@zvndev/darkmown 2.2.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 (68) hide show
  1. package/CHANGELOG.md +13 -0
  2. package/README.md +34 -2
  3. package/docs/spec-alignment.md +10 -0
  4. package/package.json +16 -11
  5. package/site/pages/docs/index.wd +14 -0
  6. package/site/pages/playground/index.js +47 -46
  7. package/src/catalog.js +16 -1
  8. package/src/compiler/actions.js +42 -22
  9. package/src/compiler/body.js +36 -14
  10. package/src/compiler/collections.js +16 -10
  11. package/src/compiler/context.js +39 -10
  12. package/src/compiler/fetch.js +39 -15
  13. package/src/compiler/format.js +5 -2
  14. package/src/compiler/forms.js +91 -26
  15. package/src/compiler/frontmatter.js +11 -2
  16. package/src/compiler/fs-reader.js +2 -1
  17. package/src/compiler/image-size.js +340 -0
  18. package/src/compiler/includes.js +8 -4
  19. package/src/compiler/interpolation.js +15 -7
  20. package/src/compiler/loops.js +63 -35
  21. package/src/compiler/markdown.js +4 -3
  22. package/src/compiler/media.js +17 -3
  23. package/src/compiler/page.js +8 -6
  24. package/src/compiler/predicates.js +49 -24
  25. package/src/compiler/reader.js +12 -2
  26. package/src/compiler/state.js +16 -2
  27. package/src/compiler/structure.js +24 -14
  28. package/src/deploy.js +8 -4
  29. package/src/errors.js +1156 -0
  30. package/src/router.js +7 -4
  31. package/src/scaffold.js +4 -2
  32. package/src/skin.js +17 -6
  33. package/types/api-runner.d.ts +94 -0
  34. package/types/builder.d.ts +141 -10
  35. package/types/catalog.d.ts +97 -0
  36. package/types/compiler/actions.d.ts +41 -0
  37. package/types/compiler/body.d.ts +11 -0
  38. package/types/compiler/collections.d.ts +76 -0
  39. package/types/compiler/context.d.ts +661 -0
  40. package/types/compiler/directives.d.ts +6 -0
  41. package/types/compiler/expr-ast.d.ts +20 -0
  42. package/types/compiler/fetch.d.ts +29 -0
  43. package/types/compiler/format.d.ts +49 -0
  44. package/types/compiler/forms.d.ts +94 -0
  45. package/types/compiler/frontmatter.d.ts +33 -0
  46. package/types/compiler/fs-reader.d.ts +7 -0
  47. package/types/compiler/image-size.d.ts +14 -0
  48. package/types/compiler/includes.d.ts +100 -0
  49. package/types/compiler/index.d.ts +5 -0
  50. package/types/compiler/interpolation.d.ts +115 -0
  51. package/types/compiler/loops.d.ts +36 -0
  52. package/types/compiler/markdown.d.ts +34 -0
  53. package/types/compiler/media.d.ts +32 -0
  54. package/types/compiler/page.d.ts +109 -0
  55. package/types/compiler/predicates.d.ts +50 -0
  56. package/types/compiler/reader.d.ts +56 -0
  57. package/types/compiler/state.d.ts +68 -0
  58. package/types/compiler/structure.d.ts +56 -0
  59. package/types/compiler.d.ts +35 -223
  60. package/types/dev.d.ts +47 -2
  61. package/types/errors.d.ts +100 -0
  62. package/types/feeds.d.ts +150 -0
  63. package/types/headers.d.ts +44 -0
  64. package/types/highlight.d.ts +24 -0
  65. package/types/router.d.ts +42 -3
  66. package/types/scaffold.d.ts +15 -3
  67. package/types/skin.d.ts +28 -1
  68. package/types/statics.d.ts +2 -1
package/src/catalog.js CHANGED
@@ -13,6 +13,10 @@
13
13
  // hints use, and the operator/formatter lists are read from the compiler's own
14
14
  // tables (FORMATTER_NAMES, PREDICATE_OPS, LOOP_META) — so the catalog cannot
15
15
  // drift from what actually compiles (drift-guarded in tests/catalog.test.js).
16
+ //
17
+ // The catalog also carries the stable compile-error codes (`errors`, from
18
+ // src/errors.js): an AI edit-loop that hits `[WD201] …` can look the code up in
19
+ // the same artifact it learned the syntax from, without parsing the prose.
16
20
  // ---------------------------------------------------------------------------
17
21
 
18
22
  import fs from "node:fs";
@@ -41,6 +45,7 @@ import {
41
45
  IF_EXAMPLE,
42
46
  INCLUDE_EXAMPLE
43
47
  } from "./compiler/structure.js";
48
+ import { ERROR_AREAS, errorCatalog } from "./errors.js";
44
49
 
45
50
  /**
46
51
  * @typedef {"static" | "reactive" | "either"} Reactivity Whether the directive
@@ -77,6 +82,10 @@ import {
77
82
  * @property {CatalogEntry[]} formatPipes The `{ value | pipe }` formatter whitelist.
78
83
  * @property {CatalogEntry[]} predicateOps Comparison operators for `where`/`:if`/`when`.
79
84
  * @property {string[]} predicateJoiners Logical joiners (`and`/`or`/`not`).
85
+ * @property {import("./errors.js").ErrorArea[]} errorAreas The `WDxxx` code blocks.
86
+ * @property {import("./errors.js").ErrorEntry[]} errors Every stable compile-error
87
+ * code, with its cause and fix. A thrown error's message starts with its code
88
+ * (`[WD201] …`) and mirrors it on `err.wd.code`.
80
89
  */
81
90
 
82
91
  /** The directive surface. `example` is drawn from the compiler's own constants. */
@@ -512,7 +521,9 @@ export function directiveCatalog() {
512
521
  description: OP_DESC[op],
513
522
  example: op === "contains" ? 'p.tags contains "sale"' : `p.price ${op} 50`
514
523
  })),
515
- predicateJoiners: [...PREDICATE_JOINERS]
524
+ predicateJoiners: [...PREDICATE_JOINERS],
525
+ errorAreas: ERROR_AREAS.map((area) => ({ ...area })),
526
+ errors: errorCatalog()
516
527
  };
517
528
  }
518
529
 
@@ -581,6 +592,10 @@ export function llmsText() {
581
592
  out.push("- One loop syntax (`@loop … into … @endloop`), one interpolation syntax (`{ name }`).");
582
593
  out.push("- Actions and predicates are a fixed whitelist — no JavaScript, no `x.filter(...)`.");
583
594
  out.push("- Close every block: `@endloop`, `:endif`, `:endform`, `:endcarousel`, `:::`.");
595
+ out.push(
596
+ "- Compile errors start with a stable code (`[WD201] …`) and always name the file," +
597
+ " the line, and a fix. Read the message; it tells you exactly what to write."
598
+ );
584
599
  out.push("");
585
600
  return out.join("\n");
586
601
  }
@@ -37,6 +37,7 @@ export function handleButton(line, ctx, index) {
37
37
  const match = line.match(/^:button\s+"([^"]+)"\s*->\s*(.+)$/);
38
38
  if (!match)
39
39
  throw wdError(`Malformed :button in ${at(ctx, index)}: ${line}. ${BUTTON_USE}`, {
40
+ code: "WD301",
40
41
  file: ctx.file,
41
42
  line: lineOf(ctx, index),
42
43
  hint: BUTTON_USE.slice("Use: ".length),
@@ -74,6 +75,7 @@ export function handleEffect(line, ctx, index) {
74
75
  const match = line.match(/^:effect\s+([A-Za-z_$][\w$.]*)\s*->\s*(.+)$/);
75
76
  if (!match)
76
77
  throw wdError(`Malformed :effect in ${at(ctx, index)}: ${line}. ${EFFECT_USE}`, {
78
+ code: "WD302",
77
79
  file: ctx.file,
78
80
  line: lineOf(ctx, index),
79
81
  hint: EFFECT_USE.slice("Use: ".length),
@@ -82,8 +84,9 @@ export function handleEffect(line, ctx, index) {
82
84
  const segs = validatePath(match[1], ctx, EFFECT_USE);
83
85
  const key = resolveStateKey(segs[0], ctx);
84
86
  if (!key)
85
- throw new Error(
86
- `:effect watches unknown state "${segs[0]}" in ${ctx.file}. Declare it first with :state ${segs[0]} = ...`
87
+ throw wdError(
88
+ `:effect watches unknown state "${segs[0]}" in ${ctx.file}. Declare it first with :state ${segs[0]} = ...`,
89
+ { code: "WD303", file: ctx.file }
87
90
  );
88
91
  ctx.comp.assets.runtime = true;
89
92
  const watch = [key, ...segs.slice(1)].join(".");
@@ -122,6 +125,7 @@ export function handleEvery(line, ctx, index) {
122
125
  const match = line.match(/^:every\s+(\S+)\s*->\s*(.+)$/);
123
126
  if (!match)
124
127
  throw wdError(`Malformed :every in ${at(ctx, index)}: ${line}. ${EVERY_USE}`, {
128
+ code: "WD304",
125
129
  file: ctx.file,
126
130
  line: lineOf(ctx, index),
127
131
  hint: EVERY_USE.slice("Use: ".length),
@@ -129,9 +133,11 @@ export function handleEvery(line, ctx, index) {
129
133
  });
130
134
  const ms = parseDuration(match[1]);
131
135
  if (ms == null || ms <= 0) {
132
- throw new Error(
133
- `:every duration "${match[1]}" is not valid in ${at(ctx, index)}. ${EVERY_USE}`
134
- );
136
+ throw wdError(`:every duration "${match[1]}" is not valid in ${at(ctx, index)}. ${EVERY_USE}`, {
137
+ code: "WD305",
138
+ file: ctx.file,
139
+ line: lineOf(ctx, index)
140
+ });
135
141
  }
136
142
  ctx.comp.assets.runtime = true;
137
143
  const action = parseAction(match[2], ctx);
@@ -176,8 +182,9 @@ function parseSingleAction(expression, raw, ctx) {
176
182
  const segs = validatePath(name, ctx, ACTION_USE);
177
183
  const key = resolveStateKey(segs[0], ctx);
178
184
  if (!key) {
179
- throw new Error(
180
- `Button action targets unknown state "${segs[0]}" in ${ctx.file}. Declare it first with :state ${segs[0]} = ...`
185
+ throw wdError(
186
+ `Button action targets unknown state "${segs[0]}" in ${ctx.file}. Declare it first with :state ${segs[0]} = ...`,
187
+ { code: "WD306", file: ctx.file }
181
188
  );
182
189
  }
183
190
  return [key, ...segs.slice(1)].join(".");
@@ -198,8 +205,9 @@ function parseSingleAction(expression, raw, ctx) {
198
205
  if (ctx.loopItem && rhs === ctx.loopItem) {
199
206
  const target = resolveTarget(add[1]);
200
207
  if (!Array.isArray(ctx.comp.state.get(target))) {
201
- throw new Error(
202
- `Button action "${raw}" needs ${add[1]} to be a :state list (declare it "${add[1]} = []") in ${ctx.file}.`
208
+ throw wdError(
209
+ `Button action "${raw}" needs ${add[1]} to be a :state list (declare it "${add[1]} = []") in ${ctx.file}.`,
210
+ { code: "WD307", file: ctx.file }
203
211
  );
204
212
  }
205
213
  return { op: "append-row", target };
@@ -208,8 +216,9 @@ function parseSingleAction(expression, raw, ctx) {
208
216
  const value = parseActionLiteral(rhs, ctx);
209
217
  if (Array.isArray(ctx.comp.state.get(target))) return { op: "append", target, value };
210
218
  if (typeof value === "number") return { op: "add", target, value };
211
- throw new Error(
212
- `Unsupported button action "${raw}" in ${ctx.file}. += with non-number values requires a list state target — declare it "${add[1]} = []".`
219
+ throw wdError(
220
+ `Unsupported button action "${raw}" in ${ctx.file}. += with non-number values requires a list state target — declare it "${add[1]} = []".`,
221
+ { code: "WD308", file: ctx.file }
213
222
  );
214
223
  }
215
224
  // `flag toggle` (no operand) → boolean flip; `list toggle v` → member-toggle.
@@ -235,13 +244,15 @@ function parseSingleAction(expression, raw, ctx) {
235
244
  // its source is a path off the outer row, which the runtime's row-remove
236
245
  // can't target. Fail loud with the honest workaround.
237
246
  if (!ctx.loopKey) {
238
- throw new Error(
239
- `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.`
247
+ throw wdError(
248
+ `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.`,
249
+ { code: "WD309", file: ctx.file }
240
250
  );
241
251
  }
242
252
  if (resolveStateKey(remove[1], ctx) !== ctx.loopKey) {
243
- throw new Error(
244
- `Button action "${raw}" must target the :state list being looped (@loop ${remove[1]} into ${ctx.loopItem}) in ${ctx.file}.`
253
+ throw wdError(
254
+ `Button action "${raw}" must target the :state list being looped (@loop ${remove[1]} into ${ctx.loopItem}) in ${ctx.file}.`,
255
+ { code: "WD310", file: ctx.file }
245
256
  );
246
257
  }
247
258
  return { op: "remove", target: ctx.loopKey };
@@ -277,7 +288,12 @@ function parseSingleAction(expression, raw, ctx) {
277
288
  target: resolveTarget(assign[1]),
278
289
  value: parseActionLiteral(assign[2], ctx)
279
290
  };
280
- throw new Error(`Unsupported button action "${raw}" in ${ctx.file}. ${ACTION_USE}`);
291
+ throw wdError(`Unsupported button action "${raw}" in ${ctx.file}. ${ACTION_USE}`, {
292
+ code: "WD311",
293
+ file: ctx.file,
294
+ hint: ACTION_USE.slice("Use: ".length),
295
+ example: ACTION_EXAMPLE
296
+ });
281
297
  }
282
298
 
283
299
  /**
@@ -292,8 +308,9 @@ function parseMergeOperand(raw, ctx) {
292
308
  if (/^[A-Za-z_$][\w$]*$/.test(value)) {
293
309
  const key = resolveStateKey(value, ctx);
294
310
  if (!key)
295
- throw new Error(
296
- `Button action merge targets unknown state "${value}" in ${ctx.file}. Declare it first with :state ${value} = ...`
311
+ throw wdError(
312
+ `Button action merge targets unknown state "${value}" in ${ctx.file}. Declare it first with :state ${value} = ...`,
313
+ { code: "WD312", file: ctx.file }
297
314
  );
298
315
  return key;
299
316
  }
@@ -302,6 +319,7 @@ function parseMergeOperand(raw, ctx) {
302
319
  throw wdError(
303
320
  `Unsupported merge operand "${raw}" in ${ctx.file}. Use: obj merge other (a state key or an inline {…} object) — e.g. settings merge patch`,
304
321
  {
322
+ code: "WD313",
305
323
  file: ctx.file,
306
324
  hint: "obj merge other (a state key or an inline {…} object) — e.g. settings merge patch",
307
325
  example: "settings merge patch"
@@ -325,12 +343,14 @@ function parseActionLiteral(raw, ctx) {
325
343
  try {
326
344
  return JSON.parse(value);
327
345
  } catch {
328
- throw new Error(
329
- `Unsupported action literal "${raw}" in ${ctx.file}. Use: a quoted string, number, boolean, null, or valid JSON.`
346
+ throw wdError(
347
+ `Unsupported action literal "${raw}" in ${ctx.file}. Use: a quoted string, number, boolean, null, or valid JSON.`,
348
+ { code: "WD314", file: ctx.file }
330
349
  );
331
350
  }
332
351
  }
333
- throw new Error(
334
- `Unsupported action literal "${raw}" in ${ctx.file}. Use: a quoted string, number, boolean, null, or valid JSON.`
352
+ throw wdError(
353
+ `Unsupported action literal "${raw}" in ${ctx.file}. Use: a quoted string, number, boolean, null, or valid JSON.`,
354
+ { code: "WD314", file: ctx.file }
335
355
  );
336
356
  }
@@ -7,7 +7,7 @@
7
7
  // multi-line directive bodies with nesting + fence awareness.
8
8
  // ---------------------------------------------------------------------------
9
9
 
10
- import { at } from "./context.js";
10
+ import { at, lineOf, wdError } from "./context.js";
11
11
  import {
12
12
  handleBind,
13
13
  handleButton,
@@ -92,7 +92,10 @@ export function compileBody(lines, ctx) {
92
92
  if (container) {
93
93
  flush();
94
94
  if (!container[1].trim())
95
- throw new Error(`Stray ::: close with no open container in ${ctx.file}`);
95
+ throw wdError(`Stray ::: close with no open container in ${ctx.file}`, {
96
+ code: "WD007",
97
+ file: ctx.file
98
+ });
96
99
  const block = scanContainer(lines, i, ctx);
97
100
  out.push(handleContainer(container[1].trim(), block.body, ctx, i));
98
101
  i = block.end;
@@ -235,17 +238,22 @@ export function compileBody(lines, ctx) {
235
238
  continue;
236
239
  }
237
240
  if (/^@repeat\b/.test(line)) {
238
- throw new Error(
239
- `@repeat was replaced by @loop in ${ctx.file}. Use: @loop /data.json into item ... @endloop`
241
+ throw wdError(
242
+ `@repeat was replaced by @loop in ${ctx.file}. Use: @loop /data.json into item ... @endloop`,
243
+ { code: "WD008", file: ctx.file, hint: "@loop /data.json into item ... @endloop" }
240
244
  );
241
245
  }
242
246
  if (/^:for\b/.test(line)) {
243
- throw new Error(
244
- `:for was replaced by @loop in ${ctx.file}. Use: @loop items into item ... @endloop`
247
+ throw wdError(
248
+ `:for was replaced by @loop in ${ctx.file}. Use: @loop items into item ... @endloop`,
249
+ { code: "WD009", file: ctx.file, hint: "@loop items into item ... @endloop" }
245
250
  );
246
251
  }
247
252
  if (/^(@endloop|:endif|:endfor|:endform|:else)\s*$/.test(line)) {
248
- throw new Error(`Stray "${line.trim()}" with no matching opener in ${ctx.file}`);
253
+ throw wdError(`Stray "${line.trim()}" with no matching opener in ${ctx.file}`, {
254
+ code: "WD010",
255
+ file: ctx.file
256
+ });
249
257
  }
250
258
  warnUnknownDirective(line, ctx);
251
259
  prose.push(line);
@@ -371,7 +379,11 @@ function scanBlock(lines, start, openRe, endToken, ctx) {
371
379
  }
372
380
  body.push(line);
373
381
  }
374
- throw new Error(`Missing ${endToken} for "${lines[start]}" in ${at(ctx, start)}`);
382
+ throw wdError(`Missing ${endToken} for "${lines[start]}" in ${at(ctx, start)}`, {
383
+ code: "WD011",
384
+ file: ctx.file,
385
+ line: lineOf(ctx, start)
386
+ });
375
387
  }
376
388
 
377
389
  /**
@@ -457,7 +469,11 @@ function scanContainer(lines, start, ctx) {
457
469
  }
458
470
  body.push(line);
459
471
  }
460
- throw new Error(`Missing closing ::: for "${lines[start]}" in ${at(ctx, start)}`);
472
+ throw wdError(`Missing closing ::: for "${lines[start]}" in ${at(ctx, start)}`, {
473
+ code: "WD012",
474
+ file: ctx.file,
475
+ line: lineOf(ctx, start)
476
+ });
461
477
  }
462
478
 
463
479
  /**
@@ -518,8 +534,9 @@ function scanConditional(lines, start, ctx) {
518
534
  const elseIf = t.match(/^:else if\s+(.+?)\s*$/);
519
535
  if (elseIf) {
520
536
  if (mode === "else")
521
- throw new Error(
522
- `":else if" after ":else" in ${ctx.file}. ":else" must be the last branch — order the "else if" conditions before the bare ":else".`
537
+ throw wdError(
538
+ `":else if" after ":else" in ${ctx.file}. ":else" must be the last branch — order the "else if" conditions before the bare ":else".`,
539
+ { code: "WD013", file: ctx.file }
523
540
  );
524
541
  falsy.push(`:if ${elseIf[1]}`); // desugar the chain tail into a nested :if
525
542
  falsyStart = i;
@@ -529,8 +546,9 @@ function scanConditional(lines, start, ctx) {
529
546
  }
530
547
  if (t === ":else") {
531
548
  if (mode === "else")
532
- throw new Error(
533
- `Duplicate ":else" in ${ctx.file}. A conditional may have only one bare ":else".`
549
+ throw wdError(
550
+ `Duplicate ":else" in ${ctx.file}. A conditional may have only one bare ":else".`,
551
+ { code: "WD014", file: ctx.file }
534
552
  );
535
553
  falsyStart = i + 1;
536
554
  current = falsy;
@@ -540,5 +558,9 @@ function scanConditional(lines, start, ctx) {
540
558
  }
541
559
  current.push(line);
542
560
  }
543
- throw new Error(`Missing :endif for "${lines[start]}" in ${at(ctx, start)}`);
561
+ throw wdError(`Missing :endif for "${lines[start]}" in ${at(ctx, start)}`, {
562
+ code: "WD015",
563
+ file: ctx.file,
564
+ line: lineOf(ctx, start)
565
+ });
544
566
  }
@@ -17,6 +17,7 @@
17
17
 
18
18
  import path from "node:path";
19
19
  import { firstParagraph } from "../feeds.js";
20
+ import { wdError } from "./context.js";
20
21
  import { parseFrontmatter } from "./frontmatter.js";
21
22
  import { fsReader } from "./fs-reader.js";
22
23
 
@@ -197,8 +198,9 @@ export function parseSchema(raw, file) {
197
198
  if (!trimmed || trimmed.startsWith("#")) continue; // blank line or comment
198
199
  const match = trimmed.match(/^([A-Za-z0-9_-]+):\s*(.+)$/);
199
200
  if (!match) {
200
- throw new Error(
201
- `Malformed schema line in ${file}:${i + 1}: "${trimmed}". Use: field: type (e.g. \`title: string\`, \`tags: string[]\`, \`featured: boolean?\`).`
201
+ throw wdError(
202
+ `Malformed schema line in ${file}:${i + 1}: "${trimmed}". Use: field: type (e.g. \`title: string\`, \`tags: string[]\`, \`featured: boolean?\`).`,
203
+ { code: "WD120", file, line: i + 1 }
202
204
  );
203
205
  }
204
206
  fields.push(parseSchemaField(match[1], match[2].trim(), file, i + 1));
@@ -219,8 +221,9 @@ function parseSchemaField(name, rawType, file, lineNo) {
219
221
  const optional = rawType.endsWith("?");
220
222
  const type = optional ? rawType.slice(0, -1).trim() : rawType;
221
223
  if (!SCHEMA_TYPES.has(type)) {
222
- throw new Error(
223
- `Unknown schema type "${rawType}" for field "${name}" in ${file}:${lineNo}. Use one of: ${[...SCHEMA_TYPES].join(", ")} (append "?" to make a field optional).`
224
+ throw wdError(
225
+ `Unknown schema type "${rawType}" for field "${name}" in ${file}:${lineNo}. Use one of: ${[...SCHEMA_TYPES].join(", ")} (append "?" to make a field optional).`,
226
+ { code: "WD121", file, line: lineNo }
224
227
  );
225
228
  }
226
229
  return { name, type, optional };
@@ -249,8 +252,9 @@ function validateRows(rows, schema, name) {
249
252
  for (const field of schema) validateField(row, field, where);
250
253
  for (const key of Object.keys(row)) {
251
254
  if (allowed.has(key)) continue;
252
- throw new Error(
253
- `Unknown frontmatter field "${key}" in ${where}: it is not declared in _schema.wd. Add \`${key}: <type>\` to the schema, or remove the field.`
255
+ throw wdError(
256
+ `Unknown frontmatter field "${key}" in ${where}: it is not declared in _schema.wd. Add \`${key}: <type>\` to the schema, or remove the field.`,
257
+ { code: "WD122" }
254
258
  );
255
259
  }
256
260
  }
@@ -267,14 +271,16 @@ function validateField(row, field, where) {
267
271
  const present = field.name in row && row[field.name] !== null && row[field.name] !== "";
268
272
  if (!present) {
269
273
  if (field.optional) return;
270
- throw new Error(
271
- `Missing required field "${field.name}" (${field.type}) in ${where}. Add \`${field.name}: …\` to the entry's frontmatter, or mark it optional with "${field.name}: ${field.type}?" in _schema.wd.`
274
+ throw wdError(
275
+ `Missing required field "${field.name}" (${field.type}) in ${where}. Add \`${field.name}: …\` to the entry's frontmatter, or mark it optional with "${field.name}: ${field.type}?" in _schema.wd.`,
276
+ { code: "WD123" }
272
277
  );
273
278
  }
274
279
  const value = row[field.name];
275
280
  if (!matchesType(value, field.type)) {
276
- throw new Error(
277
- `Field "${field.name}" in ${where} should be ${field.type} but is "${describe(value)}". Fix the entry's frontmatter or the schema.`
281
+ throw wdError(
282
+ `Field "${field.name}" in ${where} should be ${field.type} but is "${describe(value)}". Fix the entry's frontmatter or the schema.`,
283
+ { code: "WD124" }
278
284
  );
279
285
  }
280
286
  }
@@ -155,10 +155,17 @@
155
155
  /**
156
156
  * Structured, machine-readable companion to a compile error's string message.
157
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.
158
+ * editor) the same facts without re-parsing the prose: which code, which
159
+ * file/line, the corrective `Use:` template, and one concrete compilable
160
+ * example line.
160
161
  * @typedef {object} WdErrorInfo
161
- * @property {string} file Absolute path of the source file the error is in.
162
+ * @property {string} code Stable error code (`WD` + three digits, e.g. `WD201`).
163
+ * Every code is registered in `src/errors.js` with a title, cause, and fix,
164
+ * and documented in `docs/errors.md`. {@link wdError} prefixes the string
165
+ * message with `[<code>] `, so the code is greppable from the message too.
166
+ * @property {string} [file] Absolute path of the source file the error is in,
167
+ * when the throwing layer knows one (`compileSkin` is a pure string→string
168
+ * pass and a collection-row check names the entry route instead).
162
169
  * @property {number} [line] 1-based file line the error points at, when known
163
170
  * (some errors — e.g. a bad `where` operand — are file-scoped only, matching
164
171
  * the string message, which likewise omits the line there).
@@ -207,6 +214,23 @@
207
214
  */
208
215
 
209
216
  // Page-include extensions are the only two file types `@include` will pull in.
217
+ /**
218
+ * Normalize CRLF/CR line endings to LF.
219
+ *
220
+ * Every line-structured parser in the project (frontmatter delimiters, the `.wd`
221
+ * directive dispatcher, `.skin` indentation) tests LF-shaped text. Source files
222
+ * authored on Windows — or checked out anywhere with git's `core.autocrlf`, the
223
+ * default on Windows — arrive CRLF-terminated, and an un-normalized `\r` turns
224
+ * `---` into `---\r` and silently defeats those tests. Applied at the reader
225
+ * boundary and again at the two entry points that accept raw strings from
226
+ * callers that bypass the reader; it is idempotent, so doubling up is free.
227
+ * @param {string} text
228
+ * @returns {string}
229
+ */
230
+ export function normalizeNewlines(text) {
231
+ return text.includes("\r") ? text.replace(/\r\n?/g, "\n") : text;
232
+ }
233
+
210
234
  export const pageIncludeExtensions = [".md", ".wd"];
211
235
 
212
236
  // Reserved per-row meta variables, mapped to their runtime marker token. Shared
@@ -286,16 +310,21 @@ export function lineOf(ctx, index) {
286
310
  }
287
311
 
288
312
  /**
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.
313
+ * Build a compile `Error` carrying a structured {@link WdErrorInfo} on `err.wd`
314
+ * — so an AI edit-loop (or an editor) gets code/file/line/hint/example without
315
+ * re-parsing the prose. The string message is the caller's text prefixed with
316
+ * the stable `[WDxxx]` code, so a user can search the code straight out of the
317
+ * terminal; everything after the prefix (the `file:line`, the corrective `Use:`
318
+ * template, the concrete `e.g.` example) is unchanged.
319
+ *
320
+ * Every author-facing compile error is built here, so no error can ship without
321
+ * a code. See `src/errors.js` for the registry and the numbering scheme.
322
+ * @param {string} message The user-facing error text, without the code prefix.
323
+ * @param {WdErrorInfo} wd The structured mirror (its `code` becomes the prefix).
295
324
  * @returns {Error & { wd: WdErrorInfo }}
296
325
  */
297
326
  export function wdError(message, wd) {
298
- const err = /** @type {Error & { wd: WdErrorInfo }} */ (new Error(message));
327
+ const err = /** @type {Error & { wd: WdErrorInfo }} */ (new Error(`[${wd.code}] ${message}`));
299
328
  err.wd = wd;
300
329
  return err;
301
330
  }
@@ -40,6 +40,7 @@ export function handleFetch(line, ctx, index) {
40
40
  const head = line.match(/^:fetch\s+([A-Za-z_$][\w$]*)\s+from\s+("[^"]+"|\S+)\s*(.*)$/);
41
41
  if (!head)
42
42
  throw wdError(`Malformed :fetch in ${at(ctx, index)}: ${line}. ${FETCH_USE}`, {
43
+ code: "WD501",
43
44
  file: ctx.file,
44
45
  line: lineOf(ctx, index),
45
46
  hint: FETCH_USE.slice("Use: ".length),
@@ -51,10 +52,19 @@ export function handleFetch(line, ctx, index) {
51
52
  const opts = {};
52
53
  for (const part of head[3].trim().split(/\s+/).filter(Boolean)) {
53
54
  const kv = part.match(/^([A-Za-z]+)=(.+)$/);
54
- if (!kv) throw new Error(`Unknown :fetch option "${part}" in ${at(ctx, index)}. ${FETCH_USE}`);
55
+ if (!kv)
56
+ throw wdError(`Unknown :fetch option "${part}" in ${at(ctx, index)}. ${FETCH_USE}`, {
57
+ code: "WD502",
58
+ file: ctx.file,
59
+ line: lineOf(ctx, index)
60
+ });
55
61
  const optName = kv[1];
56
62
  if (!["method", "when", "timeout", "retry", "headers", "body", "refresh"].includes(optName)) {
57
- throw new Error(`Unknown :fetch option "${optName}" in ${at(ctx, index)}. ${FETCH_USE}`);
63
+ throw wdError(`Unknown :fetch option "${optName}" in ${at(ctx, index)}. ${FETCH_USE}`, {
64
+ code: "WD502",
65
+ file: ctx.file,
66
+ line: lineOf(ctx, index)
67
+ });
58
68
  }
59
69
  opts[optName] = stripQuotes(kv[2]);
60
70
  }
@@ -63,17 +73,27 @@ export function handleFetch(line, ctx, index) {
63
73
  opts.method &&
64
74
  !["GET", "POST", "PUT", "PATCH", "DELETE"].includes(opts.method.toUpperCase())
65
75
  ) {
66
- throw new Error(
67
- `:fetch method "${opts.method}" is not allowed in ${at(ctx, index)}. ${FETCH_USE}`
76
+ throw wdError(
77
+ `:fetch method "${opts.method}" is not allowed in ${at(ctx, index)}. ${FETCH_USE}`,
78
+ {
79
+ code: "WD503",
80
+ file: ctx.file,
81
+ line: lineOf(ctx, index)
82
+ }
68
83
  );
69
84
  }
70
85
  if (opts.when && !["load", "visible"].includes(opts.when)) {
71
- throw new Error(`:fetch when "${opts.when}" is not allowed in ${at(ctx, index)}. ${FETCH_USE}`);
86
+ throw wdError(`:fetch when "${opts.when}" is not allowed in ${at(ctx, index)}. ${FETCH_USE}`, {
87
+ code: "WD504",
88
+ file: ctx.file,
89
+ line: lineOf(ctx, index)
90
+ });
72
91
  }
73
92
  for (const n of ["timeout", "retry"]) {
74
93
  if (opts[n] !== undefined && !/^\d+$/.test(opts[n])) {
75
- throw new Error(
76
- `:fetch ${n} must be a non-negative integer in ${at(ctx, index)}. ${FETCH_USE}`
94
+ throw wdError(
95
+ `:fetch ${n} must be a non-negative integer in ${at(ctx, index)}. ${FETCH_USE}`,
96
+ { code: "WD505", file: ctx.file, line: lineOf(ctx, index) }
77
97
  );
78
98
  }
79
99
  }
@@ -81,8 +101,9 @@ export function handleFetch(line, ctx, index) {
81
101
  // Layer 2: a 401 triggers a token-refresh POST to this URL, then one retry.
82
102
  // The new token is written back into the `headers=` state, so it is required.
83
103
  if (!opts.headers) {
84
- throw new Error(
85
- `:fetch refresh= needs headers= (the state key holding the token to renew) in ${at(ctx, index)}. ${FETCH_USE}`
104
+ throw wdError(
105
+ `:fetch refresh= needs headers= (the state key holding the token to renew) in ${at(ctx, index)}. ${FETCH_USE}`,
106
+ { code: "WD506", file: ctx.file, line: lineOf(ctx, index) }
86
107
  );
87
108
  }
88
109
  opts.refresh = validateFetchUrl(opts.refresh, ctx, ":fetch refresh");
@@ -147,20 +168,23 @@ export function validateFetchUrl(url, ctx, what = ":fetch") {
147
168
  const value = url.trim();
148
169
  // biome-ignore lint/suspicious/noControlCharactersInRegex: intentional — rejects control characters in URLs/hrefs.
149
170
  if (value !== url || value === "" || /[\u0000-\u001F\u007F]/.test(value)) {
150
- throw new Error(
151
- `Unsafe ${what} URL "${escapeHtml(url)}" in ${ctx.file}. Use a relative path (/, ./, ../), an http(s):// URL, or a { state } interpolation.`
171
+ throw wdError(
172
+ `Unsafe ${what} URL "${escapeHtml(url)}" in ${ctx.file}. Use a relative path (/, ./, ../), an http(s):// URL, or a { state } interpolation.`,
173
+ { code: "WD507", file: ctx.file }
152
174
  );
153
175
  }
154
176
  if (value.startsWith("//")) {
155
- throw new Error(
156
- `Unsafe ${what} URL "${escapeHtml(url)}" in ${ctx.file}. Protocol-relative URLs are not allowed; use http:// or https:// explicitly.`
177
+ throw wdError(
178
+ `Unsafe ${what} URL "${escapeHtml(url)}" in ${ctx.file}. Protocol-relative URLs are not allowed; use http:// or https:// explicitly.`,
179
+ { code: "WD508", file: ctx.file }
157
180
  );
158
181
  }
159
182
  if (value.startsWith("{")) return value; // interpolation-first; the runtime percent-encodes state values
160
183
  const scheme = value.match(/^([A-Za-z][A-Za-z0-9+.-]*):/);
161
184
  if (scheme && !["http", "https"].includes(scheme[1].toLowerCase())) {
162
- throw new Error(
163
- `Unsafe ${what} URL "${escapeHtml(url)}" in ${ctx.file}. The "${scheme[1]}:" scheme is not allowed; use http://, https://, or a relative path.`
185
+ throw wdError(
186
+ `Unsafe ${what} URL "${escapeHtml(url)}" in ${ctx.file}. The "${scheme[1]}:" scheme is not allowed; use http://, https://, or a relative path.`,
187
+ { code: "WD509", file: ctx.file }
164
188
  );
165
189
  }
166
190
  return value;
@@ -11,6 +11,8 @@
11
11
  // reads :state/:store emits `data-wd-fmt` and re-formats in the runtime.
12
12
  // ---------------------------------------------------------------------------
13
13
 
14
+ import { wdError } from "./context.js";
15
+
14
16
  /**
15
17
  * @typedef {import("./context.js").Ctx} Ctx
16
18
  * @typedef {{ name: string, args: Array<string | number | boolean | null> }} Stage
@@ -221,8 +223,9 @@ export function validatePipes(inner, ctx) {
221
223
  const parsed = parsePipes(inner);
222
224
  for (const stage of parsed.stages) {
223
225
  if (!Object.hasOwn(FORMATTERS, stage.name)) {
224
- throw new Error(
225
- `Unknown formatter "${stage.name}" in { ${inner} } (${ctx.file}). Available: ${FORMATTER_NAMES.join(", ")}.`
226
+ throw wdError(
227
+ `Unknown formatter "${stage.name}" in { ${inner} } (${ctx.file}). Available: ${FORMATTER_NAMES.join(", ")}.`,
228
+ { code: "WD006", file: ctx.file }
226
229
  );
227
230
  }
228
231
  }