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,51 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../source_location"
|
|
4
|
+
|
|
5
|
+
module Lilac
|
|
6
|
+
module CLI
|
|
7
|
+
# A single `data-*` directive occurrence on a template element.
|
|
8
|
+
#
|
|
9
|
+
# `kind` is the directive family symbol (e.g. `:text`, `:on`, `:attr`,
|
|
10
|
+
# `:each`). For X-family directives (`data-on-X`, `data-attr-X`,
|
|
11
|
+
# `data-arg-X`, `data-css-X`), `name` carries the X part as it appears in
|
|
12
|
+
# the source (kebab-case, e.g. "click" / "background-color"). For
|
|
13
|
+
# non-X-family directives `name` is `nil`.
|
|
14
|
+
#
|
|
15
|
+
# `value` is the raw attribute value string (e.g. `"@count"` /
|
|
16
|
+
# `"increment"` / `"{ active: @s }"`). TemplateAST only collects the
|
|
17
|
+
# raw string; grammar validation lives in the lint layer.
|
|
18
|
+
#
|
|
19
|
+
# `ref_id` is the synthetic or explicit ref name assigned by
|
|
20
|
+
# `TemplateAST`, used by the lint layer to group directives sharing
|
|
21
|
+
# an element and to detect duplicate `data-ref` declarations.
|
|
22
|
+
#
|
|
23
|
+
# `line` is the source line in the template body (1-based,
|
|
24
|
+
# Nokogiri's `node.line`), used for error reporting.
|
|
25
|
+
#
|
|
26
|
+
# `element_tag` is the HTML element name (e.g. "div", "button"),
|
|
27
|
+
# consumed by Lilac::Directives::Lints to enforce applicability rules.
|
|
28
|
+
#
|
|
29
|
+
# `scope_id` is the `ref_id` of the enclosing `data-each` element,
|
|
30
|
+
# or `nil` for directives at the component's top level. The lint
|
|
31
|
+
# layer uses it to scope duplicate-ref detection per iteration body.
|
|
32
|
+
Directive = Struct.new(
|
|
33
|
+
:kind,
|
|
34
|
+
:name,
|
|
35
|
+
:value,
|
|
36
|
+
:ref_id,
|
|
37
|
+
:line,
|
|
38
|
+
:element_tag,
|
|
39
|
+
:scope_id,
|
|
40
|
+
keyword_init: true,
|
|
41
|
+
) do
|
|
42
|
+
# Pair the directive's line with the source file (which the
|
|
43
|
+
# Directive itself doesn't carry) for use in BuildError /
|
|
44
|
+
# LintWarning `at:` kwargs. Saves callers from writing
|
|
45
|
+
# `SourceLocation.new(file: f, line: directive.line)` over and over.
|
|
46
|
+
def source_location(file)
|
|
47
|
+
SourceLocation.new(file: file, line: line)
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
end
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "runtime_resolver"
|
|
4
|
+
|
|
5
|
+
module Lilac
|
|
6
|
+
module CLI
|
|
7
|
+
# Locates the `target: :full` runtime that gets vendored into
|
|
8
|
+
# `dist/vendor/lilac-full/`:
|
|
9
|
+
#
|
|
10
|
+
# * `lilac-full.wasm` — the full-variant wasm bundle (parser +
|
|
11
|
+
# directive scanner + bundled gems)
|
|
12
|
+
# * `mruby-wasm-js/` — the JS↔mruby bridge (resolved by the shared
|
|
13
|
+
# base — same artifact as the :compiled target)
|
|
14
|
+
#
|
|
15
|
+
# Only the wasm-specific discovery differs from `:compiled`, so this
|
|
16
|
+
# class supplies just those hooks. See `RuntimeResolver`.
|
|
17
|
+
class FullRuntimeResolver < RuntimeResolver
|
|
18
|
+
# Distinct from CompiledRuntimeResolver::Error; both < RuntimeResolver::Error.
|
|
19
|
+
class Error < RuntimeResolver::Error; end
|
|
20
|
+
|
|
21
|
+
def initialize(lilac_full_path: nil, **opts)
|
|
22
|
+
super(lilac_wasm_path: lilac_full_path, **opts)
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
private
|
|
26
|
+
|
|
27
|
+
def wasm_env_key
|
|
28
|
+
"LILAC_FULL_WASM"
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def gem_provided_wasm
|
|
32
|
+
return nil if @disable_gem_discovery
|
|
33
|
+
require "lilac/wasm/bin"
|
|
34
|
+
::Lilac::Wasm::Bin.lilac_full_wasm
|
|
35
|
+
rescue LoadError
|
|
36
|
+
nil
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def monorepo_wasm_candidate
|
|
40
|
+
File.join(monorepo_root, "build", "lilac-full.wasm")
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def wasm_not_found_message
|
|
44
|
+
<<~MSG.strip
|
|
45
|
+
lilac-full.wasm not found. Tried:
|
|
46
|
+
• configured `c.lilac_full_path` (#{@configured_wasm_path.inspect})
|
|
47
|
+
• ENV["LILAC_FULL_WASM"] (#{ENV["LILAC_FULL_WASM"].inspect})
|
|
48
|
+
• lilac-wasm-bin gem (not on load path)
|
|
49
|
+
• monorepo: #{monorepo_wasm_candidate}
|
|
50
|
+
|
|
51
|
+
To fix, either:
|
|
52
|
+
• Add `gem "lilac-wasm-bin"` to your Gemfile (recommended — the scaffolded Gemfile from `lilac new` already includes it)
|
|
53
|
+
• Pass `--lilac-full-path /abs/path/to/lilac-full.wasm` on the command line
|
|
54
|
+
• Add `c.lilac_full_path = "/abs/path"` to lilac.config.rb
|
|
55
|
+
• Set ENV["LILAC_FULL_WASM"]
|
|
56
|
+
• In the monorepo: run `make lilac-full` to produce build/lilac-full.wasm
|
|
57
|
+
MSG
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
end
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Lilac
|
|
4
|
+
module CLI
|
|
5
|
+
# Pure HTML string-rendering helpers shared by `Builder` (bundle file
|
|
6
|
+
# emission) and `PageCompiler` (per-page injection). Keeping these as
|
|
7
|
+
# module functions makes the dependency direction obvious (stateless
|
|
8
|
+
# utilities) and trims surface area from the larger classes.
|
|
9
|
+
module HtmlEmitter
|
|
10
|
+
module_function
|
|
11
|
+
|
|
12
|
+
def render_named_template(template_name, body_html)
|
|
13
|
+
%(<template data-template="#{escape_attr(template_name)}">#{body_html}</template>)
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
# Emit a <template> wrapping the component's default markup. The
|
|
17
|
+
# `default_html` already contains the outer `<div data-component="X">`
|
|
18
|
+
# element from the .lil source, so we don't add another wrapper —
|
|
19
|
+
# just surround it with <template> so the runtime registry can pick
|
|
20
|
+
# it up as the source for data-use="X" injections.
|
|
21
|
+
def render_default_template(default_html)
|
|
22
|
+
%(<template>#{default_html}</template>)
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def render_script(ruby_source)
|
|
26
|
+
"<script type=\"text/ruby\">\n#{ruby_source}\n</script>"
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def escape_attr(value)
|
|
30
|
+
value.gsub('&', '&').gsub('"', '"').gsub('<', '<')
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
# Splice text just before the page's </body>. Prefers the last
|
|
34
|
+
# </body> in the source so any earlier mention (e.g. inside a
|
|
35
|
+
# <pre> code example) doesn't get hijacked.
|
|
36
|
+
def inject_before_body_close(html, injection)
|
|
37
|
+
idx = html.rindex(%r{</body>}i)
|
|
38
|
+
return "#{html}\n#{injection}" unless idx
|
|
39
|
+
|
|
40
|
+
"#{html[0...idx]}#{injection}\n#{html[idx..]}"
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
# Inject `<link rel="lilac-bundle" href="...">` into the page's
|
|
44
|
+
# <head>. Falls back to before <body>, then to a prepend, when the
|
|
45
|
+
# page HTML is handwritten without standard boundary tags.
|
|
46
|
+
def inject_bundle_link(html, url)
|
|
47
|
+
link = %(<link rel="lilac-bundle" href="#{escape_attr(url)}">)
|
|
48
|
+
if html =~ %r{</head>}i
|
|
49
|
+
html.sub(%r{</head>}i, " #{link}\n</head>")
|
|
50
|
+
elsif html =~ %r{<body}i
|
|
51
|
+
html.sub(%r{<body}i, "#{link}\n<body")
|
|
52
|
+
else
|
|
53
|
+
"#{link}\n#{html}"
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
end
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'fileutils'
|
|
4
|
+
require 'json'
|
|
5
|
+
require_relative '../package_discovery'
|
|
6
|
+
|
|
7
|
+
module Lilac
|
|
8
|
+
module CLI
|
|
9
|
+
# Stages Lilac package `.mrb` files under `dist/packages/` and (for
|
|
10
|
+
# the :full target) writes the `lilac.packages.json` manifest the
|
|
11
|
+
# scaffold-style boot script reads to load each package ahead of
|
|
12
|
+
# `evalScript`.
|
|
13
|
+
#
|
|
14
|
+
# Two input channels merge here (decisions §25 / §26):
|
|
15
|
+
#
|
|
16
|
+
# 1. **Bundler auto-discovery** — `PackageDiscovery.run` finds gems
|
|
17
|
+
# whose gemspec declares `metadata["lilac_package"] = "true"`.
|
|
18
|
+
# Each gem's `mrblib/*.rb` is compiled locally to `.mrb` so the
|
|
19
|
+
# mruby version matches the vendored core wasm.
|
|
20
|
+
# 2. **Explicit `c.packages = [...paths]`** — pre-compiled `.mrb`
|
|
21
|
+
# files. Useful for advanced overrides (custom package not in
|
|
22
|
+
# a gem, vendored fork, etc.).
|
|
23
|
+
#
|
|
24
|
+
# Manifest emission is target-dependent:
|
|
25
|
+
# - `:full` writes `dist/lilac.packages.json` so the user's hand-
|
|
26
|
+
# rolled boot script can `fetch` it and `loadBytecode` each entry
|
|
27
|
+
# - `:compiled` skips the manifest — the generated boot module
|
|
28
|
+
# inlines `loadBytecode` calls directly
|
|
29
|
+
#
|
|
30
|
+
# Sibling of `BundleAssetWriter` / `VendorWriter`: same shape (a
|
|
31
|
+
# build phase owning one cluster of file emissions), invoked once
|
|
32
|
+
# from `Builder#build`.
|
|
33
|
+
class PackageStager
|
|
34
|
+
PACKAGES_DIR = 'packages'
|
|
35
|
+
MANIFEST_FILENAME = 'lilac.packages.json'
|
|
36
|
+
|
|
37
|
+
def initialize(packages:, target:, output_dir:, bytecode_builder:)
|
|
38
|
+
@packages = packages
|
|
39
|
+
@target = target
|
|
40
|
+
@output_dir = output_dir
|
|
41
|
+
@bytecode_builder = bytecode_builder
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
# Returns `Array<String>` — page-relative URLs of every staged
|
|
45
|
+
# `.mrb`. Empty when no packages are configured or discovered.
|
|
46
|
+
def run!
|
|
47
|
+
return [] if @packages.empty? && discovered_packages.empty?
|
|
48
|
+
|
|
49
|
+
FileUtils.mkdir_p(File.join(@output_dir, PACKAGES_DIR))
|
|
50
|
+
urls = (stage_discovered + stage_explicit).uniq
|
|
51
|
+
write_manifest!(urls) if @target == :full
|
|
52
|
+
urls
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
private
|
|
56
|
+
|
|
57
|
+
# Cached so multi-page builds discover once. Empty list outside
|
|
58
|
+
# a Bundler context.
|
|
59
|
+
def discovered_packages
|
|
60
|
+
@discovered_packages ||= PackageDiscovery.run
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
# Auto-discovered gem-based packages first. Compile each gem's
|
|
64
|
+
# mrblib source set to a single `.mrb` named after the gem so
|
|
65
|
+
# filename collisions with explicit override paths are easy to
|
|
66
|
+
# spot.
|
|
67
|
+
def stage_discovered
|
|
68
|
+
discovered_packages.map do |discovered|
|
|
69
|
+
bytes = compile_source(discovered.mrblib_files, source_label: discovered.name)
|
|
70
|
+
filename = "#{discovered.name}.mrb"
|
|
71
|
+
File.binwrite(File.join(@output_dir, PACKAGES_DIR, filename), bytes)
|
|
72
|
+
"./#{PACKAGES_DIR}/#{filename}"
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
# Explicit override paths next — already-compiled `.mrb` files
|
|
77
|
+
# the user pointed at directly via `c.packages`.
|
|
78
|
+
def stage_explicit
|
|
79
|
+
@packages.map do |src|
|
|
80
|
+
raise Builder::Error, "Lilac package `.mrb` not found: #{src}" unless File.file?(src)
|
|
81
|
+
|
|
82
|
+
basename = File.basename(src)
|
|
83
|
+
FileUtils.cp(src, File.join(@output_dir, PACKAGES_DIR, basename))
|
|
84
|
+
"./#{PACKAGES_DIR}/#{basename}"
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
# Compile concatenated mrblib source via the shared
|
|
89
|
+
# `BytecodeBuilder` backend chain (binary mrbc → wasm-driven mrbc
|
|
90
|
+
# → $PATH). Mirrors `lilac package-build`'s aggregation rule:
|
|
91
|
+
# alphabetical concat separated by newlines.
|
|
92
|
+
def compile_source(mrblib_files, source_label:)
|
|
93
|
+
source = mrblib_files.map { |f| File.read(f) }.join("\n")
|
|
94
|
+
@bytecode_builder.compile_to_bytes(source, source_label: "package #{source_label}")
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
# Write `dist/lilac.packages.json` so a scaffold-style boot script
|
|
98
|
+
# can fetch the manifest and `loadBytecode` each entry before
|
|
99
|
+
# evaluating `<script type="text/ruby">` blocks. The manifest is
|
|
100
|
+
# only written when at least one package was staged, so absence of
|
|
101
|
+
# the file means "no packages" — boot scripts can fetch with a
|
|
102
|
+
# graceful 404 fallback.
|
|
103
|
+
def write_manifest!(package_urls)
|
|
104
|
+
return if package_urls.empty?
|
|
105
|
+
|
|
106
|
+
manifest_path = File.join(@output_dir, MANIFEST_FILENAME)
|
|
107
|
+
File.write(manifest_path, JSON.pretty_generate(packages: package_urls) + "\n")
|
|
108
|
+
end
|
|
109
|
+
end
|
|
110
|
+
end
|
|
111
|
+
end
|
|
@@ -0,0 +1,405 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "fileutils"
|
|
4
|
+
require "pathname"
|
|
5
|
+
require "set"
|
|
6
|
+
require_relative "sfc"
|
|
7
|
+
require_relative "component_scripts_assembler"
|
|
8
|
+
require_relative "html_emitter"
|
|
9
|
+
require_relative "compiled_boot_module"
|
|
10
|
+
require_relative "../live_reload"
|
|
11
|
+
|
|
12
|
+
module Lilac
|
|
13
|
+
module CLI
|
|
14
|
+
# Compiles one source `pages/*.html` file into a dist HTML, given the
|
|
15
|
+
# build-level `BuildContext`. Handles:
|
|
16
|
+
#
|
|
17
|
+
# - page-inline `<script type="text/ruby">` extraction
|
|
18
|
+
# - page-inline `<X data-component=...>` synthesis (Nokogiri pass)
|
|
19
|
+
# - data-use=X reference scanning
|
|
20
|
+
# - per-target / per-delivery dispatch (4 combos: full×inline,
|
|
21
|
+
# full×bundle, compiled×inline, compiled×bundle — including the
|
|
22
|
+
# chained .mrb boot module for compiled×bundle×page-inline)
|
|
23
|
+
#
|
|
24
|
+
# The outer `Builder.build` constructs the context once and invokes
|
|
25
|
+
# `compile(page_path)` for each page; the compiler itself holds no
|
|
26
|
+
# build-level state. See ADR-29 for the data-component / data-use
|
|
27
|
+
# split and decisions §20.6 for the `Lilac.start` placement rationale.
|
|
28
|
+
class PageCompiler
|
|
29
|
+
# Matches `data-use="X"` / `data-use='X'` attribute values on any
|
|
30
|
+
# element. We don't need to know which tag carries the attribute —
|
|
31
|
+
# just collect the referenced component names so the build can
|
|
32
|
+
# bundle their templates + scripts into the page.
|
|
33
|
+
DATA_USE_PATTERN = %r{
|
|
34
|
+
\bdata-use\s*=\s*(?:"([^"]+)"|'([^']+)')
|
|
35
|
+
}x
|
|
36
|
+
|
|
37
|
+
# Output of `build_injection`. The injection HTML is what gets
|
|
38
|
+
# spliced before `</body>`; `page_local_mrb` is non-nil only in
|
|
39
|
+
# the :compiled × :bundle case, where the per-page bytecode has
|
|
40
|
+
# to be chained into the boot module instead of getting its own
|
|
41
|
+
# `<script>` tag.
|
|
42
|
+
Injection = Struct.new(:html, :page_local_mrb, keyword_init: true) do
|
|
43
|
+
def empty?
|
|
44
|
+
html.to_s.empty?
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def initialize(context)
|
|
49
|
+
@ctx = context
|
|
50
|
+
@scripts_assembler = ComponentScriptsAssembler.new(
|
|
51
|
+
template_cache: context.template_cache
|
|
52
|
+
)
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def compile(page_path)
|
|
56
|
+
html = File.read(page_path)
|
|
57
|
+
|
|
58
|
+
# Inline `<script type="text/ruby">` blocks in the page itself are
|
|
59
|
+
# surfaced to the injection step so they're not silently dropped on
|
|
60
|
+
# the compiled target. The tags themselves stay in the dist HTML
|
|
61
|
+
# for both targets:
|
|
62
|
+
# - target=full — the runtime parser evaluates them in place
|
|
63
|
+
# via `vm.evalScript`
|
|
64
|
+
# - target=compiled — the wasm has no parser so the tags are
|
|
65
|
+
# dead text (browser ignores `text/ruby`), but they remain
|
|
66
|
+
# so "view source" works, and features like the 7guis
|
|
67
|
+
# `source-display` mirror still find the page's Ruby. Size
|
|
68
|
+
# cost is marginal (a few KB, fully compressible) and the
|
|
69
|
+
# `.mrb` value prop (no parser in wasm) is unaffected
|
|
70
|
+
extracted = SFC.extract_inline_ruby_scripts(html, path: page_path)
|
|
71
|
+
page_inline_scripts = extracted[:scripts]
|
|
72
|
+
|
|
73
|
+
# Page-inline `<X data-component="...">` elements are folded into
|
|
74
|
+
# the same pipeline that handles `.lil` components: we register
|
|
75
|
+
# an in-memory `SFC::Component` for each (snapshot of the
|
|
76
|
+
# element's outer HTML). data-each rows stay IN-PLACE (the runtime
|
|
77
|
+
# scanner snapshots the live innerHTML and clones per item), and
|
|
78
|
+
# runtime resolves `refs.lilN` positionally, so nothing else
|
|
79
|
+
# about the page markup needs to change.
|
|
80
|
+
components, html, used_inline, synthesized_names = synthesize_page_inline_components(
|
|
81
|
+
html, page_path: page_path
|
|
82
|
+
)
|
|
83
|
+
|
|
84
|
+
# R4: page-inline script classes that collide with `.lil`-derived
|
|
85
|
+
# class names (project-global) are flagged here. Done AFTER
|
|
86
|
+
# synthesize_page_inline_components so synthesized in-memory
|
|
87
|
+
# components (which carry no script) don't trigger false positives,
|
|
88
|
+
# but BEFORE build_injection so the user sees a structured error
|
|
89
|
+
# instead of a downstream mrbc failure.
|
|
90
|
+
@ctx.build_linter.check_class_name_collisions!(page_inline_scripts, components, synthesized_names, page_path)
|
|
91
|
+
|
|
92
|
+
used = used_inline.dup
|
|
93
|
+
# Page references to components via data-use="X" — the runtime
|
|
94
|
+
# injects markup from the matching <template>...<div data-component="X">
|
|
95
|
+
# definition that build_injection emits below. We only need to
|
|
96
|
+
# record each X in `used` so its template + script land in the
|
|
97
|
+
# injection bundle.
|
|
98
|
+
html.scan(DATA_USE_PATTERN) do |dq, sq|
|
|
99
|
+
name = dq || sq
|
|
100
|
+
unless components.key?(name)
|
|
101
|
+
raise Builder::Error,
|
|
102
|
+
"Unknown component referenced by data-use=#{name.inspect} in #{page_path} " \
|
|
103
|
+
"(no components/#{name}.lil and no page-inline data-component=#{name.inspect})"
|
|
104
|
+
end
|
|
105
|
+
used << name
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
html =
|
|
109
|
+
if @ctx.delivery == :bundle
|
|
110
|
+
inject_bundle_page(html, page_path, used, components,
|
|
111
|
+
page_inline_scripts: page_inline_scripts,
|
|
112
|
+
synthesized_names: synthesized_names)
|
|
113
|
+
else
|
|
114
|
+
inject_inline_page(html, page_path, used, components,
|
|
115
|
+
page_inline_scripts: page_inline_scripts,
|
|
116
|
+
synthesized_names: synthesized_names)
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
File.write(output_path_for(page_path).tap { |p| FileUtils.mkdir_p(File.dirname(p)) }, html)
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
private
|
|
123
|
+
|
|
124
|
+
# :inline delivery: build the per-page template/script injection
|
|
125
|
+
# and splice it (plus optional live-reload client) before </body>.
|
|
126
|
+
def inject_inline_page(html, page_path, used, components,
|
|
127
|
+
page_inline_scripts:, synthesized_names:)
|
|
128
|
+
injection = build_injection(used.uniq, components,
|
|
129
|
+
page_inline_scripts: page_inline_scripts,
|
|
130
|
+
synthesized_names: synthesized_names,
|
|
131
|
+
page_path: page_path)
|
|
132
|
+
|
|
133
|
+
extras = []
|
|
134
|
+
extras << injection.html unless injection.empty?
|
|
135
|
+
extras << LiveReload::SCRIPT if @ctx.live_reload
|
|
136
|
+
extras.empty? ? html : HtmlEmitter.inject_before_body_close(html, extras.join("\n"))
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
# Splice :bundle-delivery extras into a page: the
|
|
140
|
+
# `<link rel="lilac-bundle">` reference, any page-local injection
|
|
141
|
+
# (synthesized data-component templates + page-inline scripts) and,
|
|
142
|
+
# for :compiled, the chained `<script type="module">` boot stub.
|
|
143
|
+
#
|
|
144
|
+
# `.lil`-derived templates / scripts are NOT injected here — those
|
|
145
|
+
# live in `dist/lilac.bundle.html` (already written by the builder).
|
|
146
|
+
# Only the page-local slice does.
|
|
147
|
+
def inject_bundle_page(html, page_path, used, components,
|
|
148
|
+
page_inline_scripts:, synthesized_names:)
|
|
149
|
+
html = HtmlEmitter.inject_bundle_link(html, @ctx.bundle_assets.url) if @ctx.bundle_assets&.url
|
|
150
|
+
|
|
151
|
+
extras = []
|
|
152
|
+
page_local_mrb = nil
|
|
153
|
+
|
|
154
|
+
page_local_names = used.uniq.select { |n| synthesized_names.include?(n) }
|
|
155
|
+
unless page_local_names.empty? && page_inline_scripts.empty?
|
|
156
|
+
injection = build_injection(
|
|
157
|
+
page_local_names, components,
|
|
158
|
+
page_inline_scripts: page_inline_scripts,
|
|
159
|
+
synthesized_names: synthesized_names,
|
|
160
|
+
page_path: page_path
|
|
161
|
+
)
|
|
162
|
+
extras << injection.html unless injection.empty?
|
|
163
|
+
page_local_mrb = injection.page_local_mrb
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
if @ctx.target == :compiled
|
|
167
|
+
chain = CompiledBootModule.mrb_chain(@ctx.bundle_assets, page_local_mrb)
|
|
168
|
+
unless chain.empty?
|
|
169
|
+
extras << CompiledBootModule.render(
|
|
170
|
+
chain,
|
|
171
|
+
package_urls: @ctx.package_dist_urls || [],
|
|
172
|
+
delivery: @ctx.delivery
|
|
173
|
+
)
|
|
174
|
+
end
|
|
175
|
+
end
|
|
176
|
+
|
|
177
|
+
extras << LiveReload::SCRIPT if @ctx.live_reload
|
|
178
|
+
extras.empty? ? html : HtmlEmitter.inject_before_body_close(html, extras.join("\n"))
|
|
179
|
+
end
|
|
180
|
+
|
|
181
|
+
# Lifts page-inline `data-component` elements into the same
|
|
182
|
+
# build pipeline that `.lil` components go through. The element
|
|
183
|
+
# stays where the user wrote it and the runtime mounts directly
|
|
184
|
+
# via the `data-component` attribute. See full design notes in
|
|
185
|
+
# ADR-29.
|
|
186
|
+
def synthesize_page_inline_components(html, page_path:)
|
|
187
|
+
components = @ctx.components
|
|
188
|
+
|
|
189
|
+
# Quick string check — if there's no `data-component=` at all,
|
|
190
|
+
# skip the round-trip and return the input verbatim.
|
|
191
|
+
return [components, html, [], Set.new] unless html.match?(/\bdata-component\s*=/)
|
|
192
|
+
|
|
193
|
+
require 'nokogiri' unless defined?(Nokogiri)
|
|
194
|
+
doc = Nokogiri::HTML5.parse(html)
|
|
195
|
+
|
|
196
|
+
# Collect data-component elements in document order. Each gets
|
|
197
|
+
# a synthesized `SFC::Component` whose template body is the
|
|
198
|
+
# element's OUTER HTML (full body, including any nested
|
|
199
|
+
# data-component subtrees) — so the outer component's `data-each`
|
|
200
|
+
# extraction picks up the nested row template verbatim, and the
|
|
201
|
+
# nested component's own AST run sees its full body too.
|
|
202
|
+
targets = []
|
|
203
|
+
walk = lambda do |node|
|
|
204
|
+
node.element_children.each do |child|
|
|
205
|
+
targets << child if child['data-component']
|
|
206
|
+
walk.call(child)
|
|
207
|
+
end
|
|
208
|
+
end
|
|
209
|
+
walk.call(doc)
|
|
210
|
+
|
|
211
|
+
return [components, html, [], Set.new] if targets.empty?
|
|
212
|
+
|
|
213
|
+
# Capture the set of `.lil`-origin component names BEFORE we
|
|
214
|
+
# start synthesising — `synthesized = components.dup` would
|
|
215
|
+
# make a `synthesized.key?(name)` check tautological.
|
|
216
|
+
lil_origin = components.keys.to_set
|
|
217
|
+
synthesized = components.dup
|
|
218
|
+
synthesized_names = Set.new
|
|
219
|
+
used_inline = []
|
|
220
|
+
seen_in_page = {} # name => line — for R2 duplicate detection
|
|
221
|
+
targets.each do |elem|
|
|
222
|
+
name = elem['data-component']
|
|
223
|
+
|
|
224
|
+
# `.lil` and page-inline can't share a name — same-name
|
|
225
|
+
# `data-component=` is a definition collision (ADR-29).
|
|
226
|
+
if lil_origin.include?(name)
|
|
227
|
+
lil_path = components[name].path
|
|
228
|
+
raise Builder::Error, duplicate_definition_message(
|
|
229
|
+
kind: :duplicate_with_lil,
|
|
230
|
+
name: name,
|
|
231
|
+
page_path: page_path,
|
|
232
|
+
elem_line: elem.line,
|
|
233
|
+
lil_path: lil_path
|
|
234
|
+
)
|
|
235
|
+
end
|
|
236
|
+
|
|
237
|
+
# Same page can't declare the same `data-component=` twice
|
|
238
|
+
# — same-name definition collision (ADR-29). Two
|
|
239
|
+
# `<X data-component="row">` siblings would race on which
|
|
240
|
+
# class body wins.
|
|
241
|
+
if seen_in_page.key?(name)
|
|
242
|
+
raise Builder::Error, duplicate_definition_message(
|
|
243
|
+
kind: :duplicate_in_page,
|
|
244
|
+
name: name,
|
|
245
|
+
page_path: page_path,
|
|
246
|
+
elem_line: elem.line,
|
|
247
|
+
previous_line: seen_in_page[name]
|
|
248
|
+
)
|
|
249
|
+
end
|
|
250
|
+
seen_in_page[name] = elem.line
|
|
251
|
+
|
|
252
|
+
body_html = elem.to_html
|
|
253
|
+
synthesized[name] = SFC::Component.new(
|
|
254
|
+
path: page_path,
|
|
255
|
+
templates: [SFC::Template.new(name: nil, body: body_html)],
|
|
256
|
+
script: ''
|
|
257
|
+
)
|
|
258
|
+
synthesized_names << name
|
|
259
|
+
# R3: record signature for cross-page drift detection.
|
|
260
|
+
@ctx.build_linter.record_inline_signature(name, body_html, page_path)
|
|
261
|
+
used_inline << name
|
|
262
|
+
|
|
263
|
+
# scanner-canonical: leave `data-each` rows IN-PLACE. The
|
|
264
|
+
# runtime scanner's `dispatch_each` snapshots the live
|
|
265
|
+
# innerHTML, empties the container, then clones per item — so
|
|
266
|
+
# the brief static row is replaced at mount (no phantom), the
|
|
267
|
+
# same way the no-build path works. (Previously we emptied the
|
|
268
|
+
# container here because codegen extracted the row into a
|
|
269
|
+
# synthetic `<template data-template>`.)
|
|
270
|
+
end
|
|
271
|
+
|
|
272
|
+
[synthesized, doc.to_html, used_inline, synthesized_names]
|
|
273
|
+
end
|
|
274
|
+
|
|
275
|
+
# Build a structured definition-collision error message. ADR-29
|
|
276
|
+
# requires each component name to be defined only once per page;
|
|
277
|
+
# this method formats the user-facing message for each variant.
|
|
278
|
+
def duplicate_definition_message(kind:, name:, page_path:, **detail)
|
|
279
|
+
page_rel = page_path ? File.basename(page_path) : '(page)'
|
|
280
|
+
case kind
|
|
281
|
+
when :duplicate_with_lil
|
|
282
|
+
lil_rel = detail[:lil_path] ? File.basename(detail[:lil_path]) : "components/#{name}.lil"
|
|
283
|
+
"Duplicate component definition #{name.inspect}: " \
|
|
284
|
+
"page-inline data-component on #{page_rel}:#{detail[:elem_line]} " \
|
|
285
|
+
"conflicts with components/#{lil_rel}. " \
|
|
286
|
+
'Each component name must be defined only once per page. ' \
|
|
287
|
+
'Rename one of them.'
|
|
288
|
+
when :duplicate_in_page
|
|
289
|
+
"Duplicate component definition #{name.inspect}: " \
|
|
290
|
+
"data-component on #{page_rel}:#{detail[:elem_line]} " \
|
|
291
|
+
"is also declared at line #{detail[:previous_line]} of the same page. " \
|
|
292
|
+
'Each component name must be defined only once per page.'
|
|
293
|
+
else
|
|
294
|
+
"Component definition collision: #{kind} (name=#{name.inspect}, page=#{page_rel})"
|
|
295
|
+
end
|
|
296
|
+
end
|
|
297
|
+
|
|
298
|
+
def build_injection(used_names, components,
|
|
299
|
+
page_inline_scripts: [], synthesized_names: nil, page_path: nil)
|
|
300
|
+
synthesized_names ||= Set.new
|
|
301
|
+
|
|
302
|
+
default_templates = render_default_templates(used_names, components, synthesized_names)
|
|
303
|
+
named_templates = render_named_templates(used_names, components)
|
|
304
|
+
scripts = @scripts_assembler.assemble(
|
|
305
|
+
used_names, components,
|
|
306
|
+
synthesized_names: synthesized_names,
|
|
307
|
+
page_inline_scripts: page_inline_scripts
|
|
308
|
+
)
|
|
309
|
+
bundle_scripts = compose_bundle_scripts(scripts, page_inline_scripts)
|
|
310
|
+
script_block, page_local_mrb = render_script_block(bundle_scripts, page_path)
|
|
311
|
+
|
|
312
|
+
# Live-reload client script is appended by the caller (the
|
|
313
|
+
# `inject_inline_page` / `inject_bundle_page` entry points)
|
|
314
|
+
# so we don't end up emitting it twice in :bundle mode where
|
|
315
|
+
# build_injection is called as a sub-step.
|
|
316
|
+
parts = [default_templates, named_templates, script_block].flatten.compact
|
|
317
|
+
Injection.new(html: parts.join("\n"), page_local_mrb: page_local_mrb)
|
|
318
|
+
end
|
|
319
|
+
|
|
320
|
+
# Emit one `<template><div data-component="X">...</div></template>`
|
|
321
|
+
# per used component, skipping synthesized in-memory components
|
|
322
|
+
# (their markup is already written inline on the page). The
|
|
323
|
+
# runtime registry consults these templates to fill empty
|
|
324
|
+
# `data-use="X"` elements at mount time.
|
|
325
|
+
def render_default_templates(used_names, components, synthesized_names)
|
|
326
|
+
used_names.reject { |n| synthesized_names.include?(n) }.map do |name|
|
|
327
|
+
parsed = @ctx.template_cache.fetch(name, components[name])
|
|
328
|
+
HtmlEmitter.render_default_template(parsed[:default_html])
|
|
329
|
+
end
|
|
330
|
+
end
|
|
331
|
+
|
|
332
|
+
def render_named_templates(used_names, components)
|
|
333
|
+
used_names.flat_map do |name|
|
|
334
|
+
parsed = @ctx.template_cache.fetch(name, components[name])
|
|
335
|
+
parsed[:named].map { |nt| HtmlEmitter.render_named_template(nt.name, nt.html) }
|
|
336
|
+
end
|
|
337
|
+
end
|
|
338
|
+
|
|
339
|
+
# Combine assembled component scripts with the page-inline ones
|
|
340
|
+
# in a target-aware way. Page-inline `<script type="text/ruby">`
|
|
341
|
+
# blocks join the bundle only on the compiled target — they're
|
|
342
|
+
# emitted last so any `Lilac.start` written there runs after the
|
|
343
|
+
# component class definitions. On :full they remain in the dist
|
|
344
|
+
# HTML body and the runtime parser picks them up via
|
|
345
|
+
# `vm.evalScript`, so duplicating them into the injected block
|
|
346
|
+
# would re-execute them.
|
|
347
|
+
#
|
|
348
|
+
# `Lilac.start` placement differs by target (decisions §20.6
|
|
349
|
+
# corrected: the compiled wasm has no parser, so post-load
|
|
350
|
+
# `vm.eval("Lilac.start")` is not available):
|
|
351
|
+
# - target=:compiled — append `Lilac.start` to the bundle so it
|
|
352
|
+
# executes as part of `loadBytecode`.
|
|
353
|
+
# - target=:full — do nothing here; the Pattern A boot helper
|
|
354
|
+
# (scaffold `boot.js`, lilac-full's CDN `boot`, …) runs
|
|
355
|
+
# `vm.eval("Lilac.start")` at the tail of its eval loop.
|
|
356
|
+
def compose_bundle_scripts(scripts, page_inline_scripts)
|
|
357
|
+
return scripts unless @ctx.target == :compiled
|
|
358
|
+
|
|
359
|
+
user_scripts = scripts + page_inline_scripts.reject { |s| s.strip.empty? }
|
|
360
|
+
user_scripts.empty? ? [] : user_scripts + ['Lilac.start']
|
|
361
|
+
end
|
|
362
|
+
|
|
363
|
+
# Returns `[script_block_html, page_local_mrb]`. Either element
|
|
364
|
+
# may be nil:
|
|
365
|
+
# - no scripts at all → both nil
|
|
366
|
+
# - :full → script_block is `<script type="text/ruby">…</script>`, mrb is nil
|
|
367
|
+
# - :compiled × :inline → script_block is the inline boot
|
|
368
|
+
# module that loads + boots the .mrb; mrb is nil
|
|
369
|
+
# - :compiled × :bundle → script_block is nil (caller chains
|
|
370
|
+
# the page-local mrb into the shared bundle boot module);
|
|
371
|
+
# page_local_mrb is the .mrb filename to chain.
|
|
372
|
+
def render_script_block(bundle_scripts, page_path)
|
|
373
|
+
return [nil, nil] if bundle_scripts.empty?
|
|
374
|
+
|
|
375
|
+
ruby_source = bundle_scripts.join("\n\n")
|
|
376
|
+
return [HtmlEmitter.render_script(ruby_source), nil] unless @ctx.target == :compiled
|
|
377
|
+
|
|
378
|
+
# :compiled — compile the aggregated Ruby to `.mrb` bytecode.
|
|
379
|
+
# The `data-lilac-bootstrap` attribute on the emitted module
|
|
380
|
+
# script marks the tag so a future asset-pipeline pass can
|
|
381
|
+
# rewrite the URLs.
|
|
382
|
+
label = page_path ? "page #{File.basename(page_path)}" : 'page bundle'
|
|
383
|
+
mrb_file = @ctx.bytecode_builder.build(ruby_source, source_label: label)
|
|
384
|
+
|
|
385
|
+
if @ctx.delivery == :bundle
|
|
386
|
+
# The page-local .mrb is chained into the same boot module
|
|
387
|
+
# as the bundle .mrb by the caller — emit no <script> here.
|
|
388
|
+
[nil, mrb_file]
|
|
389
|
+
else
|
|
390
|
+
boot = CompiledBootModule.render(
|
|
391
|
+
mrb_file,
|
|
392
|
+
package_urls: @ctx.package_dist_urls || [],
|
|
393
|
+
delivery: @ctx.delivery
|
|
394
|
+
)
|
|
395
|
+
[boot, nil]
|
|
396
|
+
end
|
|
397
|
+
end
|
|
398
|
+
|
|
399
|
+
def output_path_for(page_path)
|
|
400
|
+
rel = Pathname.new(page_path).relative_path_from(Pathname.new(@ctx.pages_dir))
|
|
401
|
+
File.join(@ctx.output_dir, rel.to_s)
|
|
402
|
+
end
|
|
403
|
+
end
|
|
404
|
+
end
|
|
405
|
+
end
|