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,194 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "wsv"
4
+
5
+ module Lilac
6
+ module CLI
7
+ # SSE pub/sub for the dev server's live-reload endpoint.
8
+ #
9
+ # Each connected browser holds an open SSE response served by `#call`.
10
+ # When `notify_all` fires (because the file watcher detected a build),
11
+ # every subscriber receives a "reload" event and refreshes.
12
+ #
13
+ # Subscribers are tracked as `Queue` instances; subscription cleanup
14
+ # happens in an `ensure` so a closed/aborted client doesn't leak.
15
+ class LiveReload
16
+ ENDPOINT_PATH = "/__lilac/livereload"
17
+
18
+ # `:keepalive` is a SSE-comment frame: clients ignore it, but
19
+ # writing it lets us detect a dropped connection (the write raises
20
+ # Errno::EPIPE) when no real reload event has fired in a while.
21
+ # Short interval keeps dead subscribers from clogging the wsv
22
+ # connection-throttle pool (default cap 8) on rapid page reloads —
23
+ # without this we'd see 503s after ~8 reloads within 30 s.
24
+ KEEPALIVE_INTERVAL = 5
25
+
26
+ # Client snippet injected by the builder into every dev-server page.
27
+ # Subscribes to the dev server's SSE channel and handles two event
28
+ # types:
29
+ #
30
+ # - default `message` event → successful rebuild, reload the page
31
+ # (any in-page error overlay is implicitly discarded by the
32
+ # reload, no explicit cleanup needed)
33
+ # - `error` event → build failed; render an in-page overlay with
34
+ # the error type + message so the dev sees the failure without
35
+ # switching to the terminal
36
+ #
37
+ # The overlay is intentionally inline-styled and self-contained
38
+ # (`__lilac_err_overlay`), no dependency on user styles.
39
+ SCRIPT = <<~HTML
40
+ <script>
41
+ // lilac dev: live reload + error overlay via SSE
42
+ (function () {
43
+ const ES_URL = "/__lilac/livereload";
44
+ const OVERLAY_ID = "__lilac_err_overlay";
45
+
46
+ function renderOverlay(payload) {
47
+ document.getElementById(OVERLAY_ID)?.remove();
48
+ const root = document.createElement("div");
49
+ root.id = OVERLAY_ID;
50
+ root.setAttribute("style", [
51
+ "position:fixed", "inset:0", "z-index:2147483647",
52
+ "background:rgba(0,0,0,0.78)", "color:#fff",
53
+ "font:14px/1.5 ui-monospace,SFMono-Regular,Menlo,monospace",
54
+ "padding:32px", "overflow:auto",
55
+ ].join(";"));
56
+
57
+ const panel = document.createElement("div");
58
+ panel.setAttribute("style", [
59
+ "max-width:880px", "margin:0 auto",
60
+ "background:#1f1f23", "border:1px solid #ff5b6c",
61
+ "border-radius:8px", "padding:20px 24px",
62
+ "box-shadow:0 12px 40px rgba(0,0,0,0.45)",
63
+ ].join(";"));
64
+
65
+ const head = document.createElement("div");
66
+ head.setAttribute("style", "display:flex;justify-content:space-between;align-items:center;gap:12px;margin-bottom:12px");
67
+ const title = document.createElement("strong");
68
+ title.textContent = "lilac dev: build failed";
69
+ title.setAttribute("style", "color:#ff5b6c;font-size:15px");
70
+ const close = document.createElement("button");
71
+ close.type = "button";
72
+ close.textContent = "×";
73
+ close.setAttribute("aria-label", "Dismiss");
74
+ close.setAttribute("style", "background:transparent;border:0;color:#fff;font-size:22px;cursor:pointer;line-height:1");
75
+ close.addEventListener("click", () => root.remove());
76
+ head.appendChild(title);
77
+ head.appendChild(close);
78
+ panel.appendChild(head);
79
+
80
+ if (payload && payload.type) {
81
+ const t = document.createElement("div");
82
+ t.textContent = payload.type;
83
+ t.setAttribute("style", "color:#9aa0a6;font-size:12px;margin-bottom:8px");
84
+ panel.appendChild(t);
85
+ }
86
+
87
+ const msg = document.createElement("pre");
88
+ msg.textContent = (payload && payload.message) || "(no message)";
89
+ msg.setAttribute("style", "white-space:pre-wrap;word-break:break-word;margin:0;color:#f5f5f5");
90
+ panel.appendChild(msg);
91
+
92
+ const hint = document.createElement("div");
93
+ hint.textContent = "Save the file to retry — this overlay will close automatically on a successful rebuild.";
94
+ hint.setAttribute("style", "color:#9aa0a6;font-size:12px;margin-top:14px");
95
+ panel.appendChild(hint);
96
+
97
+ root.appendChild(panel);
98
+ document.body.appendChild(root);
99
+ }
100
+
101
+ const es = new EventSource(ES_URL);
102
+ es.addEventListener("message", () => location.reload());
103
+ es.addEventListener("error", (ev) => {
104
+ // EventSource fires "error" on transport failure too — those
105
+ // have no `data` field. Distinguish from server-sent error
106
+ // events by presence of `ev.data`.
107
+ if (!ev.data) return;
108
+ try {
109
+ renderOverlay(JSON.parse(ev.data));
110
+ } catch (_e) {
111
+ renderOverlay({ type: "(parse failure)", message: ev.data });
112
+ }
113
+ });
114
+ })();
115
+ </script>
116
+ HTML
117
+
118
+ def initialize
119
+ @subscribers = []
120
+ @mutex = Mutex.new
121
+ end
122
+
123
+ def call(_request)
124
+ queue = subscribe
125
+ Wsv::Response.sse do |io|
126
+ io.write(":connected\n\n")
127
+ io.flush
128
+ serve_loop(queue, io)
129
+ rescue Errno::EPIPE, IOError
130
+ # Client disconnected mid-stream; producer just exits.
131
+ ensure
132
+ unsubscribe(queue)
133
+ end
134
+ end
135
+
136
+ def notify_all(message = "reload")
137
+ @mutex.synchronize { @subscribers.each { |q| q << message } }
138
+ end
139
+
140
+ # Push a `event: error` SSE frame to all subscribers with the
141
+ # given payload encoded as JSON. The client overlay reads this
142
+ # via `addEventListener("error", ...)` and renders an overlay.
143
+ # A subsequent successful build calls `notify_all("reload")`
144
+ # which reloads the page and the overlay disappears on its own.
145
+ def notify_error(payload)
146
+ require "json"
147
+ json = JSON.generate(payload)
148
+ marker = [ERROR_MARKER, json]
149
+ @mutex.synchronize { @subscribers.each { |q| q << marker } }
150
+ end
151
+
152
+ def subscriber_count
153
+ @mutex.synchronize { @subscribers.length }
154
+ end
155
+
156
+ # Sentinel object used to tag error tuples in the queue without
157
+ # colliding with any plausible reload-message string.
158
+ ERROR_MARKER = Object.new.freeze
159
+
160
+ private
161
+
162
+ def subscribe
163
+ queue = Queue.new
164
+ @mutex.synchronize { @subscribers << queue }
165
+ queue
166
+ end
167
+
168
+ def unsubscribe(queue)
169
+ @mutex.synchronize { @subscribers.delete(queue) }
170
+ end
171
+
172
+ def serve_loop(queue, io)
173
+ loop do
174
+ # Queue#pop(timeout:) is Ruby 3.2+ — required Ruby version
175
+ # already enforced in the gemspec.
176
+ msg = queue.pop(timeout: KEEPALIVE_INTERVAL)
177
+ io.write(format_frame(msg))
178
+ io.flush
179
+ end
180
+ end
181
+
182
+ def format_frame(msg)
183
+ case msg
184
+ when nil
185
+ ":keepalive\n\n"
186
+ when Array # tagged [ERROR_MARKER, json]
187
+ "event: error\ndata: #{msg.last}\n\n"
188
+ else
189
+ "data: #{msg}\n\n"
190
+ end
191
+ end
192
+ end
193
+ end
194
+ end
@@ -0,0 +1,117 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "pathname"
4
+ require_relative "build/vendor_writer"
5
+
6
+ module Lilac
7
+ module CLI
8
+ # Inspects a *built* dist directory and reports whether it is
9
+ # self-contained — i.e. it can run with no internet access:
10
+ #
11
+ # 1. The target's runtime assets are vendored locally
12
+ # (`vendor/lilac-{full,compiled}/...`).
13
+ # 2. No emitted HTML/JS loads a runtime asset from a remote origin
14
+ # (CDN import / `<script src>` / `<link href>` / `fetch()`).
15
+ #
16
+ # Outbound *content* links in page copy (`<a href="https://...">`)
17
+ # are intentionally allowed — the guarantee is about runtime asset
18
+ # loading, not about whether the page links elsewhere.
19
+ #
20
+ # Stateless; returns an Array of `Result` (level :ok / :warn / :error
21
+ # — the same shape as `Doctor::Result`) so both `doctor` and any
22
+ # future caller can consume it without duplication.
23
+ class OfflineVerifier
24
+ Result = Struct.new(:level, :message, keyword_init: true)
25
+
26
+ # A remote URL appearing in an asset-loading position. We only flag
27
+ # absolute (`https://host`, `http://host`) and protocol-relative
28
+ # (`//host`) URLs; root-relative (`/vendor/...`) and bare-relative
29
+ # (`./x.js`) paths are same-origin and fine. Captured inside the
30
+ # asset-context patterns below so prose links don't false-positive.
31
+ REMOTE_URL = %r{(?:https?:)?//[^"'\s)]+}
32
+
33
+ # Asset-loading contexts whose URL must be local. Each captures the
34
+ # URL into group 1.
35
+ ASSET_CONTEXTS = [
36
+ /\bimport\s+[^"']*["'](#{REMOTE_URL})["']/, # import ... from "URL" / import "URL"
37
+ /\bimport\s*\(\s*["'](#{REMOTE_URL})["']/, # dynamic import("URL")
38
+ /\bfrom\s+["'](#{REMOTE_URL})["']/, # from "URL"
39
+ /\bfetch\s*\(\s*["'](#{REMOTE_URL})["']/, # fetch("URL")
40
+ /<script\b[^>]*\bsrc\s*=\s*["'](#{REMOTE_URL})["']/i, # <script src="URL">
41
+ /<link\b[^>]*\bhref\s*=\s*["'](#{REMOTE_URL})["']/i, # <link href="URL"> (stylesheet/modulepreload)
42
+ /\bnew\s+URL\s*\(\s*["'](#{REMOTE_URL})["']/, # new URL("URL", ...)
43
+ ].freeze
44
+
45
+ def initialize(output_dir)
46
+ @output_dir = output_dir
47
+ end
48
+
49
+ # Returns Array<Result>. Empty of :error/:warn ⇒ self-contained.
50
+ def verify
51
+ results = []
52
+ results.concat(check_assets)
53
+ results.concat(check_remote_refs)
54
+ if results.empty?
55
+ results << ok("dist is self-contained (offline-runnable): runtime assets " \
56
+ "vendored locally, no remote asset URLs")
57
+ end
58
+ results
59
+ end
60
+
61
+ private
62
+
63
+ # Auto-detect which runtime the dist was built with by the presence
64
+ # of its `vendor/lilac-{full,compiled}/` directory (a build emits
65
+ # exactly one), then assert that runtime's required files are all
66
+ # there. A dist with no vendored runtime can't run offline at all.
67
+ def check_assets
68
+ warnings = []
69
+ any_present = false
70
+ VendorWriter::REQUIRED_ASSETS.each_value do |files|
71
+ # The target's vendor dir is the wasm's parent — derived from
72
+ # the SSOT so the dir name isn't independently reconstructed.
73
+ next unless File.directory?(File.join(@output_dir, File.dirname(files[:wasm])))
74
+ any_present = true
75
+ files.each_value do |rel|
76
+ next if File.file?(File.join(@output_dir, rel))
77
+ warnings << warn("incomplete vendored runtime: missing #{rel}")
78
+ end
79
+ end
80
+ warnings << warn("no vendored runtime under vendor/ — dist won't run offline (build with a discoverable runtime)") unless any_present
81
+ warnings
82
+ end
83
+
84
+ def check_remote_refs
85
+ scan_files.flat_map do |file|
86
+ remote_urls_in(File.read(file)).map do |url|
87
+ warn("remote asset URL in #{relative(file)}: #{url} — vendor it locally for offline use")
88
+ end
89
+ end
90
+ end
91
+
92
+ def scan_files
93
+ Dir.glob(File.join(@output_dir, "**", "*.{html,js}"), File::FNM_DOTMATCH)
94
+ .select { |p| File.file?(p) }
95
+ end
96
+
97
+ # All distinct remote URLs that appear in an asset-loading context.
98
+ def remote_urls_in(text)
99
+ ASSET_CONTEXTS.flat_map { |re| text.scan(re).map { |m| m[0] } }.uniq
100
+ end
101
+
102
+ def relative(path)
103
+ Pathname.new(path).relative_path_from(Pathname.new(@output_dir)).to_s
104
+ rescue ArgumentError
105
+ path
106
+ end
107
+
108
+ def ok(msg)
109
+ Result.new(level: :ok, message: msg)
110
+ end
111
+
112
+ def warn(msg)
113
+ Result.new(level: :warn, message: msg)
114
+ end
115
+ end
116
+ end
117
+ end
@@ -0,0 +1,68 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'fileutils'
4
+
5
+ require_relative 'build/build_error'
6
+ require_relative 'build/bytecode_builder'
7
+
8
+ module Lilac
9
+ module CLI
10
+ # Compile one or more pure-Ruby package source files (mrblib-style:
11
+ # Handler class definitions plus
12
+ # `Lilac::Directives::Scanner.register("ClassName")` calls) into a
13
+ # single `.mrb` bytecode file that can be loaded into a running
14
+ # `lilac-compiled` VM via `vm.loadBytecode(bytes)`.
15
+ #
16
+ # Wire-level: this is a thin wrapper around `BytecodeBuilder` (which
17
+ # owns mrbc backend discovery + the wasm-driven mrbc fallback). The
18
+ # difference from `build` is that package `.mrb` files have a
19
+ # user-specified output path (no content-hash filename) and are
20
+ # never wrapped with `Lilac.start` / framework boot — they're
21
+ # library-style code that registers directives / defines classes at
22
+ # load time and then yields control back.
23
+ #
24
+ # Multiple inputs are concatenated in the order given, separated by
25
+ # newlines, before compiling. This mirrors how mruby builds gems
26
+ # from their `mrblib/*.rb` set (alphabetical concat → single irep).
27
+ class PackageBuild
28
+ class Error < BuildError; end
29
+
30
+ def initialize(inputs:, output:, mrbc_path: nil)
31
+ raise Error, 'package-build: at least one input file required' if inputs.empty?
32
+
33
+ @inputs = inputs
34
+ @output = output
35
+ @mrbc_path = mrbc_path
36
+ end
37
+
38
+ attr_reader :inputs, :output
39
+
40
+ # Compile + write. Returns the absolute output path.
41
+ def run
42
+ source = aggregate_sources
43
+ builder = BytecodeBuilder.new(mrbc_path: @mrbc_path)
44
+ bytecode = builder.compile_to_bytes(source, source_label: source_label)
45
+
46
+ FileUtils.mkdir_p(File.dirname(@output))
47
+ File.binwrite(@output, bytecode)
48
+ File.expand_path(@output)
49
+ rescue BytecodeBuilder::Error => e
50
+ raise Error, e.message
51
+ end
52
+
53
+ private
54
+
55
+ def aggregate_sources
56
+ @inputs.map do |path|
57
+ raise Error, "package-build: input file not found: #{path}" unless File.file?(path)
58
+
59
+ File.read(path)
60
+ end.join("\n")
61
+ end
62
+
63
+ def source_label
64
+ @inputs.length == 1 ? @inputs.first : "#{@inputs.length} files (#{@inputs.first}, ...)"
65
+ end
66
+ end
67
+ end
68
+ end
@@ -0,0 +1,77 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Lilac
4
+ module CLI
5
+ # Walks `Bundler.load.specs` to find gems that declare themselves as
6
+ # Lilac packages via `metadata["lilac_package"] == "true"`.
7
+ #
8
+ # Each entry is described by `Discovered` (gem name, version, mrblib
9
+ # source files). `Builder` feeds the source files into
10
+ # `PackageBuild#compile_to_bytes` at build time and stages the
11
+ # produced `.mrb` under `dist/packages/`.
12
+ #
13
+ # If Bundler isn't loaded (e.g. `lilac-cli` is being run from a
14
+ # context without a Gemfile), discovery silently returns an empty
15
+ # list. Users who want explicit control set
16
+ # `c.packages = [...paths]` in `lilac.config.rb`, which goes through
17
+ # a different code path and bypasses discovery.
18
+ class PackageDiscovery
19
+ # Metadata key on a gemspec that marks the gem as a Lilac package.
20
+ # Set via `spec.metadata["lilac_package"] = "true"` in the
21
+ # `lilac-*.gemspec`.
22
+ METADATA_KEY = "lilac_package"
23
+
24
+ Discovered = Struct.new(:name, :mrblib_files, keyword_init: true)
25
+
26
+ def self.run
27
+ new.run
28
+ end
29
+
30
+ # Returns Array<Discovered>. Empty when Bundler isn't loaded or
31
+ # when no Lilac packages are declared in the current Gemfile.
32
+ def run
33
+ return [] unless bundler_loaded?
34
+
35
+ bundler_specs.filter_map do |spec|
36
+ next unless package_spec?(spec)
37
+
38
+ files = mrblib_files_for(spec)
39
+ next if files.empty?
40
+
41
+ Discovered.new(name: spec.name, mrblib_files: files)
42
+ end
43
+ end
44
+
45
+ private
46
+
47
+ # `Bundler.load.specs` (vs `Bundler.definition.specs`) returns the
48
+ # set of gems active in the current bundle — i.e. what's actually
49
+ # available to `require`. That's exactly the lens we want: packages
50
+ # listed in the user's Gemfile but uninstalled (or absent) are
51
+ # skipped silently rather than raising mid-build.
52
+ def bundler_specs
53
+ Bundler.load.specs.to_a
54
+ rescue Bundler::GemfileNotFound, Bundler::BundlerError
55
+ # Outside a bundle / Gemfile-less project: no packages to find.
56
+ # Explicit `c.packages` paths still work — see `Config#packages`.
57
+ []
58
+ end
59
+
60
+ def bundler_loaded?
61
+ defined?(Bundler) && Bundler.respond_to?(:load)
62
+ end
63
+
64
+ def package_spec?(spec)
65
+ spec.metadata && spec.metadata[METADATA_KEY] == "true"
66
+ end
67
+
68
+ # mrblib source lives at `mrblib/**/*.rb` in each package gem's
69
+ # root. Returns absolute paths in alphabetical order so the
70
+ # concatenation (`PackageBuild#aggregate_sources`) is deterministic.
71
+ def mrblib_files_for(spec)
72
+ base = spec.full_gem_path
73
+ Dir[File.join(base, "mrblib", "**", "*.rb")].sort
74
+ end
75
+ end
76
+ end
77
+ end
@@ -0,0 +1,70 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "wsv"
4
+
5
+ module Lilac
6
+ module CLI
7
+ # Static server for the `lilac build` output. The intent is "ship-
8
+ # parity preview": serve `dist/` as a CDN / static host would, with
9
+ # no file watcher, no live reload, no rebuild. Use this to verify
10
+ # the production-mode (default `--target compiled`) output before
11
+ # deploy.
12
+ #
13
+ # Mirrors `vite preview` semantics: separate command, separate
14
+ # default port from `lilac dev`, no automation.
15
+ class PreviewServer
16
+ class Error < BuildError; end
17
+
18
+ DEFAULT_HOST = "127.0.0.1"
19
+ DEFAULT_PORT = 4173
20
+
21
+ def initialize(output_dir, host: DEFAULT_HOST, port: DEFAULT_PORT, out: $stdout, err: $stderr)
22
+ @output_dir = output_dir
23
+ @host = host
24
+ @port = port
25
+ @out = out
26
+ @err = err
27
+ end
28
+
29
+ def start
30
+ verify_dist!
31
+ @server = build_server
32
+ @out.puts banner
33
+ @server.start
34
+ end
35
+
36
+ def stop
37
+ @server&.stop
38
+ end
39
+
40
+ private
41
+
42
+ def verify_dist!
43
+ unless File.directory?(@output_dir)
44
+ raise Error,
45
+ "lilac preview: output directory #{@output_dir.inspect} does not exist. " \
46
+ "Run `lilac build` first."
47
+ end
48
+ return if Dir.glob(File.join(@output_dir, "*.html")).any?
49
+ raise Error,
50
+ "lilac preview: no HTML files under #{@output_dir.inspect}. " \
51
+ "Run `lilac build` first."
52
+ end
53
+
54
+ def build_server
55
+ Wsv::Server.new(
56
+ host: @host,
57
+ port: @port,
58
+ root: @output_dir,
59
+ out: @out,
60
+ err: @err,
61
+ app: Wsv::App.new(File.realpath(@output_dir)),
62
+ )
63
+ end
64
+
65
+ def banner
66
+ "lilac preview: serving #{@output_dir} at http://#{@host}:#{@port}"
67
+ end
68
+ end
69
+ end
70
+ end
@@ -0,0 +1,85 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "fileutils"
4
+ require "pathname"
5
+
6
+ module Lilac
7
+ module CLI
8
+ # Generates a new Lilac app skeleton under `<root>/<name>/`:
9
+ #
10
+ # <name>/
11
+ # ├── .gitignore
12
+ # ├── Gemfile
13
+ # ├── README.md
14
+ # ├── pages/index.html
15
+ # └── components/counter.lil
16
+ #
17
+ # Templates live under `lib/lilac/cli/templates/`. Files are copied
18
+ # 1:1 with `{{name}}` substituted to the chosen project name. The
19
+ # `.gitignore` template is shipped as `gitignore` (no leading dot) so
20
+ # the gem's own working-directory tools don't treat it as a directive
21
+ # for the templates folder; it gets the dot prefix at copy time.
22
+ class Scaffold
23
+ class Error < StandardError; end
24
+
25
+ TEMPLATES_DIR = File.expand_path("templates", __dir__)
26
+
27
+ # Project names must look like a valid directory + future gem-ish
28
+ # identifier: ASCII lowercase letters, digits, hyphens, underscores,
29
+ # starting with a letter.
30
+ NAME_PATTERN = /\A[a-z][a-z0-9_-]*\z/
31
+
32
+ def initialize(name, root: Dir.pwd)
33
+ @name = name
34
+ @root = root
35
+ validate_name!
36
+ end
37
+
38
+ # Returns the list of relative paths written.
39
+ def run
40
+ dest = File.expand_path(@name, @root)
41
+ raise Error, "Destination already exists: #{dest}" if File.exist?(dest)
42
+
43
+ FileUtils.mkdir_p(dest)
44
+ copy_templates(dest)
45
+ end
46
+
47
+ private
48
+
49
+ def validate_name!
50
+ raise Error, "Project name is required" if @name.nil? || @name.empty?
51
+ unless @name.match?(NAME_PATTERN)
52
+ raise Error, "Invalid project name #{@name.inspect}; must match [a-z][a-z0-9_-]*"
53
+ end
54
+ end
55
+
56
+ def copy_templates(dest_root)
57
+ # FNM_DOTMATCH so dot-prefixed templates (e.g. `public/.gitkeep`)
58
+ # are included. `File.file?` already excludes the `.` / `..`
59
+ # directory entries that FNM_DOTMATCH surfaces.
60
+ sources = Dir.glob(File.join(TEMPLATES_DIR, "**", "*"), File::FNM_DOTMATCH)
61
+ .select { |p| File.file?(p) }
62
+ .sort
63
+
64
+ sources.map do |source|
65
+ rel = relative_template_path(source)
66
+ target = File.join(dest_root, rel)
67
+ FileUtils.mkdir_p(File.dirname(target))
68
+ File.write(target, substitute(File.read(source)))
69
+ rel
70
+ end
71
+ end
72
+
73
+ def relative_template_path(source)
74
+ rel = Pathname.new(source).relative_path_from(Pathname.new(TEMPLATES_DIR)).to_s
75
+ # `gitignore` → `.gitignore` only when it's a top-level entry; a
76
+ # `components/gitignore` wouldn't be silently renamed.
77
+ rel == "gitignore" ? ".gitignore" : rel
78
+ end
79
+
80
+ def substitute(content)
81
+ content.gsub("{{name}}", @name)
82
+ end
83
+ end
84
+ end
85
+ end
@@ -0,0 +1,16 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Lilac
4
+ module CLI
5
+ # `(file, line)` pair that appears in every build-time error and
6
+ # lint warning. Kept as one value so error/warning constructors take
7
+ # a single `at:` kwarg instead of two, and so future additions
8
+ # (column, snippet excerpt, end-line for ranges) can be tacked on
9
+ # here without rippling through every raise site.
10
+ SourceLocation = Struct.new(:file, :line, keyword_init: true) do
11
+ def to_s
12
+ "#{file}:#{line}"
13
+ end
14
+ end
15
+ end
16
+ end
@@ -0,0 +1,65 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "optparse"
4
+ require "stringio"
5
+ require "pathname"
6
+ require_relative "option_helpers"
7
+
8
+ module Lilac
9
+ module CLI
10
+ module Subcommand
11
+ # Base for `lilac <subcommand>` handlers. Each subclass:
12
+ #
13
+ # 1. defines `opts_parser(opts)` returning an OptionParser whose
14
+ # callbacks populate `opts`
15
+ # 2. defines `run` returning the desired process exit status
16
+ #
17
+ # `Command` instantiates the right subclass based on argv[0] and
18
+ # delegates. Exception handling (turning `Builder::Error` etc. into
19
+ # "lilac: <msg>" + status 1) stays in Command's outer rescue so the
20
+ # error surface is uniform across subcommands.
21
+ class Base
22
+ def initialize(argv, out: $stdout, err: $stderr)
23
+ @argv = argv
24
+ @out = out
25
+ @err = err
26
+ end
27
+
28
+ def run
29
+ raise NotImplementedError
30
+ end
31
+
32
+ # `lilac help <name>` dispatcher uses this to render per-subcommand
33
+ # help. Sink IO so the `-h` callback some `opts_parser` definitions
34
+ # bake in doesn't fire during help rendering.
35
+ def self.help_text
36
+ sink = StringIO.new
37
+ new([], out: sink, err: sink).send(:opts_parser, {}).to_s
38
+ end
39
+
40
+ private
41
+
42
+ # Default `parse!` (rearranges argv so flags can appear after
43
+ # positionals). Subcommands like `new` that need `order!` (stops
44
+ # at first non-option) override this.
45
+ def parse_opts
46
+ opts = {}
47
+ opts_parser(opts).parse!(@argv)
48
+ opts
49
+ end
50
+
51
+ def opts_parser(_opts)
52
+ raise NotImplementedError
53
+ end
54
+
55
+ # Path display helper used by subcommands that report output paths
56
+ # in user-facing messages.
57
+ def relative(path)
58
+ Pathname.new(path).relative_path_from(Pathname.new(Dir.pwd)).to_s
59
+ rescue ArgumentError
60
+ path
61
+ end
62
+ end
63
+ end
64
+ end
65
+ end