@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.
@@ -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) throw new Error(`Malformed :button in ${at(ctx, index)}: ${line}`);
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
- const EFFECT_USE =
48
- "Use: :effect watchedState -> action[; action…] (actions use the :button vocabulary).";
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) throw new Error(`Malformed :effect in ${at(ctx, index)}: ${line}. ${EFFECT_USE}`);
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 EVERY_USE =
77
- "Use: :every <duration> -> action[; action…] — duration like 5s, 500ms, or 2m (actions use the :button vocabulary).";
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) throw new Error(`Malformed :every in ${at(ctx, index)}: ${line}. ${EVERY_USE}`);
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
- const ACTION_USE =
118
- '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 ";".';
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 new Error(
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
 
@@ -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}:${index + 1 + (ctx.bodyLine ?? 0) + (ctx.lineOffset ?? 0)}`;
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
  /**
@@ -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
- const FETCH_USE =
25
- 'Use: :fetch name from "url" [method=…] [timeout=ms] [retry=N] [when=visible] [headers=key] [body=key] [refresh=url]';
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) throw new Error(`Malformed :fetch in ${at(ctx, index)}: ${line}. ${FETCH_USE}`);
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>} */
@@ -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
- throw new Error(
47
- `Malformed :form in ${at(ctx, index)}: ${line}. Use ':form into name' (client state), ':form action="/url"' (native post), or both (fetch round-trip into state).`
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) throw new Error(`Malformed :input in ${at(ctx, index)}: ${line}`);
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 new Error(
131
- `Malformed :bind in ${at(ctx, index)}: ${line}. Use: :bind query placeholder="Search"`
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 new Error(
191
- `Malformed :slider in ${at(ctx, index)}: ${line}. Use: :slider volume = 50 min=0 max=100 step=1`
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 new Error(`Malformed :submit in ${at(ctx, index)}: ${line}. Use: :submit "Label"`);
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
- throw new Error(
295
- `Malformed :textarea in ${at(ctx, index)}: ${line}. Use: :textarea name [placeholder="…"] [rows=N] [required]`
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
- throw new Error(
348
- `Malformed :select in ${at(ctx, index)}: ${line}. Use: :select name [required] then "- Label" lines`
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
- throw new Error(
409
- `Malformed ${directive} in ${at(ctx, index)}: ${line}. Use: ${directive} name [required] then "- Label" lines`
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;
@@ -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) throw new Error(`Malformed @loop in ${at(ctx, index)}: ${line}. ${LOOP_USAGE}`);
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]
@@ -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
- throw new Error(
45
- `Malformed :${kind} in ${at(ctx, index)}: ${line}. Use: :${kind} /clip [controls] [autoplay] [loop] [muted]`
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
- throw new Error(
97
- `Malformed :embed in ${at(ctx, index)}: ${line}. Use: :embed https://youtu.be/ID [title="…"]`
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(/^(.+?)\s+(contains|==|!=|>=|<=|>|<)\s+(.+)$/i);
50
- if (!m)
51
- throw new Error(
52
- `Malformed where-condition "${cond}" in ${ctx.file}. Use: ${itemName}.field contains state, or ${itemName}.field <op> value.`
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(/^(.+?)\s+(contains|==|!=|>=|<=|>|<)\s+(.+)$/i);
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);