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,150 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Lilac
4
+ module CLI
5
+ # Shared discovery logic for the two build-target runtimes
6
+ # (`:full` / `:compiled`). Both vendor a wasm bundle plus the SAME
7
+ # mruby-wasm-js JS bridge into `dist/vendor/lilac-<target>/`, and both
8
+ # discover sources in the same order: explicit CLI/config → env →
9
+ # `lilac-wasm-bin` gem (the canonical install path — decisions §25) →
10
+ # monorepo sibling layout for in-repo development.
11
+ #
12
+ # The bridge is identical across targets, so its resolution lives
13
+ # here. Subclasses supply only the wasm-specific bits via the
14
+ # protected hooks at the bottom (env key, gem accessor, monorepo
15
+ # candidate, not-found message).
16
+ class RuntimeResolver
17
+ # Common ancestor so a caller can `rescue RuntimeResolver::Error`
18
+ # for either target. Each subclass defines its own `Error <
19
+ # RuntimeResolver::Error` and the `resolve_*!` methods raise
20
+ # `self.class::Error`, so `FullRuntimeResolver::Error` and
21
+ # `CompiledRuntimeResolver::Error` stay distinct (a rescue of one
22
+ # doesn't swallow the other).
23
+ class Error < StandardError; end
24
+
25
+ def initialize(lilac_wasm_path: nil, mruby_wasm_js_path: nil,
26
+ monorepo_root: nil, disable_gem_discovery: false)
27
+ @configured_wasm_path = lilac_wasm_path
28
+ @configured_bridge_path = mruby_wasm_js_path
29
+ # Tests inject a dummy directory to keep the (real) monorepo wasm
30
+ # from being discovered; production callers leave it nil and the
31
+ # gem-relative ancestor is used.
32
+ @monorepo_root_override = monorepo_root
33
+ # Tests that exercise the fallback chain pass `true` so the gem's
34
+ # wasm doesn't satisfy a "nothing should resolve" expectation.
35
+ @disable_gem_discovery = disable_gem_discovery
36
+ end
37
+
38
+ # Absolute path to the wasm, or nil if none of the discovery routes
39
+ # turn up a readable file. Non-raising: use this to *report*
40
+ # discoverability (Doctor); use `resolve_wasm!` on the build path
41
+ # where absence must be a hard error.
42
+ def wasm_path
43
+ return @configured_wasm_path if File.file?(@configured_wasm_path.to_s)
44
+ if (env = ENV[wasm_env_key]) && File.file?(env)
45
+ return env
46
+ end
47
+ if (gem_wasm = gem_provided_wasm) && File.file?(gem_wasm)
48
+ return gem_wasm
49
+ end
50
+ monorepo_wasm_candidate.then { |c| return c if File.file?(c) }
51
+
52
+ nil
53
+ end
54
+
55
+ # Directory containing the bridge's `index.js`, or nil if not found.
56
+ # Shared across targets — the bridge artifact is the same for both.
57
+ def bridge_path
58
+ if @configured_bridge_path
59
+ return @configured_bridge_path if bridge_dir?(@configured_bridge_path)
60
+ end
61
+ if (env = ENV["MRUBY_WASM_JS_PATH"]) && bridge_dir?(env)
62
+ return env
63
+ end
64
+ [
65
+ gem_provided_bridge, # `lilac-wasm-bin` gem (canonical install path)
66
+ monorepo_bridge_candidate,
67
+ ].compact.find { |c| bridge_dir?(c) }
68
+ end
69
+
70
+ # Raising variants for the build path: a missing runtime must fail
71
+ # the build loudly with an actionable message.
72
+ def resolve_wasm!
73
+ wasm_path || raise(self.class::Error, wasm_not_found_message)
74
+ end
75
+
76
+ def resolve_bridge!
77
+ bridge_path || raise(self.class::Error, bridge_not_found_message)
78
+ end
79
+
80
+ private
81
+
82
+ # Soft-requires the `lilac-wasm-bin` gem for the bridge directory,
83
+ # or nil if the gem isn't on the load path.
84
+ def gem_provided_bridge
85
+ return nil if @disable_gem_discovery
86
+ require "lilac/wasm/bin"
87
+ ::Lilac::Wasm::Bin.mruby_wasm_js_dir
88
+ rescue LoadError
89
+ nil
90
+ end
91
+
92
+ def monorepo_bridge_candidate
93
+ candidate = File.join(monorepo_root, "mrbgem", "mruby-wasm-js", "js")
94
+ candidate if File.directory?(candidate)
95
+ end
96
+
97
+ def bridge_dir?(path)
98
+ File.directory?(path.to_s) && File.file?(File.join(path.to_s, "index.js"))
99
+ end
100
+
101
+ # The cli gem ships from `<repo>/cli`. This file lives at
102
+ # `<repo>/cli/lib/lilac/cli/build/runtime_resolver.rb`, so `<repo>`
103
+ # is five levels up. When installed via rubygems the cli gem is
104
+ # unpacked alone — the candidates simply won't exist, which is fine:
105
+ # discovery falls through to gem / explicit overrides.
106
+ def monorepo_root
107
+ @monorepo_root ||= @monorepo_root_override || File.expand_path("../../../../..", __dir__)
108
+ end
109
+
110
+ def bridge_not_found_message
111
+ <<~MSG.strip
112
+ @takahashim/mruby-wasm-js bridge not found. Tried:
113
+ • configured `c.mruby_wasm_js_path` (#{@configured_bridge_path.inspect})
114
+ • ENV["MRUBY_WASM_JS_PATH"] (#{ENV["MRUBY_WASM_JS_PATH"].inspect})
115
+ • lilac-wasm-bin gem (not on load path)
116
+ • monorepo: #{monorepo_bridge_candidate || '(no mrbgem/mruby-wasm-js/js dir)'}
117
+
118
+ To fix, either:
119
+ • Add `gem "lilac-wasm-bin"` to your Gemfile (recommended)
120
+ • Pass `--mruby-wasm-js-path /abs/path/to/mruby-wasm-js/` on the command line
121
+ • Add `c.mruby_wasm_js_path = "/abs/path"` to lilac.config.rb
122
+ • Set ENV["MRUBY_WASM_JS_PATH"]
123
+ MSG
124
+ end
125
+
126
+ # ---- wasm-specific hooks (subclasses must override) --------------
127
+
128
+ # ENV var consulted for an explicit wasm path.
129
+ def wasm_env_key
130
+ raise NotImplementedError, "#{self.class} must define #wasm_env_key"
131
+ end
132
+
133
+ # Path the `lilac-wasm-bin` gem publishes for this target's wasm,
134
+ # or nil if the gem isn't loadable / discovery is disabled.
135
+ def gem_provided_wasm
136
+ raise NotImplementedError, "#{self.class} must define #gem_provided_wasm"
137
+ end
138
+
139
+ # The monorepo `build/<wasm>` candidate for in-repo development.
140
+ def monorepo_wasm_candidate
141
+ raise NotImplementedError, "#{self.class} must define #monorepo_wasm_candidate"
142
+ end
143
+
144
+ # Actionable "wasm not found" message naming this target's overrides.
145
+ def wasm_not_found_message
146
+ raise NotImplementedError, "#{self.class} must define #wasm_not_found_message"
147
+ end
148
+ end
149
+ end
150
+ end
@@ -0,0 +1,150 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Lilac
4
+ module CLI
5
+ # Parses a `.lil` single-file component into its constituent parts:
6
+ #
7
+ # * one or more `<template>` blocks, optionally named via
8
+ # `data-template="..."` (a standard HTML5 data attribute, same as
9
+ # what the Lilac runtime expects for clone-targets)
10
+ # * one or more `<script type="text/ruby">` blocks
11
+ #
12
+ # The format is HTML5-valid: any conformant HTML parser (browser,
13
+ # nokogiri, gammo) can build a DOM from a `.lil` file. We use regex
14
+ # rather than a full HTML parser internally because we want the
15
+ # `<template>` inner content **verbatim** — a DOM-based parser would
16
+ # require re-serializing children back to HTML and would normalize
17
+ # quote / whitespace / attribute order along the way.
18
+ #
19
+ # The parsed result is a `Component` value object. Order of templates
20
+ # is preserved from the source; named templates remember their name.
21
+ # Anonymous templates (no `data-template` attribute) are the
22
+ # component's "default" markup; multiple anonymous templates are
23
+ # allowed and concatenated in document order.
24
+ module SFC
25
+ # `name` is nil for anonymous templates.
26
+ Template = Struct.new(:name, :body, keyword_init: true)
27
+
28
+ # `path` is the source file path (or nil for in-memory parses).
29
+ # `templates` is an Array<Template>, `script` is a single concatenated
30
+ # String of all Ruby blocks joined by a newline.
31
+ Component = Struct.new(:path, :templates, :script, keyword_init: true) do
32
+ def default_templates
33
+ templates.select { |t| t.name.nil? }
34
+ end
35
+
36
+ def named_templates
37
+ templates.reject { |t| t.name.nil? }
38
+ end
39
+ end
40
+
41
+ class ParseError < StandardError; end
42
+
43
+ TEMPLATE_OPEN = /<template(?:\s+data-template="([^"]*)")?\s*>/
44
+ TEMPLATE_CLOSE = "</template>"
45
+ SCRIPT_OPEN = /<script\s+type="text\/ruby"\s*>/
46
+ SCRIPT_CLOSE = "</script>"
47
+
48
+ def self.parse_file(path)
49
+ parse(File.read(path), path: path)
50
+ end
51
+
52
+ # Walks the source linearly so nested `<template>` / `<script>` tags
53
+ # don't get matched by a greedy regex. Each iteration:
54
+ # 1. find the earliest opening tag (template or script)
55
+ # 2. find its matching close, extract the body
56
+ # 3. advance past the close
57
+ def self.parse(source, path: nil)
58
+ templates = []
59
+ script_parts = []
60
+ cursor = 0
61
+
62
+ while cursor < source.length
63
+ tmpl_match = source.match(TEMPLATE_OPEN, cursor)
64
+ script_match = source.match(SCRIPT_OPEN, cursor)
65
+
66
+ # Earlier of the two openings wins; nil-safe sort by offset.
67
+ next_match = [tmpl_match, script_match].compact
68
+ .min_by { |m| m.begin(0) }
69
+ break unless next_match
70
+
71
+ if next_match == tmpl_match
72
+ templates << extract_template(source, tmpl_match, path)
73
+ cursor = source.index(TEMPLATE_CLOSE, tmpl_match.end(0)) + TEMPLATE_CLOSE.length
74
+ else
75
+ script_parts << extract_script(source, script_match, path)
76
+ cursor = source.index(SCRIPT_CLOSE, script_match.end(0)) + SCRIPT_CLOSE.length
77
+ end
78
+ end
79
+
80
+ Component.new(
81
+ path: path,
82
+ templates: templates,
83
+ script: script_parts.join("\n"),
84
+ )
85
+ end
86
+
87
+ def self.extract_template(source, match, path)
88
+ name = match[1].to_s.empty? ? nil : match[1]
89
+ body_start = match.end(0)
90
+ body_end = source.index(TEMPLATE_CLOSE, body_start)
91
+ raise ParseError, "Unterminated <template> in #{path || '<input>'}" unless body_end
92
+
93
+ Template.new(name: name, body: source[body_start...body_end])
94
+ end
95
+
96
+ def self.extract_script(source, match, path)
97
+ body_start = match.end(0)
98
+ body_end = source.index(SCRIPT_CLOSE, body_start)
99
+ raise ParseError, "Unterminated <script type=\"text/ruby\"> in #{path || '<input>'}" unless body_end
100
+
101
+ source[body_start...body_end]
102
+ end
103
+
104
+ # Walk an arbitrary HTML string for `<script type="text/ruby">…</script>`
105
+ # blocks (the same shape `.lil` files use). Returns the source strings in
106
+ # document order plus an HTML copy with each block removed verbatim
107
+ # (open tag + body + close tag). Other `<script>` types (e.g. `module`,
108
+ # untyped) are untouched.
109
+ #
110
+ # Used by the page-build path so a page may embed inline Ruby without
111
+ # being silently dropped on the compiled target.
112
+ def self.extract_inline_ruby_scripts(html, path: nil)
113
+ scripts = []
114
+ ranges = []
115
+ cursor = 0
116
+
117
+ while (script_match = html.match(SCRIPT_OPEN, cursor))
118
+ body_start = script_match.end(0)
119
+ body_end = html.index(SCRIPT_CLOSE, body_start) or
120
+ raise ParseError, "Unterminated <script type=\"text/ruby\"> in #{path || '<input>'}"
121
+
122
+ scripts << html[body_start...body_end]
123
+ ranges << (script_match.begin(0)...(body_end + SCRIPT_CLOSE.length))
124
+ cursor = body_end + SCRIPT_CLOSE.length
125
+ end
126
+
127
+ stripped = remove_ranges(html, ranges)
128
+ { stripped_html: stripped, scripts: scripts }
129
+ end
130
+
131
+ # Drop the byte ranges (sorted in document order) from `html` and
132
+ # collapse the now-empty surrounding whitespace minimally — we keep
133
+ # surrounding HTML formatting untouched and only delete the matched
134
+ # script tags themselves.
135
+ def self.remove_ranges(html, ranges)
136
+ return html if ranges.empty?
137
+
138
+ result = +""
139
+ prev_end = 0
140
+ ranges.each do |r|
141
+ result << html[prev_end...r.begin]
142
+ prev_end = r.end
143
+ end
144
+ result << html[prev_end..]
145
+ result
146
+ end
147
+ private_class_method :remove_ranges
148
+ end
149
+ end
150
+ end
@@ -0,0 +1,304 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "nokogiri"
4
+ require_relative "build_error"
5
+ require_relative "directive"
6
+
7
+ module Lilac
8
+ module CLI
9
+ # Parses a template body HTML fragment with Nokogiri, walks the
10
+ # element tree, and:
11
+ #
12
+ # 1. Collects every `data-*` directive into a flat list of
13
+ # `Directive` records (in document order) for the lint layer.
14
+ # 2. Assigns a synthetic `data-ref="lilN"` to any element bearing
15
+ # a binding directive but no explicit `data-ref`, so the
16
+ # runtime scanner can resolve it positionally at mount.
17
+ # 3. Builds `refs_map` keyed by ref name, recording each ref's
18
+ # source line for duplicate / reserved-name lint checks.
19
+ #
20
+ # SFC's regex-based template extraction (`SFC.parse`) preserves the
21
+ # template body byte-for-byte. TemplateAST does NOT preserve byte
22
+ # equality on the way out — Nokogiri normalizes quoting and
23
+ # boolean attribute serialization. The HTML returned by `parse` is
24
+ # round-tripped through Nokogiri::HTML5.
25
+ #
26
+ # Directive values are NOT validated against the value grammar here
27
+ # — directive attributes are left in the output HTML so the runtime
28
+ # scanner sees `data-component` / `data-ref` and the directives it
29
+ # binds at mount time.
30
+ class TemplateAST
31
+ # Subclass of BuildError so callers can `rescue BuildError` to
32
+ # catch every build-time failure uniformly, regardless of source.
33
+ class Error < BuildError; end
34
+
35
+ Result = Struct.new(:html, :directives, :refs_map, keyword_init: true)
36
+
37
+ # Maps an attribute-name regex to its directive kind. Order matters:
38
+ # the more specific X-family patterns (`data-on-X` etc.) must be
39
+ # checked before the simpler ones so that `data-on-click` is
40
+ # classified as `:on` not as an unknown attribute.
41
+ #
42
+ # `class_` trailing underscore avoids shadowing Ruby's reserved
43
+ # `class` keyword in pattern-match contexts; symbol-side only.
44
+
45
+ DIRECTIVE_PATTERNS = [
46
+ [/\Adata-text\z/, :text, false],
47
+ [/\Adata-unsafe-html\z/, :unsafe_html, false],
48
+ # data-bind is the form-independent two-way binding directive
49
+ # (Phase E revival/unification of data-value / data-checked).
50
+ # Property auto-selected from element type:
51
+ # <input type=checkbox> → :checked, others → :value.
52
+ # See directive-spec §6.2 for the full grammar; runtime parity
53
+ # lives in runtime/mruby-lilac-directives/.
54
+ [/\Adata-bind\z/, :bind, false],
55
+ [/\Adata-show\z/, :show, false],
56
+ [/\Adata-hide\z/, :hide, false],
57
+ [/\Adata-each\z/, :each, false],
58
+ [/\Adata-key\z/, :key, false],
59
+ [/\Adata-class\z/, :class_, false],
60
+ # data-component is detected only so collision checks can see
61
+ # it; it has no emit_* of its own (the runtime autoregister
62
+ # reads the HTML attribute directly) and `walk` skips Directive
63
+ # records when it appears alone, to keep the dist HTML and the
64
+ # linter input unchanged for the common case.
65
+ [/\Adata-component\z/, :component, false],
66
+ # Form directives (mruby-lilac-form gem). Collected so scope
67
+ # validation / lint can see them; the runtime form scanner wires
68
+ # the actual behavior at mount (resolves scope via ancestor walk).
69
+ [/\Adata-form\z/, :form, false],
70
+ [/\Adata-field\z/, :field, false],
71
+ [/\Adata-button\z/, :button, false],
72
+ [/\Adata-on-(.+)\z/, :on, true],
73
+ [/\Adata-attr-(.+)\z/, :attr, true],
74
+ [/\Adata-arg-(.+)\z/, :arg, true],
75
+ [/\Adata-css-(.+)\z/, :css, true],
76
+ ].freeze
77
+
78
+ def initialize(body_html, source_path: nil)
79
+ @body_html = body_html
80
+ @source_path = source_path
81
+ end
82
+
83
+ # Immutable per-frame walk state: the stacks that get pushed/popped
84
+ # as the walker descends/ascends. Bundled so `walk` doesn't have to
85
+ # thread individual args through recursion (the accumulators —
86
+ # directives / refs_map — are mutated in place and stay separate).
87
+ class WalkScopes
88
+ # each_scope: ref_ids of currently-open `data-each` elements;
89
+ # children's directives carry `scope_id = each_scope.last`
90
+ # ref_scopes: Stack<{ ref_name => line }> for duplicate ref detection
91
+ # per `data-each` / `data-component` scope
92
+ attr_reader :each_scope, :ref_scopes
93
+
94
+ def initialize(each_scope: [], ref_scopes: [{}])
95
+ @each_scope = each_scope
96
+ @ref_scopes = ref_scopes
97
+ end
98
+
99
+ # Push helpers return a NEW WalkScopes — frames are immutable so
100
+ # the recursive call doesn't accidentally mutate the parent's stack.
101
+ def push_each(ref_id)
102
+ self.class.new(each_scope: @each_scope + [ref_id], ref_scopes: @ref_scopes)
103
+ end
104
+
105
+ def push_ref_scope
106
+ self.class.new(each_scope: @each_scope, ref_scopes: @ref_scopes + [{}])
107
+ end
108
+
109
+ def current_ref_scope
110
+ @ref_scopes.last
111
+ end
112
+
113
+ def current_each_ref
114
+ @each_scope.last
115
+ end
116
+ end
117
+
118
+ def parse
119
+ fragment = Nokogiri::HTML5.fragment(@body_html)
120
+ directives = []
121
+ refs_map = {}
122
+ # `at_root: true` for the outermost walk — when the first
123
+ # element child of the fragment carries `data-component`, it's
124
+ # the component's own root (this AST run IS that component),
125
+ # so its directives + body belong to this component. Any deeper
126
+ # `data-component` element is a different component; we treat
127
+ # those subtrees opaquely so their directives don't leak into
128
+ # the current component's scope.
129
+ walk(fragment, directives, refs_map, WalkScopes.new, at_root: true)
130
+
131
+ Result.new(
132
+ html: fragment.to_html,
133
+ directives: directives,
134
+ refs_map: refs_map,
135
+ )
136
+ end
137
+
138
+ private
139
+
140
+ # `scopes` is a WalkScopes carrying two immutable stacks:
141
+ # - each_scope: ref_ids of currently-open `data-each` elements
142
+ # (children carry `scope_id` pointing at the enclosing iteration).
143
+ # - ref_scopes: per-scope `data-ref` namespace for duplicate detection.
144
+ # `data-each` AND `data-component` both open a fresh ref scope —
145
+ # iteration bodies and nested component subtrees each get clean
146
+ # ref sets. The directive `each_scope` is unaffected by
147
+ # `data-component` (its body's directives still belong to the
148
+ # parent component's scope).
149
+ def walk(node, directives, refs_map, scopes, at_root: false)
150
+ node.element_children.to_a.each do |elem|
151
+ element_directives = collect_directives_with_synthesis(elem)
152
+ has_each = element_directives.any? { |k, _, _| k == :each }
153
+ has_component = element_directives.any? { |k, _, _| k == :component }
154
+
155
+ # Nested `data-component` (not the parse's outermost element)
156
+ # is a different component, whose directives + body are
157
+ # handled by its OWN AST run. Skip the entire subtree —
158
+ # don't record directives, don't recurse — so the parent's
159
+ # bindings don't double-cover what the nested component will
160
+ # already wire at mount.
161
+ if has_component && !at_root
162
+ next
163
+ end
164
+
165
+ # A bare `data-component` element needs no ref_id (the runtime
166
+ # mounts via the data-component attribute), so we skip the
167
+ # Directive record entirely to keep the dist HTML byte-identical
168
+ # and the linter input clean. The :component record only matters
169
+ # when it coexists with another directive (e.g. `data-each`)
170
+ # so Lilac::Directives::Lints can flag the collision.
171
+ has_real_directive = element_directives.any? { |k, _, _| k != :component }
172
+ ref_id = nil
173
+
174
+ if has_real_directive
175
+ ref_id = assign_or_reuse_ref(elem, scopes.current_ref_scope)
176
+ element_directives.each do |kind, name, attr_value|
177
+ directives << Directive.new(
178
+ kind: kind,
179
+ name: name,
180
+ value: attr_value,
181
+ ref_id: ref_id,
182
+ line: elem.line,
183
+ element_tag: elem.name,
184
+ scope_id: scopes.current_each_ref,
185
+ )
186
+ end
187
+ refs_map[ref_id] ||= elem.line
188
+ elsif (explicit = elem["data-ref"]) && !explicit.empty?
189
+ # Ref declared on an element without any other directive
190
+ # (e.g. `<input data-ref="email">` used only by user-side
191
+ # `refs.email`). Still register so duplicates trip the
192
+ # check, but don't allocate a synthetic / Directive record.
193
+ unless scopes.current_ref_scope.key?(explicit)
194
+ register_ref!(explicit, elem.line, scopes.current_ref_scope)
195
+ end
196
+ end
197
+
198
+ child_scopes = scopes
199
+ # `has_component` only pushes a fresh ref scope for NESTED
200
+ # data-component elements — and we already `next` on those
201
+ # above, so we never reach this branch for them. The root
202
+ # data-component shares its ref scope with this AST run.
203
+ child_scopes = child_scopes.push_ref_scope if has_each
204
+
205
+ if has_each
206
+ # scanner-canonical: keep the data-each row IN-PLACE. The
207
+ # runtime scanner's `dispatch_each` snapshots the live
208
+ # innerHTML, empties the container, and clones per item — so
209
+ # the builder must NOT extract the row. We still recurse to
210
+ # collect directives for lint scoping.
211
+ walk(elem, directives, refs_map, child_scopes.push_each(ref_id))
212
+ else
213
+ walk(elem, directives, refs_map, child_scopes)
214
+ end
215
+ end
216
+ end
217
+
218
+ # Returns Array<[kind, name, value]> for every directive on `elem`.
219
+ # `name` is nil for non-X-family directives.
220
+ def extract_directives(elem)
221
+ result = []
222
+ elem.attributes.each do |attr_name, attr|
223
+ match_row = DIRECTIVE_PATTERNS.find { |pattern, _, _| pattern.match(attr_name) }
224
+ next unless match_row
225
+
226
+ pattern, kind, captures_name = match_row
227
+ match = pattern.match(attr_name)
228
+ name = captures_name ? match[1] : nil
229
+ result << [kind, name, attr.value]
230
+ end
231
+ result
232
+ end
233
+
234
+ # Wraps `extract_directives` to inject a synthetic `:form` directive
235
+ # for bare `<form>` elements (no data-form attr), so the lint layer
236
+ # sees the form even when the author didn't name a scope. Keeping
237
+ # this in one named helper makes the "directives can include things
238
+ # not in the HTML" surprise explicit rather than buried in `walk`.
239
+ def collect_directives_with_synthesis(elem)
240
+ directives = extract_directives(elem)
241
+ if elem.name == "form" && directives.none? { |k, _, _| k == :form }
242
+ directives << [:form, nil, ""]
243
+ end
244
+ directives
245
+ end
246
+
247
+ # If the element already has an explicit `data-ref`, reuse it
248
+ # (so user-chosen refs remain stable across rebuilds) but
249
+ # register it so a duplicate in the same scope raises. Otherwise
250
+ # allocate a fresh synthetic name based on the scope's current
251
+ # size — `refs.lilN` is resolved positionally at runtime, so the
252
+ # name doesn't need to be written back to the DOM (decisions §19).
253
+ def assign_or_reuse_ref(elem, current_ref_scope)
254
+ existing = elem["data-ref"]
255
+ if existing && !existing.empty?
256
+ # register_ref! enforces the `lilN`-namespace reservation +
257
+ # in-scope uniqueness.
258
+ register_ref!(existing, elem.line, current_ref_scope)
259
+ return existing
260
+ end
261
+
262
+ # Per-scope counter — synthetic refs are positional indices
263
+ # (0-based) within the current data-component / data-each scope.
264
+ # The runtime walks the same DFS order and maps `lilN` → N-th
265
+ # directive-bearing element. NO DOM mutation: the dist HTML
266
+ # carries no synthetic `data-ref` attributes for normal
267
+ # directive elements (decisions §19).
268
+ counter = current_ref_scope.size
269
+ loop do
270
+ candidate = "lil#{counter}"
271
+ counter += 1
272
+ next if current_ref_scope.key?(candidate)
273
+
274
+ current_ref_scope[candidate] = elem.line
275
+ return candidate
276
+ end
277
+ end
278
+
279
+ def register_ref!(ref, line, current_ref_scope)
280
+ # `lilN` is reserved for runtime positional ref slots — see ADR-0019.
281
+ if ref.match?(/^lil\d+$/)
282
+ raise Error.new(
283
+ "data-ref=#{ref.inspect}: the `lilN` namespace is reserved for runtime-internal directive slots.",
284
+ at: SourceLocation.new(file: source_file, line: line),
285
+ suggestion: "Use a domain name like `email` / `list` instead.",
286
+ )
287
+ end
288
+ previous = current_ref_scope[ref]
289
+ if previous
290
+ raise Error.new(
291
+ "Duplicate data-ref #{ref.inspect} — already declared at line #{previous} in the same template scope.",
292
+ at: SourceLocation.new(file: source_file, line: line),
293
+ suggestion: "Rename one of them; data-ref names must be unique within a top-level / data-each / data-component scope.",
294
+ )
295
+ end
296
+ current_ref_scope[ref] = line
297
+ end
298
+
299
+ def source_file
300
+ @source_path ? File.basename(@source_path) : "(template)"
301
+ end
302
+ end
303
+ end
304
+ end
@@ -0,0 +1,58 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "template_ast"
4
+
5
+ module Lilac
6
+ module CLI
7
+ # Memoizes parsed component template ASTs by component name. The
8
+ # same `.lil` shape often appears on many pages; the parse step is
9
+ # not free and the parsed result is immutable, so caching once per
10
+ # build keeps multi-page builds linear in components rather than
11
+ # `pages × components`.
12
+ #
13
+ # Shared by `Builder` (when emitting the :bundle delivery file) and
14
+ # `PageCompiler` (per-page injection). Both read through `fetch`;
15
+ # writes happen lazily on miss.
16
+ class TemplateASTCache
17
+ # A user-defined named template (from `<template data-template="...">`
18
+ # in `.lil` source) ready to be injected as `<template
19
+ # data-template="X">` into the page.
20
+ RenderedTemplate = Struct.new(:name, :html, keyword_init: true)
21
+
22
+ def initialize
23
+ @cache = {}
24
+ end
25
+
26
+ # Returns a Hash with:
27
+ # :default_html — concatenated body HTML of the default templates
28
+ # :default_directives — Array<Directive> for top-level binding emission
29
+ # :default_refs_map — Hash { ref_name => line } for lint
30
+ # :named — Array<RenderedTemplate> (user-defined)
31
+ # :source_path — Path to the `.lil` (or in-memory page)
32
+ def fetch(name, component)
33
+ @cache[name] ||= parse(component)
34
+ end
35
+
36
+ private
37
+
38
+ def parse(component)
39
+ default_results = component.default_templates.map do |t|
40
+ TemplateAST.new(t.body, source_path: component.path).parse
41
+ end
42
+
43
+ named = component.named_templates.map do |t|
44
+ result = TemplateAST.new(t.body, source_path: component.path).parse
45
+ RenderedTemplate.new(name: t.name, html: result.html)
46
+ end
47
+
48
+ {
49
+ default_html: default_results.map(&:html).join.strip,
50
+ default_directives: default_results.flat_map(&:directives),
51
+ default_refs_map: default_results.map(&:refs_map).reduce({}, :merge),
52
+ named: named,
53
+ source_path: component.path
54
+ }
55
+ end
56
+ end
57
+ end
58
+ end