@zvndev/darkmown 1.5.0 → 2.0.1
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.
- package/AGENTS.md +18 -4
- package/CHANGELOG.md +40 -0
- package/README.md +49 -19
- package/SECURITY.md +21 -18
- package/docs/cli.md +5 -1
- package/docs/spec-alignment.md +21 -7
- package/package.json +3 -2
- package/site/_/base.skin +8 -0
- package/site/_/base.wd +4 -0
- package/site/_/feature-card.wd +4 -0
- package/site/_/footer.wd +5 -1
- package/site/_/nav.wd +4 -0
- package/site/_/scoped-promo.wd +4 -0
- package/site/_/scoped-stat.wd +4 -0
- package/site/pages/404.wd +1 -0
- package/site/pages/app.wd +2 -1
- package/site/pages/blog/collections-and-pagination.skin +127 -0
- package/site/pages/blog/drafts-that-never-leak.skin +127 -0
- package/site/pages/blog/index.wd +1 -0
- package/site/pages/blog/shipping-feeds.skin +127 -0
- package/site/pages/blog/typed-frontmatter.skin +127 -0
- package/site/pages/blog/unfinished-thoughts.skin +127 -0
- package/site/pages/blog/zero-js-by-default.md +1 -1
- package/site/pages/blog/zero-js-by-default.skin +127 -0
- package/site/pages/carousel/index.wd +1 -0
- package/site/pages/compass/index.wd +1 -0
- package/site/pages/data.wd +3 -0
- package/site/pages/docs/-relative-note.wd +4 -0
- package/site/pages/docs/index.skin +27 -0
- package/site/pages/docs/index.wd +85 -14
- package/site/pages/folio/-style.wd +4 -0
- package/site/pages/folio/cart.wd +1 -0
- package/site/pages/folio/index.wd +1 -0
- package/site/pages/forge/index.wd +1 -0
- package/site/pages/highlight/index.wd +1 -0
- package/site/pages/index.js +19 -0
- package/site/pages/index.skin +46 -1
- package/site/pages/index.wd +6 -3
- package/site/pages/interactions/index.wd +1 -0
- package/site/pages/ledger/index.wd +1 -0
- package/site/pages/loops.wd +1 -0
- package/site/pages/media/index.wd +1 -0
- package/site/pages/pulse/index.wd +1 -0
- package/site/pages/reactive.wd +1 -0
- package/site/pages/scoped/index.wd +1 -0
- package/site/pages/showcase/index.wd +2 -1
- package/site/pages/store.wd +1 -0
- package/site/pages/theme.wd +1 -0
- package/src/builder.js +444 -65
- package/src/cli.js +96 -18
- package/src/compiler/actions.js +303 -0
- package/src/compiler/body.js +36 -24
- package/src/compiler/context.js +65 -6
- package/src/compiler/directives.js +31 -1470
- package/src/compiler/fetch.js +159 -0
- package/src/compiler/format.js +17 -4
- package/src/compiler/forms.js +456 -0
- package/src/compiler/frontmatter.js +10 -5
- package/src/compiler/includes.js +75 -4
- package/src/compiler/index.js +15 -3
- package/src/compiler/loops.js +85 -23
- package/src/compiler/markdown.js +65 -15
- package/src/compiler/media.js +133 -0
- package/src/compiler/page.js +88 -77
- package/src/compiler/state.js +168 -0
- package/src/compiler/structure.js +399 -0
- package/src/dev.js +76 -0
- package/src/headers.js +56 -17
- package/src/router.js +12 -1
- package/src/runtime.js +5 -3
- package/src/statics.js +63 -12
- package/src/templates/blog/README.md +13 -4
- package/src/templates/blog/site/_/nav.wd +7 -6
- package/src/templates/blog/site/pages/index.skin +10 -4
- package/src/templates/blog/site/pages/index.wd +11 -6
- package/src/templates/blog/site/pages/posts/_schema.wd +16 -0
- package/src/templates/blog/site/pages/posts/hello-darkmown.md +2 -0
- package/src/templates/blog/site/pages/posts/hello-darkmown.skin +59 -0
- package/src/templates/blog/site/pages/posts/markdown-native.md +9 -4
- package/src/templates/blog/site/pages/posts/markdown-native.skin +59 -0
- package/src/templates/dashboard/site/_/nav.wd +7 -6
- package/src/templates/dashboard/site/pages/index.skin +7 -4
- package/src/templates/dashboard/site/pages/index.wd +2 -2
- package/src/templates/landing/site/_/nav.wd +7 -6
- package/src/templates/landing/site/pages/index.skin +13 -4
- package/src/templates/landing/site/pages/index.wd +22 -8
- package/src/templates/starter/site/_/nav.wd +8 -7
- package/src/templates/starter/site/pages/about.md +8 -2
- package/src/templates/starter/site/pages/about.skin +44 -0
- package/src/templates/starter/site/pages/index.skin +10 -6
- package/src/templates/starter/site/pages/index.wd +2 -2
- package/src/templates/store/site/_/nav.wd +7 -6
- package/src/templates/store/site/pages/index.skin +7 -4
- package/src/templates/store/site/pages/index.wd +2 -2
- package/src/templates/blog/site/_/posts.json +0 -16
|
@@ -1,1474 +1,35 @@
|
|
|
1
1
|
// ---------------------------------------------------------------------------
|
|
2
|
-
// Directive
|
|
3
|
-
//
|
|
4
|
-
//
|
|
5
|
-
// parser and the documentation-demo directives (`:try`/`:note`/`:sprint`).
|
|
2
|
+
// Directive handler barrel: the `handle*` family the body parser dispatches to,
|
|
3
|
+
// re-exported from the cohesive handler modules so `body.js` (and anything else
|
|
4
|
+
// reaching for a handler) has one import site. The implementations live in:
|
|
6
5
|
//
|
|
7
|
-
//
|
|
8
|
-
//
|
|
9
|
-
//
|
|
6
|
+
// state.js :state / :store / :computed / :theme + the declare* helpers
|
|
7
|
+
// actions.js the :button action parser + :button / :effect / :every
|
|
8
|
+
// fetch.js :fetch + the shared URL scheme guard (validateFetchUrl)
|
|
9
|
+
// forms.js :form + :input/:textarea/:select/:checkbox/:radio/:submit,
|
|
10
|
+
// and the bound controls :bind / :slider
|
|
11
|
+
// media.js :video / :audio / :embed (compile-time only, zero runtime)
|
|
12
|
+
// structure.js @include / ::: container / :if / :carousel + demo directives
|
|
10
13
|
// ---------------------------------------------------------------------------
|
|
11
14
|
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
* @typedef {import("./context.js").Ctx} Ctx
|
|
34
|
-
* @typedef {import("./context.js").Action} Action
|
|
35
|
-
*/
|
|
36
|
-
|
|
37
|
-
/**
|
|
38
|
-
* @param {string} line
|
|
39
|
-
* @param {Ctx} ctx
|
|
40
|
-
* @param {number} index 0-based line index for `file:line` errors.
|
|
41
|
-
* @returns {string}
|
|
42
|
-
*/
|
|
43
|
-
export function handleInclude(line, ctx, index) {
|
|
44
|
-
const match = line.match(/^@include\s+(\S+)(?:\s+with\s+(.+))?$/);
|
|
45
|
-
if (!match) throw new Error(`Malformed @include in ${at(ctx.file, index)}: ${line}`);
|
|
46
|
-
const target = resolveInclude(match[1], ctx.file, ctx.context);
|
|
47
|
-
const args = parseIncludeArgs(match[2] || "", ctx);
|
|
48
|
-
const childScope = createScope(ctx.scope, args);
|
|
49
|
-
const child = compileFile(
|
|
50
|
-
target,
|
|
51
|
-
ctx.context,
|
|
52
|
-
ctx.stack,
|
|
53
|
-
childScope,
|
|
54
|
-
ctx.comp,
|
|
55
|
-
ctx.sections,
|
|
56
|
-
ctx.loopItem
|
|
57
|
-
);
|
|
58
|
-
// An include with its OWN scoped colocated skin stamps just its returned
|
|
59
|
-
// subtree — so a scoped `.card` in the include can't collide with a `.card`
|
|
60
|
-
// anywhere else on the page, and the scope never leaks to the include's
|
|
61
|
-
// siblings (only this child HTML is stamped). The CSS rewrite (builder) uses
|
|
62
|
-
// the same path-derived id, so attribute and stylesheet line up.
|
|
63
|
-
const scopedSkin = scopedSkinFor(target, ctx.context);
|
|
64
|
-
return scopedSkin && scopedSkin.scoped ? stampScope(child.html, scopedSkin.scopeId) : child.html;
|
|
65
|
-
}
|
|
66
|
-
|
|
67
|
-
/**
|
|
68
|
-
* @param {string} raw
|
|
69
|
-
* @param {Ctx} ctx
|
|
70
|
-
* @returns {Record<string, unknown>}
|
|
71
|
-
*/
|
|
72
|
-
function parseIncludeArgs(raw, ctx) {
|
|
73
|
-
/** @type {Record<string, unknown>} */
|
|
74
|
-
const args = {};
|
|
75
|
-
const re = /([A-Za-z0-9_-]+)=("[^"]*"|'[^']*'|\{[^}]*\}|\S+)/g;
|
|
76
|
-
for (const match of raw.matchAll(re)) {
|
|
77
|
-
const value = match[2];
|
|
78
|
-
if (value.startsWith("{")) {
|
|
79
|
-
const expr = value.slice(1, -1).trim();
|
|
80
|
-
const resolved = lookupPath(expr, ctx);
|
|
81
|
-
if (!resolved.found) {
|
|
82
|
-
throw new Error(
|
|
83
|
-
`@include argument ${match[1]}={ ${expr} } in ${ctx.file} does not match any value in scope`
|
|
84
|
-
);
|
|
85
|
-
}
|
|
86
|
-
args[match[1]] = resolved.value;
|
|
87
|
-
} else {
|
|
88
|
-
args[match[1]] = parseScalar(value);
|
|
89
|
-
}
|
|
90
|
-
}
|
|
91
|
-
return args;
|
|
92
|
-
}
|
|
93
|
-
|
|
94
|
-
/**
|
|
95
|
-
* @param {string} header
|
|
96
|
-
* @param {string[]} bodyLines
|
|
97
|
-
* @param {Ctx} ctx
|
|
98
|
-
* @param {number} index 0-based line index for `file:line` errors.
|
|
99
|
-
* @returns {string}
|
|
100
|
-
*/
|
|
101
|
-
export function handleContainer(header, bodyLines, ctx, index) {
|
|
102
|
-
let rest = header.trim();
|
|
103
|
-
let tag = "section";
|
|
104
|
-
/** @type {string[]} Static classes baked into class="". */
|
|
105
|
-
const extraClass = [];
|
|
106
|
-
/** @type {[string, string][]} Reactive loop-item class bindings (data-wd-each-class). */
|
|
107
|
-
const eachClasses = [];
|
|
108
|
-
/** @type {[string, string][]} Global state-driven class bindings (data-wd-class). */
|
|
109
|
-
const stateClasses = [];
|
|
110
|
-
let id = "";
|
|
111
|
-
// Leading tag/name token (anything not starting with . or #). "section" keeps
|
|
112
|
-
// the <section> tag; any other name becomes a <div> and also a class.
|
|
113
|
-
const lead = rest.match(/^([^\s.#]\S*)/);
|
|
114
|
-
let nameToken = "section";
|
|
115
|
-
if (lead) {
|
|
116
|
-
nameToken = lead[1];
|
|
117
|
-
rest = rest.slice(lead[0].length).trim();
|
|
118
|
-
}
|
|
119
|
-
if (nameToken !== "section") {
|
|
120
|
-
tag = "div";
|
|
121
|
-
extraClass.push(nameToken);
|
|
122
|
-
}
|
|
123
|
-
while (rest.length) {
|
|
124
|
-
if (rest.startsWith("#")) {
|
|
125
|
-
const m = rest.match(/^#(\S+)/);
|
|
126
|
-
id = m ? m[1] : "";
|
|
127
|
-
rest = rest.slice((m ? m[0] : rest).length).trim();
|
|
128
|
-
continue;
|
|
129
|
-
}
|
|
130
|
-
const cm = rest.match(/^\.([A-Za-z_][\w-]*)/);
|
|
131
|
-
if (!cm)
|
|
132
|
-
throw new Error(
|
|
133
|
-
`Unexpected token "${rest.split(/\s+/)[0]}" in container "::: ${header}" in ${at(ctx.file, index)}`
|
|
134
|
-
);
|
|
135
|
-
const cls = cm[1];
|
|
136
|
-
rest = rest.slice(cm[0].length).trim();
|
|
137
|
-
// Optional `when <predicate>` makes the class reactive. The predicate runs to
|
|
138
|
-
// the next ` .`/` #` token or the end of the header.
|
|
139
|
-
const whenMatch = rest.match(/^when\s+(.+?)(?=\s+[.#]|$)/);
|
|
140
|
-
if (whenMatch) {
|
|
141
|
-
rest = rest.slice(whenMatch[0].length).trim();
|
|
142
|
-
const compiled = compileWhen(whenMatch[1].trim(), ctx);
|
|
143
|
-
if (compiled.static) {
|
|
144
|
-
if (compiled.value) extraClass.push(cls);
|
|
145
|
-
} else if (compiled.item) eachClasses.push([cls, compiled.body]);
|
|
146
|
-
else stateClasses.push([cls, compiled.body]);
|
|
147
|
-
} else {
|
|
148
|
-
extraClass.push(cls);
|
|
149
|
-
}
|
|
150
|
-
}
|
|
151
|
-
const explicitId = Boolean(id);
|
|
152
|
-
if (!id) id = `wd-s${++ctx.comp.sectionCounter}`;
|
|
153
|
-
|
|
154
|
-
ctx.sections.push(id);
|
|
155
|
-
let inner;
|
|
156
|
-
try {
|
|
157
|
-
inner = compileBody(bodyLines, ctx);
|
|
158
|
-
} finally {
|
|
159
|
-
ctx.sections.pop();
|
|
160
|
-
}
|
|
161
|
-
const idAttr = explicitId ? ` id="${escapeHtml(id)}"` : "";
|
|
162
|
-
const classAttr = extraClass.length ? ` class="${escapeHtml(extraClass.join(" "))}"` : "";
|
|
163
|
-
let classBind = "";
|
|
164
|
-
if (eachClasses.length) {
|
|
165
|
-
ctx.comp.assets.runtime = true;
|
|
166
|
-
classBind += ` data-wd-each-class="${escapeHtml(JSON.stringify(eachClasses))}"`;
|
|
167
|
-
}
|
|
168
|
-
if (stateClasses.length) {
|
|
169
|
-
ctx.comp.assets.runtime = true;
|
|
170
|
-
classBind += ` data-wd-class="${escapeHtml(JSON.stringify(stateClasses))}"`;
|
|
171
|
-
}
|
|
172
|
-
return `<${tag}${idAttr}${classAttr}${classBind}>\n${inner}\n</${tag}>`;
|
|
173
|
-
}
|
|
174
|
-
|
|
175
|
-
/**
|
|
176
|
-
* @param {string} line
|
|
177
|
-
* @param {Ctx} ctx
|
|
178
|
-
* @param {number} index 0-based line index for `file:line` errors.
|
|
179
|
-
* @returns {string}
|
|
180
|
-
*/
|
|
181
|
-
export function handleState(line, ctx, index) {
|
|
182
|
-
const match = line.match(/^:state\s+([A-Za-z_$][\w$]*)\s*=\s*(.+?)(\s+persist)?$/);
|
|
183
|
-
if (!match) throw new Error(`Malformed :state in ${at(ctx.file, index)}: ${line}`);
|
|
184
|
-
const value = parseStateValue(match[2], at(ctx.file, index));
|
|
185
|
-
const key = declareState(match[1], value, ctx);
|
|
186
|
-
const persistAttr = match[3] ? ` data-wd-persist="${key}"` : "";
|
|
187
|
-
return `<script type="application/json" data-wd-state${persistAttr}>${safeScriptJson({ [key]: value })}</script>`;
|
|
188
|
-
}
|
|
189
|
-
|
|
190
|
-
/**
|
|
191
|
-
* Register a state key in the current section scope, enabling the runtime.
|
|
192
|
-
* @param {string} name
|
|
193
|
-
* @param {unknown} value
|
|
194
|
-
* @param {Ctx} ctx
|
|
195
|
-
* @returns {string} The fully-qualified state key.
|
|
196
|
-
*/
|
|
197
|
-
function declareState(name, value, ctx) {
|
|
198
|
-
if (ctx.loopItem)
|
|
199
|
-
throw new Error(`State cannot be declared inside a reactive @loop body (${ctx.file})`);
|
|
200
|
-
if (ctx.comp.stores.has(name))
|
|
201
|
-
throw new Error(
|
|
202
|
-
`State "${name}" collides with a :store of the same name in ${ctx.file}. Use: :store name = value for the global, or rename one.`
|
|
203
|
-
);
|
|
204
|
-
const key = ctx.sections.length ? `${ctx.sections.at(-1)}:${name}` : name;
|
|
205
|
-
if (ctx.comp.state.has(key))
|
|
206
|
-
throw new Error(`State "${name}" is declared twice in the same scope (${ctx.file})`);
|
|
207
|
-
ctx.comp.state.set(key, value);
|
|
208
|
-
ctx.comp.assets.runtime = true;
|
|
209
|
-
return key;
|
|
210
|
-
}
|
|
211
|
-
|
|
212
|
-
/**
|
|
213
|
-
* @param {string} line
|
|
214
|
-
* @param {Ctx} ctx
|
|
215
|
-
* @param {number} index 0-based line index for `file:line` errors.
|
|
216
|
-
* @returns {string}
|
|
217
|
-
*/
|
|
218
|
-
export function handleStore(line, ctx, index) {
|
|
219
|
-
const match = line.match(/^:store\s+([A-Za-z_$][\w$]*)\s*=\s*(.+?)(\s+ephemeral)?$/);
|
|
220
|
-
if (!match)
|
|
221
|
-
throw new Error(
|
|
222
|
-
`Malformed :store in ${at(ctx.file, index)}: ${line}. Use: :store name = value [ephemeral]`
|
|
223
|
-
);
|
|
224
|
-
const value = parseStateValue(match[2], at(ctx.file, index));
|
|
225
|
-
const name = declareStore(match[1], value, ctx);
|
|
226
|
-
const ephemeral = match[3] ? " data-wd-store-ephemeral" : "";
|
|
227
|
-
return `<script type="application/json" data-wd-store="${name}"${ephemeral}>${safeScriptJson(value)}</script>`;
|
|
228
|
-
}
|
|
229
|
-
|
|
230
|
-
/**
|
|
231
|
-
* Register a page-global store. The bare name is added to `comp.state` so every
|
|
232
|
-
* resolver (interpolation, :if, @loop, :computed, actions) sees it, and tracked
|
|
233
|
-
* in `comp.stores` for collision checks. Never section-scoped.
|
|
234
|
-
* @param {string} name
|
|
235
|
-
* @param {unknown} value
|
|
236
|
-
* @param {Ctx} ctx
|
|
237
|
-
* @returns {string} The store name (also its bare state key).
|
|
238
|
-
*/
|
|
239
|
-
function declareStore(name, value, ctx) {
|
|
240
|
-
if (ctx.comp.stores.has(name))
|
|
241
|
-
throw new Error(`Store "${name}" is declared twice in ${ctx.file}. Use: :store name = value`);
|
|
242
|
-
if (ctx.comp.state.has(name))
|
|
243
|
-
throw new Error(
|
|
244
|
-
`Store "${name}" collides with a :state of the same name in ${ctx.file}. Use: :store name = value for the global, or rename one.`
|
|
245
|
-
);
|
|
246
|
-
ctx.comp.stores.add(name);
|
|
247
|
-
ctx.comp.state.set(name, value);
|
|
248
|
-
ctx.comp.assets.runtime = true;
|
|
249
|
-
return name;
|
|
250
|
-
}
|
|
251
|
-
|
|
252
|
-
/**
|
|
253
|
-
* Seed a `<name>_error` state key (null) if absent. Shared by :fetch and the
|
|
254
|
-
* round-trip :form so error fallbacks have a key to bind.
|
|
255
|
-
* @param {string} key
|
|
256
|
-
* @param {Ctx} ctx
|
|
257
|
-
* @returns {void}
|
|
258
|
-
*/
|
|
259
|
-
function declareErrorState(key, ctx) {
|
|
260
|
-
const errorKey = `${key}_error`;
|
|
261
|
-
if (!ctx.comp.state.has(errorKey)) ctx.comp.state.set(errorKey, null);
|
|
262
|
-
}
|
|
263
|
-
|
|
264
|
-
const FETCH_USE =
|
|
265
|
-
'Use: :fetch name from "url" [method=…] [timeout=ms] [retry=N] [when=visible] [headers=key] [body=key] [refresh=url]';
|
|
266
|
-
|
|
267
|
-
/**
|
|
268
|
-
* Parse a keyword-arg `:fetch` directive into a lifecycle-aware marker.
|
|
269
|
-
* Auto-declares four state keys (value/error/loading/empty), seeds them, and
|
|
270
|
-
* emits `data-wd-fetch-*` attributes (url/method/when/timeout/retry/headers/
|
|
271
|
-
* body/deps) consumed by the runtime's `startFetch`.
|
|
272
|
-
* @param {string} line
|
|
273
|
-
* @param {Ctx} ctx
|
|
274
|
-
* @param {number} index 0-based line index for `file:line` errors.
|
|
275
|
-
* @returns {string}
|
|
276
|
-
*/
|
|
277
|
-
export function handleFetch(line, ctx, index) {
|
|
278
|
-
const head = line.match(/^:fetch\s+([A-Za-z_$][\w$]*)\s+from\s+("[^"]+"|\S+)\s*(.*)$/);
|
|
279
|
-
if (!head) throw new Error(`Malformed :fetch in ${at(ctx.file, index)}: ${line}. ${FETCH_USE}`);
|
|
280
|
-
const name = head[1];
|
|
281
|
-
const url = validateFetchUrl(stripQuotes(head[2]), ctx);
|
|
282
|
-
/** @type {Record<string, string>} */
|
|
283
|
-
const opts = {};
|
|
284
|
-
for (const part of head[3].trim().split(/\s+/).filter(Boolean)) {
|
|
285
|
-
const kv = part.match(/^([A-Za-z]+)=(.+)$/);
|
|
286
|
-
if (!kv)
|
|
287
|
-
throw new Error(`Unknown :fetch option "${part}" in ${at(ctx.file, index)}. ${FETCH_USE}`);
|
|
288
|
-
const optName = kv[1];
|
|
289
|
-
if (!["method", "when", "timeout", "retry", "headers", "body", "refresh"].includes(optName)) {
|
|
290
|
-
throw new Error(`Unknown :fetch option "${optName}" in ${at(ctx.file, index)}. ${FETCH_USE}`);
|
|
291
|
-
}
|
|
292
|
-
opts[optName] = stripQuotes(kv[2]);
|
|
293
|
-
}
|
|
294
|
-
|
|
295
|
-
if (
|
|
296
|
-
opts.method &&
|
|
297
|
-
!["GET", "POST", "PUT", "PATCH", "DELETE"].includes(opts.method.toUpperCase())
|
|
298
|
-
) {
|
|
299
|
-
throw new Error(
|
|
300
|
-
`:fetch method "${opts.method}" is not allowed in ${at(ctx.file, index)}. ${FETCH_USE}`
|
|
301
|
-
);
|
|
302
|
-
}
|
|
303
|
-
if (opts.when && !["load", "visible"].includes(opts.when)) {
|
|
304
|
-
throw new Error(
|
|
305
|
-
`:fetch when "${opts.when}" is not allowed in ${at(ctx.file, index)}. ${FETCH_USE}`
|
|
306
|
-
);
|
|
307
|
-
}
|
|
308
|
-
for (const n of ["timeout", "retry"]) {
|
|
309
|
-
if (opts[n] !== undefined && !/^\d+$/.test(opts[n])) {
|
|
310
|
-
throw new Error(
|
|
311
|
-
`:fetch ${n} must be a non-negative integer in ${at(ctx.file, index)}. ${FETCH_USE}`
|
|
312
|
-
);
|
|
313
|
-
}
|
|
314
|
-
}
|
|
315
|
-
if (opts.refresh !== undefined) {
|
|
316
|
-
// Layer 2: a 401 triggers a token-refresh POST to this URL, then one retry.
|
|
317
|
-
// The new token is written back into the `headers=` state, so it is required.
|
|
318
|
-
if (!opts.headers) {
|
|
319
|
-
throw new Error(
|
|
320
|
-
`:fetch refresh= needs headers= (the state key holding the token to renew) in ${at(ctx.file, index)}. ${FETCH_USE}`
|
|
321
|
-
);
|
|
322
|
-
}
|
|
323
|
-
opts.refresh = validateFetchUrl(opts.refresh, ctx, ":fetch refresh");
|
|
324
|
-
}
|
|
325
|
-
|
|
326
|
-
// Extract `{ path }` dependency keys from the URL (validated against poison
|
|
327
|
-
// segments). The runtime fills them from state on each (re)fetch.
|
|
328
|
-
/** @type {string[]} */
|
|
329
|
-
const deps = [];
|
|
330
|
-
for (const dep of url.matchAll(/\{\s*([A-Za-z_$][\w$.]*)\s*\}/g)) {
|
|
331
|
-
validatePath(dep[1], ctx, FETCH_USE);
|
|
332
|
-
const headKey = dep[1].split(".")[0];
|
|
333
|
-
if (!deps.includes(headKey)) deps.push(headKey);
|
|
334
|
-
}
|
|
335
|
-
|
|
336
|
-
// Auto-declare the four lifecycle keys. `name` is declared via declareState
|
|
337
|
-
// (collision-checked); the derived keys are seeded directly.
|
|
338
|
-
const key = declareState(name, null, ctx);
|
|
339
|
-
/** @type {Record<string, unknown>} */
|
|
340
|
-
const seeds = { [key]: null };
|
|
341
|
-
for (const [suffix, seed] of [
|
|
342
|
-
["_error", null],
|
|
343
|
-
["_loading", false],
|
|
344
|
-
["_empty", false]
|
|
345
|
-
]) {
|
|
346
|
-
const k = `${key}${suffix}`;
|
|
347
|
-
if (!ctx.comp.state.has(k)) ctx.comp.state.set(k, seed);
|
|
348
|
-
seeds[k] = seed;
|
|
349
|
-
}
|
|
350
|
-
|
|
351
|
-
/** @param {string} n @param {string | null | false | undefined} v */
|
|
352
|
-
const attr = (n, v) =>
|
|
353
|
-
v != null && v !== false && v !== "" ? ` data-wd-fetch-${n}="${escapeHtml(v)}"` : "";
|
|
354
|
-
const marker =
|
|
355
|
-
`<span data-wd-fetch data-wd-fetch-key="${key}" data-wd-fetch-url="${escapeHtml(url)}"` +
|
|
356
|
-
attr("method", opts.method && opts.method.toUpperCase()) +
|
|
357
|
-
attr("when", opts.when === "visible" ? "visible" : "") +
|
|
358
|
-
attr("timeout", opts.timeout) +
|
|
359
|
-
attr("retry", opts.retry) +
|
|
360
|
-
attr("headers", opts.headers && resolveStateKey(opts.headers, ctx)) +
|
|
361
|
-
attr("body", opts.body && resolveStateKey(opts.body, ctx)) +
|
|
362
|
-
attr("refresh", opts.refresh) +
|
|
363
|
-
attr("deps", deps.join(",")) +
|
|
364
|
-
`></span>`;
|
|
365
|
-
return `<script type="application/json" data-wd-state>${safeScriptJson(seeds)}</script>${marker}`;
|
|
366
|
-
}
|
|
367
|
-
|
|
368
|
-
/**
|
|
369
|
-
* Validate a `:fetch` (or `refresh=`) URL's scheme at compile time. Mirrors the
|
|
370
|
-
* `:try` href guard: relative paths (`/`, `./`, `../`, bare), an `http(s)://`
|
|
371
|
-
* URL, or a leading `{ state }` interpolation are allowed; a protocol-relative
|
|
372
|
-
* `//host` or any non-http(s) scheme (`file:`, `data:`, `javascript:`, …) is
|
|
373
|
-
* rejected. Interpolated values are percent-encoded by the runtime, so a scheme
|
|
374
|
-
* cannot be injected through state at request time.
|
|
375
|
-
* @param {string} url
|
|
376
|
-
* @param {Ctx} ctx
|
|
377
|
-
* @param {string} [what] Option name for the error message.
|
|
378
|
-
* @returns {string}
|
|
379
|
-
*/
|
|
380
|
-
function validateFetchUrl(url, ctx, what = ":fetch") {
|
|
381
|
-
const value = url.trim();
|
|
382
|
-
// biome-ignore lint/suspicious/noControlCharactersInRegex: intentional — rejects control characters in URLs/hrefs.
|
|
383
|
-
if (value !== url || value === "" || /[\u0000-\u001F\u007F]/.test(value)) {
|
|
384
|
-
throw new Error(
|
|
385
|
-
`Unsafe ${what} URL "${escapeHtml(url)}" in ${ctx.file}. Use a relative path (/, ./, ../), an http(s):// URL, or a { state } interpolation.`
|
|
386
|
-
);
|
|
387
|
-
}
|
|
388
|
-
if (value.startsWith("//")) {
|
|
389
|
-
throw new Error(
|
|
390
|
-
`Unsafe ${what} URL "${escapeHtml(url)}" in ${ctx.file}. Protocol-relative URLs are not allowed; use http:// or https:// explicitly.`
|
|
391
|
-
);
|
|
392
|
-
}
|
|
393
|
-
if (value.startsWith("{")) return value; // interpolation-first; the runtime percent-encodes state values
|
|
394
|
-
const scheme = value.match(/^([A-Za-z][A-Za-z0-9+.-]*):/);
|
|
395
|
-
if (scheme && !["http", "https"].includes(scheme[1].toLowerCase())) {
|
|
396
|
-
throw new Error(
|
|
397
|
-
`Unsafe ${what} URL "${escapeHtml(url)}" in ${ctx.file}. The "${scheme[1]}:" scheme is not allowed; use http://, https://, or a relative path.`
|
|
398
|
-
);
|
|
399
|
-
}
|
|
400
|
-
return value;
|
|
401
|
-
}
|
|
402
|
-
|
|
403
|
-
/**
|
|
404
|
-
* @param {string} line
|
|
405
|
-
* @param {Ctx} ctx
|
|
406
|
-
* @param {number} index 0-based line index for `file:line` errors.
|
|
407
|
-
* @returns {string}
|
|
408
|
-
*/
|
|
409
|
-
export function handleComputed(line, ctx, index) {
|
|
410
|
-
const match = line.match(/^:computed\s+([A-Za-z_$][\w$]*)\s*=\s*(.+)$/);
|
|
411
|
-
if (!match)
|
|
412
|
-
throw new Error(
|
|
413
|
-
`Malformed :computed in ${at(ctx.file, index)}: ${line}. Use: :computed total = items.length * 4`
|
|
414
|
-
);
|
|
415
|
-
const expr = compileComputedExpr(match[2].trim(), ctx);
|
|
416
|
-
/** @type {unknown} */
|
|
417
|
-
let initial;
|
|
418
|
-
try {
|
|
419
|
-
/** @param {string} key @param {string} [path] */
|
|
420
|
-
const read = (key, path) => getPath(ctx.comp.state.get(key), path ? path.split(".") : []);
|
|
421
|
-
// Build-time mirror of the runtime's AGG helper, so a `:computed total = sum(...)`
|
|
422
|
-
// has the same initial value the runtime will recompute to.
|
|
423
|
-
const agg = (
|
|
424
|
-
/** @type {string} */ name,
|
|
425
|
-
/** @type {any} */ list,
|
|
426
|
-
/** @type {string} */ field
|
|
427
|
-
) => (FORMATTERS[name] ? FORMATTERS[name](list, field == null ? [] : [field]) : undefined);
|
|
428
|
-
initial = new Function("S", "A", `return (${expr});`)(read, agg);
|
|
429
|
-
} catch {
|
|
430
|
-
console.warn(
|
|
431
|
-
`:computed "${match[2].trim()}" in ${ctx.file} could not be evaluated at build time; falling back to null. Check the expression.`
|
|
432
|
-
);
|
|
433
|
-
initial = null;
|
|
434
|
-
}
|
|
435
|
-
const key = declareState(match[1], initial ?? null, ctx);
|
|
436
|
-
return `<span data-wd-computed data-wd-computed-key="${key}" data-wd-computed-expr="${escapeHtml(expr)}"></span><script type="application/json" data-wd-state>${safeScriptJson({ [key]: initial ?? null })}</script>`;
|
|
437
|
-
}
|
|
438
|
-
|
|
439
|
-
/**
|
|
440
|
-
* @param {string} line
|
|
441
|
-
* @param {string[]} bodyLines
|
|
442
|
-
* @param {Ctx} ctx
|
|
443
|
-
* @param {number} index 0-based line index for `file:line` errors.
|
|
444
|
-
* @returns {string}
|
|
445
|
-
*/
|
|
446
|
-
export function handleForm(line, bodyLines, ctx, index) {
|
|
447
|
-
const rest = line.slice(":form".length).trim();
|
|
448
|
-
const rawAction = rest.match(/action="([^"]+)"/)?.[1];
|
|
449
|
-
const method = rest.match(/method="([^"]+)"/)?.[1] || "post";
|
|
450
|
-
const into = rest.match(/(?:^|\s)into\s+([A-Za-z_$][\w$]*)/)?.[1];
|
|
451
|
-
const leftover = rest
|
|
452
|
-
.replace(/action="[^"]+"/, "")
|
|
453
|
-
.replace(/method="[^"]+"/, "")
|
|
454
|
-
.replace(/(?:^|\s)into\s+[A-Za-z_$][\w$]*/, "")
|
|
455
|
-
.trim();
|
|
456
|
-
if ((!rawAction && !into) || leftover) {
|
|
457
|
-
throw new Error(
|
|
458
|
-
`Malformed :form in ${at(ctx.file, index)}: ${line}. Use ':form into name' (client state), ':form action="/url"' (native post), or both (fetch round-trip into state).`
|
|
459
|
-
);
|
|
460
|
-
}
|
|
461
|
-
const action = rawAction ? validateFetchUrl(rawAction, ctx, ":form action") : undefined;
|
|
462
|
-
const inner = compileBody(bodyLines, ctx).trim();
|
|
463
|
-
if (!into) {
|
|
464
|
-
return `<form action="${escapeHtml(action)}" method="${escapeHtml(method)}">${inner}</form>`;
|
|
465
|
-
}
|
|
466
|
-
const key = declareState(into, null, ctx);
|
|
467
|
-
declareErrorState(key, ctx);
|
|
468
|
-
const actionAttrs = action
|
|
469
|
-
? ` action="${escapeHtml(action)}" method="${escapeHtml(method)}"`
|
|
470
|
-
: "";
|
|
471
|
-
return `<script type="application/json" data-wd-state>${safeScriptJson({ [key]: null })}</script><form data-wd-form="${key}"${actionAttrs}>${inner}</form>`;
|
|
472
|
-
}
|
|
473
|
-
|
|
474
|
-
/**
|
|
475
|
-
* @param {string} line
|
|
476
|
-
* @param {Ctx} ctx
|
|
477
|
-
* @param {number} index 0-based line index for `file:line` errors.
|
|
478
|
-
* @returns {string}
|
|
479
|
-
*/
|
|
480
|
-
export function handleInput(line, ctx, index) {
|
|
481
|
-
const match = line.match(/^:input\s+([A-Za-z_][\w-]*)\s*(.*)$/);
|
|
482
|
-
if (!match) throw new Error(`Malformed :input in ${at(ctx.file, index)}: ${line}`);
|
|
483
|
-
const attrs = [`name="${escapeHtml(match[1])}"`];
|
|
484
|
-
let type = "text";
|
|
485
|
-
let placeholder;
|
|
486
|
-
let hasAria = false;
|
|
487
|
-
const re = /([A-Za-z-]+)=("[^"]*"|\S+)|([A-Za-z-]+)/g;
|
|
488
|
-
for (const token of (match[2] || "").matchAll(re)) {
|
|
489
|
-
if (token[3]) {
|
|
490
|
-
if (!["required", "autofocus", "disabled", "readonly"].includes(token[3])) {
|
|
491
|
-
throw new Error(`Unknown :input flag "${token[3]}" in ${at(ctx.file, index)}`);
|
|
492
|
-
}
|
|
493
|
-
attrs.push(token[3]);
|
|
494
|
-
continue;
|
|
495
|
-
}
|
|
496
|
-
const value = stripQuotes(token[2]);
|
|
497
|
-
if (token[1] === "type") {
|
|
498
|
-
type = value;
|
|
499
|
-
continue;
|
|
500
|
-
}
|
|
501
|
-
if (
|
|
502
|
-
![
|
|
503
|
-
"placeholder",
|
|
504
|
-
"value",
|
|
505
|
-
"min",
|
|
506
|
-
"max",
|
|
507
|
-
"step",
|
|
508
|
-
"pattern",
|
|
509
|
-
"autocomplete",
|
|
510
|
-
"aria-label",
|
|
511
|
-
"aria-describedby"
|
|
512
|
-
].includes(token[1])
|
|
513
|
-
) {
|
|
514
|
-
throw new Error(`Unknown :input attribute "${token[1]}" in ${at(ctx.file, index)}`);
|
|
515
|
-
}
|
|
516
|
-
if (token[1] === "placeholder") placeholder = value;
|
|
517
|
-
if (token[1] === "aria-label" || token[1] === "aria-describedby") hasAria = true;
|
|
518
|
-
attrs.push(`${token[1]}="${escapeHtml(value)}"`);
|
|
519
|
-
}
|
|
520
|
-
// Accessible name: if the author supplied neither aria-label nor aria-describedby,
|
|
521
|
-
// derive a non-visual aria-label from the placeholder, else from the field name.
|
|
522
|
-
// Never overrides an author-supplied aria attribute (zero layout impact).
|
|
523
|
-
if (!hasAria) {
|
|
524
|
-
attrs.push(`aria-label="${escapeHtml(placeholder || humanizeName(match[1]))}"`);
|
|
525
|
-
}
|
|
526
|
-
return `<input type="${escapeHtml(type)}" ${attrs.join(" ")}>`;
|
|
527
|
-
}
|
|
528
|
-
|
|
529
|
-
// :bind <state> [placeholder="…"] [type=…] — a live two-way input bound to a
|
|
530
|
-
// declared :state. Updates state on every keystroke; reflects state back when
|
|
531
|
-
// not focused. The state must be declared first (with :state).
|
|
532
|
-
/**
|
|
533
|
-
* @param {string} line
|
|
534
|
-
* @param {Ctx} ctx
|
|
535
|
-
* @param {number} index 0-based line index for `file:line` errors.
|
|
536
|
-
* @returns {string}
|
|
537
|
-
*/
|
|
538
|
-
export function handleBind(line, ctx, index) {
|
|
539
|
-
const match = line.match(/^:bind\s+([A-Za-z_$][\w$]*)\s*(.*)$/);
|
|
540
|
-
if (!match)
|
|
541
|
-
throw new Error(
|
|
542
|
-
`Malformed :bind in ${at(ctx.file, index)}: ${line}. Use: :bind query placeholder="Search"`
|
|
543
|
-
);
|
|
544
|
-
const key = resolveStateKey(match[1], ctx);
|
|
545
|
-
if (!key) {
|
|
546
|
-
throw new Error(
|
|
547
|
-
`:bind ${match[1]} in ${ctx.file} has no matching state. Declare it first: :state ${match[1]} = ""`
|
|
548
|
-
);
|
|
549
|
-
}
|
|
550
|
-
ctx.comp.assets.runtime = true;
|
|
551
|
-
let type = "text";
|
|
552
|
-
let placeholder;
|
|
553
|
-
let hasAria = false;
|
|
554
|
-
const attrs = [];
|
|
555
|
-
const re = /([A-Za-z-]+)=("[^"]*"|\S+)|([A-Za-z-]+)/g;
|
|
556
|
-
for (const token of (match[2] || "").matchAll(re)) {
|
|
557
|
-
if (token[3]) {
|
|
558
|
-
if (!["required", "autofocus"].includes(token[3]))
|
|
559
|
-
throw new Error(`Unknown :bind flag "${token[3]}" in ${at(ctx.file, index)}`);
|
|
560
|
-
attrs.push(token[3]);
|
|
561
|
-
continue;
|
|
562
|
-
}
|
|
563
|
-
const value = stripQuotes(token[2]);
|
|
564
|
-
if (token[1] === "type") {
|
|
565
|
-
type = value;
|
|
566
|
-
continue;
|
|
567
|
-
}
|
|
568
|
-
if (!["placeholder", "autocomplete", "aria-label", "aria-describedby"].includes(token[1])) {
|
|
569
|
-
throw new Error(`Unknown :bind attribute "${token[1]}" in ${at(ctx.file, index)}`);
|
|
570
|
-
}
|
|
571
|
-
if (token[1] === "placeholder") placeholder = value;
|
|
572
|
-
if (token[1] === "aria-label" || token[1] === "aria-describedby") hasAria = true;
|
|
573
|
-
attrs.push(`${token[1]}="${escapeHtml(value)}"`);
|
|
574
|
-
}
|
|
575
|
-
// Accessible name (see handleInput): default a non-visual aria-label from the
|
|
576
|
-
// placeholder, else a humanized version of the bound state key. The author's own
|
|
577
|
-
// aria-label/aria-describedby always wins.
|
|
578
|
-
if (!hasAria) {
|
|
579
|
-
attrs.push(`aria-label="${escapeHtml(placeholder || humanizeName(match[1]))}"`);
|
|
580
|
-
}
|
|
581
|
-
const initial = ctx.comp.state.get(key);
|
|
582
|
-
const valueAttr =
|
|
583
|
-
initial === undefined || initial === null ? "" : ` value="${escapeHtml(String(initial))}"`;
|
|
584
|
-
return `<input type="${escapeHtml(type)}" data-wd-bind-input="${key}"${valueAttr} ${attrs.join(" ")}>`;
|
|
585
|
-
}
|
|
586
|
-
|
|
587
|
-
/**
|
|
588
|
-
* `:slider name [= value] [min=N] [max=N] [step=N] [aria-label="…"] [persist]` — a
|
|
589
|
-
* range input two-way bound to a NUMBER :state. With `= value` it declares the state
|
|
590
|
-
* inline (seeding it numeric) and may `persist`; without `=` it binds to an already-
|
|
591
|
-
* declared :state. Pure compile-time: it reuses the runtime's input binding (range
|
|
592
|
-
* values coerce to Number), so it ships NO behavior module.
|
|
593
|
-
* @param {string} line
|
|
594
|
-
* @param {Ctx} ctx
|
|
595
|
-
* @param {number} index 0-based line index for `file:line` errors.
|
|
596
|
-
* @returns {string}
|
|
597
|
-
*/
|
|
598
|
-
export function handleSlider(line, ctx, index) {
|
|
599
|
-
const head = line.match(/^:slider\s+([A-Za-z_$][\w$]*)\s*(.*)$/);
|
|
600
|
-
if (!head)
|
|
601
|
-
throw new Error(
|
|
602
|
-
`Malformed :slider in ${at(ctx.file, index)}: ${line}. Use: :slider volume = 50 min=0 max=100 step=1`
|
|
603
|
-
);
|
|
604
|
-
const name = head[1];
|
|
605
|
-
let rest = head[2].trim();
|
|
606
|
-
|
|
607
|
-
/** @type {string | undefined} */
|
|
608
|
-
let initialRaw;
|
|
609
|
-
if (rest.startsWith("=")) {
|
|
610
|
-
const valueMatch = rest.match(/^=\s*(\S+)\s*(.*)$/);
|
|
611
|
-
if (!valueMatch)
|
|
612
|
-
throw new Error(
|
|
613
|
-
`Malformed :slider initial value in ${at(ctx.file, index)}: ${line}. Use: :slider ${name} = 50`
|
|
614
|
-
);
|
|
615
|
-
initialRaw = valueMatch[1];
|
|
616
|
-
rest = valueMatch[2].trim();
|
|
617
|
-
}
|
|
618
|
-
|
|
619
|
-
let min = "0";
|
|
620
|
-
let max = "100";
|
|
621
|
-
let step = "1";
|
|
622
|
-
/** @type {string | undefined} */
|
|
623
|
-
let ariaLabel;
|
|
624
|
-
let persist = false;
|
|
625
|
-
for (const token of rest.matchAll(/([A-Za-z-]+)=("[^"]*"|\S+)|(\bpersist\b)/g)) {
|
|
626
|
-
if (token[3]) {
|
|
627
|
-
persist = true;
|
|
628
|
-
continue;
|
|
629
|
-
}
|
|
630
|
-
const value = stripQuotes(token[2]);
|
|
631
|
-
if (token[1] === "min") min = value;
|
|
632
|
-
else if (token[1] === "max") max = value;
|
|
633
|
-
else if (token[1] === "step") step = value;
|
|
634
|
-
else if (token[1] === "aria-label") ariaLabel = value;
|
|
635
|
-
else throw new Error(`Unknown :slider attribute "${token[1]}" in ${at(ctx.file, index)}`);
|
|
636
|
-
}
|
|
637
|
-
for (const [label, raw] of [
|
|
638
|
-
["min", min],
|
|
639
|
-
["max", max],
|
|
640
|
-
["step", step]
|
|
641
|
-
]) {
|
|
642
|
-
if (!/^-?\d+(?:\.\d+)?$/.test(raw))
|
|
643
|
-
throw new Error(`:slider ${label} must be a number in ${at(ctx.file, index)}: ${raw}`);
|
|
644
|
-
}
|
|
645
|
-
|
|
646
|
-
ctx.comp.assets.runtime = true;
|
|
647
|
-
/** @type {string} */
|
|
648
|
-
let key;
|
|
649
|
-
let seed = "";
|
|
650
|
-
if (initialRaw !== undefined) {
|
|
651
|
-
const value = parseStateValue(initialRaw, at(ctx.file, index));
|
|
652
|
-
if (typeof value !== "number")
|
|
653
|
-
throw new Error(
|
|
654
|
-
`:slider ${name} initial value must be a number in ${at(ctx.file, index)}: ${initialRaw}`
|
|
655
|
-
);
|
|
656
|
-
key = declareState(name, value, ctx);
|
|
657
|
-
const persistAttr = persist ? ` data-wd-persist="${key}"` : "";
|
|
658
|
-
seed = `<script type="application/json" data-wd-state${persistAttr}>${safeScriptJson({ [key]: value })}</script>`;
|
|
659
|
-
} else {
|
|
660
|
-
if (persist)
|
|
661
|
-
throw new Error(
|
|
662
|
-
`:slider persist only applies when declaring state inline (:slider ${name} = 0 … persist) in ${at(ctx.file, index)}`
|
|
663
|
-
);
|
|
664
|
-
const resolved = resolveStateKey(name, ctx);
|
|
665
|
-
if (!resolved)
|
|
666
|
-
throw new Error(
|
|
667
|
-
`:slider ${name} in ${ctx.file} has no matching state. Declare it: :slider ${name} = 0 min=0 max=100`
|
|
668
|
-
);
|
|
669
|
-
key = resolved;
|
|
670
|
-
}
|
|
671
|
-
|
|
672
|
-
const initial = ctx.comp.state.get(key);
|
|
673
|
-
const valueAttr =
|
|
674
|
-
initial === undefined || initial === null ? "" : ` value="${escapeHtml(String(initial))}"`;
|
|
675
|
-
const ariaAttr = ` aria-label="${escapeHtml(ariaLabel || humanizeName(name))}"`;
|
|
676
|
-
return `${seed}<input type="range" data-wd-bind-input="${key}" min="${escapeHtml(min)}" max="${escapeHtml(max)}" step="${escapeHtml(step)}"${valueAttr}${ariaAttr}>`;
|
|
677
|
-
}
|
|
678
|
-
|
|
679
|
-
/**
|
|
680
|
-
* `:carousel [autoplay=N]` … `:endcarousel` — a horizontally scroll-snapping
|
|
681
|
-
* carousel. Contract: each DIRECT child element of the track is one slide, so put
|
|
682
|
-
* each slide in its own block (e.g. a `::: slide` container) and give that block
|
|
683
|
-
* the slide sizing in your skin — loose prose lines would each count as a slide.
|
|
684
|
-
* Registers the `carousel` behavior (prev/next, dot nav, optional autoplay, mouse
|
|
685
|
-
* drag); native CSS scroll-snap + the page skin handle layout and touch swipe.
|
|
686
|
-
* `autoplay` is suppressed under `prefers-reduced-motion`. No runtime required.
|
|
687
|
-
* @param {string} line
|
|
688
|
-
* @param {string[]} bodyLines
|
|
689
|
-
* @param {Ctx} ctx
|
|
690
|
-
* @param {number} index 0-based line index for `file:line` errors.
|
|
691
|
-
* @returns {string}
|
|
692
|
-
*/
|
|
693
|
-
export function handleCarousel(line, bodyLines, ctx, index) {
|
|
694
|
-
const match = line.match(/^:carousel\s*(.*)$/);
|
|
695
|
-
const rest = (match?.[1] || "").trim();
|
|
696
|
-
let autoplayAttr = "";
|
|
697
|
-
if (rest) {
|
|
698
|
-
const auto = rest.match(/^autoplay=(\d+)$/);
|
|
699
|
-
if (!auto)
|
|
700
|
-
throw new Error(
|
|
701
|
-
`Malformed :carousel in ${at(ctx.file, index)}: ${line}. Use: :carousel [autoplay=3000] … :endcarousel`
|
|
702
|
-
);
|
|
703
|
-
autoplayAttr = ` data-wd-carousel-autoplay="${auto[1]}"`;
|
|
704
|
-
}
|
|
705
|
-
ctx.comp.assets.behaviors.add("carousel");
|
|
706
|
-
const inner = compileBody(bodyLines, ctx);
|
|
707
|
-
return `<div class="wd-carousel" data-wd-carousel${autoplayAttr}>\n<div class="wd-carousel-track" data-wd-carousel-track>\n${inner}\n</div>\n</div>`;
|
|
708
|
-
}
|
|
709
|
-
|
|
710
|
-
/**
|
|
711
|
-
* @param {string} line
|
|
712
|
-
* @param {Ctx} ctx
|
|
713
|
-
* @param {number} index 0-based line index for `file:line` errors.
|
|
714
|
-
* @returns {string}
|
|
715
|
-
*/
|
|
716
|
-
export function handleSubmit(line, ctx, index) {
|
|
717
|
-
const match = line.match(/^:submit\s+"([^"]+)"\s*$/);
|
|
718
|
-
if (!match)
|
|
719
|
-
throw new Error(`Malformed :submit in ${at(ctx.file, index)}: ${line}. Use: :submit "Label"`);
|
|
720
|
-
return `<button type="submit">${escapeHtml(match[1])}</button>`;
|
|
721
|
-
}
|
|
722
|
-
|
|
723
|
-
/**
|
|
724
|
-
* `:textarea name [placeholder="…"] [rows=N] [required]` → a `<textarea>`. Like
|
|
725
|
-
* `:input`, it derives a non-visual aria-label from the placeholder (else the
|
|
726
|
-
* humanized name) when the author supplies none. Captured by the runtime's
|
|
727
|
-
* FormData exactly like `:input` — no runtime change needed.
|
|
728
|
-
* @param {string} line
|
|
729
|
-
* @param {Ctx} ctx
|
|
730
|
-
* @param {number} index 0-based line index for `file:line` errors.
|
|
731
|
-
* @returns {string}
|
|
732
|
-
*/
|
|
733
|
-
export function handleTextarea(line, ctx, index) {
|
|
734
|
-
const match = line.match(/^:textarea\s+([A-Za-z_][\w-]*)\s*(.*)$/);
|
|
735
|
-
if (!match)
|
|
736
|
-
throw new Error(
|
|
737
|
-
`Malformed :textarea in ${at(ctx.file, index)}: ${line}. Use: :textarea name [placeholder="…"] [rows=N] [required]`
|
|
738
|
-
);
|
|
739
|
-
const attrs = [`name="${escapeHtml(match[1])}"`];
|
|
740
|
-
let placeholder;
|
|
741
|
-
let hasAria = false;
|
|
742
|
-
const re = /([A-Za-z-]+)=("[^"]*"|\S+)|([A-Za-z-]+)/g;
|
|
743
|
-
for (const token of (match[2] || "").matchAll(re)) {
|
|
744
|
-
if (token[3]) {
|
|
745
|
-
if (!["required", "autofocus", "disabled", "readonly"].includes(token[3])) {
|
|
746
|
-
throw new Error(`Unknown :textarea flag "${token[3]}" in ${at(ctx.file, index)}`);
|
|
747
|
-
}
|
|
748
|
-
attrs.push(token[3]);
|
|
749
|
-
continue;
|
|
750
|
-
}
|
|
751
|
-
const value = stripQuotes(token[2]);
|
|
752
|
-
if (
|
|
753
|
-
![
|
|
754
|
-
"placeholder",
|
|
755
|
-
"rows",
|
|
756
|
-
"cols",
|
|
757
|
-
"minlength",
|
|
758
|
-
"maxlength",
|
|
759
|
-
"autocomplete",
|
|
760
|
-
"aria-label",
|
|
761
|
-
"aria-describedby"
|
|
762
|
-
].includes(token[1])
|
|
763
|
-
) {
|
|
764
|
-
throw new Error(`Unknown :textarea attribute "${token[1]}" in ${at(ctx.file, index)}`);
|
|
765
|
-
}
|
|
766
|
-
if (token[1] === "placeholder") placeholder = value;
|
|
767
|
-
if (token[1] === "aria-label" || token[1] === "aria-describedby") hasAria = true;
|
|
768
|
-
attrs.push(`${token[1]}="${escapeHtml(value)}"`);
|
|
769
|
-
}
|
|
770
|
-
if (!hasAria) {
|
|
771
|
-
attrs.push(`aria-label="${escapeHtml(placeholder || humanizeName(match[1]))}"`);
|
|
772
|
-
}
|
|
773
|
-
return `<textarea ${attrs.join(" ")}></textarea>`;
|
|
774
|
-
}
|
|
775
|
-
|
|
776
|
-
/**
|
|
777
|
-
* `:select name [required]` followed by `- Label` list lines → a `<select>` with
|
|
778
|
-
* one `<option>` per label (value === label). Derives an aria-label from the
|
|
779
|
-
* humanized name when none is given. Captured by FormData like the other fields.
|
|
780
|
-
* @param {string} line
|
|
781
|
-
* @param {string[]} optionLines The following `- Label` lines consumed by dispatch.
|
|
782
|
-
* @param {Ctx} ctx
|
|
783
|
-
* @param {number} index 0-based line index for `file:line` errors.
|
|
784
|
-
* @returns {string}
|
|
785
|
-
*/
|
|
786
|
-
export function handleSelect(line, optionLines, ctx, index) {
|
|
787
|
-
const match = line.match(/^:select\s+([A-Za-z_][\w-]*)\s*(.*)$/);
|
|
788
|
-
if (!match)
|
|
789
|
-
throw new Error(
|
|
790
|
-
`Malformed :select in ${at(ctx.file, index)}: ${line}. Use: :select name [required] then "- Label" lines`
|
|
791
|
-
);
|
|
792
|
-
const attrs = [`name="${escapeHtml(match[1])}"`];
|
|
793
|
-
let hasAria = false;
|
|
794
|
-
const re = /([A-Za-z-]+)=("[^"]*"|\S+)|([A-Za-z-]+)/g;
|
|
795
|
-
for (const token of (match[2] || "").matchAll(re)) {
|
|
796
|
-
if (token[3]) {
|
|
797
|
-
if (!["required", "disabled", "autofocus"].includes(token[3])) {
|
|
798
|
-
throw new Error(`Unknown :select flag "${token[3]}" in ${at(ctx.file, index)}`);
|
|
799
|
-
}
|
|
800
|
-
attrs.push(token[3]);
|
|
801
|
-
continue;
|
|
802
|
-
}
|
|
803
|
-
const value = stripQuotes(token[2]);
|
|
804
|
-
if (!["autocomplete", "aria-label", "aria-describedby"].includes(token[1])) {
|
|
805
|
-
throw new Error(`Unknown :select attribute "${token[1]}" in ${at(ctx.file, index)}`);
|
|
806
|
-
}
|
|
807
|
-
if (token[1] === "aria-label" || token[1] === "aria-describedby") hasAria = true;
|
|
808
|
-
attrs.push(`${token[1]}="${escapeHtml(value)}"`);
|
|
809
|
-
}
|
|
810
|
-
if (!hasAria) {
|
|
811
|
-
attrs.push(`aria-label="${escapeHtml(humanizeName(match[1]))}"`);
|
|
812
|
-
}
|
|
813
|
-
const options = optionLines.map((l) => l.replace(/^\s*-\s+/, "").trim()).filter(Boolean);
|
|
814
|
-
if (!options.length) {
|
|
815
|
-
throw new Error(
|
|
816
|
-
`:select "${match[1]}" in ${at(ctx.file, index)} has no options. Add "- Label" lines beneath it.`
|
|
817
|
-
);
|
|
818
|
-
}
|
|
819
|
-
const opts = options
|
|
820
|
-
.map((o) => `<option value="${escapeHtml(o)}">${escapeHtml(o)}</option>`)
|
|
821
|
-
.join("");
|
|
822
|
-
return `<select ${attrs.join(" ")}>${opts}</select>`;
|
|
823
|
-
}
|
|
824
|
-
|
|
825
|
-
/**
|
|
826
|
-
* `:checkbox name [required]` / `:radio name [required]` followed by `- Label`
|
|
827
|
-
* lines → a labelled group of `<input type=checkbox|radio>`, each sharing `name`
|
|
828
|
-
* with value === label and wrapped in its own `<label>`. The group is a
|
|
829
|
-
* `role="group"` / `role="radiogroup"` container with an aria-label (explicit or
|
|
830
|
-
* humanized from the name). Checkbox groups are marked `data-wd-multi="name"` so
|
|
831
|
-
* the runtime captures every checked value as an array (`FormData.getAll`); radio
|
|
832
|
-
* groups capture a single value like `:input`. Flags: `required` (radio → the
|
|
833
|
-
* group; not emitted on checkboxes, where "at least one" has no native HTML form),
|
|
834
|
-
* `disabled` (whole group), `autofocus` (first control).
|
|
835
|
-
* @param {string} line
|
|
836
|
-
* @param {string[]} optionLines The following `- Label` lines consumed by dispatch.
|
|
837
|
-
* @param {Ctx} ctx
|
|
838
|
-
* @param {"checkbox" | "radio"} kind
|
|
839
|
-
* @param {number} index 0-based line index for `file:line` errors.
|
|
840
|
-
* @returns {string}
|
|
841
|
-
*/
|
|
842
|
-
export function handleChoiceGroup(line, optionLines, ctx, kind, index) {
|
|
843
|
-
const directive = `:${kind}`;
|
|
844
|
-
const match = line.match(
|
|
845
|
-
kind === "checkbox"
|
|
846
|
-
? /^:checkbox\s+([A-Za-z_][\w-]*)\s*(.*)$/
|
|
847
|
-
: /^:radio\s+([A-Za-z_][\w-]*)\s*(.*)$/
|
|
848
|
-
);
|
|
849
|
-
if (!match)
|
|
850
|
-
throw new Error(
|
|
851
|
-
`Malformed ${directive} in ${at(ctx.file, index)}: ${line}. Use: ${directive} name [required] then "- Label" lines`
|
|
852
|
-
);
|
|
853
|
-
const name = match[1];
|
|
854
|
-
const flags = [];
|
|
855
|
-
let ariaLabel = null;
|
|
856
|
-
let ariaDescribedby = null;
|
|
857
|
-
const re = /([A-Za-z-]+)=("[^"]*"|\S+)|([A-Za-z-]+)/g;
|
|
858
|
-
for (const token of (match[2] || "").matchAll(re)) {
|
|
859
|
-
if (token[3]) {
|
|
860
|
-
if (!["required", "disabled", "autofocus"].includes(token[3])) {
|
|
861
|
-
throw new Error(`Unknown ${directive} flag "${token[3]}" in ${at(ctx.file, index)}`);
|
|
862
|
-
}
|
|
863
|
-
flags.push(token[3]);
|
|
864
|
-
continue;
|
|
865
|
-
}
|
|
866
|
-
const value = stripQuotes(token[2]);
|
|
867
|
-
if (token[1] === "aria-label") ariaLabel = value;
|
|
868
|
-
else if (token[1] === "aria-describedby") ariaDescribedby = value;
|
|
869
|
-
else throw new Error(`Unknown ${directive} attribute "${token[1]}" in ${at(ctx.file, index)}`);
|
|
870
|
-
}
|
|
871
|
-
const options = optionLines.map((l) => l.replace(/^\s*-\s+/, "").trim()).filter(Boolean);
|
|
872
|
-
if (!options.length) {
|
|
873
|
-
throw new Error(
|
|
874
|
-
`${directive} "${name}" in ${at(ctx.file, index)} has no options. Add "- Label" lines beneath it.`
|
|
875
|
-
);
|
|
876
|
-
}
|
|
877
|
-
const isCheckbox = kind === "checkbox";
|
|
878
|
-
const groupAttrs = [
|
|
879
|
-
`class="wd-${isCheckbox ? "checkboxes" : "radios"}"`,
|
|
880
|
-
`role="${isCheckbox ? "group" : "radiogroup"}"`
|
|
881
|
-
];
|
|
882
|
-
if (isCheckbox) groupAttrs.push(`data-wd-multi="${escapeHtml(name)}"`);
|
|
883
|
-
groupAttrs.push(`aria-label="${escapeHtml(ariaLabel || humanizeName(name))}"`);
|
|
884
|
-
if (ariaDescribedby) groupAttrs.push(`aria-describedby="${escapeHtml(ariaDescribedby)}"`);
|
|
885
|
-
const disabled = flags.includes("disabled");
|
|
886
|
-
const required = flags.includes("required");
|
|
887
|
-
const autofocus = flags.includes("autofocus");
|
|
888
|
-
const controls = options
|
|
889
|
-
.map((opt, idx) => {
|
|
890
|
-
const a = [`type="${kind}"`, `name="${escapeHtml(name)}"`, `value="${escapeHtml(opt)}"`];
|
|
891
|
-
if (disabled) a.push("disabled");
|
|
892
|
-
if (required && !isCheckbox) a.push("required");
|
|
893
|
-
if (autofocus && idx === 0) a.push("autofocus");
|
|
894
|
-
return `<label><input ${a.join(" ")}> ${escapeHtml(opt)}</label>`;
|
|
895
|
-
})
|
|
896
|
-
.join("");
|
|
897
|
-
return `<div ${groupAttrs.join(" ")}>${controls}</div>`;
|
|
898
|
-
}
|
|
899
|
-
|
|
900
|
-
/**
|
|
901
|
-
* @param {string} line
|
|
902
|
-
* @param {Ctx} ctx
|
|
903
|
-
* @param {number} index 0-based line index for `file:line` errors.
|
|
904
|
-
* @returns {string}
|
|
905
|
-
*/
|
|
906
|
-
export function handleButton(line, ctx, index) {
|
|
907
|
-
const match = line.match(/^:button\s+"([^"]+)"\s*->\s*(.+)$/);
|
|
908
|
-
if (!match) throw new Error(`Malformed :button in ${at(ctx.file, index)}: ${line}`);
|
|
909
|
-
ctx.comp.assets.runtime = true;
|
|
910
|
-
const action = parseAction(match[2], ctx);
|
|
911
|
-
if (Array.isArray(action)) {
|
|
912
|
-
return `<button type="button" data-wd-actions="${escapeHtml(JSON.stringify(action))}">${escapeHtml(match[1])}</button>`;
|
|
913
|
-
}
|
|
914
|
-
const valueAttr =
|
|
915
|
-
action.value === undefined
|
|
916
|
-
? ""
|
|
917
|
-
: ` data-wd-value="${escapeHtml(JSON.stringify(action.value))}"`;
|
|
918
|
-
return `<button type="button" data-wd-action="${action.op}" data-wd-target="${action.target}"${valueAttr}>${escapeHtml(match[1])}</button>`;
|
|
919
|
-
}
|
|
920
|
-
|
|
921
|
-
const EFFECT_USE =
|
|
922
|
-
"Use: :effect watchedState -> action[; action…] (actions use the :button vocabulary).";
|
|
923
|
-
|
|
924
|
-
/**
|
|
925
|
-
* Parse `:effect <watched> -> <actions>` into a zero-output marker the runtime
|
|
926
|
-
* watches: when `<watched>` state changes, it runs `<actions>` (the same `:button`
|
|
927
|
-
* action vocabulary, `;`-chained). For arbitrary side effects beyond `:computed`
|
|
928
|
-
* (derive state) and fetch deps (auto-refetch).
|
|
929
|
-
* @param {string} line
|
|
930
|
-
* @param {Ctx} ctx
|
|
931
|
-
* @param {number} index 0-based line index for `file:line` errors.
|
|
932
|
-
* @returns {string}
|
|
933
|
-
*/
|
|
934
|
-
export function handleEffect(line, ctx, index) {
|
|
935
|
-
const match = line.match(/^:effect\s+([A-Za-z_$][\w$.]*)\s*->\s*(.+)$/);
|
|
936
|
-
if (!match)
|
|
937
|
-
throw new Error(`Malformed :effect in ${at(ctx.file, index)}: ${line}. ${EFFECT_USE}`);
|
|
938
|
-
const segs = validatePath(match[1], ctx, EFFECT_USE);
|
|
939
|
-
const key = resolveStateKey(segs[0], ctx);
|
|
940
|
-
if (!key)
|
|
941
|
-
throw new Error(
|
|
942
|
-
`:effect watches unknown state "${segs[0]}" in ${ctx.file}. Declare it first with :state ${segs[0]} = ...`
|
|
943
|
-
);
|
|
944
|
-
ctx.comp.assets.runtime = true;
|
|
945
|
-
const watch = [key, ...segs.slice(1)].join(".");
|
|
946
|
-
const action = parseAction(match[2], ctx);
|
|
947
|
-
const actions = Array.isArray(action) ? action : [action];
|
|
948
|
-
return `<script type="application/json" data-wd-effect>${safeScriptJson({ watch, actions })}</script>`;
|
|
949
|
-
}
|
|
950
|
-
|
|
951
|
-
const EVERY_USE =
|
|
952
|
-
"Use: :every <duration> -> action[; action…] — duration like 5s, 500ms, or 2m (actions use the :button vocabulary).";
|
|
953
|
-
|
|
954
|
-
/**
|
|
955
|
-
* Parse a duration token into milliseconds: `<int>[ms|s|m]`, defaulting to ms.
|
|
956
|
-
* @param {string} raw
|
|
957
|
-
* @returns {number | null}
|
|
958
|
-
*/
|
|
959
|
-
function parseDuration(raw) {
|
|
960
|
-
const match = raw.trim().match(/^(\d+)(ms|s|m)?$/);
|
|
961
|
-
if (!match) return null;
|
|
962
|
-
const n = Number(match[1]);
|
|
963
|
-
return match[2] === "s" ? n * 1000 : match[2] === "m" ? n * 60000 : n;
|
|
964
|
-
}
|
|
965
|
-
|
|
966
|
-
/**
|
|
967
|
-
* Parse `:every <duration> -> <actions>` into a marker the runtime drives on a
|
|
968
|
-
* timer: every `<duration>` it runs `<actions>` (the same `:button` vocabulary,
|
|
969
|
-
* `;`-chained), and the interval auto-pauses while the tab is hidden. The one time
|
|
970
|
-
* primitive — behind live polling (`:every 5s -> board refetch`), clocks and
|
|
971
|
-
* countdowns (`:every 1s -> seconds++`), and slideshow autoplay (`:every 4s -> slide++`).
|
|
972
|
-
* @param {string} line
|
|
973
|
-
* @param {Ctx} ctx
|
|
974
|
-
* @param {number} index 0-based line index for `file:line` errors.
|
|
975
|
-
* @returns {string}
|
|
976
|
-
*/
|
|
977
|
-
export function handleEvery(line, ctx, index) {
|
|
978
|
-
const match = line.match(/^:every\s+(\S+)\s*->\s*(.+)$/);
|
|
979
|
-
if (!match) throw new Error(`Malformed :every in ${at(ctx.file, index)}: ${line}. ${EVERY_USE}`);
|
|
980
|
-
const ms = parseDuration(match[1]);
|
|
981
|
-
if (ms == null || ms <= 0) {
|
|
982
|
-
throw new Error(
|
|
983
|
-
`:every duration "${match[1]}" is not valid in ${at(ctx.file, index)}. ${EVERY_USE}`
|
|
984
|
-
);
|
|
985
|
-
}
|
|
986
|
-
ctx.comp.assets.runtime = true;
|
|
987
|
-
const action = parseAction(match[2], ctx);
|
|
988
|
-
const actions = Array.isArray(action) ? action : [action];
|
|
989
|
-
return `<script type="application/json" data-wd-every>${safeScriptJson({ ms, actions })}</script>`;
|
|
990
|
-
}
|
|
991
|
-
|
|
992
|
-
/**
|
|
993
|
-
* `:theme [name] [= "auto"]` declares a durable `:store` (default name `theme`,
|
|
994
|
-
* seed `"auto"`) and reflects its value onto `<html data-theme>`. This layers a
|
|
995
|
-
* manual light/dark switch over the OS preference: `"auto"` follows the skin's
|
|
996
|
-
* `tokens dark` media query, while `"light"`/`"dark"` force the matching
|
|
997
|
-
* `:root[data-theme]` scope. Wire a switch with ordinary store actions —
|
|
998
|
-
* `:button "Dark" -> theme = "dark"`. Durable, so the choice survives reloads.
|
|
999
|
-
* @param {string} line
|
|
1000
|
-
* @param {Ctx} ctx
|
|
1001
|
-
* @param {number} index 0-based line index for `file:line` errors.
|
|
1002
|
-
* @returns {string}
|
|
1003
|
-
*/
|
|
1004
|
-
export function handleTheme(line, ctx, index) {
|
|
1005
|
-
const match = line.match(/^:theme(?:\s+([A-Za-z_$][\w$]*))?(?:\s*=\s*(.+))?$/);
|
|
1006
|
-
if (!match) {
|
|
1007
|
-
throw new Error(
|
|
1008
|
-
`Malformed :theme in ${at(ctx.file, index)}: ${line}. Use: :theme (or :theme name = "auto")`
|
|
1009
|
-
);
|
|
1010
|
-
}
|
|
1011
|
-
const name = match[1] || "theme";
|
|
1012
|
-
const seed = match[2] != null ? parseStateValue(match[2], at(ctx.file, index)) : "auto";
|
|
1013
|
-
const storeName = declareStore(name, seed, ctx);
|
|
1014
|
-
return `<script type="application/json" data-wd-store="${storeName}">${safeScriptJson(seed)}</script><span data-wd-theme="${storeName}" hidden></span>`;
|
|
1015
|
-
}
|
|
1016
|
-
|
|
1017
|
-
// ---------------------------------------------------------------------------
|
|
1018
|
-
// Media — :video / :audio / :embed. Compile-time only (zero runtime): hardened
|
|
1019
|
-
// HTML5 media and privacy-friendly, lazy iframe embeds. The extension stays the
|
|
1020
|
-
// feature gate; these emit no `data-wd-*`, so a media-only page ships zero JS.
|
|
1021
|
-
// ---------------------------------------------------------------------------
|
|
1022
|
-
|
|
1023
|
-
/** @type {Record<"video" | "audio", { flags: string[], attrs: string[] }>} */
|
|
1024
|
-
const MEDIA_SPEC = {
|
|
1025
|
-
video: {
|
|
1026
|
-
flags: ["controls", "autoplay", "loop", "muted", "playsinline"],
|
|
1027
|
-
attrs: ["poster", "width", "height", "preload"]
|
|
1028
|
-
},
|
|
1029
|
-
audio: { flags: ["controls", "autoplay", "loop", "muted"], attrs: ["preload"] }
|
|
1030
|
-
};
|
|
1031
|
-
|
|
1032
|
-
/**
|
|
1033
|
-
* `:video /clip.mp4 [poster=…] [width=…] [height=…] [preload=…] [controls] [autoplay]
|
|
1034
|
-
* [loop] [muted] [playsinline]` and `:audio /track.mp3 [preload=…] [controls] …` →
|
|
1035
|
-
* a hardened `<video>`/`<audio>`. Defaults: `preload="metadata"`, and `controls`
|
|
1036
|
-
* unless the clip is an `autoplay` background (autoplay also implies `muted`, which
|
|
1037
|
-
* browsers require). Author flags/attrs always win. URLs use the `:fetch` scheme
|
|
1038
|
-
* guard (relative or http(s)).
|
|
1039
|
-
* @param {string} line
|
|
1040
|
-
* @param {"video" | "audio"} kind
|
|
1041
|
-
* @param {Ctx} ctx
|
|
1042
|
-
* @param {number} index 0-based line index for `file:line` errors.
|
|
1043
|
-
* @returns {string}
|
|
1044
|
-
*/
|
|
1045
|
-
export function handleMedia(line, kind, ctx, index) {
|
|
1046
|
-
const match = line.match(new RegExp(`^:${kind}\\s+(\\S+)\\s*(.*)$`));
|
|
1047
|
-
if (!match) {
|
|
1048
|
-
throw new Error(
|
|
1049
|
-
`Malformed :${kind} in ${at(ctx.file, index)}: ${line}. Use: :${kind} /clip [controls] [autoplay] [loop] [muted]`
|
|
1050
|
-
);
|
|
1051
|
-
}
|
|
1052
|
-
const src = validateFetchUrl(stripQuotes(match[1]), ctx, `:${kind}`);
|
|
1053
|
-
const spec = MEDIA_SPEC[kind];
|
|
1054
|
-
/** @type {Set<string>} */
|
|
1055
|
-
const flags = new Set();
|
|
1056
|
-
/** @type {Map<string, string>} */
|
|
1057
|
-
const attrs = new Map();
|
|
1058
|
-
const re = /([A-Za-z-]+)=("[^"]*"|\S+)|([A-Za-z-]+)/g;
|
|
1059
|
-
for (const token of (match[2] || "").matchAll(re)) {
|
|
1060
|
-
if (token[3]) {
|
|
1061
|
-
if (!spec.flags.includes(token[3]))
|
|
1062
|
-
throw new Error(`Unknown :${kind} flag "${token[3]}" in ${at(ctx.file, index)}`);
|
|
1063
|
-
flags.add(token[3]);
|
|
1064
|
-
} else {
|
|
1065
|
-
if (!spec.attrs.includes(token[1]))
|
|
1066
|
-
throw new Error(`Unknown :${kind} attribute "${token[1]}" in ${at(ctx.file, index)}`);
|
|
1067
|
-
attrs.set(token[1], stripQuotes(token[2]));
|
|
1068
|
-
}
|
|
1069
|
-
}
|
|
1070
|
-
// Sensible defaults; author input always wins.
|
|
1071
|
-
if (flags.has("autoplay")) flags.add("muted"); // browsers block unmuted autoplay
|
|
1072
|
-
if (kind === "audio" || !flags.has("autoplay")) flags.add("controls");
|
|
1073
|
-
const parts = [`src="${escapeHtml(src)}"`];
|
|
1074
|
-
if (attrs.has("poster"))
|
|
1075
|
-
parts.push(
|
|
1076
|
-
`poster="${escapeHtml(validateFetchUrl(/** @type {string} */ (attrs.get("poster")), ctx, `:${kind} poster`))}"`
|
|
1077
|
-
);
|
|
1078
|
-
for (const name of ["width", "height"]) {
|
|
1079
|
-
if (attrs.has(name))
|
|
1080
|
-
parts.push(`${name}="${escapeHtml(/** @type {string} */ (attrs.get(name)))}"`);
|
|
1081
|
-
}
|
|
1082
|
-
parts.push(`preload="${escapeHtml(attrs.get("preload") || "metadata")}"`);
|
|
1083
|
-
for (const flag of spec.flags) if (flags.has(flag)) parts.push(flag);
|
|
1084
|
-
return `<${kind} ${parts.join(" ")}></${kind}>`;
|
|
1085
|
-
}
|
|
1086
|
-
|
|
1087
|
-
/**
|
|
1088
|
-
* `:embed <url> [title="…"]` → a lazy, privacy-friendly responsive iframe. A
|
|
1089
|
-
* YouTube or Vimeo URL is rewritten to its no-cookie / player embed; any other
|
|
1090
|
-
* http(s) URL becomes a generic 16:9 iframe. Inline styles keep the wrapper
|
|
1091
|
-
* self-contained (no framework CSS), so an embed-only page stays zero-JS/zero-CSS.
|
|
1092
|
-
* @param {string} line
|
|
1093
|
-
* @param {Ctx} ctx
|
|
1094
|
-
* @param {number} index 0-based line index for `file:line` errors.
|
|
1095
|
-
* @returns {string}
|
|
1096
|
-
*/
|
|
1097
|
-
export function handleEmbed(line, ctx, index) {
|
|
1098
|
-
const match = line.match(/^:embed\s+(\S+)\s*(.*)$/);
|
|
1099
|
-
if (!match) {
|
|
1100
|
-
throw new Error(
|
|
1101
|
-
`Malformed :embed in ${at(ctx.file, index)}: ${line}. Use: :embed https://youtu.be/ID [title="…"]`
|
|
1102
|
-
);
|
|
1103
|
-
}
|
|
1104
|
-
const raw = stripQuotes(match[1]);
|
|
1105
|
-
let title = "";
|
|
1106
|
-
const re = /([A-Za-z-]+)=("[^"]*"|\S+)/g;
|
|
1107
|
-
for (const token of (match[2] || "").matchAll(re)) {
|
|
1108
|
-
if (token[1] !== "title")
|
|
1109
|
-
throw new Error(`Unknown :embed attribute "${token[1]}" in ${at(ctx.file, index)}`);
|
|
1110
|
-
title = stripQuotes(token[2]);
|
|
1111
|
-
}
|
|
1112
|
-
const { url, label } = resolveEmbed(raw, ctx);
|
|
1113
|
-
const iframe =
|
|
1114
|
-
`<iframe src="${escapeHtml(url)}" title="${escapeHtml(title || label)}" loading="lazy" ` +
|
|
1115
|
-
`referrerpolicy="strict-origin-when-cross-origin" ` +
|
|
1116
|
-
`allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share" ` +
|
|
1117
|
-
`allowfullscreen style="width:100%;height:100%;border:0"></iframe>`;
|
|
1118
|
-
return `<div class="wd-embed" style="aspect-ratio:16/9">${iframe}</div>`;
|
|
1119
|
-
}
|
|
1120
|
-
|
|
1121
|
-
/**
|
|
1122
|
-
* Map a watch/share URL to its embeddable form. YouTube → no-cookie embed, Vimeo →
|
|
1123
|
-
* player; anything else is validated as a generic http(s) iframe source.
|
|
1124
|
-
* @param {string} raw
|
|
1125
|
-
* @param {Ctx} ctx
|
|
1126
|
-
* @returns {{ url: string, label: string }}
|
|
1127
|
-
*/
|
|
1128
|
-
function resolveEmbed(raw, ctx) {
|
|
1129
|
-
const youtube = raw.match(
|
|
1130
|
-
/(?:youtube\.com\/(?:watch\?v=|embed\/|shorts\/)|youtu\.be\/)([\w-]{6,})/
|
|
1131
|
-
);
|
|
1132
|
-
if (youtube)
|
|
1133
|
-
return { url: `https://www.youtube-nocookie.com/embed/${youtube[1]}`, label: "YouTube video" };
|
|
1134
|
-
const vimeo = raw.match(/vimeo\.com\/(?:video\/)?(\d+)/);
|
|
1135
|
-
if (vimeo) return { url: `https://player.vimeo.com/video/${vimeo[1]}`, label: "Vimeo video" };
|
|
1136
|
-
return { url: validateFetchUrl(raw, ctx, ":embed"), label: "Embedded content" };
|
|
1137
|
-
}
|
|
1138
|
-
|
|
1139
|
-
/**
|
|
1140
|
-
* @param {string} line
|
|
1141
|
-
* @param {string[]} truthyLines
|
|
1142
|
-
* @param {string[]} falsyLines
|
|
1143
|
-
* @param {Ctx} ctx
|
|
1144
|
-
* @param {number} index 0-based line index for `file:line` errors.
|
|
1145
|
-
* @returns {string}
|
|
1146
|
-
*/
|
|
1147
|
-
export function handleIf(line, truthyLines, falsyLines, ctx, index) {
|
|
1148
|
-
const condition = line.replace(/^:if\s+/, "").trim();
|
|
1149
|
-
// Fast path: a bare truthy dotted path (`:if open`, `:if item.done`). Keeps the
|
|
1150
|
-
// existing markup/behavior identical. Anything with operators (`>`, `==`, `and`,
|
|
1151
|
-
// `not`, …) falls through to the predicate path below.
|
|
1152
|
-
const match = condition.match(/^([A-Za-z_$][\w$]*(?:\.[A-Za-z_$][\w$]*)*)$/);
|
|
1153
|
-
if (match) {
|
|
1154
|
-
const segs = match[1].split(".");
|
|
1155
|
-
const head = segs[0];
|
|
1156
|
-
|
|
1157
|
-
// Per-row meta vars in :if — only valid inside a loop.
|
|
1158
|
-
if (LOOP_META[head]) {
|
|
1159
|
-
if (!ctx.loopMeta)
|
|
1160
|
-
throw new Error(
|
|
1161
|
-
`":if ${match[1]}" uses the loop meta variable "${head}" outside a @loop in ${ctx.file}. Use it inside a loop body.`
|
|
1162
|
-
);
|
|
1163
|
-
if (ctx.loopItem) {
|
|
1164
|
-
const truthy = compileBody(truthyLines, ctx).trim();
|
|
1165
|
-
const falsy = compileBody(falsyLines, ctx).trim();
|
|
1166
|
-
return `<span data-wd-each-if data-wd-meta="${LOOP_META[head]}"><template data-wd-if-true>${truthy}</template><template data-wd-if-false>${falsy}</template><span data-wd-each-if-out></span></span>`;
|
|
1167
|
-
}
|
|
1168
|
-
// static: the meta boolean is in scope — fall through to the static branch below.
|
|
1169
|
-
}
|
|
1170
|
-
|
|
1171
|
-
const staticValue = lookupVar(ctx.scope, head);
|
|
1172
|
-
if (staticValue.found) {
|
|
1173
|
-
const active = Boolean(getPath(staticValue.value, segs.slice(1)));
|
|
1174
|
-
return compileBody(active ? truthyLines : falsyLines, ctx);
|
|
1175
|
-
}
|
|
1176
|
-
|
|
1177
|
-
if (ctx.loopItem && head === ctx.loopItem) {
|
|
1178
|
-
const truthy = compileBody(truthyLines, ctx).trim();
|
|
1179
|
-
const falsy = compileBody(falsyLines, ctx).trim();
|
|
1180
|
-
const rest = segs.slice(1).join(".");
|
|
1181
|
-
const pathAttr = ` data-wd-path="${escapeHtml(rest)}"`;
|
|
1182
|
-
return `<span data-wd-each-if${pathAttr}><template data-wd-if-true>${truthy}</template><template data-wd-if-false>${falsy}</template><span data-wd-each-if-out></span></span>`;
|
|
1183
|
-
}
|
|
1184
|
-
|
|
1185
|
-
const key = resolveStateKey(head, ctx);
|
|
1186
|
-
if (!key) {
|
|
1187
|
-
throw new Error(
|
|
1188
|
-
`:if ${match[1]} in ${ctx.file} does not match a :state or in-scope value. Declare it first.`
|
|
1189
|
-
);
|
|
1190
|
-
}
|
|
1191
|
-
ctx.comp.assets.runtime = true;
|
|
1192
|
-
const truthy = compileBody(truthyLines, ctx).trim();
|
|
1193
|
-
const falsy = compileBody(falsyLines, ctx).trim();
|
|
1194
|
-
const restPath = segs.slice(1).join(".");
|
|
1195
|
-
const pathAttr = restPath ? ` data-wd-path="${escapeHtml(restPath)}"` : "";
|
|
1196
|
-
const initialTruthy = Boolean(getPath(ctx.comp.state.get(key), segs.slice(1)));
|
|
1197
|
-
const active = initialTruthy ? truthy : falsy;
|
|
1198
|
-
return `<div data-wd-if="${key}"${pathAttr} data-wd-if-active="${initialTruthy}"><template data-wd-true>${truthy}</template><template data-wd-false>${falsy}</template><div data-wd-if-out>${active}</div></div>`;
|
|
1199
|
-
}
|
|
1200
|
-
|
|
1201
|
-
// Predicate path: a comparison / logical condition. Compiles through the same
|
|
1202
|
-
// whitelist as `@loop … where` / `.class when` (with `not`), so it folds at
|
|
1203
|
-
// build when static, drives a per-row each-if when it reads the loop item, and
|
|
1204
|
-
// a global if-region (evaluated each render) when it reads state. No raw eval.
|
|
1205
|
-
if (!condition)
|
|
1206
|
-
throw new Error(
|
|
1207
|
-
`Malformed :if in ${at(ctx.file, index)}: ${line}. Use ":if name" or ":if a <op> b [and|or|not …]".`
|
|
1208
|
-
);
|
|
1209
|
-
const compiled = compileWhen(condition, ctx, '":if"');
|
|
1210
|
-
if (compiled.static) return compileBody(compiled.value ? truthyLines : falsyLines, ctx);
|
|
1211
|
-
ctx.comp.assets.runtime = true;
|
|
1212
|
-
const truthy = compileBody(truthyLines, ctx).trim();
|
|
1213
|
-
const falsy = compileBody(falsyLines, ctx).trim();
|
|
1214
|
-
const exprAttr = ` data-wd-if-expr="${escapeHtml(compiled.body)}"`;
|
|
1215
|
-
if (compiled.item) {
|
|
1216
|
-
return `<span data-wd-each-if${exprAttr}><template data-wd-if-true>${truthy}</template><template data-wd-if-false>${falsy}</template><span data-wd-each-if-out></span></span>`;
|
|
1217
|
-
}
|
|
1218
|
-
const initialTruthy = evalPredicate(compiled.body, undefined, ctx);
|
|
1219
|
-
const active = initialTruthy ? truthy : falsy;
|
|
1220
|
-
return `<div data-wd-if=""${exprAttr} data-wd-if-active="${initialTruthy}"><template data-wd-true>${truthy}</template><template data-wd-false>${falsy}</template><div data-wd-if-out>${active}</div></div>`;
|
|
1221
|
-
}
|
|
1222
|
-
|
|
1223
|
-
/**
|
|
1224
|
-
* Render the documentation-demo directives (`:try`, `:note`, `:sprint`).
|
|
1225
|
-
* @param {string} line
|
|
1226
|
-
* @param {Ctx} ctx
|
|
1227
|
-
* @returns {string}
|
|
1228
|
-
*/
|
|
1229
|
-
export function renderDemoDirective(line, ctx) {
|
|
1230
|
-
const tryMatch = line.match(/^:try\s+"([^"]+)"\s+href="([^"]+)"$/);
|
|
1231
|
-
if (tryMatch) {
|
|
1232
|
-
const href = validateDemoHref(tryMatch[2], ctx);
|
|
1233
|
-
return `<a class="try-card" href="${escapeHtml(href)}"><span>Try</span>${escapeHtml(tryMatch[1])}</a>`;
|
|
1234
|
-
}
|
|
1235
|
-
const note = line.match(/^:note\s+"([^"]+)"$/);
|
|
1236
|
-
if (note) return `<aside class="note">${escapeHtml(note[1])}</aside>`;
|
|
1237
|
-
const sprint = line.match(/^:sprint\s+min=(\d+)\s+max=(\d+)\s+roles="([^"]+)"$/);
|
|
1238
|
-
if (sprint) {
|
|
1239
|
-
const roles = sprint[3]
|
|
1240
|
-
.split(",")
|
|
1241
|
-
.map((role) => role.trim())
|
|
1242
|
-
.filter(Boolean);
|
|
1243
|
-
return `<section class="sprint-board" data-min="${sprint[1]}" data-max="${sprint[2]}">${roles.map((role) => `<article><strong>${escapeHtml(role)}</strong><span>active lane</span></article>`).join("")}</section>`;
|
|
1244
|
-
}
|
|
1245
|
-
return "";
|
|
1246
|
-
}
|
|
1247
|
-
|
|
1248
|
-
/**
|
|
1249
|
-
* Validate demo-card links without adding arbitrary URL passthrough.
|
|
1250
|
-
* @param {string} href
|
|
1251
|
-
* @param {Ctx} ctx
|
|
1252
|
-
* @returns {string}
|
|
1253
|
-
*/
|
|
1254
|
-
function validateDemoHref(href, ctx) {
|
|
1255
|
-
const value = href.trim();
|
|
1256
|
-
// biome-ignore lint/suspicious/noControlCharactersInRegex: intentional — rejects control characters in URLs/hrefs.
|
|
1257
|
-
if (value !== href || /[\u0000-\u001F\u007F]/.test(value)) {
|
|
1258
|
-
throw new Error(
|
|
1259
|
-
`Unsafe :try href "${escapeHtml(href)}" in ${ctx.file}. Use a relative URL starting with /, ./, ../, or #, or an http:, https:, or mailto: URL.`
|
|
1260
|
-
);
|
|
1261
|
-
}
|
|
1262
|
-
if (value.startsWith("//")) {
|
|
1263
|
-
throw new Error(
|
|
1264
|
-
`Unsafe :try href "${escapeHtml(href)}" in ${ctx.file}. Protocol-relative URLs are not allowed; use http: or https: explicitly.`
|
|
1265
|
-
);
|
|
1266
|
-
}
|
|
1267
|
-
if (
|
|
1268
|
-
value.startsWith("/") ||
|
|
1269
|
-
value.startsWith("./") ||
|
|
1270
|
-
value.startsWith("../") ||
|
|
1271
|
-
value.startsWith("#")
|
|
1272
|
-
) {
|
|
1273
|
-
return value;
|
|
1274
|
-
}
|
|
1275
|
-
const scheme = value.match(/^([A-Za-z][A-Za-z0-9+.-]*):/);
|
|
1276
|
-
if (scheme && ["http", "https", "mailto"].includes(scheme[1].toLowerCase())) {
|
|
1277
|
-
return value;
|
|
1278
|
-
}
|
|
1279
|
-
throw new Error(
|
|
1280
|
-
`Unsafe :try href "${escapeHtml(href)}" in ${ctx.file}. Use a relative URL starting with /, ./, ../, or #, or an http:, https:, or mailto: URL.`
|
|
1281
|
-
);
|
|
1282
|
-
}
|
|
1283
|
-
|
|
1284
|
-
// ---------------------------------------------------------------------------
|
|
1285
|
-
// Actions
|
|
1286
|
-
// ---------------------------------------------------------------------------
|
|
1287
|
-
|
|
1288
|
-
const ACTION_USE =
|
|
1289
|
-
'Use: name++, name--, n += k, n -= k, name = v, flag toggle, list append/prepend v, list toggle v, list remove v, x clear, obj merge other, obj delete key, name reset — chain with ";".';
|
|
1290
|
-
|
|
1291
|
-
/**
|
|
1292
|
-
* Parse a `:button` action expression into a validated `{ op, target, value }`,
|
|
1293
|
-
* or — when `;`-separated — an ordered array of them applied in sequence.
|
|
1294
|
-
* @param {string} raw
|
|
1295
|
-
* @param {Ctx} ctx
|
|
1296
|
-
* @returns {Action | Action[]}
|
|
1297
|
-
*/
|
|
1298
|
-
function parseAction(raw, ctx) {
|
|
1299
|
-
const parts = raw
|
|
1300
|
-
.split(";")
|
|
1301
|
-
.map((p) => p.trim())
|
|
1302
|
-
.filter(Boolean);
|
|
1303
|
-
if (parts.length > 1) return parts.map((part) => parseSingleAction(part, raw, ctx));
|
|
1304
|
-
return parseSingleAction(raw.trim(), raw, ctx);
|
|
1305
|
-
}
|
|
1306
|
-
|
|
1307
|
-
/**
|
|
1308
|
-
* Parse one (non-`;`) action expression into a validated `{ op, target, value }`.
|
|
1309
|
-
* Targets may be dotted paths (`cart.count`); a bare name is a 1-segment path.
|
|
1310
|
-
* @param {string} expression
|
|
1311
|
-
* @param {string} raw The full original action source (for error context).
|
|
1312
|
-
* @param {Ctx} ctx
|
|
1313
|
-
* @returns {Action}
|
|
1314
|
-
*/
|
|
1315
|
-
function parseSingleAction(expression, raw, ctx) {
|
|
1316
|
-
const PATH = "[A-Za-z_$][\\w$]*(?:\\.[A-Za-z_$][\\w$]*)*";
|
|
1317
|
-
/** @param {string} name @returns {string} */
|
|
1318
|
-
const resolveTarget = (name) => {
|
|
1319
|
-
const segs = validatePath(name, ctx, ACTION_USE);
|
|
1320
|
-
const key = resolveStateKey(segs[0], ctx);
|
|
1321
|
-
if (!key) {
|
|
1322
|
-
throw new Error(
|
|
1323
|
-
`Button action targets unknown state "${segs[0]}" in ${ctx.file}. Declare it first with :state ${segs[0]} = ...`
|
|
1324
|
-
);
|
|
1325
|
-
}
|
|
1326
|
-
return [key, ...segs.slice(1)].join(".");
|
|
1327
|
-
};
|
|
1328
|
-
|
|
1329
|
-
const increment = expression.match(new RegExp(`^(${PATH})\\+\\+$`));
|
|
1330
|
-
if (increment) return { op: "inc", target: resolveTarget(increment[1]) };
|
|
1331
|
-
const decrement = expression.match(new RegExp(`^(${PATH})--$`));
|
|
1332
|
-
if (decrement) return { op: "dec", target: resolveTarget(decrement[1]) };
|
|
1333
|
-
const sub = expression.match(new RegExp(`^(${PATH})\\s*-=\\s*(.+)$`));
|
|
1334
|
-
if (sub)
|
|
1335
|
-
return { op: "sub", target: resolveTarget(sub[1]), value: parseActionLiteral(sub[2], ctx) };
|
|
1336
|
-
// Per-row: carry the current loop item into another list — `cart += product`.
|
|
1337
|
-
// Only inside a reactive @loop; the runtime resolves the row from the DOM.
|
|
1338
|
-
const add = expression.match(new RegExp(`^(${PATH})\\s*\\+=\\s*(.+)$`));
|
|
1339
|
-
if (add) {
|
|
1340
|
-
const rhs = add[2].trim();
|
|
1341
|
-
if (ctx.loopItem && rhs === ctx.loopItem) {
|
|
1342
|
-
const target = resolveTarget(add[1]);
|
|
1343
|
-
if (!Array.isArray(ctx.comp.state.get(target))) {
|
|
1344
|
-
throw new Error(
|
|
1345
|
-
`Button action "${raw}" needs ${add[1]} to be a :state list (declare it "${add[1]} = []") in ${ctx.file}.`
|
|
1346
|
-
);
|
|
1347
|
-
}
|
|
1348
|
-
return { op: "append-row", target };
|
|
1349
|
-
}
|
|
1350
|
-
const target = resolveTarget(add[1]);
|
|
1351
|
-
const value = parseActionLiteral(rhs, ctx);
|
|
1352
|
-
if (Array.isArray(ctx.comp.state.get(target))) return { op: "append", target, value };
|
|
1353
|
-
if (typeof value === "number") return { op: "add", target, value };
|
|
1354
|
-
throw new Error(
|
|
1355
|
-
`Unsupported button action "${raw}" in ${ctx.file}. += with non-number values requires a list state target — declare it "${add[1]} = []".`
|
|
1356
|
-
);
|
|
1357
|
-
}
|
|
1358
|
-
// `flag toggle` (no operand) → boolean flip; `list toggle v` → member-toggle.
|
|
1359
|
-
const toggle = expression.match(new RegExp(`^(${PATH})\\s+toggle(?:\\s+(.+))?$`));
|
|
1360
|
-
if (toggle) {
|
|
1361
|
-
const target = resolveTarget(toggle[1]);
|
|
1362
|
-
if (toggle[2] === undefined) return { op: "toggle", target };
|
|
1363
|
-
return { op: "member-toggle", target, value: parseActionLiteral(toggle[2], ctx) };
|
|
1364
|
-
}
|
|
1365
|
-
const prepend = expression.match(new RegExp(`^(${PATH})\\s+(?:append|prepend)\\s+(.+)$`));
|
|
1366
|
-
if (prepend) {
|
|
1367
|
-
const op = expression.includes(" prepend ") ? "prepend" : "append";
|
|
1368
|
-
return { op, target: resolveTarget(prepend[1]), value: parseActionLiteral(prepend[2], ctx) };
|
|
1369
|
-
}
|
|
1370
|
-
// Per-row: remove the current row from the list being looped — `todos remove todo`.
|
|
1371
|
-
// Disambiguation: operand IS the loop item name → `remove` (current row);
|
|
1372
|
-
// otherwise the operand is a value → `remove-value`.
|
|
1373
|
-
const remove = expression.match(new RegExp(`^(${PATH})\\s+remove\\s+(.+)$`));
|
|
1374
|
-
if (remove) {
|
|
1375
|
-
const operand = remove[2].trim();
|
|
1376
|
-
if (ctx.loopItem && operand === ctx.loopItem) {
|
|
1377
|
-
// A nested (item-relative) loop has a loop item but no top-level state key —
|
|
1378
|
-
// its source is a path off the outer row, which the runtime's row-remove
|
|
1379
|
-
// can't target. Fail loud with the honest workaround.
|
|
1380
|
-
if (!ctx.loopKey) {
|
|
1381
|
-
throw new Error(
|
|
1382
|
-
`Button action "${raw}" can't delete a row of a nested (item-relative) loop in ${ctx.file}. Per-row "remove" needs a top-level :state/:store list; carry the row into one (cart += ${ctx.loopItem}) and remove it there.`
|
|
1383
|
-
);
|
|
1384
|
-
}
|
|
1385
|
-
if (resolveStateKey(remove[1], ctx) !== ctx.loopKey) {
|
|
1386
|
-
throw new Error(
|
|
1387
|
-
`Button action "${raw}" must target the :state list being looped (@loop ${remove[1]} into ${ctx.loopItem}) in ${ctx.file}.`
|
|
1388
|
-
);
|
|
1389
|
-
}
|
|
1390
|
-
return { op: "remove", target: ctx.loopKey };
|
|
1391
|
-
}
|
|
1392
|
-
return {
|
|
1393
|
-
op: "remove-value",
|
|
1394
|
-
target: resolveTarget(remove[1]),
|
|
1395
|
-
value: parseActionLiteral(operand, ctx)
|
|
1396
|
-
};
|
|
1397
|
-
}
|
|
1398
|
-
const clear = expression.match(new RegExp(`^(${PATH})\\s+clear$`));
|
|
1399
|
-
if (clear) return { op: "clear", target: resolveTarget(clear[1]) };
|
|
1400
|
-
const merge = expression.match(new RegExp(`^(${PATH})\\s+merge\\s+(.+)$`));
|
|
1401
|
-
if (merge)
|
|
1402
|
-
return {
|
|
1403
|
-
op: "merge",
|
|
1404
|
-
target: resolveTarget(merge[1]),
|
|
1405
|
-
value: parseMergeOperand(merge[2], ctx)
|
|
1406
|
-
};
|
|
1407
|
-
const del = expression.match(new RegExp(`^(${PATH})\\s+delete\\s+(.+)$`));
|
|
1408
|
-
if (del)
|
|
1409
|
-
return { op: "delete", target: resolveTarget(del[1]), value: parseActionLiteral(del[2], ctx) };
|
|
1410
|
-
const reset = expression.match(new RegExp(`^(${PATH})\\s+reset$`));
|
|
1411
|
-
if (reset) return { op: "reset", target: resolveTarget(reset[1]) };
|
|
1412
|
-
// `name refetch` re-invokes the matching :fetch node. The target is the fetch
|
|
1413
|
-
// key (bare name); the runtime finds the [data-wd-fetch-key] node and re-runs.
|
|
1414
|
-
const refetch = expression.match(new RegExp(`^(${PATH})\\s+refetch$`));
|
|
1415
|
-
if (refetch) return { op: "refetch", target: resolveTarget(refetch[1]) };
|
|
1416
|
-
const assign = expression.match(new RegExp(`^(${PATH})\\s*=\\s*(.+)$`));
|
|
1417
|
-
if (assign)
|
|
1418
|
-
return {
|
|
1419
|
-
op: "set",
|
|
1420
|
-
target: resolveTarget(assign[1]),
|
|
1421
|
-
value: parseActionLiteral(assign[2], ctx)
|
|
1422
|
-
};
|
|
1423
|
-
throw new Error(`Unsupported button action "${raw}" in ${ctx.file}. ${ACTION_USE}`);
|
|
1424
|
-
}
|
|
1425
|
-
|
|
1426
|
-
/**
|
|
1427
|
-
* `merge` operand: either an inline JSON object literal, or a state/store key
|
|
1428
|
-
* name (emitted as a string the runtime resolves via getPath).
|
|
1429
|
-
* @param {string} raw
|
|
1430
|
-
* @param {Ctx} ctx
|
|
1431
|
-
* @returns {unknown}
|
|
1432
|
-
*/
|
|
1433
|
-
function parseMergeOperand(raw, ctx) {
|
|
1434
|
-
const value = raw.trim();
|
|
1435
|
-
if (/^[A-Za-z_$][\w$]*$/.test(value)) {
|
|
1436
|
-
const key = resolveStateKey(value, ctx);
|
|
1437
|
-
if (!key)
|
|
1438
|
-
throw new Error(
|
|
1439
|
-
`Button action merge targets unknown state "${value}" in ${ctx.file}. Declare it first with :state ${value} = ...`
|
|
1440
|
-
);
|
|
1441
|
-
return key;
|
|
1442
|
-
}
|
|
1443
|
-
const parsed = parseActionLiteral(value, ctx);
|
|
1444
|
-
if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) return parsed;
|
|
1445
|
-
throw new Error(
|
|
1446
|
-
`Unsupported merge operand "${raw}" in ${ctx.file}. Use: obj merge other (a state key or an inline {…} object).`
|
|
1447
|
-
);
|
|
1448
|
-
}
|
|
1449
|
-
|
|
1450
|
-
/**
|
|
1451
|
-
* @param {string} raw
|
|
1452
|
-
* @param {Ctx} ctx
|
|
1453
|
-
* @returns {unknown}
|
|
1454
|
-
*/
|
|
1455
|
-
function parseActionLiteral(raw, ctx) {
|
|
1456
|
-
const value = raw.trim();
|
|
1457
|
-
if (/^["'].*["']$/.test(value)) return stripQuotes(value);
|
|
1458
|
-
if (value === "true") return true;
|
|
1459
|
-
if (value === "false") return false;
|
|
1460
|
-
if (value === "null") return null;
|
|
1461
|
-
if (/^-?\d+(?:\.\d+)?$/.test(value)) return Number(value);
|
|
1462
|
-
if (/^[[{]/.test(value)) {
|
|
1463
|
-
try {
|
|
1464
|
-
return JSON.parse(value);
|
|
1465
|
-
} catch {
|
|
1466
|
-
throw new Error(
|
|
1467
|
-
`Unsupported action literal "${raw}" in ${ctx.file}. Use: a quoted string, number, boolean, null, or valid JSON.`
|
|
1468
|
-
);
|
|
1469
|
-
}
|
|
1470
|
-
}
|
|
1471
|
-
throw new Error(
|
|
1472
|
-
`Unsupported action literal "${raw}" in ${ctx.file}. Use: a quoted string, number, boolean, null, or valid JSON.`
|
|
1473
|
-
);
|
|
1474
|
-
}
|
|
15
|
+
export { handleButton, handleEffect, handleEvery } from "./actions.js";
|
|
16
|
+
export { handleFetch } from "./fetch.js";
|
|
17
|
+
export {
|
|
18
|
+
handleBind,
|
|
19
|
+
handleChoiceGroup,
|
|
20
|
+
handleForm,
|
|
21
|
+
handleInput,
|
|
22
|
+
handleSelect,
|
|
23
|
+
handleSlider,
|
|
24
|
+
handleSubmit,
|
|
25
|
+
handleTextarea
|
|
26
|
+
} from "./forms.js";
|
|
27
|
+
export { handleEmbed, handleMedia } from "./media.js";
|
|
28
|
+
export { handleComputed, handleState, handleStore, handleTheme } from "./state.js";
|
|
29
|
+
export {
|
|
30
|
+
handleCarousel,
|
|
31
|
+
handleContainer,
|
|
32
|
+
handleIf,
|
|
33
|
+
handleInclude,
|
|
34
|
+
renderDemoDirective
|
|
35
|
+
} from "./structure.js";
|