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,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