@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
@@ -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,14 @@ 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
+ code: "WD301",
41
+ file: ctx.file,
42
+ line: lineOf(ctx, index),
43
+ hint: BUTTON_USE.slice("Use: ".length),
44
+ example: BUTTON_EXAMPLE
45
+ });
35
46
  ctx.comp.assets.runtime = true;
36
47
  const action = parseAction(match[2], ctx);
37
48
  if (Array.isArray(action)) {
@@ -44,8 +55,11 @@ export function handleButton(line, ctx, index) {
44
55
  return `<button type="button" data-wd-action="${action.op}" data-wd-target="${action.target}"${valueAttr}>${escapeHtml(match[1])}</button>`;
45
56
  }
46
57
 
47
- const EFFECT_USE =
48
- "Use: :effect watchedState -> action[; action…] (actions use the :button vocabulary).";
58
+ // Concrete, compilable examples for the effect / every hint tails + catalog.
59
+ // (Effects run the :button action vocabulary — mutations, not expressions; use
60
+ // :computed for derived values. `searches++` is the canonical watch-and-act.)
61
+ export const EFFECT_EXAMPLE = ":effect query -> searches++";
62
+ const EFFECT_USE = `Use: :effect watchedState -> action[; action…] (actions use the :button vocabulary) — e.g. ${EFFECT_EXAMPLE}`;
49
63
 
50
64
  /**
51
65
  * Parse `:effect <watched> -> <actions>` into a zero-output marker the runtime
@@ -59,12 +73,20 @@ const EFFECT_USE =
59
73
  */
60
74
  export function handleEffect(line, ctx, index) {
61
75
  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}`);
76
+ if (!match)
77
+ throw wdError(`Malformed :effect in ${at(ctx, index)}: ${line}. ${EFFECT_USE}`, {
78
+ code: "WD302",
79
+ file: ctx.file,
80
+ line: lineOf(ctx, index),
81
+ hint: EFFECT_USE.slice("Use: ".length),
82
+ example: EFFECT_EXAMPLE
83
+ });
63
84
  const segs = validatePath(match[1], ctx, EFFECT_USE);
64
85
  const key = resolveStateKey(segs[0], ctx);
65
86
  if (!key)
66
- throw new Error(
67
- `: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 }
68
90
  );
69
91
  ctx.comp.assets.runtime = true;
70
92
  const watch = [key, ...segs.slice(1)].join(".");
@@ -73,8 +95,8 @@ export function handleEffect(line, ctx, index) {
73
95
  return `<script type="application/json" data-wd-effect>${safeScriptJson({ watch, actions })}</script>`;
74
96
  }
75
97
 
76
- const EVERY_USE =
77
- "Use: :every <duration> -> action[; action…] — duration like 5s, 500ms, or 2m (actions use the :button vocabulary).";
98
+ export const EVERY_EXAMPLE = ":every 5s -> seconds++";
99
+ const EVERY_USE = `Use: :every <duration> -> action[; action…] — duration like 5s, 500ms, or 2m (actions use the :button vocabulary) — e.g. ${EVERY_EXAMPLE}`;
78
100
 
79
101
  /**
80
102
  * Parse a duration token into milliseconds: `<int>[ms|s|m]`, defaulting to ms.
@@ -101,12 +123,21 @@ function parseDuration(raw) {
101
123
  */
102
124
  export function handleEvery(line, ctx, index) {
103
125
  const match = line.match(/^:every\s+(\S+)\s*->\s*(.+)$/);
104
- if (!match) throw new Error(`Malformed :every in ${at(ctx, index)}: ${line}. ${EVERY_USE}`);
126
+ if (!match)
127
+ throw wdError(`Malformed :every in ${at(ctx, index)}: ${line}. ${EVERY_USE}`, {
128
+ code: "WD304",
129
+ file: ctx.file,
130
+ line: lineOf(ctx, index),
131
+ hint: EVERY_USE.slice("Use: ".length),
132
+ example: EVERY_EXAMPLE
133
+ });
105
134
  const ms = parseDuration(match[1]);
106
135
  if (ms == null || ms <= 0) {
107
- throw new Error(
108
- `:every duration "${match[1]}" is not valid in ${at(ctx, index)}. ${EVERY_USE}`
109
- );
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
+ });
110
141
  }
111
142
  ctx.comp.assets.runtime = true;
112
143
  const action = parseAction(match[2], ctx);
@@ -114,8 +145,11 @@ export function handleEvery(line, ctx, index) {
114
145
  return `<script type="application/json" data-wd-every>${safeScriptJson({ ms, actions })}</script>`;
115
146
  }
116
147
 
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 ";".';
148
+ // One concrete action expression, appended so a model copies `count++` rather
149
+ // than the schematic `name++`. The action vocabulary the directive catalog draws
150
+ // from lives here (the parser branches below), mirrored compactly in ACTION_USE.
151
+ export const ACTION_EXAMPLE = "count++";
152
+ 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
153
 
120
154
  /**
121
155
  * Parse a `:button` action expression into a validated `{ op, target, value }`,
@@ -148,8 +182,9 @@ function parseSingleAction(expression, raw, ctx) {
148
182
  const segs = validatePath(name, ctx, ACTION_USE);
149
183
  const key = resolveStateKey(segs[0], ctx);
150
184
  if (!key) {
151
- throw new Error(
152
- `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 }
153
188
  );
154
189
  }
155
190
  return [key, ...segs.slice(1)].join(".");
@@ -170,8 +205,9 @@ function parseSingleAction(expression, raw, ctx) {
170
205
  if (ctx.loopItem && rhs === ctx.loopItem) {
171
206
  const target = resolveTarget(add[1]);
172
207
  if (!Array.isArray(ctx.comp.state.get(target))) {
173
- throw new Error(
174
- `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 }
175
211
  );
176
212
  }
177
213
  return { op: "append-row", target };
@@ -180,8 +216,9 @@ function parseSingleAction(expression, raw, ctx) {
180
216
  const value = parseActionLiteral(rhs, ctx);
181
217
  if (Array.isArray(ctx.comp.state.get(target))) return { op: "append", target, value };
182
218
  if (typeof value === "number") return { op: "add", target, value };
183
- throw new Error(
184
- `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 }
185
222
  );
186
223
  }
187
224
  // `flag toggle` (no operand) → boolean flip; `list toggle v` → member-toggle.
@@ -207,13 +244,15 @@ function parseSingleAction(expression, raw, ctx) {
207
244
  // its source is a path off the outer row, which the runtime's row-remove
208
245
  // can't target. Fail loud with the honest workaround.
209
246
  if (!ctx.loopKey) {
210
- throw new Error(
211
- `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 }
212
250
  );
213
251
  }
214
252
  if (resolveStateKey(remove[1], ctx) !== ctx.loopKey) {
215
- throw new Error(
216
- `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 }
217
256
  );
218
257
  }
219
258
  return { op: "remove", target: ctx.loopKey };
@@ -249,7 +288,12 @@ function parseSingleAction(expression, raw, ctx) {
249
288
  target: resolveTarget(assign[1]),
250
289
  value: parseActionLiteral(assign[2], ctx)
251
290
  };
252
- 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
+ });
253
297
  }
254
298
 
255
299
  /**
@@ -264,15 +308,22 @@ function parseMergeOperand(raw, ctx) {
264
308
  if (/^[A-Za-z_$][\w$]*$/.test(value)) {
265
309
  const key = resolveStateKey(value, ctx);
266
310
  if (!key)
267
- throw new Error(
268
- `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 }
269
314
  );
270
315
  return key;
271
316
  }
272
317
  const parsed = parseActionLiteral(value, ctx);
273
318
  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).`
319
+ throw wdError(
320
+ `Unsupported merge operand "${raw}" in ${ctx.file}. Use: obj merge other (a state key or an inline {…} object) — e.g. settings merge patch`,
321
+ {
322
+ code: "WD313",
323
+ file: ctx.file,
324
+ hint: "obj merge other (a state key or an inline {…} object) — e.g. settings merge patch",
325
+ example: "settings merge patch"
326
+ }
276
327
  );
277
328
  }
278
329
 
@@ -292,12 +343,14 @@ function parseActionLiteral(raw, ctx) {
292
343
  try {
293
344
  return JSON.parse(value);
294
345
  } catch {
295
- throw new Error(
296
- `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 }
297
349
  );
298
350
  }
299
351
  }
300
- throw new Error(
301
- `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 }
302
355
  );
303
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
  }
@@ -152,6 +152,27 @@
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 code, which
159
+ * file/line, the corrective `Use:` template, and one concrete compilable
160
+ * example line.
161
+ * @typedef {object} WdErrorInfo
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).
169
+ * @property {number} [line] 1-based file line the error points at, when known
170
+ * (some errors — e.g. a bad `where` operand — are file-scoped only, matching
171
+ * the string message, which likewise omits the line there).
172
+ * @property {string} [hint] The corrective `Use:` template (without the `Use: ` prefix).
173
+ * @property {string} [example] One concrete, compilable directive line.
174
+ */
175
+
155
176
  /**
156
177
  * A compiled `@loop … where` predicate.
157
178
  * @typedef {object} Predicate
@@ -193,6 +214,23 @@
193
214
  */
194
215
 
195
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
+
196
234
  export const pageIncludeExtensions = [".md", ".wd"];
197
235
 
198
236
  // Reserved per-row meta variables, mapped to their runtime marker token. Shared
@@ -257,7 +295,38 @@ export function createScope(parent, vars = {}) {
257
295
  * @returns {string}
258
296
  */
259
297
  export function at(ctx, index) {
260
- return `${ctx.file}:${index + 1 + (ctx.bodyLine ?? 0) + (ctx.lineOffset ?? 0)}`;
298
+ return `${ctx.file}:${lineOf(ctx, index)}`;
299
+ }
300
+
301
+ /**
302
+ * The 1-based true file line for a body-slice `index` — the numeric half of
303
+ * {@link at}, exposed so {@link wdError} can populate the structured `wd.line`.
304
+ * @param {Pick<Ctx, "bodyLine" | "lineOffset">} ctx
305
+ * @param {number} index 0-based line index into the current body slice.
306
+ * @returns {number}
307
+ */
308
+ export function lineOf(ctx, index) {
309
+ return index + 1 + (ctx.bodyLine ?? 0) + (ctx.lineOffset ?? 0);
310
+ }
311
+
312
+ /**
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).
324
+ * @returns {Error & { wd: WdErrorInfo }}
325
+ */
326
+ export function wdError(message, wd) {
327
+ const err = /** @type {Error & { wd: WdErrorInfo }} */ (new Error(`[${wd.code}] ${message}`));
328
+ err.wd = wd;
329
+ return err;
261
330
  }
262
331
 
263
332
  /**
@@ -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,17 +38,33 @@ 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
+ code: "WD501",
44
+ file: ctx.file,
45
+ line: lineOf(ctx, index),
46
+ hint: FETCH_USE.slice("Use: ".length),
47
+ example: FETCH_EXAMPLE
48
+ });
40
49
  const name = head[1];
41
50
  const url = validateFetchUrl(stripQuotes(head[2]), ctx);
42
51
  /** @type {Record<string, string>} */
43
52
  const opts = {};
44
53
  for (const part of head[3].trim().split(/\s+/).filter(Boolean)) {
45
54
  const kv = part.match(/^([A-Za-z]+)=(.+)$/);
46
- 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
+ });
47
61
  const optName = kv[1];
48
62
  if (!["method", "when", "timeout", "retry", "headers", "body", "refresh"].includes(optName)) {
49
- 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
+ });
50
68
  }
51
69
  opts[optName] = stripQuotes(kv[2]);
52
70
  }
@@ -55,17 +73,27 @@ export function handleFetch(line, ctx, index) {
55
73
  opts.method &&
56
74
  !["GET", "POST", "PUT", "PATCH", "DELETE"].includes(opts.method.toUpperCase())
57
75
  ) {
58
- throw new Error(
59
- `: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
+ }
60
83
  );
61
84
  }
62
85
  if (opts.when && !["load", "visible"].includes(opts.when)) {
63
- 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
+ });
64
91
  }
65
92
  for (const n of ["timeout", "retry"]) {
66
93
  if (opts[n] !== undefined && !/^\d+$/.test(opts[n])) {
67
- throw new Error(
68
- `: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) }
69
97
  );
70
98
  }
71
99
  }
@@ -73,8 +101,9 @@ export function handleFetch(line, ctx, index) {
73
101
  // Layer 2: a 401 triggers a token-refresh POST to this URL, then one retry.
74
102
  // The new token is written back into the `headers=` state, so it is required.
75
103
  if (!opts.headers) {
76
- throw new Error(
77
- `: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) }
78
107
  );
79
108
  }
80
109
  opts.refresh = validateFetchUrl(opts.refresh, ctx, ":fetch refresh");
@@ -139,20 +168,23 @@ export function validateFetchUrl(url, ctx, what = ":fetch") {
139
168
  const value = url.trim();
140
169
  // biome-ignore lint/suspicious/noControlCharactersInRegex: intentional — rejects control characters in URLs/hrefs.
141
170
  if (value !== url || value === "" || /[\u0000-\u001F\u007F]/.test(value)) {
142
- throw new Error(
143
- `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 }
144
174
  );
145
175
  }
146
176
  if (value.startsWith("//")) {
147
- throw new Error(
148
- `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 }
149
180
  );
150
181
  }
151
182
  if (value.startsWith("{")) return value; // interpolation-first; the runtime percent-encodes state values
152
183
  const scheme = value.match(/^([A-Za-z][A-Za-z0-9+.-]*):/);
153
184
  if (scheme && !["http", "https"].includes(scheme[1].toLowerCase())) {
154
- throw new Error(
155
- `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 }
156
188
  );
157
189
  }
158
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
  }