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.
- checksums.yaml +7 -0
- data/LICENSE.txt +21 -0
- data/README.md +144 -0
- data/exe/lilac +6 -0
- data/lib/lilac/cli/build/build_context.rb +31 -0
- data/lib/lilac/cli/build/build_error.rb +50 -0
- data/lib/lilac/cli/build/builder.rb +326 -0
- data/lib/lilac/cli/build/bundle_asset_writer.rb +129 -0
- data/lib/lilac/cli/build/bytecode_builder.rb +212 -0
- data/lib/lilac/cli/build/compiled_boot_module.rb +103 -0
- data/lib/lilac/cli/build/compiled_runtime_resolver.rb +64 -0
- data/lib/lilac/cli/build/component_name.rb +57 -0
- data/lib/lilac/cli/build/component_scripts_assembler.rb +76 -0
- data/lib/lilac/cli/build/directive.rb +51 -0
- data/lib/lilac/cli/build/full_runtime_resolver.rb +61 -0
- data/lib/lilac/cli/build/html_emitter.rb +58 -0
- data/lib/lilac/cli/build/package_stager.rb +111 -0
- data/lib/lilac/cli/build/page_compiler.rb +405 -0
- data/lib/lilac/cli/build/runtime_resolver.rb +150 -0
- data/lib/lilac/cli/build/sfc.rb +150 -0
- data/lib/lilac/cli/build/template_ast.rb +304 -0
- data/lib/lilac/cli/build/template_ast_cache.rb +58 -0
- data/lib/lilac/cli/build/vendor_writer.rb +58 -0
- data/lib/lilac/cli/build/wasm_mrbc_driver.rb +183 -0
- data/lib/lilac/cli/command.rb +110 -0
- data/lib/lilac/cli/config.rb +150 -0
- data/lib/lilac/cli/config_loader.rb +97 -0
- data/lib/lilac/cli/dev_server.rb +156 -0
- data/lib/lilac/cli/doctor.rb +310 -0
- data/lib/lilac/cli/lint/build_linter.rb +95 -0
- data/lib/lilac/cli/lint/cross_ref_linter.rb +336 -0
- data/lib/lilac/cli/lint/lint_warning.rb +53 -0
- data/lib/lilac/cli/lint/script_analyzer.rb +255 -0
- data/lib/lilac/cli/live_reload.rb +194 -0
- data/lib/lilac/cli/offline_verifier.rb +117 -0
- data/lib/lilac/cli/package_build.rb +68 -0
- data/lib/lilac/cli/package_discovery.rb +77 -0
- data/lib/lilac/cli/preview_server.rb +70 -0
- data/lib/lilac/cli/scaffold.rb +85 -0
- data/lib/lilac/cli/source_location.rb +16 -0
- data/lib/lilac/cli/subcommand/base.rb +65 -0
- data/lib/lilac/cli/subcommand/build.rb +82 -0
- data/lib/lilac/cli/subcommand/dev.rb +58 -0
- data/lib/lilac/cli/subcommand/doctor.rb +39 -0
- data/lib/lilac/cli/subcommand/new.rb +81 -0
- data/lib/lilac/cli/subcommand/option_helpers.rb +58 -0
- data/lib/lilac/cli/subcommand/package_build.rb +51 -0
- data/lib/lilac/cli/subcommand/preview.rb +50 -0
- data/lib/lilac/cli/templates/Gemfile +13 -0
- data/lib/lilac/cli/templates/README.md +125 -0
- data/lib/lilac/cli/templates/components/counter.lil +18 -0
- data/lib/lilac/cli/templates/gitignore +5 -0
- data/lib/lilac/cli/templates/lilac.config.rb +24 -0
- data/lib/lilac/cli/templates/pages/index.html +46 -0
- data/lib/lilac/cli/templates/public/.gitkeep +0 -0
- data/lib/lilac/cli/version.rb +7 -0
- data/lib/lilac/cli/watcher.rb +62 -0
- data/lib/lilac/cli.rb +20 -0
- data/lib/lilac/directives/class_parser.rb +179 -0
- data/lib/lilac/directives/collision_rules.rb +49 -0
- data/lib/lilac/directives/grammar.rb +76 -0
- data/lib/lilac/directives/lints.rb +128 -0
- data/lib/lilac/directives/value.rb +85 -0
- data/lib/lilac/directives.rb +16 -0
- 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
|