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,51 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../source_location"
4
+
5
+ module Lilac
6
+ module CLI
7
+ # A single `data-*` directive occurrence on a template element.
8
+ #
9
+ # `kind` is the directive family symbol (e.g. `:text`, `:on`, `:attr`,
10
+ # `:each`). For X-family directives (`data-on-X`, `data-attr-X`,
11
+ # `data-arg-X`, `data-css-X`), `name` carries the X part as it appears in
12
+ # the source (kebab-case, e.g. "click" / "background-color"). For
13
+ # non-X-family directives `name` is `nil`.
14
+ #
15
+ # `value` is the raw attribute value string (e.g. `"@count"` /
16
+ # `"increment"` / `"{ active: @s }"`). TemplateAST only collects the
17
+ # raw string; grammar validation lives in the lint layer.
18
+ #
19
+ # `ref_id` is the synthetic or explicit ref name assigned by
20
+ # `TemplateAST`, used by the lint layer to group directives sharing
21
+ # an element and to detect duplicate `data-ref` declarations.
22
+ #
23
+ # `line` is the source line in the template body (1-based,
24
+ # Nokogiri's `node.line`), used for error reporting.
25
+ #
26
+ # `element_tag` is the HTML element name (e.g. "div", "button"),
27
+ # consumed by Lilac::Directives::Lints to enforce applicability rules.
28
+ #
29
+ # `scope_id` is the `ref_id` of the enclosing `data-each` element,
30
+ # or `nil` for directives at the component's top level. The lint
31
+ # layer uses it to scope duplicate-ref detection per iteration body.
32
+ Directive = Struct.new(
33
+ :kind,
34
+ :name,
35
+ :value,
36
+ :ref_id,
37
+ :line,
38
+ :element_tag,
39
+ :scope_id,
40
+ keyword_init: true,
41
+ ) do
42
+ # Pair the directive's line with the source file (which the
43
+ # Directive itself doesn't carry) for use in BuildError /
44
+ # LintWarning `at:` kwargs. Saves callers from writing
45
+ # `SourceLocation.new(file: f, line: directive.line)` over and over.
46
+ def source_location(file)
47
+ SourceLocation.new(file: file, line: line)
48
+ end
49
+ end
50
+ end
51
+ end
@@ -0,0 +1,61 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "runtime_resolver"
4
+
5
+ module Lilac
6
+ module CLI
7
+ # Locates the `target: :full` runtime that gets vendored into
8
+ # `dist/vendor/lilac-full/`:
9
+ #
10
+ # * `lilac-full.wasm` — the full-variant wasm bundle (parser +
11
+ # directive scanner + bundled gems)
12
+ # * `mruby-wasm-js/` — the JS↔mruby bridge (resolved by the shared
13
+ # base — same artifact as the :compiled target)
14
+ #
15
+ # Only the wasm-specific discovery differs from `:compiled`, so this
16
+ # class supplies just those hooks. See `RuntimeResolver`.
17
+ class FullRuntimeResolver < RuntimeResolver
18
+ # Distinct from CompiledRuntimeResolver::Error; both < RuntimeResolver::Error.
19
+ class Error < RuntimeResolver::Error; end
20
+
21
+ def initialize(lilac_full_path: nil, **opts)
22
+ super(lilac_wasm_path: lilac_full_path, **opts)
23
+ end
24
+
25
+ private
26
+
27
+ def wasm_env_key
28
+ "LILAC_FULL_WASM"
29
+ end
30
+
31
+ def gem_provided_wasm
32
+ return nil if @disable_gem_discovery
33
+ require "lilac/wasm/bin"
34
+ ::Lilac::Wasm::Bin.lilac_full_wasm
35
+ rescue LoadError
36
+ nil
37
+ end
38
+
39
+ def monorepo_wasm_candidate
40
+ File.join(monorepo_root, "build", "lilac-full.wasm")
41
+ end
42
+
43
+ def wasm_not_found_message
44
+ <<~MSG.strip
45
+ lilac-full.wasm not found. Tried:
46
+ • configured `c.lilac_full_path` (#{@configured_wasm_path.inspect})
47
+ • ENV["LILAC_FULL_WASM"] (#{ENV["LILAC_FULL_WASM"].inspect})
48
+ • lilac-wasm-bin gem (not on load path)
49
+ • monorepo: #{monorepo_wasm_candidate}
50
+
51
+ To fix, either:
52
+ • Add `gem "lilac-wasm-bin"` to your Gemfile (recommended — the scaffolded Gemfile from `lilac new` already includes it)
53
+ • Pass `--lilac-full-path /abs/path/to/lilac-full.wasm` on the command line
54
+ • Add `c.lilac_full_path = "/abs/path"` to lilac.config.rb
55
+ • Set ENV["LILAC_FULL_WASM"]
56
+ • In the monorepo: run `make lilac-full` to produce build/lilac-full.wasm
57
+ MSG
58
+ end
59
+ end
60
+ end
61
+ end
@@ -0,0 +1,58 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Lilac
4
+ module CLI
5
+ # Pure HTML string-rendering helpers shared by `Builder` (bundle file
6
+ # emission) and `PageCompiler` (per-page injection). Keeping these as
7
+ # module functions makes the dependency direction obvious (stateless
8
+ # utilities) and trims surface area from the larger classes.
9
+ module HtmlEmitter
10
+ module_function
11
+
12
+ def render_named_template(template_name, body_html)
13
+ %(<template data-template="#{escape_attr(template_name)}">#{body_html}</template>)
14
+ end
15
+
16
+ # Emit a <template> wrapping the component's default markup. The
17
+ # `default_html` already contains the outer `<div data-component="X">`
18
+ # element from the .lil source, so we don't add another wrapper —
19
+ # just surround it with <template> so the runtime registry can pick
20
+ # it up as the source for data-use="X" injections.
21
+ def render_default_template(default_html)
22
+ %(<template>#{default_html}</template>)
23
+ end
24
+
25
+ def render_script(ruby_source)
26
+ "<script type=\"text/ruby\">\n#{ruby_source}\n</script>"
27
+ end
28
+
29
+ def escape_attr(value)
30
+ value.gsub('&', '&amp;').gsub('"', '&quot;').gsub('<', '&lt;')
31
+ end
32
+
33
+ # Splice text just before the page's </body>. Prefers the last
34
+ # </body> in the source so any earlier mention (e.g. inside a
35
+ # <pre> code example) doesn't get hijacked.
36
+ def inject_before_body_close(html, injection)
37
+ idx = html.rindex(%r{</body>}i)
38
+ return "#{html}\n#{injection}" unless idx
39
+
40
+ "#{html[0...idx]}#{injection}\n#{html[idx..]}"
41
+ end
42
+
43
+ # Inject `<link rel="lilac-bundle" href="...">` into the page's
44
+ # <head>. Falls back to before <body>, then to a prepend, when the
45
+ # page HTML is handwritten without standard boundary tags.
46
+ def inject_bundle_link(html, url)
47
+ link = %(<link rel="lilac-bundle" href="#{escape_attr(url)}">)
48
+ if html =~ %r{</head>}i
49
+ html.sub(%r{</head>}i, " #{link}\n</head>")
50
+ elsif html =~ %r{<body}i
51
+ html.sub(%r{<body}i, "#{link}\n<body")
52
+ else
53
+ "#{link}\n#{html}"
54
+ end
55
+ end
56
+ end
57
+ end
58
+ end
@@ -0,0 +1,111 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'fileutils'
4
+ require 'json'
5
+ require_relative '../package_discovery'
6
+
7
+ module Lilac
8
+ module CLI
9
+ # Stages Lilac package `.mrb` files under `dist/packages/` and (for
10
+ # the :full target) writes the `lilac.packages.json` manifest the
11
+ # scaffold-style boot script reads to load each package ahead of
12
+ # `evalScript`.
13
+ #
14
+ # Two input channels merge here (decisions §25 / §26):
15
+ #
16
+ # 1. **Bundler auto-discovery** — `PackageDiscovery.run` finds gems
17
+ # whose gemspec declares `metadata["lilac_package"] = "true"`.
18
+ # Each gem's `mrblib/*.rb` is compiled locally to `.mrb` so the
19
+ # mruby version matches the vendored core wasm.
20
+ # 2. **Explicit `c.packages = [...paths]`** — pre-compiled `.mrb`
21
+ # files. Useful for advanced overrides (custom package not in
22
+ # a gem, vendored fork, etc.).
23
+ #
24
+ # Manifest emission is target-dependent:
25
+ # - `:full` writes `dist/lilac.packages.json` so the user's hand-
26
+ # rolled boot script can `fetch` it and `loadBytecode` each entry
27
+ # - `:compiled` skips the manifest — the generated boot module
28
+ # inlines `loadBytecode` calls directly
29
+ #
30
+ # Sibling of `BundleAssetWriter` / `VendorWriter`: same shape (a
31
+ # build phase owning one cluster of file emissions), invoked once
32
+ # from `Builder#build`.
33
+ class PackageStager
34
+ PACKAGES_DIR = 'packages'
35
+ MANIFEST_FILENAME = 'lilac.packages.json'
36
+
37
+ def initialize(packages:, target:, output_dir:, bytecode_builder:)
38
+ @packages = packages
39
+ @target = target
40
+ @output_dir = output_dir
41
+ @bytecode_builder = bytecode_builder
42
+ end
43
+
44
+ # Returns `Array<String>` — page-relative URLs of every staged
45
+ # `.mrb`. Empty when no packages are configured or discovered.
46
+ def run!
47
+ return [] if @packages.empty? && discovered_packages.empty?
48
+
49
+ FileUtils.mkdir_p(File.join(@output_dir, PACKAGES_DIR))
50
+ urls = (stage_discovered + stage_explicit).uniq
51
+ write_manifest!(urls) if @target == :full
52
+ urls
53
+ end
54
+
55
+ private
56
+
57
+ # Cached so multi-page builds discover once. Empty list outside
58
+ # a Bundler context.
59
+ def discovered_packages
60
+ @discovered_packages ||= PackageDiscovery.run
61
+ end
62
+
63
+ # Auto-discovered gem-based packages first. Compile each gem's
64
+ # mrblib source set to a single `.mrb` named after the gem so
65
+ # filename collisions with explicit override paths are easy to
66
+ # spot.
67
+ def stage_discovered
68
+ discovered_packages.map do |discovered|
69
+ bytes = compile_source(discovered.mrblib_files, source_label: discovered.name)
70
+ filename = "#{discovered.name}.mrb"
71
+ File.binwrite(File.join(@output_dir, PACKAGES_DIR, filename), bytes)
72
+ "./#{PACKAGES_DIR}/#{filename}"
73
+ end
74
+ end
75
+
76
+ # Explicit override paths next — already-compiled `.mrb` files
77
+ # the user pointed at directly via `c.packages`.
78
+ def stage_explicit
79
+ @packages.map do |src|
80
+ raise Builder::Error, "Lilac package `.mrb` not found: #{src}" unless File.file?(src)
81
+
82
+ basename = File.basename(src)
83
+ FileUtils.cp(src, File.join(@output_dir, PACKAGES_DIR, basename))
84
+ "./#{PACKAGES_DIR}/#{basename}"
85
+ end
86
+ end
87
+
88
+ # Compile concatenated mrblib source via the shared
89
+ # `BytecodeBuilder` backend chain (binary mrbc → wasm-driven mrbc
90
+ # → $PATH). Mirrors `lilac package-build`'s aggregation rule:
91
+ # alphabetical concat separated by newlines.
92
+ def compile_source(mrblib_files, source_label:)
93
+ source = mrblib_files.map { |f| File.read(f) }.join("\n")
94
+ @bytecode_builder.compile_to_bytes(source, source_label: "package #{source_label}")
95
+ end
96
+
97
+ # Write `dist/lilac.packages.json` so a scaffold-style boot script
98
+ # can fetch the manifest and `loadBytecode` each entry before
99
+ # evaluating `<script type="text/ruby">` blocks. The manifest is
100
+ # only written when at least one package was staged, so absence of
101
+ # the file means "no packages" — boot scripts can fetch with a
102
+ # graceful 404 fallback.
103
+ def write_manifest!(package_urls)
104
+ return if package_urls.empty?
105
+
106
+ manifest_path = File.join(@output_dir, MANIFEST_FILENAME)
107
+ File.write(manifest_path, JSON.pretty_generate(packages: package_urls) + "\n")
108
+ end
109
+ end
110
+ end
111
+ end
@@ -0,0 +1,405 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "fileutils"
4
+ require "pathname"
5
+ require "set"
6
+ require_relative "sfc"
7
+ require_relative "component_scripts_assembler"
8
+ require_relative "html_emitter"
9
+ require_relative "compiled_boot_module"
10
+ require_relative "../live_reload"
11
+
12
+ module Lilac
13
+ module CLI
14
+ # Compiles one source `pages/*.html` file into a dist HTML, given the
15
+ # build-level `BuildContext`. Handles:
16
+ #
17
+ # - page-inline `<script type="text/ruby">` extraction
18
+ # - page-inline `<X data-component=...>` synthesis (Nokogiri pass)
19
+ # - data-use=X reference scanning
20
+ # - per-target / per-delivery dispatch (4 combos: full×inline,
21
+ # full×bundle, compiled×inline, compiled×bundle — including the
22
+ # chained .mrb boot module for compiled×bundle×page-inline)
23
+ #
24
+ # The outer `Builder.build` constructs the context once and invokes
25
+ # `compile(page_path)` for each page; the compiler itself holds no
26
+ # build-level state. See ADR-29 for the data-component / data-use
27
+ # split and decisions §20.6 for the `Lilac.start` placement rationale.
28
+ class PageCompiler
29
+ # Matches `data-use="X"` / `data-use='X'` attribute values on any
30
+ # element. We don't need to know which tag carries the attribute —
31
+ # just collect the referenced component names so the build can
32
+ # bundle their templates + scripts into the page.
33
+ DATA_USE_PATTERN = %r{
34
+ \bdata-use\s*=\s*(?:"([^"]+)"|'([^']+)')
35
+ }x
36
+
37
+ # Output of `build_injection`. The injection HTML is what gets
38
+ # spliced before `</body>`; `page_local_mrb` is non-nil only in
39
+ # the :compiled × :bundle case, where the per-page bytecode has
40
+ # to be chained into the boot module instead of getting its own
41
+ # `<script>` tag.
42
+ Injection = Struct.new(:html, :page_local_mrb, keyword_init: true) do
43
+ def empty?
44
+ html.to_s.empty?
45
+ end
46
+ end
47
+
48
+ def initialize(context)
49
+ @ctx = context
50
+ @scripts_assembler = ComponentScriptsAssembler.new(
51
+ template_cache: context.template_cache
52
+ )
53
+ end
54
+
55
+ def compile(page_path)
56
+ html = File.read(page_path)
57
+
58
+ # Inline `<script type="text/ruby">` blocks in the page itself are
59
+ # surfaced to the injection step so they're not silently dropped on
60
+ # the compiled target. The tags themselves stay in the dist HTML
61
+ # for both targets:
62
+ # - target=full — the runtime parser evaluates them in place
63
+ # via `vm.evalScript`
64
+ # - target=compiled — the wasm has no parser so the tags are
65
+ # dead text (browser ignores `text/ruby`), but they remain
66
+ # so "view source" works, and features like the 7guis
67
+ # `source-display` mirror still find the page's Ruby. Size
68
+ # cost is marginal (a few KB, fully compressible) and the
69
+ # `.mrb` value prop (no parser in wasm) is unaffected
70
+ extracted = SFC.extract_inline_ruby_scripts(html, path: page_path)
71
+ page_inline_scripts = extracted[:scripts]
72
+
73
+ # Page-inline `<X data-component="...">` elements are folded into
74
+ # the same pipeline that handles `.lil` components: we register
75
+ # an in-memory `SFC::Component` for each (snapshot of the
76
+ # element's outer HTML). data-each rows stay IN-PLACE (the runtime
77
+ # scanner snapshots the live innerHTML and clones per item), and
78
+ # runtime resolves `refs.lilN` positionally, so nothing else
79
+ # about the page markup needs to change.
80
+ components, html, used_inline, synthesized_names = synthesize_page_inline_components(
81
+ html, page_path: page_path
82
+ )
83
+
84
+ # R4: page-inline script classes that collide with `.lil`-derived
85
+ # class names (project-global) are flagged here. Done AFTER
86
+ # synthesize_page_inline_components so synthesized in-memory
87
+ # components (which carry no script) don't trigger false positives,
88
+ # but BEFORE build_injection so the user sees a structured error
89
+ # instead of a downstream mrbc failure.
90
+ @ctx.build_linter.check_class_name_collisions!(page_inline_scripts, components, synthesized_names, page_path)
91
+
92
+ used = used_inline.dup
93
+ # Page references to components via data-use="X" — the runtime
94
+ # injects markup from the matching <template>...<div data-component="X">
95
+ # definition that build_injection emits below. We only need to
96
+ # record each X in `used` so its template + script land in the
97
+ # injection bundle.
98
+ html.scan(DATA_USE_PATTERN) do |dq, sq|
99
+ name = dq || sq
100
+ unless components.key?(name)
101
+ raise Builder::Error,
102
+ "Unknown component referenced by data-use=#{name.inspect} in #{page_path} " \
103
+ "(no components/#{name}.lil and no page-inline data-component=#{name.inspect})"
104
+ end
105
+ used << name
106
+ end
107
+
108
+ html =
109
+ if @ctx.delivery == :bundle
110
+ inject_bundle_page(html, page_path, used, components,
111
+ page_inline_scripts: page_inline_scripts,
112
+ synthesized_names: synthesized_names)
113
+ else
114
+ inject_inline_page(html, page_path, used, components,
115
+ page_inline_scripts: page_inline_scripts,
116
+ synthesized_names: synthesized_names)
117
+ end
118
+
119
+ File.write(output_path_for(page_path).tap { |p| FileUtils.mkdir_p(File.dirname(p)) }, html)
120
+ end
121
+
122
+ private
123
+
124
+ # :inline delivery: build the per-page template/script injection
125
+ # and splice it (plus optional live-reload client) before </body>.
126
+ def inject_inline_page(html, page_path, used, components,
127
+ page_inline_scripts:, synthesized_names:)
128
+ injection = build_injection(used.uniq, components,
129
+ page_inline_scripts: page_inline_scripts,
130
+ synthesized_names: synthesized_names,
131
+ page_path: page_path)
132
+
133
+ extras = []
134
+ extras << injection.html unless injection.empty?
135
+ extras << LiveReload::SCRIPT if @ctx.live_reload
136
+ extras.empty? ? html : HtmlEmitter.inject_before_body_close(html, extras.join("\n"))
137
+ end
138
+
139
+ # Splice :bundle-delivery extras into a page: the
140
+ # `<link rel="lilac-bundle">` reference, any page-local injection
141
+ # (synthesized data-component templates + page-inline scripts) and,
142
+ # for :compiled, the chained `<script type="module">` boot stub.
143
+ #
144
+ # `.lil`-derived templates / scripts are NOT injected here — those
145
+ # live in `dist/lilac.bundle.html` (already written by the builder).
146
+ # Only the page-local slice does.
147
+ def inject_bundle_page(html, page_path, used, components,
148
+ page_inline_scripts:, synthesized_names:)
149
+ html = HtmlEmitter.inject_bundle_link(html, @ctx.bundle_assets.url) if @ctx.bundle_assets&.url
150
+
151
+ extras = []
152
+ page_local_mrb = nil
153
+
154
+ page_local_names = used.uniq.select { |n| synthesized_names.include?(n) }
155
+ unless page_local_names.empty? && page_inline_scripts.empty?
156
+ injection = build_injection(
157
+ page_local_names, components,
158
+ page_inline_scripts: page_inline_scripts,
159
+ synthesized_names: synthesized_names,
160
+ page_path: page_path
161
+ )
162
+ extras << injection.html unless injection.empty?
163
+ page_local_mrb = injection.page_local_mrb
164
+ end
165
+
166
+ if @ctx.target == :compiled
167
+ chain = CompiledBootModule.mrb_chain(@ctx.bundle_assets, page_local_mrb)
168
+ unless chain.empty?
169
+ extras << CompiledBootModule.render(
170
+ chain,
171
+ package_urls: @ctx.package_dist_urls || [],
172
+ delivery: @ctx.delivery
173
+ )
174
+ end
175
+ end
176
+
177
+ extras << LiveReload::SCRIPT if @ctx.live_reload
178
+ extras.empty? ? html : HtmlEmitter.inject_before_body_close(html, extras.join("\n"))
179
+ end
180
+
181
+ # Lifts page-inline `data-component` elements into the same
182
+ # build pipeline that `.lil` components go through. The element
183
+ # stays where the user wrote it and the runtime mounts directly
184
+ # via the `data-component` attribute. See full design notes in
185
+ # ADR-29.
186
+ def synthesize_page_inline_components(html, page_path:)
187
+ components = @ctx.components
188
+
189
+ # Quick string check — if there's no `data-component=` at all,
190
+ # skip the round-trip and return the input verbatim.
191
+ return [components, html, [], Set.new] unless html.match?(/\bdata-component\s*=/)
192
+
193
+ require 'nokogiri' unless defined?(Nokogiri)
194
+ doc = Nokogiri::HTML5.parse(html)
195
+
196
+ # Collect data-component elements in document order. Each gets
197
+ # a synthesized `SFC::Component` whose template body is the
198
+ # element's OUTER HTML (full body, including any nested
199
+ # data-component subtrees) — so the outer component's `data-each`
200
+ # extraction picks up the nested row template verbatim, and the
201
+ # nested component's own AST run sees its full body too.
202
+ targets = []
203
+ walk = lambda do |node|
204
+ node.element_children.each do |child|
205
+ targets << child if child['data-component']
206
+ walk.call(child)
207
+ end
208
+ end
209
+ walk.call(doc)
210
+
211
+ return [components, html, [], Set.new] if targets.empty?
212
+
213
+ # Capture the set of `.lil`-origin component names BEFORE we
214
+ # start synthesising — `synthesized = components.dup` would
215
+ # make a `synthesized.key?(name)` check tautological.
216
+ lil_origin = components.keys.to_set
217
+ synthesized = components.dup
218
+ synthesized_names = Set.new
219
+ used_inline = []
220
+ seen_in_page = {} # name => line — for R2 duplicate detection
221
+ targets.each do |elem|
222
+ name = elem['data-component']
223
+
224
+ # `.lil` and page-inline can't share a name — same-name
225
+ # `data-component=` is a definition collision (ADR-29).
226
+ if lil_origin.include?(name)
227
+ lil_path = components[name].path
228
+ raise Builder::Error, duplicate_definition_message(
229
+ kind: :duplicate_with_lil,
230
+ name: name,
231
+ page_path: page_path,
232
+ elem_line: elem.line,
233
+ lil_path: lil_path
234
+ )
235
+ end
236
+
237
+ # Same page can't declare the same `data-component=` twice
238
+ # — same-name definition collision (ADR-29). Two
239
+ # `<X data-component="row">` siblings would race on which
240
+ # class body wins.
241
+ if seen_in_page.key?(name)
242
+ raise Builder::Error, duplicate_definition_message(
243
+ kind: :duplicate_in_page,
244
+ name: name,
245
+ page_path: page_path,
246
+ elem_line: elem.line,
247
+ previous_line: seen_in_page[name]
248
+ )
249
+ end
250
+ seen_in_page[name] = elem.line
251
+
252
+ body_html = elem.to_html
253
+ synthesized[name] = SFC::Component.new(
254
+ path: page_path,
255
+ templates: [SFC::Template.new(name: nil, body: body_html)],
256
+ script: ''
257
+ )
258
+ synthesized_names << name
259
+ # R3: record signature for cross-page drift detection.
260
+ @ctx.build_linter.record_inline_signature(name, body_html, page_path)
261
+ used_inline << name
262
+
263
+ # scanner-canonical: leave `data-each` rows IN-PLACE. The
264
+ # runtime scanner's `dispatch_each` snapshots the live
265
+ # innerHTML, empties the container, then clones per item — so
266
+ # the brief static row is replaced at mount (no phantom), the
267
+ # same way the no-build path works. (Previously we emptied the
268
+ # container here because codegen extracted the row into a
269
+ # synthetic `<template data-template>`.)
270
+ end
271
+
272
+ [synthesized, doc.to_html, used_inline, synthesized_names]
273
+ end
274
+
275
+ # Build a structured definition-collision error message. ADR-29
276
+ # requires each component name to be defined only once per page;
277
+ # this method formats the user-facing message for each variant.
278
+ def duplicate_definition_message(kind:, name:, page_path:, **detail)
279
+ page_rel = page_path ? File.basename(page_path) : '(page)'
280
+ case kind
281
+ when :duplicate_with_lil
282
+ lil_rel = detail[:lil_path] ? File.basename(detail[:lil_path]) : "components/#{name}.lil"
283
+ "Duplicate component definition #{name.inspect}: " \
284
+ "page-inline data-component on #{page_rel}:#{detail[:elem_line]} " \
285
+ "conflicts with components/#{lil_rel}. " \
286
+ 'Each component name must be defined only once per page. ' \
287
+ 'Rename one of them.'
288
+ when :duplicate_in_page
289
+ "Duplicate component definition #{name.inspect}: " \
290
+ "data-component on #{page_rel}:#{detail[:elem_line]} " \
291
+ "is also declared at line #{detail[:previous_line]} of the same page. " \
292
+ 'Each component name must be defined only once per page.'
293
+ else
294
+ "Component definition collision: #{kind} (name=#{name.inspect}, page=#{page_rel})"
295
+ end
296
+ end
297
+
298
+ def build_injection(used_names, components,
299
+ page_inline_scripts: [], synthesized_names: nil, page_path: nil)
300
+ synthesized_names ||= Set.new
301
+
302
+ default_templates = render_default_templates(used_names, components, synthesized_names)
303
+ named_templates = render_named_templates(used_names, components)
304
+ scripts = @scripts_assembler.assemble(
305
+ used_names, components,
306
+ synthesized_names: synthesized_names,
307
+ page_inline_scripts: page_inline_scripts
308
+ )
309
+ bundle_scripts = compose_bundle_scripts(scripts, page_inline_scripts)
310
+ script_block, page_local_mrb = render_script_block(bundle_scripts, page_path)
311
+
312
+ # Live-reload client script is appended by the caller (the
313
+ # `inject_inline_page` / `inject_bundle_page` entry points)
314
+ # so we don't end up emitting it twice in :bundle mode where
315
+ # build_injection is called as a sub-step.
316
+ parts = [default_templates, named_templates, script_block].flatten.compact
317
+ Injection.new(html: parts.join("\n"), page_local_mrb: page_local_mrb)
318
+ end
319
+
320
+ # Emit one `<template><div data-component="X">...</div></template>`
321
+ # per used component, skipping synthesized in-memory components
322
+ # (their markup is already written inline on the page). The
323
+ # runtime registry consults these templates to fill empty
324
+ # `data-use="X"` elements at mount time.
325
+ def render_default_templates(used_names, components, synthesized_names)
326
+ used_names.reject { |n| synthesized_names.include?(n) }.map do |name|
327
+ parsed = @ctx.template_cache.fetch(name, components[name])
328
+ HtmlEmitter.render_default_template(parsed[:default_html])
329
+ end
330
+ end
331
+
332
+ def render_named_templates(used_names, components)
333
+ used_names.flat_map do |name|
334
+ parsed = @ctx.template_cache.fetch(name, components[name])
335
+ parsed[:named].map { |nt| HtmlEmitter.render_named_template(nt.name, nt.html) }
336
+ end
337
+ end
338
+
339
+ # Combine assembled component scripts with the page-inline ones
340
+ # in a target-aware way. Page-inline `<script type="text/ruby">`
341
+ # blocks join the bundle only on the compiled target — they're
342
+ # emitted last so any `Lilac.start` written there runs after the
343
+ # component class definitions. On :full they remain in the dist
344
+ # HTML body and the runtime parser picks them up via
345
+ # `vm.evalScript`, so duplicating them into the injected block
346
+ # would re-execute them.
347
+ #
348
+ # `Lilac.start` placement differs by target (decisions §20.6
349
+ # corrected: the compiled wasm has no parser, so post-load
350
+ # `vm.eval("Lilac.start")` is not available):
351
+ # - target=:compiled — append `Lilac.start` to the bundle so it
352
+ # executes as part of `loadBytecode`.
353
+ # - target=:full — do nothing here; the Pattern A boot helper
354
+ # (scaffold `boot.js`, lilac-full's CDN `boot`, …) runs
355
+ # `vm.eval("Lilac.start")` at the tail of its eval loop.
356
+ def compose_bundle_scripts(scripts, page_inline_scripts)
357
+ return scripts unless @ctx.target == :compiled
358
+
359
+ user_scripts = scripts + page_inline_scripts.reject { |s| s.strip.empty? }
360
+ user_scripts.empty? ? [] : user_scripts + ['Lilac.start']
361
+ end
362
+
363
+ # Returns `[script_block_html, page_local_mrb]`. Either element
364
+ # may be nil:
365
+ # - no scripts at all → both nil
366
+ # - :full → script_block is `<script type="text/ruby">…</script>`, mrb is nil
367
+ # - :compiled × :inline → script_block is the inline boot
368
+ # module that loads + boots the .mrb; mrb is nil
369
+ # - :compiled × :bundle → script_block is nil (caller chains
370
+ # the page-local mrb into the shared bundle boot module);
371
+ # page_local_mrb is the .mrb filename to chain.
372
+ def render_script_block(bundle_scripts, page_path)
373
+ return [nil, nil] if bundle_scripts.empty?
374
+
375
+ ruby_source = bundle_scripts.join("\n\n")
376
+ return [HtmlEmitter.render_script(ruby_source), nil] unless @ctx.target == :compiled
377
+
378
+ # :compiled — compile the aggregated Ruby to `.mrb` bytecode.
379
+ # The `data-lilac-bootstrap` attribute on the emitted module
380
+ # script marks the tag so a future asset-pipeline pass can
381
+ # rewrite the URLs.
382
+ label = page_path ? "page #{File.basename(page_path)}" : 'page bundle'
383
+ mrb_file = @ctx.bytecode_builder.build(ruby_source, source_label: label)
384
+
385
+ if @ctx.delivery == :bundle
386
+ # The page-local .mrb is chained into the same boot module
387
+ # as the bundle .mrb by the caller — emit no <script> here.
388
+ [nil, mrb_file]
389
+ else
390
+ boot = CompiledBootModule.render(
391
+ mrb_file,
392
+ package_urls: @ctx.package_dist_urls || [],
393
+ delivery: @ctx.delivery
394
+ )
395
+ [boot, nil]
396
+ end
397
+ end
398
+
399
+ def output_path_for(page_path)
400
+ rel = Pathname.new(page_path).relative_path_from(Pathname.new(@ctx.pages_dir))
401
+ File.join(@ctx.output_dir, rel.to_s)
402
+ end
403
+ end
404
+ end
405
+ end