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,156 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "wsv"
4
+ require_relative "build/builder"
5
+ require_relative "build/build_error"
6
+ require_relative "live_reload"
7
+ require_relative "watcher"
8
+
9
+ module Lilac
10
+ module CLI
11
+ # Long-running dev server: initial build, file watch, live reload.
12
+ #
13
+ # 1. Builds `components/*.lil + pages/*.html` → `output_dir/` once,
14
+ # with the live-reload client script injected
15
+ # 2. Wraps `Wsv::Server` with a custom app that routes
16
+ # `/__lilac/livereload` to the SSE pub/sub, everything else
17
+ # to the default static-file `Wsv::App`
18
+ # 3. Starts the file watcher; on debounced change events,
19
+ # rebuilds and notifies all SSE subscribers, which reload
20
+ # their pages
21
+ class DevServer
22
+ DEFAULT_HOST = "127.0.0.1"
23
+ DEFAULT_PORT = 5173
24
+
25
+ def initialize(config, host: DEFAULT_HOST, port: DEFAULT_PORT, out: $stdout, err: $stderr)
26
+ @config = config
27
+ @host = host
28
+ @port = port
29
+ @out = out
30
+ @err = err
31
+ @live_reload = LiveReload.new
32
+ end
33
+
34
+ def start
35
+ rebuild!
36
+ # Watch public/ too, so changes to vendored assets (vendor/*.js,
37
+ # favicons, images) also trigger a rebuild + reload.
38
+ @watcher = Watcher.new(watched_paths) { rebuild_and_notify }
39
+ @watcher.start
40
+
41
+ @server = build_server
42
+ @out.puts banner
43
+ @server.start
44
+ ensure
45
+ @watcher&.stop
46
+ end
47
+
48
+ def stop
49
+ @server&.stop
50
+ @watcher&.stop
51
+ end
52
+
53
+ private
54
+
55
+ def rebuild!
56
+ # `dev_target` (not `build_target`) is intentional — `lilac dev`
57
+ # follows the dev path: `:full` skips mrbc for fast reloads,
58
+ # `:compiled` exercises the production mrbc + lilac-compiled
59
+ # flow under live reload so the dev experience matches what
60
+ # ships in prod.
61
+ #
62
+ # `delivery:` is left unset so `Builder.from_config` reads
63
+ # `config.delivery` — projects that opt into `c.delivery = :bundle`
64
+ # in lilac.config.rb get the bundle path under live reload too,
65
+ # which is the same shape that ships to prod.
66
+ Builder.from_config(
67
+ @config,
68
+ live_reload: true,
69
+ target: @config.dev_target,
70
+ ).build
71
+ end
72
+
73
+ def watched_paths
74
+ [@config.components_dir, @config.pages_dir, @config.public_dir].select { |p| File.directory?(p) }
75
+ end
76
+
77
+ def rebuild_and_notify
78
+ @out.puts "lilac dev: rebuilding…"
79
+ rebuild!
80
+ @live_reload.notify_all
81
+ @out.puts "lilac dev: reloaded #{@live_reload.subscriber_count} client(s)"
82
+ rescue Builder::Error, SFC::ParseError, BuildError => e
83
+ # BuildError covers BytecodeBuilder::Error (mrbc invocation
84
+ # failures) as well as Lints errors. Keeps the dev
85
+ # loop alive — the watcher stays armed for the next save. The
86
+ # client-side overlay (injected with LIVE_RELOAD_SCRIPT) renders
87
+ # the error in-page so the developer doesn't have to switch to
88
+ # the terminal to see why the reload didn't happen.
89
+ @err.puts "lilac dev: build failed: #{e.message}"
90
+ @live_reload.notify_error(
91
+ type: e.class.name,
92
+ message: e.message,
93
+ )
94
+ end
95
+
96
+ # Bumping `max_connections` (wsv defaults to 8) gives the dev server
97
+ # enough headroom to handle many SSE subscribers (one per open tab,
98
+ # plus a brief overlap during navigation) without rejecting page
99
+ # requests with 503. LiveReload's keepalive reaps dead subscribers
100
+ # promptly, so this is generous-but-not-leaky.
101
+ DEV_MAX_CONNECTIONS = 64
102
+
103
+ def build_server
104
+ Wsv::Server.new(
105
+ host: @host,
106
+ port: @port,
107
+ root: @config.output_dir,
108
+ out: @out,
109
+ err: @err,
110
+ app: build_app,
111
+ max_connections: DEV_MAX_CONNECTIONS,
112
+ )
113
+ end
114
+
115
+ # The custom app routes the SSE endpoint to LiveReload; every
116
+ # other URL falls through to the default static-file app rooted
117
+ # at `output_dir/`.
118
+ #
119
+ # `Wsv::App.new` does NOT realpath its root the way `Wsv::Server`
120
+ # does, so we resolve symlinks here. On macOS, `/tmp` is a symlink
121
+ # to `/private/tmp`; without this the resolver's within-root check
122
+ # rejects every request as 403.
123
+ def build_app
124
+ RoutingApp.new(
125
+ default_app: Wsv::App.new(File.realpath(@config.output_dir)),
126
+ live_reload: @live_reload,
127
+ endpoint: LiveReload::ENDPOINT_PATH,
128
+ )
129
+ end
130
+
131
+ def banner
132
+ "lilac dev: serving #{@config.output_dir} at http://#{@host}:#{@port}"
133
+ end
134
+
135
+ # Wsv app that routes one path to `live_reload`, everything else
136
+ # to `default_app`. Lives here because it's only useful as the
137
+ # dev server's request demultiplexer.
138
+ class RoutingApp
139
+ def initialize(default_app:, live_reload:, endpoint:)
140
+ @default_app = default_app
141
+ @live_reload = live_reload
142
+ @endpoint = endpoint
143
+ end
144
+
145
+ def call(request)
146
+ path, = request.target.split("?", 2)
147
+ if path == @endpoint
148
+ @live_reload.call(request)
149
+ else
150
+ @default_app.call(request)
151
+ end
152
+ end
153
+ end
154
+ end
155
+ end
156
+ end
@@ -0,0 +1,310 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'build/builder'
4
+ require_relative 'build/bytecode_builder'
5
+ require_relative 'build/compiled_runtime_resolver'
6
+ require_relative 'build/full_runtime_resolver'
7
+ require_relative 'build/page_compiler'
8
+ require_relative 'build/sfc'
9
+ require_relative 'offline_verifier'
10
+
11
+ module Lilac
12
+ module CLI
13
+ # Inspect a Lilac project for the common ways a fresh `lilac new`
14
+ # fails before the user sees the page boot. Catches missing wasm
15
+ # runtime, dangling `<lilac-component>` references, unparseable
16
+ # `.lil`, and similar setup problems.
17
+ #
18
+ # `run` returns 0 when every check passes (or only warns); 1 when
19
+ # any check produced an :error result. Output is plain-text and
20
+ # mirrors the layout of `rails about` / `bundle doctor` /
21
+ # `npx ... doctor` — one line per check, prefix marks the level.
22
+ class Doctor
23
+ Result = Struct.new(:level, :message, keyword_init: true)
24
+
25
+ # Where the wasm runtime is expected to live, relative to public_dir.
26
+ # Both live under `vendor/lilac-full/` so the target-aware public
27
+ # mirror can prune them when building `--target compiled`. Sourced
28
+ # from the single `VendorWriter::REQUIRED_ASSETS` definition.
29
+ RUNTIME_WASM = VendorWriter::REQUIRED_ASSETS[:full][:wasm]
30
+ RUNTIME_JS_ADAPTER = VendorWriter::REQUIRED_ASSETS[:full][:bridge]
31
+
32
+ def initialize(config, out: $stdout)
33
+ @config = config
34
+ @out = out
35
+ end
36
+
37
+ # Returns exit status (0 if no errors).
38
+ def run
39
+ results = run_checks
40
+ emit(results)
41
+ results.any? { |r| r.level == :error } ? 1 : 0
42
+ end
43
+
44
+ private
45
+
46
+ # Each check returns a single Result. Order is roughly "structure
47
+ # → content → external assets" so the report reads top-down from
48
+ # project layout outward.
49
+ def run_checks
50
+ [
51
+ check_pages_dir,
52
+ check_components_dir,
53
+ *check_components_parse,
54
+ *check_component_references,
55
+ check_unused_components,
56
+ check_public_dir,
57
+ check_runtime_wasm,
58
+ check_js_adapter,
59
+ check_compiled_runtime,
60
+ check_full_runtime,
61
+ check_mrbc_backend,
62
+ *check_offline_dist
63
+ ]
64
+ end
65
+
66
+ def check_pages_dir
67
+ if File.directory?(@config.pages_dir)
68
+ ok("pages/ directory found at #{relative(@config.pages_dir)}")
69
+ else
70
+ error("pages/ directory missing: #{relative(@config.pages_dir)}")
71
+ end
72
+ end
73
+
74
+ def check_components_dir
75
+ if File.directory?(@config.components_dir)
76
+ ok("components/ directory found at #{relative(@config.components_dir)}")
77
+ else
78
+ warn("components/ directory missing: #{relative(@config.components_dir)} (OK if you have no components yet)")
79
+ end
80
+ end
81
+
82
+ # Each .lil file gets its own Result so a parse failure pinpoints
83
+ # the offending file (rather than aborting the whole report).
84
+ def check_components_parse
85
+ gnt_paths.map do |path|
86
+ SFC.parse_file(path)
87
+ ok("component parses: #{relative(path)}")
88
+ rescue SFC::ParseError => e
89
+ error("component parse error: #{relative(path)}: #{e.message}")
90
+ end
91
+ end
92
+
93
+ def check_component_references
94
+ return [] unless File.directory?(@config.pages_dir)
95
+
96
+ component_names = gnt_paths.map { |p| File.basename(p, '.lil') }.to_set
97
+ results = []
98
+ page_paths.each do |page_path|
99
+ html = File.read(page_path)
100
+ html.scan(PageCompiler::DATA_USE_PATTERN) do |dq, sq|
101
+ name = dq || sq
102
+ unless component_names.include?(name)
103
+ results << error(
104
+ "page #{relative(page_path)} references data-use=#{name.inspect}, " \
105
+ "but no components/#{name}.lil exists"
106
+ )
107
+ end
108
+ end
109
+ end
110
+ results.empty? ? [ok('all data-use= references resolve')] : results
111
+ end
112
+
113
+ def check_unused_components
114
+ return ok('no components to check for usage') if gnt_paths.empty?
115
+
116
+ component_names = gnt_paths.map { |p| File.basename(p, '.lil') }.to_set
117
+ referenced = page_paths.flat_map do |page_path|
118
+ File.read(page_path).scan(PageCompiler::DATA_USE_PATTERN).map { |dq, sq| dq || sq }
119
+ end.uniq.to_set
120
+
121
+ unused = component_names - referenced
122
+ if unused.empty?
123
+ ok('all components are referenced from at least one page')
124
+ else
125
+ warn("unused components: #{unused.to_a.sort.join(', ')}")
126
+ end
127
+ end
128
+
129
+ def check_public_dir
130
+ if File.directory?(@config.public_dir)
131
+ ok("public/ directory found at #{relative(@config.public_dir)}")
132
+ else
133
+ warn("public/ directory missing: #{relative(@config.public_dir)} (required for the wasm runtime)")
134
+ end
135
+ end
136
+
137
+ def check_runtime_wasm
138
+ path = File.join(@config.public_dir, RUNTIME_WASM)
139
+ if File.file?(path)
140
+ ok("mruby-wasm runtime present: #{relative(path)} (#{format_size(File.size(path))})")
141
+ else
142
+ error("mruby-wasm runtime missing: expected at #{relative(path)}")
143
+ end
144
+ end
145
+
146
+ def check_js_adapter
147
+ path = File.join(@config.public_dir, RUNTIME_JS_ADAPTER)
148
+ if File.file?(path)
149
+ ok("JS adapter present: #{relative(path)}")
150
+ else
151
+ error("JS adapter missing: expected at #{relative(path)}")
152
+ end
153
+ end
154
+
155
+ # `lilac build` defaults to `--target compiled`, which requires
156
+ # a discoverable `lilac-compiled.wasm` (monorepo build/ dir, npm
157
+ # package, or explicit config). This check reports whether one is
158
+ # available, but doesn't fail the run — `lilac dev` and
159
+ # `lilac build --target full` work without it, so a project that
160
+ # only uses the full target shouldn't be forced to set up
161
+ # compiled deps.
162
+ def check_compiled_runtime
163
+ resolver = CompiledRuntimeResolver.new(
164
+ lilac_compiled_path: @config.lilac_compiled_path,
165
+ mruby_wasm_js_path: @config.mruby_wasm_js_path
166
+ )
167
+ path = resolver.wasm_path
168
+ if path && File.file?(path)
169
+ ok("compiled wasm discoverable: #{relative(path)} (#{format_size(File.size(path))})")
170
+ else
171
+ warn(
172
+ 'lilac-compiled.wasm not discoverable — `lilac build` (default ' \
173
+ 'target=compiled) will fail. Either run `make lilac-compiled` ' \
174
+ 'in the lilac monorepo, add `gem "lilac-wasm-bin"` to your Gemfile ' \
175
+ 'and `bundle install`, or use `lilac build --target full` to skip it.'
176
+ )
177
+ end
178
+ end
179
+
180
+ # `lilac build --target full` vendors `lilac-full.wasm` + bridge
181
+ # into the dist; this reports whether a runtime is discoverable so
182
+ # a `:full` build won't fail. Warn-only (a `:compiled`-only project
183
+ # needn't set up the full runtime), mirroring `check_compiled_runtime`.
184
+ def check_full_runtime
185
+ resolver = FullRuntimeResolver.new(
186
+ lilac_full_path: @config.lilac_full_path,
187
+ mruby_wasm_js_path: @config.mruby_wasm_js_path
188
+ )
189
+ path = resolver.wasm_path
190
+ if path && File.file?(path)
191
+ ok("full wasm discoverable: #{relative(path)} (#{format_size(File.size(path))})")
192
+ else
193
+ warn(
194
+ 'lilac-full.wasm not discoverable — `lilac build --target full` ' \
195
+ 'will fail. Either run `make lilac-full` in the lilac monorepo, ' \
196
+ 'add `gem "lilac-wasm-bin"` to your Gemfile and `bundle install`, ' \
197
+ 'or set `c.lilac_full_path`.'
198
+ )
199
+ end
200
+ end
201
+
202
+ # If a build output already exists, verify it can run offline: all
203
+ # of the target's runtime assets are vendored locally and no
204
+ # emitted HTML/JS pulls a runtime asset from a remote origin (CDN).
205
+ # Skipped (informational) when `dist/` is absent — `doctor` is
206
+ # usually run before the first build. Warn-only, like the other
207
+ # external-asset checks.
208
+ # Always returns Array<Result> (splatted into the check list) so a
209
+ # dist with several issues reports one line per issue.
210
+ def check_offline_dist
211
+ dist = @config.output_dir
212
+ return [ok("offline check skipped — no build output at #{relative(dist)} (run `lilac build` first)")] unless File.directory?(dist)
213
+
214
+ results = OfflineVerifier.new(dist).verify
215
+ problems = results.reject { |r| r.level == :ok }
216
+ if problems.empty?
217
+ [ok("dist is offline-runnable: #{relative(dist)} (runtime vendored locally, no remote asset URLs)")]
218
+ else
219
+ problems.map { |r| Result.new(level: r.level, message: "offline: #{r.message}") }
220
+ end
221
+ end
222
+
223
+ # Reports which mrbc backend `lilac build --target compiled` will
224
+ # pick (binary vs wasm-driven) so users can see at a glance why
225
+ # their setup works (or doesn't). Same priority chain as
226
+ # `BytecodeBuilder#resolve_backend`.
227
+ def check_mrbc_backend
228
+ builder = BytecodeBuilder.new(
229
+ mrbc_path: @config.mrbc_path,
230
+ output_dir: Dir.tmpdir # never used by resolve_backend
231
+ )
232
+ backend = builder.resolve_backend
233
+ case backend&.first
234
+ when :binary
235
+ ok("mrbc backend: binary (#{backend.last})")
236
+ when :wasm
237
+ ok("mrbc backend: wasm via lilac-wasm-bin gem (#{relative(backend.last)})")
238
+ else
239
+ warn(
240
+ 'no mrbc backend discoverable — `lilac build --target compiled` ' \
241
+ 'will fail. Add `gem "lilac-wasm-bin"` + `gem "wasmtime"` to ' \
242
+ 'your Gemfile, or install mrbc on PATH.'
243
+ )
244
+ end
245
+ end
246
+
247
+ def gnt_paths
248
+ return [] unless File.directory?(@config.components_dir)
249
+
250
+ @gnt_paths ||= Dir.glob(File.join(@config.components_dir, '**', '*.lil'))
251
+ end
252
+
253
+ def page_paths
254
+ return [] unless File.directory?(@config.pages_dir)
255
+
256
+ @page_paths ||= Dir.glob(File.join(@config.pages_dir, '**', '*.html'))
257
+ end
258
+
259
+ def relative(path)
260
+ rel = Pathname.new(path).relative_path_from(Pathname.new(@config.root)).to_s
261
+ # Paths outside the project root produce ugly `../../../../...`
262
+ # forms — fall back to the absolute path which is easier to
263
+ # read (and grep) than a long traversal sequence.
264
+ rel.start_with?('../..') ? path : rel
265
+ rescue ArgumentError
266
+ path
267
+ end
268
+
269
+ def format_size(bytes)
270
+ return "#{bytes} B" if bytes < 1024
271
+ return "#{(bytes / 1024.0).round(1)} KB" if bytes < 1024 * 1024
272
+
273
+ "#{(bytes / (1024.0 * 1024.0)).round(1)} MB"
274
+ end
275
+
276
+ def ok(msg)
277
+ Result.new(level: :ok, message: msg)
278
+ end
279
+
280
+ def warn(msg)
281
+ Result.new(level: :warn, message: msg)
282
+ end
283
+
284
+ def error(msg)
285
+ Result.new(level: :error, message: msg)
286
+ end
287
+
288
+ def emit(results)
289
+ results.each { |r| @out.puts " #{prefix(r.level)} #{r.message}" }
290
+ @out.puts
291
+ @out.puts summary(results)
292
+ end
293
+
294
+ def prefix(level)
295
+ case level
296
+ when :ok then '[OK] '
297
+ when :warn then '[WARN] '
298
+ when :error then '[FAIL] '
299
+ end
300
+ end
301
+
302
+ def summary(results)
303
+ ok_count = results.count { |r| r.level == :ok }
304
+ warn_count = results.count { |r| r.level == :warn }
305
+ error_count = results.count { |r| r.level == :error }
306
+ "#{ok_count} ok, #{warn_count} warning(s), #{error_count} error(s)"
307
+ end
308
+ end
309
+ end
310
+ end
@@ -0,0 +1,95 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "digest"
4
+ require_relative "script_analyzer"
5
+ require_relative "../build/component_name"
6
+
7
+ module Lilac
8
+ module CLI
9
+ # Build-time linter for component-name / cross-page consistency.
10
+ # Extracted from `Builder` so the build pipeline itself stays focused
11
+ # on HTML rewriting + bytecode emission. Owns the per-build state
12
+ # (page-inline component signatures) that drives cross-page drift
13
+ # detection.
14
+ class BuildLinter
15
+ def initialize
16
+ # `{ component_name => [ [content_hash, page_path], ... ] }`
17
+ # for every page-inline `data-component` element across pages.
18
+ # Populated via `record_inline_signature` from the builder.
19
+ @page_inline_signatures = Hash.new { |h, k| h[k] = [] }
20
+ end
21
+
22
+ # R4: page-inline script classes that collide with `.lil`-derived
23
+ # class names (project-global) are flagged here so the user sees a
24
+ # structured error instead of a downstream mrbc failure. Raises
25
+ # `Lilac::CLI::Builder::Error` to match the error surface other
26
+ # build-time scope violations use.
27
+ def check_class_name_collisions!(page_inline_scripts, components, synthesized_names, page_path)
28
+ return if page_inline_scripts.empty?
29
+
30
+ # `.lil` class names (skip synthesized in-memory entries — those
31
+ # are the page-inline data-component snapshots, not real .lil files).
32
+ lil_class_names = {} # ruby_class_name => kebab (original file)
33
+ components.each_key do |name|
34
+ next if synthesized_names.include?(name)
35
+
36
+ ruby_name = ComponentName.new(name).ruby_class
37
+ lil_class_names[ruby_name] = name
38
+ end
39
+ return if lil_class_names.empty?
40
+
41
+ page_inline_scripts.each do |script|
42
+ ScriptAnalyzer.extract_top_level_class_names(script).each do |declared|
43
+ next unless lil_class_names.key?(declared)
44
+
45
+ page_rel = page_path ? File.basename(page_path) : '(page)'
46
+ lil_basename = "#{lil_class_names[declared]}.lil"
47
+ raise Lilac::CLI::Builder::Error,
48
+ "page-inline class #{declared} in #{page_rel} collides with the class " \
49
+ "derived from components/#{lil_basename}. " \
50
+ "Rename either the page-inline class or the .lil file."
51
+ end
52
+ end
53
+ end
54
+
55
+ # Records a page-inline component's body signature so
56
+ # `warn_cross_page_signature_drift!` can later detect divergent
57
+ # shapes of the same name across pages.
58
+ def record_inline_signature(name, body_html, page_path)
59
+ @page_inline_signatures[name] << [signature_for(body_html), page_path]
60
+ end
61
+
62
+ # R3: after every page is built, scan recorded signatures for the
63
+ # same name appearing with different content_hashes across pages.
64
+ # Output a single warning grouping divergent pages so the user can
65
+ # decide whether to rename one of them or align the shapes.
66
+ def warn_cross_page_signature_drift!
67
+ @page_inline_signatures.each do |name, entries|
68
+ unique_sigs = entries.map { |sig, _| sig }.uniq
69
+ next if unique_sigs.size <= 1
70
+
71
+ pages_str = entries.uniq { |sig, _| sig }
72
+ .map { |_sig, page| File.basename(page) }
73
+ .join(', ')
74
+ warn(
75
+ "[lilac] page-inline component #{name.inspect} appears with " \
76
+ "different shapes across pages (#{pages_str}). " \
77
+ "Page-inline names are page-local so this is allowed, but " \
78
+ "is likely unintentional drift — consider renaming or moving " \
79
+ "the component to components/#{name}.lil to share one shape."
80
+ )
81
+ end
82
+ end
83
+
84
+ private
85
+
86
+ # Stable hash of a page-inline component element body (outer HTML)
87
+ # for cross-page drift detection (proposal §A.R3). Whitespace
88
+ # normalised so cosmetic indentation differences across pages don't
89
+ # spuriously fire the warning.
90
+ def signature_for(body_html)
91
+ Digest::SHA1.hexdigest(body_html.gsub(/\s+/, ' ').strip)
92
+ end
93
+ end
94
+ end
95
+ end