@zvndev/darkmown 2.2.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 (105) 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 +647 -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 +48 -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/src/api-runner.d.ts +94 -0
  69. package/types/src/builder.d.ts +170 -0
  70. package/types/src/catalog.d.ts +97 -0
  71. package/types/src/compiler/actions.d.ts +41 -0
  72. package/types/src/compiler/body.d.ts +11 -0
  73. package/types/src/compiler/collections.d.ts +76 -0
  74. package/types/src/compiler/context.d.ts +647 -0
  75. package/types/src/compiler/directives.d.ts +6 -0
  76. package/types/src/compiler/expr-ast.d.ts +20 -0
  77. package/types/src/compiler/fetch.d.ts +29 -0
  78. package/types/src/compiler/format.d.ts +49 -0
  79. package/types/src/compiler/forms.d.ts +94 -0
  80. package/types/src/compiler/frontmatter.d.ts +33 -0
  81. package/types/src/compiler/fs-reader.d.ts +7 -0
  82. package/types/src/compiler/image-size.d.ts +14 -0
  83. package/types/src/compiler/includes.d.ts +100 -0
  84. package/types/src/compiler/index.d.ts +5 -0
  85. package/types/src/compiler/interpolation.d.ts +115 -0
  86. package/types/src/compiler/loops.d.ts +36 -0
  87. package/types/src/compiler/markdown.d.ts +34 -0
  88. package/types/src/compiler/media.d.ts +32 -0
  89. package/types/src/compiler/page.d.ts +109 -0
  90. package/types/src/compiler/predicates.d.ts +50 -0
  91. package/types/src/compiler/reader.d.ts +48 -0
  92. package/types/src/compiler/state.d.ts +68 -0
  93. package/types/src/compiler/structure.d.ts +56 -0
  94. package/types/src/compiler.d.ts +48 -0
  95. package/types/src/config.d.ts +45 -0
  96. package/types/src/dev.d.ts +58 -0
  97. package/types/src/errors.d.ts +100 -0
  98. package/types/src/feeds.d.ts +150 -0
  99. package/types/src/headers.d.ts +44 -0
  100. package/types/src/highlight.d.ts +24 -0
  101. package/types/src/router.d.ts +78 -0
  102. package/types/src/scaffold.d.ts +20 -0
  103. package/types/src/skin.d.ts +48 -0
  104. package/types/src/statics.d.ts +24 -0
  105. package/types/statics.d.ts +2 -1
@@ -7,7 +7,7 @@
7
7
 
8
8
  import path from "node:path";
9
9
  import { scopeIdFor } from "../skin.js";
10
- import { pageIncludeExtensions } from "./context.js";
10
+ import { pageIncludeExtensions, wdError } from "./context.js";
11
11
  import { stripQuotes } from "./interpolation.js";
12
12
 
13
13
  /**
@@ -181,14 +181,18 @@ export function resolveInclude(spec, fromFile, context, allowAny, loc, reader) {
181
181
  for (const candidate of candidates) {
182
182
  const resolved = path.resolve(candidate);
183
183
  if (!isAllowedInclude(resolved, context)) {
184
- throw new Error(`Include "${spec}" from ${loc} resolves outside site/pages or site/_`);
184
+ throw wdError(`Include "${spec}" from ${loc} resolves outside site/pages or site/_`, {
185
+ code: "WD601",
186
+ file: fromFile
187
+ });
185
188
  }
186
189
  if (!reader.exists(resolved)) continue;
187
190
  if (!allowAny && !pageIncludeExtensions.includes(path.extname(resolved))) continue;
188
191
  return resolved;
189
192
  }
190
- throw new Error(
191
- `Could not resolve include "${spec}" from ${loc}. Looked in site/pages and site/_ — check the path and leading slash.`
193
+ throw wdError(
194
+ `Could not resolve include "${spec}" from ${loc}. Looked in site/pages and site/_ — check the path and leading slash.`,
195
+ { code: "WD602", file: fromFile }
192
196
  );
193
197
  }
194
198
 
@@ -1,10 +1,12 @@
1
1
  // ---------------------------------------------------------------------------
2
2
  // Interpolation primitives: path/value resolution, state-key lookup, scalar
3
3
  // parsing, and HTML escaping. These are the leaf utilities the directive
4
- // handlers, loops, predicates, and markdown layer all build on — this module
5
- // imports only the shared typedefs, so it sits near the root of the DAG.
4
+ // handlers, loops, predicates, and markdown layer all build on — it imports
5
+ // only `context.js` (which imports nothing), so it sits at the root of the DAG.
6
6
  // ---------------------------------------------------------------------------
7
7
 
8
+ import { wdError } from "./context.js";
9
+
8
10
  /**
9
11
  * @typedef {import("./context.js").Scope} Scope
10
12
  * @typedef {import("./context.js").Ctx} Ctx
@@ -66,7 +68,11 @@ export function getPath(value, segments) {
66
68
  export function validatePath(path, ctx, use) {
67
69
  const segs = path.split(".");
68
70
  if (segs.some((seg) => ["constructor", "prototype", "__proto__"].includes(seg))) {
69
- throw new Error(`Path segment in "${path}" is not allowed in ${ctx.file}. Use: ${use}`);
71
+ throw wdError(`Path segment in "${path}" is not allowed in ${ctx.file}. Use: ${use}`, {
72
+ code: "WD002",
73
+ file: ctx.file,
74
+ hint: use
75
+ });
70
76
  }
71
77
  return segs;
72
78
  }
@@ -98,8 +104,9 @@ export function interpolateLeaf(value, expr, ctx) {
98
104
  if (value === null || value === undefined) return "";
99
105
  if (Array.isArray(value)) return value.join(", ");
100
106
  if (typeof value === "object") {
101
- throw new Error(
102
- `Cannot interpolate the object value "{ ${expr} }" in ${ctx.file}. Interpolate a field instead — e.g. { ${expr}.title } — or iterate it with @loop ${expr} into item.`
107
+ throw wdError(
108
+ `Cannot interpolate the object value "{ ${expr} }" in ${ctx.file}. Interpolate a field instead — e.g. { ${expr}.title } — or iterate it with @loop ${expr} into item.`,
109
+ { code: "WD003", file: ctx.file }
103
110
  );
104
111
  }
105
112
  return String(value);
@@ -143,11 +150,12 @@ export function parseStateValue(raw, where) {
143
150
  const open = value[0];
144
151
  const close = open === "[" ? "]" : "}";
145
152
  const shown = value.length > 60 ? `${value.slice(0, 57)}…` : value;
146
- throw new Error(
153
+ throw wdError(
147
154
  `Unbalanced JSON value "${shown}"${where ? ` in ${where}` : ""}: this opens "${open}" but ` +
148
155
  `never closes it. :state/:store/:theme accept a multi-line array/object, but the literal ` +
149
156
  `must balance (a "${close}" is missing) with no blank lines inside it. ` +
150
- `Quote it for literal bracket text (e.g. = "[draft]").`
157
+ `Quote it for literal bracket text (e.g. = "[draft]").`,
158
+ { code: "WD004" }
151
159
  );
152
160
  }
153
161
  return value;
@@ -61,7 +61,10 @@ function parseLoopClauses(tail, itemName, ctx) {
61
61
  /** @type {number|null} */ let paginate = null;
62
62
  /** @returns {never} */
63
63
  const bad = () => {
64
- throw new Error(`Malformed @loop clause in ${ctx.file}: "${tail.trim()}". ${LOOP_USAGE}`);
64
+ throw wdError(`Malformed @loop clause in ${ctx.file}: "${tail.trim()}". ${LOOP_USAGE}`, {
65
+ code: "WD102",
66
+ file: ctx.file
67
+ });
65
68
  };
66
69
 
67
70
  // `sortable` is a position-independent flag (drag-reorder); peel it wherever it
@@ -152,20 +155,25 @@ function parseSortClause(fieldTok, dirTok, itemName, ctx) {
152
155
  if (fieldState) {
153
156
  const resolved = resolveStateKey(fieldState[1], ctx);
154
157
  if (!resolved)
155
- throw new Error(
156
- `@loop sort by { ${fieldState[1]} } references unknown :state/:store in ${ctx.file}. ${LOOP_USAGE}`
158
+ throw wdError(
159
+ `@loop sort by { ${fieldState[1]} } references unknown :state/:store in ${ctx.file}. ${LOOP_USAGE}`,
160
+ { code: "WD103", file: ctx.file }
157
161
  );
158
162
  key = resolved;
159
163
  keyKind = "key";
160
164
  } else {
161
165
  const segs = fieldTok.split(".");
162
166
  if (segs[0] !== itemName)
163
- throw new Error(
164
- `@loop sort key "${fieldTok}" must start with the loop item "${itemName}" in ${ctx.file}. ${LOOP_USAGE}`
167
+ throw wdError(
168
+ `@loop sort key "${fieldTok}" must start with the loop item "${itemName}" in ${ctx.file}. ${LOOP_USAGE}`,
169
+ { code: "WD104", file: ctx.file }
165
170
  );
166
171
  const rest = segs.slice(1);
167
172
  if (rest.some((seg) => ["constructor", "prototype", "__proto__"].includes(seg)))
168
- throw new Error(`Sort key "${fieldTok}" is not allowed in @loop (${ctx.file})`);
173
+ throw wdError(`Sort key "${fieldTok}" is not allowed in @loop (${ctx.file})`, {
174
+ code: "WD105",
175
+ file: ctx.file
176
+ });
169
177
  key = rest.join(".");
170
178
  keyKind = "literal";
171
179
  }
@@ -177,8 +185,9 @@ function parseSortClause(fieldTok, dirTok, itemName, ctx) {
177
185
  if (dirState) {
178
186
  const resolved = resolveStateKey(dirState[1], ctx);
179
187
  if (!resolved)
180
- throw new Error(
181
- `@loop sort direction { ${dirState[1]} } references unknown :state/:store in ${ctx.file}. ${LOOP_USAGE}`
188
+ throw wdError(
189
+ `@loop sort direction { ${dirState[1]} } references unknown :state/:store in ${ctx.file}. ${LOOP_USAGE}`,
190
+ { code: "WD106", file: ctx.file }
182
191
  );
183
192
  dir = resolved;
184
193
  dirKind = "key";
@@ -201,8 +210,9 @@ function parseNumArg(tok, ctx) {
201
210
  if (/^\d+$/.test(tok)) return { kind: "literal", value: Number(tok) };
202
211
  const key = resolveStateKey(tok, ctx);
203
212
  if (!key)
204
- throw new Error(
205
- `@loop offset/limit "${tok}" in ${ctx.file} is neither a non-negative integer nor a declared :state. ${LOOP_USAGE}`
213
+ throw wdError(
214
+ `@loop offset/limit "${tok}" in ${ctx.file} is neither a non-negative integer nor a declared :state. ${LOOP_USAGE}`,
215
+ { code: "WD107", file: ctx.file }
206
216
  );
207
217
  return { kind: "key", value: key };
208
218
  }
@@ -226,6 +236,7 @@ export function handleLoop(line, bodyLines, emptyLines, ctx, index, emptyStart =
226
236
  const match = line.match(/^@loop\s+(.+?)\s+into\s+([A-Za-z_$][\w$]*)(\s+.+?)?\s*$/);
227
237
  if (!match)
228
238
  throw wdError(`Malformed @loop in ${at(ctx, index)}: ${line}. ${LOOP_USAGE}`, {
239
+ code: "WD101",
229
240
  file: ctx.file,
230
241
  line: lineOf(ctx, index),
231
242
  hint: LOOP_USAGE.slice("Use: ".length),
@@ -258,16 +269,18 @@ export function handleLoop(line, bodyLines, emptyLines, ctx, index, emptyStart =
258
269
  clauses.limit ||
259
270
  clauses.paginate)
260
271
  ) {
261
- throw new Error(
262
- `@loop sortable cannot combine with where/sort/reverse/offset/limit/paginate in ${ctx.file}. ${LOOP_USAGE}`
272
+ throw wdError(
273
+ `@loop sortable cannot combine with where/sort/reverse/offset/limit/paginate in ${ctx.file}. ${LOOP_USAGE}`,
274
+ { code: "WD108", file: ctx.file }
263
275
  );
264
276
  }
265
277
  // `paginate N` OWNS the page slice, so an explicit offset/limit alongside it is
266
278
  // a conflict (which one wins?). Reject it with a clear hint rather than silently
267
279
  // letting one override the other.
268
280
  if (clauses.paginate && (clauses.offset || clauses.limit)) {
269
- throw new Error(
270
- `@loop paginate cannot combine with offset/limit in ${ctx.file} — paginate already slices each page. ${LOOP_USAGE}`
281
+ throw wdError(
282
+ `@loop paginate cannot combine with offset/limit in ${ctx.file} — paginate already slices each page. ${LOOP_USAGE}`,
283
+ { code: "WD109", file: ctx.file }
271
284
  );
272
285
  }
273
286
  /** @type {LoopOpts} */
@@ -298,7 +311,7 @@ export function handleLoop(line, bodyLines, emptyLines, ctx, index, emptyStart =
298
311
  source.startsWith("../") ||
299
312
  source.endsWith(".json")
300
313
  ) {
301
- if (opts.paginate) throw new Error(paginateOnlyCollections(ctx));
314
+ if (opts.paginate) throw paginateOnlyCollections(ctx);
302
315
  const dataFile = resolveInclude(
303
316
  source,
304
317
  ctx.file,
@@ -309,23 +322,28 @@ export function handleLoop(line, bodyLines, emptyLines, ctx, index, emptyStart =
309
322
  );
310
323
  ctx.comp.deps.add(dataFile);
311
324
  const rows = JSON.parse(ctx.comp.reader.readText(dataFile));
312
- if (!Array.isArray(rows)) throw new Error(`@loop data must be a JSON array: ${dataFile}`);
325
+ if (!Array.isArray(rows))
326
+ throw wdError(`@loop data must be a JSON array: ${dataFile}`, {
327
+ code: "WD111",
328
+ file: ctx.file
329
+ });
313
330
  return loopOverData(rows, itemName, bodyLines, bodyCtx, opts);
314
331
  }
315
332
 
316
333
  const resolved = lookupPath(source, ctx);
317
334
  if (resolved.found) {
318
- if (opts.paginate) throw new Error(paginateOnlyCollections(ctx));
335
+ if (opts.paginate) throw paginateOnlyCollections(ctx);
319
336
  // A missing/unset field (e.g. an optional frontmatter key) is an EMPTY
320
337
  // list, not an error — the `@empty` branch renders, matching the runtime.
321
338
  if (resolved.value === null || resolved.value === undefined) {
322
339
  return loopOverData([], itemName, bodyLines, bodyCtx, opts);
323
340
  }
324
341
  if (!Array.isArray(resolved.value)) {
325
- throw new Error(
342
+ throw wdError(
326
343
  `@loop ${source} in ${at(ctx, index)} found an in-scope value, but it is not a list ` +
327
344
  `(got ${typeof resolved.value}). Use: a list value (e.g. \`tags: [a, b]\` in frontmatter) — ` +
328
- `or leave the field out entirely, which loops zero rows and renders the @empty branch.`
345
+ `or leave the field out entirely, which loops zero rows and renders the @empty branch.`,
346
+ { code: "WD112", file: ctx.file, line: lineOf(ctx, index) }
329
347
  );
330
348
  }
331
349
  return loopOverData(resolved.value, itemName, bodyLines, bodyCtx, opts);
@@ -335,11 +353,14 @@ export function handleLoop(line, bodyLines, emptyLines, ctx, index, emptyStart =
335
353
  const segs = source.split(".");
336
354
  if (/^[A-Za-z_$][\w$]*(?:\.[A-Za-z_$][\w$]*)*$/.test(source)) {
337
355
  if (segs.some((seg) => ["constructor", "prototype", "__proto__"].includes(seg))) {
338
- throw new Error(`@loop source "${source}" is not allowed in ${ctx.file}`);
356
+ throw wdError(`@loop source "${source}" is not allowed in ${ctx.file}`, {
357
+ code: "WD113",
358
+ file: ctx.file
359
+ });
339
360
  }
340
361
  const key = resolveStateKey(segs[0], ctx);
341
362
  if (key) {
342
- if (opts.paginate) throw new Error(paginateOnlyCollections(ctx));
363
+ if (opts.paginate) throw paginateOnlyCollections(ctx);
343
364
  const fullKey = segs.length > 1 ? `${key}.${segs.slice(1).join(".")}` : key;
344
365
  return reactiveLoop(fullKey, itemName, bodyLines, bodyCtx, opts);
345
366
  }
@@ -355,16 +376,17 @@ export function handleLoop(line, bodyLines, emptyLines, ctx, index, emptyStart =
355
376
  // the enclosing row item — an ITEM-RELATIVE loop the runtime fills per row.
356
377
  if (ctx.loopItem && segs[0] === ctx.loopItem && segs.length > 1) {
357
378
  if (opts.sortable) {
358
- throw new Error(
359
- `@loop sortable is not supported on a nested (item-relative) loop in ${ctx.file}. ${LOOP_USAGE}`
379
+ throw wdError(
380
+ `@loop sortable is not supported on a nested (item-relative) loop in ${ctx.file}. ${LOOP_USAGE}`,
381
+ { code: "WD114", file: ctx.file }
360
382
  );
361
383
  }
362
- if (opts.paginate) throw new Error(paginateOnlyCollections(ctx));
384
+ if (opts.paginate) throw paginateOnlyCollections(ctx);
363
385
  return itemRelativeLoop(segs.slice(1).join("."), itemName, bodyLines, bodyCtx, opts);
364
386
  }
365
387
  }
366
388
 
367
- throw new Error(unresolvedSourceError(source, ctx));
389
+ throw unresolvedSourceError(source, ctx);
368
390
  }
369
391
 
370
392
  /**
@@ -373,15 +395,16 @@ export function handleLoop(line, bodyLines, emptyLines, ctx, index, emptyStart =
373
395
  * collection reference is immediately actionable.
374
396
  * @param {string} source
375
397
  * @param {Ctx} ctx
376
- * @returns {string}
398
+ * @returns {Error}
377
399
  */
378
400
  function unresolvedSourceError(source, ctx) {
379
401
  const names = [...ctx.comp.collections.keys()].sort();
380
402
  const collectionsHint = names.length ? ` Available collections: ${names.join(", ")}.` : "";
381
- return (
403
+ return wdError(
382
404
  `@loop source "${source}" in ${ctx.file} was not found. Loop over a collection ` +
383
- `(a site/pages/<name>/ subdirectory, by its bare name), a JSON file ` +
384
- `(@loop /data.json into row), an in-scope value, or a :state list.${collectionsHint}`
405
+ `(a site/pages/<name>/ subdirectory, by its bare name), a JSON file ` +
406
+ `(@loop /data.json into row), an in-scope value, or a :state list.${collectionsHint}`,
407
+ { code: "WD115", file: ctx.file }
385
408
  );
386
409
  }
387
410
 
@@ -389,10 +412,13 @@ function unresolvedSourceError(source, ctx) {
389
412
  * The error for `paginate` on a non-collection source: pagination multiplies
390
413
  * static routes, which only makes sense for a build-time collection listing.
391
414
  * @param {Ctx} ctx
392
- * @returns {string}
415
+ * @returns {Error}
393
416
  */
394
417
  function paginateOnlyCollections(ctx) {
395
- return `@loop paginate requires a collection source (a site/pages/<name>/ subdirectory) in ${ctx.file}. ${LOOP_USAGE}`;
418
+ return wdError(
419
+ `@loop paginate requires a collection source (a site/pages/<name>/ subdirectory) in ${ctx.file}. ${LOOP_USAGE}`,
420
+ { code: "WD110", file: ctx.file }
421
+ );
396
422
  }
397
423
 
398
424
  /**
@@ -410,9 +436,10 @@ function paginateOnlyCollections(ctx) {
410
436
  function assertReactiveDepth(ctx) {
411
437
  if ((ctx.reactiveDepth ?? 0) < 2) return;
412
438
  const opener = ctx.loopOpener ?? { at: ctx.file, line: "" };
413
- throw new Error(
439
+ throw wdError(
414
440
  `Reactive @loop nesting is limited to one inner level in ${opener.at}: "${opener.line}". ` +
415
- `Unroll the outer data at build time (JSON/frontmatter source) or move the innermost list into build-time data.`
441
+ `Unroll the outer data at build time (JSON/frontmatter source) or move the innermost list into build-time data.`,
442
+ { code: "WD116", file: ctx.file }
416
443
  );
417
444
  }
418
445
 
@@ -522,8 +549,9 @@ function pipelineRows(rows, where, opts, ctx) {
522
549
  */
523
550
  function loopOverData(rows, itemName, bodyLines, ctx, opts) {
524
551
  if (opts.sortable) {
525
- throw new Error(
526
- `@loop sortable requires a :state or :store list to reorder, not a JSON file or in-scope value, in ${ctx.file}. ${LOOP_USAGE}`
552
+ throw wdError(
553
+ `@loop sortable requires a :state or :store list to reorder, not a JSON file or in-scope value, in ${ctx.file}. ${LOOP_USAGE}`,
554
+ { code: "WD117", file: ctx.file }
527
555
  );
528
556
  }
529
557
  // Reactive when the where reads state OR an offset/limit references a state key.
@@ -7,7 +7,7 @@
7
7
 
8
8
  import MarkdownIt from "markdown-it";
9
9
  import { highlightCode } from "../highlight.js";
10
- import { LOOP_META } from "./context.js";
10
+ import { LOOP_META, wdError } from "./context.js";
11
11
  import { applyPipeline, fmtAttr, validatePipes } from "./format.js";
12
12
  import {
13
13
  escapeHtml,
@@ -266,8 +266,9 @@ function resolveBindingHtml(expr, ctx) {
266
266
  // Per-row meta vars ($index/$number/$first/$last/$count) — only inside a loop.
267
267
  if (LOOP_META[head]) {
268
268
  if (!ctx.loopMeta)
269
- throw new Error(
270
- `"{ ${expr} }" uses the loop meta variable "${head}" outside a @loop in ${ctx.file}. Move it into a loop body, or rename your value.`
269
+ throw wdError(
270
+ `"{ ${expr} }" uses the loop meta variable "${head}" outside a @loop in ${ctx.file}. Move it into a loop body, or rename your value.`,
271
+ { code: "WD005", file: ctx.file }
271
272
  );
272
273
  if (ctx.loopItem) return `<span data-wd-each-meta="${LOOP_META[head]}"${fmt}></span>`; // reactive: filled per row
273
274
  // static: the value is in scope (injected by staticUnroll); fall through.
@@ -49,6 +49,7 @@ export function handleMedia(line, kind, ctx, index) {
49
49
  const example = kind === "video" ? VIDEO_EXAMPLE : AUDIO_EXAMPLE;
50
50
  const hint = `:${kind} /clip [controls] [autoplay] [loop] [muted] — e.g. ${example}`;
51
51
  throw wdError(`Malformed :${kind} in ${at(ctx, index)}: ${line}. Use: ${hint}`, {
52
+ code: "WD701",
52
53
  file: ctx.file,
53
54
  line: lineOf(ctx, index),
54
55
  hint,
@@ -65,11 +66,19 @@ export function handleMedia(line, kind, ctx, index) {
65
66
  for (const token of (match[2] || "").matchAll(re)) {
66
67
  if (token[3]) {
67
68
  if (!spec.flags.includes(token[3]))
68
- throw new Error(`Unknown :${kind} flag "${token[3]}" in ${at(ctx, index)}`);
69
+ throw wdError(`Unknown :${kind} flag "${token[3]}" in ${at(ctx, index)}`, {
70
+ code: "WD702",
71
+ file: ctx.file,
72
+ line: lineOf(ctx, index)
73
+ });
69
74
  flags.add(token[3]);
70
75
  } else {
71
76
  if (!spec.attrs.includes(token[1]))
72
- throw new Error(`Unknown :${kind} attribute "${token[1]}" in ${at(ctx, index)}`);
77
+ throw wdError(`Unknown :${kind} attribute "${token[1]}" in ${at(ctx, index)}`, {
78
+ code: "WD703",
79
+ file: ctx.file,
80
+ line: lineOf(ctx, index)
81
+ });
73
82
  attrs.set(token[1], stripQuotes(token[2]));
74
83
  }
75
84
  }
@@ -105,6 +114,7 @@ export function handleEmbed(line, ctx, index) {
105
114
  if (!match) {
106
115
  const hint = `:embed https://youtu.be/ID [title="…"] — e.g. ${EMBED_EXAMPLE}`;
107
116
  throw wdError(`Malformed :embed in ${at(ctx, index)}: ${line}. Use: ${hint}`, {
117
+ code: "WD704",
108
118
  file: ctx.file,
109
119
  line: lineOf(ctx, index),
110
120
  hint,
@@ -116,7 +126,11 @@ export function handleEmbed(line, ctx, index) {
116
126
  const re = /([A-Za-z-]+)=("[^"]*"|\S+)/g;
117
127
  for (const token of (match[2] || "").matchAll(re)) {
118
128
  if (token[1] !== "title")
119
- throw new Error(`Unknown :embed attribute "${token[1]}" in ${at(ctx, index)}`);
129
+ throw wdError(`Unknown :embed attribute "${token[1]}" in ${at(ctx, index)}`, {
130
+ code: "WD705",
131
+ file: ctx.file,
132
+ line: lineOf(ctx, index)
133
+ });
120
134
  title = stripQuotes(token[2]);
121
135
  }
122
136
  const { url, label } = resolveEmbed(raw, ctx);
@@ -6,12 +6,12 @@
6
6
  // ---------------------------------------------------------------------------
7
7
 
8
8
  import path from "node:path";
9
- import { imageSize } from "image-size";
10
9
  import { createPaths } from "../config.js";
11
10
  import { htmlHasHighlight } from "../highlight.js";
12
11
  import { compileBody } from "./body.js";
13
- import { createCompilation, createScope } from "./context.js";
12
+ import { createCompilation, createScope, wdError } from "./context.js";
14
13
  import { parseFrontmatter, warnLikelyFrontmatter } from "./frontmatter.js";
14
+ import { imageSize } from "./image-size.js";
15
15
  import {
16
16
  collectColocatedAssets,
17
17
  scanMarkdownHints,
@@ -279,8 +279,9 @@ function measureImage(src, paths, reader) {
279
279
  return null; // page-relative: the source directory is lost after assembly
280
280
  }
281
281
  try {
282
- const { width, height } = imageSize(reader.readBinary(filePath));
283
- if (typeof width === "number" && typeof height === "number") return { width, height };
282
+ // `imageSize` itself never throws (an unsupported or malformed image is just
283
+ // null); the try/catch is for the read, which does throw on a missing file.
284
+ return imageSize(reader.readBinary(filePath));
284
285
  } catch {
285
286
  /* missing or unreadable — degrade to no dimensions */
286
287
  }
@@ -363,8 +364,9 @@ export function compileFile(
363
364
  ) {
364
365
  const real = comp.reader.realpath(file);
365
366
  if (stack.includes(real)) {
366
- throw new Error(
367
- `Include cycle detected: ${[...stack, real].map((p) => path.basename(p)).join(" -> ")}`
367
+ throw wdError(
368
+ `[WD612] Include cycle detected: ${[...stack, real].map((p) => path.basename(p)).join(" -> ")}`,
369
+ { code: "WD612", file }
368
370
  );
369
371
  }
370
372
 
@@ -65,6 +65,7 @@ function compileCondition(cond, itemName, ctx) {
65
65
  if (!m) {
66
66
  const hint = `${itemName}.field contains state, or ${itemName}.field <op> value — e.g. ${itemName}.price < 50`;
67
67
  throw wdError(`Malformed where-condition "${cond}" in ${ctx.file}. Use: ${hint}`, {
68
+ code: "WD220",
68
69
  file: ctx.file,
69
70
  hint,
70
71
  example: `${itemName}.price < 50`
@@ -90,20 +91,25 @@ function compileOperand(tok, itemName, ctx) {
90
91
  if (/^-?\d+(?:\.\d+)?$/.test(tok)) return { code: tok, usesState: false };
91
92
  if (["true", "false", "null"].includes(tok)) return { code: tok, usesState: false };
92
93
  if (!/^[A-Za-z_$][\w$]*(?:\.[A-Za-z_$][\w$]*)*$/.test(tok)) {
93
- throw new Error(
94
- `Unsupported operand "${tok}" in @loop where (${ctx.file}). Use ${itemName}.field, a :state name, a number, or a "string".`
94
+ throw wdError(
95
+ `Unsupported operand "${tok}" in @loop where (${ctx.file}). Use ${itemName}.field, a :state name, a number, or a "string".`,
96
+ { code: "WD221", file: ctx.file }
95
97
  );
96
98
  }
97
99
  const segs = tok.split(".");
98
100
  if (segs.some((seg) => ["constructor", "prototype", "__proto__"].includes(seg))) {
99
- throw new Error(`Path "${tok}" is not allowed in @loop where (${ctx.file})`);
101
+ throw wdError(`Path "${tok}" is not allowed in @loop where (${ctx.file})`, {
102
+ code: "WD222",
103
+ file: ctx.file
104
+ });
100
105
  }
101
106
  if (segs[0] === itemName)
102
107
  return { code: `I(${JSON.stringify(segs.slice(1).join("."))})`, usesState: false };
103
108
  const key = resolveStateKey(segs[0], ctx);
104
109
  if (!key) {
105
- throw new Error(
106
- `@loop where references unknown name "${segs[0]}" in ${ctx.file}. Use the loop item (${itemName}.field) or a declared :state.`
110
+ throw wdError(
111
+ `@loop where references unknown name "${segs[0]}" in ${ctx.file}. Use the loop item (${itemName}.field) or a declared :state.`,
112
+ { code: "WD223", file: ctx.file }
107
113
  );
108
114
  }
109
115
  const rest = segs.slice(1).join(".");
@@ -149,13 +155,17 @@ function compileWhenOperand(tok, ctx, what) {
149
155
  if (/^-?\d+(?:\.\d+)?$/.test(tok)) return { code: tok, state: false, item: false };
150
156
  if (["true", "false", "null"].includes(tok)) return { code: tok, state: false, item: false };
151
157
  if (!/^[A-Za-z_$][\w$]*(?:\.[A-Za-z_$][\w$]*)*$/.test(tok)) {
152
- throw new Error(
153
- `Unsupported operand "${tok}" in ${what} (${ctx.file}). Use item.field, a :state name, a number, or a "string".`
158
+ throw wdError(
159
+ `Unsupported operand "${tok}" in ${what} (${ctx.file}). Use item.field, a :state name, a number, or a "string".`,
160
+ { code: "WD224", file: ctx.file }
154
161
  );
155
162
  }
156
163
  const segs = tok.split(".");
157
164
  if (segs.some((seg) => ["constructor", "prototype", "__proto__"].includes(seg))) {
158
- throw new Error(`Path "${tok}" is not allowed in ${what} (${ctx.file})`);
165
+ throw wdError(`Path "${tok}" is not allowed in ${what} (${ctx.file})`, {
166
+ code: "WD225",
167
+ file: ctx.file
168
+ });
159
169
  }
160
170
  const scoped = lookupVar(ctx.scope, segs[0]);
161
171
  if (scoped.found)
@@ -168,8 +178,9 @@ function compileWhenOperand(tok, ctx, what) {
168
178
  return { code: `I(${JSON.stringify(segs.slice(1).join("."))})`, state: false, item: true };
169
179
  const key = resolveStateKey(segs[0], ctx);
170
180
  if (!key)
171
- throw new Error(
172
- `${what} references unknown name "${segs[0]}" in ${ctx.file}. Use a loop item field, a declared :state, a number, or a "string".`
181
+ throw wdError(
182
+ `${what} references unknown name "${segs[0]}" in ${ctx.file}. Use a loop item field, a declared :state, a number, or a "string".`,
183
+ { code: "WD226", file: ctx.file }
173
184
  );
174
185
  const rest = segs.slice(1).join(".");
175
186
  return {
@@ -262,12 +273,16 @@ export function compileComputedExpr(raw, ctx) {
262
273
  ["constructor", "prototype", "__proto__"].includes(s)
263
274
  )
264
275
  ) {
265
- throw new Error(`Path "${listPath}" is not allowed in :computed ${name}() (${ctx.file})`);
276
+ throw wdError(`Path "${listPath}" is not allowed in :computed ${name}() (${ctx.file})`, {
277
+ code: "WD227",
278
+ file: ctx.file
279
+ });
266
280
  }
267
281
  const key = resolveStateKey(segs[0], ctx);
268
282
  if (!key) {
269
- throw new Error(
270
- `:computed ${name}() references unknown state "${segs[0]}" in ${ctx.file}. Declare it with :state or :fetch first.`
283
+ throw wdError(
284
+ `:computed ${name}() references unknown state "${segs[0]}" in ${ctx.file}. Declare it with :state or :fetch first.`,
285
+ { code: "WD228", file: ctx.file }
271
286
  );
272
287
  }
273
288
  const rest = segs.slice(1).join(".");
@@ -279,15 +294,22 @@ export function compileComputedExpr(raw, ctx) {
279
294
  }
280
295
  );
281
296
  if (/["'\\`]/.test(expr)) {
282
- throw new Error(`Unsupported string syntax in :computed expression "${raw}" (${ctx.file})`);
297
+ throw wdError(`Unsupported string syntax in :computed expression "${raw}" (${ctx.file})`, {
298
+ code: "WD229",
299
+ file: ctx.file
300
+ });
283
301
  }
284
302
  if (!/^[\w$.\s+\-*/%()<>=!&|]*$/.test(expr)) {
285
- throw new Error(
286
- `Unsupported syntax in :computed expression "${raw}" (${ctx.file}). Allowed: state names, numbers, strings, + - * / % ( ), comparisons, && || !.`
303
+ throw wdError(
304
+ `Unsupported syntax in :computed expression "${raw}" (${ctx.file}). Allowed: state names, numbers, strings, + - * / % ( ), comparisons, && || !.`,
305
+ { code: "WD230", file: ctx.file }
287
306
  );
288
307
  }
289
308
  if (/(^|[^=!<>])=(?!=)/.test(expr)) {
290
- throw new Error(`Assignment is not allowed in :computed expressions ("${raw}" in ${ctx.file})`);
309
+ throw wdError(`Assignment is not allowed in :computed expressions ("${raw}" in ${ctx.file})`, {
310
+ code: "WD231",
311
+ file: ctx.file
312
+ });
291
313
  }
292
314
  // Reject function-call syntax: a `(` that directly follows an identifier, a
293
315
  // string literal, or a closing `)` (e.g. `x()`, `x.valueOf()`, `(a)(b)`). Only
@@ -297,8 +319,9 @@ export function compileComputedExpr(raw, ctx) {
297
319
  // SECURITY.md guarantee that function calls are compile errors. Runs BEFORE the
298
320
  // identifier→S() mapping so it never trips on the emitted helper calls.
299
321
  if (/[\w$)]\s*\(/.test(expr)) {
300
- throw new Error(
301
- `Function calls are not allowed in :computed expressions ("${raw}" in ${ctx.file})`
322
+ throw wdError(
323
+ `Function calls are not allowed in :computed expressions ("${raw}" in ${ctx.file})`,
324
+ { code: "WD232", file: ctx.file }
302
325
  );
303
326
  }
304
327
  expr = expr.replace(/[A-Za-z_$][\w$]*(?:\.[A-Za-z_$][\w$]*)*/g, (ref) => {
@@ -306,14 +329,16 @@ export function compileComputedExpr(raw, ctx) {
306
329
  if (["true", "false", "null"].includes(ref)) return ref;
307
330
  const segs = ref.split(".");
308
331
  if (segs.some((seg) => ["constructor", "prototype", "__proto__"].includes(seg))) {
309
- throw new Error(
310
- `Path segment "${ref}" is not allowed in :computed expressions (${ctx.file})`
311
- );
332
+ throw wdError(`Path segment "${ref}" is not allowed in :computed expressions (${ctx.file})`, {
333
+ code: "WD233",
334
+ file: ctx.file
335
+ });
312
336
  }
313
337
  const key = resolveStateKey(segs[0], ctx);
314
338
  if (!key) {
315
- throw new Error(
316
- `:computed references unknown state "${segs[0]}" in ${ctx.file}. Declare it with :state or :fetch first.`
339
+ throw wdError(
340
+ `:computed references unknown state "${segs[0]}" in ${ctx.file}. Declare it with :state or :fetch first.`,
341
+ { code: "WD234", file: ctx.file }
317
342
  );
318
343
  }
319
344
  const rest = segs.slice(1).join(".");
@@ -13,13 +13,18 @@
13
13
  // ---------------------------------------------------------------------------
14
14
 
15
15
  import path from "node:path";
16
+ import { normalizeNewlines } from "./context.js";
16
17
 
17
18
  /**
18
19
  * The narrow filesystem surface the compiler needs. Every method takes an
19
20
  * ABSOLUTE path (the compiler resolves includes/assets to absolute paths before
20
21
  * reading), so a reader only has to map absolute paths to bytes/existence.
21
22
  * @typedef {object} Reader
22
- * @property {(absPath: string) => string} readText Read a UTF-8 text file.
23
+ * @property {(absPath: string) => string} readText Read a UTF-8 text file. The
24
+ * result is ALWAYS LF-normalized: every parser downstream is line-structured
25
+ * and LF-shaped, so a CRLF source (Windows, or any `core.autocrlf` checkout)
26
+ * would otherwise fail to match delimiters like `---`. A custom reader must
27
+ * uphold this.
23
28
  * @property {(absPath: string) => Uint8Array} readBinary Read raw bytes (images).
24
29
  * @property {(absPath: string) => boolean} exists Whether the path exists.
25
30
  * @property {(absPath: string) => string} realpath Canonical path (symlinks
@@ -40,6 +45,11 @@ import path from "node:path";
40
45
  */
41
46
  export function memoryReader(files, cwd = "/") {
42
47
  const map = files instanceof Map ? files : new Map(Object.entries(files));
48
+ // Resolution deliberately uses the HOST `path`, because `cwd` is frequently a
49
+ // real OS directory (the fs-parity test compiles the same project from disk
50
+ // and from memory against one temp root, and a Windows temp root is
51
+ // `C:\…` — which `path.posix` does not recognize as absolute). Only the
52
+ // resulting KEY is forced to POSIX, matching the map's documented key shape.
43
53
  const keyOf = (/** @type {string} */ absPath) =>
44
54
  path.relative(cwd, path.resolve(absPath)).split(path.sep).join("/");
45
55
  return {
@@ -49,7 +59,7 @@ export function memoryReader(files, cwd = "/") {
49
59
  if (value === undefined) {
50
60
  throw new Error(`ENOENT: no in-memory file "${key}" (from ${absPath})`);
51
61
  }
52
- return value;
62
+ return normalizeNewlines(value);
53
63
  },
54
64
  readBinary: () => {
55
65
  throw new Error("memoryReader: binary reads are unsupported");