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