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
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: fe398ae7cd444978bcce0e26b56f755b7f75de66aa63e62a1cda3729c4bbba06
4
+ data.tar.gz: a0bfd3e47f5d61af27b30e0c362aaf3a24154a4f1fb27f56c2b1efd2154d66ec
5
+ SHA512:
6
+ metadata.gz: b2edc6ad8d25033f378d941fd230134d11927f247d4e1eb8878f4b9e5c5a39cfd59ceee489864a8b697f730d6420392a21141f818c08281ef23091c8740b510f
7
+ data.tar.gz: 32e2e3ee8d5ef6e4dbb3763f7dbb8a9f67eeb6f1faeebd687773147a2db86349bbed58993190ae62f8da9e4e5c245d352df80ba90e96b35f30d824b18310b780
data/LICENSE.txt ADDED
@@ -0,0 +1,21 @@
1
+ The MIT License (MIT)
2
+
3
+ Copyright (c) 2026 takahashim
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in
13
+ all copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
21
+ THE SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,144 @@
1
+ # lilac-cli
2
+
3
+ Optional build tool for **Lilac single-file components** (`.lil`) —
4
+ template + Ruby script in one file, compiled to static HTML for the
5
+ Lilac component runtime on mruby-wasm.
6
+
7
+ The runtime is the canonical interpreter of `data-*` directives, so
8
+ Lilac apps can be shipped as plain HTML without this CLI (see the
9
+ repo-root README's Quick start). What lilac-cli adds on top:
10
+
11
+ - **Static lint** for directive grammar / undeclared signals / dead
12
+ methods, caught at build time with source positions.
13
+ - **`.lil` single-file component** format + project structure
14
+ (`components/` + `pages/`) and `<div data-use="..."></div>`
15
+ placeholders.
16
+ - **Pre-compiled bindings** (`codegen: :auto`, default) — directive
17
+ interpretation moves from mount time to build time; the generated
18
+ `Lilac::Bindings::<Class>#bind_template_hook` takes precedence over
19
+ the runtime scanner, so there's no double-binding when both paths
20
+ coexist on the same page. Set `codegen: :off` to validate the
21
+ runtime path against the same `.lil` source.
22
+ - **Dev server** with live reload, `scaffold`, `doctor`.
23
+
24
+ > [!WARNING]
25
+ > Pre-alpha. API and CLI surface will change.
26
+
27
+ ## Status
28
+
29
+ | Phase | Status |
30
+ |---|---|
31
+ | 1. `.lil` parser + `lilac build` (static output) | ✅ |
32
+ | 2. `lilac dev` (file watch via `listen`) | ✅ |
33
+ | 3. SSE live reload (via `Wsv::Response.sse`) | ✅ |
34
+ | 4. `lilac new` scaffold | ✅ |
35
+ | 5. `lilac doctor` setup verifier | ✅ |
36
+ | 6. `lilac.config.rb` project config | ✅ |
37
+
38
+ ## What is a `.lil` file?
39
+
40
+ A single-file component containing markup, optional sub-templates, and a
41
+ Ruby script that defines a `Lilac::Component` subclass:
42
+
43
+ ```html
44
+ <!-- components/counter.lil -->
45
+ <template>
46
+ <div data-component="counter">
47
+ <button data-ref="inc">+</button>
48
+ <span data-ref="count">0</span>
49
+ </div>
50
+ </template>
51
+
52
+ <!-- Sub-templates use `data-template="..."` — same convention the
53
+ Lilac runtime uses for `bind_list` clone-targets. -->
54
+ <template data-template="counter-banner">
55
+ <div class="banner"></div>
56
+ </template>
57
+
58
+ <script type="text/ruby">
59
+ class Counter < Lilac::Component
60
+ def setup
61
+ @count = signal(0)
62
+ refs.inc.on(:click) { @count.update(&:succ) }
63
+ bind refs.count, text: @count
64
+ end
65
+ end
66
+ </script>
67
+ ```
68
+
69
+ `.lil` is HTML5-valid (use a `.lil → text/html` association in your
70
+ editor for syntax highlighting). The build tool extracts the top-level
71
+ `<template>` and `<script type="text/ruby">` blocks; everything inside
72
+ each block is preserved verbatim.
73
+
74
+ `lilac build` reads `components/*.lil` + `pages/*.html`, inlines the
75
+ referenced component templates and scripts into each page, and writes the
76
+ result to `dist/`.
77
+
78
+ The kebab-to-CamelCase autoregister feature in Lilac means
79
+ `<div data-component="counter"></div>` resolves to the `Counter` class with
80
+ no `Lilac.register` call needed.
81
+
82
+ ## Installation
83
+
84
+ ```sh
85
+ gem install lilac-cli
86
+ ```
87
+
88
+ Or in a Gemfile:
89
+
90
+ ```ruby
91
+ group :development do
92
+ gem "lilac-cli"
93
+ end
94
+ ```
95
+
96
+ Requires Ruby 3.2 or later.
97
+
98
+ ## Usage
99
+
100
+ ```sh
101
+ lilac new my-app # scaffold a fresh project (Counter sample inside)
102
+ cd my-app && bundle install
103
+
104
+ lilac doctor # check project setup (runtime, component refs, paths)
105
+
106
+ lilac build # produce dist/ (default --target compiled — requires mrbc)
107
+ lilac build --target full # opt out: ship runtime parser in dist (mrbc-free)
108
+ lilac build --output _site
109
+
110
+ lilac dev # build + serve + live reload at http://127.0.0.1:5173
111
+ # (default --target full so no mrbc needed for iteration)
112
+ lilac dev --port 8000 # bind to a different port
113
+
114
+ lilac preview # serve the built dist/ at http://127.0.0.1:4173
115
+ # (no watch / no reload — use to verify production output)
116
+ ```
117
+
118
+ ### Configuration
119
+
120
+ Project-wide defaults can live in `lilac.config.rb` at the project
121
+ root (a commented template is generated by `lilac new`):
122
+
123
+ ```ruby
124
+ Lilac::CLI.configure do |c|
125
+ c.components_dir = "src/components"
126
+ c.pages_dir = "src/pages"
127
+ c.dev_port = 3000
128
+ # c.codegen = :off # skip bind_template_hook codegen and let
129
+ # the runtime scanner interpret directives
130
+ # at mount time (parity-testing or
131
+ # runtime-only deployment).
132
+ end
133
+ ```
134
+
135
+ Precedence (lowest to highest): built-in defaults → `lilac.config.rb`
136
+ → CLI flags (`--components`, `--port`, etc.).
137
+
138
+ `lilac dev` watches `components/**/*.lil` and `pages/**/*.html` for
139
+ changes, rebuilds on save, and pushes a `reload` event over SSE to every
140
+ connected browser tab.
141
+
142
+ ## License
143
+
144
+ MIT. See [`LICENSE.txt`](./LICENSE.txt).
data/exe/lilac ADDED
@@ -0,0 +1,6 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ require_relative "../lib/lilac/cli"
5
+
6
+ exit Lilac::CLI::Command.new(ARGV).run
@@ -0,0 +1,31 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Lilac
4
+ module CLI
5
+ # Per-build shared state handed from `Builder` (orchestrator) to
6
+ # `PageCompiler` (per-page compiler). All fields are set before the
7
+ # page loop starts, so PageCompiler treats the struct as read-only;
8
+ # the cache / linter references inside it accumulate per-page state
9
+ # via their own APIs.
10
+ #
11
+ # Fields:
12
+ # - `components` — { name => SFC::Component }, .lil + synthesized
13
+ # - `bundle_assets` — BundleAssetWriter::BundleAssets or nil (:inline mode)
14
+ # - `package_dist_urls` — Array<String> page-relative URLs for `.mrb` packages
15
+ # - `template_cache` — shared TemplateASTCache (parse once, reuse)
16
+ # - `build_linter` — BuildLinter accumulating cross-page diagnostics
17
+ # - `bytecode_builder` — BytecodeBuilder for compiled-target `.mrb` emission
18
+ # - `target` — :full | :compiled
19
+ # - `delivery` — :inline | :bundle
20
+ # - `live_reload` — boolean (dev path injects SSE client script)
21
+ # - `output_dir` — absolute dist path (relative refs in boot module need this)
22
+ # - `pages_dir` — absolute pages root (for computing output paths)
23
+ BuildContext = Struct.new(
24
+ :components, :bundle_assets, :package_dist_urls,
25
+ :template_cache, :build_linter, :bytecode_builder,
26
+ :target, :delivery, :live_reload,
27
+ :output_dir, :pages_dir,
28
+ keyword_init: true
29
+ )
30
+ end
31
+ end
@@ -0,0 +1,50 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Lilac
4
+ module CLI
5
+ # Common base + formatter for build-time errors. Wraps a structured
6
+ # message into the multi-line shape:
7
+ #
8
+ # lilac: build error in <file>:<line>
9
+ # <snippet> # optional
10
+ # <message body>
11
+ # <suggestion> # optional
12
+ #
13
+ # The bare-string form (`raise BuildError, "..."`) is supported so
14
+ # callers that don't have a location on hand can still raise, but
15
+ # the formatted shape only kicks in when `at:` is provided.
16
+ #
17
+ # Subclassed by `Lilac::Directives::Lints::Error`.
18
+ class BuildError < StandardError
19
+ attr_reader :at, :snippet, :suggestion
20
+
21
+ def initialize(message = nil, at: nil, snippet: nil, suggestion: nil)
22
+ @at = at
23
+ @snippet = snippet
24
+ @suggestion = suggestion
25
+ @body = message
26
+ super(format)
27
+ end
28
+
29
+ private
30
+
31
+ # Two paths: structured (location known → spec header + indented
32
+ # body) vs bare (no location → return body verbatim, mostly used
33
+ # by code paths that have no source coordinates).
34
+ def format
35
+ return @body unless @at
36
+
37
+ parts = ["lilac: build error in #{@at}"]
38
+ parts << " #{@snippet}" if @snippet
39
+ # Indent each body line so multi-line messages stay aligned.
40
+ if @body
41
+ @body.to_s.each_line do |l|
42
+ parts << " #{l.chomp}"
43
+ end
44
+ end
45
+ parts << " #{@suggestion}" if @suggestion
46
+ parts.join("\n")
47
+ end
48
+ end
49
+ end
50
+ end
@@ -0,0 +1,326 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'fileutils'
4
+ require 'pathname'
5
+ require_relative 'sfc'
6
+ require_relative 'bytecode_builder'
7
+ require_relative 'html_emitter'
8
+ require_relative 'template_ast_cache'
9
+ require_relative 'build_context'
10
+ require_relative 'bundle_asset_writer'
11
+ require_relative 'package_stager'
12
+ require_relative 'vendor_writer'
13
+ require_relative 'compiled_runtime_resolver'
14
+ require_relative 'full_runtime_resolver'
15
+ require_relative 'page_compiler'
16
+ require_relative '../lint/build_linter'
17
+
18
+ module Lilac
19
+ module CLI
20
+ # Orchestrates a full build: loads components, stages packages,
21
+ # writes the (optional) bundle file, compiles each page via
22
+ # `PageCompiler`, then vendors runtime assets into `dist/vendor/`.
23
+ #
24
+ # The per-page rewriting logic lives in `PageCompiler`; HTML helpers
25
+ # in `HtmlEmitter`; template parse caching in `TemplateASTCache`;
26
+ # all of those share state through the `BuildContext` value object
27
+ # this class assembles.
28
+ class Builder
29
+ class Error < StandardError; end
30
+
31
+ # Filenames that must not land in the build output even when they
32
+ # exist under `public/`. Add to this list as new conventions are
33
+ # encountered (e.g. `.DS_Store`, `Thumbs.db`).
34
+ EXCLUDED_BASENAMES = %w[.gitkeep].freeze
35
+
36
+ # Target-specific public/ subdirectories that should NOT be mirrored
37
+ # into dist/ for the inactive target. Both runtime variants live
38
+ # under their own namespace in `public/vendor/` so a project that
39
+ # ships both (e.g. dev=full, prod=compiled) can keep them side by
40
+ # side and let the CLI prune the inactive one at build time.
41
+ #
42
+ # Paths are relative to `public_dir`, POSIX style. The match is on
43
+ # path prefix + boundary, so `vendor/lilac-full` matches
44
+ # `vendor/lilac-full/lilac-full.wasm` but not `vendor/lilac-full-x`.
45
+ EXCLUDED_DIRS_FOR_TARGET = {
46
+ full: %w[vendor/lilac-compiled].freeze,
47
+ compiled: %w[vendor/lilac-full].freeze
48
+ }.freeze
49
+
50
+ # Construct a Builder from a resolved `Config`. Centralises the
51
+ # Config-attr → Builder-kwarg mapping so callers in `Command` and
52
+ # `DevServer` don't drift out of sync. Overridable kwargs cover
53
+ # the per-caller differences:
54
+ #
55
+ # - `target:` — Command picks `config.build_target`, DevServer
56
+ # picks `config.dev_target`. Defaults to the build target.
57
+ # - `delivery:` — either caller can pin a value here; both
58
+ # currently leave it unset so `config.delivery` wins (build
59
+ # and dev share the same delivery path, matching what ships
60
+ # to prod).
61
+ # - `live_reload:` — DevServer turns it on for SSE-driven page
62
+ # reload; `lilac build` leaves it off.
63
+ def self.from_config(config, live_reload: false,
64
+ target: nil, delivery: nil)
65
+ new(
66
+ components_dir: config.components_dir,
67
+ pages_dir: config.pages_dir,
68
+ output_dir: config.output_dir,
69
+ public_dir: config.public_dir,
70
+ target: target || config.build_target,
71
+ mrbc_path: config.mrbc_path,
72
+ lilac_compiled_path: config.lilac_compiled_path,
73
+ lilac_full_path: config.lilac_full_path,
74
+ mruby_wasm_js_path: config.mruby_wasm_js_path,
75
+ packages: config.packages,
76
+ delivery: delivery || config.delivery,
77
+ live_reload: live_reload
78
+ )
79
+ end
80
+
81
+ def initialize(components_dir:, pages_dir:, output_dir:, public_dir: nil,
82
+ live_reload: false,
83
+ target: :full, mrbc_path: nil,
84
+ lilac_compiled_path: nil, lilac_full_path: nil,
85
+ mruby_wasm_js_path: nil,
86
+ packages: [],
87
+ disable_gem_discovery: false,
88
+ delivery: :inline)
89
+ @components_dir = components_dir
90
+ @pages_dir = pages_dir
91
+ @output_dir = output_dir
92
+ # public_dir is optional. When nil or absent on disk, the
93
+ # mirroring step is skipped — projects that don't need static
94
+ # passthrough (no vendor bundle, no images) work fine without
95
+ # creating the directory.
96
+ @public_dir = public_dir
97
+ @live_reload = live_reload
98
+ # `:full` — dist HTML loads inline Ruby via lilac-full wasm
99
+ # (vm.evalScript). `:compiled` — Ruby is pre-compiled to
100
+ # `.mrb` bytecode via `mrbc` and loaded by lilac-compiled wasm
101
+ # (vm.loadBytecode). The compiled target shaves ~32% off the brotli
102
+ # bundle but requires `mrbc` available at build time. See
103
+ # `BytecodeBuilder` for path discovery.
104
+ @target = target
105
+ @mrbc_path = mrbc_path
106
+ # Discovery hints for the compiled runtime — wasm + boot helper
107
+ # + JS bridge. Used by `auto_vendor_compiled_runtime!` so the
108
+ # built dist is fully self-contained and no manual cp into
109
+ # `public/vendor/lilac-compiled/` is required.
110
+ @lilac_compiled_path = lilac_compiled_path
111
+ # Discovery hint for the full runtime wasm — parallel to
112
+ # `@lilac_compiled_path`. Used by `auto_vendor_full_runtime!`
113
+ # via `FullRuntimeResolver` so a `:full` build is reliably
114
+ # self-contained (offline-runnable) without the CDN.
115
+ @lilac_full_path = lilac_full_path
116
+ @mruby_wasm_js_path = mruby_wasm_js_path
117
+ # Pre-compiled Lilac package `.mrb` paths (absolute). For both
118
+ # `:compiled` and `:full` builds these get staged under
119
+ # `dist/packages/`; `:compiled` injects loadBytecode into the
120
+ # generated boot module directly, `:full` writes a
121
+ # `dist/lilac.packages.json` manifest the scaffold boot fetches.
122
+ # See decisions §25 / §26.
123
+ @packages = Array(packages).map { |p| File.expand_path(p) }
124
+ # Mirrors `CompiledRuntimeResolver` / `BytecodeBuilder`'s
125
+ # `disable_gem_discovery:` — tests pass `true` so the gem-bundled
126
+ # wasm doesn't satisfy lookups they're trying to isolate. Plumbed
127
+ # through to both resolvers below.
128
+ @disable_gem_discovery = disable_gem_discovery
129
+ # `:inline` (default) — inject component definitions into each
130
+ # page's HTML. `:bundle` — emit a single dist/lilac.bundle.html
131
+ # referenced from pages via `<link rel="lilac-bundle">`. Runtime
132
+ # registry fetches the bundle and injects templates + evals
133
+ # scripts before mount.
134
+ @delivery = delivery
135
+ end
136
+
137
+ def build
138
+ components = load_components
139
+ pages = Dir.glob(File.join(@pages_dir, '**', '*.html'))
140
+ raise Error, "No pages found under #{@pages_dir.inspect}" if pages.empty?
141
+
142
+ public_files = mirror_public_files
143
+
144
+ template_cache = TemplateASTCache.new
145
+ build_linter = BuildLinter.new
146
+ # Resolve and stage package `.mrb` files once for the build —
147
+ # the URLs are stable across pages so each page's boot module
148
+ # can reference the same set. PackageStager owns both the
149
+ # discovered + explicit input channels and the :full-only
150
+ # `lilac.packages.json` manifest emission.
151
+ package_dist_urls = PackageStager.new(
152
+ packages: @packages,
153
+ target: @target,
154
+ output_dir: @output_dir,
155
+ bytecode_builder: bytecode_builder
156
+ ).run!
157
+
158
+ # In :bundle delivery mode, emit a single dist/lilac.bundle.html
159
+ # containing all components' templates + scripts. Pages then
160
+ # reference it via <link rel="lilac-bundle">. Done once before
161
+ # page processing so PageCompiler can inject the <link>.
162
+ bundle_assets =
163
+ if @delivery == :bundle
164
+ BundleAssetWriter.new(
165
+ components: components,
166
+ template_cache: template_cache,
167
+ target: @target,
168
+ output_dir: @output_dir,
169
+ bytecode_builder: bytecode_builder
170
+ ).write!
171
+ end
172
+
173
+ context = BuildContext.new(
174
+ components: components,
175
+ bundle_assets: bundle_assets,
176
+ package_dist_urls: package_dist_urls,
177
+ template_cache: template_cache,
178
+ build_linter: build_linter,
179
+ bytecode_builder: bytecode_builder,
180
+ target: @target,
181
+ delivery: @delivery,
182
+ live_reload: @live_reload,
183
+ output_dir: @output_dir,
184
+ pages_dir: @pages_dir,
185
+ )
186
+
187
+ compiler = PageCompiler.new(context)
188
+ pages.each { |page_path| compiler.compile(page_path) }
189
+
190
+ build_linter.warn_cross_page_signature_drift!
191
+
192
+ # `:compiled` target needs the runtime (wasm + bridge + boot
193
+ # helper) sitting under `dist/vendor/lilac-compiled/`. We emit
194
+ # it from the CLI directly so users don't have to vendor the
195
+ # npm package by hand. Skipped when no `.mrb` was actually
196
+ # produced — pages without any Ruby script don't reference the
197
+ # bootstrap module.
198
+ auto_vendor_compiled_runtime! if @target == :compiled && Dir.glob(File.join(@output_dir, '*.mrb')).any?
199
+ # :full vendor — symmetric to :compiled. Strict: raises with an
200
+ # actionable message when no runtime is discoverable (so an
201
+ # offline build can't silently ship without its wasm), unless the
202
+ # assets are already vendored (manual / public-mirror workflow).
203
+ auto_vendor_full_runtime! if @target == :full
204
+
205
+ { pages: pages.length, components: components.length, public_files: public_files }
206
+ end
207
+
208
+ private
209
+
210
+ # Mirror `public/**/*` → `output_dir/`. Preserves the relative
211
+ # directory structure (e.g. `public/vendor/x.js` →
212
+ # `output_dir/vendor/x.js`). Returns the number of files copied.
213
+ #
214
+ # `.gitkeep` is filtered so an empty placeholder file doesn't
215
+ # land in the build output. Other dot-prefixed files (e.g.
216
+ # `.well-known/`) are copied so users can publish standard web
217
+ # conventions.
218
+ def mirror_public_files
219
+ return 0 unless @public_dir && File.directory?(@public_dir)
220
+
221
+ excluded_dirs = EXCLUDED_DIRS_FOR_TARGET.fetch(@target, [])
222
+ copied = 0
223
+ Dir.glob(File.join(@public_dir, '**', '*'), File::FNM_DOTMATCH).each do |source|
224
+ # File.file? already filters out the `.` / `..` directory
225
+ # entries that FNM_DOTMATCH surfaces, so no extra guard needed.
226
+ next unless File.file?(source)
227
+ next if EXCLUDED_BASENAMES.include?(File.basename(source))
228
+
229
+ rel = Pathname.new(source).relative_path_from(Pathname.new(@public_dir)).to_s
230
+ next if excluded_dirs.any? { |prefix| rel == prefix || rel.start_with?("#{prefix}/") }
231
+
232
+ dest = File.join(@output_dir, rel)
233
+ FileUtils.mkdir_p(File.dirname(dest))
234
+ FileUtils.cp(source, dest)
235
+ copied += 1
236
+ end
237
+ copied
238
+ end
239
+
240
+ def load_components
241
+ Dir.glob(File.join(@components_dir, '**', '*.lil')).to_h do |path|
242
+ [File.basename(path, '.lil'), SFC.parse_file(path)]
243
+ end
244
+ end
245
+
246
+ # Lazily instantiated so `:full` builds incur no mrbc resolution
247
+ # cost (`BytecodeBuilder.new` itself is cheap, but keeping the
248
+ # creation lazy keeps the `:full` happy path obviously side-effect-free).
249
+ def bytecode_builder
250
+ @bytecode_builder ||= BytecodeBuilder.new(
251
+ mrbc_path: @mrbc_path,
252
+ output_dir: @output_dir,
253
+ disable_gem_discovery: @disable_gem_discovery
254
+ )
255
+ end
256
+
257
+ # Lazily resolves the lilac-compiled runtime (wasm + bridge + boot
258
+ # helper). Constructed only when target=:compiled actually emits a
259
+ # `.mrb`, mirroring `bytecode_builder`'s "no cost on the happy
260
+ # :full path" pattern.
261
+ def compiled_runtime_resolver
262
+ @compiled_runtime_resolver ||= CompiledRuntimeResolver.new(
263
+ lilac_compiled_path: @lilac_compiled_path,
264
+ mruby_wasm_js_path: @mruby_wasm_js_path,
265
+ disable_gem_discovery: @disable_gem_discovery
266
+ )
267
+ end
268
+
269
+ # Lazily resolves the lilac-full runtime (wasm + bridge), mirroring
270
+ # `compiled_runtime_resolver`.
271
+ def full_runtime_resolver
272
+ @full_runtime_resolver ||= FullRuntimeResolver.new(
273
+ lilac_full_path: @lilac_full_path,
274
+ mruby_wasm_js_path: @mruby_wasm_js_path,
275
+ disable_gem_discovery: @disable_gem_discovery
276
+ )
277
+ end
278
+
279
+ # :full target: vendor lilac-full.wasm + bridge into
280
+ # `dist/vendor/lilac-full/` so the scaffold-generated page
281
+ # (`import { createVM } from "/vendor/lilac-full/..."`) finds its
282
+ # runtime assets after `lilac build` — making the dist fully
283
+ # self-contained / offline-runnable with no CDN.
284
+ #
285
+ # Strict, symmetric with `auto_vendor_compiled_runtime!`: raises
286
+ # `FullRuntimeResolver::Error` (actionable message) when no runtime
287
+ # can be discovered, rather than silently shipping a dist missing
288
+ # its runtime. The one exception is the manual-vendor workflow: if
289
+ # the assets are already present under the vendor dir (e.g. mirrored
290
+ # from `public/vendor/lilac-full/` — `mirror_public_files` runs
291
+ # before this — or copied by hand), we skip resolution entirely so
292
+ # gem-less self-hosting still works.
293
+ def auto_vendor_full_runtime!
294
+ return if VendorWriter::REQUIRED_ASSETS[:full]
295
+ .each_value.all? { |rel| File.file?(File.join(@output_dir, rel)) }
296
+
297
+ VendorWriter.copy!(
298
+ wasm_src: full_runtime_resolver.resolve_wasm!,
299
+ bridge_src: full_runtime_resolver.resolve_bridge!,
300
+ vendor_dir: File.join(@output_dir, 'vendor', 'lilac-full'),
301
+ wasm_name: 'lilac-full.wasm'
302
+ )
303
+ end
304
+
305
+ # Emits `dist/vendor/lilac-compiled/{lilac.wasm,mruby-wasm-js/...}`
306
+ # from the resolved runtime sources. The boot module itself is
307
+ # rendered inline in the page HTML (see PageCompiler's
308
+ # render_compiled_boot_module), so we don't need to vendor
309
+ # `index.js`: the page imports the bridge directly and calls
310
+ # `loadBytecode` itself.
311
+ #
312
+ # Raises `CompiledRuntimeResolver::Error` if a source is missing,
313
+ # with an actionable message — the caller (the build command) lets
314
+ # it propagate.
315
+ def auto_vendor_compiled_runtime!
316
+ VendorWriter.copy!(
317
+ wasm_src: compiled_runtime_resolver.resolve_wasm!,
318
+ bridge_src: compiled_runtime_resolver.resolve_bridge!,
319
+ vendor_dir: File.join(@output_dir, 'vendor', 'lilac-compiled'),
320
+ wasm_name: 'lilac.wasm'
321
+ )
322
+ end
323
+
324
+ end
325
+ end
326
+ end