lilac-cli 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (65) hide show
  1. checksums.yaml +7 -0
  2. data/LICENSE.txt +21 -0
  3. data/README.md +144 -0
  4. data/exe/lilac +6 -0
  5. data/lib/lilac/cli/build/build_context.rb +31 -0
  6. data/lib/lilac/cli/build/build_error.rb +50 -0
  7. data/lib/lilac/cli/build/builder.rb +326 -0
  8. data/lib/lilac/cli/build/bundle_asset_writer.rb +129 -0
  9. data/lib/lilac/cli/build/bytecode_builder.rb +212 -0
  10. data/lib/lilac/cli/build/compiled_boot_module.rb +103 -0
  11. data/lib/lilac/cli/build/compiled_runtime_resolver.rb +64 -0
  12. data/lib/lilac/cli/build/component_name.rb +57 -0
  13. data/lib/lilac/cli/build/component_scripts_assembler.rb +76 -0
  14. data/lib/lilac/cli/build/directive.rb +51 -0
  15. data/lib/lilac/cli/build/full_runtime_resolver.rb +61 -0
  16. data/lib/lilac/cli/build/html_emitter.rb +58 -0
  17. data/lib/lilac/cli/build/package_stager.rb +111 -0
  18. data/lib/lilac/cli/build/page_compiler.rb +405 -0
  19. data/lib/lilac/cli/build/runtime_resolver.rb +150 -0
  20. data/lib/lilac/cli/build/sfc.rb +150 -0
  21. data/lib/lilac/cli/build/template_ast.rb +304 -0
  22. data/lib/lilac/cli/build/template_ast_cache.rb +58 -0
  23. data/lib/lilac/cli/build/vendor_writer.rb +58 -0
  24. data/lib/lilac/cli/build/wasm_mrbc_driver.rb +183 -0
  25. data/lib/lilac/cli/command.rb +110 -0
  26. data/lib/lilac/cli/config.rb +150 -0
  27. data/lib/lilac/cli/config_loader.rb +97 -0
  28. data/lib/lilac/cli/dev_server.rb +156 -0
  29. data/lib/lilac/cli/doctor.rb +310 -0
  30. data/lib/lilac/cli/lint/build_linter.rb +95 -0
  31. data/lib/lilac/cli/lint/cross_ref_linter.rb +336 -0
  32. data/lib/lilac/cli/lint/lint_warning.rb +53 -0
  33. data/lib/lilac/cli/lint/script_analyzer.rb +255 -0
  34. data/lib/lilac/cli/live_reload.rb +194 -0
  35. data/lib/lilac/cli/offline_verifier.rb +117 -0
  36. data/lib/lilac/cli/package_build.rb +68 -0
  37. data/lib/lilac/cli/package_discovery.rb +77 -0
  38. data/lib/lilac/cli/preview_server.rb +70 -0
  39. data/lib/lilac/cli/scaffold.rb +85 -0
  40. data/lib/lilac/cli/source_location.rb +16 -0
  41. data/lib/lilac/cli/subcommand/base.rb +65 -0
  42. data/lib/lilac/cli/subcommand/build.rb +82 -0
  43. data/lib/lilac/cli/subcommand/dev.rb +58 -0
  44. data/lib/lilac/cli/subcommand/doctor.rb +39 -0
  45. data/lib/lilac/cli/subcommand/new.rb +81 -0
  46. data/lib/lilac/cli/subcommand/option_helpers.rb +58 -0
  47. data/lib/lilac/cli/subcommand/package_build.rb +51 -0
  48. data/lib/lilac/cli/subcommand/preview.rb +50 -0
  49. data/lib/lilac/cli/templates/Gemfile +13 -0
  50. data/lib/lilac/cli/templates/README.md +125 -0
  51. data/lib/lilac/cli/templates/components/counter.lil +18 -0
  52. data/lib/lilac/cli/templates/gitignore +5 -0
  53. data/lib/lilac/cli/templates/lilac.config.rb +24 -0
  54. data/lib/lilac/cli/templates/pages/index.html +46 -0
  55. data/lib/lilac/cli/templates/public/.gitkeep +0 -0
  56. data/lib/lilac/cli/version.rb +7 -0
  57. data/lib/lilac/cli/watcher.rb +62 -0
  58. data/lib/lilac/cli.rb +20 -0
  59. data/lib/lilac/directives/class_parser.rb +179 -0
  60. data/lib/lilac/directives/collision_rules.rb +49 -0
  61. data/lib/lilac/directives/grammar.rb +76 -0
  62. data/lib/lilac/directives/lints.rb +128 -0
  63. data/lib/lilac/directives/value.rb +85 -0
  64. data/lib/lilac/directives.rb +16 -0
  65. metadata +159 -0
@@ -0,0 +1,62 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "listen"
4
+
5
+ module Lilac
6
+ module CLI
7
+ # Thin wrapper around `listen` that coalesces bursts of file events
8
+ # into a single `on_change` callback. Editors that "save" via
9
+ # write-then-rename or "mtime touch" frequently emit 2–5 events in
10
+ # rapid succession; without debouncing we would rebuild that many
11
+ # times.
12
+ #
13
+ # The implementation timer-resets on each event: after the most
14
+ # recent event, we wait `debounce` seconds of quiet, then fire.
15
+ class Watcher
16
+ DEFAULT_DEBOUNCE = 0.15
17
+
18
+ def initialize(paths, debounce: DEFAULT_DEBOUNCE, &on_change)
19
+ raise ArgumentError, "block required" unless on_change
20
+
21
+ @paths = paths
22
+ @debounce = debounce
23
+ @on_change = on_change
24
+ @listener = nil
25
+ @pending = nil
26
+ @mutex = Mutex.new
27
+ end
28
+
29
+ def start
30
+ # No `only:` filter: callers pass `components/`, `pages/`, and
31
+ # `public/`, and the public mirror needs to react to arbitrary
32
+ # static assets (.js, .css, .wasm, images). Listen's default
33
+ # ignore list already filters .git, swap files, OS metadata.
34
+ @listener = Listen.to(*@paths) do |_modified, _added, _removed|
35
+ schedule_change
36
+ end
37
+ @listener.start
38
+ end
39
+
40
+ def stop
41
+ @listener&.stop
42
+ @mutex.synchronize { @pending&.kill }
43
+ end
44
+
45
+ private
46
+
47
+ def schedule_change
48
+ @mutex.synchronize do
49
+ @pending&.kill
50
+ @pending = Thread.new do
51
+ sleep @debounce
52
+ begin
53
+ @on_change.call
54
+ rescue StandardError => e
55
+ warn "lilac watcher: callback raised #{e.class}: #{e.message}"
56
+ end
57
+ end
58
+ end
59
+ end
60
+ end
61
+ end
62
+ end
data/lib/lilac/cli.rb ADDED
@@ -0,0 +1,20 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "cli/version"
4
+ require_relative "cli/build/sfc"
5
+ require_relative "cli/build/compiled_runtime_resolver"
6
+ require_relative "cli/package_discovery"
7
+ require_relative "cli/build/builder"
8
+ require_relative "cli/config_loader"
9
+ require_relative "cli/config"
10
+ require_relative "cli/live_reload"
11
+ require_relative "cli/watcher"
12
+ require_relative "cli/dev_server"
13
+ require_relative "cli/scaffold"
14
+ require_relative "cli/doctor"
15
+ require_relative "cli/command"
16
+
17
+ module Lilac
18
+ module CLI
19
+ end
20
+ end
@@ -0,0 +1,179 @@
1
+ module Lilac
2
+ module Directives
3
+ # Parser for the `data-class` hash literal grammar.
4
+ #
5
+ # Duplicate pair (build-time / runtime). See decisions §17.
6
+ #
7
+ # { active: @is_active, 'btn-primary': @primary, "hover:bg-blue": @h }
8
+ #
9
+ # Returns Array<[key_string, value_string]> in source order. Keys
10
+ # are returned as plain strings (quotes stripped); values are the
11
+ # raw text between `:` and the next `,` / `}`, leaving value-
12
+ # grammar validation (ivar / bare ident) to the caller (Scanner).
13
+ #
14
+ # Quoted keys may contain `:` (Tailwind variants like
15
+ # `'hover:bg-blue-500'`), so a naive split-on-colon would break.
16
+ # The grammar is stricter than JSON (forbids `;`, control chars,
17
+ # whitespace inside keys) and looser elsewhere (bare Ruby idents
18
+ # as keys, no string quoting on values).
19
+ #
20
+ # Per-character checks use char comparisons rather than JS-bridged
21
+ # Regexp.test calls because they run in inner loops; whole-string
22
+ # validates use Regexp.
23
+ class ClassParser
24
+ class Error < StandardError; end
25
+
26
+ BARE_KEY = /^[a-zA-Z_][a-zA-Z0-9_]*$/
27
+
28
+ # Spec 6.4: quoted-key body forbids whitespace, control chars
29
+ # (\x00-\x1F + \x7F), `;`, and any quote character. Checked via
30
+ # `valid_quoted_body?` (char-walk) instead of a Regexp because
31
+ # mruby-regexp-compat doesn't support `\xHH` hex escapes inside
32
+ # character classes — `[^\s\\'";\x00-\x1F\x7F]` mis-parses as a
33
+ # class containing literal `x`/`0`/etc.
34
+
35
+ def self.parse(source)
36
+ new(source).parse
37
+ end
38
+
39
+ def initialize(source)
40
+ @src = source.to_s
41
+ @pos = 0
42
+ end
43
+
44
+ def parse
45
+ skip_ws
46
+ expect("{")
47
+ skip_ws
48
+ pairs = []
49
+ unless peek == "}"
50
+ pairs << parse_pair
51
+ loop do
52
+ skip_ws
53
+ break unless peek == ","
54
+ advance
55
+ skip_ws
56
+ pairs << parse_pair
57
+ end
58
+ end
59
+ skip_ws
60
+ expect("}")
61
+ skip_ws
62
+ if @pos < @src.length
63
+ raise Error, "unexpected trailing content: #{@src[@pos..].inspect}"
64
+ end
65
+ pairs
66
+ end
67
+
68
+ private
69
+
70
+ def parse_pair
71
+ skip_ws
72
+ key = parse_key
73
+ skip_ws
74
+ expect(":")
75
+ skip_ws
76
+ value = parse_value
77
+ [key, value]
78
+ end
79
+
80
+ def parse_key
81
+ case peek
82
+ when "'" then parse_quoted("'")
83
+ when '"' then parse_quoted('"')
84
+ else parse_bare_ident
85
+ end
86
+ end
87
+
88
+ def parse_quoted(quote)
89
+ advance
90
+ start = @pos
91
+ @pos += 1 while @pos < @src.length && @src[@pos] != quote
92
+ raise Error, "unterminated #{quote} string" if @pos >= @src.length
93
+ body = @src[start...@pos]
94
+ advance
95
+ unless valid_quoted_body?(body)
96
+ raise Error,
97
+ "invalid character(s) in quoted key #{body.inspect} " \
98
+ "(whitespace, control chars, `;`, and quotes are forbidden)"
99
+ end
100
+ body
101
+ end
102
+
103
+ def parse_bare_ident
104
+ start = @pos
105
+ @pos += 1 while @pos < @src.length && bare_key_char?(@src[@pos])
106
+ body = @src[start...@pos]
107
+ raise Error, "expected hash key at position #{start}, got #{peek.inspect}" if body.empty?
108
+ unless BARE_KEY.match?(body)
109
+ raise Error,
110
+ "bare hash key #{body.inspect} is not a Ruby identifier " \
111
+ "(use quoted form for kebab / special chars)"
112
+ end
113
+ body
114
+ end
115
+
116
+ # Values are ivar (`@x`) / bare ident (`x`) — neither contains
117
+ # the hash delimiters `,` / `}` nor whitespace, so stop at the
118
+ # first whitespace or delimiter. Grammar validation happens in
119
+ # the caller (Scanner.dispatch_class via Value.parse).
120
+ def parse_value
121
+ start = @pos
122
+ until @pos >= @src.length
123
+ ch = @src[@pos]
124
+ break if ch == "," || ch == "}" || ws_char?(ch)
125
+ @pos += 1
126
+ end
127
+ raise Error, "expected value, got #{peek.inspect} at #{start}" if @pos == start
128
+ @src[start...@pos]
129
+ end
130
+
131
+ def skip_ws
132
+ @pos += 1 while @pos < @src.length && ws_char?(@src[@pos])
133
+ end
134
+
135
+ def peek
136
+ @src[@pos]
137
+ end
138
+
139
+ def advance
140
+ c = @src[@pos]
141
+ @pos += 1
142
+ c
143
+ end
144
+
145
+ def expect(ch)
146
+ actual = @src[@pos]
147
+ if actual != ch
148
+ raise Error, "expected #{ch.inspect}, got #{actual.inspect} at position #{@pos}"
149
+ end
150
+ @pos += 1
151
+ end
152
+
153
+ # Per-char predicates inlined to avoid a JS-bridged Regexp.test
154
+ # call per character (which would dominate cost in tight loops).
155
+
156
+ def ws_char?(c)
157
+ c == " " || c == "\t" || c == "\n" || c == "\r" || c == "\f" || c == "\v"
158
+ end
159
+
160
+ def bare_key_char?(c)
161
+ (c >= "a" && c <= "z") || (c >= "A" && c <= "Z") ||
162
+ (c >= "0" && c <= "9") || c == "_"
163
+ end
164
+
165
+ # Whole-body validator for quoted keys. Rejects empty, any of
166
+ # `\\`, `'`, `"`, `;`, whitespace, control chars (< 0x20 or == 0x7F).
167
+ def valid_quoted_body?(body)
168
+ return false if body.empty?
169
+ body.each_char do |c|
170
+ return false if c == "\\" || c == "'" || c == "\"" || c == ";"
171
+ return false if ws_char?(c)
172
+ code = c.ord
173
+ return false if code < 0x20 || code == 0x7F
174
+ end
175
+ true
176
+ end
177
+ end
178
+ end
179
+ end
@@ -0,0 +1,49 @@
1
+ module Lilac
2
+ module Directives
3
+ # Collision rules SSOT — duplicate pair (build-time / runtime).
4
+ # See decisions §17.
5
+ #
6
+ # The data here is consumed by both halves of `Lints`:
7
+ # - build-time: `Lilac::Directives::Lints.check!` (raises)
8
+ # - runtime: `Lilac::Directives::Lints.check!` (warn+skip /
9
+ # raise depending on severity)
10
+ #
11
+ # Each row in COLLISION_PAIRS is `[attrs, message]` where `attrs`
12
+ # is the unordered pair of `data-*` attribute names that may not
13
+ # coexist on the same element, and `message` is the human-readable
14
+ # rationale used in both build-error and runtime warning text.
15
+ # Attribute-name form (not Symbol kinds) lets the same rules apply
16
+ # uniformly to built-in directives and class-based Handler packages
17
+ # (ADR-0027) — the public surface a user actually writes is the
18
+ # attribute name, so matching against it removes the Symbol-pivot
19
+ # indirection on both sides.
20
+ module Lints
21
+ COLLISION_PAIRS = [
22
+ [
23
+ %w[data-text data-unsafe-html],
24
+ "data-text and data-unsafe-html cannot coexist (both write the element body)",
25
+ ],
26
+ [
27
+ %w[data-text data-each],
28
+ "data-text and data-each cannot coexist (data-each generates children; data-text would overwrite them)",
29
+ ],
30
+ [
31
+ %w[data-unsafe-html data-each],
32
+ "data-unsafe-html and data-each cannot coexist (data-each generates children; data-unsafe-html would overwrite them)",
33
+ ],
34
+ [
35
+ %w[data-show data-hide],
36
+ "data-show and data-hide cannot coexist (use one; the inverse is implicit)",
37
+ ],
38
+ [
39
+ %w[data-component data-each],
40
+ "data-component and data-each cannot coexist (wrap with another element — put the child component inside the iteration body)",
41
+ ],
42
+ [
43
+ %w[data-bind data-field],
44
+ "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)",
45
+ ],
46
+ ].freeze
47
+ end
48
+ end
49
+ end
@@ -0,0 +1,76 @@
1
+ module Lilac
2
+ module Directives
3
+ # Predicates for the *name-shaped* tokens that appear in directives —
4
+ # event-handler method names, the X parts of `data-attr-X` /
5
+ # `data-css-X`, and the banned-attribute deny-list.
6
+ #
7
+ # Duplicate pair (build-time / runtime). See decisions §17.
8
+ #
9
+ # Reactive *values* (`@ivar` / bare ident) live on `Value`
10
+ # so they can carry their kind polymorphically rather than as a
11
+ # string-prefix check.
12
+ #
13
+ # Anchors: mruby-regexp-compat supports `^`/`$` but not `\A`/`\z`.
14
+ # Since directive values are always single-line strings, `^/$` are
15
+ # equivalent here.
16
+ #
17
+ # Module methods are defined via `class << self` instead of
18
+ # `module_function` because mruby's `module_function` doesn't
19
+ # behave like MRI's (see runtime/mruby-lilac/mrblib/html.rb:60).
20
+ module Grammar
21
+ # `?` predicate suffix allowed (used by `@active?` ivars and
22
+ # bare-ident predicate field reads). Bang `!` is rejected — the
23
+ # regex stops before any trailing `!`.
24
+ METHOD_IDENT = /^[a-zA-Z_][a-zA-Z0-9_]*$/
25
+
26
+ # X part of `data-attr-X` / `data-css-X`: kebab-lowercase,
27
+ # letter-first, no `--` prefix (clashes with CSS variable prefix
28
+ # the framework auto-prepends).
29
+ KEBAB_NAME = /^[a-z][a-z0-9-]*$/
30
+
31
+ # Inline event handlers (`on*`), `srcdoc` (iframe HTML injection
32
+ # vector), and `style` (use `data-css-X` or `RefElement#set_style`
33
+ # instead) are banned from `data-attr-X`.
34
+ BANNED_ATTR = /^on[a-z]+$|^srcdoc$|^style$/
35
+
36
+ # `data-*` attribute names that map to a directive (one of the
37
+ # `Directive::Kind` values aside from `:component`). This is the
38
+ # SSOT for both:
39
+ # * build-time: TemplateAST uses it to identify directive-bearing
40
+ # elements that need a synthetic `lilN` slot.
41
+ # * runtime: `Refs` / `TemplateRefs` use it to walk the mounted
42
+ # subtree in DFS and resolve `refs.lilN` positionally.
43
+ # Anything matching here counts toward a scope's directive-bearing
44
+ # index; markers like `data-ref` / `data-component` / `data-template`
45
+ # / `data-arg-*` are excluded.
46
+ DIRECTIVE_ATTR = /^data-(?:text|unsafe-html|bind|show|hide|each|key|class|form|field|button|on-.+|attr-.+|css-.+)$/
47
+
48
+ class << self
49
+ # `increment` / `add_todo`. Event-handler method names — no `?`
50
+ # suffix so a typoed `save?` is caught at mount time.
51
+ def method_ident?(s)
52
+ !!METHOD_IDENT.match?(s.to_s)
53
+ end
54
+
55
+ # `progress` / `theme-color`. Rejects uppercase, digit-start,
56
+ # leading `-` (which would collide with the auto-prepended `--`).
57
+ def kebab_name?(s)
58
+ str = s.to_s
59
+ !!KEBAB_NAME.match?(str) && !str.start_with?("-")
60
+ end
61
+
62
+ def banned_attr?(s)
63
+ !!BANNED_ATTR.match?(s.to_s)
64
+ end
65
+
66
+ # True when `s` is a `data-*` attribute name that triggers
67
+ # codegen (i.e. occupies a `lilN` slot in its scope). Used by
68
+ # both build-time TemplateAST and runtime Refs to keep their
69
+ # DFS counters in lockstep.
70
+ def directive_attribute?(s)
71
+ !!DIRECTIVE_ATTR.match?(s.to_s)
72
+ end
73
+ end
74
+ end
75
+ end
76
+ end
@@ -0,0 +1,128 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Lilac
4
+ module Directives
5
+ # Build-time validation of directive composition rules. Called after
6
+ # directive collection so violations surface as a build error rather
7
+ # than a runtime failure on the user's page.
8
+ #
9
+ # Duplicate pair (build-time / runtime). See decisions §17. The
10
+ # build-time half raises on every violation; the runtime half
11
+ # (runtime/mruby-lilac-directives/mrblib/lilac_directives_lints.rb)
12
+ # applies warn+skip for ergonomics violations.
13
+ #
14
+ # Currently checks pair collisions, tag-level applicability, and
15
+ # `<input>` type-attribute constraints. Not yet enforced:
16
+ # - data-arg-X validations — data-arg has no emitter yet
17
+ module Lints
18
+ # `COLLISION_PAIRS` lives in `collision_rules.rb` (the duplicate-
19
+ # pair SSOT). This file consumes it via the constant lookup below.
20
+ class Error < Lilac::CLI::BuildError; end
21
+
22
+ def self.check!(directives, file:)
23
+ # Group by (scope_id, ref_id) so directives that share a `lilN`
24
+ # NAME but live in different ref scopes (top-level vs each
25
+ # iteration body) are not falsely flagged as colliding. With
26
+ # the per-scope positional counter (decisions §19), ref_ids are
27
+ # only unique WITHIN a scope.
28
+ directives.group_by { |d| [d.scope_id, d.ref_id] }.each_value do |dirs_on_element|
29
+ check_collisions(dirs_on_element, file)
30
+ check_gn_hidden_conflict(dirs_on_element, file)
31
+ end
32
+ check_form_scope!(directives, file)
33
+ end
34
+
35
+ # Scope rules for the form gem (form-spec §8 / §10.2):
36
+ # - `data-form` is only allowed on `<form>` elements
37
+ # - multiple bare `<form>` (no data-form attr) within the same
38
+ # component would collide on the `:default` scope, so flag the
39
+ # second occurrence as an error
40
+ def self.check_form_scope!(directives, file)
41
+ seen_default_form = false
42
+ directives.each do |d|
43
+ next unless d.kind == :form
44
+
45
+ if d.element_tag != "form"
46
+ raise Error.new(
47
+ "data-form is only allowed on <form> elements " \
48
+ "(found on <#{d.element_tag}>).",
49
+ at: d.source_location(file),
50
+ suggestion: "Move data-form to a <form> element, or drop it if scope isn't needed.",
51
+ )
52
+ end
53
+
54
+ # `value == ""` is the synthetic marker injected by TemplateAST
55
+ # for bare <form>. Track the first; flag any second occurrence.
56
+ if d.value.to_s.empty?
57
+ if seen_default_form
58
+ raise Error.new(
59
+ "second bare <form> in the same component would collide on the " \
60
+ ":default scope.",
61
+ at: d.source_location(file),
62
+ suggestion: %(Add `data-form="..."` to one of them to distinguish.),
63
+ )
64
+ end
65
+ seen_default_form = true
66
+ end
67
+ end
68
+ end
69
+
70
+ def self.check_collisions(dirs, file)
71
+ attr_names = dirs.map { |d| attribute_for(d) }
72
+ COLLISION_PAIRS.each do |pair, message|
73
+ next unless pair.all? { |a| attr_names.include?(a) }
74
+
75
+ # Report at the line of the second (later) directive in the
76
+ # pair so users see where the conflict was introduced.
77
+ offenders = dirs.select { |d| pair.include?(attribute_for(d)) }
78
+ raise Error.new(
79
+ "Directive collision: #{message}",
80
+ at: offenders.last.source_location(file),
81
+ )
82
+ end
83
+ end
84
+
85
+ # Derive the `data-*` attribute name from a TemplateAST directive.
86
+ # Mirrors the runtime helper of the same name: both built-in
87
+ # directive kinds and CLI-registered emitter kinds (form's
88
+ # `:form` / `:field` / `:button`) follow the `data-<kind>` rule
89
+ # with `_` → `-` and a trailing `_` stripped.
90
+ def self.attribute_for(directive)
91
+ "data-#{directive.kind.to_s.tr('_', '-').chomp('-')}"
92
+ end
93
+
94
+ # `lil-hidden` is reserved by data-show / data-hide. If the user
95
+ # puts `'lil-hidden': @x` in data-class on the same element, three
96
+ # signals can race over the class — fail at build time and tell
97
+ # the user to drop the data-class entry.
98
+ #
99
+ # Note: parses the data-class hash literal purely for this check.
100
+ # The substring guard above keeps the cost zero for the common
101
+ # case (no `lil-hidden` anywhere in the value).
102
+ def self.check_gn_hidden_conflict(dirs, file)
103
+ return unless dirs.any? { |d| %i[show hide].include?(d.kind) }
104
+
105
+ class_dir = dirs.find { |d| d.kind == :class_ }
106
+ return unless class_dir
107
+ return unless class_dir.value.to_s.include?("lil-hidden")
108
+
109
+ pairs =
110
+ begin
111
+ ClassParser.parse(class_dir.value)
112
+ rescue ClassParser::Error
113
+ # Malformed data-class — the runtime scanner surfaces the
114
+ # parse error at mount; skip the conflict check here.
115
+ return
116
+ end
117
+ return unless pairs.any? { |key, _| key == "lil-hidden" }
118
+
119
+ raise Error.new(
120
+ "data-class uses the reserved class `lil-hidden` on an element " \
121
+ "that also has data-show / data-hide.",
122
+ at: class_dir.source_location(file),
123
+ suggestion: "Drop the `lil-hidden` key from data-class — data-show/data-hide manage it.",
124
+ )
125
+ end
126
+ end
127
+ end
128
+ end
@@ -0,0 +1,85 @@
1
+ module Lilac
2
+ module Directives
3
+ # The right-hand side of a directive that takes a reactive value.
4
+ # Two kinds exist, both single-identifier (no dot / no path / no
5
+ # expression):
6
+ #
7
+ # - `@ivar` — a host Signal/Computed. Resolves via
8
+ # `Evaluator#lookup_ivar` and feeds into
9
+ # `bind ref, prop: signal` directly.
10
+ # - bare ident — a field of the current iteration item.
11
+ # `Evaluator#read` reads `item[name]` (Hash key
12
+ # first, then String fallback, then public_send).
13
+ # Only meaningful inside a `data-each` body;
14
+ # value-binding dispatch silent-skips when
15
+ # `item.nil?` (= scanning the host root).
16
+ #
17
+ # Duplicate pair (build-time / runtime). See decisions §17.
18
+ #
19
+ # `Value.parse` returns `Ivar`, `BareIdent`, or `nil` for invalid
20
+ # input — callers raise their own error with directive context.
21
+ # Match precedence: `@ivar` first (unambiguous prefix), then bare
22
+ # ident (everything else that looks like a Ruby identifier).
23
+ class Value
24
+ # Anchors use `^`/`$` not `\A`/`\z` — see Grammar for the
25
+ # mruby-regexp-compat compatibility note.
26
+ IVAR = /^@[a-zA-Z_]\w*\??$/
27
+ BARE_IDENT = /^[a-zA-Z_]\w*\??$/
28
+
29
+ def self.parse(raw)
30
+ s = raw.to_s.strip
31
+ if IVAR.match?(s)
32
+ Ivar.new(s)
33
+ elsif BARE_IDENT.match?(s)
34
+ BareIdent.new(s)
35
+ end
36
+ end
37
+
38
+ attr_reader :raw
39
+
40
+ def initialize(raw)
41
+ @raw = raw
42
+ end
43
+
44
+ def to_s
45
+ @raw
46
+ end
47
+
48
+ def inspect
49
+ @raw.inspect
50
+ end
51
+
52
+ def ivar?
53
+ false
54
+ end
55
+
56
+ def bare_ident?
57
+ false
58
+ end
59
+ end
60
+
61
+ class Value::Ivar < Value
62
+ # `@count` -> :@count
63
+ def ivar_sym
64
+ @raw.to_sym
65
+ end
66
+
67
+ def ivar?
68
+ true
69
+ end
70
+ end
71
+
72
+ # Bare identifier referring to a field of the current iteration item.
73
+ # Outside `data-each` scope (item.nil?), value-binding dispatch
74
+ # silent-skips.
75
+ class Value::BareIdent < Value
76
+ def field
77
+ @raw
78
+ end
79
+
80
+ def bare_ident?
81
+ true
82
+ end
83
+ end
84
+ end
85
+ end
@@ -0,0 +1,16 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Build-time loader for `Lilac::Directives::*` files. The files under
4
+ # `directives/` are intentional duplicates of the runtime mrbgem at
5
+ # `runtime/mruby-lilac-directives/mrblib/` — they share class names so
6
+ # `diff(1)` between the pair surfaces only semantic differences. Per
7
+ # decisions §17, this means the individual files must NOT carry
8
+ # `require_relative` statements (the runtime mrbgem also has none —
9
+ # mruby-config drives load order there). MRI load order is owned here.
10
+
11
+ require_relative "cli/build/build_error" # lints.rb references Lilac::CLI::BuildError
12
+ require_relative "directives/value"
13
+ require_relative "directives/grammar"
14
+ require_relative "directives/class_parser"
15
+ require_relative "directives/collision_rules" # COLLISION_PAIRS SSOT, consumed by lints.rb
16
+ require_relative "directives/lints"