@heroiclands/package-build 22.21.0 → 22.23.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 (152) hide show
  1. package/CHANGELOG.md +223 -0
  2. package/assets/styles/content-base.css +162 -0
  3. package/bin/content-commands.mjs +77 -17
  4. package/bin/package-build.mjs +15 -2
  5. package/ci/content-format-reference.mjs +68 -1
  6. package/config.mjs +26 -0
  7. package/content-config.mjs +153 -6
  8. package/docs/api.md +120 -89
  9. package/docs/authoring/assets.md +14 -0
  10. package/docs/authoring/dates-and-calendars.md +41 -3
  11. package/docs/authoring/frontmatter.md +2 -2
  12. package/docs/authoring/links-and-markup.md +325 -35
  13. package/docs/commands.md +12 -10
  14. package/docs/configuration.md +117 -14
  15. package/docs/content-format.md +4 -2
  16. package/docs/guides/book.md +5 -4
  17. package/docs/guides/reference-pages.md +15 -0
  18. package/docs/reference/format-details.md +545 -104
  19. package/docs/reference/note-types.md +30 -13
  20. package/engine/actor-compiler.mjs +23 -0
  21. package/engine/anchored-sections.mjs +35 -4
  22. package/engine/anchors.mjs +26 -30
  23. package/engine/art-fields.mjs +118 -0
  24. package/engine/art-slots.mjs +7 -1
  25. package/engine/asset-index.mjs +160 -10
  26. package/engine/asset-replacement.mjs +93 -0
  27. package/engine/asset-types.mjs +240 -0
  28. package/engine/base-compiler.mjs +139 -22
  29. package/engine/base-styles.mjs +149 -0
  30. package/engine/bundles.mjs +13 -2
  31. package/engine/compendiums.mjs +8 -1
  32. package/engine/content-blocks.mjs +410 -0
  33. package/engine/content-embeds.mjs +19 -0
  34. package/engine/content-figures.mjs +468 -0
  35. package/engine/content-footnotes.mjs +197 -0
  36. package/engine/content-format-check.mjs +15 -12
  37. package/engine/content-format.yaml +1 -1
  38. package/engine/content-images.mjs +126 -25
  39. package/engine/content-index.mjs +38 -12
  40. package/engine/content-links.mjs +14 -6
  41. package/engine/content-tables.mjs +45 -4
  42. package/engine/content-tasklists.mjs +189 -0
  43. package/engine/docs-checks.mjs +4 -4
  44. package/engine/extension-attributes.mjs +48 -2
  45. package/engine/field-reference.mjs +31 -10
  46. package/engine/field-spec.mjs +10 -8
  47. package/engine/foreign-catalog.mjs +143 -18
  48. package/engine/frontmatter-lint.mjs +120 -65
  49. package/engine/frontmatter.mjs +3 -3
  50. package/engine/heading-attributes.mjs +321 -0
  51. package/engine/helpers.mjs +109 -41
  52. package/engine/homepage.mjs +8 -8
  53. package/engine/index.mjs +3 -0
  54. package/engine/infobox-registry.mjs +4 -1
  55. package/engine/infobox-render.mjs +15 -3
  56. package/engine/infobox.mjs +129 -15
  57. package/engine/itinerary-scenes.mjs +4 -10
  58. package/engine/journals.mjs +294 -50
  59. package/engine/lore-events.mjs +197 -0
  60. package/engine/macros.mjs +1 -1
  61. package/engine/markdown-expressions.mjs +434 -59
  62. package/engine/note-dates.mjs +354 -12
  63. package/engine/note-frontmatter.mjs +7 -16
  64. package/engine/note-vocabulary.mjs +183 -42
  65. package/engine/page-lists.mjs +358 -0
  66. package/engine/pathnames.mjs +36 -0
  67. package/engine/pdf-build.mjs +431 -74
  68. package/engine/pdf-images.mjs +64 -0
  69. package/engine/pdf-render.mjs +563 -66
  70. package/engine/rank-ladder.mjs +198 -0
  71. package/engine/reckoning-markers.mjs +1 -1
  72. package/engine/retired-fields.mjs +8 -13
  73. package/engine/scenes.mjs +26 -7
  74. package/engine/site-build.mjs +166 -23
  75. package/engine/site-config.mjs +10 -1
  76. package/engine/site-index.mjs +90 -3
  77. package/engine/site-preview.mjs +20 -7
  78. package/engine/social-ties.mjs +52 -14
  79. package/engine/sql-tables.mjs +66 -9
  80. package/engine/standings.mjs +89 -6
  81. package/engine/system-block.mjs +56 -30
  82. package/engine/web-wikilinks.mjs +48 -1
  83. package/hm3/actors.mjs +8 -5
  84. package/manifest.mjs +43 -24
  85. package/package.json +1 -1
  86. package/packagebuild-metadata.jsonl +13 -13
  87. package/sohl/actors.mjs +22 -99
  88. package/sohl/item-fields.mjs +5 -5
  89. package/sohl/items.mjs +1 -1
  90. package/sohl/note-schemas.mjs +11 -21
  91. package/types/config.d.mts +9 -0
  92. package/types/content-config.d.mts +48 -1
  93. package/types/engine/actor-compiler.d.mts +11 -0
  94. package/types/engine/anchored-sections.d.mts +24 -0
  95. package/types/engine/anchors.d.mts +9 -4
  96. package/types/engine/art-fields.d.mts +64 -0
  97. package/types/engine/asset-index.d.mts +43 -0
  98. package/types/engine/asset-replacement.d.mts +22 -0
  99. package/types/engine/asset-types.d.mts +18 -0
  100. package/types/engine/base-compiler.d.mts +19 -17
  101. package/types/engine/base-styles.d.mts +93 -0
  102. package/types/engine/content-blocks.d.mts +87 -0
  103. package/types/engine/content-embeds.d.mts +16 -0
  104. package/types/engine/content-figures.d.mts +101 -0
  105. package/types/engine/content-footnotes.d.mts +80 -0
  106. package/types/engine/content-format-check.d.mts +8 -6
  107. package/types/engine/content-images.d.mts +78 -12
  108. package/types/engine/content-index.d.mts +8 -2
  109. package/types/engine/content-tables.d.mts +12 -4
  110. package/types/engine/content-tasklists.d.mts +72 -0
  111. package/types/engine/extension-attributes.d.mts +22 -1
  112. package/types/engine/field-reference.d.mts +21 -0
  113. package/types/engine/field-spec.d.mts +20 -16
  114. package/types/engine/foreign-catalog.d.mts +39 -0
  115. package/types/engine/frontmatter.d.mts +1 -1
  116. package/types/engine/heading-attributes.d.mts +179 -0
  117. package/types/engine/helpers.d.mts +29 -10
  118. package/types/engine/homepage.d.mts +7 -7
  119. package/types/engine/index.d.mts +1 -0
  120. package/types/engine/infobox-registry.d.mts +4 -1
  121. package/types/engine/infobox-render.d.mts +6 -1
  122. package/types/engine/infobox.d.mts +6 -0
  123. package/types/engine/journals.d.mts +41 -12
  124. package/types/engine/lore-events.d.mts +14 -0
  125. package/types/engine/markdown-expressions.d.mts +46 -2
  126. package/types/engine/note-dates.d.mts +64 -1
  127. package/types/engine/note-frontmatter.d.mts +1 -4
  128. package/types/engine/note-vocabulary.d.mts +82 -6
  129. package/types/engine/page-lists.d.mts +126 -0
  130. package/types/engine/pathnames.d.mts +26 -0
  131. package/types/engine/pdf-build.d.mts +28 -6
  132. package/types/engine/pdf-images.d.mts +52 -0
  133. package/types/engine/pdf-render.d.mts +84 -2
  134. package/types/engine/rank-ladder.d.mts +32 -0
  135. package/types/engine/retired-fields.d.mts +4 -6
  136. package/types/engine/site-build.d.mts +27 -4
  137. package/types/engine/site-config.d.mts +9 -0
  138. package/types/engine/site-index.d.mts +67 -1
  139. package/types/engine/site-preview.d.mts +1 -1
  140. package/types/engine/sql-tables.d.mts +31 -7
  141. package/types/engine/standings.d.mts +21 -3
  142. package/types/engine/system-block.d.mts +37 -14
  143. package/types/engine/web-wikilinks.d.mts +3 -1
  144. package/types/hm3/actors.d.mts +21 -3
  145. package/types/manifest.d.mts +19 -23
  146. package/types/sohl/actors.d.mts +6 -7
  147. package/engine/content-admonitions.mjs +0 -114
  148. package/engine/content-captions.mjs +0 -181
  149. package/engine/content-secrets.mjs +0 -112
  150. package/types/engine/content-admonitions.d.mts +0 -24
  151. package/types/engine/content-captions.d.mts +0 -45
  152. package/types/engine/content-secrets.d.mts +0 -17
@@ -50,6 +50,13 @@
50
50
  * drop: `licence` beside `license` is otherwise an attribution record that looks
51
51
  * complete and carries nothing.
52
52
  *
53
+ * **`role` names what the picture is for**, from a closed set, and belongs to
54
+ * the `image` type only — an `icon` address carries one nominal size per medium
55
+ * whatever the file holds, so a `role` declared there is a finding and is
56
+ * dropped rather than carried through. **`width` and `height` come from the
57
+ * walk itself**, read from the file's own header rather than declared, and are
58
+ * blank for an SVG, which has no pixel dimensions to state.
59
+ *
53
60
  * @module
54
61
  */
55
62
 
@@ -60,7 +67,18 @@ import YAML from "yaml";
60
67
 
61
68
  import { canonicalKey } from "./content-address.mjs";
62
69
  import { positionOfYamlPath } from "./diagnostics.mjs";
63
- import { ASSET_SYSTEM, ASSET_TYPES, isAssetShortcode } from "./asset-types.mjs";
70
+ import { ASSET_SYSTEM, ASSET_TYPES, imageDimensions, isAssetShortcode } from "./asset-types.mjs";
71
+
72
+ /**
73
+ * The closed set of roles a picture may declare.
74
+ *
75
+ * Shared with the field declaration below and with every medium that sizes a
76
+ * picture by its role, so the vocabulary cannot drift between the record that
77
+ * carries it and the renderer that reads it.
78
+ *
79
+ * @type {readonly string[]}
80
+ */
81
+ export const ASSET_ROLES = Object.freeze(["portrait", "emblem", "banner", "plate", "map"]);
64
82
 
65
83
  /**
66
84
  * The file a directory records provenance for its subtree in.
@@ -92,6 +110,18 @@ export const PROVENANCE_SIDECAR_SUFFIX = ".yaml";
92
110
  * state this key. Omitting one is a finding rather than a blank, because a
93
111
  * record resolves wholesale: the nearest one is the whole answer, so a key it
94
112
  * leaves out is not inherited from above but simply absent.
113
+ * @property {"boolean"|"integer"} [type] - The value's own type, for a field
114
+ * whose answer is not a string. Omitted for every string field; `ai` carries
115
+ * the YAML boolean itself rather than a stringified copy, and `width` and
116
+ * `height` carry the pixel count the walk measured. Each is refused rather
117
+ * than coerced when the value does not match.
118
+ * @property {readonly string[]} [values] - The closed set a provenance
119
+ * declaration must choose one of; a value outside it is a finding and the
120
+ * key is left blank, the same treatment an `ai` value of the wrong shape
121
+ * gets. `role` is the one field that carries it.
122
+ * @property {string} [onlyType] - The one asset type this field may be
123
+ * declared for. A provenance record stating it for another type is a
124
+ * finding, and the value is dropped rather than carried through.
95
125
  * @property {string} describe - One line, for the author-facing reference.
96
126
  */
97
127
 
@@ -134,7 +164,10 @@ export const ASSET_RECORD_FIELDS = Object.freeze([
134
164
  Object.freeze({
135
165
  name: "ai",
136
166
  from: "provenance",
137
- describe: "Whether the file is machine-generated — `true` or `false`.",
167
+ type: "boolean",
168
+ describe:
169
+ "Whether the file is machine-generated — the YAML boolean `true` " +
170
+ "or `false`, never a string.",
138
171
  }),
139
172
  Object.freeze({
140
173
  name: "license",
@@ -147,6 +180,32 @@ export const ASSET_RECORD_FIELDS = Object.freeze([
147
180
  from: "provenance",
148
181
  describe: "Anything else a person reading the attribution needs.",
149
182
  }),
183
+ Object.freeze({
184
+ name: "role",
185
+ from: "provenance",
186
+ onlyType: "image",
187
+ values: ASSET_ROLES,
188
+ describe:
189
+ "What the picture is for — portrait, emblem, banner, plate or map. " +
190
+ "Absent for an ordinary picture, and refused outside that set. " +
191
+ "The `image` type only; a value on an `icon` address is a finding.",
192
+ }),
193
+ Object.freeze({
194
+ name: "width",
195
+ from: "walk",
196
+ type: "integer",
197
+ describe:
198
+ "The file's pixel width, read from its own header during the " +
199
+ "asset walk. Blank for an SVG, which has no pixel dimensions.",
200
+ }),
201
+ Object.freeze({
202
+ name: "height",
203
+ from: "walk",
204
+ type: "integer",
205
+ describe:
206
+ "The file's pixel height, read from its own header during the " +
207
+ "asset walk. Blank for an SVG, which has no pixel dimensions.",
208
+ }),
150
209
  ]);
151
210
 
152
211
  /**
@@ -181,12 +240,30 @@ export const REQUIRED_PROVENANCE_KEYS = Object.freeze(
181
240
  ),
182
241
  );
183
242
 
243
+ /**
244
+ * The provenance field declaring each key, by name.
245
+ *
246
+ * Read in the parsing loop below to ask whether a key has its own `type`
247
+ * rather than the ordinary string one — derived from {@link ASSET_RECORD_FIELDS}
248
+ * for the same reason {@link PROVENANCE_KEYS} is, so the field declaration
249
+ * stays the one place a key's shape is stated.
250
+ *
251
+ * @type {ReadonlyMap<string, AssetRecordField>}
252
+ */
253
+ const PROVENANCE_FIELD_BY_KEY = new Map(
254
+ ASSET_RECORD_FIELDS.filter((field) => field.from === "provenance").map((field) => [
255
+ field.name,
256
+ field,
257
+ ]),
258
+ );
259
+
184
260
  /**
185
261
  * Read one provenance file, reporting every key that is not a provenance key.
186
262
  *
187
263
  * @param {string} file - The provenance file.
188
264
  * @param {object[]} findings - Collects a diagnostic per unknown key.
189
- * @returns {Record<string, string>} The recognised keys, as strings.
265
+ * @returns {Record<string, string|boolean>} The recognised keys — a string for
266
+ * every field but `ai`, which carries the YAML boolean itself.
190
267
  */
191
268
  function readProvenanceFile(file, findings) {
192
269
  let text;
@@ -238,6 +315,44 @@ function readProvenanceFile(file, findings) {
238
315
  });
239
316
  continue;
240
317
  }
318
+ const field = PROVENANCE_FIELD_BY_KEY.get(key);
319
+ if (field?.type === "boolean") {
320
+ if (typeof value === "boolean") {
321
+ out[key] = value;
322
+ } else {
323
+ // Distinct from the unknown-key finding above: this key is
324
+ // recognised, and the problem is the shape of its value — a
325
+ // quoted `"false"` or a YAML `yes`/`1` would otherwise be
326
+ // stringified and read as truthy regardless of which it was.
327
+ findings.push({
328
+ file,
329
+ ...positionOfYamlPath(text, [key], { key: true }),
330
+ severity: "error",
331
+ message:
332
+ `\`${key}\` must be the YAML boolean \`true\` or \`false\`, ` +
333
+ `not ${JSON.stringify(value)}`,
334
+ });
335
+ }
336
+ continue;
337
+ }
338
+ if (field?.values) {
339
+ if (field.values.includes(value)) {
340
+ out[key] = value;
341
+ } else {
342
+ // Distinct from the unknown-key finding above: this key is
343
+ // recognised, and the problem is that its value is not one of
344
+ // the closed set it accepts.
345
+ findings.push({
346
+ file,
347
+ ...positionOfYamlPath(text, [key], { key: true }),
348
+ severity: "error",
349
+ message:
350
+ `\`${key}\` must be one of ${field.values.join(", ")}, ` +
351
+ `not ${JSON.stringify(value)}`,
352
+ });
353
+ }
354
+ continue;
355
+ }
241
356
  out[key] = value == null ? "" : String(value);
242
357
  }
243
358
 
@@ -299,15 +414,30 @@ function inheritedProvenance(dir, root, cache, findings) {
299
414
  /**
300
415
  * The `asset` block for one file.
301
416
  *
302
- * @param {string} relPath - The file's path below the package's asset directory.
303
- * @param {Record<string, string>|null} provenance - The resolved record.
304
- * @returns {Record<string, string>} The block, every field present.
417
+ * @param {Record<string, unknown>} walkValues - Every `from: "walk"` field's
418
+ * value, keyed by field name.
419
+ * @param {Record<string, string|boolean>|null} provenance - The resolved record.
420
+ * @param {string} type - The asset type this file was walked as, so a field
421
+ * declaring `onlyType` can be dropped for every other type.
422
+ * @returns {Record<string, string|boolean|number>} The block, every field
423
+ * present — blank (`""`) for a string or integer field nothing states,
424
+ * `false` for `ai`.
305
425
  */
306
- function assetBlock(relPath, provenance) {
426
+ function assetBlock(walkValues, provenance, type) {
307
427
  const block = {};
308
428
  for (const field of ASSET_RECORD_FIELDS) {
309
- const value = field.from === "walk" ? relPath : provenance?.[field.name];
310
- block[field.name] = typeof value === "string" ? value : "";
429
+ if (field.onlyType && field.onlyType !== type) {
430
+ block[field.name] = field.type === "boolean" ? false : "";
431
+ continue;
432
+ }
433
+ const value = field.from === "walk" ? walkValues[field.name] : provenance?.[field.name];
434
+ if (field.type === "boolean") {
435
+ block[field.name] = typeof value === "boolean" ? value : false;
436
+ } else if (field.type === "integer") {
437
+ block[field.name] = typeof value === "number" ? value : "";
438
+ } else {
439
+ block[field.name] = typeof value === "string" ? value : "";
440
+ }
311
441
  }
312
442
  return block;
313
443
  }
@@ -418,6 +548,18 @@ export function collectAssetRecords(assetsBase, { contentPackage, problems }) {
418
548
  // ancestor walk is not consulted when one is present.
419
549
  : inheritedProvenance(path.dirname(absPath), rootDir, cache, findings);
420
550
 
551
+ for (const field of ASSET_RECORD_FIELDS) {
552
+ if (field.onlyType && field.onlyType !== type && provenance?.[field.name]) {
553
+ report(
554
+ absPath,
555
+ `\`${field.name}\` only applies to the \`${field.onlyType}\` type, ` +
556
+ `and this address is \`${type}\` — the declaration is dropped`,
557
+ );
558
+ }
559
+ }
560
+
561
+ const dimensions = imageDimensions(absPath, path.extname(absPath).toLowerCase());
562
+
421
563
  records.push({
422
564
  package: contentPackage,
423
565
  type,
@@ -427,7 +569,15 @@ export function collectAssetRecords(assetsBase, { contentPackage, problems }) {
427
569
  address: {
428
570
  canonical: canonicalKey(contentPackage, ASSET_SYSTEM, type, shortcode),
429
571
  },
430
- asset: assetBlock(`${root}/${relPath}`, provenance),
572
+ asset: assetBlock(
573
+ {
574
+ path: `${root}/${relPath}`,
575
+ width: dimensions?.width,
576
+ height: dimensions?.height,
577
+ },
578
+ provenance,
579
+ type,
580
+ ),
431
581
  });
432
582
  }
433
583
  }
@@ -0,0 +1,93 @@
1
+ /*
2
+ * This file is part of the Song of Heroic Lands (SoHL) system for Foundry VTT.
3
+ * Copyright (c) 2024-2026 Tom Rodriguez ("Toasty") — <toasty@heroiclands.org>
4
+ *
5
+ * This work is licensed under the GNU General Public License v3.0 (GPLv3).
6
+ * You may copy, modify, and distribute it under the terms of that license.
7
+ *
8
+ * For full terms, see the LICENSE.md file in the project root or visit:
9
+ * https://www.gnu.org/licenses/gpl-3.0.html
10
+ *
11
+ * SPDX-License-Identifier: GPL-3.0-or-later
12
+ */
13
+
14
+ /**
15
+ * A declared replacement package's asset record, picked over the local one,
16
+ * by address.
17
+ *
18
+ * A package may declare, on a `relationships.<kind>[]` entry, that a
19
+ * dependency's asset tree answers its own asset addresses —
20
+ * `assetReplacement: true` in `package-build.config.yaml`:
21
+ *
22
+ * ```yaml
23
+ * relationships:
24
+ * requires:
25
+ * - id: thalornaaltart
26
+ * manifest: https://…/releases/latest/download/module.json
27
+ * assetReplacement: true
28
+ * contentIndex: true
29
+ * ```
30
+ *
31
+ * `assetReplacement: true` opts that relationship in. `resolveAssetReplacement`
32
+ * is what turns the declaration into an answer, one address at a time, against
33
+ * four rules:
34
+ *
35
+ * 1. **Scoped to asset types.** A non-asset address, or one whose package
36
+ * segment is not the compiling package's own, resolves to `null` at once —
37
+ * tested with {@link module:engine/asset-types.isAssetType} rather than
38
+ * reimplemented. A being, an item or a lore note is never replaced this
39
+ * way.
40
+ * 2. **Ordered, single pass.** Replacements are tried in declaration order —
41
+ * the order their relationships appear in `package-build.config.yaml` —
42
+ * and the first one carrying the address wins.
43
+ * 3. **Never recursive.** A replacement's own output address is not itself
44
+ * looked up against another replacement: resolving
45
+ * `thalornaaltart-none-image-thorn` never asks whether something replaces
46
+ * `thalornaaltart`. This forecloses a cycle by construction — there is
47
+ * nothing to loop.
48
+ * 4. **A miss falls through.** `null` means no declared replacement carries
49
+ * the address, and the caller falls back to the local address exactly as
50
+ * it does today. This function never decides that a missing replacement is
51
+ * an error; a caller that wants that is free to build it on the answer.
52
+ *
53
+ * This resolver answers a different question from
54
+ * {@link module:engine/asset-bindings.checkForeignAssetBindings}: that check
55
+ * validates an art module's own staged files against another package's
56
+ * addresses, at that module's own build time. This one runs at the *replaced*
57
+ * package's build, consulted by its book, and asks whether a declared
58
+ * replacement carries an address this package would otherwise answer itself.
59
+ *
60
+ * @module
61
+ */
62
+
63
+ import { isAssetType } from "./asset-types.mjs";
64
+ import { canonicalKey, readCanonicalKey } from "./content-address.mjs";
65
+
66
+ /**
67
+ * The replacement record for one asset address, or `null`.
68
+ *
69
+ * @param {string} address - A canonical asset address.
70
+ * @param {object} opts
71
+ * @param {string} opts.localPackage - The compiling package's own segment.
72
+ * @param {readonly string[]} opts.replacements - The package segments of
73
+ * every relationship declaring `assetReplacement: true`, in declaration
74
+ * order.
75
+ * @param {Map<string, object>} opts.index - The merged content index, keyed
76
+ * by canonical address.
77
+ * @returns {{record: object, package: string}|null} The first replacement
78
+ * carrying the address, or `null` when none does.
79
+ */
80
+ export function resolveAssetReplacement(address, { localPackage, replacements, index }) {
81
+ const parts = readCanonicalKey(address);
82
+ if (!parts) return null;
83
+ if (!isAssetType(parts.type)) return null;
84
+ if (parts.package !== localPackage) return null;
85
+ for (const replacementPackage of replacements ?? []) {
86
+ const key = canonicalKey(replacementPackage, parts.system, parts.type, parts.shortcode);
87
+ const record = index?.get(key);
88
+ if (record?.asset?.path && record.package) {
89
+ return { record, package: replacementPackage };
90
+ }
91
+ }
92
+ return null;
93
+ }
@@ -46,6 +46,8 @@
46
46
  * @module
47
47
  */
48
48
 
49
+ import fs from "node:fs";
50
+
49
51
  import { ADDRESS_SEGMENT_PATTERN } from "./address-charset.mjs";
50
52
 
51
53
  /**
@@ -190,3 +192,241 @@ export function assetTypeOfRoot(root) {
190
192
  export function isAssetShortcode(shortcode) {
191
193
  return typeof shortcode === "string" && ADDRESS_SEGMENT_PATTERN.test(shortcode);
192
194
  }
195
+
196
+ /**
197
+ * The PNG (and APNG, which shares the signature) header.
198
+ *
199
+ * @param {Buffer} bytes
200
+ * @returns {{width: number, height: number}|undefined}
201
+ */
202
+ function pngDimensions(bytes) {
203
+ if (bytes.length < 24) return undefined;
204
+ if (bytes.toString("hex", 0, 8) !== "89504e470d0a1a0a") return undefined;
205
+ return { width: bytes.readUInt32BE(16), height: bytes.readUInt32BE(20) };
206
+ }
207
+
208
+ /**
209
+ * The GIF header's logical screen size.
210
+ *
211
+ * @param {Buffer} bytes
212
+ * @returns {{width: number, height: number}|undefined}
213
+ */
214
+ function gifDimensions(bytes) {
215
+ if (bytes.length < 10) return undefined;
216
+ const signature = bytes.toString("ascii", 0, 6);
217
+ if (signature !== "GIF87a" && signature !== "GIF89a") return undefined;
218
+ return { width: bytes.readUInt16LE(6), height: bytes.readUInt16LE(8) };
219
+ }
220
+
221
+ /**
222
+ * The BMP header's size, carried as a signed value so a bottom-up bitmap's
223
+ * negative height still reads as a pixel count.
224
+ *
225
+ * @param {Buffer} bytes
226
+ * @returns {{width: number, height: number}|undefined}
227
+ */
228
+ function bmpDimensions(bytes) {
229
+ if (bytes.length < 26) return undefined;
230
+ if (bytes.toString("ascii", 0, 2) !== "BM") return undefined;
231
+ return {
232
+ width: Math.abs(bytes.readInt32LE(18)),
233
+ height: Math.abs(bytes.readInt32LE(22)),
234
+ };
235
+ }
236
+
237
+ /**
238
+ * The size a JPEG's first SOF (start-of-frame) segment states, found by
239
+ * walking its markers rather than assuming a fixed offset — a JPEG carries an
240
+ * arbitrary run of metadata segments before the frame header.
241
+ *
242
+ * @param {Buffer} bytes
243
+ * @returns {{width: number, height: number}|undefined}
244
+ */
245
+ function jpegDimensions(bytes) {
246
+ if (bytes.length < 4 || bytes.readUInt16BE(0) !== 0xffd8) return undefined;
247
+ let pos = 2;
248
+ while (pos + 1 < bytes.length) {
249
+ if (bytes[pos] !== 0xff) return undefined;
250
+ const marker = bytes[pos + 1];
251
+ pos += 2;
252
+ // TEM and the restart markers carry no segment length; EOI ends the
253
+ // stream with no frame header found.
254
+ if (marker === 0x01 || (marker >= 0xd0 && marker <= 0xd7)) continue;
255
+ if (marker === 0xd9) return undefined;
256
+ if (pos + 2 > bytes.length) return undefined;
257
+ const length = bytes.readUInt16BE(pos);
258
+ const isStartOfFrame =
259
+ marker >= 0xc0 &&
260
+ marker <= 0xcf &&
261
+ marker !== 0xc4 &&
262
+ marker !== 0xc8 &&
263
+ marker !== 0xcc;
264
+ if (isStartOfFrame) {
265
+ if (pos + 7 > bytes.length) return undefined;
266
+ return { height: bytes.readUInt16BE(pos + 3), width: bytes.readUInt16BE(pos + 5) };
267
+ }
268
+ pos += length;
269
+ }
270
+ return undefined;
271
+ }
272
+
273
+ /**
274
+ * A WebP file's size, read from whichever of the three chunk layouts the
275
+ * format defines — the plain lossy frame, the lossless bitstream, or the
276
+ * extended header a file with alpha, animation or metadata carries instead.
277
+ *
278
+ * @param {Buffer} bytes
279
+ * @returns {{width: number, height: number}|undefined}
280
+ */
281
+ function webpDimensions(bytes) {
282
+ if (bytes.length < 30) return undefined;
283
+ if (bytes.toString("ascii", 0, 4) !== "RIFF" || bytes.toString("ascii", 8, 12) !== "WEBP") {
284
+ return undefined;
285
+ }
286
+ const chunk = bytes.toString("ascii", 12, 16);
287
+ if (chunk === "VP8 ") {
288
+ return {
289
+ width: bytes.readUInt16LE(26) & 0x3fff,
290
+ height: bytes.readUInt16LE(28) & 0x3fff,
291
+ };
292
+ }
293
+ if (chunk === "VP8L") {
294
+ const bits = bytes.readUInt32LE(21);
295
+ return { width: (bits & 0x3fff) + 1, height: ((bits >>> 14) & 0x3fff) + 1 };
296
+ }
297
+ if (chunk === "VP8X") {
298
+ return {
299
+ width: (bytes[24] | (bytes[25] << 8) | (bytes[26] << 16)) + 1,
300
+ height: (bytes[27] | (bytes[28] << 8) | (bytes[29] << 16)) + 1,
301
+ };
302
+ }
303
+ return undefined;
304
+ }
305
+
306
+ /**
307
+ * A TIFF's `ImageWidth`/`ImageLength` tags, read from its first IFD in
308
+ * whichever byte order the file's own header declares.
309
+ *
310
+ * @param {Buffer} bytes
311
+ * @returns {{width: number, height: number}|undefined}
312
+ */
313
+ function tiffDimensions(bytes) {
314
+ if (bytes.length < 8) return undefined;
315
+ const order = bytes.toString("ascii", 0, 2);
316
+ let little;
317
+ if (order === "II") little = true;
318
+ else if (order === "MM") little = false;
319
+ else return undefined;
320
+ const u16 = (offset) => (little ? bytes.readUInt16LE(offset) : bytes.readUInt16BE(offset));
321
+ const u32 = (offset) => (little ? bytes.readUInt32LE(offset) : bytes.readUInt32BE(offset));
322
+ if (u16(2) !== 42) return undefined;
323
+ const ifd = u32(4);
324
+ if (ifd + 2 > bytes.length) return undefined;
325
+ const entryCount = u16(ifd);
326
+ let width;
327
+ let height;
328
+ for (let i = 0; i < entryCount && (width === undefined || height === undefined); i++) {
329
+ const entry = ifd + 2 + i * 12;
330
+ if (entry + 12 > bytes.length) break;
331
+ const tag = u16(entry);
332
+ if (tag !== 256 && tag !== 257) continue;
333
+ // SHORT (type 3) values sit in the first two bytes of the value
334
+ // field; LONG (type 4) occupies all four, so only the width in that
335
+ // field differs by type.
336
+ const type = u16(entry + 2);
337
+ const value = type === 3 ? u16(entry + 8) : u32(entry + 8);
338
+ if (tag === 256) width = value;
339
+ else height = value;
340
+ }
341
+ return width !== undefined && height !== undefined ? { width, height } : undefined;
342
+ }
343
+
344
+ /**
345
+ * An AVIF's declared size, read from the first `ispe` (Image Spatial
346
+ * Extents) property its ISOBMFF container carries, by walking box headers
347
+ * down through `meta` → `iprp` → `ipco` rather than assuming a fixed layout.
348
+ *
349
+ * @param {Buffer} bytes
350
+ * @returns {{width: number, height: number}|undefined}
351
+ */
352
+ function avifDimensions(bytes) {
353
+ /** @type {{width: number, height: number}|undefined} */
354
+ let found;
355
+ const walk = (start, end) => {
356
+ let pos = start;
357
+ while (!found && pos + 8 <= end) {
358
+ const size = bytes.readUInt32BE(pos);
359
+ if (size < 8) return;
360
+ const type = bytes.toString("ascii", pos + 4, pos + 8);
361
+ const dataStart = pos + 8;
362
+ const dataEnd = Math.min(pos + size, end);
363
+ if (type === "meta") {
364
+ // `meta` is a full box: four bytes of version and flags
365
+ // precede its own nested boxes.
366
+ walk(dataStart + 4, dataEnd);
367
+ } else if (type === "iprp" || type === "ipco") {
368
+ walk(dataStart, dataEnd);
369
+ } else if (type === "ispe" && dataEnd - dataStart >= 12) {
370
+ found = {
371
+ width: bytes.readUInt32BE(dataStart + 4),
372
+ height: bytes.readUInt32BE(dataStart + 8),
373
+ };
374
+ }
375
+ pos += size;
376
+ }
377
+ };
378
+ walk(0, bytes.length);
379
+ return found;
380
+ }
381
+
382
+ /**
383
+ * The pixel dimensions a raster image's own header states.
384
+ *
385
+ * Read from the file's bytes directly rather than through a decoder, so a
386
+ * corrupt or partial file fails closed — the same `undefined` answer as a
387
+ * format this carries no reader for — instead of throwing out of the asset
388
+ * walk. An SVG has no pixel dimensions to read; stating one would be a
389
+ * fabricated viewBox reading rather than a fact about the file.
390
+ *
391
+ * @param {string} absPath - The file's absolute path.
392
+ * @param {string} ext - Its extension, lowercase and dot-led.
393
+ * @returns {{width: number, height: number}|undefined} The declared size, or
394
+ * `undefined` when the format carries none, or the header cannot be read.
395
+ */
396
+ export function imageDimensions(absPath, ext) {
397
+ if (ext === ".svg") return undefined;
398
+ const reader = IMAGE_DIMENSION_READERS[ext];
399
+ if (!reader) return undefined;
400
+ let bytes;
401
+ try {
402
+ bytes = fs.readFileSync(absPath);
403
+ } catch {
404
+ return undefined;
405
+ }
406
+ try {
407
+ return reader(bytes);
408
+ } catch {
409
+ // A header this short or this malformed to parse is the same answer
410
+ // as one this reads no format for: the dimensions stay blank.
411
+ return undefined;
412
+ }
413
+ }
414
+
415
+ /**
416
+ * The dimension reader for each raster extension {@link IMAGE_EXTENSIONS}
417
+ * declares, `.svg` excepted — it has no pixel dimensions, handled above
418
+ * rather than entered here.
419
+ *
420
+ * @type {Readonly<Record<string, (bytes: Buffer) => ({width: number, height: number}|undefined)>>}
421
+ */
422
+ const IMAGE_DIMENSION_READERS = Object.freeze({
423
+ ".png": pngDimensions,
424
+ ".apng": pngDimensions,
425
+ ".gif": gifDimensions,
426
+ ".bmp": bmpDimensions,
427
+ ".jpg": jpegDimensions,
428
+ ".jpeg": jpegDimensions,
429
+ ".webp": webpDimensions,
430
+ ".tiff": tiffDimensions,
431
+ ".avif": avifDimensions,
432
+ });