Module: LocoMotion::Lint::ComponentMap

Defined in:
lib/loco_motion/lint/component_map.rb

Overview

Derives the "DaisyUI class -> LocoMotion helper" map that the component usage linter enforces.

The map is DERIVED, never hand-maintained. Every entry comes from COMPONENTS paired with the component's own add_css(:component, "...") declaration, so a component and the rule covering it cannot drift apart: adding a component adds its coverage in the same commit, and ComponentMap.unmapped makes a component that declares no class a visible, testable fact rather than a silent gap.

This replaces a hand-written list that had reached 25 of 70 components.

Constant Summary collapse

LAYOUT_UTILITIES =

A component root is sometimes a plain Tailwind utility — Countdown declares only "flex", putting its DaisyUI class on child spans. Claiming those would flag every .flex in every view, so they are refused and the component is reported as unmapped instead. A gap is recoverable; a false positive teaches people to ignore the linter.

%w[
  flex grid block inline inline-block hidden contents flow-root
  relative absolute fixed sticky static isolate table
].freeze

Class Method Summary collapse

Class Method Details

.add_component(map, class_name, meta, names_only:) ⇒ Object



95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
# File 'lib/loco_motion/lint/component_map.rb', line 95

def add_component(map, class_name, meta, names_only:)
  path = source_path(class_name)
  return unless File.exist?(path)

  names = Array(meta[:names]).map(&:to_s)
  decls = declarations(File.read(path))
  return if names_only && (names & component_tokens(decls)).empty?

  base = base_for(decls, names)
  return unless base

  helper = helper_name(class_name, meta)
  map[base] ||= helper
  add_parts(map, decls, base, helper)
end

.add_parts(map, decls, base, helper) ⇒ Object

Anything extending the base ("card-body", "drawer-side") belongs to this component. A part reusing a shared class like "label" belongs to no single component and is left alone.



114
115
116
117
118
119
120
121
122
123
# File 'lib/loco_motion/lint/component_map.rb', line 114

def add_parts(map, decls, base, helper)
  decls.each do |part, tokens|
    tokens.each do |token|
      next if token == base || !token.start_with?("#{base}-")

      label = part == "component" ? "nested" : part
      map[token] ||= "#{helper} (#{label} part)"
    end
  end
end

.base_for(decls, names) ⇒ Object



73
74
75
76
77
78
79
80
# File 'lib/loco_motion/lint/component_map.rb', line 73

def base_for(decls, names)
  tokens = component_tokens(decls)
  # A file may declare several component roots — a nested sub-component
  # (drawer-side, carousel-item) often appears BEFORE the real root — so
  # prefer the token that matches a registered helper name.
  tokens.find { |token| names.include?(token) } ||
    tokens.reject { |token| LAYOUT_UTILITIES.include?(token) }.first
end

.build ⇒ Object

{ "card" => "daisy_card", "card-body" => "daisy_card (body part)" }



83
84
85
86
87
88
89
90
91
92
93
# File 'lib/loco_motion/lint/component_map.rb', line 83

def build
  map = {}
  # Two passes so an exact name match always wins the class: Accordion and
  # Collapse both style "collapse", and it should read as daisy_collapse.
  [true, false].each do |names_only|
    LocoMotion::COMPONENTS.each do |class_name, meta|
      add_component(map, class_name, meta, names_only: names_only)
    end
  end
  map
end

.component_class?(token) ⇒ Boolean

Tailwind variants (sm:, where:) and utilities are not component classes — a view is free to use them, so they must never be flagged.

Returns:

  • (Boolean)


29
30
31
# File 'lib/loco_motion/lint/component_map.rb', line 29

def component_class?(token)
  !token.include?(":") && token.match?(/\A[a-z][a-z0-9-]*\z/)
end

.component_tokens(decls) ⇒ Object



69
70
71
# File 'lib/loco_motion/lint/component_map.rb', line 69

def component_tokens(decls)
  decls.select { |part, _| part == "component" }.flat_map(&:last)
end

.declarations(source) ⇒ Object



54
55
56
57
# File 'lib/loco_motion/lint/component_map.rb', line 54

def declarations(source)
  source.scan(/add_css\(\s*:([a-z_]+)\s*,\s*"([^"]+)"/)
        .map { |part, css| [part, css.split(/\s+/).select { |t| component_class?(t) }] }
end

.gem_root ⇒ Object



37
38
39
# File 'lib/loco_motion/lint/component_map.rb', line 37

def gem_root
  File.expand_path("../../..", __dir__)
end

.helper_name(class_name, meta) ⇒ Object

Helper names follow "#framework_#name" (see LocoMotion.define_helpers), and a component may register several — the first is the canonical suggestion.



49
50
51
52
# File 'lib/loco_motion/lint/component_map.rb', line 49

def helper_name(class_name, meta)
  framework = underscore(class_name.split("::").first)
  "#{framework}_#{Array(meta[:names]).first}"
end

.source_path(class_name) ⇒ Object

"Daisy::DataDisplay::CardComponent" -> app/components/daisy/data_display/card_component.rb



42
43
44
45
# File 'lib/loco_motion/lint/component_map.rb', line 42

def source_path(class_name)
  segments = class_name.split("::").map { |segment| underscore(segment) }
  "#{File.join(gem_root, 'app', 'components', *segments)}.rb"
end

.underscore(string) ⇒ Object



33
34
35
# File 'lib/loco_motion/lint/component_map.rb', line 33

def underscore(string)
  string.gsub(/([a-z\d])([A-Z])/, '\1_\2').downcase
end

.unmapped ⇒ Object

Components that declare no usable base class — they build it dynamically, or style the root with utilities. Surfaced so the spec can pin the list: a NEW component landing here fails the suite rather than quietly going unenforced.



129
130
131
132
133
134
135
136
# File 'lib/loco_motion/lint/component_map.rb', line 129

def unmapped
  LocoMotion::COMPONENTS.reject do |class_name, meta|
    path = source_path(class_name)
    next false unless File.exist?(path)

    !base_for(declarations(File.read(path)), Array(meta[:names]).map(&:to_s)).nil?
  end.keys
end