Module: Lilac::Directives::Lints

Defined in:
lib/lilac/directives/lints.rb,
lib/lilac/directives/collision_rules.rb

Overview

Collision rules SSOT — duplicate pair (build-time / runtime). See decisions §17.

The data here is consumed by both halves of Lints:

- build-time: `Lilac::Directives::Lints.check!` (raises)
- runtime:    `Lilac::Directives::Lints.check!` (warn+skip /
            raise depending on severity)

Each row in COLLISION_PAIRS is [attrs, message] where attrs is the unordered pair of data-* attribute names that may not coexist on the same element, and message is the human-readable rationale used in both build-error and runtime warning text. Attribute-name form (not Symbol kinds) lets the same rules apply uniformly to built-in directives and class-based Handler packages (ADR-0027) — the public surface a user actually writes is the attribute name, so matching against it removes the Symbol-pivot indirection on both sides.

Defined Under Namespace

Classes: Error

Constant Summary collapse

COLLISION_PAIRS =
[
  [
    %w[data-text data-unsafe-html],
    "data-text and data-unsafe-html cannot coexist (both write the element body)",
  ],
  [
    %w[data-text data-each],
    "data-text and data-each cannot coexist (data-each generates children; data-text would overwrite them)",
  ],
  [
    %w[data-unsafe-html data-each],
    "data-unsafe-html and data-each cannot coexist (data-each generates children; data-unsafe-html would overwrite them)",
  ],
  [
    %w[data-show data-hide],
    "data-show and data-hide cannot coexist (use one; the inverse is implicit)",
  ],
  [
    %w[data-component data-each],
    "data-component and data-each cannot coexist (wrap with another element — put the child component inside the iteration body)",
  ],
  [
    %w[data-bind data-field],
    "data-bind and data-field cannot coexist (both wire the input value — pick one: data-bind for form-independent binding, data-field for form-scope binding)",
  ],
].freeze

Class Method Summary collapse

Class Method Details

.attribute_for(directive) ⇒ Object

Derive the data-* attribute name from a TemplateAST directive. Mirrors the runtime helper of the same name: both built-in directive kinds and CLI-registered emitter kinds (form's :form / :field / :button) follow the data-<kind> rule with _- and a trailing _ stripped.



90
91
92
# File 'lib/lilac/directives/lints.rb', line 90

def self.attribute_for(directive)
  "data-#{directive.kind.to_s.tr('_', '-').chomp('-')}"
end

.check!(directives, file:) ⇒ Object



22
23
24
25
26
27
28
29
30
31
32
33
# File 'lib/lilac/directives/lints.rb', line 22

def self.check!(directives, file:)
  # Group by (scope_id, ref_id) so directives that share a `lilN`
  # NAME but live in different ref scopes (top-level vs each
  # iteration body) are not falsely flagged as colliding. With
  # the per-scope positional counter (decisions §19), ref_ids are
  # only unique WITHIN a scope.
  directives.group_by { |d| [d.scope_id, d.ref_id] }.each_value do |dirs_on_element|
    check_collisions(dirs_on_element, file)
    check_gn_hidden_conflict(dirs_on_element, file)
  end
  check_form_scope!(directives, file)
end

.check_collisions(dirs, file) ⇒ Object



70
71
72
73
74
75
76
77
78
79
80
81
82
83
# File 'lib/lilac/directives/lints.rb', line 70

def self.check_collisions(dirs, file)
  attr_names = dirs.map { |d| attribute_for(d) }
  COLLISION_PAIRS.each do |pair, message|
    next unless pair.all? { |a| attr_names.include?(a) }

    # Report at the line of the second (later) directive in the
    # pair so users see where the conflict was introduced.
    offenders = dirs.select { |d| pair.include?(attribute_for(d)) }
    raise Error.new(
      "Directive collision: #{message}",
      at: offenders.last.source_location(file),
    )
  end
end

.check_form_scope!(directives, file) ⇒ Object

Scope rules for the form gem (form-spec §8 / §10.2):

  • data-form is only allowed on <form> elements
  • multiple bare <form> (no data-form attr) within the same component would collide on the :default scope, so flag the second occurrence as an error


40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
# File 'lib/lilac/directives/lints.rb', line 40

def self.check_form_scope!(directives, file)
  seen_default_form = false
  directives.each do |d|
    next unless d.kind == :form

    if d.element_tag != "form"
      raise Error.new(
        "data-form is only allowed on <form> elements " \
        "(found on <#{d.element_tag}>).",
        at: d.source_location(file),
        suggestion: "Move data-form to a <form> element, or drop it if scope isn't needed.",
      )
    end

    # `value == ""` is the synthetic marker injected by TemplateAST
    # for bare <form>. Track the first; flag any second occurrence.
    if d.value.to_s.empty?
      if seen_default_form
        raise Error.new(
          "second bare <form> in the same component would collide on the " \
          ":default scope.",
          at: d.source_location(file),
          suggestion: %(Add `data-form="..."` to one of them to distinguish.),
        )
      end
      seen_default_form = true
    end
  end
end

.check_gn_hidden_conflict(dirs, file) ⇒ Object

lil-hidden is reserved by data-show / data-hide. If the user puts 'lil-hidden': @x in data-class on the same element, three signals can race over the class — fail at build time and tell the user to drop the data-class entry.

Note: parses the data-class hash literal purely for this check. The substring guard above keeps the cost zero for the common case (no lil-hidden anywhere in the value).

Raises:



102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
# File 'lib/lilac/directives/lints.rb', line 102

def self.check_gn_hidden_conflict(dirs, file)
  return unless dirs.any? { |d| %i[show hide].include?(d.kind) }

  class_dir = dirs.find { |d| d.kind == :class_ }
  return unless class_dir
  return unless class_dir.value.to_s.include?("lil-hidden")

  pairs =
    begin
      ClassParser.parse(class_dir.value)
    rescue ClassParser::Error
      # Malformed data-class — the runtime scanner surfaces the
      # parse error at mount; skip the conflict check here.
      return
    end
  return unless pairs.any? { |key, _| key == "lil-hidden" }

  raise Error.new(
    "data-class uses the reserved class `lil-hidden` on an element " \
    "that also has data-show / data-hide.",
    at: class_dir.source_location(file),
    suggestion: "Drop the `lil-hidden` key from data-class — data-show/data-hide manage it.",
  )
end