@zvndev/darkmown 2.2.0 → 2.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (68) hide show
  1. package/CHANGELOG.md +13 -0
  2. package/README.md +34 -2
  3. package/docs/spec-alignment.md +10 -0
  4. package/package.json +16 -11
  5. package/site/pages/docs/index.wd +14 -0
  6. package/site/pages/playground/index.js +47 -46
  7. package/src/catalog.js +16 -1
  8. package/src/compiler/actions.js +42 -22
  9. package/src/compiler/body.js +36 -14
  10. package/src/compiler/collections.js +16 -10
  11. package/src/compiler/context.js +39 -10
  12. package/src/compiler/fetch.js +39 -15
  13. package/src/compiler/format.js +5 -2
  14. package/src/compiler/forms.js +91 -26
  15. package/src/compiler/frontmatter.js +11 -2
  16. package/src/compiler/fs-reader.js +2 -1
  17. package/src/compiler/image-size.js +340 -0
  18. package/src/compiler/includes.js +8 -4
  19. package/src/compiler/interpolation.js +15 -7
  20. package/src/compiler/loops.js +63 -35
  21. package/src/compiler/markdown.js +4 -3
  22. package/src/compiler/media.js +17 -3
  23. package/src/compiler/page.js +8 -6
  24. package/src/compiler/predicates.js +49 -24
  25. package/src/compiler/reader.js +12 -2
  26. package/src/compiler/state.js +16 -2
  27. package/src/compiler/structure.js +24 -14
  28. package/src/deploy.js +8 -4
  29. package/src/errors.js +1156 -0
  30. package/src/router.js +7 -4
  31. package/src/scaffold.js +4 -2
  32. package/src/skin.js +17 -6
  33. package/types/api-runner.d.ts +94 -0
  34. package/types/builder.d.ts +141 -10
  35. package/types/catalog.d.ts +97 -0
  36. package/types/compiler/actions.d.ts +41 -0
  37. package/types/compiler/body.d.ts +11 -0
  38. package/types/compiler/collections.d.ts +76 -0
  39. package/types/compiler/context.d.ts +661 -0
  40. package/types/compiler/directives.d.ts +6 -0
  41. package/types/compiler/expr-ast.d.ts +20 -0
  42. package/types/compiler/fetch.d.ts +29 -0
  43. package/types/compiler/format.d.ts +49 -0
  44. package/types/compiler/forms.d.ts +94 -0
  45. package/types/compiler/frontmatter.d.ts +33 -0
  46. package/types/compiler/fs-reader.d.ts +7 -0
  47. package/types/compiler/image-size.d.ts +14 -0
  48. package/types/compiler/includes.d.ts +100 -0
  49. package/types/compiler/index.d.ts +5 -0
  50. package/types/compiler/interpolation.d.ts +115 -0
  51. package/types/compiler/loops.d.ts +36 -0
  52. package/types/compiler/markdown.d.ts +34 -0
  53. package/types/compiler/media.d.ts +32 -0
  54. package/types/compiler/page.d.ts +109 -0
  55. package/types/compiler/predicates.d.ts +50 -0
  56. package/types/compiler/reader.d.ts +56 -0
  57. package/types/compiler/state.d.ts +68 -0
  58. package/types/compiler/structure.d.ts +56 -0
  59. package/types/compiler.d.ts +35 -223
  60. package/types/dev.d.ts +47 -2
  61. package/types/errors.d.ts +100 -0
  62. package/types/feeds.d.ts +150 -0
  63. package/types/headers.d.ts +44 -0
  64. package/types/highlight.d.ts +24 -0
  65. package/types/router.d.ts +42 -3
  66. package/types/scaffold.d.ts +15 -3
  67. package/types/skin.d.ts +28 -1
  68. package/types/statics.d.ts +2 -1
@@ -59,6 +59,7 @@ export function handleForm(line, bodyLines, ctx, index) {
59
59
  if ((!rawAction && !into) || leftover) {
60
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
61
  throw wdError(`Malformed :form in ${at(ctx, index)}: ${line}. Use ${hint}`, {
62
+ code: "WD401",
62
63
  file: ctx.file,
63
64
  line: lineOf(ctx, index),
64
65
  hint,
@@ -88,6 +89,7 @@ export function handleInput(line, ctx, index) {
88
89
  const match = line.match(/^:input\s+([A-Za-z_][\w-]*)\s*(.*)$/);
89
90
  if (!match)
90
91
  throw wdError(`Malformed :input in ${at(ctx, index)}: ${line}. ${INPUT_USE}`, {
92
+ code: "WD402",
91
93
  file: ctx.file,
92
94
  line: lineOf(ctx, index),
93
95
  hint: INPUT_USE.slice("Use: ".length),
@@ -101,7 +103,11 @@ export function handleInput(line, ctx, index) {
101
103
  for (const token of (match[2] || "").matchAll(re)) {
102
104
  if (token[3]) {
103
105
  if (!["required", "autofocus", "disabled", "readonly"].includes(token[3])) {
104
- 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
+ });
105
111
  }
106
112
  attrs.push(token[3]);
107
113
  continue;
@@ -124,7 +130,11 @@ export function handleInput(line, ctx, index) {
124
130
  "aria-describedby"
125
131
  ].includes(token[1])
126
132
  ) {
127
- 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
+ });
128
138
  }
129
139
  if (token[1] === "placeholder") placeholder = value;
130
140
  if (token[1] === "aria-label" || token[1] === "aria-describedby") hasAria = true;
@@ -152,6 +162,7 @@ export function handleBind(line, ctx, index) {
152
162
  const match = line.match(/^:bind\s+([A-Za-z_$][\w$]*)\s*(.*)$/);
153
163
  if (!match)
154
164
  throw wdError(`Malformed :bind in ${at(ctx, index)}: ${line}. Use: ${BIND_EXAMPLE}`, {
165
+ code: "WD405",
155
166
  file: ctx.file,
156
167
  line: lineOf(ctx, index),
157
168
  hint: BIND_EXAMPLE,
@@ -159,8 +170,9 @@ export function handleBind(line, ctx, index) {
159
170
  });
160
171
  const key = resolveStateKey(match[1], ctx);
161
172
  if (!key) {
162
- throw new Error(
163
- `: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 }
164
176
  );
165
177
  }
166
178
  ctx.comp.assets.runtime = true;
@@ -172,7 +184,11 @@ export function handleBind(line, ctx, index) {
172
184
  for (const token of (match[2] || "").matchAll(re)) {
173
185
  if (token[3]) {
174
186
  if (!["required", "autofocus"].includes(token[3]))
175
- 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
+ });
176
192
  attrs.push(token[3]);
177
193
  continue;
178
194
  }
@@ -182,7 +198,11 @@ export function handleBind(line, ctx, index) {
182
198
  continue;
183
199
  }
184
200
  if (!["placeholder", "autocomplete", "aria-label", "aria-describedby"].includes(token[1])) {
185
- 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
+ });
186
206
  }
187
207
  if (token[1] === "placeholder") placeholder = value;
188
208
  if (token[1] === "aria-label" || token[1] === "aria-describedby") hasAria = true;
@@ -215,6 +235,7 @@ export function handleSlider(line, ctx, index) {
215
235
  const head = line.match(/^:slider\s+([A-Za-z_$][\w$]*)\s*(.*)$/);
216
236
  if (!head)
217
237
  throw wdError(`Malformed :slider in ${at(ctx, index)}: ${line}. Use: ${SLIDER_EXAMPLE}`, {
238
+ code: "WD409",
218
239
  file: ctx.file,
219
240
  line: lineOf(ctx, index),
220
241
  hint: SLIDER_EXAMPLE,
@@ -228,8 +249,9 @@ export function handleSlider(line, ctx, index) {
228
249
  if (rest.startsWith("=")) {
229
250
  const valueMatch = rest.match(/^=\s*(\S+)\s*(.*)$/);
230
251
  if (!valueMatch)
231
- throw new Error(
232
- `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) }
233
255
  );
234
256
  initialRaw = valueMatch[1];
235
257
  rest = valueMatch[2].trim();
@@ -251,7 +273,12 @@ export function handleSlider(line, ctx, index) {
251
273
  else if (token[1] === "max") max = value;
252
274
  else if (token[1] === "step") step = value;
253
275
  else if (token[1] === "aria-label") ariaLabel = value;
254
- 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
+ });
255
282
  }
256
283
  for (const [label, raw] of [
257
284
  ["min", min],
@@ -259,7 +286,11 @@ export function handleSlider(line, ctx, index) {
259
286
  ["step", step]
260
287
  ]) {
261
288
  if (!/^-?\d+(?:\.\d+)?$/.test(raw))
262
- 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
+ });
263
294
  }
264
295
 
265
296
  ctx.comp.assets.runtime = true;
@@ -269,21 +300,24 @@ export function handleSlider(line, ctx, index) {
269
300
  if (initialRaw !== undefined) {
270
301
  const value = parseStateValue(initialRaw, at(ctx, index));
271
302
  if (typeof value !== "number")
272
- throw new Error(
273
- `: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) }
274
306
  );
275
307
  key = declareState(name, value, ctx);
276
308
  const persistAttr = persist ? ` data-wd-persist="${key}"` : "";
277
309
  seed = `<script type="application/json" data-wd-state${persistAttr}>${safeScriptJson({ [key]: value })}</script>`;
278
310
  } else {
279
311
  if (persist)
280
- throw new Error(
281
- `: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) }
282
315
  );
283
316
  const resolved = resolveStateKey(name, ctx);
284
317
  if (!resolved)
285
- throw new Error(
286
- `: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 }
287
321
  );
288
322
  key = resolved;
289
323
  }
@@ -307,6 +341,7 @@ export function handleSubmit(line, ctx, index) {
307
341
  throw wdError(
308
342
  `Malformed :submit in ${at(ctx, index)}: ${line}. Use: :submit "Label" — e.g. ${SUBMIT_EXAMPLE}`,
309
343
  {
344
+ code: "WD416",
310
345
  file: ctx.file,
311
346
  line: lineOf(ctx, index),
312
347
  hint: `:submit "Label" — e.g. ${SUBMIT_EXAMPLE}`,
@@ -331,6 +366,7 @@ export function handleTextarea(line, ctx, index) {
331
366
  if (!match) {
332
367
  const hint = `:textarea name [placeholder="…"] [rows=N] [required] — e.g. ${TEXTAREA_EXAMPLE}`;
333
368
  throw wdError(`Malformed :textarea in ${at(ctx, index)}: ${line}. Use: ${hint}`, {
369
+ code: "WD417",
334
370
  file: ctx.file,
335
371
  line: lineOf(ctx, index),
336
372
  hint,
@@ -344,7 +380,11 @@ export function handleTextarea(line, ctx, index) {
344
380
  for (const token of (match[2] || "").matchAll(re)) {
345
381
  if (token[3]) {
346
382
  if (!["required", "autofocus", "disabled", "readonly"].includes(token[3])) {
347
- 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
+ });
348
388
  }
349
389
  attrs.push(token[3]);
350
390
  continue;
@@ -362,7 +402,11 @@ export function handleTextarea(line, ctx, index) {
362
402
  "aria-describedby"
363
403
  ].includes(token[1])
364
404
  ) {
365
- 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
+ });
366
410
  }
367
411
  if (token[1] === "placeholder") placeholder = value;
368
412
  if (token[1] === "aria-label" || token[1] === "aria-describedby") hasAria = true;
@@ -389,6 +433,7 @@ export function handleSelect(line, optionLines, ctx, index) {
389
433
  if (!match) {
390
434
  const hint = `:select name [required] then "- Label" lines — e.g. ${SELECT_EXAMPLE}`;
391
435
  throw wdError(`Malformed :select in ${at(ctx, index)}: ${line}. Use: ${hint}`, {
436
+ code: "WD420",
392
437
  file: ctx.file,
393
438
  line: lineOf(ctx, index),
394
439
  hint,
@@ -401,14 +446,22 @@ export function handleSelect(line, optionLines, ctx, index) {
401
446
  for (const token of (match[2] || "").matchAll(re)) {
402
447
  if (token[3]) {
403
448
  if (!["required", "disabled", "autofocus"].includes(token[3])) {
404
- 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
+ });
405
454
  }
406
455
  attrs.push(token[3]);
407
456
  continue;
408
457
  }
409
458
  const value = stripQuotes(token[2]);
410
459
  if (!["autocomplete", "aria-label", "aria-describedby"].includes(token[1])) {
411
- 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
+ });
412
465
  }
413
466
  if (token[1] === "aria-label" || token[1] === "aria-describedby") hasAria = true;
414
467
  attrs.push(`${token[1]}="${escapeHtml(value)}"`);
@@ -418,8 +471,9 @@ export function handleSelect(line, optionLines, ctx, index) {
418
471
  }
419
472
  const options = optionLines.map((l) => l.replace(/^\s*-\s+/, "").trim()).filter(Boolean);
420
473
  if (!options.length) {
421
- throw new Error(
422
- `: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) }
423
477
  );
424
478
  }
425
479
  const opts = options
@@ -456,6 +510,7 @@ export function handleChoiceGroup(line, optionLines, ctx, kind, index) {
456
510
  const example = kind === "checkbox" ? CHECKBOX_EXAMPLE : RADIO_EXAMPLE;
457
511
  const hint = `${directive} name [required] then "- Label" lines — e.g. ${example}`;
458
512
  throw wdError(`Malformed ${directive} in ${at(ctx, index)}: ${line}. Use: ${hint}`, {
513
+ code: "WD424",
459
514
  file: ctx.file,
460
515
  line: lineOf(ctx, index),
461
516
  hint,
@@ -470,7 +525,11 @@ export function handleChoiceGroup(line, optionLines, ctx, kind, index) {
470
525
  for (const token of (match[2] || "").matchAll(re)) {
471
526
  if (token[3]) {
472
527
  if (!["required", "disabled", "autofocus"].includes(token[3])) {
473
- 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
+ });
474
533
  }
475
534
  flags.push(token[3]);
476
535
  continue;
@@ -478,12 +537,18 @@ export function handleChoiceGroup(line, optionLines, ctx, kind, index) {
478
537
  const value = stripQuotes(token[2]);
479
538
  if (token[1] === "aria-label") ariaLabel = value;
480
539
  else if (token[1] === "aria-describedby") ariaDescribedby = value;
481
- 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
+ });
482
546
  }
483
547
  const options = optionLines.map((l) => l.replace(/^\s*-\s+/, "").trim()).filter(Boolean);
484
548
  if (!options.length) {
485
- throw new Error(
486
- `${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) }
487
552
  );
488
553
  }
489
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)
@@ -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
+ }