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,129 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'fileutils'
4
+ require_relative 'html_emitter'
5
+
6
+ module Lilac
7
+ module CLI
8
+ # In :bundle delivery mode, emits all components' default templates,
9
+ # named templates, and Ruby scripts into a single
10
+ # `dist/lilac.bundle.html`. Pages reference it via
11
+ # `<link rel="lilac-bundle">`, and PageCompiler's boot module / the
12
+ # runtime registry fetches it before mount.
13
+ #
14
+ # For the :compiled target, scripts are NOT inlined into the bundle
15
+ # (the compiled wasm has no parser to evaluate text/ruby). Instead
16
+ # they are aggregated and compiled into a single `bundle.mrb`, plus
17
+ # a tiny standalone `start_only.mrb` (just `Lilac.start`) — both
18
+ # filenames ride on the returned `BundleAssets` so PageCompiler can
19
+ # chain them into each page's boot module.
20
+ #
21
+ # See decisions §20.6 (Lilac.start placement) and the bundle-fetch
22
+ # proposal for the rationale behind splitting bundle.mrb /
23
+ # start_only.mrb instead of appending `Lilac.start` to the bundle.
24
+ class BundleAssetWriter
25
+ # Build-level artifacts handed back to the orchestrator (Builder)
26
+ # and forwarded into the `BuildContext` PageCompiler reads. `url`
27
+ # is what gets stamped into each page's `<link rel="lilac-bundle">`;
28
+ # the two `.mrb` filenames are only populated when
29
+ # `target == :compiled` (otherwise scripts go into the bundle.html
30
+ # itself as <script type="text/ruby">).
31
+ BundleAssets = Struct.new(:url, :bundle_mrb, :start_only_mrb, keyword_init: true)
32
+
33
+ BUNDLE_FILENAME = 'lilac.bundle.html'
34
+ BUNDLE_URL = "/#{BUNDLE_FILENAME}"
35
+
36
+ def initialize(components:, template_cache:, target:,
37
+ output_dir:, bytecode_builder:)
38
+ @components = components
39
+ @template_cache = template_cache
40
+ @target = target
41
+ @output_dir = output_dir
42
+ @bytecode_builder = bytecode_builder
43
+ end
44
+
45
+ # Writes the bundle file and (for :compiled) compiles bundle.mrb +
46
+ # start_only.mrb. Returns a `BundleAssets`, or nil when there are
47
+ # no components to bundle.
48
+ def write!
49
+ return nil if @components.empty?
50
+
51
+ parts, compiled_scripts = assemble_parts
52
+ write_html_file!(parts)
53
+ bundle_mrb, start_only_mrb = compile_compiled_scripts!(compiled_scripts)
54
+
55
+ BundleAssets.new(
56
+ url: BUNDLE_URL,
57
+ bundle_mrb: bundle_mrb,
58
+ start_only_mrb: start_only_mrb
59
+ )
60
+ end
61
+
62
+ private
63
+
64
+ # Walks every component once, producing the HTML `parts` that go
65
+ # into bundle.html and the deferred `compiled_scripts` list that
66
+ # gets compiled to bytecode (only on :compiled target — :full puts
67
+ # the script bodies inline into `parts` as <script type="text/ruby">).
68
+ def assemble_parts
69
+ parts = []
70
+ compiled_scripts = []
71
+
72
+ @components.each do |name, comp|
73
+ parsed = @template_cache.fetch(name, comp)
74
+
75
+ # Default template
76
+ parts << "<template>#{parsed[:default_html]}</template>"
77
+
78
+ # Named templates (user-defined + synthetic data-each rows)
79
+ parsed[:named].each do |nt|
80
+ parts << HtmlEmitter.render_named_template(nt.name, nt.html)
81
+ end
82
+
83
+ # Scanner-canonical: emit the user script as-is (no codegen
84
+ # bindings). Skipped entirely when no user_script exists.
85
+ user_script = comp.script.strip
86
+ next if user_script.empty?
87
+
88
+ if @target == :compiled
89
+ # Defer: scripts get aggregated into one .mrb below.
90
+ compiled_scripts << user_script
91
+ else
92
+ parts << HtmlEmitter.render_script(user_script)
93
+ end
94
+ end
95
+
96
+ [parts, compiled_scripts]
97
+ end
98
+
99
+ def write_html_file!(parts)
100
+ FileUtils.mkdir_p(@output_dir)
101
+ bundle_path = File.join(@output_dir, BUNDLE_FILENAME)
102
+ File.write(bundle_path, parts.join("\n") + "\n")
103
+ end
104
+
105
+ # :compiled — compile aggregated scripts into a .mrb that the
106
+ # page boot module loads via loadBytecode. `Lilac.start` is NOT
107
+ # appended to the bundle: when a page has its own page-inline
108
+ # .mrb chained after the bundle, that one terminates with
109
+ # `Lilac.start`; when a page has only the bundle, we emit a
110
+ # tiny start-only .mrb so the chain still ends with `Lilac.start`
111
+ # running once after both class definitions and any page-local
112
+ # scripts are loaded.
113
+ def compile_compiled_scripts!(compiled_scripts)
114
+ return [nil, nil] unless @target == :compiled && !compiled_scripts.empty?
115
+
116
+ bundle_mrb = @bytecode_builder.build(
117
+ compiled_scripts.join("\n\n"), source_label: 'bundle'
118
+ )
119
+ # Standalone `Lilac.start` .mrb, shared by all pages that have
120
+ # no page-inline scripts. Content-hashed so cache reuses it
121
+ # across pages.
122
+ start_only_mrb = @bytecode_builder.build(
123
+ 'Lilac.start', source_label: 'bundle-start'
124
+ )
125
+ [bundle_mrb, start_only_mrb]
126
+ end
127
+ end
128
+ end
129
+ end
@@ -0,0 +1,212 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'digest'
4
+ require 'fileutils'
5
+ require 'open3'
6
+ require 'tmpdir'
7
+
8
+ require_relative 'build_error'
9
+ require_relative 'wasm_mrbc_driver'
10
+
11
+ module Lilac
12
+ module CLI
13
+ # Compiles aggregated Ruby source to mruby bytecode, then writes it
14
+ # under the build output with a content-hash filename so browsers
15
+ # cache-invalidate automatically when the bytes change.
16
+ #
17
+ # Used by `Builder` when `target == :compiled`. Owns the compile
18
+ # subprocess (or wasm driver) lifecycle and error translation;
19
+ # `Builder` keeps the HTML / vendor concerns.
20
+ #
21
+ # Backend dispatch — first hit wins:
22
+ #
23
+ # 1. explicit `mrbc_path:` (from `Lilac::CLI.configure { c.mrbc_path = ... }`
24
+ # or `--mrbc-path` CLI flag) → :binary
25
+ # 2. ENV["MRBC"] → :binary
26
+ # 3. ENV["MRUBY_WASM_RUNTIME_PATH"]/mruby/build/host/bin/mrbc → :binary
27
+ # 4. `lilac-wasm-bin` gem ships `mrbc-host.wasm` AND wasmtime-rb
28
+ # loadable AND module instantiates with required exports → :wasm
29
+ # 5. `mrbc` on $PATH → :binary
30
+ #
31
+ # The wasm-driven backend is the "default for end users" path — a
32
+ # scaffolded `lilac new` Gemfile pulls in `lilac-wasm-bin` and
33
+ # `wasmtime`, so `gem install lilac-cli && bundle install` is enough
34
+ # to make `lilac build --target compiled` work without any external
35
+ # binary. Explicit `mrbc_path` / ENV vars override (priority 1-3) so
36
+ # devs who built mruby themselves get the native path.
37
+ #
38
+ # If nothing resolves, raises BuildError pointing at the most likely
39
+ # fix paths.
40
+ class BytecodeBuilder
41
+ class Error < BuildError; end
42
+
43
+ # 8 hex chars (32 bits) — enough collision resistance for cache
44
+ # busting, short enough to keep filenames readable.
45
+ HASH_LENGTH = 8
46
+
47
+ def initialize(output_dir: nil, mrbc_path: nil, basename: 'app',
48
+ disable_gem_discovery: false)
49
+ @configured_mrbc_path = mrbc_path
50
+ @output_dir = output_dir
51
+ @basename = basename
52
+ # Mirrors `CompiledRuntimeResolver`'s kwarg — lets the bytecode
53
+ # builder's tests sandbox out the gem's `mrbc-host.wasm` when
54
+ # they want to assert specific discovery fallbacks. Production
55
+ # callers leave it false and the gem's wasm is picked up by
56
+ # default (priority #4).
57
+ @disable_gem_discovery = disable_gem_discovery
58
+ end
59
+
60
+ # Compile a Ruby source string into a `.mrb` file under
61
+ # `output_dir`. Returns the basename of the produced file (e.g.
62
+ # `"app.a3f29b21.mrb"`) so the caller can wire it into a fetch URL.
63
+ def build(ruby_source, source_label: '(aggregated)')
64
+ raise Error, '`build` requires `output_dir:` at construction time' unless @output_dir
65
+ FileUtils.mkdir_p(@output_dir)
66
+ bytecode = compile_to_bytes(ruby_source, source_label: source_label)
67
+ filename = "#{@basename}.#{content_hash(bytecode)}.mrb"
68
+ dest = File.join(@output_dir, filename)
69
+ File.binwrite(dest, bytecode)
70
+ filename
71
+ end
72
+
73
+ # Compile a Ruby source string into raw mruby bytecode (no write,
74
+ # no hashing). Used by `lilac package-build` which wants explicit
75
+ # control over output path / filename — it has no need for the
76
+ # content-hash cache-busting that `build` does for `.lil` apps.
77
+ def compile_to_bytes(ruby_source, source_label: '(aggregated)')
78
+ backend = resolve_backend!
79
+ case backend.first
80
+ when :binary then compile_via_binary(backend.last, ruby_source, source_label)
81
+ when :wasm then compile_via_wasm(backend.last, ruby_source, source_label)
82
+ end
83
+ end
84
+
85
+ # Diagnostic accessor: returns `[:binary, "/path"]` or
86
+ # `[:wasm, "/path/to/wasm"]` describing which backend `build` would
87
+ # select, or nil when nothing resolves. Doesn't raise — `lilac
88
+ # doctor` uses it to render the OK / WARN line; `build` calls
89
+ # `resolve_backend!` instead.
90
+ def resolve_backend
91
+ if (binary = resolve_mrbc_binary)
92
+ return [:binary, binary]
93
+ end
94
+ if (wasm = discoverable_mrbc_host_wasm) && WasmMrbcDriver.available?(wasm_path: wasm)
95
+ return [:wasm, wasm]
96
+ end
97
+ if (path_binary = path_lookup('mrbc'))
98
+ return [:binary, path_binary]
99
+ end
100
+
101
+ nil
102
+ end
103
+
104
+ # Resolved mrbc binary path for `lilac doctor` and back-compat.
105
+ # Nil when no binary is discoverable (gem-provided wasm doesn't
106
+ # count). Renamed-but-kept-as-alias for callers that pre-date the
107
+ # backend dispatch (e.g. existing tests).
108
+ def resolve_mrbc
109
+ resolve_mrbc_binary || path_lookup('mrbc')
110
+ end
111
+
112
+ private
113
+
114
+ # Discovery routes 1-3 from the docstring above — explicit
115
+ # arg / ENV / monorepo convention. The PATH fallback (#5) lives
116
+ # in `resolve_backend` so it's only consulted after the wasm
117
+ # backend has been tried.
118
+ def resolve_mrbc_binary
119
+ @configured_mrbc_path && File.executable?(@configured_mrbc_path) and return @configured_mrbc_path
120
+ if (env = ENV['MRBC']) && File.executable?(env)
121
+ return env
122
+ end
123
+
124
+ if (mwr = ENV['MRUBY_WASM_RUNTIME_PATH'])
125
+ candidate = File.join(mwr, 'mruby', 'build', 'host', 'bin', 'mrbc')
126
+ return candidate if File.executable?(candidate)
127
+ end
128
+ nil
129
+ end
130
+
131
+ def discoverable_mrbc_host_wasm
132
+ return nil if @disable_gem_discovery
133
+
134
+ require 'lilac/wasm/bin'
135
+ ::Lilac::Wasm::Bin.mrbc_host_wasm
136
+ rescue LoadError
137
+ nil
138
+ end
139
+
140
+ def resolve_backend!
141
+ resolve_backend || raise(Error, backend_not_found_message)
142
+ end
143
+
144
+ def compile_via_binary(mrbc, ruby_source, source_label)
145
+ Dir.mktmpdir('lilac-mrbc-') do |dir|
146
+ tmp_rb = File.join(dir, 'input.rb')
147
+ tmp_mrb = File.join(dir, 'input.mrb')
148
+ File.write(tmp_rb, ruby_source)
149
+
150
+ stdout, stderr, status = Open3.capture3(mrbc, '-o', tmp_mrb, tmp_rb)
151
+ raise Error, binary_error_message(source_label, stdout, stderr, status) unless status.success?
152
+
153
+ File.binread(tmp_mrb)
154
+ end
155
+ end
156
+
157
+ def compile_via_wasm(wasm_path, ruby_source, source_label)
158
+ # Reuse the driver across `build` calls within a single
159
+ # invocation — engine + module + instance setup is the slow part
160
+ # (~200-500ms cold) and is amortized over every component.
161
+ @wasm_driver ||= WasmMrbcDriver.new(wasm_path: wasm_path)
162
+ @wasm_driver.compile(ruby_source)
163
+ rescue WasmMrbcDriver::CompileError => e
164
+ raise Error, "mrbc-host.wasm failed compiling #{source_label}:\n#{e.message}"
165
+ rescue WasmMrbcDriver::WasmtimeMissingError, WasmMrbcDriver::WasmExportMissingError => e
166
+ # Shouldn't reach here under normal flow — `resolve_backend!`
167
+ # only returns `:wasm` after `available?` succeeded — but if the
168
+ # wasm path goes stale mid-build (e.g. file removed), surface
169
+ # the typed error rather than crashing.
170
+ raise Error, "wasm mrbc backend unavailable: #{e.message}"
171
+ end
172
+
173
+ def content_hash(bytes)
174
+ Digest::SHA256.hexdigest(bytes)[0, HASH_LENGTH]
175
+ end
176
+
177
+ # Walk $PATH for `name`. Falls back to nil when not found —
178
+ # callers translate to a user-facing error with
179
+ # `backend_not_found_message`.
180
+ def path_lookup(name)
181
+ (ENV['PATH'] || '').split(File::PATH_SEPARATOR).each do |dir|
182
+ candidate = File.join(dir, name)
183
+ return candidate if File.executable?(candidate) && !File.directory?(candidate)
184
+ end
185
+ nil
186
+ end
187
+
188
+ def backend_not_found_message
189
+ <<~MSG.strip
190
+ No mrbc backend found. Tried (in priority order):
191
+ 1. configured `c.mrbc_path` / `--mrbc-path`
192
+ 2. ENV["MRBC"]
193
+ 3. ENV["MRUBY_WASM_RUNTIME_PATH"]/mruby/build/host/bin/mrbc
194
+ 4. lilac-wasm-bin gem's mrbc-host.wasm (wasmtime-rb driven)
195
+ 5. `mrbc` on $PATH
196
+
197
+ To fix, either:
198
+ • Add `gem "lilac-wasm-bin"` and `gem "wasmtime"` to your Gemfile
199
+ (recommended — the scaffolded Gemfile from `lilac new` already does this)
200
+ • Set ENV["MRBC"]=/abs/path/to/mrbc
201
+ • Set ENV["MRUBY_WASM_RUNTIME_PATH"] to a built mruby-wasm-runtime checkout
202
+ • Add `c.mrbc_path = "/abs/path"` to lilac.config.rb
203
+ • Put `mrbc` on your $PATH (e.g. build mruby locally)
204
+ MSG
205
+ end
206
+
207
+ def binary_error_message(source_label, _stdout, stderr, status)
208
+ "mrbc failed (exit=#{status.exitstatus}) compiling #{source_label}:\n#{stderr.strip}"
209
+ end
210
+ end
211
+ end
212
+ end
@@ -0,0 +1,103 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Lilac
4
+ module CLI
5
+ # Emits the `<script type="module">` boot stub for the `:compiled`
6
+ # target: it loads `.mrb` bytecode, boots the lilac-compiled wasm,
7
+ # and (in :bundle delivery) pulls the bundle's `<template>` elements
8
+ # into the live document before `Lilac.start` runs.
9
+ #
10
+ # Split out of PageCompiler so the JS-string generation lives next to
11
+ # HtmlEmitter's markup-emitting peers rather than mixing into the
12
+ # per-page compile flow.
13
+ module CompiledBootModule
14
+ module_function
15
+
16
+ # Decide the .mrb load order for a :compiled × :bundle page's boot
17
+ # module. The bundle .mrb always loads first (component class
18
+ # definitions, no `Lilac.start`). The tail is either the page-local
19
+ # .mrb (which itself ends with `Lilac.start`) or the shared
20
+ # `start-only.mrb` fallback so every page's chain terminates with
21
+ # `Lilac.start`.
22
+ def mrb_chain(bundle_assets, page_local_mrb)
23
+ return [] unless bundle_assets
24
+
25
+ chain = []
26
+ chain << bundle_assets.bundle_mrb if bundle_assets.bundle_mrb
27
+ if page_local_mrb
28
+ chain << page_local_mrb
29
+ elsif bundle_assets.bundle_mrb && bundle_assets.start_only_mrb
30
+ chain << bundle_assets.start_only_mrb
31
+ end
32
+ chain
33
+ end
34
+
35
+ # Emits the module script that loads `.mrb` bytecode and boots
36
+ # the lilac-compiled wasm. Inlines the boot logic instead of
37
+ # depending on `@takahashim/lilac-compiled`'s published `index.js`
38
+ # — the npm boot helper has occasionally drifted from the bridge's
39
+ # current API (e.g. `loadIrep` rename → `loadBytecode`) and a
40
+ # self-contained module is one fewer moving part to keep in sync.
41
+ # The `data-lilac-bootstrap` attribute marks the tag so a future
42
+ # asset-pipeline pass can rewrite the URLs.
43
+ #
44
+ # `Lilac.start` is NOT called here via `vm.eval`: the compiled
45
+ # wasm excludes `mruby-compiler` / `mruby-eval`, so post-load
46
+ # eval of arbitrary Ruby source is unsupported. Instead the
47
+ # builder appends `Lilac.start` to the bundle so it runs as part
48
+ # of `loadBytecode` (decisions §20.6 caveat).
49
+ def render(mrb_filenames, package_urls: [], delivery: :inline)
50
+ # Accept either a single filename or an array (used by the
51
+ # :bundle delivery path to chain bundle.mrb + page-inline.mrb in
52
+ # one VM).
53
+ mrb_filenames = Array(mrb_filenames)
54
+
55
+ # Package `.mrb` bundles load BEFORE the user bytecode so any
56
+ # `Scanner.register("ClassName")` calls (and the Handler classes
57
+ # they refer to) are ready by the time component mount runs.
58
+ # Mirrors the load ordering in `npm/lilac-compiled/index.js`'s
59
+ # boot helper.
60
+ package_loads = package_urls.map do |url|
61
+ "vm.loadBytecode(new Uint8Array(await (await fetch(#{url.inspect})).arrayBuffer()));"
62
+ end
63
+ bytecode_loads = mrb_filenames.map do |filename|
64
+ %(vm.loadBytecode(new Uint8Array(await (await fetch("./#{filename}")).arrayBuffer())));
65
+ end
66
+ all_loads = (package_loads + bytecode_loads).join("\n ")
67
+
68
+ # :bundle delivery: the compiled wasm has no parser, so unlike
69
+ # the :full boot helper we can't `vm.eval` bundle scripts —
70
+ # those land in the chained .mrb above. But we still need to
71
+ # pull the bundle's <template> elements into the live document
72
+ # before `Lilac.start` runs (which is in the page-local /
73
+ # start-only .mrb). Fetch + DOMParser the bundle and append
74
+ # each <template>; scripts inside the bundle are intentionally
75
+ # ignored (they don't exist for :compiled bundles).
76
+ bundle_block =
77
+ if delivery == :bundle
78
+ <<~JS.chomp.gsub(/^/, ' ')
79
+ for (const link of document.querySelectorAll('link[rel="lilac-bundle"]')) {
80
+ const res = await fetch(link.getAttribute("href"));
81
+ const doc = new DOMParser().parseFromString(await res.text(), "text/html");
82
+ for (const tpl of doc.querySelectorAll("template")) {
83
+ document.body.appendChild(tpl.cloneNode(true));
84
+ }
85
+ }
86
+ JS
87
+ end
88
+
89
+ body_parts = []
90
+ body_parts << bundle_block if bundle_block
91
+ body_parts << " #{all_loads}" unless all_loads.empty?
92
+
93
+ <<~HTML.strip
94
+ <script type="module" data-lilac-bootstrap>
95
+ import { createVM } from "./vendor/lilac-compiled/mruby-wasm-js/index.js";
96
+ const vm = await createVM({ wasm: "./vendor/lilac-compiled/lilac.wasm" });
97
+ #{body_parts.join("\n")}
98
+ </script>
99
+ HTML
100
+ end
101
+ end
102
+ end
103
+ end
@@ -0,0 +1,64 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "runtime_resolver"
4
+
5
+ module Lilac
6
+ module CLI
7
+ # Locates the `target: :compiled` runtime that gets vendored into
8
+ # `dist/vendor/lilac-compiled/`:
9
+ #
10
+ # * `lilac.wasm` — the compiled-variant wasm bundle (vendored
11
+ # name; the monorepo/gem source is
12
+ # `lilac-compiled.wasm`)
13
+ # * `mruby-wasm-js/` — the JS↔mruby bridge (resolved by the shared
14
+ # base — same artifact as the :full target)
15
+ #
16
+ # The boot module is rendered inline by the builder
17
+ # (`render_compiled_boot_module`); we don't ship the npm package's
18
+ # `index.js`. Only the wasm-specific discovery differs from `:full`,
19
+ # so this class supplies just those hooks. See `RuntimeResolver`.
20
+ class CompiledRuntimeResolver < RuntimeResolver
21
+ # Distinct from FullRuntimeResolver::Error; both < RuntimeResolver::Error.
22
+ class Error < RuntimeResolver::Error; end
23
+
24
+ def initialize(lilac_compiled_path: nil, **opts)
25
+ super(lilac_wasm_path: lilac_compiled_path, **opts)
26
+ end
27
+
28
+ private
29
+
30
+ def wasm_env_key
31
+ "LILAC_COMPILED_WASM"
32
+ end
33
+
34
+ def gem_provided_wasm
35
+ return nil if @disable_gem_discovery
36
+ require "lilac/wasm/bin"
37
+ ::Lilac::Wasm::Bin.lilac_compiled_wasm
38
+ rescue LoadError
39
+ nil
40
+ end
41
+
42
+ def monorepo_wasm_candidate
43
+ File.join(monorepo_root, "build", "lilac-compiled.wasm")
44
+ end
45
+
46
+ def wasm_not_found_message
47
+ <<~MSG.strip
48
+ lilac-compiled.wasm not found. Tried:
49
+ • configured `c.lilac_compiled_path` (#{@configured_wasm_path.inspect})
50
+ • ENV["LILAC_COMPILED_WASM"] (#{ENV["LILAC_COMPILED_WASM"].inspect})
51
+ • lilac-wasm-bin gem (not on load path)
52
+ • monorepo: #{monorepo_wasm_candidate}
53
+
54
+ To fix, either:
55
+ • Add `gem "lilac-wasm-bin"` to your Gemfile (recommended — the scaffolded Gemfile from `lilac new` already includes it)
56
+ • Pass `--lilac-compiled-path /abs/path/to/lilac.wasm` on the command line
57
+ • Add `c.lilac_compiled_path = "/abs/path"` to lilac.config.rb
58
+ • Set ENV["LILAC_COMPILED_WASM"]
59
+ • In the monorepo: run `make lilac-compiled` to produce build/lilac-compiled.wasm
60
+ MSG
61
+ end
62
+ end
63
+ end
64
+ end
@@ -0,0 +1,57 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Lilac
4
+ module CLI
5
+ # Value object for a `.lil` component's kebab-case name. Holds the
6
+ # raw string and exposes the derived forms the build pipeline
7
+ # needs:
8
+ #
9
+ # name = ComponentName.new("admin--user-card")
10
+ # name.ruby_class # => "Admin::UserCard"
11
+ # name.to_s # => "admin--user-card"
12
+ #
13
+ # `--` separates namespace segments, `-` separates words within a
14
+ # segment. Empty segments (leading/trailing `--`, double `-` between
15
+ # words) raise at construction so misnamed files fail loudly rather
16
+ # than emitting `::Foo` / `Foo::` downstream.
17
+ class ComponentName
18
+ attr_reader :kebab
19
+
20
+ def initialize(kebab)
21
+ @kebab = kebab.to_s
22
+ @ruby_class = build_ruby_class
23
+ end
24
+
25
+ def ruby_class
26
+ @ruby_class
27
+ end
28
+
29
+ def to_s
30
+ @kebab
31
+ end
32
+
33
+ def ==(other)
34
+ other.is_a?(ComponentName) && other.kebab == @kebab
35
+ end
36
+ alias_method :eql?, :==
37
+
38
+ def hash
39
+ @kebab.hash
40
+ end
41
+
42
+ private
43
+
44
+ def build_ruby_class
45
+ @kebab.split("--", -1).map { |segment|
46
+ raise ArgumentError, "Invalid component name: #{@kebab.inspect} (empty namespace segment)" if segment.empty?
47
+
48
+ segment.split("-").map { |word|
49
+ raise ArgumentError, "Invalid component name: #{@kebab.inspect} (empty word segment)" if word.empty?
50
+
51
+ word[0].upcase + (word[1..] || "")
52
+ }.join
53
+ }.join("::")
54
+ end
55
+ end
56
+ end
57
+ end
@@ -0,0 +1,76 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'component_name'
4
+ require_relative '../lint/cross_ref_linter'
5
+
6
+ module Lilac
7
+ module CLI
8
+ # Per-page assembly of the Ruby source one bundle script contains:
9
+ # the user-authored `<script>` body of every component used on the
10
+ # page. Binding is scanner-canonical (the runtime scanner wires
11
+ # `data-*` at mount), so no `Lilac::Bindings::<Class>` codegen is
12
+ # emitted here — this class only runs `CrossRefLinter` (to surface
13
+ # signal/method mismatches as build errors) and returns the user
14
+ # script unchanged.
15
+ #
16
+ # Used only by `PageCompiler` (per-page injection). The :bundle
17
+ # delivery writer takes a leaner path through `BundleAssetWriter`
18
+ # because the bundle file gathers ALL components rather than the
19
+ # "used on this page" subset, and skips the lint pass entirely.
20
+ class ComponentScriptsAssembler
21
+ def initialize(template_cache:)
22
+ @template_cache = template_cache
23
+ end
24
+
25
+ # Returns `Array<String>` — one Ruby source chunk per component
26
+ # that had a non-empty user script. Order matches `used_names`
27
+ # so that any user-side dependency on declaration order (e.g.
28
+ # subclass after parent) is preserved across the bundle.
29
+ #
30
+ # `synthesized_names` marks components that originate from a
31
+ # page-inline `<X data-component=...>` (their `comp.script` slot
32
+ # is empty by construction; the actual class body lives in the
33
+ # page's `<script type="text/ruby">` blocks). For those the
34
+ # cross-ref linter reads the page-level inline Ruby instead so
35
+ # `@signal` / `def method` lookups resolve.
36
+ def assemble(used_names, components, synthesized_names:, page_inline_scripts:)
37
+ synth_lint_script = page_inline_scripts.join("\n\n")
38
+ used_names.map do |name|
39
+ assemble_one(name, components[name], synthesized_names, synth_lint_script)
40
+ end.reject(&:empty?)
41
+ end
42
+
43
+ private
44
+
45
+ def assemble_one(name, comp, synthesized_names, synth_lint_script)
46
+ parsed = @template_cache.fetch(name, comp)
47
+ user_script = comp.script.strip
48
+ lint_script = synthesized_names.include?(name) ? synth_lint_script : user_script
49
+
50
+ run_lint!(name, parsed, lint_script)
51
+
52
+ # Scanner-canonical: no codegen module is emitted. The user
53
+ # script is returned as-is; the runtime scanner wires the
54
+ # component's `data-*` directives at mount.
55
+ user_script
56
+ end
57
+
58
+ # Cross-reference lint surfaces signal/method mismatches as build
59
+ # errors before the bytecode pass. Errors raise — warnings go to
60
+ # stderr and the build carries on inside CrossRefLinter.
61
+ def run_lint!(name, parsed, lint_script)
62
+ result = CrossRefLinter.lint(
63
+ script_text: lint_script,
64
+ directives: parsed[:default_directives],
65
+ refs_map: parsed[:default_refs_map],
66
+ component_name: ComponentName.new(name).ruby_class,
67
+ file: parsed[:source_path] ? File.basename(parsed[:source_path]) : '(template)'
68
+ )
69
+ return unless result.errors?
70
+
71
+ raise Builder::Error,
72
+ "build failed: #{result.errors} lint error(s) in template; see warnings above."
73
+ end
74
+ end
75
+ end
76
+ end