@zvndev/darkmown 2.1.0 → 2.3.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.
Files changed (74) hide show
  1. package/CHANGELOG.md +22 -0
  2. package/README.md +65 -0
  3. package/docs/cli.md +8 -0
  4. package/docs/spec-alignment.md +18 -0
  5. package/grammar/wd-directives.gbnf +58 -0
  6. package/package.json +17 -10
  7. package/site/pages/docs/index.wd +29 -0
  8. package/site/pages/playground/index.js +47 -46
  9. package/src/builder.js +5 -0
  10. package/src/catalog.js +601 -0
  11. package/src/cli.js +8 -0
  12. package/src/compiler/actions.js +87 -34
  13. package/src/compiler/body.js +36 -14
  14. package/src/compiler/collections.js +16 -10
  15. package/src/compiler/context.js +70 -1
  16. package/src/compiler/fetch.js +51 -19
  17. package/src/compiler/format.js +5 -2
  18. package/src/compiler/forms.js +169 -50
  19. package/src/compiler/frontmatter.js +11 -2
  20. package/src/compiler/fs-reader.js +2 -1
  21. package/src/compiler/image-size.js +340 -0
  22. package/src/compiler/includes.js +8 -4
  23. package/src/compiler/interpolation.js +15 -7
  24. package/src/compiler/loops.js +78 -39
  25. package/src/compiler/markdown.js +4 -3
  26. package/src/compiler/media.js +38 -10
  27. package/src/compiler/page.js +8 -6
  28. package/src/compiler/predicates.js +74 -30
  29. package/src/compiler/reader.js +12 -2
  30. package/src/compiler/state.js +83 -18
  31. package/src/compiler/structure.js +57 -24
  32. package/src/deploy.js +8 -4
  33. package/src/errors.js +1156 -0
  34. package/src/grammar.js +131 -0
  35. package/src/index.js +1 -0
  36. package/src/router.js +7 -4
  37. package/src/scaffold.js +4 -2
  38. package/src/skin.js +17 -6
  39. package/types/api-runner.d.ts +94 -0
  40. package/types/builder.d.ts +141 -10
  41. package/types/catalog.d.ts +97 -0
  42. package/types/compiler/actions.d.ts +41 -0
  43. package/types/compiler/body.d.ts +11 -0
  44. package/types/compiler/collections.d.ts +76 -0
  45. package/types/compiler/context.d.ts +661 -0
  46. package/types/compiler/directives.d.ts +6 -0
  47. package/types/compiler/expr-ast.d.ts +20 -0
  48. package/types/compiler/fetch.d.ts +29 -0
  49. package/types/compiler/format.d.ts +49 -0
  50. package/types/compiler/forms.d.ts +94 -0
  51. package/types/compiler/frontmatter.d.ts +33 -0
  52. package/types/compiler/fs-reader.d.ts +7 -0
  53. package/types/compiler/image-size.d.ts +14 -0
  54. package/types/compiler/includes.d.ts +100 -0
  55. package/types/compiler/index.d.ts +5 -0
  56. package/types/compiler/interpolation.d.ts +115 -0
  57. package/types/compiler/loops.d.ts +36 -0
  58. package/types/compiler/markdown.d.ts +34 -0
  59. package/types/compiler/media.d.ts +32 -0
  60. package/types/compiler/page.d.ts +109 -0
  61. package/types/compiler/predicates.d.ts +50 -0
  62. package/types/compiler/reader.d.ts +56 -0
  63. package/types/compiler/state.d.ts +68 -0
  64. package/types/compiler/structure.d.ts +56 -0
  65. package/types/compiler.d.ts +35 -223
  66. package/types/dev.d.ts +47 -2
  67. package/types/errors.d.ts +100 -0
  68. package/types/feeds.d.ts +150 -0
  69. package/types/headers.d.ts +44 -0
  70. package/types/highlight.d.ts +24 -0
  71. package/types/router.d.ts +42 -3
  72. package/types/scaffold.d.ts +15 -3
  73. package/types/skin.d.ts +28 -1
  74. package/types/statics.d.ts +2 -1
@@ -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:
@@ -56,7 +61,10 @@ function parseLoopClauses(tail, itemName, ctx) {
56
61
  /** @type {number|null} */ let paginate = null;
57
62
  /** @returns {never} */
58
63
  const bad = () => {
59
- throw new Error(`Malformed @loop clause in ${ctx.file}: "${tail.trim()}". ${LOOP_USAGE}`);
64
+ throw wdError(`Malformed @loop clause in ${ctx.file}: "${tail.trim()}". ${LOOP_USAGE}`, {
65
+ code: "WD102",
66
+ file: ctx.file
67
+ });
60
68
  };
61
69
 
62
70
  // `sortable` is a position-independent flag (drag-reorder); peel it wherever it
@@ -147,20 +155,25 @@ function parseSortClause(fieldTok, dirTok, itemName, ctx) {
147
155
  if (fieldState) {
148
156
  const resolved = resolveStateKey(fieldState[1], ctx);
149
157
  if (!resolved)
150
- throw new Error(
151
- `@loop sort by { ${fieldState[1]} } references unknown :state/:store in ${ctx.file}. ${LOOP_USAGE}`
158
+ throw wdError(
159
+ `@loop sort by { ${fieldState[1]} } references unknown :state/:store in ${ctx.file}. ${LOOP_USAGE}`,
160
+ { code: "WD103", file: ctx.file }
152
161
  );
153
162
  key = resolved;
154
163
  keyKind = "key";
155
164
  } else {
156
165
  const segs = fieldTok.split(".");
157
166
  if (segs[0] !== itemName)
158
- throw new Error(
159
- `@loop sort key "${fieldTok}" must start with the loop item "${itemName}" in ${ctx.file}. ${LOOP_USAGE}`
167
+ throw wdError(
168
+ `@loop sort key "${fieldTok}" must start with the loop item "${itemName}" in ${ctx.file}. ${LOOP_USAGE}`,
169
+ { code: "WD104", file: ctx.file }
160
170
  );
161
171
  const rest = segs.slice(1);
162
172
  if (rest.some((seg) => ["constructor", "prototype", "__proto__"].includes(seg)))
163
- throw new Error(`Sort key "${fieldTok}" is not allowed in @loop (${ctx.file})`);
173
+ throw wdError(`Sort key "${fieldTok}" is not allowed in @loop (${ctx.file})`, {
174
+ code: "WD105",
175
+ file: ctx.file
176
+ });
164
177
  key = rest.join(".");
165
178
  keyKind = "literal";
166
179
  }
@@ -172,8 +185,9 @@ function parseSortClause(fieldTok, dirTok, itemName, ctx) {
172
185
  if (dirState) {
173
186
  const resolved = resolveStateKey(dirState[1], ctx);
174
187
  if (!resolved)
175
- throw new Error(
176
- `@loop sort direction { ${dirState[1]} } references unknown :state/:store in ${ctx.file}. ${LOOP_USAGE}`
188
+ throw wdError(
189
+ `@loop sort direction { ${dirState[1]} } references unknown :state/:store in ${ctx.file}. ${LOOP_USAGE}`,
190
+ { code: "WD106", file: ctx.file }
177
191
  );
178
192
  dir = resolved;
179
193
  dirKind = "key";
@@ -196,8 +210,9 @@ function parseNumArg(tok, ctx) {
196
210
  if (/^\d+$/.test(tok)) return { kind: "literal", value: Number(tok) };
197
211
  const key = resolveStateKey(tok, ctx);
198
212
  if (!key)
199
- throw new Error(
200
- `@loop offset/limit "${tok}" in ${ctx.file} is neither a non-negative integer nor a declared :state. ${LOOP_USAGE}`
213
+ throw wdError(
214
+ `@loop offset/limit "${tok}" in ${ctx.file} is neither a non-negative integer nor a declared :state. ${LOOP_USAGE}`,
215
+ { code: "WD107", file: ctx.file }
201
216
  );
202
217
  return { kind: "key", value: key };
203
218
  }
@@ -219,7 +234,14 @@ function numArgAttr(arg) {
219
234
  */
220
235
  export function handleLoop(line, bodyLines, emptyLines, ctx, index, emptyStart = 0) {
221
236
  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}`);
237
+ if (!match)
238
+ throw wdError(`Malformed @loop in ${at(ctx, index)}: ${line}. ${LOOP_USAGE}`, {
239
+ code: "WD101",
240
+ file: ctx.file,
241
+ line: lineOf(ctx, index),
242
+ hint: LOOP_USAGE.slice("Use: ".length),
243
+ example: LOOP_EXAMPLE
244
+ });
223
245
  const source = stripQuotes(match[1].trim());
224
246
  const itemName = match[2];
225
247
  const clauses = match[3]
@@ -247,16 +269,18 @@ export function handleLoop(line, bodyLines, emptyLines, ctx, index, emptyStart =
247
269
  clauses.limit ||
248
270
  clauses.paginate)
249
271
  ) {
250
- throw new Error(
251
- `@loop sortable cannot combine with where/sort/reverse/offset/limit/paginate in ${ctx.file}. ${LOOP_USAGE}`
272
+ throw wdError(
273
+ `@loop sortable cannot combine with where/sort/reverse/offset/limit/paginate in ${ctx.file}. ${LOOP_USAGE}`,
274
+ { code: "WD108", file: ctx.file }
252
275
  );
253
276
  }
254
277
  // `paginate N` OWNS the page slice, so an explicit offset/limit alongside it is
255
278
  // a conflict (which one wins?). Reject it with a clear hint rather than silently
256
279
  // letting one override the other.
257
280
  if (clauses.paginate && (clauses.offset || clauses.limit)) {
258
- throw new Error(
259
- `@loop paginate cannot combine with offset/limit in ${ctx.file} — paginate already slices each page. ${LOOP_USAGE}`
281
+ throw wdError(
282
+ `@loop paginate cannot combine with offset/limit in ${ctx.file} — paginate already slices each page. ${LOOP_USAGE}`,
283
+ { code: "WD109", file: ctx.file }
260
284
  );
261
285
  }
262
286
  /** @type {LoopOpts} */
@@ -287,7 +311,7 @@ export function handleLoop(line, bodyLines, emptyLines, ctx, index, emptyStart =
287
311
  source.startsWith("../") ||
288
312
  source.endsWith(".json")
289
313
  ) {
290
- if (opts.paginate) throw new Error(paginateOnlyCollections(ctx));
314
+ if (opts.paginate) throw paginateOnlyCollections(ctx);
291
315
  const dataFile = resolveInclude(
292
316
  source,
293
317
  ctx.file,
@@ -298,23 +322,28 @@ export function handleLoop(line, bodyLines, emptyLines, ctx, index, emptyStart =
298
322
  );
299
323
  ctx.comp.deps.add(dataFile);
300
324
  const rows = JSON.parse(ctx.comp.reader.readText(dataFile));
301
- if (!Array.isArray(rows)) throw new Error(`@loop data must be a JSON array: ${dataFile}`);
325
+ if (!Array.isArray(rows))
326
+ throw wdError(`@loop data must be a JSON array: ${dataFile}`, {
327
+ code: "WD111",
328
+ file: ctx.file
329
+ });
302
330
  return loopOverData(rows, itemName, bodyLines, bodyCtx, opts);
303
331
  }
304
332
 
305
333
  const resolved = lookupPath(source, ctx);
306
334
  if (resolved.found) {
307
- if (opts.paginate) throw new Error(paginateOnlyCollections(ctx));
335
+ if (opts.paginate) throw paginateOnlyCollections(ctx);
308
336
  // A missing/unset field (e.g. an optional frontmatter key) is an EMPTY
309
337
  // list, not an error — the `@empty` branch renders, matching the runtime.
310
338
  if (resolved.value === null || resolved.value === undefined) {
311
339
  return loopOverData([], itemName, bodyLines, bodyCtx, opts);
312
340
  }
313
341
  if (!Array.isArray(resolved.value)) {
314
- throw new Error(
342
+ throw wdError(
315
343
  `@loop ${source} in ${at(ctx, index)} found an in-scope value, but it is not a list ` +
316
344
  `(got ${typeof resolved.value}). Use: a list value (e.g. \`tags: [a, b]\` in frontmatter) — ` +
317
- `or leave the field out entirely, which loops zero rows and renders the @empty branch.`
345
+ `or leave the field out entirely, which loops zero rows and renders the @empty branch.`,
346
+ { code: "WD112", file: ctx.file, line: lineOf(ctx, index) }
318
347
  );
319
348
  }
320
349
  return loopOverData(resolved.value, itemName, bodyLines, bodyCtx, opts);
@@ -324,11 +353,14 @@ export function handleLoop(line, bodyLines, emptyLines, ctx, index, emptyStart =
324
353
  const segs = source.split(".");
325
354
  if (/^[A-Za-z_$][\w$]*(?:\.[A-Za-z_$][\w$]*)*$/.test(source)) {
326
355
  if (segs.some((seg) => ["constructor", "prototype", "__proto__"].includes(seg))) {
327
- throw new Error(`@loop source "${source}" is not allowed in ${ctx.file}`);
356
+ throw wdError(`@loop source "${source}" is not allowed in ${ctx.file}`, {
357
+ code: "WD113",
358
+ file: ctx.file
359
+ });
328
360
  }
329
361
  const key = resolveStateKey(segs[0], ctx);
330
362
  if (key) {
331
- if (opts.paginate) throw new Error(paginateOnlyCollections(ctx));
363
+ if (opts.paginate) throw paginateOnlyCollections(ctx);
332
364
  const fullKey = segs.length > 1 ? `${key}.${segs.slice(1).join(".")}` : key;
333
365
  return reactiveLoop(fullKey, itemName, bodyLines, bodyCtx, opts);
334
366
  }
@@ -344,16 +376,17 @@ export function handleLoop(line, bodyLines, emptyLines, ctx, index, emptyStart =
344
376
  // the enclosing row item — an ITEM-RELATIVE loop the runtime fills per row.
345
377
  if (ctx.loopItem && segs[0] === ctx.loopItem && segs.length > 1) {
346
378
  if (opts.sortable) {
347
- throw new Error(
348
- `@loop sortable is not supported on a nested (item-relative) loop in ${ctx.file}. ${LOOP_USAGE}`
379
+ throw wdError(
380
+ `@loop sortable is not supported on a nested (item-relative) loop in ${ctx.file}. ${LOOP_USAGE}`,
381
+ { code: "WD114", file: ctx.file }
349
382
  );
350
383
  }
351
- if (opts.paginate) throw new Error(paginateOnlyCollections(ctx));
384
+ if (opts.paginate) throw paginateOnlyCollections(ctx);
352
385
  return itemRelativeLoop(segs.slice(1).join("."), itemName, bodyLines, bodyCtx, opts);
353
386
  }
354
387
  }
355
388
 
356
- throw new Error(unresolvedSourceError(source, ctx));
389
+ throw unresolvedSourceError(source, ctx);
357
390
  }
358
391
 
359
392
  /**
@@ -362,15 +395,16 @@ export function handleLoop(line, bodyLines, emptyLines, ctx, index, emptyStart =
362
395
  * collection reference is immediately actionable.
363
396
  * @param {string} source
364
397
  * @param {Ctx} ctx
365
- * @returns {string}
398
+ * @returns {Error}
366
399
  */
367
400
  function unresolvedSourceError(source, ctx) {
368
401
  const names = [...ctx.comp.collections.keys()].sort();
369
402
  const collectionsHint = names.length ? ` Available collections: ${names.join(", ")}.` : "";
370
- return (
403
+ return wdError(
371
404
  `@loop source "${source}" in ${ctx.file} was not found. Loop over a collection ` +
372
- `(a site/pages/<name>/ subdirectory, by its bare name), a JSON file ` +
373
- `(@loop /data.json into row), an in-scope value, or a :state list.${collectionsHint}`
405
+ `(a site/pages/<name>/ subdirectory, by its bare name), a JSON file ` +
406
+ `(@loop /data.json into row), an in-scope value, or a :state list.${collectionsHint}`,
407
+ { code: "WD115", file: ctx.file }
374
408
  );
375
409
  }
376
410
 
@@ -378,10 +412,13 @@ function unresolvedSourceError(source, ctx) {
378
412
  * The error for `paginate` on a non-collection source: pagination multiplies
379
413
  * static routes, which only makes sense for a build-time collection listing.
380
414
  * @param {Ctx} ctx
381
- * @returns {string}
415
+ * @returns {Error}
382
416
  */
383
417
  function paginateOnlyCollections(ctx) {
384
- return `@loop paginate requires a collection source (a site/pages/<name>/ subdirectory) in ${ctx.file}. ${LOOP_USAGE}`;
418
+ return wdError(
419
+ `@loop paginate requires a collection source (a site/pages/<name>/ subdirectory) in ${ctx.file}. ${LOOP_USAGE}`,
420
+ { code: "WD110", file: ctx.file }
421
+ );
385
422
  }
386
423
 
387
424
  /**
@@ -399,9 +436,10 @@ function paginateOnlyCollections(ctx) {
399
436
  function assertReactiveDepth(ctx) {
400
437
  if ((ctx.reactiveDepth ?? 0) < 2) return;
401
438
  const opener = ctx.loopOpener ?? { at: ctx.file, line: "" };
402
- throw new Error(
439
+ throw wdError(
403
440
  `Reactive @loop nesting is limited to one inner level in ${opener.at}: "${opener.line}". ` +
404
- `Unroll the outer data at build time (JSON/frontmatter source) or move the innermost list into build-time data.`
441
+ `Unroll the outer data at build time (JSON/frontmatter source) or move the innermost list into build-time data.`,
442
+ { code: "WD116", file: ctx.file }
405
443
  );
406
444
  }
407
445
 
@@ -511,8 +549,9 @@ function pipelineRows(rows, where, opts, ctx) {
511
549
  */
512
550
  function loopOverData(rows, itemName, bodyLines, ctx, opts) {
513
551
  if (opts.sortable) {
514
- throw new Error(
515
- `@loop sortable requires a :state or :store list to reorder, not a JSON file or in-scope value, in ${ctx.file}. ${LOOP_USAGE}`
552
+ throw wdError(
553
+ `@loop sortable requires a :state or :store list to reorder, not a JSON file or in-scope value, in ${ctx.file}. ${LOOP_USAGE}`,
554
+ { code: "WD117", file: ctx.file }
516
555
  );
517
556
  }
518
557
  // Reactive when the where reads state OR an offset/limit references a state key.
@@ -7,7 +7,7 @@
7
7
 
8
8
  import MarkdownIt from "markdown-it";
9
9
  import { highlightCode } from "../highlight.js";
10
- import { LOOP_META } from "./context.js";
10
+ import { LOOP_META, wdError } from "./context.js";
11
11
  import { applyPipeline, fmtAttr, validatePipes } from "./format.js";
12
12
  import {
13
13
  escapeHtml,
@@ -266,8 +266,9 @@ function resolveBindingHtml(expr, ctx) {
266
266
  // Per-row meta vars ($index/$number/$first/$last/$count) — only inside a loop.
267
267
  if (LOOP_META[head]) {
268
268
  if (!ctx.loopMeta)
269
- throw new Error(
270
- `"{ ${expr} }" uses the loop meta variable "${head}" outside a @loop in ${ctx.file}. Move it into a loop body, or rename your value.`
269
+ throw wdError(
270
+ `"{ ${expr} }" uses the loop meta variable "${head}" outside a @loop in ${ctx.file}. Move it into a loop body, or rename your value.`,
271
+ { code: "WD005", file: ctx.file }
271
272
  );
272
273
  if (ctx.loopItem) return `<span data-wd-each-meta="${LOOP_META[head]}"${fmt}></span>`; // reactive: filled per row
273
274
  // static: the value is in scope (injected by staticUnroll); fall through.
@@ -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,15 @@ 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
+ code: "WD701",
53
+ file: ctx.file,
54
+ line: lineOf(ctx, index),
55
+ hint,
56
+ example
57
+ });
47
58
  }
48
59
  const src = validateFetchUrl(stripQuotes(match[1]), ctx, `:${kind}`);
49
60
  const spec = MEDIA_SPEC[kind];
@@ -55,11 +66,19 @@ export function handleMedia(line, kind, ctx, index) {
55
66
  for (const token of (match[2] || "").matchAll(re)) {
56
67
  if (token[3]) {
57
68
  if (!spec.flags.includes(token[3]))
58
- throw new Error(`Unknown :${kind} flag "${token[3]}" in ${at(ctx, index)}`);
69
+ throw wdError(`Unknown :${kind} flag "${token[3]}" in ${at(ctx, index)}`, {
70
+ code: "WD702",
71
+ file: ctx.file,
72
+ line: lineOf(ctx, index)
73
+ });
59
74
  flags.add(token[3]);
60
75
  } else {
61
76
  if (!spec.attrs.includes(token[1]))
62
- throw new Error(`Unknown :${kind} attribute "${token[1]}" in ${at(ctx, index)}`);
77
+ throw wdError(`Unknown :${kind} attribute "${token[1]}" in ${at(ctx, index)}`, {
78
+ code: "WD703",
79
+ file: ctx.file,
80
+ line: lineOf(ctx, index)
81
+ });
63
82
  attrs.set(token[1], stripQuotes(token[2]));
64
83
  }
65
84
  }
@@ -93,16 +112,25 @@ export function handleMedia(line, kind, ctx, index) {
93
112
  export function handleEmbed(line, ctx, index) {
94
113
  const match = line.match(/^:embed\s+(\S+)\s*(.*)$/);
95
114
  if (!match) {
96
- throw new Error(
97
- `Malformed :embed in ${at(ctx, index)}: ${line}. Use: :embed https://youtu.be/ID [title="…"]`
98
- );
115
+ const hint = `:embed https://youtu.be/ID [title="…"] — e.g. ${EMBED_EXAMPLE}`;
116
+ throw wdError(`Malformed :embed in ${at(ctx, index)}: ${line}. Use: ${hint}`, {
117
+ code: "WD704",
118
+ file: ctx.file,
119
+ line: lineOf(ctx, index),
120
+ hint,
121
+ example: EMBED_EXAMPLE
122
+ });
99
123
  }
100
124
  const raw = stripQuotes(match[1]);
101
125
  let title = "";
102
126
  const re = /([A-Za-z-]+)=("[^"]*"|\S+)/g;
103
127
  for (const token of (match[2] || "").matchAll(re)) {
104
128
  if (token[1] !== "title")
105
- throw new Error(`Unknown :embed attribute "${token[1]}" in ${at(ctx, index)}`);
129
+ throw wdError(`Unknown :embed attribute "${token[1]}" in ${at(ctx, index)}`, {
130
+ code: "WD705",
131
+ file: ctx.file,
132
+ line: lineOf(ctx, index)
133
+ });
106
134
  title = stripQuotes(token[2]);
107
135
  }
108
136
  const { url, label } = resolveEmbed(raw, ctx);
@@ -6,12 +6,12 @@
6
6
  // ---------------------------------------------------------------------------
7
7
 
8
8
  import path from "node:path";
9
- import { imageSize } from "image-size";
10
9
  import { createPaths } from "../config.js";
11
10
  import { htmlHasHighlight } from "../highlight.js";
12
11
  import { compileBody } from "./body.js";
13
- import { createCompilation, createScope } from "./context.js";
12
+ import { createCompilation, createScope, wdError } from "./context.js";
14
13
  import { parseFrontmatter, warnLikelyFrontmatter } from "./frontmatter.js";
14
+ import { imageSize } from "./image-size.js";
15
15
  import {
16
16
  collectColocatedAssets,
17
17
  scanMarkdownHints,
@@ -279,8 +279,9 @@ function measureImage(src, paths, reader) {
279
279
  return null; // page-relative: the source directory is lost after assembly
280
280
  }
281
281
  try {
282
- const { width, height } = imageSize(reader.readBinary(filePath));
283
- if (typeof width === "number" && typeof height === "number") return { width, height };
282
+ // `imageSize` itself never throws (an unsupported or malformed image is just
283
+ // null); the try/catch is for the read, which does throw on a missing file.
284
+ return imageSize(reader.readBinary(filePath));
284
285
  } catch {
285
286
  /* missing or unreadable — degrade to no dimensions */
286
287
  }
@@ -363,8 +364,9 @@ export function compileFile(
363
364
  ) {
364
365
  const real = comp.reader.realpath(file);
365
366
  if (stack.includes(real)) {
366
- throw new Error(
367
- `Include cycle detected: ${[...stack, real].map((p) => path.basename(p)).join(" -> ")}`
367
+ throw wdError(
368
+ `[WD612] Include cycle detected: ${[...stack, real].map((p) => path.basename(p)).join(" -> ")}`,
369
+ { code: "WD612", file }
368
370
  );
369
371
  }
370
372
 
@@ -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,16 @@ 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
+ code: "WD220",
69
+ file: ctx.file,
70
+ hint,
71
+ example: `${itemName}.price < 50`
72
+ });
73
+ }
54
74
  const left = compileOperand(m[1].trim(), itemName, ctx);
55
75
  const right = compileOperand(m[3].trim(), itemName, ctx);
56
76
  const usesState = left.usesState || right.usesState;
@@ -71,20 +91,25 @@ function compileOperand(tok, itemName, ctx) {
71
91
  if (/^-?\d+(?:\.\d+)?$/.test(tok)) return { code: tok, usesState: false };
72
92
  if (["true", "false", "null"].includes(tok)) return { code: tok, usesState: false };
73
93
  if (!/^[A-Za-z_$][\w$]*(?:\.[A-Za-z_$][\w$]*)*$/.test(tok)) {
74
- throw new Error(
75
- `Unsupported operand "${tok}" in @loop where (${ctx.file}). Use ${itemName}.field, a :state name, a number, or a "string".`
94
+ throw wdError(
95
+ `Unsupported operand "${tok}" in @loop where (${ctx.file}). Use ${itemName}.field, a :state name, a number, or a "string".`,
96
+ { code: "WD221", file: ctx.file }
76
97
  );
77
98
  }
78
99
  const segs = tok.split(".");
79
100
  if (segs.some((seg) => ["constructor", "prototype", "__proto__"].includes(seg))) {
80
- throw new Error(`Path "${tok}" is not allowed in @loop where (${ctx.file})`);
101
+ throw wdError(`Path "${tok}" is not allowed in @loop where (${ctx.file})`, {
102
+ code: "WD222",
103
+ file: ctx.file
104
+ });
81
105
  }
82
106
  if (segs[0] === itemName)
83
107
  return { code: `I(${JSON.stringify(segs.slice(1).join("."))})`, usesState: false };
84
108
  const key = resolveStateKey(segs[0], ctx);
85
109
  if (!key) {
86
- throw new Error(
87
- `@loop where references unknown name "${segs[0]}" in ${ctx.file}. Use the loop item (${itemName}.field) or a declared :state.`
110
+ throw wdError(
111
+ `@loop where references unknown name "${segs[0]}" in ${ctx.file}. Use the loop item (${itemName}.field) or a declared :state.`,
112
+ { code: "WD223", file: ctx.file }
88
113
  );
89
114
  }
90
115
  const rest = segs.slice(1).join(".");
@@ -130,13 +155,17 @@ function compileWhenOperand(tok, ctx, what) {
130
155
  if (/^-?\d+(?:\.\d+)?$/.test(tok)) return { code: tok, state: false, item: false };
131
156
  if (["true", "false", "null"].includes(tok)) return { code: tok, state: false, item: false };
132
157
  if (!/^[A-Za-z_$][\w$]*(?:\.[A-Za-z_$][\w$]*)*$/.test(tok)) {
133
- throw new Error(
134
- `Unsupported operand "${tok}" in ${what} (${ctx.file}). Use item.field, a :state name, a number, or a "string".`
158
+ throw wdError(
159
+ `Unsupported operand "${tok}" in ${what} (${ctx.file}). Use item.field, a :state name, a number, or a "string".`,
160
+ { code: "WD224", file: ctx.file }
135
161
  );
136
162
  }
137
163
  const segs = tok.split(".");
138
164
  if (segs.some((seg) => ["constructor", "prototype", "__proto__"].includes(seg))) {
139
- throw new Error(`Path "${tok}" is not allowed in ${what} (${ctx.file})`);
165
+ throw wdError(`Path "${tok}" is not allowed in ${what} (${ctx.file})`, {
166
+ code: "WD225",
167
+ file: ctx.file
168
+ });
140
169
  }
141
170
  const scoped = lookupVar(ctx.scope, segs[0]);
142
171
  if (scoped.found)
@@ -149,8 +178,9 @@ function compileWhenOperand(tok, ctx, what) {
149
178
  return { code: `I(${JSON.stringify(segs.slice(1).join("."))})`, state: false, item: true };
150
179
  const key = resolveStateKey(segs[0], ctx);
151
180
  if (!key)
152
- throw new Error(
153
- `${what} references unknown name "${segs[0]}" in ${ctx.file}. Use a loop item field, a declared :state, a number, or a "string".`
181
+ throw wdError(
182
+ `${what} references unknown name "${segs[0]}" in ${ctx.file}. Use a loop item field, a declared :state, a number, or a "string".`,
183
+ { code: "WD226", file: ctx.file }
154
184
  );
155
185
  const rest = segs.slice(1).join(".");
156
186
  return {
@@ -189,7 +219,7 @@ export function compileWhen(raw, ctx, what = '"::: … when"') {
189
219
  negate = true;
190
220
  seg = seg.replace(/^not\s+/i, "").trim();
191
221
  }
192
- const opMatch = seg.match(/^(.+?)\s+(contains|==|!=|>=|<=|>|<)\s+(.+)$/i);
222
+ const opMatch = seg.match(CONDITION_RE);
193
223
  let expr;
194
224
  if (opMatch) {
195
225
  const left = compileWhenOperand(opMatch[1].trim(), ctx, what);
@@ -243,12 +273,16 @@ export function compileComputedExpr(raw, ctx) {
243
273
  ["constructor", "prototype", "__proto__"].includes(s)
244
274
  )
245
275
  ) {
246
- throw new Error(`Path "${listPath}" is not allowed in :computed ${name}() (${ctx.file})`);
276
+ throw wdError(`Path "${listPath}" is not allowed in :computed ${name}() (${ctx.file})`, {
277
+ code: "WD227",
278
+ file: ctx.file
279
+ });
247
280
  }
248
281
  const key = resolveStateKey(segs[0], ctx);
249
282
  if (!key) {
250
- throw new Error(
251
- `:computed ${name}() references unknown state "${segs[0]}" in ${ctx.file}. Declare it with :state or :fetch first.`
283
+ throw wdError(
284
+ `:computed ${name}() references unknown state "${segs[0]}" in ${ctx.file}. Declare it with :state or :fetch first.`,
285
+ { code: "WD228", file: ctx.file }
252
286
  );
253
287
  }
254
288
  const rest = segs.slice(1).join(".");
@@ -260,15 +294,22 @@ export function compileComputedExpr(raw, ctx) {
260
294
  }
261
295
  );
262
296
  if (/["'\\`]/.test(expr)) {
263
- throw new Error(`Unsupported string syntax in :computed expression "${raw}" (${ctx.file})`);
297
+ throw wdError(`Unsupported string syntax in :computed expression "${raw}" (${ctx.file})`, {
298
+ code: "WD229",
299
+ file: ctx.file
300
+ });
264
301
  }
265
302
  if (!/^[\w$.\s+\-*/%()<>=!&|]*$/.test(expr)) {
266
- throw new Error(
267
- `Unsupported syntax in :computed expression "${raw}" (${ctx.file}). Allowed: state names, numbers, strings, + - * / % ( ), comparisons, && || !.`
303
+ throw wdError(
304
+ `Unsupported syntax in :computed expression "${raw}" (${ctx.file}). Allowed: state names, numbers, strings, + - * / % ( ), comparisons, && || !.`,
305
+ { code: "WD230", file: ctx.file }
268
306
  );
269
307
  }
270
308
  if (/(^|[^=!<>])=(?!=)/.test(expr)) {
271
- throw new Error(`Assignment is not allowed in :computed expressions ("${raw}" in ${ctx.file})`);
309
+ throw wdError(`Assignment is not allowed in :computed expressions ("${raw}" in ${ctx.file})`, {
310
+ code: "WD231",
311
+ file: ctx.file
312
+ });
272
313
  }
273
314
  // Reject function-call syntax: a `(` that directly follows an identifier, a
274
315
  // string literal, or a closing `)` (e.g. `x()`, `x.valueOf()`, `(a)(b)`). Only
@@ -278,8 +319,9 @@ export function compileComputedExpr(raw, ctx) {
278
319
  // SECURITY.md guarantee that function calls are compile errors. Runs BEFORE the
279
320
  // identifier→S() mapping so it never trips on the emitted helper calls.
280
321
  if (/[\w$)]\s*\(/.test(expr)) {
281
- throw new Error(
282
- `Function calls are not allowed in :computed expressions ("${raw}" in ${ctx.file})`
322
+ throw wdError(
323
+ `Function calls are not allowed in :computed expressions ("${raw}" in ${ctx.file})`,
324
+ { code: "WD232", file: ctx.file }
283
325
  );
284
326
  }
285
327
  expr = expr.replace(/[A-Za-z_$][\w$]*(?:\.[A-Za-z_$][\w$]*)*/g, (ref) => {
@@ -287,14 +329,16 @@ export function compileComputedExpr(raw, ctx) {
287
329
  if (["true", "false", "null"].includes(ref)) return ref;
288
330
  const segs = ref.split(".");
289
331
  if (segs.some((seg) => ["constructor", "prototype", "__proto__"].includes(seg))) {
290
- throw new Error(
291
- `Path segment "${ref}" is not allowed in :computed expressions (${ctx.file})`
292
- );
332
+ throw wdError(`Path segment "${ref}" is not allowed in :computed expressions (${ctx.file})`, {
333
+ code: "WD233",
334
+ file: ctx.file
335
+ });
293
336
  }
294
337
  const key = resolveStateKey(segs[0], ctx);
295
338
  if (!key) {
296
- throw new Error(
297
- `:computed references unknown state "${segs[0]}" in ${ctx.file}. Declare it with :state or :fetch first.`
339
+ throw wdError(
340
+ `:computed references unknown state "${segs[0]}" in ${ctx.file}. Declare it with :state or :fetch first.`,
341
+ { code: "WD234", file: ctx.file }
298
342
  );
299
343
  }
300
344
  const rest = segs.slice(1).join(".");