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,150 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Lilac
|
|
4
|
+
module CLI
|
|
5
|
+
# Shared discovery logic for the two build-target runtimes
|
|
6
|
+
# (`:full` / `:compiled`). Both vendor a wasm bundle plus the SAME
|
|
7
|
+
# mruby-wasm-js JS bridge into `dist/vendor/lilac-<target>/`, and both
|
|
8
|
+
# discover sources in the same order: explicit CLI/config → env →
|
|
9
|
+
# `lilac-wasm-bin` gem (the canonical install path — decisions §25) →
|
|
10
|
+
# monorepo sibling layout for in-repo development.
|
|
11
|
+
#
|
|
12
|
+
# The bridge is identical across targets, so its resolution lives
|
|
13
|
+
# here. Subclasses supply only the wasm-specific bits via the
|
|
14
|
+
# protected hooks at the bottom (env key, gem accessor, monorepo
|
|
15
|
+
# candidate, not-found message).
|
|
16
|
+
class RuntimeResolver
|
|
17
|
+
# Common ancestor so a caller can `rescue RuntimeResolver::Error`
|
|
18
|
+
# for either target. Each subclass defines its own `Error <
|
|
19
|
+
# RuntimeResolver::Error` and the `resolve_*!` methods raise
|
|
20
|
+
# `self.class::Error`, so `FullRuntimeResolver::Error` and
|
|
21
|
+
# `CompiledRuntimeResolver::Error` stay distinct (a rescue of one
|
|
22
|
+
# doesn't swallow the other).
|
|
23
|
+
class Error < StandardError; end
|
|
24
|
+
|
|
25
|
+
def initialize(lilac_wasm_path: nil, mruby_wasm_js_path: nil,
|
|
26
|
+
monorepo_root: nil, disable_gem_discovery: false)
|
|
27
|
+
@configured_wasm_path = lilac_wasm_path
|
|
28
|
+
@configured_bridge_path = mruby_wasm_js_path
|
|
29
|
+
# Tests inject a dummy directory to keep the (real) monorepo wasm
|
|
30
|
+
# from being discovered; production callers leave it nil and the
|
|
31
|
+
# gem-relative ancestor is used.
|
|
32
|
+
@monorepo_root_override = monorepo_root
|
|
33
|
+
# Tests that exercise the fallback chain pass `true` so the gem's
|
|
34
|
+
# wasm doesn't satisfy a "nothing should resolve" expectation.
|
|
35
|
+
@disable_gem_discovery = disable_gem_discovery
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# Absolute path to the wasm, or nil if none of the discovery routes
|
|
39
|
+
# turn up a readable file. Non-raising: use this to *report*
|
|
40
|
+
# discoverability (Doctor); use `resolve_wasm!` on the build path
|
|
41
|
+
# where absence must be a hard error.
|
|
42
|
+
def wasm_path
|
|
43
|
+
return @configured_wasm_path if File.file?(@configured_wasm_path.to_s)
|
|
44
|
+
if (env = ENV[wasm_env_key]) && File.file?(env)
|
|
45
|
+
return env
|
|
46
|
+
end
|
|
47
|
+
if (gem_wasm = gem_provided_wasm) && File.file?(gem_wasm)
|
|
48
|
+
return gem_wasm
|
|
49
|
+
end
|
|
50
|
+
monorepo_wasm_candidate.then { |c| return c if File.file?(c) }
|
|
51
|
+
|
|
52
|
+
nil
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
# Directory containing the bridge's `index.js`, or nil if not found.
|
|
56
|
+
# Shared across targets — the bridge artifact is the same for both.
|
|
57
|
+
def bridge_path
|
|
58
|
+
if @configured_bridge_path
|
|
59
|
+
return @configured_bridge_path if bridge_dir?(@configured_bridge_path)
|
|
60
|
+
end
|
|
61
|
+
if (env = ENV["MRUBY_WASM_JS_PATH"]) && bridge_dir?(env)
|
|
62
|
+
return env
|
|
63
|
+
end
|
|
64
|
+
[
|
|
65
|
+
gem_provided_bridge, # `lilac-wasm-bin` gem (canonical install path)
|
|
66
|
+
monorepo_bridge_candidate,
|
|
67
|
+
].compact.find { |c| bridge_dir?(c) }
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
# Raising variants for the build path: a missing runtime must fail
|
|
71
|
+
# the build loudly with an actionable message.
|
|
72
|
+
def resolve_wasm!
|
|
73
|
+
wasm_path || raise(self.class::Error, wasm_not_found_message)
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def resolve_bridge!
|
|
77
|
+
bridge_path || raise(self.class::Error, bridge_not_found_message)
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
private
|
|
81
|
+
|
|
82
|
+
# Soft-requires the `lilac-wasm-bin` gem for the bridge directory,
|
|
83
|
+
# or nil if the gem isn't on the load path.
|
|
84
|
+
def gem_provided_bridge
|
|
85
|
+
return nil if @disable_gem_discovery
|
|
86
|
+
require "lilac/wasm/bin"
|
|
87
|
+
::Lilac::Wasm::Bin.mruby_wasm_js_dir
|
|
88
|
+
rescue LoadError
|
|
89
|
+
nil
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
def monorepo_bridge_candidate
|
|
93
|
+
candidate = File.join(monorepo_root, "mrbgem", "mruby-wasm-js", "js")
|
|
94
|
+
candidate if File.directory?(candidate)
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
def bridge_dir?(path)
|
|
98
|
+
File.directory?(path.to_s) && File.file?(File.join(path.to_s, "index.js"))
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
# The cli gem ships from `<repo>/cli`. This file lives at
|
|
102
|
+
# `<repo>/cli/lib/lilac/cli/build/runtime_resolver.rb`, so `<repo>`
|
|
103
|
+
# is five levels up. When installed via rubygems the cli gem is
|
|
104
|
+
# unpacked alone — the candidates simply won't exist, which is fine:
|
|
105
|
+
# discovery falls through to gem / explicit overrides.
|
|
106
|
+
def monorepo_root
|
|
107
|
+
@monorepo_root ||= @monorepo_root_override || File.expand_path("../../../../..", __dir__)
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
def bridge_not_found_message
|
|
111
|
+
<<~MSG.strip
|
|
112
|
+
@takahashim/mruby-wasm-js bridge not found. Tried:
|
|
113
|
+
• configured `c.mruby_wasm_js_path` (#{@configured_bridge_path.inspect})
|
|
114
|
+
• ENV["MRUBY_WASM_JS_PATH"] (#{ENV["MRUBY_WASM_JS_PATH"].inspect})
|
|
115
|
+
• lilac-wasm-bin gem (not on load path)
|
|
116
|
+
• monorepo: #{monorepo_bridge_candidate || '(no mrbgem/mruby-wasm-js/js dir)'}
|
|
117
|
+
|
|
118
|
+
To fix, either:
|
|
119
|
+
• Add `gem "lilac-wasm-bin"` to your Gemfile (recommended)
|
|
120
|
+
• Pass `--mruby-wasm-js-path /abs/path/to/mruby-wasm-js/` on the command line
|
|
121
|
+
• Add `c.mruby_wasm_js_path = "/abs/path"` to lilac.config.rb
|
|
122
|
+
• Set ENV["MRUBY_WASM_JS_PATH"]
|
|
123
|
+
MSG
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
# ---- wasm-specific hooks (subclasses must override) --------------
|
|
127
|
+
|
|
128
|
+
# ENV var consulted for an explicit wasm path.
|
|
129
|
+
def wasm_env_key
|
|
130
|
+
raise NotImplementedError, "#{self.class} must define #wasm_env_key"
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
# Path the `lilac-wasm-bin` gem publishes for this target's wasm,
|
|
134
|
+
# or nil if the gem isn't loadable / discovery is disabled.
|
|
135
|
+
def gem_provided_wasm
|
|
136
|
+
raise NotImplementedError, "#{self.class} must define #gem_provided_wasm"
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
# The monorepo `build/<wasm>` candidate for in-repo development.
|
|
140
|
+
def monorepo_wasm_candidate
|
|
141
|
+
raise NotImplementedError, "#{self.class} must define #monorepo_wasm_candidate"
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
# Actionable "wasm not found" message naming this target's overrides.
|
|
145
|
+
def wasm_not_found_message
|
|
146
|
+
raise NotImplementedError, "#{self.class} must define #wasm_not_found_message"
|
|
147
|
+
end
|
|
148
|
+
end
|
|
149
|
+
end
|
|
150
|
+
end
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Lilac
|
|
4
|
+
module CLI
|
|
5
|
+
# Parses a `.lil` single-file component into its constituent parts:
|
|
6
|
+
#
|
|
7
|
+
# * one or more `<template>` blocks, optionally named via
|
|
8
|
+
# `data-template="..."` (a standard HTML5 data attribute, same as
|
|
9
|
+
# what the Lilac runtime expects for clone-targets)
|
|
10
|
+
# * one or more `<script type="text/ruby">` blocks
|
|
11
|
+
#
|
|
12
|
+
# The format is HTML5-valid: any conformant HTML parser (browser,
|
|
13
|
+
# nokogiri, gammo) can build a DOM from a `.lil` file. We use regex
|
|
14
|
+
# rather than a full HTML parser internally because we want the
|
|
15
|
+
# `<template>` inner content **verbatim** — a DOM-based parser would
|
|
16
|
+
# require re-serializing children back to HTML and would normalize
|
|
17
|
+
# quote / whitespace / attribute order along the way.
|
|
18
|
+
#
|
|
19
|
+
# The parsed result is a `Component` value object. Order of templates
|
|
20
|
+
# is preserved from the source; named templates remember their name.
|
|
21
|
+
# Anonymous templates (no `data-template` attribute) are the
|
|
22
|
+
# component's "default" markup; multiple anonymous templates are
|
|
23
|
+
# allowed and concatenated in document order.
|
|
24
|
+
module SFC
|
|
25
|
+
# `name` is nil for anonymous templates.
|
|
26
|
+
Template = Struct.new(:name, :body, keyword_init: true)
|
|
27
|
+
|
|
28
|
+
# `path` is the source file path (or nil for in-memory parses).
|
|
29
|
+
# `templates` is an Array<Template>, `script` is a single concatenated
|
|
30
|
+
# String of all Ruby blocks joined by a newline.
|
|
31
|
+
Component = Struct.new(:path, :templates, :script, keyword_init: true) do
|
|
32
|
+
def default_templates
|
|
33
|
+
templates.select { |t| t.name.nil? }
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def named_templates
|
|
37
|
+
templates.reject { |t| t.name.nil? }
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
class ParseError < StandardError; end
|
|
42
|
+
|
|
43
|
+
TEMPLATE_OPEN = /<template(?:\s+data-template="([^"]*)")?\s*>/
|
|
44
|
+
TEMPLATE_CLOSE = "</template>"
|
|
45
|
+
SCRIPT_OPEN = /<script\s+type="text\/ruby"\s*>/
|
|
46
|
+
SCRIPT_CLOSE = "</script>"
|
|
47
|
+
|
|
48
|
+
def self.parse_file(path)
|
|
49
|
+
parse(File.read(path), path: path)
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
# Walks the source linearly so nested `<template>` / `<script>` tags
|
|
53
|
+
# don't get matched by a greedy regex. Each iteration:
|
|
54
|
+
# 1. find the earliest opening tag (template or script)
|
|
55
|
+
# 2. find its matching close, extract the body
|
|
56
|
+
# 3. advance past the close
|
|
57
|
+
def self.parse(source, path: nil)
|
|
58
|
+
templates = []
|
|
59
|
+
script_parts = []
|
|
60
|
+
cursor = 0
|
|
61
|
+
|
|
62
|
+
while cursor < source.length
|
|
63
|
+
tmpl_match = source.match(TEMPLATE_OPEN, cursor)
|
|
64
|
+
script_match = source.match(SCRIPT_OPEN, cursor)
|
|
65
|
+
|
|
66
|
+
# Earlier of the two openings wins; nil-safe sort by offset.
|
|
67
|
+
next_match = [tmpl_match, script_match].compact
|
|
68
|
+
.min_by { |m| m.begin(0) }
|
|
69
|
+
break unless next_match
|
|
70
|
+
|
|
71
|
+
if next_match == tmpl_match
|
|
72
|
+
templates << extract_template(source, tmpl_match, path)
|
|
73
|
+
cursor = source.index(TEMPLATE_CLOSE, tmpl_match.end(0)) + TEMPLATE_CLOSE.length
|
|
74
|
+
else
|
|
75
|
+
script_parts << extract_script(source, script_match, path)
|
|
76
|
+
cursor = source.index(SCRIPT_CLOSE, script_match.end(0)) + SCRIPT_CLOSE.length
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
Component.new(
|
|
81
|
+
path: path,
|
|
82
|
+
templates: templates,
|
|
83
|
+
script: script_parts.join("\n"),
|
|
84
|
+
)
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def self.extract_template(source, match, path)
|
|
88
|
+
name = match[1].to_s.empty? ? nil : match[1]
|
|
89
|
+
body_start = match.end(0)
|
|
90
|
+
body_end = source.index(TEMPLATE_CLOSE, body_start)
|
|
91
|
+
raise ParseError, "Unterminated <template> in #{path || '<input>'}" unless body_end
|
|
92
|
+
|
|
93
|
+
Template.new(name: name, body: source[body_start...body_end])
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
def self.extract_script(source, match, path)
|
|
97
|
+
body_start = match.end(0)
|
|
98
|
+
body_end = source.index(SCRIPT_CLOSE, body_start)
|
|
99
|
+
raise ParseError, "Unterminated <script type=\"text/ruby\"> in #{path || '<input>'}" unless body_end
|
|
100
|
+
|
|
101
|
+
source[body_start...body_end]
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
# Walk an arbitrary HTML string for `<script type="text/ruby">…</script>`
|
|
105
|
+
# blocks (the same shape `.lil` files use). Returns the source strings in
|
|
106
|
+
# document order plus an HTML copy with each block removed verbatim
|
|
107
|
+
# (open tag + body + close tag). Other `<script>` types (e.g. `module`,
|
|
108
|
+
# untyped) are untouched.
|
|
109
|
+
#
|
|
110
|
+
# Used by the page-build path so a page may embed inline Ruby without
|
|
111
|
+
# being silently dropped on the compiled target.
|
|
112
|
+
def self.extract_inline_ruby_scripts(html, path: nil)
|
|
113
|
+
scripts = []
|
|
114
|
+
ranges = []
|
|
115
|
+
cursor = 0
|
|
116
|
+
|
|
117
|
+
while (script_match = html.match(SCRIPT_OPEN, cursor))
|
|
118
|
+
body_start = script_match.end(0)
|
|
119
|
+
body_end = html.index(SCRIPT_CLOSE, body_start) or
|
|
120
|
+
raise ParseError, "Unterminated <script type=\"text/ruby\"> in #{path || '<input>'}"
|
|
121
|
+
|
|
122
|
+
scripts << html[body_start...body_end]
|
|
123
|
+
ranges << (script_match.begin(0)...(body_end + SCRIPT_CLOSE.length))
|
|
124
|
+
cursor = body_end + SCRIPT_CLOSE.length
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
stripped = remove_ranges(html, ranges)
|
|
128
|
+
{ stripped_html: stripped, scripts: scripts }
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
# Drop the byte ranges (sorted in document order) from `html` and
|
|
132
|
+
# collapse the now-empty surrounding whitespace minimally — we keep
|
|
133
|
+
# surrounding HTML formatting untouched and only delete the matched
|
|
134
|
+
# script tags themselves.
|
|
135
|
+
def self.remove_ranges(html, ranges)
|
|
136
|
+
return html if ranges.empty?
|
|
137
|
+
|
|
138
|
+
result = +""
|
|
139
|
+
prev_end = 0
|
|
140
|
+
ranges.each do |r|
|
|
141
|
+
result << html[prev_end...r.begin]
|
|
142
|
+
prev_end = r.end
|
|
143
|
+
end
|
|
144
|
+
result << html[prev_end..]
|
|
145
|
+
result
|
|
146
|
+
end
|
|
147
|
+
private_class_method :remove_ranges
|
|
148
|
+
end
|
|
149
|
+
end
|
|
150
|
+
end
|
|
@@ -0,0 +1,304 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "nokogiri"
|
|
4
|
+
require_relative "build_error"
|
|
5
|
+
require_relative "directive"
|
|
6
|
+
|
|
7
|
+
module Lilac
|
|
8
|
+
module CLI
|
|
9
|
+
# Parses a template body HTML fragment with Nokogiri, walks the
|
|
10
|
+
# element tree, and:
|
|
11
|
+
#
|
|
12
|
+
# 1. Collects every `data-*` directive into a flat list of
|
|
13
|
+
# `Directive` records (in document order) for the lint layer.
|
|
14
|
+
# 2. Assigns a synthetic `data-ref="lilN"` to any element bearing
|
|
15
|
+
# a binding directive but no explicit `data-ref`, so the
|
|
16
|
+
# runtime scanner can resolve it positionally at mount.
|
|
17
|
+
# 3. Builds `refs_map` keyed by ref name, recording each ref's
|
|
18
|
+
# source line for duplicate / reserved-name lint checks.
|
|
19
|
+
#
|
|
20
|
+
# SFC's regex-based template extraction (`SFC.parse`) preserves the
|
|
21
|
+
# template body byte-for-byte. TemplateAST does NOT preserve byte
|
|
22
|
+
# equality on the way out — Nokogiri normalizes quoting and
|
|
23
|
+
# boolean attribute serialization. The HTML returned by `parse` is
|
|
24
|
+
# round-tripped through Nokogiri::HTML5.
|
|
25
|
+
#
|
|
26
|
+
# Directive values are NOT validated against the value grammar here
|
|
27
|
+
# — directive attributes are left in the output HTML so the runtime
|
|
28
|
+
# scanner sees `data-component` / `data-ref` and the directives it
|
|
29
|
+
# binds at mount time.
|
|
30
|
+
class TemplateAST
|
|
31
|
+
# Subclass of BuildError so callers can `rescue BuildError` to
|
|
32
|
+
# catch every build-time failure uniformly, regardless of source.
|
|
33
|
+
class Error < BuildError; end
|
|
34
|
+
|
|
35
|
+
Result = Struct.new(:html, :directives, :refs_map, keyword_init: true)
|
|
36
|
+
|
|
37
|
+
# Maps an attribute-name regex to its directive kind. Order matters:
|
|
38
|
+
# the more specific X-family patterns (`data-on-X` etc.) must be
|
|
39
|
+
# checked before the simpler ones so that `data-on-click` is
|
|
40
|
+
# classified as `:on` not as an unknown attribute.
|
|
41
|
+
#
|
|
42
|
+
# `class_` trailing underscore avoids shadowing Ruby's reserved
|
|
43
|
+
# `class` keyword in pattern-match contexts; symbol-side only.
|
|
44
|
+
|
|
45
|
+
DIRECTIVE_PATTERNS = [
|
|
46
|
+
[/\Adata-text\z/, :text, false],
|
|
47
|
+
[/\Adata-unsafe-html\z/, :unsafe_html, false],
|
|
48
|
+
# data-bind is the form-independent two-way binding directive
|
|
49
|
+
# (Phase E revival/unification of data-value / data-checked).
|
|
50
|
+
# Property auto-selected from element type:
|
|
51
|
+
# <input type=checkbox> → :checked, others → :value.
|
|
52
|
+
# See directive-spec §6.2 for the full grammar; runtime parity
|
|
53
|
+
# lives in runtime/mruby-lilac-directives/.
|
|
54
|
+
[/\Adata-bind\z/, :bind, false],
|
|
55
|
+
[/\Adata-show\z/, :show, false],
|
|
56
|
+
[/\Adata-hide\z/, :hide, false],
|
|
57
|
+
[/\Adata-each\z/, :each, false],
|
|
58
|
+
[/\Adata-key\z/, :key, false],
|
|
59
|
+
[/\Adata-class\z/, :class_, false],
|
|
60
|
+
# data-component is detected only so collision checks can see
|
|
61
|
+
# it; it has no emit_* of its own (the runtime autoregister
|
|
62
|
+
# reads the HTML attribute directly) and `walk` skips Directive
|
|
63
|
+
# records when it appears alone, to keep the dist HTML and the
|
|
64
|
+
# linter input unchanged for the common case.
|
|
65
|
+
[/\Adata-component\z/, :component, false],
|
|
66
|
+
# Form directives (mruby-lilac-form gem). Collected so scope
|
|
67
|
+
# validation / lint can see them; the runtime form scanner wires
|
|
68
|
+
# the actual behavior at mount (resolves scope via ancestor walk).
|
|
69
|
+
[/\Adata-form\z/, :form, false],
|
|
70
|
+
[/\Adata-field\z/, :field, false],
|
|
71
|
+
[/\Adata-button\z/, :button, false],
|
|
72
|
+
[/\Adata-on-(.+)\z/, :on, true],
|
|
73
|
+
[/\Adata-attr-(.+)\z/, :attr, true],
|
|
74
|
+
[/\Adata-arg-(.+)\z/, :arg, true],
|
|
75
|
+
[/\Adata-css-(.+)\z/, :css, true],
|
|
76
|
+
].freeze
|
|
77
|
+
|
|
78
|
+
def initialize(body_html, source_path: nil)
|
|
79
|
+
@body_html = body_html
|
|
80
|
+
@source_path = source_path
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
# Immutable per-frame walk state: the stacks that get pushed/popped
|
|
84
|
+
# as the walker descends/ascends. Bundled so `walk` doesn't have to
|
|
85
|
+
# thread individual args through recursion (the accumulators —
|
|
86
|
+
# directives / refs_map — are mutated in place and stay separate).
|
|
87
|
+
class WalkScopes
|
|
88
|
+
# each_scope: ref_ids of currently-open `data-each` elements;
|
|
89
|
+
# children's directives carry `scope_id = each_scope.last`
|
|
90
|
+
# ref_scopes: Stack<{ ref_name => line }> for duplicate ref detection
|
|
91
|
+
# per `data-each` / `data-component` scope
|
|
92
|
+
attr_reader :each_scope, :ref_scopes
|
|
93
|
+
|
|
94
|
+
def initialize(each_scope: [], ref_scopes: [{}])
|
|
95
|
+
@each_scope = each_scope
|
|
96
|
+
@ref_scopes = ref_scopes
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
# Push helpers return a NEW WalkScopes — frames are immutable so
|
|
100
|
+
# the recursive call doesn't accidentally mutate the parent's stack.
|
|
101
|
+
def push_each(ref_id)
|
|
102
|
+
self.class.new(each_scope: @each_scope + [ref_id], ref_scopes: @ref_scopes)
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
def push_ref_scope
|
|
106
|
+
self.class.new(each_scope: @each_scope, ref_scopes: @ref_scopes + [{}])
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
def current_ref_scope
|
|
110
|
+
@ref_scopes.last
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
def current_each_ref
|
|
114
|
+
@each_scope.last
|
|
115
|
+
end
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
def parse
|
|
119
|
+
fragment = Nokogiri::HTML5.fragment(@body_html)
|
|
120
|
+
directives = []
|
|
121
|
+
refs_map = {}
|
|
122
|
+
# `at_root: true` for the outermost walk — when the first
|
|
123
|
+
# element child of the fragment carries `data-component`, it's
|
|
124
|
+
# the component's own root (this AST run IS that component),
|
|
125
|
+
# so its directives + body belong to this component. Any deeper
|
|
126
|
+
# `data-component` element is a different component; we treat
|
|
127
|
+
# those subtrees opaquely so their directives don't leak into
|
|
128
|
+
# the current component's scope.
|
|
129
|
+
walk(fragment, directives, refs_map, WalkScopes.new, at_root: true)
|
|
130
|
+
|
|
131
|
+
Result.new(
|
|
132
|
+
html: fragment.to_html,
|
|
133
|
+
directives: directives,
|
|
134
|
+
refs_map: refs_map,
|
|
135
|
+
)
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
private
|
|
139
|
+
|
|
140
|
+
# `scopes` is a WalkScopes carrying two immutable stacks:
|
|
141
|
+
# - each_scope: ref_ids of currently-open `data-each` elements
|
|
142
|
+
# (children carry `scope_id` pointing at the enclosing iteration).
|
|
143
|
+
# - ref_scopes: per-scope `data-ref` namespace for duplicate detection.
|
|
144
|
+
# `data-each` AND `data-component` both open a fresh ref scope —
|
|
145
|
+
# iteration bodies and nested component subtrees each get clean
|
|
146
|
+
# ref sets. The directive `each_scope` is unaffected by
|
|
147
|
+
# `data-component` (its body's directives still belong to the
|
|
148
|
+
# parent component's scope).
|
|
149
|
+
def walk(node, directives, refs_map, scopes, at_root: false)
|
|
150
|
+
node.element_children.to_a.each do |elem|
|
|
151
|
+
element_directives = collect_directives_with_synthesis(elem)
|
|
152
|
+
has_each = element_directives.any? { |k, _, _| k == :each }
|
|
153
|
+
has_component = element_directives.any? { |k, _, _| k == :component }
|
|
154
|
+
|
|
155
|
+
# Nested `data-component` (not the parse's outermost element)
|
|
156
|
+
# is a different component, whose directives + body are
|
|
157
|
+
# handled by its OWN AST run. Skip the entire subtree —
|
|
158
|
+
# don't record directives, don't recurse — so the parent's
|
|
159
|
+
# bindings don't double-cover what the nested component will
|
|
160
|
+
# already wire at mount.
|
|
161
|
+
if has_component && !at_root
|
|
162
|
+
next
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
# A bare `data-component` element needs no ref_id (the runtime
|
|
166
|
+
# mounts via the data-component attribute), so we skip the
|
|
167
|
+
# Directive record entirely to keep the dist HTML byte-identical
|
|
168
|
+
# and the linter input clean. The :component record only matters
|
|
169
|
+
# when it coexists with another directive (e.g. `data-each`)
|
|
170
|
+
# so Lilac::Directives::Lints can flag the collision.
|
|
171
|
+
has_real_directive = element_directives.any? { |k, _, _| k != :component }
|
|
172
|
+
ref_id = nil
|
|
173
|
+
|
|
174
|
+
if has_real_directive
|
|
175
|
+
ref_id = assign_or_reuse_ref(elem, scopes.current_ref_scope)
|
|
176
|
+
element_directives.each do |kind, name, attr_value|
|
|
177
|
+
directives << Directive.new(
|
|
178
|
+
kind: kind,
|
|
179
|
+
name: name,
|
|
180
|
+
value: attr_value,
|
|
181
|
+
ref_id: ref_id,
|
|
182
|
+
line: elem.line,
|
|
183
|
+
element_tag: elem.name,
|
|
184
|
+
scope_id: scopes.current_each_ref,
|
|
185
|
+
)
|
|
186
|
+
end
|
|
187
|
+
refs_map[ref_id] ||= elem.line
|
|
188
|
+
elsif (explicit = elem["data-ref"]) && !explicit.empty?
|
|
189
|
+
# Ref declared on an element without any other directive
|
|
190
|
+
# (e.g. `<input data-ref="email">` used only by user-side
|
|
191
|
+
# `refs.email`). Still register so duplicates trip the
|
|
192
|
+
# check, but don't allocate a synthetic / Directive record.
|
|
193
|
+
unless scopes.current_ref_scope.key?(explicit)
|
|
194
|
+
register_ref!(explicit, elem.line, scopes.current_ref_scope)
|
|
195
|
+
end
|
|
196
|
+
end
|
|
197
|
+
|
|
198
|
+
child_scopes = scopes
|
|
199
|
+
# `has_component` only pushes a fresh ref scope for NESTED
|
|
200
|
+
# data-component elements — and we already `next` on those
|
|
201
|
+
# above, so we never reach this branch for them. The root
|
|
202
|
+
# data-component shares its ref scope with this AST run.
|
|
203
|
+
child_scopes = child_scopes.push_ref_scope if has_each
|
|
204
|
+
|
|
205
|
+
if has_each
|
|
206
|
+
# scanner-canonical: keep the data-each row IN-PLACE. The
|
|
207
|
+
# runtime scanner's `dispatch_each` snapshots the live
|
|
208
|
+
# innerHTML, empties the container, and clones per item — so
|
|
209
|
+
# the builder must NOT extract the row. We still recurse to
|
|
210
|
+
# collect directives for lint scoping.
|
|
211
|
+
walk(elem, directives, refs_map, child_scopes.push_each(ref_id))
|
|
212
|
+
else
|
|
213
|
+
walk(elem, directives, refs_map, child_scopes)
|
|
214
|
+
end
|
|
215
|
+
end
|
|
216
|
+
end
|
|
217
|
+
|
|
218
|
+
# Returns Array<[kind, name, value]> for every directive on `elem`.
|
|
219
|
+
# `name` is nil for non-X-family directives.
|
|
220
|
+
def extract_directives(elem)
|
|
221
|
+
result = []
|
|
222
|
+
elem.attributes.each do |attr_name, attr|
|
|
223
|
+
match_row = DIRECTIVE_PATTERNS.find { |pattern, _, _| pattern.match(attr_name) }
|
|
224
|
+
next unless match_row
|
|
225
|
+
|
|
226
|
+
pattern, kind, captures_name = match_row
|
|
227
|
+
match = pattern.match(attr_name)
|
|
228
|
+
name = captures_name ? match[1] : nil
|
|
229
|
+
result << [kind, name, attr.value]
|
|
230
|
+
end
|
|
231
|
+
result
|
|
232
|
+
end
|
|
233
|
+
|
|
234
|
+
# Wraps `extract_directives` to inject a synthetic `:form` directive
|
|
235
|
+
# for bare `<form>` elements (no data-form attr), so the lint layer
|
|
236
|
+
# sees the form even when the author didn't name a scope. Keeping
|
|
237
|
+
# this in one named helper makes the "directives can include things
|
|
238
|
+
# not in the HTML" surprise explicit rather than buried in `walk`.
|
|
239
|
+
def collect_directives_with_synthesis(elem)
|
|
240
|
+
directives = extract_directives(elem)
|
|
241
|
+
if elem.name == "form" && directives.none? { |k, _, _| k == :form }
|
|
242
|
+
directives << [:form, nil, ""]
|
|
243
|
+
end
|
|
244
|
+
directives
|
|
245
|
+
end
|
|
246
|
+
|
|
247
|
+
# If the element already has an explicit `data-ref`, reuse it
|
|
248
|
+
# (so user-chosen refs remain stable across rebuilds) but
|
|
249
|
+
# register it so a duplicate in the same scope raises. Otherwise
|
|
250
|
+
# allocate a fresh synthetic name based on the scope's current
|
|
251
|
+
# size — `refs.lilN` is resolved positionally at runtime, so the
|
|
252
|
+
# name doesn't need to be written back to the DOM (decisions §19).
|
|
253
|
+
def assign_or_reuse_ref(elem, current_ref_scope)
|
|
254
|
+
existing = elem["data-ref"]
|
|
255
|
+
if existing && !existing.empty?
|
|
256
|
+
# register_ref! enforces the `lilN`-namespace reservation +
|
|
257
|
+
# in-scope uniqueness.
|
|
258
|
+
register_ref!(existing, elem.line, current_ref_scope)
|
|
259
|
+
return existing
|
|
260
|
+
end
|
|
261
|
+
|
|
262
|
+
# Per-scope counter — synthetic refs are positional indices
|
|
263
|
+
# (0-based) within the current data-component / data-each scope.
|
|
264
|
+
# The runtime walks the same DFS order and maps `lilN` → N-th
|
|
265
|
+
# directive-bearing element. NO DOM mutation: the dist HTML
|
|
266
|
+
# carries no synthetic `data-ref` attributes for normal
|
|
267
|
+
# directive elements (decisions §19).
|
|
268
|
+
counter = current_ref_scope.size
|
|
269
|
+
loop do
|
|
270
|
+
candidate = "lil#{counter}"
|
|
271
|
+
counter += 1
|
|
272
|
+
next if current_ref_scope.key?(candidate)
|
|
273
|
+
|
|
274
|
+
current_ref_scope[candidate] = elem.line
|
|
275
|
+
return candidate
|
|
276
|
+
end
|
|
277
|
+
end
|
|
278
|
+
|
|
279
|
+
def register_ref!(ref, line, current_ref_scope)
|
|
280
|
+
# `lilN` is reserved for runtime positional ref slots — see ADR-0019.
|
|
281
|
+
if ref.match?(/^lil\d+$/)
|
|
282
|
+
raise Error.new(
|
|
283
|
+
"data-ref=#{ref.inspect}: the `lilN` namespace is reserved for runtime-internal directive slots.",
|
|
284
|
+
at: SourceLocation.new(file: source_file, line: line),
|
|
285
|
+
suggestion: "Use a domain name like `email` / `list` instead.",
|
|
286
|
+
)
|
|
287
|
+
end
|
|
288
|
+
previous = current_ref_scope[ref]
|
|
289
|
+
if previous
|
|
290
|
+
raise Error.new(
|
|
291
|
+
"Duplicate data-ref #{ref.inspect} — already declared at line #{previous} in the same template scope.",
|
|
292
|
+
at: SourceLocation.new(file: source_file, line: line),
|
|
293
|
+
suggestion: "Rename one of them; data-ref names must be unique within a top-level / data-each / data-component scope.",
|
|
294
|
+
)
|
|
295
|
+
end
|
|
296
|
+
current_ref_scope[ref] = line
|
|
297
|
+
end
|
|
298
|
+
|
|
299
|
+
def source_file
|
|
300
|
+
@source_path ? File.basename(@source_path) : "(template)"
|
|
301
|
+
end
|
|
302
|
+
end
|
|
303
|
+
end
|
|
304
|
+
end
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "template_ast"
|
|
4
|
+
|
|
5
|
+
module Lilac
|
|
6
|
+
module CLI
|
|
7
|
+
# Memoizes parsed component template ASTs by component name. The
|
|
8
|
+
# same `.lil` shape often appears on many pages; the parse step is
|
|
9
|
+
# not free and the parsed result is immutable, so caching once per
|
|
10
|
+
# build keeps multi-page builds linear in components rather than
|
|
11
|
+
# `pages × components`.
|
|
12
|
+
#
|
|
13
|
+
# Shared by `Builder` (when emitting the :bundle delivery file) and
|
|
14
|
+
# `PageCompiler` (per-page injection). Both read through `fetch`;
|
|
15
|
+
# writes happen lazily on miss.
|
|
16
|
+
class TemplateASTCache
|
|
17
|
+
# A user-defined named template (from `<template data-template="...">`
|
|
18
|
+
# in `.lil` source) ready to be injected as `<template
|
|
19
|
+
# data-template="X">` into the page.
|
|
20
|
+
RenderedTemplate = Struct.new(:name, :html, keyword_init: true)
|
|
21
|
+
|
|
22
|
+
def initialize
|
|
23
|
+
@cache = {}
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
# Returns a Hash with:
|
|
27
|
+
# :default_html — concatenated body HTML of the default templates
|
|
28
|
+
# :default_directives — Array<Directive> for top-level binding emission
|
|
29
|
+
# :default_refs_map — Hash { ref_name => line } for lint
|
|
30
|
+
# :named — Array<RenderedTemplate> (user-defined)
|
|
31
|
+
# :source_path — Path to the `.lil` (or in-memory page)
|
|
32
|
+
def fetch(name, component)
|
|
33
|
+
@cache[name] ||= parse(component)
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
private
|
|
37
|
+
|
|
38
|
+
def parse(component)
|
|
39
|
+
default_results = component.default_templates.map do |t|
|
|
40
|
+
TemplateAST.new(t.body, source_path: component.path).parse
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
named = component.named_templates.map do |t|
|
|
44
|
+
result = TemplateAST.new(t.body, source_path: component.path).parse
|
|
45
|
+
RenderedTemplate.new(name: t.name, html: result.html)
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
{
|
|
49
|
+
default_html: default_results.map(&:html).join.strip,
|
|
50
|
+
default_directives: default_results.flat_map(&:directives),
|
|
51
|
+
default_refs_map: default_results.map(&:refs_map).reduce({}, :merge),
|
|
52
|
+
named: named,
|
|
53
|
+
source_path: component.path
|
|
54
|
+
}
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
end
|