@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
@@ -0,0 +1,340 @@
1
+ // ---------------------------------------------------------------------------
2
+ // Intrinsic image dimensions, read straight from the file header.
3
+ //
4
+ // `enhanceImages` needs one number pair per `<img>` — the width and height the
5
+ // browser reserves so the page never reflows as an image decodes. That is a
6
+ // header read, not image decoding: every format we care about states its size
7
+ // in the first few dozen bytes. This module does exactly that much and nothing
8
+ // else, which is why it replaced the `image-size` dependency (whose ICNS/JXL/
9
+ // HEIF parsers ship a live denial-of-service advisory with no fixed release —
10
+ // unbounded loops on malformed input). We parse only the formats a Darkmown
11
+ // site actually references, and every loop here is bounded by the buffer
12
+ // length, so a truncated or hostile file terminates instead of spinning.
13
+ //
14
+ // Supported: PNG (including Apple's "fried" CgBI variant), JPEG (behind any
15
+ // number of leading APP/EXIF segments), GIF, WebP (VP8 lossy, VP8L lossless,
16
+ // VP8X extended), and SVG (root width/height in any CSS absolute unit, falling
17
+ // back to the viewBox). Anything else — and anything malformed — returns null,
18
+ // which `measureImage` treats exactly like a missing file: emit no dimensions
19
+ // and move on. A build must never fail because of an image.
20
+ // ---------------------------------------------------------------------------
21
+
22
+ /**
23
+ * @typedef {{ width: number, height: number }} Dimensions Intrinsic pixel size.
24
+ */
25
+
26
+ /** PNG's 8-byte file signature. */
27
+ const PNG_SIGNATURE = [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a];
28
+
29
+ /** The chunk Apple's pngcrush (Xcode asset pipeline) inserts AHEAD of the IHDR,
30
+ * the one legal-in-the-wild violation of "IHDR is the first chunk". */
31
+ const PNG_FRIED_CHUNK = "CgBI";
32
+
33
+ /** The two GIF header versions in existence. */
34
+ const GIF_SIGNATURES = new Set(["GIF87a", "GIF89a"]);
35
+
36
+ /** How much of an unrecognized file to decode as text when sniffing for SVG.
37
+ * An SVG's root element is always in the first few hundred bytes; capping the
38
+ * decode keeps the scan constant-time on a large unknown binary. */
39
+ const SVG_SCAN_BYTES = 4096;
40
+
41
+ /** CSS absolute length units, in pixels. Font-relative units (`em`, `ex`, `rem`)
42
+ * and percentages depend on layout we do not have, so they are deliberately
43
+ * ABSENT: such an SVG falls back to its viewBox instead of to a guess. */
44
+ /** @type {Record<string, number>} */
45
+ const CSS_UNITS = {
46
+ px: 1,
47
+ pt: 96 / 72,
48
+ pc: 16,
49
+ in: 96,
50
+ cm: 96 / 2.54,
51
+ mm: 96 / 25.4,
52
+ q: 96 / 101.6
53
+ };
54
+
55
+ /**
56
+ * Read an image's intrinsic dimensions from its header bytes.
57
+ * @param {Uint8Array} bytes Raw file contents (a Node `Buffer` is a `Uint8Array`).
58
+ * @returns {Dimensions | null} The size, or null when the bytes are empty, of an
59
+ * unsupported format, or malformed. Never throws.
60
+ */
61
+ export function imageSize(bytes) {
62
+ if (!(bytes instanceof Uint8Array) || bytes.length === 0) return null;
63
+ // Ordered cheapest-magic-first; SVG is last because it is the only one that
64
+ // has to decode text rather than compare a fixed byte signature.
65
+ return png(bytes) ?? jpeg(bytes) ?? gif(bytes) ?? webp(bytes) ?? svg(bytes);
66
+ }
67
+
68
+ /**
69
+ * PNG: the IHDR chunk is mandated to be the FIRST chunk, and carries the size
70
+ * as two big-endian 32-bit fields. The one exception in the wild is an Apple
71
+ * "fried" PNG, which pushes IHDR behind a `CgBI` chunk; skip that by its own
72
+ * declared length rather than a hardcoded offset, and let the bounds check
73
+ * below reject a length that points outside the file.
74
+ * @param {Uint8Array} b
75
+ * @returns {Dimensions | null}
76
+ */
77
+ function png(b) {
78
+ if (!matches(b, PNG_SIGNATURE, 0)) return null;
79
+ // Chunk framing: 4-byte length, 4-byte type, payload, 4-byte CRC.
80
+ let at = 8;
81
+ if (b.length >= 16 && ascii(b, 12, 4) === PNG_FRIED_CHUNK) at += 12 + u32be(b, 8);
82
+ if (b.length < at + 16 || ascii(b, at + 4, 4) !== "IHDR") return null;
83
+ return dimensions(u32be(b, at + 8), u32be(b, at + 12));
84
+ }
85
+
86
+ /**
87
+ * JPEG: the size lives in a Start-Of-Frame segment, which sits behind an
88
+ * arbitrary run of APP0/APP1(EXIF)/COM/quantization segments, so the header has
89
+ * to be walked. The walk is bounded twice over: `offset` only ever grows (by at
90
+ * least one byte per pass), and the loop condition is the buffer length — a
91
+ * truncated or self-contradictory file falls out instead of looping forever.
92
+ * @param {Uint8Array} b
93
+ * @returns {Dimensions | null}
94
+ */
95
+ function jpeg(b) {
96
+ if (b.length < 4 || b[0] !== 0xff || b[1] !== 0xd8) return null;
97
+ let offset = 2;
98
+ while (offset + 1 < b.length) {
99
+ // Every segment begins at a marker boundary. Anything else means we lost
100
+ // sync with the segment chain and can no longer trust our offsets.
101
+ if (b[offset] !== 0xff) return null;
102
+ const marker = b[offset + 1];
103
+ // A run of 0xFF bytes is legal padding before the real marker type.
104
+ if (marker === 0xff) {
105
+ offset += 1;
106
+ continue;
107
+ }
108
+ offset += 2;
109
+ // Standalone markers carry no payload: TEM (0x01) and the RST0-7 restarts.
110
+ if (marker === 0x01 || (marker >= 0xd0 && marker <= 0xd7)) continue;
111
+ // End-of-image, or the start of entropy-coded scan data: past this point
112
+ // there is no more header to read, so a missing SOF is simply unknowable.
113
+ if (marker === 0xd9 || marker === 0xda) return null;
114
+ if (offset + 1 >= b.length) return null;
115
+ const length = u16be(b, offset);
116
+ // The length field counts itself, and must fit inside the file — a segment
117
+ // claiming to run past the end is the malformed case, not a short read.
118
+ if (length < 2 || offset + length > b.length) return null;
119
+ // SOF0-SOF15 hold the frame dimensions. C4/C8/CC share the range but are
120
+ // DHT / JPG-extension / DAC segments, not frame headers.
121
+ if (isStartOfFrame(marker)) {
122
+ // precision (1) + height (2) + width (2) after the length field itself.
123
+ if (length < 7) return null;
124
+ return dimensions(u16be(b, offset + 5), u16be(b, offset + 3));
125
+ }
126
+ offset += length;
127
+ }
128
+ return null;
129
+ }
130
+
131
+ /**
132
+ * Whether a JPEG marker is a Start-Of-Frame (the segment carrying dimensions).
133
+ * @param {number} marker
134
+ * @returns {boolean}
135
+ */
136
+ function isStartOfFrame(marker) {
137
+ if (marker < 0xc0 || marker > 0xcf) return false;
138
+ return marker !== 0xc4 && marker !== 0xc8 && marker !== 0xcc;
139
+ }
140
+
141
+ /**
142
+ * GIF: the logical screen descriptor follows the 6-byte header immediately,
143
+ * with little-endian 16-bit dimensions.
144
+ * @param {Uint8Array} b
145
+ * @returns {Dimensions | null}
146
+ */
147
+ function gif(b) {
148
+ if (b.length < 10 || !GIF_SIGNATURES.has(ascii(b, 0, 6))) return null;
149
+ return dimensions(u16le(b, 6), u16le(b, 8));
150
+ }
151
+
152
+ /**
153
+ * WebP: a RIFF container whose first chunk identifies the coding variant, each
154
+ * of which stores the canvas size differently.
155
+ * @param {Uint8Array} b
156
+ * @returns {Dimensions | null}
157
+ */
158
+ function webp(b) {
159
+ if (b.length < 16 || ascii(b, 0, 4) !== "RIFF" || ascii(b, 8, 4) !== "WEBP") return null;
160
+ const chunk = ascii(b, 12, 4);
161
+ // Lossy: a 3-byte frame tag, the 9D 01 2A sync code, then 14-bit dimensions
162
+ // (the top two bits of each 16-bit field are a scaling hint, not size).
163
+ if (chunk === "VP8 ") {
164
+ if (b.length < 30 || b[23] !== 0x9d || b[24] !== 0x01 || b[25] !== 0x2a) return null;
165
+ return dimensions(u16le(b, 26) & 0x3fff, u16le(b, 28) & 0x3fff);
166
+ }
167
+ // Lossless: a 0x2F signature byte, then (width - 1) in the low 14 bits and
168
+ // (height - 1) in the next 14 of one little-endian 32-bit word.
169
+ if (chunk === "VP8L") {
170
+ if (b.length < 25 || b[20] !== 0x2f) return null;
171
+ const bits = u32le(b, 21);
172
+ return dimensions((bits & 0x3fff) + 1, ((bits >>> 14) & 0x3fff) + 1);
173
+ }
174
+ // Extended (alpha / animation / ICC): the canvas size as two 3-byte
175
+ // little-endian (value - 1) fields, after the flag byte and 3 reserved bytes.
176
+ if (chunk === "VP8X") {
177
+ if (b.length < 30) return null;
178
+ return dimensions(u24le(b, 24) + 1, u24le(b, 27) + 1);
179
+ }
180
+ return null;
181
+ }
182
+
183
+ /**
184
+ * SVG: markup, not a binary header. The intrinsic size is the root element's
185
+ * `width`/`height`; when those are absent or expressed in units we cannot
186
+ * resolve to pixels (percentages, `em`), the `viewBox`'s third and fourth
187
+ * numbers are the intrinsic size — that is the fallback browsers use too.
188
+ * @param {Uint8Array} b
189
+ * @returns {Dimensions | null}
190
+ */
191
+ function svg(b) {
192
+ const head = decodeHead(b);
193
+ // Markup starts with `<` once a BOM / whitespace / XML declaration is past.
194
+ // Bailing here keeps an unrecognized binary from being scanned as text.
195
+ if (!/^\s*</.test(head)) return null;
196
+ const open = head.match(/<svg\b[^>]*>/i);
197
+ if (!open) return null;
198
+ const width = svgLength(attr(open[0], "width"));
199
+ const height = svgLength(attr(open[0], "height"));
200
+ if (width !== null && height !== null) return dimensions(width, height);
201
+ const box = viewBox(open[0]);
202
+ if (!box) return null;
203
+ // With one axis given, the viewBox supplies the aspect ratio for the other —
204
+ // how a browser resolves an `auto` dimension. With neither, the viewBox is
205
+ // itself the intrinsic size.
206
+ if (width !== null) return dimensions(width, (width * box.height) / box.width);
207
+ if (height !== null) return dimensions((height * box.width) / box.height, height);
208
+ return dimensions(box.width, box.height);
209
+ }
210
+
211
+ /**
212
+ * The width/height of an SVG root's `viewBox` (its third and fourth numbers).
213
+ * Per the spec the four values are separated by whitespace and/or a comma.
214
+ * @param {string} tag The `<svg …>` opening tag.
215
+ * @returns {Dimensions | null}
216
+ */
217
+ function viewBox(tag) {
218
+ const raw = attr(tag, "viewBox");
219
+ if (raw === null) return null;
220
+ const parts = raw.trim().split(/[\s,]+/);
221
+ if (parts.length !== 4) return null;
222
+ const width = Number(parts[2]);
223
+ const height = Number(parts[3]);
224
+ // Written as `!(x > 0)` so NaN — a non-numeric viewBox — is rejected too.
225
+ if (!(width > 0) || !(height > 0)) return null;
226
+ return { width, height };
227
+ }
228
+
229
+ /**
230
+ * Read a quoted attribute value out of a single opening tag. The name must be
231
+ * preceded by whitespace or a quote so `width` never matches `stroke-width`
232
+ * (the same guard `ensureMainLandmark` uses for `id` vs `data-id`). Unquoted
233
+ * values are not matched — rare in SVG, and the viewBox fallback covers them.
234
+ * @param {string} tag One opening tag, `<` through `>`.
235
+ * @param {string} name Attribute name (an internal constant, never user input).
236
+ * @returns {string | null}
237
+ */
238
+ function attr(tag, name) {
239
+ const m = tag.match(new RegExp(`[\\s"']${name}\\s*=\\s*["']([^"']*)["']`, "i"));
240
+ return m ? m[1] : null;
241
+ }
242
+
243
+ /**
244
+ * Parse an SVG length into CSS pixels. Unitless values are pixels, and the CSS
245
+ * absolute units convert exactly ({@link CSS_UNITS} — `pt` is common in
246
+ * Graphviz/Illustrator output). A percentage or a font-relative unit needs
247
+ * layout we do not have, so it returns null and the caller falls back to the
248
+ * viewBox — which is what a browser effectively does.
249
+ * @param {string | null} value
250
+ * @returns {number | null}
251
+ */
252
+ function svgLength(value) {
253
+ if (value === null) return null;
254
+ const m = /^\s*([0-9]*\.?[0-9]+)\s*([a-z]*)\s*$/i.exec(value);
255
+ if (!m) return null;
256
+ const scale = m[2] ? CSS_UNITS[m[2].toLowerCase()] : 1;
257
+ return scale === undefined ? null : Number(m[1]) * scale;
258
+ }
259
+
260
+ /**
261
+ * Decode the leading bytes of a file as UTF-8 text for the SVG sniff. Capped at
262
+ * {@link SVG_SCAN_BYTES}; a truncated multi-byte sequence at the cap decodes to
263
+ * a replacement character rather than throwing.
264
+ * @param {Uint8Array} b
265
+ * @returns {string}
266
+ */
267
+ function decodeHead(b) {
268
+ return new TextDecoder("utf-8").decode(b.subarray(0, SVG_SCAN_BYTES));
269
+ }
270
+
271
+ /**
272
+ * Validate and normalize a parsed pair into the public shape. Non-numeric,
273
+ * non-finite, and non-positive results are rejected — a zero or NaN dimension
274
+ * on an `<img>` is worse than none at all.
275
+ * @param {number} width
276
+ * @param {number} height
277
+ * @returns {Dimensions | null}
278
+ */
279
+ function dimensions(width, height) {
280
+ const w = Math.round(width);
281
+ const h = Math.round(height);
282
+ if (!Number.isFinite(w) || !Number.isFinite(h) || w <= 0 || h <= 0) return null;
283
+ return { width: w, height: h };
284
+ }
285
+
286
+ /**
287
+ * Whether the buffer carries an exact byte sequence at an offset.
288
+ * @param {Uint8Array} b
289
+ * @param {number[]} signature
290
+ * @param {number} offset
291
+ * @returns {boolean}
292
+ */
293
+ function matches(b, signature, offset) {
294
+ if (b.length < offset + signature.length) return false;
295
+ for (let i = 0; i < signature.length; i++) {
296
+ if (b[offset + i] !== signature[i]) return false;
297
+ }
298
+ return true;
299
+ }
300
+
301
+ /**
302
+ * Read a fixed-length run of bytes as a Latin-1 string (format four-character
303
+ * codes: `IHDR`, `RIFF`, `VP8L`, …). The caller guarantees the range is inside
304
+ * the buffer.
305
+ * @param {Uint8Array} b
306
+ * @param {number} offset
307
+ * @param {number} length
308
+ * @returns {string}
309
+ */
310
+ function ascii(b, offset, length) {
311
+ let out = "";
312
+ for (let i = 0; i < length; i++) out += String.fromCharCode(b[offset + i]);
313
+ return out;
314
+ }
315
+
316
+ /** @param {Uint8Array} b @param {number} i @returns {number} Big-endian uint16. */
317
+ function u16be(b, i) {
318
+ return (b[i] << 8) | b[i + 1];
319
+ }
320
+
321
+ /** @param {Uint8Array} b @param {number} i @returns {number} Little-endian uint16. */
322
+ function u16le(b, i) {
323
+ return b[i] | (b[i + 1] << 8);
324
+ }
325
+
326
+ /** @param {Uint8Array} b @param {number} i @returns {number} Big-endian uint32. */
327
+ function u32be(b, i) {
328
+ // Multiply rather than shift the top byte: `<< 24` would go negative.
329
+ return b[i] * 0x1000000 + ((b[i + 1] << 16) | (b[i + 2] << 8) | b[i + 3]);
330
+ }
331
+
332
+ /** @param {Uint8Array} b @param {number} i @returns {number} Little-endian uint32. */
333
+ function u32le(b, i) {
334
+ return (b[i] | (b[i + 1] << 8) | (b[i + 2] << 16) | (b[i + 3] << 24)) >>> 0;
335
+ }
336
+
337
+ /** @param {Uint8Array} b @param {number} i @returns {number} Little-endian uint24. */
338
+ function u24le(b, i) {
339
+ return b[i] | (b[i + 1] << 8) | (b[i + 2] << 16);
340
+ }
@@ -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;