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,58 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'fileutils'
|
|
4
|
+
|
|
5
|
+
module Lilac
|
|
6
|
+
module CLI
|
|
7
|
+
# Stages a wasm runtime + its JS bridge into `dist/vendor/<name>/`
|
|
8
|
+
# so a built site is fully self-contained. Used for both the
|
|
9
|
+
# `:full` and `:compiled` target vendor steps — the two paths only
|
|
10
|
+
# differ in how the source files are resolved (gem soft-require vs
|
|
11
|
+
# CompiledRuntimeResolver) and in the vendored wasm's filename.
|
|
12
|
+
# The copy mechanics — mkdir, single-file wasm cp, bridge dir
|
|
13
|
+
# walk skipping subdirectories — are the same for both.
|
|
14
|
+
module VendorWriter
|
|
15
|
+
# Single source of truth for the runtime assets a self-contained
|
|
16
|
+
# (offline-runnable) dist must carry, per build target, as paths
|
|
17
|
+
# relative to the dist root. Consumed by `Builder` (the :full skip
|
|
18
|
+
# guard), `OfflineVerifier` (presence check), and `Doctor`
|
|
19
|
+
# (`RUNTIME_WASM` / `RUNTIME_JS_ADAPTER`) so the definition of "a
|
|
20
|
+
# complete vendored runtime" lives in exactly one place.
|
|
21
|
+
#
|
|
22
|
+
# Each value names its roles (`:wasm` / `:bridge`) so consumers
|
|
23
|
+
# reference by meaning, not array position. The wasm basename
|
|
24
|
+
# differs by target (`lilac-full.wasm` vs the vendored `lilac.wasm`);
|
|
25
|
+
# the bridge entry is the shared `mruby-wasm-js/index.js`. The
|
|
26
|
+
# vendor directory of a target is `File.dirname(...[:wasm])`.
|
|
27
|
+
REQUIRED_ASSETS = {
|
|
28
|
+
full: {
|
|
29
|
+
wasm: 'vendor/lilac-full/lilac-full.wasm',
|
|
30
|
+
bridge: 'vendor/lilac-full/mruby-wasm-js/index.js',
|
|
31
|
+
}.freeze,
|
|
32
|
+
compiled: {
|
|
33
|
+
wasm: 'vendor/lilac-compiled/lilac.wasm',
|
|
34
|
+
bridge: 'vendor/lilac-compiled/mruby-wasm-js/index.js',
|
|
35
|
+
}.freeze,
|
|
36
|
+
}.freeze
|
|
37
|
+
|
|
38
|
+
module_function
|
|
39
|
+
|
|
40
|
+
# Copy `wasm_src` to `<vendor_dir>/<wasm_name>` and every regular
|
|
41
|
+
# file directly inside `bridge_src` to `<vendor_dir>/mruby-wasm-js/`.
|
|
42
|
+
# Subdirectories of `bridge_src` are intentionally skipped — the
|
|
43
|
+
# bridge ships as a flat js/ directory and any nested folders are
|
|
44
|
+
# build artifacts the runtime doesn't read.
|
|
45
|
+
def copy!(wasm_src:, bridge_src:, vendor_dir:, wasm_name:)
|
|
46
|
+
bridge_out = File.join(vendor_dir, 'mruby-wasm-js')
|
|
47
|
+
FileUtils.mkdir_p(bridge_out)
|
|
48
|
+
|
|
49
|
+
FileUtils.cp(wasm_src, File.join(vendor_dir, wasm_name))
|
|
50
|
+
Dir.glob(File.join(bridge_src, '*')).each do |entry|
|
|
51
|
+
next if File.directory?(entry)
|
|
52
|
+
|
|
53
|
+
FileUtils.cp(entry, File.join(bridge_out, File.basename(entry)))
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
end
|
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Lilac
|
|
4
|
+
module CLI
|
|
5
|
+
# Drives `mrbc-host.wasm` from the host Ruby via `wasmtime-rb` to
|
|
6
|
+
# produce mruby bytecode in place of an external `mrbc` binary.
|
|
7
|
+
#
|
|
8
|
+
# Wire-level ABI (defined in `runtime/mruby-host-compile/src/host_compile.c`):
|
|
9
|
+
#
|
|
10
|
+
# compile_source(src_ptr, src_len,
|
|
11
|
+
# out_ptr_outp, out_len_outp,
|
|
12
|
+
# err_ptr_outp, err_len_outp) -> i32 status
|
|
13
|
+
# mrbc_alloc(len) -> i32 ptr (host writes/reads through this buffer)
|
|
14
|
+
# mrbc_free(ptr) (host frees both input and output)
|
|
15
|
+
#
|
|
16
|
+
# Status codes:
|
|
17
|
+
# 0 ok — irep bytes in out_ptr / out_len
|
|
18
|
+
# 1 compile fail — utf-8 message in err_ptr / err_len
|
|
19
|
+
# 2 no compiler — wasm was built without mruby-compiler
|
|
20
|
+
#
|
|
21
|
+
# Lifecycle: the engine + module + instance are created once on the
|
|
22
|
+
# first `compile` call and reused across subsequent calls within a
|
|
23
|
+
# single `BytecodeBuilder` invocation. mruby's `mrb_state` itself is
|
|
24
|
+
# opened/closed *inside* the wasm per call (see `compile_source` in
|
|
25
|
+
# host_compile.c) — this matches `mrbc` binary's process-per-invocation
|
|
26
|
+
# semantics and keeps symbol-table growth from leaking across builds.
|
|
27
|
+
class WasmMrbcDriver
|
|
28
|
+
class Error < StandardError; end
|
|
29
|
+
class WasmtimeMissingError < Error; end
|
|
30
|
+
class WasmExportMissingError < Error; end
|
|
31
|
+
class CompileError < Error; end
|
|
32
|
+
|
|
33
|
+
REQUIRED_EXPORTS = %w[compile_source mrbc_alloc mrbc_free memory].freeze
|
|
34
|
+
|
|
35
|
+
# Test whether a usable backend can be constructed from `wasm_path`.
|
|
36
|
+
# Returns true only when wasmtime-rb is loadable AND the module
|
|
37
|
+
# parses + instantiates AND all required exports are present.
|
|
38
|
+
#
|
|
39
|
+
# Used by `BytecodeBuilder#resolve_backend!` to decide between the
|
|
40
|
+
# wasm-driven and binary mrbc paths; also surfaced via `lilac doctor`
|
|
41
|
+
# so users can see exactly why the wasm path was/wasn't picked.
|
|
42
|
+
#
|
|
43
|
+
# wasmtime-rb's `Module` doesn't expose `exports` directly, so we
|
|
44
|
+
# actually instantiate to enumerate the exports. The cost (~200-500ms
|
|
45
|
+
# cold) is paid once per `lilac build`, which is acceptable for a
|
|
46
|
+
# build-time tool.
|
|
47
|
+
def self.available?(wasm_path:)
|
|
48
|
+
return false unless wasm_path && File.file?(wasm_path)
|
|
49
|
+
|
|
50
|
+
begin
|
|
51
|
+
require 'wasmtime'
|
|
52
|
+
rescue LoadError
|
|
53
|
+
return false
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
begin
|
|
57
|
+
engine = Wasmtime::Engine.new(wasm_exceptions: true)
|
|
58
|
+
mod = Wasmtime::Module.from_file(engine, wasm_path)
|
|
59
|
+
linker = Wasmtime::Linker.new(engine)
|
|
60
|
+
Wasmtime::WASI::P1.add_to_linker_sync(linker)
|
|
61
|
+
store = Wasmtime::Store.new(engine, wasi_p1_config: Wasmtime::WasiConfig.new)
|
|
62
|
+
instance = linker.instantiate(store, mod)
|
|
63
|
+
REQUIRED_EXPORTS.all? { |name| !instance.export(name).nil? }
|
|
64
|
+
rescue Wasmtime::Error, ArgumentError
|
|
65
|
+
false
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def initialize(wasm_path:)
|
|
70
|
+
@wasm_path = wasm_path
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
attr_reader :wasm_path
|
|
74
|
+
|
|
75
|
+
# Compile a Ruby source string and return the raw mruby bytecode
|
|
76
|
+
# (binary string, starts with "RITE" magic). Raises CompileError on
|
|
77
|
+
# syntax/semantic errors (carrying the parser's error buffer text)
|
|
78
|
+
# or WasmExportMissingError / WasmtimeMissingError on environment
|
|
79
|
+
# problems.
|
|
80
|
+
def compile(ruby_source)
|
|
81
|
+
ensure_instance!
|
|
82
|
+
|
|
83
|
+
# mrbc_alloc returns 0 on size <= 0 or malloc failure. We allocate
|
|
84
|
+
# six buffers per call:
|
|
85
|
+
# src_buf — caller-written source bytes
|
|
86
|
+
# out_p/out_l — out-params written by compile_source on success
|
|
87
|
+
# err_p/err_l — out-params written by compile_source on failure
|
|
88
|
+
# The three out-param pairs are 4-byte i32 each.
|
|
89
|
+
src_bytes = ruby_source.b
|
|
90
|
+
src_buf = @alloc.call(src_bytes.bytesize)
|
|
91
|
+
raise CompileError, 'mrbc_alloc returned 0 for src buffer' if src_buf.zero?
|
|
92
|
+
|
|
93
|
+
out_p = @alloc.call(4)
|
|
94
|
+
out_l = @alloc.call(4)
|
|
95
|
+
err_p = @alloc.call(4)
|
|
96
|
+
err_l = @alloc.call(4)
|
|
97
|
+
if [out_p, out_l, err_p, err_l].any?(&:zero?)
|
|
98
|
+
[src_buf, out_p, out_l, err_p, err_l].each { |p| @free.call(p) unless p.zero? }
|
|
99
|
+
raise CompileError, 'mrbc_alloc returned 0 for out-param buffers'
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
@memory.write(src_buf, src_bytes)
|
|
103
|
+
|
|
104
|
+
status = @compile.call(src_buf, src_bytes.bytesize, out_p, out_l, err_p, err_l)
|
|
105
|
+
|
|
106
|
+
irep_ptr = read_i32(out_p)
|
|
107
|
+
irep_len = read_i32(out_l)
|
|
108
|
+
err_ptr = read_i32(err_p)
|
|
109
|
+
err_len = read_i32(err_l)
|
|
110
|
+
|
|
111
|
+
case status
|
|
112
|
+
when 0
|
|
113
|
+
bytecode = @memory.read(irep_ptr, irep_len)
|
|
114
|
+
@free.call(irep_ptr)
|
|
115
|
+
bytecode
|
|
116
|
+
when 1
|
|
117
|
+
message = err_len.positive? ? @memory.read(err_ptr, err_len).force_encoding('UTF-8') : '(no message)'
|
|
118
|
+
@free.call(err_ptr) unless err_ptr.zero?
|
|
119
|
+
raise CompileError, message
|
|
120
|
+
when 2
|
|
121
|
+
raise WasmExportMissingError,
|
|
122
|
+
'mrbc-host.wasm was built without mruby-compiler — rebuild with build_config/mrbc-host.rb'
|
|
123
|
+
else
|
|
124
|
+
raise CompileError, "compile_source returned unexpected status=#{status}"
|
|
125
|
+
end
|
|
126
|
+
ensure
|
|
127
|
+
# Free the input buffer and the four out-param scratch buffers.
|
|
128
|
+
# irep_ptr / err_ptr were freed inside the case branches because
|
|
129
|
+
# they're the wasm's own malloc'd outputs (separate from the
|
|
130
|
+
# 4-byte holders the host allocated for the pointer values).
|
|
131
|
+
[src_buf, out_p, out_l, err_p, err_l].compact.each do |p|
|
|
132
|
+
@free.call(p) unless p.zero?
|
|
133
|
+
end
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
private
|
|
137
|
+
|
|
138
|
+
# Lazy initialization: avoid paying the ~200-500ms wasmtime cold
|
|
139
|
+
# compile cost if the caller only uses `available?` for discovery
|
|
140
|
+
# and never actually compiles anything.
|
|
141
|
+
def ensure_instance!
|
|
142
|
+
return if @instance
|
|
143
|
+
|
|
144
|
+
begin
|
|
145
|
+
require 'wasmtime'
|
|
146
|
+
rescue LoadError => e
|
|
147
|
+
raise WasmtimeMissingError, "wasmtime-rb not available: #{e.message}"
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
@engine = Wasmtime::Engine.new(wasm_exceptions: true)
|
|
151
|
+
@module = Wasmtime::Module.from_file(@engine, @wasm_path)
|
|
152
|
+
|
|
153
|
+
# WASI is wired even though host_compile.c doesn't deliberately
|
|
154
|
+
# call into it — mruby-wasm-runtime / wasi-libc reference fd_write,
|
|
155
|
+
# fd_fdstat_get, etc. at link time even when those code paths are
|
|
156
|
+
# unreachable, so wasmtime requires the imports to be satisfied.
|
|
157
|
+
linker = Wasmtime::Linker.new(@engine)
|
|
158
|
+
Wasmtime::WASI::P1.add_to_linker_sync(linker)
|
|
159
|
+
store = Wasmtime::Store.new(@engine, wasi_p1_config: Wasmtime::WasiConfig.new)
|
|
160
|
+
@instance = linker.instantiate(store, @module)
|
|
161
|
+
|
|
162
|
+
if (init = @instance.export('_initialize')&.to_func)
|
|
163
|
+
init.call
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
@memory = export_or_raise('memory').to_memory
|
|
167
|
+
@alloc = export_or_raise('mrbc_alloc').to_func
|
|
168
|
+
@free = export_or_raise('mrbc_free').to_func
|
|
169
|
+
@compile = export_or_raise('compile_source').to_func
|
|
170
|
+
end
|
|
171
|
+
|
|
172
|
+
def export_or_raise(name)
|
|
173
|
+
@instance.export(name) ||
|
|
174
|
+
raise(WasmExportMissingError,
|
|
175
|
+
"#{File.basename(@wasm_path)} is missing required export `#{name}`")
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
def read_i32(ptr)
|
|
179
|
+
@memory.read(ptr, 4).unpack1('l<')
|
|
180
|
+
end
|
|
181
|
+
end
|
|
182
|
+
end
|
|
183
|
+
end
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "subcommand/build"
|
|
4
|
+
require_relative "subcommand/dev"
|
|
5
|
+
require_relative "subcommand/preview"
|
|
6
|
+
require_relative "subcommand/new"
|
|
7
|
+
require_relative "subcommand/doctor"
|
|
8
|
+
require_relative "subcommand/package_build"
|
|
9
|
+
|
|
10
|
+
module Lilac
|
|
11
|
+
module CLI
|
|
12
|
+
# Entry point for `exe/lilac`. Routes argv[0] to the matching
|
|
13
|
+
# `Subcommand::*` handler; each subcommand owns its own OptionParser
|
|
14
|
+
# and `run` method. The outer rescue here keeps the user-facing
|
|
15
|
+
# error surface uniform (Builder::Error / ConfigLoader::LoadError /
|
|
16
|
+
# etc. → "lilac: <msg>" + exit 1).
|
|
17
|
+
class Command
|
|
18
|
+
SUBCOMMAND_HANDLERS = {
|
|
19
|
+
"build" => Subcommand::Build,
|
|
20
|
+
"dev" => Subcommand::Dev,
|
|
21
|
+
"preview" => Subcommand::Preview,
|
|
22
|
+
"new" => Subcommand::New,
|
|
23
|
+
"doctor" => Subcommand::Doctor,
|
|
24
|
+
"package-build" => Subcommand::PackageBuild,
|
|
25
|
+
}.freeze
|
|
26
|
+
|
|
27
|
+
def initialize(argv, out: $stdout, err: $stderr)
|
|
28
|
+
@argv = argv.dup
|
|
29
|
+
@out = out
|
|
30
|
+
@err = err
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
# Returns an exit status (0 for success).
|
|
34
|
+
def run
|
|
35
|
+
name = @argv.shift || "help"
|
|
36
|
+
|
|
37
|
+
return print_version if name == "--version"
|
|
38
|
+
return run_help if %w[help -h --help].include?(name)
|
|
39
|
+
|
|
40
|
+
if (handler = SUBCOMMAND_HANDLERS[name])
|
|
41
|
+
handler.new(@argv, out: @out, err: @err).run
|
|
42
|
+
else
|
|
43
|
+
@err.puts "lilac: unknown command #{name.inspect}"
|
|
44
|
+
@err.puts
|
|
45
|
+
print_help(io: @err)
|
|
46
|
+
1
|
|
47
|
+
end
|
|
48
|
+
rescue Builder::Error, SFC::ParseError, Scaffold::Error,
|
|
49
|
+
ConfigLoader::LoadError, PreviewServer::Error,
|
|
50
|
+
PackageBuild::Error => e
|
|
51
|
+
@err.puts "lilac: #{e.message}"
|
|
52
|
+
1
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
# `lilac help` shows the top-level help; `lilac help <subcmd>`
|
|
56
|
+
# shows the per-subcommand option list (sourced from the same
|
|
57
|
+
# OptionParser that the subcommand uses, so it stays in sync).
|
|
58
|
+
def run_help
|
|
59
|
+
topic = @argv.shift
|
|
60
|
+
case topic
|
|
61
|
+
when nil, "help", "-h", "--help"
|
|
62
|
+
print_help
|
|
63
|
+
0
|
|
64
|
+
else
|
|
65
|
+
if (handler = SUBCOMMAND_HANDLERS[topic])
|
|
66
|
+
@out.puts handler.help_text
|
|
67
|
+
0
|
|
68
|
+
else
|
|
69
|
+
@err.puts "lilac help: unknown command #{topic.inspect}"
|
|
70
|
+
@err.puts
|
|
71
|
+
print_help(io: @err)
|
|
72
|
+
1
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
private
|
|
78
|
+
|
|
79
|
+
def print_help(io: @out)
|
|
80
|
+
io.puts <<~HELP
|
|
81
|
+
lilac — build .lil single-file components into static HTML
|
|
82
|
+
|
|
83
|
+
Usage:
|
|
84
|
+
lilac <command> [options]
|
|
85
|
+
|
|
86
|
+
Commands:
|
|
87
|
+
new <name> Scaffold a new Lilac app
|
|
88
|
+
build Compile components/ + pages/ into dist/
|
|
89
|
+
dev Build, serve, watch — live reload on changes
|
|
90
|
+
preview Serve the built dist/ as a static site (no watch / reload)
|
|
91
|
+
doctor Verify project setup (runtime, references, paths)
|
|
92
|
+
package-build Compile pure-Ruby package source(s) to `.mrb` bytecode
|
|
93
|
+
help Show this help
|
|
94
|
+
--version Print version
|
|
95
|
+
|
|
96
|
+
Tip: place the mruby-wasm runtime under public/vendor/. Run
|
|
97
|
+
`lilac doctor` to check the setup.
|
|
98
|
+
|
|
99
|
+
Run `lilac help <command>` or `lilac <command> --help` for
|
|
100
|
+
command-specific options.
|
|
101
|
+
HELP
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
def print_version
|
|
105
|
+
@out.puts "lilac-cli #{VERSION}"
|
|
106
|
+
0
|
|
107
|
+
end
|
|
108
|
+
end
|
|
109
|
+
end
|
|
110
|
+
end
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "config_loader"
|
|
4
|
+
|
|
5
|
+
module Lilac
|
|
6
|
+
module CLI
|
|
7
|
+
# Holds resolved paths and server settings for the build pipeline.
|
|
8
|
+
# Three sources, in increasing precedence:
|
|
9
|
+
#
|
|
10
|
+
# 1. Built-in defaults (DEFAULT_* constants below)
|
|
11
|
+
# 2. `lilac.config.rb` at the project root (via ConfigLoader)
|
|
12
|
+
# 3. CLI flags (`--components`, `--port`, etc.)
|
|
13
|
+
#
|
|
14
|
+
# Most call sites should use `Config.load(opts)`, which performs the
|
|
15
|
+
# three-way merge in one shot. `Config.new(...)` remains for tests
|
|
16
|
+
# and callers that want to construct a config without a file.
|
|
17
|
+
class Config
|
|
18
|
+
DEFAULT_COMPONENTS_DIR = "components"
|
|
19
|
+
DEFAULT_PAGES_DIR = "pages"
|
|
20
|
+
DEFAULT_OUTPUT_DIR = "dist"
|
|
21
|
+
# Static files in `public/` are mirrored to `output_dir/` at
|
|
22
|
+
# build time (Vite / Eleventy / Astro convention). The directory
|
|
23
|
+
# itself is optional — Builder skips silently when absent.
|
|
24
|
+
DEFAULT_PUBLIC_DIR = "public"
|
|
25
|
+
DEFAULT_DEV_HOST = "127.0.0.1"
|
|
26
|
+
DEFAULT_DEV_PORT = 5173
|
|
27
|
+
|
|
28
|
+
# Build target. `:full` (default) emits dist HTML with inline Ruby
|
|
29
|
+
# script tags loaded by the lilac-full wasm at runtime — the
|
|
30
|
+
# original Lilac story, no extra tooling needed. `:compiled` emits
|
|
31
|
+
# pre-compiled mruby bytecode (`.mrb`) loaded by lilac-compiled
|
|
32
|
+
# wasm — smaller production bundle (~32% brotli), but requires
|
|
33
|
+
# `mrbc` in the build environment (see DEFAULT_MRBC_CANDIDATES).
|
|
34
|
+
#
|
|
35
|
+
# Mirrors Vite's dev/prod two-stage philosophy:
|
|
36
|
+
# - `lilac dev` defaults to `:full` so the development loop stays
|
|
37
|
+
# mrbc-free and gets the runtime parser for fast iteration.
|
|
38
|
+
# - `lilac build` defaults to `:compiled` so production deploys
|
|
39
|
+
# ship the smaller bundle by default — opt out with
|
|
40
|
+
# `--target full` when you want the runtime parser in dist.
|
|
41
|
+
DEFAULT_BUILD_TARGET = :compiled
|
|
42
|
+
DEFAULT_DEV_TARGET = :full
|
|
43
|
+
TARGET_VALUES = %i[full compiled].freeze
|
|
44
|
+
|
|
45
|
+
# Delivery mode for component templates + scripts:
|
|
46
|
+
# :inline — embed everything in each page's HTML (current default)
|
|
47
|
+
# :bundle — emit a single lilac.bundle.html, page HTML references it
|
|
48
|
+
# via <link rel="lilac-bundle">
|
|
49
|
+
DEFAULT_DELIVERY = :inline
|
|
50
|
+
DELIVERY_VALUES = %i[inline bundle].freeze
|
|
51
|
+
|
|
52
|
+
attr_reader :root, :components_dir, :pages_dir, :output_dir, :public_dir,
|
|
53
|
+
:dev_host, :dev_port,
|
|
54
|
+
:build_target, :dev_target, :mrbc_path,
|
|
55
|
+
:lilac_compiled_path, :lilac_full_path, :mruby_wasm_js_path,
|
|
56
|
+
:packages, :delivery
|
|
57
|
+
|
|
58
|
+
# Three-way merge: CLI opts > lilac.config.rb > built-in defaults.
|
|
59
|
+
# `opts` keys mirror the keyword args of `initialize`; nil values
|
|
60
|
+
# mean "no CLI override given" and let the file/default win.
|
|
61
|
+
def self.load(root: nil, components_dir: nil, pages_dir: nil, output_dir: nil,
|
|
62
|
+
public_dir: nil, dev_host: nil, dev_port: nil,
|
|
63
|
+
build_target: nil, dev_target: nil, mrbc_path: nil,
|
|
64
|
+
lilac_compiled_path: nil, lilac_full_path: nil,
|
|
65
|
+
mruby_wasm_js_path: nil,
|
|
66
|
+
packages: nil, delivery: nil)
|
|
67
|
+
resolved_root = File.expand_path(root || Dir.pwd)
|
|
68
|
+
settings = ConfigLoader.load(resolved_root) || ConfigLoader::Settings.new
|
|
69
|
+
|
|
70
|
+
new(
|
|
71
|
+
root: resolved_root,
|
|
72
|
+
components_dir: components_dir || settings.components_dir,
|
|
73
|
+
pages_dir: pages_dir || settings.pages_dir,
|
|
74
|
+
output_dir: output_dir || settings.output_dir,
|
|
75
|
+
public_dir: public_dir || settings.public_dir,
|
|
76
|
+
dev_host: dev_host || settings.dev_host,
|
|
77
|
+
dev_port: dev_port || settings.dev_port,
|
|
78
|
+
build_target: build_target || settings.build_target,
|
|
79
|
+
dev_target: dev_target || settings.dev_target,
|
|
80
|
+
mrbc_path: mrbc_path || settings.mrbc_path,
|
|
81
|
+
lilac_compiled_path: lilac_compiled_path || settings.lilac_compiled_path,
|
|
82
|
+
lilac_full_path: lilac_full_path || settings.lilac_full_path,
|
|
83
|
+
mruby_wasm_js_path: mruby_wasm_js_path || settings.mruby_wasm_js_path,
|
|
84
|
+
packages: packages || settings.packages,
|
|
85
|
+
delivery: delivery || settings.delivery,
|
|
86
|
+
)
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
def initialize(root: nil, components_dir: nil, pages_dir: nil, output_dir: nil,
|
|
90
|
+
public_dir: nil, dev_host: nil, dev_port: nil,
|
|
91
|
+
build_target: nil, dev_target: nil, mrbc_path: nil,
|
|
92
|
+
lilac_compiled_path: nil, lilac_full_path: nil,
|
|
93
|
+
mruby_wasm_js_path: nil,
|
|
94
|
+
packages: nil, delivery: nil)
|
|
95
|
+
# Use `|| Dir.pwd` rather than a default keyword so callers can
|
|
96
|
+
# pass `root: opts[:root]` (often nil from un-set CLI flags)
|
|
97
|
+
# without overriding the default to nil.
|
|
98
|
+
@root = File.expand_path(root || Dir.pwd)
|
|
99
|
+
@components_dir = expand(components_dir || DEFAULT_COMPONENTS_DIR)
|
|
100
|
+
@pages_dir = expand(pages_dir || DEFAULT_PAGES_DIR)
|
|
101
|
+
@output_dir = expand(output_dir || DEFAULT_OUTPUT_DIR)
|
|
102
|
+
@public_dir = expand(public_dir || DEFAULT_PUBLIC_DIR)
|
|
103
|
+
@dev_host = dev_host || DEFAULT_DEV_HOST
|
|
104
|
+
@dev_port = dev_port || DEFAULT_DEV_PORT
|
|
105
|
+
@build_target = normalize_target(build_target || DEFAULT_BUILD_TARGET, kind: "build_target")
|
|
106
|
+
@dev_target = normalize_target(dev_target || DEFAULT_DEV_TARGET, kind: "dev_target")
|
|
107
|
+
# nil = auto-discover at use time (see BytecodeBuilder.resolve_mrbc).
|
|
108
|
+
@mrbc_path = mrbc_path
|
|
109
|
+
# nil = auto-discover via CompiledRuntimeResolver (env / monorepo
|
|
110
|
+
# ancestor / node_modules).
|
|
111
|
+
@lilac_compiled_path = lilac_compiled_path
|
|
112
|
+
# nil = auto-discover via FullRuntimeResolver (env / gem /
|
|
113
|
+
# monorepo). Parallel to @lilac_compiled_path.
|
|
114
|
+
@lilac_full_path = lilac_full_path
|
|
115
|
+
@mruby_wasm_js_path = mruby_wasm_js_path
|
|
116
|
+
# Pre-compiled Lilac package `.mrb` paths (advanced override
|
|
117
|
+
# path — most users get packages via Bundler auto-discovery,
|
|
118
|
+
# see decisions §25/§26). Each entry is resolved against `@root`
|
|
119
|
+
# so relative paths Just Work. Nil from Config.load is
|
|
120
|
+
# normalized to an empty array.
|
|
121
|
+
@packages = (packages || []).map { |p| expand(p) }
|
|
122
|
+
@delivery = normalize_delivery(delivery || DEFAULT_DELIVERY)
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
private
|
|
126
|
+
|
|
127
|
+
def expand(path)
|
|
128
|
+
File.expand_path(path, @root)
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
def normalize_target(value, kind:)
|
|
132
|
+
sym = value.to_sym
|
|
133
|
+
unless TARGET_VALUES.include?(sym)
|
|
134
|
+
raise ArgumentError,
|
|
135
|
+
"#{kind} must be one of #{TARGET_VALUES.inspect}, got #{value.inspect}"
|
|
136
|
+
end
|
|
137
|
+
sym
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
def normalize_delivery(value)
|
|
141
|
+
sym = value.to_sym
|
|
142
|
+
unless DELIVERY_VALUES.include?(sym)
|
|
143
|
+
raise ArgumentError,
|
|
144
|
+
"delivery must be one of #{DELIVERY_VALUES.inspect}, got #{value.inspect}"
|
|
145
|
+
end
|
|
146
|
+
sym
|
|
147
|
+
end
|
|
148
|
+
end
|
|
149
|
+
end
|
|
150
|
+
end
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Lilac
|
|
4
|
+
module CLI
|
|
5
|
+
# Reads `lilac.config.rb` from a project root. The file uses the
|
|
6
|
+
# `Lilac::CLI.configure` DSL:
|
|
7
|
+
#
|
|
8
|
+
# Lilac::CLI.configure do |c|
|
|
9
|
+
# c.components_dir = "src/components"
|
|
10
|
+
# c.dev_port = 3000
|
|
11
|
+
# end
|
|
12
|
+
#
|
|
13
|
+
# Unset fields stay nil so callers (Config.load) can fall through to
|
|
14
|
+
# their hardcoded defaults. The DSL is only callable during a
|
|
15
|
+
# `ConfigLoader.load` invocation; calling it outside that context
|
|
16
|
+
# raises, which catches typos like `Lilac.configure { ... }` in
|
|
17
|
+
# arbitrary scripts.
|
|
18
|
+
class ConfigLoader
|
|
19
|
+
DEFAULT_FILENAME = "lilac.config.rb"
|
|
20
|
+
|
|
21
|
+
# Fields here are the union of every value the CLI knows how to
|
|
22
|
+
# configure. Keep in sync with Config defaults.
|
|
23
|
+
Settings = Struct.new(
|
|
24
|
+
:components_dir, :pages_dir, :public_dir, :output_dir,
|
|
25
|
+
:dev_host, :dev_port,
|
|
26
|
+
# build_target / dev_target: `:full` (default) ships dist HTML
|
|
27
|
+
# with inline Ruby + lilac-full wasm; `:compiled` ships .mrb
|
|
28
|
+
# bytecode + lilac-compiled wasm. mrbc_path overrides the
|
|
29
|
+
# auto-discovery in BytecodeBuilder.
|
|
30
|
+
:build_target, :dev_target, :mrbc_path,
|
|
31
|
+
# Overrides for the runtime discovery (see CompiledRuntimeResolver
|
|
32
|
+
# / FullRuntimeResolver). All nil = auto-discover via
|
|
33
|
+
# env / lilac-wasm-bin gem / monorepo ancestor.
|
|
34
|
+
# `lilac_full_path` overrides the `--target full` wasm.
|
|
35
|
+
:lilac_compiled_path, :lilac_full_path, :mruby_wasm_js_path,
|
|
36
|
+
# `packages` — Array<String> of paths (absolute or
|
|
37
|
+
# project-root-relative) pointing at pre-compiled Lilac package
|
|
38
|
+
# `.mrb` files. Advanced override; most users get packages via
|
|
39
|
+
# Bundler auto-discovery (`Lilac::CLI::PackageDiscovery`).
|
|
40
|
+
# At build time the CLI copies each to `dist/packages/` and
|
|
41
|
+
# the generated boot script `loadBytecode`s them before user
|
|
42
|
+
# code. See decisions §25 / §26 + `docs/lilac-package-spec.md`.
|
|
43
|
+
:packages,
|
|
44
|
+
# `delivery` — :inline (default) embeds component definitions
|
|
45
|
+
# in each page's HTML; :bundle emits a single lilac.bundle.html
|
|
46
|
+
# referenced from each page via <link rel="lilac-bundle">. See
|
|
47
|
+
# lilac-proposals.md for the bundle-fetch strategy.
|
|
48
|
+
:delivery,
|
|
49
|
+
keyword_init: true,
|
|
50
|
+
)
|
|
51
|
+
|
|
52
|
+
class LoadError < StandardError; end
|
|
53
|
+
|
|
54
|
+
# Loads `<root>/lilac.config.rb` if present and returns a
|
|
55
|
+
# Settings struct (with nils for unset fields). Returns nil when
|
|
56
|
+
# the file doesn't exist — callers treat that as "use defaults".
|
|
57
|
+
def self.load(root, filename: DEFAULT_FILENAME)
|
|
58
|
+
path = File.join(root, filename)
|
|
59
|
+
return nil unless File.file?(path)
|
|
60
|
+
|
|
61
|
+
settings = Settings.new
|
|
62
|
+
with_settings(settings) do
|
|
63
|
+
begin
|
|
64
|
+
Kernel.load(path)
|
|
65
|
+
rescue StandardError, ScriptError => e
|
|
66
|
+
raise LoadError, "Error loading #{filename}: #{e.message}"
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
settings
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
# Singleton stash so `Lilac::CLI.configure` can find the active
|
|
73
|
+
# Settings without each config file passing it explicitly.
|
|
74
|
+
def self.with_settings(settings)
|
|
75
|
+
previous = Thread.current[:lilac_cli_settings]
|
|
76
|
+
Thread.current[:lilac_cli_settings] = settings
|
|
77
|
+
yield
|
|
78
|
+
ensure
|
|
79
|
+
Thread.current[:lilac_cli_settings] = previous
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def self.current_settings
|
|
83
|
+
Thread.current[:lilac_cli_settings]
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
# Public DSL hook. `lilac.config.rb` calls this; outside a
|
|
88
|
+
# ConfigLoader.load it raises so accidental misuse fails loudly.
|
|
89
|
+
def self.configure
|
|
90
|
+
settings = ConfigLoader.current_settings
|
|
91
|
+
raise ConfigLoader::LoadError,
|
|
92
|
+
"Lilac::CLI.configure must be called from lilac.config.rb" unless settings
|
|
93
|
+
|
|
94
|
+
yield settings
|
|
95
|
+
end
|
|
96
|
+
end
|
|
97
|
+
end
|