@zvndev/darkmown 2.1.0 → 2.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.
- package/CHANGELOG.md +9 -0
- package/README.md +33 -0
- package/docs/cli.md +8 -0
- package/docs/spec-alignment.md +8 -0
- package/grammar/wd-directives.gbnf +58 -0
- package/package.json +3 -1
- package/site/pages/docs/index.wd +15 -0
- package/src/builder.js +5 -0
- package/src/catalog.js +586 -0
- package/src/cli.js +8 -0
- package/src/compiler/actions.js +45 -12
- package/src/compiler/context.js +41 -1
- package/src/compiler/fetch.js +12 -4
- package/src/compiler/forms.js +78 -24
- package/src/compiler/loops.js +15 -4
- package/src/compiler/media.js +21 -7
- package/src/compiler/predicates.js +25 -6
- package/src/compiler/state.js +67 -16
- package/src/compiler/structure.js +33 -10
- package/src/grammar.js +131 -0
- package/src/index.js +1 -0
package/src/compiler/actions.js
CHANGED
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
// while keeping the message text and `Use:` hints intact.
|
|
10
10
|
// ---------------------------------------------------------------------------
|
|
11
11
|
|
|
12
|
-
import { at } from "./context.js";
|
|
12
|
+
import { at, lineOf, wdError } from "./context.js";
|
|
13
13
|
import {
|
|
14
14
|
escapeHtml,
|
|
15
15
|
resolveStateKey,
|
|
@@ -23,6 +23,10 @@ import {
|
|
|
23
23
|
* @typedef {import("./context.js").Action} Action
|
|
24
24
|
*/
|
|
25
25
|
|
|
26
|
+
// A concrete, compilable :button line for the malformed hint + directive catalog.
|
|
27
|
+
export const BUTTON_EXAMPLE = ':button "Add one" -> count++';
|
|
28
|
+
const BUTTON_USE = `Use: :button "Label" -> action — e.g. ${BUTTON_EXAMPLE}`;
|
|
29
|
+
|
|
26
30
|
/**
|
|
27
31
|
* @param {string} line
|
|
28
32
|
* @param {Ctx} ctx
|
|
@@ -31,7 +35,13 @@ import {
|
|
|
31
35
|
*/
|
|
32
36
|
export function handleButton(line, ctx, index) {
|
|
33
37
|
const match = line.match(/^:button\s+"([^"]+)"\s*->\s*(.+)$/);
|
|
34
|
-
if (!match)
|
|
38
|
+
if (!match)
|
|
39
|
+
throw wdError(`Malformed :button in ${at(ctx, index)}: ${line}. ${BUTTON_USE}`, {
|
|
40
|
+
file: ctx.file,
|
|
41
|
+
line: lineOf(ctx, index),
|
|
42
|
+
hint: BUTTON_USE.slice("Use: ".length),
|
|
43
|
+
example: BUTTON_EXAMPLE
|
|
44
|
+
});
|
|
35
45
|
ctx.comp.assets.runtime = true;
|
|
36
46
|
const action = parseAction(match[2], ctx);
|
|
37
47
|
if (Array.isArray(action)) {
|
|
@@ -44,8 +54,11 @@ export function handleButton(line, ctx, index) {
|
|
|
44
54
|
return `<button type="button" data-wd-action="${action.op}" data-wd-target="${action.target}"${valueAttr}>${escapeHtml(match[1])}</button>`;
|
|
45
55
|
}
|
|
46
56
|
|
|
47
|
-
|
|
48
|
-
|
|
57
|
+
// Concrete, compilable examples for the effect / every hint tails + catalog.
|
|
58
|
+
// (Effects run the :button action vocabulary — mutations, not expressions; use
|
|
59
|
+
// :computed for derived values. `searches++` is the canonical watch-and-act.)
|
|
60
|
+
export const EFFECT_EXAMPLE = ":effect query -> searches++";
|
|
61
|
+
const EFFECT_USE = `Use: :effect watchedState -> action[; action…] (actions use the :button vocabulary) — e.g. ${EFFECT_EXAMPLE}`;
|
|
49
62
|
|
|
50
63
|
/**
|
|
51
64
|
* Parse `:effect <watched> -> <actions>` into a zero-output marker the runtime
|
|
@@ -59,7 +72,13 @@ const EFFECT_USE =
|
|
|
59
72
|
*/
|
|
60
73
|
export function handleEffect(line, ctx, index) {
|
|
61
74
|
const match = line.match(/^:effect\s+([A-Za-z_$][\w$.]*)\s*->\s*(.+)$/);
|
|
62
|
-
if (!match)
|
|
75
|
+
if (!match)
|
|
76
|
+
throw wdError(`Malformed :effect in ${at(ctx, index)}: ${line}. ${EFFECT_USE}`, {
|
|
77
|
+
file: ctx.file,
|
|
78
|
+
line: lineOf(ctx, index),
|
|
79
|
+
hint: EFFECT_USE.slice("Use: ".length),
|
|
80
|
+
example: EFFECT_EXAMPLE
|
|
81
|
+
});
|
|
63
82
|
const segs = validatePath(match[1], ctx, EFFECT_USE);
|
|
64
83
|
const key = resolveStateKey(segs[0], ctx);
|
|
65
84
|
if (!key)
|
|
@@ -73,8 +92,8 @@ export function handleEffect(line, ctx, index) {
|
|
|
73
92
|
return `<script type="application/json" data-wd-effect>${safeScriptJson({ watch, actions })}</script>`;
|
|
74
93
|
}
|
|
75
94
|
|
|
76
|
-
const
|
|
77
|
-
|
|
95
|
+
export const EVERY_EXAMPLE = ":every 5s -> seconds++";
|
|
96
|
+
const EVERY_USE = `Use: :every <duration> -> action[; action…] — duration like 5s, 500ms, or 2m (actions use the :button vocabulary) — e.g. ${EVERY_EXAMPLE}`;
|
|
78
97
|
|
|
79
98
|
/**
|
|
80
99
|
* Parse a duration token into milliseconds: `<int>[ms|s|m]`, defaulting to ms.
|
|
@@ -101,7 +120,13 @@ function parseDuration(raw) {
|
|
|
101
120
|
*/
|
|
102
121
|
export function handleEvery(line, ctx, index) {
|
|
103
122
|
const match = line.match(/^:every\s+(\S+)\s*->\s*(.+)$/);
|
|
104
|
-
if (!match)
|
|
123
|
+
if (!match)
|
|
124
|
+
throw wdError(`Malformed :every in ${at(ctx, index)}: ${line}. ${EVERY_USE}`, {
|
|
125
|
+
file: ctx.file,
|
|
126
|
+
line: lineOf(ctx, index),
|
|
127
|
+
hint: EVERY_USE.slice("Use: ".length),
|
|
128
|
+
example: EVERY_EXAMPLE
|
|
129
|
+
});
|
|
105
130
|
const ms = parseDuration(match[1]);
|
|
106
131
|
if (ms == null || ms <= 0) {
|
|
107
132
|
throw new Error(
|
|
@@ -114,8 +139,11 @@ export function handleEvery(line, ctx, index) {
|
|
|
114
139
|
return `<script type="application/json" data-wd-every>${safeScriptJson({ ms, actions })}</script>`;
|
|
115
140
|
}
|
|
116
141
|
|
|
117
|
-
|
|
118
|
-
|
|
142
|
+
// One concrete action expression, appended so a model copies `count++` rather
|
|
143
|
+
// than the schematic `name++`. The action vocabulary the directive catalog draws
|
|
144
|
+
// from lives here (the parser branches below), mirrored compactly in ACTION_USE.
|
|
145
|
+
export const ACTION_EXAMPLE = "count++";
|
|
146
|
+
export const ACTION_USE = `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 ";" — e.g. ${ACTION_EXAMPLE}`;
|
|
119
147
|
|
|
120
148
|
/**
|
|
121
149
|
* Parse a `:button` action expression into a validated `{ op, target, value }`,
|
|
@@ -271,8 +299,13 @@ function parseMergeOperand(raw, ctx) {
|
|
|
271
299
|
}
|
|
272
300
|
const parsed = parseActionLiteral(value, ctx);
|
|
273
301
|
if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) return parsed;
|
|
274
|
-
throw
|
|
275
|
-
`Unsupported merge operand "${raw}" in ${ctx.file}. Use: obj merge other (a state key or an inline {…} object)
|
|
302
|
+
throw wdError(
|
|
303
|
+
`Unsupported merge operand "${raw}" in ${ctx.file}. Use: obj merge other (a state key or an inline {…} object) — e.g. settings merge patch`,
|
|
304
|
+
{
|
|
305
|
+
file: ctx.file,
|
|
306
|
+
hint: "obj merge other (a state key or an inline {…} object) — e.g. settings merge patch",
|
|
307
|
+
example: "settings merge patch"
|
|
308
|
+
}
|
|
276
309
|
);
|
|
277
310
|
}
|
|
278
311
|
|
package/src/compiler/context.js
CHANGED
|
@@ -152,6 +152,20 @@
|
|
|
152
152
|
* @property {Set<string>} collectionsUsed Collection names this compile looped.
|
|
153
153
|
*/
|
|
154
154
|
|
|
155
|
+
/**
|
|
156
|
+
* Structured, machine-readable companion to a compile error's string message.
|
|
157
|
+
* The string `message` contract is unchanged; `wd` gives an AI edit-loop (or an
|
|
158
|
+
* editor) the same facts without re-parsing the prose: which file/line, the
|
|
159
|
+
* corrective `Use:` template, and one concrete compilable example line.
|
|
160
|
+
* @typedef {object} WdErrorInfo
|
|
161
|
+
* @property {string} file Absolute path of the source file the error is in.
|
|
162
|
+
* @property {number} [line] 1-based file line the error points at, when known
|
|
163
|
+
* (some errors — e.g. a bad `where` operand — are file-scoped only, matching
|
|
164
|
+
* the string message, which likewise omits the line there).
|
|
165
|
+
* @property {string} [hint] The corrective `Use:` template (without the `Use: ` prefix).
|
|
166
|
+
* @property {string} [example] One concrete, compilable directive line.
|
|
167
|
+
*/
|
|
168
|
+
|
|
155
169
|
/**
|
|
156
170
|
* A compiled `@loop … where` predicate.
|
|
157
171
|
* @typedef {object} Predicate
|
|
@@ -257,7 +271,33 @@ export function createScope(parent, vars = {}) {
|
|
|
257
271
|
* @returns {string}
|
|
258
272
|
*/
|
|
259
273
|
export function at(ctx, index) {
|
|
260
|
-
return `${ctx.file}:${
|
|
274
|
+
return `${ctx.file}:${lineOf(ctx, index)}`;
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
/**
|
|
278
|
+
* The 1-based true file line for a body-slice `index` — the numeric half of
|
|
279
|
+
* {@link at}, exposed so {@link wdError} can populate the structured `wd.line`.
|
|
280
|
+
* @param {Pick<Ctx, "bodyLine" | "lineOffset">} ctx
|
|
281
|
+
* @param {number} index 0-based line index into the current body slice.
|
|
282
|
+
* @returns {number}
|
|
283
|
+
*/
|
|
284
|
+
export function lineOf(ctx, index) {
|
|
285
|
+
return index + 1 + (ctx.bodyLine ?? 0) + (ctx.lineOffset ?? 0);
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
/**
|
|
289
|
+
* Build a compile `Error` whose string `message` is unchanged but which also
|
|
290
|
+
* carries a structured {@link WdErrorInfo} on `err.wd` — so an AI edit-loop (or
|
|
291
|
+
* an editor) gets file/line/hint/example without re-parsing the prose. The
|
|
292
|
+
* string contract is the source of truth; `wd` is a strictly additive mirror.
|
|
293
|
+
* @param {string} message The full user-facing error string (unchanged contract).
|
|
294
|
+
* @param {WdErrorInfo} wd The structured mirror.
|
|
295
|
+
* @returns {Error & { wd: WdErrorInfo }}
|
|
296
|
+
*/
|
|
297
|
+
export function wdError(message, wd) {
|
|
298
|
+
const err = /** @type {Error & { wd: WdErrorInfo }} */ (new Error(message));
|
|
299
|
+
err.wd = wd;
|
|
300
|
+
return err;
|
|
261
301
|
}
|
|
262
302
|
|
|
263
303
|
/**
|
package/src/compiler/fetch.js
CHANGED
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
// while keeping the message text and `Use:` hints intact.
|
|
8
8
|
// ---------------------------------------------------------------------------
|
|
9
9
|
|
|
10
|
-
import { at } from "./context.js";
|
|
10
|
+
import { at, lineOf, wdError } from "./context.js";
|
|
11
11
|
import {
|
|
12
12
|
escapeHtml,
|
|
13
13
|
resolveStateKey,
|
|
@@ -21,8 +21,10 @@ import { declareState } from "./state.js";
|
|
|
21
21
|
* @typedef {import("./context.js").Ctx} Ctx
|
|
22
22
|
*/
|
|
23
23
|
|
|
24
|
-
|
|
25
|
-
|
|
24
|
+
// A concrete, compilable :fetch line for the hint tail + the directive catalog.
|
|
25
|
+
export const FETCH_EXAMPLE = ':fetch todos from "/api/todos.json" when=visible';
|
|
26
|
+
|
|
27
|
+
const FETCH_USE = `Use: :fetch name from "url" [method=…] [timeout=ms] [retry=N] [when=visible] [headers=key] [body=key] [refresh=url] — e.g. ${FETCH_EXAMPLE}`;
|
|
26
28
|
|
|
27
29
|
/**
|
|
28
30
|
* Parse a keyword-arg `:fetch` directive into a lifecycle-aware marker.
|
|
@@ -36,7 +38,13 @@ const FETCH_USE =
|
|
|
36
38
|
*/
|
|
37
39
|
export function handleFetch(line, ctx, index) {
|
|
38
40
|
const head = line.match(/^:fetch\s+([A-Za-z_$][\w$]*)\s+from\s+("[^"]+"|\S+)\s*(.*)$/);
|
|
39
|
-
if (!head)
|
|
41
|
+
if (!head)
|
|
42
|
+
throw wdError(`Malformed :fetch in ${at(ctx, index)}: ${line}. ${FETCH_USE}`, {
|
|
43
|
+
file: ctx.file,
|
|
44
|
+
line: lineOf(ctx, index),
|
|
45
|
+
hint: FETCH_USE.slice("Use: ".length),
|
|
46
|
+
example: FETCH_EXAMPLE
|
|
47
|
+
});
|
|
40
48
|
const name = head[1];
|
|
41
49
|
const url = validateFetchUrl(stripQuotes(head[2]), ctx);
|
|
42
50
|
/** @type {Record<string, string>} */
|
package/src/compiler/forms.js
CHANGED
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
// while keeping the message text and `Use:` hints intact.
|
|
10
10
|
// ---------------------------------------------------------------------------
|
|
11
11
|
|
|
12
|
-
import { at, nestedCtx } from "./context.js";
|
|
12
|
+
import { at, lineOf, nestedCtx, wdError } from "./context.js";
|
|
13
13
|
import { validateFetchUrl } from "./fetch.js";
|
|
14
14
|
import {
|
|
15
15
|
escapeHtml,
|
|
@@ -25,6 +25,20 @@ import { declareErrorState, declareState } from "./state.js";
|
|
|
25
25
|
* @typedef {import("./context.js").Ctx} Ctx
|
|
26
26
|
*/
|
|
27
27
|
|
|
28
|
+
// Concrete, compilable one-liners for the form-family hint tails + the catalog.
|
|
29
|
+
// (Field openers like :select/:checkbox expect following `- Label` lines; the
|
|
30
|
+
// example is the opener, which the catalog/grammar pair with option lines.)
|
|
31
|
+
export const FORM_EXAMPLE = ":form into contact";
|
|
32
|
+
export const INPUT_EXAMPLE = ":input email type=email required";
|
|
33
|
+
export const TEXTAREA_EXAMPLE = ':textarea message placeholder="Your message" rows=4';
|
|
34
|
+
export const SELECT_EXAMPLE = ":select topic";
|
|
35
|
+
export const CHECKBOX_EXAMPLE = ":checkbox toppings";
|
|
36
|
+
export const RADIO_EXAMPLE = ":radio size";
|
|
37
|
+
export const SUBMIT_EXAMPLE = ':submit "Send"';
|
|
38
|
+
export const BIND_EXAMPLE = ':bind query placeholder="Search"';
|
|
39
|
+
export const SLIDER_EXAMPLE = ":slider volume = 50 min=0 max=100 step=1";
|
|
40
|
+
const INPUT_USE = `Use: :input name [type=…] [placeholder="…"] [required] — e.g. ${INPUT_EXAMPLE}`;
|
|
41
|
+
|
|
28
42
|
/**
|
|
29
43
|
* @param {string} line
|
|
30
44
|
* @param {string[]} bodyLines
|
|
@@ -43,9 +57,13 @@ export function handleForm(line, bodyLines, ctx, index) {
|
|
|
43
57
|
.replace(/(?:^|\s)into\s+[A-Za-z_$][\w$]*/, "")
|
|
44
58
|
.trim();
|
|
45
59
|
if ((!rawAction && !into) || leftover) {
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
60
|
+
const hint = `':form into name' (client state), ':form action="/url"' (native post), or both (fetch round-trip into state) — e.g. ${FORM_EXAMPLE}`;
|
|
61
|
+
throw wdError(`Malformed :form in ${at(ctx, index)}: ${line}. Use ${hint}`, {
|
|
62
|
+
file: ctx.file,
|
|
63
|
+
line: lineOf(ctx, index),
|
|
64
|
+
hint,
|
|
65
|
+
example: FORM_EXAMPLE
|
|
66
|
+
});
|
|
49
67
|
}
|
|
50
68
|
const action = rawAction ? validateFetchUrl(rawAction, ctx, ":form action") : undefined;
|
|
51
69
|
const inner = ctx.compileBody(bodyLines, nestedCtx(ctx, index + 1)).trim();
|
|
@@ -68,7 +86,13 @@ export function handleForm(line, bodyLines, ctx, index) {
|
|
|
68
86
|
*/
|
|
69
87
|
export function handleInput(line, ctx, index) {
|
|
70
88
|
const match = line.match(/^:input\s+([A-Za-z_][\w-]*)\s*(.*)$/);
|
|
71
|
-
if (!match)
|
|
89
|
+
if (!match)
|
|
90
|
+
throw wdError(`Malformed :input in ${at(ctx, index)}: ${line}. ${INPUT_USE}`, {
|
|
91
|
+
file: ctx.file,
|
|
92
|
+
line: lineOf(ctx, index),
|
|
93
|
+
hint: INPUT_USE.slice("Use: ".length),
|
|
94
|
+
example: INPUT_EXAMPLE
|
|
95
|
+
});
|
|
72
96
|
const attrs = [`name="${escapeHtml(match[1])}"`];
|
|
73
97
|
let type = "text";
|
|
74
98
|
let placeholder;
|
|
@@ -127,9 +151,12 @@ export function handleInput(line, ctx, index) {
|
|
|
127
151
|
export function handleBind(line, ctx, index) {
|
|
128
152
|
const match = line.match(/^:bind\s+([A-Za-z_$][\w$]*)\s*(.*)$/);
|
|
129
153
|
if (!match)
|
|
130
|
-
throw
|
|
131
|
-
|
|
132
|
-
|
|
154
|
+
throw wdError(`Malformed :bind in ${at(ctx, index)}: ${line}. Use: ${BIND_EXAMPLE}`, {
|
|
155
|
+
file: ctx.file,
|
|
156
|
+
line: lineOf(ctx, index),
|
|
157
|
+
hint: BIND_EXAMPLE,
|
|
158
|
+
example: BIND_EXAMPLE
|
|
159
|
+
});
|
|
133
160
|
const key = resolveStateKey(match[1], ctx);
|
|
134
161
|
if (!key) {
|
|
135
162
|
throw new Error(
|
|
@@ -187,9 +214,12 @@ export function handleBind(line, ctx, index) {
|
|
|
187
214
|
export function handleSlider(line, ctx, index) {
|
|
188
215
|
const head = line.match(/^:slider\s+([A-Za-z_$][\w$]*)\s*(.*)$/);
|
|
189
216
|
if (!head)
|
|
190
|
-
throw
|
|
191
|
-
|
|
192
|
-
|
|
217
|
+
throw wdError(`Malformed :slider in ${at(ctx, index)}: ${line}. Use: ${SLIDER_EXAMPLE}`, {
|
|
218
|
+
file: ctx.file,
|
|
219
|
+
line: lineOf(ctx, index),
|
|
220
|
+
hint: SLIDER_EXAMPLE,
|
|
221
|
+
example: SLIDER_EXAMPLE
|
|
222
|
+
});
|
|
193
223
|
const name = head[1];
|
|
194
224
|
let rest = head[2].trim();
|
|
195
225
|
|
|
@@ -274,7 +304,15 @@ export function handleSlider(line, ctx, index) {
|
|
|
274
304
|
export function handleSubmit(line, ctx, index) {
|
|
275
305
|
const match = line.match(/^:submit\s+"([^"]+)"\s*$/);
|
|
276
306
|
if (!match)
|
|
277
|
-
throw
|
|
307
|
+
throw wdError(
|
|
308
|
+
`Malformed :submit in ${at(ctx, index)}: ${line}. Use: :submit "Label" — e.g. ${SUBMIT_EXAMPLE}`,
|
|
309
|
+
{
|
|
310
|
+
file: ctx.file,
|
|
311
|
+
line: lineOf(ctx, index),
|
|
312
|
+
hint: `:submit "Label" — e.g. ${SUBMIT_EXAMPLE}`,
|
|
313
|
+
example: SUBMIT_EXAMPLE
|
|
314
|
+
}
|
|
315
|
+
);
|
|
278
316
|
return `<button type="submit">${escapeHtml(match[1])}</button>`;
|
|
279
317
|
}
|
|
280
318
|
|
|
@@ -290,10 +328,15 @@ export function handleSubmit(line, ctx, index) {
|
|
|
290
328
|
*/
|
|
291
329
|
export function handleTextarea(line, ctx, index) {
|
|
292
330
|
const match = line.match(/^:textarea\s+([A-Za-z_][\w-]*)\s*(.*)$/);
|
|
293
|
-
if (!match)
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
331
|
+
if (!match) {
|
|
332
|
+
const hint = `:textarea name [placeholder="…"] [rows=N] [required] — e.g. ${TEXTAREA_EXAMPLE}`;
|
|
333
|
+
throw wdError(`Malformed :textarea in ${at(ctx, index)}: ${line}. Use: ${hint}`, {
|
|
334
|
+
file: ctx.file,
|
|
335
|
+
line: lineOf(ctx, index),
|
|
336
|
+
hint,
|
|
337
|
+
example: TEXTAREA_EXAMPLE
|
|
338
|
+
});
|
|
339
|
+
}
|
|
297
340
|
const attrs = [`name="${escapeHtml(match[1])}"`];
|
|
298
341
|
let placeholder;
|
|
299
342
|
let hasAria = false;
|
|
@@ -343,10 +386,15 @@ export function handleTextarea(line, ctx, index) {
|
|
|
343
386
|
*/
|
|
344
387
|
export function handleSelect(line, optionLines, ctx, index) {
|
|
345
388
|
const match = line.match(/^:select\s+([A-Za-z_][\w-]*)\s*(.*)$/);
|
|
346
|
-
if (!match)
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
389
|
+
if (!match) {
|
|
390
|
+
const hint = `:select name [required] then "- Label" lines — e.g. ${SELECT_EXAMPLE}`;
|
|
391
|
+
throw wdError(`Malformed :select in ${at(ctx, index)}: ${line}. Use: ${hint}`, {
|
|
392
|
+
file: ctx.file,
|
|
393
|
+
line: lineOf(ctx, index),
|
|
394
|
+
hint,
|
|
395
|
+
example: SELECT_EXAMPLE
|
|
396
|
+
});
|
|
397
|
+
}
|
|
350
398
|
const attrs = [`name="${escapeHtml(match[1])}"`];
|
|
351
399
|
let hasAria = false;
|
|
352
400
|
const re = /([A-Za-z-]+)=("[^"]*"|\S+)|([A-Za-z-]+)/g;
|
|
@@ -404,10 +452,16 @@ export function handleChoiceGroup(line, optionLines, ctx, kind, index) {
|
|
|
404
452
|
? /^:checkbox\s+([A-Za-z_][\w-]*)\s*(.*)$/
|
|
405
453
|
: /^:radio\s+([A-Za-z_][\w-]*)\s*(.*)$/
|
|
406
454
|
);
|
|
407
|
-
if (!match)
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
)
|
|
455
|
+
if (!match) {
|
|
456
|
+
const example = kind === "checkbox" ? CHECKBOX_EXAMPLE : RADIO_EXAMPLE;
|
|
457
|
+
const hint = `${directive} name [required] then "- Label" lines — e.g. ${example}`;
|
|
458
|
+
throw wdError(`Malformed ${directive} in ${at(ctx, index)}: ${line}. Use: ${hint}`, {
|
|
459
|
+
file: ctx.file,
|
|
460
|
+
line: lineOf(ctx, index),
|
|
461
|
+
hint,
|
|
462
|
+
example
|
|
463
|
+
});
|
|
464
|
+
}
|
|
411
465
|
const name = match[1];
|
|
412
466
|
const flags = [];
|
|
413
467
|
let ariaLabel = null;
|
package/src/compiler/loops.js
CHANGED
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
// string fill used for the initial paint (text binds, per-row `:if`, meta).
|
|
7
7
|
// ---------------------------------------------------------------------------
|
|
8
8
|
|
|
9
|
-
import { at, createScope, nestedCtx } from "./context.js";
|
|
9
|
+
import { at, createScope, lineOf, nestedCtx, wdError } from "./context.js";
|
|
10
10
|
import { serializeExpr } from "./expr-ast.js";
|
|
11
11
|
import { applyPipeline, stagesFromAttr } from "./format.js";
|
|
12
12
|
import { resolveInclude } from "./includes.js";
|
|
@@ -28,9 +28,14 @@ import { compilePredicate, evalPredicate } from "./predicates.js";
|
|
|
28
28
|
* @typedef {import("./context.js").LoopOpts} LoopOpts
|
|
29
29
|
*/
|
|
30
30
|
|
|
31
|
+
// One concrete, compilable @loop line, appended to the schematic hint so a small
|
|
32
|
+
// model copies a real line instead of echoing the `[…]` placeholders literally
|
|
33
|
+
// (the on-device finding: bracket-placeholders get pasted into source verbatim).
|
|
34
|
+
// Also the canonical @loop example the directive catalog + grammar draw from.
|
|
35
|
+
export const LOOP_EXAMPLE = "@loop /products.json into p where p.price < 50 sort by p.price asc";
|
|
36
|
+
|
|
31
37
|
// The fixed corrective suggestion shown for any malformed @loop header.
|
|
32
|
-
const LOOP_USAGE =
|
|
33
|
-
"Use: @loop src into item [where …] [sort by …] [reverse] [offset N] [limit N] [paginate N] [sortable]";
|
|
38
|
+
const LOOP_USAGE = `Use: @loop src into item [where …] [sort by …] [reverse] [offset N] [limit N] [paginate N] [sortable] — e.g. ${LOOP_EXAMPLE}`;
|
|
34
39
|
|
|
35
40
|
/**
|
|
36
41
|
* Parse the optional clause tail of a `@loop` header in FIXED order:
|
|
@@ -219,7 +224,13 @@ function numArgAttr(arg) {
|
|
|
219
224
|
*/
|
|
220
225
|
export function handleLoop(line, bodyLines, emptyLines, ctx, index, emptyStart = 0) {
|
|
221
226
|
const match = line.match(/^@loop\s+(.+?)\s+into\s+([A-Za-z_$][\w$]*)(\s+.+?)?\s*$/);
|
|
222
|
-
if (!match)
|
|
227
|
+
if (!match)
|
|
228
|
+
throw wdError(`Malformed @loop in ${at(ctx, index)}: ${line}. ${LOOP_USAGE}`, {
|
|
229
|
+
file: ctx.file,
|
|
230
|
+
line: lineOf(ctx, index),
|
|
231
|
+
hint: LOOP_USAGE.slice("Use: ".length),
|
|
232
|
+
example: LOOP_EXAMPLE
|
|
233
|
+
});
|
|
223
234
|
const source = stripQuotes(match[1].trim());
|
|
224
235
|
const itemName = match[2];
|
|
225
236
|
const clauses = match[3]
|
package/src/compiler/media.js
CHANGED
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
// while keeping the message text and `Use:` hints intact.
|
|
9
9
|
// ---------------------------------------------------------------------------
|
|
10
10
|
|
|
11
|
-
import { at } from "./context.js";
|
|
11
|
+
import { at, lineOf, wdError } from "./context.js";
|
|
12
12
|
import { validateFetchUrl } from "./fetch.js";
|
|
13
13
|
import { escapeHtml, stripQuotes } from "./interpolation.js";
|
|
14
14
|
|
|
@@ -16,6 +16,11 @@ import { escapeHtml, stripQuotes } from "./interpolation.js";
|
|
|
16
16
|
* @typedef {import("./context.js").Ctx} Ctx
|
|
17
17
|
*/
|
|
18
18
|
|
|
19
|
+
// Concrete, compilable media examples for the hint tails + the directive catalog.
|
|
20
|
+
export const VIDEO_EXAMPLE = ":video /demo.mp4 controls";
|
|
21
|
+
export const AUDIO_EXAMPLE = ":audio /theme.mp3 controls";
|
|
22
|
+
export const EMBED_EXAMPLE = ':embed https://youtu.be/dQw4w9WgXcQ title="Demo"';
|
|
23
|
+
|
|
19
24
|
/** @type {Record<"video" | "audio", { flags: string[], attrs: string[] }>} */
|
|
20
25
|
const MEDIA_SPEC = {
|
|
21
26
|
video: {
|
|
@@ -41,9 +46,14 @@ const MEDIA_SPEC = {
|
|
|
41
46
|
export function handleMedia(line, kind, ctx, index) {
|
|
42
47
|
const match = line.match(new RegExp(`^:${kind}\\s+(\\S+)\\s*(.*)$`));
|
|
43
48
|
if (!match) {
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
)
|
|
49
|
+
const example = kind === "video" ? VIDEO_EXAMPLE : AUDIO_EXAMPLE;
|
|
50
|
+
const hint = `:${kind} /clip [controls] [autoplay] [loop] [muted] — e.g. ${example}`;
|
|
51
|
+
throw wdError(`Malformed :${kind} in ${at(ctx, index)}: ${line}. Use: ${hint}`, {
|
|
52
|
+
file: ctx.file,
|
|
53
|
+
line: lineOf(ctx, index),
|
|
54
|
+
hint,
|
|
55
|
+
example
|
|
56
|
+
});
|
|
47
57
|
}
|
|
48
58
|
const src = validateFetchUrl(stripQuotes(match[1]), ctx, `:${kind}`);
|
|
49
59
|
const spec = MEDIA_SPEC[kind];
|
|
@@ -93,9 +103,13 @@ export function handleMedia(line, kind, ctx, index) {
|
|
|
93
103
|
export function handleEmbed(line, ctx, index) {
|
|
94
104
|
const match = line.match(/^:embed\s+(\S+)\s*(.*)$/);
|
|
95
105
|
if (!match) {
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
106
|
+
const hint = `:embed https://youtu.be/ID [title="…"] — e.g. ${EMBED_EXAMPLE}`;
|
|
107
|
+
throw wdError(`Malformed :embed in ${at(ctx, index)}: ${line}. Use: ${hint}`, {
|
|
108
|
+
file: ctx.file,
|
|
109
|
+
line: lineOf(ctx, index),
|
|
110
|
+
hint,
|
|
111
|
+
example: EMBED_EXAMPLE
|
|
112
|
+
});
|
|
99
113
|
}
|
|
100
114
|
const raw = stripQuotes(match[1]);
|
|
101
115
|
let title = "";
|
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
// readers — no identifier survives un-mapped, so no raw user content is eval'd.
|
|
6
6
|
// ---------------------------------------------------------------------------
|
|
7
7
|
|
|
8
|
+
import { wdError } from "./context.js";
|
|
8
9
|
import { astOf, evalAst } from "./expr-ast.js";
|
|
9
10
|
import { getPath, lookupVar, resolveStateKey } from "./interpolation.js";
|
|
10
11
|
|
|
@@ -13,6 +14,20 @@ import { getPath, lookupVar, resolveStateKey } from "./interpolation.js";
|
|
|
13
14
|
* @typedef {import("./context.js").Predicate} Predicate
|
|
14
15
|
*/
|
|
15
16
|
|
|
17
|
+
// The comparison operators a `where` / `.class when` / `:if` / `:computed`
|
|
18
|
+
// condition may use, longest-match first so `>=` wins over `>`. The single
|
|
19
|
+
// source of truth: the operator regex is built from this list, and the directive
|
|
20
|
+
// catalog + GBNF grammar enumerate the same array (drift-guarded in tests).
|
|
21
|
+
export const PREDICATE_OPS = ["contains", "==", "!=", ">=", "<=", ">", "<"];
|
|
22
|
+
|
|
23
|
+
// The logical joiners between conditions (`and`/`or`), plus a leading `not` the
|
|
24
|
+
// `.class when` / `:if` paths accept. Catalogued alongside the operators.
|
|
25
|
+
export const PREDICATE_JOINERS = ["and", "or", "not"];
|
|
26
|
+
|
|
27
|
+
// `left <op> right` where <op> is one of PREDICATE_OPS. Built from the array so
|
|
28
|
+
// adding an operator in one place updates the parser and the catalog together.
|
|
29
|
+
const CONDITION_RE = new RegExp(`^(.+?)\\s+(${PREDICATE_OPS.join("|")})\\s+(.+)$`, "i");
|
|
30
|
+
|
|
16
31
|
// Compile a `where` predicate to a safe JS boolean expression over I()/S()/C().
|
|
17
32
|
// Conditions (operand <op> operand) join with `and`/`or`; operands are loop-item
|
|
18
33
|
// paths, declared :state, numbers, or strings. No identifiers survive un-mapped.
|
|
@@ -46,11 +61,15 @@ export function compilePredicate(raw, itemName, ctx) {
|
|
|
46
61
|
* @returns {{ expr: string, usesState: boolean }}
|
|
47
62
|
*/
|
|
48
63
|
function compileCondition(cond, itemName, ctx) {
|
|
49
|
-
const m = cond.match(
|
|
50
|
-
if (!m)
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
64
|
+
const m = cond.match(CONDITION_RE);
|
|
65
|
+
if (!m) {
|
|
66
|
+
const hint = `${itemName}.field contains state, or ${itemName}.field <op> value — e.g. ${itemName}.price < 50`;
|
|
67
|
+
throw wdError(`Malformed where-condition "${cond}" in ${ctx.file}. Use: ${hint}`, {
|
|
68
|
+
file: ctx.file,
|
|
69
|
+
hint,
|
|
70
|
+
example: `${itemName}.price < 50`
|
|
71
|
+
});
|
|
72
|
+
}
|
|
54
73
|
const left = compileOperand(m[1].trim(), itemName, ctx);
|
|
55
74
|
const right = compileOperand(m[3].trim(), itemName, ctx);
|
|
56
75
|
const usesState = left.usesState || right.usesState;
|
|
@@ -189,7 +208,7 @@ export function compileWhen(raw, ctx, what = '"::: … when"') {
|
|
|
189
208
|
negate = true;
|
|
190
209
|
seg = seg.replace(/^not\s+/i, "").trim();
|
|
191
210
|
}
|
|
192
|
-
const opMatch = seg.match(
|
|
211
|
+
const opMatch = seg.match(CONDITION_RE);
|
|
193
212
|
let expr;
|
|
194
213
|
if (opMatch) {
|
|
195
214
|
const left = compileWhenOperand(opMatch[1].trim(), ctx, what);
|