@zvndev/darkmown 2.1.0 → 2.3.1-next.1.g3a4a8dc

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 (111) 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 +647 -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 +48 -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/src/api-runner.d.ts +94 -0
  75. package/types/src/builder.d.ts +170 -0
  76. package/types/src/catalog.d.ts +97 -0
  77. package/types/src/compiler/actions.d.ts +41 -0
  78. package/types/src/compiler/body.d.ts +11 -0
  79. package/types/src/compiler/collections.d.ts +76 -0
  80. package/types/src/compiler/context.d.ts +647 -0
  81. package/types/src/compiler/directives.d.ts +6 -0
  82. package/types/src/compiler/expr-ast.d.ts +20 -0
  83. package/types/src/compiler/fetch.d.ts +29 -0
  84. package/types/src/compiler/format.d.ts +49 -0
  85. package/types/src/compiler/forms.d.ts +94 -0
  86. package/types/src/compiler/frontmatter.d.ts +33 -0
  87. package/types/src/compiler/fs-reader.d.ts +7 -0
  88. package/types/src/compiler/image-size.d.ts +14 -0
  89. package/types/src/compiler/includes.d.ts +100 -0
  90. package/types/src/compiler/index.d.ts +5 -0
  91. package/types/src/compiler/interpolation.d.ts +115 -0
  92. package/types/src/compiler/loops.d.ts +36 -0
  93. package/types/src/compiler/markdown.d.ts +34 -0
  94. package/types/src/compiler/media.d.ts +32 -0
  95. package/types/src/compiler/page.d.ts +109 -0
  96. package/types/src/compiler/predicates.d.ts +50 -0
  97. package/types/src/compiler/reader.d.ts +48 -0
  98. package/types/src/compiler/state.d.ts +68 -0
  99. package/types/src/compiler/structure.d.ts +56 -0
  100. package/types/src/compiler.d.ts +48 -0
  101. package/types/src/config.d.ts +45 -0
  102. package/types/src/dev.d.ts +58 -0
  103. package/types/src/errors.d.ts +100 -0
  104. package/types/src/feeds.d.ts +150 -0
  105. package/types/src/headers.d.ts +44 -0
  106. package/types/src/highlight.d.ts +24 -0
  107. package/types/src/router.d.ts +78 -0
  108. package/types/src/scaffold.d.ts +20 -0
  109. package/types/src/skin.d.ts +48 -0
  110. package/types/src/statics.d.ts +24 -0
  111. 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, nestedCtx } from "./context.js";
12
+ import { at, lineOf, nestedCtx, wdError } from "./context.js";
13
13
  import { validateFetchUrl } from "./fetch.js";
14
14
  import {
15
15
  escapeHtml,
@@ -25,6 +25,20 @@ import { declareErrorState, declareState } from "./state.js";
25
25
  * @typedef {import("./context.js").Ctx} Ctx
26
26
  */
27
27
 
28
+ // Concrete, compilable one-liners for the form-family hint tails + the catalog.
29
+ // (Field openers like :select/:checkbox expect following `- Label` lines; the
30
+ // example is the opener, which the catalog/grammar pair with option lines.)
31
+ export const FORM_EXAMPLE = ":form into contact";
32
+ export const INPUT_EXAMPLE = ":input email type=email required";
33
+ export const TEXTAREA_EXAMPLE = ':textarea message placeholder="Your message" rows=4';
34
+ export const SELECT_EXAMPLE = ":select topic";
35
+ export const CHECKBOX_EXAMPLE = ":checkbox toppings";
36
+ export const RADIO_EXAMPLE = ":radio size";
37
+ export const SUBMIT_EXAMPLE = ':submit "Send"';
38
+ export const BIND_EXAMPLE = ':bind query placeholder="Search"';
39
+ export const SLIDER_EXAMPLE = ":slider volume = 50 min=0 max=100 step=1";
40
+ const INPUT_USE = `Use: :input name [type=…] [placeholder="…"] [required] — e.g. ${INPUT_EXAMPLE}`;
41
+
28
42
  /**
29
43
  * @param {string} line
30
44
  * @param {string[]} bodyLines
@@ -43,9 +57,14 @@ export function handleForm(line, bodyLines, ctx, index) {
43
57
  .replace(/(?:^|\s)into\s+[A-Za-z_$][\w$]*/, "")
44
58
  .trim();
45
59
  if ((!rawAction && !into) || leftover) {
46
- throw new Error(
47
- `Malformed :form in ${at(ctx, index)}: ${line}. Use ':form into name' (client state), ':form action="/url"' (native post), or both (fetch round-trip into state).`
48
- );
60
+ const hint = `':form into name' (client state), ':form action="/url"' (native post), or both (fetch round-trip into state) — e.g. ${FORM_EXAMPLE}`;
61
+ throw wdError(`Malformed :form in ${at(ctx, index)}: ${line}. Use ${hint}`, {
62
+ code: "WD401",
63
+ file: ctx.file,
64
+ line: lineOf(ctx, index),
65
+ hint,
66
+ example: FORM_EXAMPLE
67
+ });
49
68
  }
50
69
  const action = rawAction ? validateFetchUrl(rawAction, ctx, ":form action") : undefined;
51
70
  const inner = ctx.compileBody(bodyLines, nestedCtx(ctx, index + 1)).trim();
@@ -68,7 +87,14 @@ export function handleForm(line, bodyLines, ctx, index) {
68
87
  */
69
88
  export function handleInput(line, ctx, index) {
70
89
  const match = line.match(/^:input\s+([A-Za-z_][\w-]*)\s*(.*)$/);
71
- if (!match) throw new Error(`Malformed :input in ${at(ctx, index)}: ${line}`);
90
+ if (!match)
91
+ throw wdError(`Malformed :input in ${at(ctx, index)}: ${line}. ${INPUT_USE}`, {
92
+ code: "WD402",
93
+ file: ctx.file,
94
+ line: lineOf(ctx, index),
95
+ hint: INPUT_USE.slice("Use: ".length),
96
+ example: INPUT_EXAMPLE
97
+ });
72
98
  const attrs = [`name="${escapeHtml(match[1])}"`];
73
99
  let type = "text";
74
100
  let placeholder;
@@ -77,7 +103,11 @@ export function handleInput(line, ctx, index) {
77
103
  for (const token of (match[2] || "").matchAll(re)) {
78
104
  if (token[3]) {
79
105
  if (!["required", "autofocus", "disabled", "readonly"].includes(token[3])) {
80
- throw new Error(`Unknown :input flag "${token[3]}" in ${at(ctx, index)}`);
106
+ throw wdError(`Unknown :input flag "${token[3]}" in ${at(ctx, index)}`, {
107
+ code: "WD403",
108
+ file: ctx.file,
109
+ line: lineOf(ctx, index)
110
+ });
81
111
  }
82
112
  attrs.push(token[3]);
83
113
  continue;
@@ -100,7 +130,11 @@ export function handleInput(line, ctx, index) {
100
130
  "aria-describedby"
101
131
  ].includes(token[1])
102
132
  ) {
103
- throw new Error(`Unknown :input attribute "${token[1]}" in ${at(ctx, index)}`);
133
+ throw wdError(`Unknown :input attribute "${token[1]}" in ${at(ctx, index)}`, {
134
+ code: "WD404",
135
+ file: ctx.file,
136
+ line: lineOf(ctx, index)
137
+ });
104
138
  }
105
139
  if (token[1] === "placeholder") placeholder = value;
106
140
  if (token[1] === "aria-label" || token[1] === "aria-describedby") hasAria = true;
@@ -127,13 +161,18 @@ export function handleInput(line, ctx, index) {
127
161
  export function handleBind(line, ctx, index) {
128
162
  const match = line.match(/^:bind\s+([A-Za-z_$][\w$]*)\s*(.*)$/);
129
163
  if (!match)
130
- throw new Error(
131
- `Malformed :bind in ${at(ctx, index)}: ${line}. Use: :bind query placeholder="Search"`
132
- );
164
+ throw wdError(`Malformed :bind in ${at(ctx, index)}: ${line}. Use: ${BIND_EXAMPLE}`, {
165
+ code: "WD405",
166
+ file: ctx.file,
167
+ line: lineOf(ctx, index),
168
+ hint: BIND_EXAMPLE,
169
+ example: BIND_EXAMPLE
170
+ });
133
171
  const key = resolveStateKey(match[1], ctx);
134
172
  if (!key) {
135
- throw new Error(
136
- `:bind ${match[1]} in ${ctx.file} has no matching state. Declare it first: :state ${match[1]} = ""`
173
+ throw wdError(
174
+ `:bind ${match[1]} in ${ctx.file} has no matching state. Declare it first: :state ${match[1]} = ""`,
175
+ { code: "WD406", file: ctx.file }
137
176
  );
138
177
  }
139
178
  ctx.comp.assets.runtime = true;
@@ -145,7 +184,11 @@ export function handleBind(line, ctx, index) {
145
184
  for (const token of (match[2] || "").matchAll(re)) {
146
185
  if (token[3]) {
147
186
  if (!["required", "autofocus"].includes(token[3]))
148
- throw new Error(`Unknown :bind flag "${token[3]}" in ${at(ctx, index)}`);
187
+ throw wdError(`Unknown :bind flag "${token[3]}" in ${at(ctx, index)}`, {
188
+ code: "WD407",
189
+ file: ctx.file,
190
+ line: lineOf(ctx, index)
191
+ });
149
192
  attrs.push(token[3]);
150
193
  continue;
151
194
  }
@@ -155,7 +198,11 @@ export function handleBind(line, ctx, index) {
155
198
  continue;
156
199
  }
157
200
  if (!["placeholder", "autocomplete", "aria-label", "aria-describedby"].includes(token[1])) {
158
- throw new Error(`Unknown :bind attribute "${token[1]}" in ${at(ctx, index)}`);
201
+ throw wdError(`Unknown :bind attribute "${token[1]}" in ${at(ctx, index)}`, {
202
+ code: "WD408",
203
+ file: ctx.file,
204
+ line: lineOf(ctx, index)
205
+ });
159
206
  }
160
207
  if (token[1] === "placeholder") placeholder = value;
161
208
  if (token[1] === "aria-label" || token[1] === "aria-describedby") hasAria = true;
@@ -187,9 +234,13 @@ export function handleBind(line, ctx, index) {
187
234
  export function handleSlider(line, ctx, index) {
188
235
  const head = line.match(/^:slider\s+([A-Za-z_$][\w$]*)\s*(.*)$/);
189
236
  if (!head)
190
- throw new Error(
191
- `Malformed :slider in ${at(ctx, index)}: ${line}. Use: :slider volume = 50 min=0 max=100 step=1`
192
- );
237
+ throw wdError(`Malformed :slider in ${at(ctx, index)}: ${line}. Use: ${SLIDER_EXAMPLE}`, {
238
+ code: "WD409",
239
+ file: ctx.file,
240
+ line: lineOf(ctx, index),
241
+ hint: SLIDER_EXAMPLE,
242
+ example: SLIDER_EXAMPLE
243
+ });
193
244
  const name = head[1];
194
245
  let rest = head[2].trim();
195
246
 
@@ -198,8 +249,9 @@ export function handleSlider(line, ctx, index) {
198
249
  if (rest.startsWith("=")) {
199
250
  const valueMatch = rest.match(/^=\s*(\S+)\s*(.*)$/);
200
251
  if (!valueMatch)
201
- throw new Error(
202
- `Malformed :slider initial value in ${at(ctx, index)}: ${line}. Use: :slider ${name} = 50`
252
+ throw wdError(
253
+ `Malformed :slider initial value in ${at(ctx, index)}: ${line}. Use: :slider ${name} = 50`,
254
+ { code: "WD410", file: ctx.file, line: lineOf(ctx, index) }
203
255
  );
204
256
  initialRaw = valueMatch[1];
205
257
  rest = valueMatch[2].trim();
@@ -221,7 +273,12 @@ export function handleSlider(line, ctx, index) {
221
273
  else if (token[1] === "max") max = value;
222
274
  else if (token[1] === "step") step = value;
223
275
  else if (token[1] === "aria-label") ariaLabel = value;
224
- else throw new Error(`Unknown :slider attribute "${token[1]}" in ${at(ctx, index)}`);
276
+ else
277
+ throw wdError(`Unknown :slider attribute "${token[1]}" in ${at(ctx, index)}`, {
278
+ code: "WD411",
279
+ file: ctx.file,
280
+ line: lineOf(ctx, index)
281
+ });
225
282
  }
226
283
  for (const [label, raw] of [
227
284
  ["min", min],
@@ -229,7 +286,11 @@ export function handleSlider(line, ctx, index) {
229
286
  ["step", step]
230
287
  ]) {
231
288
  if (!/^-?\d+(?:\.\d+)?$/.test(raw))
232
- throw new Error(`:slider ${label} must be a number in ${at(ctx, index)}: ${raw}`);
289
+ throw wdError(`:slider ${label} must be a number in ${at(ctx, index)}: ${raw}`, {
290
+ code: "WD412",
291
+ file: ctx.file,
292
+ line: lineOf(ctx, index)
293
+ });
233
294
  }
234
295
 
235
296
  ctx.comp.assets.runtime = true;
@@ -239,21 +300,24 @@ export function handleSlider(line, ctx, index) {
239
300
  if (initialRaw !== undefined) {
240
301
  const value = parseStateValue(initialRaw, at(ctx, index));
241
302
  if (typeof value !== "number")
242
- throw new Error(
243
- `:slider ${name} initial value must be a number in ${at(ctx, index)}: ${initialRaw}`
303
+ throw wdError(
304
+ `:slider ${name} initial value must be a number in ${at(ctx, index)}: ${initialRaw}`,
305
+ { code: "WD413", file: ctx.file, line: lineOf(ctx, index) }
244
306
  );
245
307
  key = declareState(name, value, ctx);
246
308
  const persistAttr = persist ? ` data-wd-persist="${key}"` : "";
247
309
  seed = `<script type="application/json" data-wd-state${persistAttr}>${safeScriptJson({ [key]: value })}</script>`;
248
310
  } else {
249
311
  if (persist)
250
- throw new Error(
251
- `:slider persist only applies when declaring state inline (:slider ${name} = 0 … persist) in ${at(ctx, index)}`
312
+ throw wdError(
313
+ `:slider persist only applies when declaring state inline (:slider ${name} = 0 … persist) in ${at(ctx, index)}`,
314
+ { code: "WD414", file: ctx.file, line: lineOf(ctx, index) }
252
315
  );
253
316
  const resolved = resolveStateKey(name, ctx);
254
317
  if (!resolved)
255
- throw new Error(
256
- `:slider ${name} in ${ctx.file} has no matching state. Declare it: :slider ${name} = 0 min=0 max=100`
318
+ throw wdError(
319
+ `:slider ${name} in ${ctx.file} has no matching state. Declare it: :slider ${name} = 0 min=0 max=100`,
320
+ { code: "WD415", file: ctx.file }
257
321
  );
258
322
  key = resolved;
259
323
  }
@@ -274,7 +338,16 @@ export function handleSlider(line, ctx, index) {
274
338
  export function handleSubmit(line, ctx, index) {
275
339
  const match = line.match(/^:submit\s+"([^"]+)"\s*$/);
276
340
  if (!match)
277
- throw new Error(`Malformed :submit in ${at(ctx, index)}: ${line}. Use: :submit "Label"`);
341
+ throw wdError(
342
+ `Malformed :submit in ${at(ctx, index)}: ${line}. Use: :submit "Label" — e.g. ${SUBMIT_EXAMPLE}`,
343
+ {
344
+ code: "WD416",
345
+ file: ctx.file,
346
+ line: lineOf(ctx, index),
347
+ hint: `:submit "Label" — e.g. ${SUBMIT_EXAMPLE}`,
348
+ example: SUBMIT_EXAMPLE
349
+ }
350
+ );
278
351
  return `<button type="submit">${escapeHtml(match[1])}</button>`;
279
352
  }
280
353
 
@@ -290,10 +363,16 @@ export function handleSubmit(line, ctx, index) {
290
363
  */
291
364
  export function handleTextarea(line, ctx, index) {
292
365
  const match = line.match(/^:textarea\s+([A-Za-z_][\w-]*)\s*(.*)$/);
293
- if (!match)
294
- throw new Error(
295
- `Malformed :textarea in ${at(ctx, index)}: ${line}. Use: :textarea name [placeholder="…"] [rows=N] [required]`
296
- );
366
+ if (!match) {
367
+ const hint = `:textarea name [placeholder="…"] [rows=N] [required] — e.g. ${TEXTAREA_EXAMPLE}`;
368
+ throw wdError(`Malformed :textarea in ${at(ctx, index)}: ${line}. Use: ${hint}`, {
369
+ code: "WD417",
370
+ file: ctx.file,
371
+ line: lineOf(ctx, index),
372
+ hint,
373
+ example: TEXTAREA_EXAMPLE
374
+ });
375
+ }
297
376
  const attrs = [`name="${escapeHtml(match[1])}"`];
298
377
  let placeholder;
299
378
  let hasAria = false;
@@ -301,7 +380,11 @@ export function handleTextarea(line, ctx, index) {
301
380
  for (const token of (match[2] || "").matchAll(re)) {
302
381
  if (token[3]) {
303
382
  if (!["required", "autofocus", "disabled", "readonly"].includes(token[3])) {
304
- throw new Error(`Unknown :textarea flag "${token[3]}" in ${at(ctx, index)}`);
383
+ throw wdError(`Unknown :textarea flag "${token[3]}" in ${at(ctx, index)}`, {
384
+ code: "WD418",
385
+ file: ctx.file,
386
+ line: lineOf(ctx, index)
387
+ });
305
388
  }
306
389
  attrs.push(token[3]);
307
390
  continue;
@@ -319,7 +402,11 @@ export function handleTextarea(line, ctx, index) {
319
402
  "aria-describedby"
320
403
  ].includes(token[1])
321
404
  ) {
322
- throw new Error(`Unknown :textarea attribute "${token[1]}" in ${at(ctx, index)}`);
405
+ throw wdError(`Unknown :textarea attribute "${token[1]}" in ${at(ctx, index)}`, {
406
+ code: "WD419",
407
+ file: ctx.file,
408
+ line: lineOf(ctx, index)
409
+ });
323
410
  }
324
411
  if (token[1] === "placeholder") placeholder = value;
325
412
  if (token[1] === "aria-label" || token[1] === "aria-describedby") hasAria = true;
@@ -343,24 +430,38 @@ export function handleTextarea(line, ctx, index) {
343
430
  */
344
431
  export function handleSelect(line, optionLines, ctx, index) {
345
432
  const match = line.match(/^:select\s+([A-Za-z_][\w-]*)\s*(.*)$/);
346
- if (!match)
347
- throw new Error(
348
- `Malformed :select in ${at(ctx, index)}: ${line}. Use: :select name [required] then "- Label" lines`
349
- );
433
+ if (!match) {
434
+ const hint = `:select name [required] then "- Label" lines — e.g. ${SELECT_EXAMPLE}`;
435
+ throw wdError(`Malformed :select in ${at(ctx, index)}: ${line}. Use: ${hint}`, {
436
+ code: "WD420",
437
+ file: ctx.file,
438
+ line: lineOf(ctx, index),
439
+ hint,
440
+ example: SELECT_EXAMPLE
441
+ });
442
+ }
350
443
  const attrs = [`name="${escapeHtml(match[1])}"`];
351
444
  let hasAria = false;
352
445
  const re = /([A-Za-z-]+)=("[^"]*"|\S+)|([A-Za-z-]+)/g;
353
446
  for (const token of (match[2] || "").matchAll(re)) {
354
447
  if (token[3]) {
355
448
  if (!["required", "disabled", "autofocus"].includes(token[3])) {
356
- throw new Error(`Unknown :select flag "${token[3]}" in ${at(ctx, index)}`);
449
+ throw wdError(`Unknown :select flag "${token[3]}" in ${at(ctx, index)}`, {
450
+ code: "WD421",
451
+ file: ctx.file,
452
+ line: lineOf(ctx, index)
453
+ });
357
454
  }
358
455
  attrs.push(token[3]);
359
456
  continue;
360
457
  }
361
458
  const value = stripQuotes(token[2]);
362
459
  if (!["autocomplete", "aria-label", "aria-describedby"].includes(token[1])) {
363
- throw new Error(`Unknown :select attribute "${token[1]}" in ${at(ctx, index)}`);
460
+ throw wdError(`Unknown :select attribute "${token[1]}" in ${at(ctx, index)}`, {
461
+ code: "WD422",
462
+ file: ctx.file,
463
+ line: lineOf(ctx, index)
464
+ });
364
465
  }
365
466
  if (token[1] === "aria-label" || token[1] === "aria-describedby") hasAria = true;
366
467
  attrs.push(`${token[1]}="${escapeHtml(value)}"`);
@@ -370,8 +471,9 @@ export function handleSelect(line, optionLines, ctx, index) {
370
471
  }
371
472
  const options = optionLines.map((l) => l.replace(/^\s*-\s+/, "").trim()).filter(Boolean);
372
473
  if (!options.length) {
373
- throw new Error(
374
- `:select "${match[1]}" in ${at(ctx, index)} has no options. Add "- Label" lines beneath it.`
474
+ throw wdError(
475
+ `:select "${match[1]}" in ${at(ctx, index)} has no options. Add "- Label" lines beneath it.`,
476
+ { code: "WD423", file: ctx.file, line: lineOf(ctx, index) }
375
477
  );
376
478
  }
377
479
  const opts = options
@@ -404,10 +506,17 @@ export function handleChoiceGroup(line, optionLines, ctx, kind, index) {
404
506
  ? /^:checkbox\s+([A-Za-z_][\w-]*)\s*(.*)$/
405
507
  : /^:radio\s+([A-Za-z_][\w-]*)\s*(.*)$/
406
508
  );
407
- if (!match)
408
- throw new Error(
409
- `Malformed ${directive} in ${at(ctx, index)}: ${line}. Use: ${directive} name [required] then "- Label" lines`
410
- );
509
+ if (!match) {
510
+ const example = kind === "checkbox" ? CHECKBOX_EXAMPLE : RADIO_EXAMPLE;
511
+ const hint = `${directive} name [required] then "- Label" lines — e.g. ${example}`;
512
+ throw wdError(`Malformed ${directive} in ${at(ctx, index)}: ${line}. Use: ${hint}`, {
513
+ code: "WD424",
514
+ file: ctx.file,
515
+ line: lineOf(ctx, index),
516
+ hint,
517
+ example
518
+ });
519
+ }
411
520
  const name = match[1];
412
521
  const flags = [];
413
522
  let ariaLabel = null;
@@ -416,7 +525,11 @@ export function handleChoiceGroup(line, optionLines, ctx, kind, index) {
416
525
  for (const token of (match[2] || "").matchAll(re)) {
417
526
  if (token[3]) {
418
527
  if (!["required", "disabled", "autofocus"].includes(token[3])) {
419
- throw new Error(`Unknown ${directive} flag "${token[3]}" in ${at(ctx, index)}`);
528
+ throw wdError(`Unknown ${directive} flag "${token[3]}" in ${at(ctx, index)}`, {
529
+ code: "WD425",
530
+ file: ctx.file,
531
+ line: lineOf(ctx, index)
532
+ });
420
533
  }
421
534
  flags.push(token[3]);
422
535
  continue;
@@ -424,12 +537,18 @@ export function handleChoiceGroup(line, optionLines, ctx, kind, index) {
424
537
  const value = stripQuotes(token[2]);
425
538
  if (token[1] === "aria-label") ariaLabel = value;
426
539
  else if (token[1] === "aria-describedby") ariaDescribedby = value;
427
- else throw new Error(`Unknown ${directive} attribute "${token[1]}" in ${at(ctx, index)}`);
540
+ else
541
+ throw wdError(`Unknown ${directive} attribute "${token[1]}" in ${at(ctx, index)}`, {
542
+ code: "WD426",
543
+ file: ctx.file,
544
+ line: lineOf(ctx, index)
545
+ });
428
546
  }
429
547
  const options = optionLines.map((l) => l.replace(/^\s*-\s+/, "").trim()).filter(Boolean);
430
548
  if (!options.length) {
431
- throw new Error(
432
- `${directive} "${name}" in ${at(ctx, index)} has no options. Add "- Label" lines beneath it.`
549
+ throw wdError(
550
+ `${directive} "${name}" in ${at(ctx, index)} has no options. Add "- Label" lines beneath it.`,
551
+ { code: "WD427", file: ctx.file, line: lineOf(ctx, index) }
433
552
  );
434
553
  }
435
554
  const isCheckbox = kind === "checkbox";
@@ -3,6 +3,7 @@
3
3
  // values, and warn when a file looks like it forgot the opening `---`.
4
4
  // ---------------------------------------------------------------------------
5
5
 
6
+ import { normalizeNewlines, wdError } from "./context.js";
6
7
  import { stripQuotes } from "./interpolation.js";
7
8
 
8
9
  /**
@@ -20,12 +21,20 @@ import { stripQuotes } from "./interpolation.js";
20
21
  * @returns {{ meta: Meta, body: string, bodyLine: number }}
21
22
  */
22
23
  export function parseFrontmatter(raw, file) {
24
+ // A file authored on Windows (or checked out with git's autocrlf) arrives
25
+ // CRLF-terminated, and every delimiter/line test below is LF-shaped — so
26
+ // without this the opening `---\r\n` never matches and the whole frontmatter
27
+ // block is silently treated as body text. Normalizing here rather than only
28
+ // in the reader keeps the direct callers (builder.js reads route frontmatter
29
+ // straight off disk for feeds) on the same footing. Idempotent on LF input.
30
+ raw = normalizeNewlines(raw);
23
31
  if (!raw.startsWith("---\n")) return { meta: {}, body: raw, bodyLine: 0 };
24
32
  const end = raw.indexOf("\n---", 3);
25
33
  if (end === -1) {
26
34
  const where = file ? ` in ${file}` : "";
27
- throw new Error(
28
- `Unterminated frontmatter${where}: opening "---" has no closing "---". Use: --- on its own line to open and another --- to close, then the page body.`
35
+ throw wdError(
36
+ `Unterminated frontmatter${where}: opening "---" has no closing "---". Use: --- on its own line to open and another --- to close, then the page body.`,
37
+ { code: "WD001", file }
29
38
  );
30
39
  }
31
40
  const front = raw.slice(4, end).trim();
@@ -8,6 +8,7 @@
8
8
  // ---------------------------------------------------------------------------
9
9
 
10
10
  import fs from "node:fs";
11
+ import { normalizeNewlines } from "./context.js";
11
12
 
12
13
  /**
13
14
  * A {@link import("./reader.js").Reader} backed by `node:fs`. Each method is a
@@ -17,7 +18,7 @@ import fs from "node:fs";
17
18
  */
18
19
  export function fsReader() {
19
20
  return {
20
- readText: (absPath) => fs.readFileSync(absPath, "utf8"),
21
+ readText: (absPath) => normalizeNewlines(fs.readFileSync(absPath, "utf8")),
21
22
  readBinary: (absPath) => fs.readFileSync(absPath),
22
23
  exists: (absPath) => fs.existsSync(absPath),
23
24
  realpath: (absPath) => fs.realpathSync(absPath)