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,62 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "listen"
|
|
4
|
+
|
|
5
|
+
module Lilac
|
|
6
|
+
module CLI
|
|
7
|
+
# Thin wrapper around `listen` that coalesces bursts of file events
|
|
8
|
+
# into a single `on_change` callback. Editors that "save" via
|
|
9
|
+
# write-then-rename or "mtime touch" frequently emit 2–5 events in
|
|
10
|
+
# rapid succession; without debouncing we would rebuild that many
|
|
11
|
+
# times.
|
|
12
|
+
#
|
|
13
|
+
# The implementation timer-resets on each event: after the most
|
|
14
|
+
# recent event, we wait `debounce` seconds of quiet, then fire.
|
|
15
|
+
class Watcher
|
|
16
|
+
DEFAULT_DEBOUNCE = 0.15
|
|
17
|
+
|
|
18
|
+
def initialize(paths, debounce: DEFAULT_DEBOUNCE, &on_change)
|
|
19
|
+
raise ArgumentError, "block required" unless on_change
|
|
20
|
+
|
|
21
|
+
@paths = paths
|
|
22
|
+
@debounce = debounce
|
|
23
|
+
@on_change = on_change
|
|
24
|
+
@listener = nil
|
|
25
|
+
@pending = nil
|
|
26
|
+
@mutex = Mutex.new
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def start
|
|
30
|
+
# No `only:` filter: callers pass `components/`, `pages/`, and
|
|
31
|
+
# `public/`, and the public mirror needs to react to arbitrary
|
|
32
|
+
# static assets (.js, .css, .wasm, images). Listen's default
|
|
33
|
+
# ignore list already filters .git, swap files, OS metadata.
|
|
34
|
+
@listener = Listen.to(*@paths) do |_modified, _added, _removed|
|
|
35
|
+
schedule_change
|
|
36
|
+
end
|
|
37
|
+
@listener.start
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def stop
|
|
41
|
+
@listener&.stop
|
|
42
|
+
@mutex.synchronize { @pending&.kill }
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
private
|
|
46
|
+
|
|
47
|
+
def schedule_change
|
|
48
|
+
@mutex.synchronize do
|
|
49
|
+
@pending&.kill
|
|
50
|
+
@pending = Thread.new do
|
|
51
|
+
sleep @debounce
|
|
52
|
+
begin
|
|
53
|
+
@on_change.call
|
|
54
|
+
rescue StandardError => e
|
|
55
|
+
warn "lilac watcher: callback raised #{e.class}: #{e.message}"
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
end
|
data/lib/lilac/cli.rb
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "cli/version"
|
|
4
|
+
require_relative "cli/build/sfc"
|
|
5
|
+
require_relative "cli/build/compiled_runtime_resolver"
|
|
6
|
+
require_relative "cli/package_discovery"
|
|
7
|
+
require_relative "cli/build/builder"
|
|
8
|
+
require_relative "cli/config_loader"
|
|
9
|
+
require_relative "cli/config"
|
|
10
|
+
require_relative "cli/live_reload"
|
|
11
|
+
require_relative "cli/watcher"
|
|
12
|
+
require_relative "cli/dev_server"
|
|
13
|
+
require_relative "cli/scaffold"
|
|
14
|
+
require_relative "cli/doctor"
|
|
15
|
+
require_relative "cli/command"
|
|
16
|
+
|
|
17
|
+
module Lilac
|
|
18
|
+
module CLI
|
|
19
|
+
end
|
|
20
|
+
end
|
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
module Lilac
|
|
2
|
+
module Directives
|
|
3
|
+
# Parser for the `data-class` hash literal grammar.
|
|
4
|
+
#
|
|
5
|
+
# Duplicate pair (build-time / runtime). See decisions §17.
|
|
6
|
+
#
|
|
7
|
+
# { active: @is_active, 'btn-primary': @primary, "hover:bg-blue": @h }
|
|
8
|
+
#
|
|
9
|
+
# Returns Array<[key_string, value_string]> in source order. Keys
|
|
10
|
+
# are returned as plain strings (quotes stripped); values are the
|
|
11
|
+
# raw text between `:` and the next `,` / `}`, leaving value-
|
|
12
|
+
# grammar validation (ivar / bare ident) to the caller (Scanner).
|
|
13
|
+
#
|
|
14
|
+
# Quoted keys may contain `:` (Tailwind variants like
|
|
15
|
+
# `'hover:bg-blue-500'`), so a naive split-on-colon would break.
|
|
16
|
+
# The grammar is stricter than JSON (forbids `;`, control chars,
|
|
17
|
+
# whitespace inside keys) and looser elsewhere (bare Ruby idents
|
|
18
|
+
# as keys, no string quoting on values).
|
|
19
|
+
#
|
|
20
|
+
# Per-character checks use char comparisons rather than JS-bridged
|
|
21
|
+
# Regexp.test calls because they run in inner loops; whole-string
|
|
22
|
+
# validates use Regexp.
|
|
23
|
+
class ClassParser
|
|
24
|
+
class Error < StandardError; end
|
|
25
|
+
|
|
26
|
+
BARE_KEY = /^[a-zA-Z_][a-zA-Z0-9_]*$/
|
|
27
|
+
|
|
28
|
+
# Spec 6.4: quoted-key body forbids whitespace, control chars
|
|
29
|
+
# (\x00-\x1F + \x7F), `;`, and any quote character. Checked via
|
|
30
|
+
# `valid_quoted_body?` (char-walk) instead of a Regexp because
|
|
31
|
+
# mruby-regexp-compat doesn't support `\xHH` hex escapes inside
|
|
32
|
+
# character classes — `[^\s\\'";\x00-\x1F\x7F]` mis-parses as a
|
|
33
|
+
# class containing literal `x`/`0`/etc.
|
|
34
|
+
|
|
35
|
+
def self.parse(source)
|
|
36
|
+
new(source).parse
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def initialize(source)
|
|
40
|
+
@src = source.to_s
|
|
41
|
+
@pos = 0
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def parse
|
|
45
|
+
skip_ws
|
|
46
|
+
expect("{")
|
|
47
|
+
skip_ws
|
|
48
|
+
pairs = []
|
|
49
|
+
unless peek == "}"
|
|
50
|
+
pairs << parse_pair
|
|
51
|
+
loop do
|
|
52
|
+
skip_ws
|
|
53
|
+
break unless peek == ","
|
|
54
|
+
advance
|
|
55
|
+
skip_ws
|
|
56
|
+
pairs << parse_pair
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
skip_ws
|
|
60
|
+
expect("}")
|
|
61
|
+
skip_ws
|
|
62
|
+
if @pos < @src.length
|
|
63
|
+
raise Error, "unexpected trailing content: #{@src[@pos..].inspect}"
|
|
64
|
+
end
|
|
65
|
+
pairs
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
private
|
|
69
|
+
|
|
70
|
+
def parse_pair
|
|
71
|
+
skip_ws
|
|
72
|
+
key = parse_key
|
|
73
|
+
skip_ws
|
|
74
|
+
expect(":")
|
|
75
|
+
skip_ws
|
|
76
|
+
value = parse_value
|
|
77
|
+
[key, value]
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def parse_key
|
|
81
|
+
case peek
|
|
82
|
+
when "'" then parse_quoted("'")
|
|
83
|
+
when '"' then parse_quoted('"')
|
|
84
|
+
else parse_bare_ident
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def parse_quoted(quote)
|
|
89
|
+
advance
|
|
90
|
+
start = @pos
|
|
91
|
+
@pos += 1 while @pos < @src.length && @src[@pos] != quote
|
|
92
|
+
raise Error, "unterminated #{quote} string" if @pos >= @src.length
|
|
93
|
+
body = @src[start...@pos]
|
|
94
|
+
advance
|
|
95
|
+
unless valid_quoted_body?(body)
|
|
96
|
+
raise Error,
|
|
97
|
+
"invalid character(s) in quoted key #{body.inspect} " \
|
|
98
|
+
"(whitespace, control chars, `;`, and quotes are forbidden)"
|
|
99
|
+
end
|
|
100
|
+
body
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
def parse_bare_ident
|
|
104
|
+
start = @pos
|
|
105
|
+
@pos += 1 while @pos < @src.length && bare_key_char?(@src[@pos])
|
|
106
|
+
body = @src[start...@pos]
|
|
107
|
+
raise Error, "expected hash key at position #{start}, got #{peek.inspect}" if body.empty?
|
|
108
|
+
unless BARE_KEY.match?(body)
|
|
109
|
+
raise Error,
|
|
110
|
+
"bare hash key #{body.inspect} is not a Ruby identifier " \
|
|
111
|
+
"(use quoted form for kebab / special chars)"
|
|
112
|
+
end
|
|
113
|
+
body
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
# Values are ivar (`@x`) / bare ident (`x`) — neither contains
|
|
117
|
+
# the hash delimiters `,` / `}` nor whitespace, so stop at the
|
|
118
|
+
# first whitespace or delimiter. Grammar validation happens in
|
|
119
|
+
# the caller (Scanner.dispatch_class via Value.parse).
|
|
120
|
+
def parse_value
|
|
121
|
+
start = @pos
|
|
122
|
+
until @pos >= @src.length
|
|
123
|
+
ch = @src[@pos]
|
|
124
|
+
break if ch == "," || ch == "}" || ws_char?(ch)
|
|
125
|
+
@pos += 1
|
|
126
|
+
end
|
|
127
|
+
raise Error, "expected value, got #{peek.inspect} at #{start}" if @pos == start
|
|
128
|
+
@src[start...@pos]
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
def skip_ws
|
|
132
|
+
@pos += 1 while @pos < @src.length && ws_char?(@src[@pos])
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
def peek
|
|
136
|
+
@src[@pos]
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
def advance
|
|
140
|
+
c = @src[@pos]
|
|
141
|
+
@pos += 1
|
|
142
|
+
c
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
def expect(ch)
|
|
146
|
+
actual = @src[@pos]
|
|
147
|
+
if actual != ch
|
|
148
|
+
raise Error, "expected #{ch.inspect}, got #{actual.inspect} at position #{@pos}"
|
|
149
|
+
end
|
|
150
|
+
@pos += 1
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
# Per-char predicates inlined to avoid a JS-bridged Regexp.test
|
|
154
|
+
# call per character (which would dominate cost in tight loops).
|
|
155
|
+
|
|
156
|
+
def ws_char?(c)
|
|
157
|
+
c == " " || c == "\t" || c == "\n" || c == "\r" || c == "\f" || c == "\v"
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
def bare_key_char?(c)
|
|
161
|
+
(c >= "a" && c <= "z") || (c >= "A" && c <= "Z") ||
|
|
162
|
+
(c >= "0" && c <= "9") || c == "_"
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
# Whole-body validator for quoted keys. Rejects empty, any of
|
|
166
|
+
# `\\`, `'`, `"`, `;`, whitespace, control chars (< 0x20 or == 0x7F).
|
|
167
|
+
def valid_quoted_body?(body)
|
|
168
|
+
return false if body.empty?
|
|
169
|
+
body.each_char do |c|
|
|
170
|
+
return false if c == "\\" || c == "'" || c == "\"" || c == ";"
|
|
171
|
+
return false if ws_char?(c)
|
|
172
|
+
code = c.ord
|
|
173
|
+
return false if code < 0x20 || code == 0x7F
|
|
174
|
+
end
|
|
175
|
+
true
|
|
176
|
+
end
|
|
177
|
+
end
|
|
178
|
+
end
|
|
179
|
+
end
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
module Lilac
|
|
2
|
+
module Directives
|
|
3
|
+
# Collision rules SSOT — duplicate pair (build-time / runtime).
|
|
4
|
+
# See decisions §17.
|
|
5
|
+
#
|
|
6
|
+
# The data here is consumed by both halves of `Lints`:
|
|
7
|
+
# - build-time: `Lilac::Directives::Lints.check!` (raises)
|
|
8
|
+
# - runtime: `Lilac::Directives::Lints.check!` (warn+skip /
|
|
9
|
+
# raise depending on severity)
|
|
10
|
+
#
|
|
11
|
+
# Each row in COLLISION_PAIRS is `[attrs, message]` where `attrs`
|
|
12
|
+
# is the unordered pair of `data-*` attribute names that may not
|
|
13
|
+
# coexist on the same element, and `message` is the human-readable
|
|
14
|
+
# rationale used in both build-error and runtime warning text.
|
|
15
|
+
# Attribute-name form (not Symbol kinds) lets the same rules apply
|
|
16
|
+
# uniformly to built-in directives and class-based Handler packages
|
|
17
|
+
# (ADR-0027) — the public surface a user actually writes is the
|
|
18
|
+
# attribute name, so matching against it removes the Symbol-pivot
|
|
19
|
+
# indirection on both sides.
|
|
20
|
+
module Lints
|
|
21
|
+
COLLISION_PAIRS = [
|
|
22
|
+
[
|
|
23
|
+
%w[data-text data-unsafe-html],
|
|
24
|
+
"data-text and data-unsafe-html cannot coexist (both write the element body)",
|
|
25
|
+
],
|
|
26
|
+
[
|
|
27
|
+
%w[data-text data-each],
|
|
28
|
+
"data-text and data-each cannot coexist (data-each generates children; data-text would overwrite them)",
|
|
29
|
+
],
|
|
30
|
+
[
|
|
31
|
+
%w[data-unsafe-html data-each],
|
|
32
|
+
"data-unsafe-html and data-each cannot coexist (data-each generates children; data-unsafe-html would overwrite them)",
|
|
33
|
+
],
|
|
34
|
+
[
|
|
35
|
+
%w[data-show data-hide],
|
|
36
|
+
"data-show and data-hide cannot coexist (use one; the inverse is implicit)",
|
|
37
|
+
],
|
|
38
|
+
[
|
|
39
|
+
%w[data-component data-each],
|
|
40
|
+
"data-component and data-each cannot coexist (wrap with another element — put the child component inside the iteration body)",
|
|
41
|
+
],
|
|
42
|
+
[
|
|
43
|
+
%w[data-bind data-field],
|
|
44
|
+
"data-bind and data-field cannot coexist (both wire the input value — pick one: data-bind for form-independent binding, data-field for form-scope binding)",
|
|
45
|
+
],
|
|
46
|
+
].freeze
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
end
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
module Lilac
|
|
2
|
+
module Directives
|
|
3
|
+
# Predicates for the *name-shaped* tokens that appear in directives —
|
|
4
|
+
# event-handler method names, the X parts of `data-attr-X` /
|
|
5
|
+
# `data-css-X`, and the banned-attribute deny-list.
|
|
6
|
+
#
|
|
7
|
+
# Duplicate pair (build-time / runtime). See decisions §17.
|
|
8
|
+
#
|
|
9
|
+
# Reactive *values* (`@ivar` / bare ident) live on `Value`
|
|
10
|
+
# so they can carry their kind polymorphically rather than as a
|
|
11
|
+
# string-prefix check.
|
|
12
|
+
#
|
|
13
|
+
# Anchors: mruby-regexp-compat supports `^`/`$` but not `\A`/`\z`.
|
|
14
|
+
# Since directive values are always single-line strings, `^/$` are
|
|
15
|
+
# equivalent here.
|
|
16
|
+
#
|
|
17
|
+
# Module methods are defined via `class << self` instead of
|
|
18
|
+
# `module_function` because mruby's `module_function` doesn't
|
|
19
|
+
# behave like MRI's (see runtime/mruby-lilac/mrblib/html.rb:60).
|
|
20
|
+
module Grammar
|
|
21
|
+
# `?` predicate suffix allowed (used by `@active?` ivars and
|
|
22
|
+
# bare-ident predicate field reads). Bang `!` is rejected — the
|
|
23
|
+
# regex stops before any trailing `!`.
|
|
24
|
+
METHOD_IDENT = /^[a-zA-Z_][a-zA-Z0-9_]*$/
|
|
25
|
+
|
|
26
|
+
# X part of `data-attr-X` / `data-css-X`: kebab-lowercase,
|
|
27
|
+
# letter-first, no `--` prefix (clashes with CSS variable prefix
|
|
28
|
+
# the framework auto-prepends).
|
|
29
|
+
KEBAB_NAME = /^[a-z][a-z0-9-]*$/
|
|
30
|
+
|
|
31
|
+
# Inline event handlers (`on*`), `srcdoc` (iframe HTML injection
|
|
32
|
+
# vector), and `style` (use `data-css-X` or `RefElement#set_style`
|
|
33
|
+
# instead) are banned from `data-attr-X`.
|
|
34
|
+
BANNED_ATTR = /^on[a-z]+$|^srcdoc$|^style$/
|
|
35
|
+
|
|
36
|
+
# `data-*` attribute names that map to a directive (one of the
|
|
37
|
+
# `Directive::Kind` values aside from `:component`). This is the
|
|
38
|
+
# SSOT for both:
|
|
39
|
+
# * build-time: TemplateAST uses it to identify directive-bearing
|
|
40
|
+
# elements that need a synthetic `lilN` slot.
|
|
41
|
+
# * runtime: `Refs` / `TemplateRefs` use it to walk the mounted
|
|
42
|
+
# subtree in DFS and resolve `refs.lilN` positionally.
|
|
43
|
+
# Anything matching here counts toward a scope's directive-bearing
|
|
44
|
+
# index; markers like `data-ref` / `data-component` / `data-template`
|
|
45
|
+
# / `data-arg-*` are excluded.
|
|
46
|
+
DIRECTIVE_ATTR = /^data-(?:text|unsafe-html|bind|show|hide|each|key|class|form|field|button|on-.+|attr-.+|css-.+)$/
|
|
47
|
+
|
|
48
|
+
class << self
|
|
49
|
+
# `increment` / `add_todo`. Event-handler method names — no `?`
|
|
50
|
+
# suffix so a typoed `save?` is caught at mount time.
|
|
51
|
+
def method_ident?(s)
|
|
52
|
+
!!METHOD_IDENT.match?(s.to_s)
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
# `progress` / `theme-color`. Rejects uppercase, digit-start,
|
|
56
|
+
# leading `-` (which would collide with the auto-prepended `--`).
|
|
57
|
+
def kebab_name?(s)
|
|
58
|
+
str = s.to_s
|
|
59
|
+
!!KEBAB_NAME.match?(str) && !str.start_with?("-")
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def banned_attr?(s)
|
|
63
|
+
!!BANNED_ATTR.match?(s.to_s)
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
# True when `s` is a `data-*` attribute name that triggers
|
|
67
|
+
# codegen (i.e. occupies a `lilN` slot in its scope). Used by
|
|
68
|
+
# both build-time TemplateAST and runtime Refs to keep their
|
|
69
|
+
# DFS counters in lockstep.
|
|
70
|
+
def directive_attribute?(s)
|
|
71
|
+
!!DIRECTIVE_ATTR.match?(s.to_s)
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
end
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Lilac
|
|
4
|
+
module Directives
|
|
5
|
+
# Build-time validation of directive composition rules. Called after
|
|
6
|
+
# directive collection so violations surface as a build error rather
|
|
7
|
+
# than a runtime failure on the user's page.
|
|
8
|
+
#
|
|
9
|
+
# Duplicate pair (build-time / runtime). See decisions §17. The
|
|
10
|
+
# build-time half raises on every violation; the runtime half
|
|
11
|
+
# (runtime/mruby-lilac-directives/mrblib/lilac_directives_lints.rb)
|
|
12
|
+
# applies warn+skip for ergonomics violations.
|
|
13
|
+
#
|
|
14
|
+
# Currently checks pair collisions, tag-level applicability, and
|
|
15
|
+
# `<input>` type-attribute constraints. Not yet enforced:
|
|
16
|
+
# - data-arg-X validations — data-arg has no emitter yet
|
|
17
|
+
module Lints
|
|
18
|
+
# `COLLISION_PAIRS` lives in `collision_rules.rb` (the duplicate-
|
|
19
|
+
# pair SSOT). This file consumes it via the constant lookup below.
|
|
20
|
+
class Error < Lilac::CLI::BuildError; end
|
|
21
|
+
|
|
22
|
+
def self.check!(directives, file:)
|
|
23
|
+
# Group by (scope_id, ref_id) so directives that share a `lilN`
|
|
24
|
+
# NAME but live in different ref scopes (top-level vs each
|
|
25
|
+
# iteration body) are not falsely flagged as colliding. With
|
|
26
|
+
# the per-scope positional counter (decisions §19), ref_ids are
|
|
27
|
+
# only unique WITHIN a scope.
|
|
28
|
+
directives.group_by { |d| [d.scope_id, d.ref_id] }.each_value do |dirs_on_element|
|
|
29
|
+
check_collisions(dirs_on_element, file)
|
|
30
|
+
check_gn_hidden_conflict(dirs_on_element, file)
|
|
31
|
+
end
|
|
32
|
+
check_form_scope!(directives, file)
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# Scope rules for the form gem (form-spec §8 / §10.2):
|
|
36
|
+
# - `data-form` is only allowed on `<form>` elements
|
|
37
|
+
# - multiple bare `<form>` (no data-form attr) within the same
|
|
38
|
+
# component would collide on the `:default` scope, so flag the
|
|
39
|
+
# second occurrence as an error
|
|
40
|
+
def self.check_form_scope!(directives, file)
|
|
41
|
+
seen_default_form = false
|
|
42
|
+
directives.each do |d|
|
|
43
|
+
next unless d.kind == :form
|
|
44
|
+
|
|
45
|
+
if d.element_tag != "form"
|
|
46
|
+
raise Error.new(
|
|
47
|
+
"data-form is only allowed on <form> elements " \
|
|
48
|
+
"(found on <#{d.element_tag}>).",
|
|
49
|
+
at: d.source_location(file),
|
|
50
|
+
suggestion: "Move data-form to a <form> element, or drop it if scope isn't needed.",
|
|
51
|
+
)
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
# `value == ""` is the synthetic marker injected by TemplateAST
|
|
55
|
+
# for bare <form>. Track the first; flag any second occurrence.
|
|
56
|
+
if d.value.to_s.empty?
|
|
57
|
+
if seen_default_form
|
|
58
|
+
raise Error.new(
|
|
59
|
+
"second bare <form> in the same component would collide on the " \
|
|
60
|
+
":default scope.",
|
|
61
|
+
at: d.source_location(file),
|
|
62
|
+
suggestion: %(Add `data-form="..."` to one of them to distinguish.),
|
|
63
|
+
)
|
|
64
|
+
end
|
|
65
|
+
seen_default_form = true
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def self.check_collisions(dirs, file)
|
|
71
|
+
attr_names = dirs.map { |d| attribute_for(d) }
|
|
72
|
+
COLLISION_PAIRS.each do |pair, message|
|
|
73
|
+
next unless pair.all? { |a| attr_names.include?(a) }
|
|
74
|
+
|
|
75
|
+
# Report at the line of the second (later) directive in the
|
|
76
|
+
# pair so users see where the conflict was introduced.
|
|
77
|
+
offenders = dirs.select { |d| pair.include?(attribute_for(d)) }
|
|
78
|
+
raise Error.new(
|
|
79
|
+
"Directive collision: #{message}",
|
|
80
|
+
at: offenders.last.source_location(file),
|
|
81
|
+
)
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
# Derive the `data-*` attribute name from a TemplateAST directive.
|
|
86
|
+
# Mirrors the runtime helper of the same name: both built-in
|
|
87
|
+
# directive kinds and CLI-registered emitter kinds (form's
|
|
88
|
+
# `:form` / `:field` / `:button`) follow the `data-<kind>` rule
|
|
89
|
+
# with `_` → `-` and a trailing `_` stripped.
|
|
90
|
+
def self.attribute_for(directive)
|
|
91
|
+
"data-#{directive.kind.to_s.tr('_', '-').chomp('-')}"
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
# `lil-hidden` is reserved by data-show / data-hide. If the user
|
|
95
|
+
# puts `'lil-hidden': @x` in data-class on the same element, three
|
|
96
|
+
# signals can race over the class — fail at build time and tell
|
|
97
|
+
# the user to drop the data-class entry.
|
|
98
|
+
#
|
|
99
|
+
# Note: parses the data-class hash literal purely for this check.
|
|
100
|
+
# The substring guard above keeps the cost zero for the common
|
|
101
|
+
# case (no `lil-hidden` anywhere in the value).
|
|
102
|
+
def self.check_gn_hidden_conflict(dirs, file)
|
|
103
|
+
return unless dirs.any? { |d| %i[show hide].include?(d.kind) }
|
|
104
|
+
|
|
105
|
+
class_dir = dirs.find { |d| d.kind == :class_ }
|
|
106
|
+
return unless class_dir
|
|
107
|
+
return unless class_dir.value.to_s.include?("lil-hidden")
|
|
108
|
+
|
|
109
|
+
pairs =
|
|
110
|
+
begin
|
|
111
|
+
ClassParser.parse(class_dir.value)
|
|
112
|
+
rescue ClassParser::Error
|
|
113
|
+
# Malformed data-class — the runtime scanner surfaces the
|
|
114
|
+
# parse error at mount; skip the conflict check here.
|
|
115
|
+
return
|
|
116
|
+
end
|
|
117
|
+
return unless pairs.any? { |key, _| key == "lil-hidden" }
|
|
118
|
+
|
|
119
|
+
raise Error.new(
|
|
120
|
+
"data-class uses the reserved class `lil-hidden` on an element " \
|
|
121
|
+
"that also has data-show / data-hide.",
|
|
122
|
+
at: class_dir.source_location(file),
|
|
123
|
+
suggestion: "Drop the `lil-hidden` key from data-class — data-show/data-hide manage it.",
|
|
124
|
+
)
|
|
125
|
+
end
|
|
126
|
+
end
|
|
127
|
+
end
|
|
128
|
+
end
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
module Lilac
|
|
2
|
+
module Directives
|
|
3
|
+
# The right-hand side of a directive that takes a reactive value.
|
|
4
|
+
# Two kinds exist, both single-identifier (no dot / no path / no
|
|
5
|
+
# expression):
|
|
6
|
+
#
|
|
7
|
+
# - `@ivar` — a host Signal/Computed. Resolves via
|
|
8
|
+
# `Evaluator#lookup_ivar` and feeds into
|
|
9
|
+
# `bind ref, prop: signal` directly.
|
|
10
|
+
# - bare ident — a field of the current iteration item.
|
|
11
|
+
# `Evaluator#read` reads `item[name]` (Hash key
|
|
12
|
+
# first, then String fallback, then public_send).
|
|
13
|
+
# Only meaningful inside a `data-each` body;
|
|
14
|
+
# value-binding dispatch silent-skips when
|
|
15
|
+
# `item.nil?` (= scanning the host root).
|
|
16
|
+
#
|
|
17
|
+
# Duplicate pair (build-time / runtime). See decisions §17.
|
|
18
|
+
#
|
|
19
|
+
# `Value.parse` returns `Ivar`, `BareIdent`, or `nil` for invalid
|
|
20
|
+
# input — callers raise their own error with directive context.
|
|
21
|
+
# Match precedence: `@ivar` first (unambiguous prefix), then bare
|
|
22
|
+
# ident (everything else that looks like a Ruby identifier).
|
|
23
|
+
class Value
|
|
24
|
+
# Anchors use `^`/`$` not `\A`/`\z` — see Grammar for the
|
|
25
|
+
# mruby-regexp-compat compatibility note.
|
|
26
|
+
IVAR = /^@[a-zA-Z_]\w*\??$/
|
|
27
|
+
BARE_IDENT = /^[a-zA-Z_]\w*\??$/
|
|
28
|
+
|
|
29
|
+
def self.parse(raw)
|
|
30
|
+
s = raw.to_s.strip
|
|
31
|
+
if IVAR.match?(s)
|
|
32
|
+
Ivar.new(s)
|
|
33
|
+
elsif BARE_IDENT.match?(s)
|
|
34
|
+
BareIdent.new(s)
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
attr_reader :raw
|
|
39
|
+
|
|
40
|
+
def initialize(raw)
|
|
41
|
+
@raw = raw
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def to_s
|
|
45
|
+
@raw
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def inspect
|
|
49
|
+
@raw.inspect
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def ivar?
|
|
53
|
+
false
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def bare_ident?
|
|
57
|
+
false
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
class Value::Ivar < Value
|
|
62
|
+
# `@count` -> :@count
|
|
63
|
+
def ivar_sym
|
|
64
|
+
@raw.to_sym
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def ivar?
|
|
68
|
+
true
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
# Bare identifier referring to a field of the current iteration item.
|
|
73
|
+
# Outside `data-each` scope (item.nil?), value-binding dispatch
|
|
74
|
+
# silent-skips.
|
|
75
|
+
class Value::BareIdent < Value
|
|
76
|
+
def field
|
|
77
|
+
@raw
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def bare_ident?
|
|
81
|
+
true
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
end
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Build-time loader for `Lilac::Directives::*` files. The files under
|
|
4
|
+
# `directives/` are intentional duplicates of the runtime mrbgem at
|
|
5
|
+
# `runtime/mruby-lilac-directives/mrblib/` — they share class names so
|
|
6
|
+
# `diff(1)` between the pair surfaces only semantic differences. Per
|
|
7
|
+
# decisions §17, this means the individual files must NOT carry
|
|
8
|
+
# `require_relative` statements (the runtime mrbgem also has none —
|
|
9
|
+
# mruby-config drives load order there). MRI load order is owned here.
|
|
10
|
+
|
|
11
|
+
require_relative "cli/build/build_error" # lints.rb references Lilac::CLI::BuildError
|
|
12
|
+
require_relative "directives/value"
|
|
13
|
+
require_relative "directives/grammar"
|
|
14
|
+
require_relative "directives/class_parser"
|
|
15
|
+
require_relative "directives/collision_rules" # COLLISION_PAIRS SSOT, consumed by lints.rb
|
|
16
|
+
require_relative "directives/lints"
|