@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.
- package/CHANGELOG.md +223 -0
- package/assets/styles/content-base.css +162 -0
- package/bin/content-commands.mjs +77 -17
- package/bin/package-build.mjs +15 -2
- package/ci/content-format-reference.mjs +68 -1
- package/config.mjs +26 -0
- package/content-config.mjs +153 -6
- package/docs/api.md +120 -89
- package/docs/authoring/assets.md +14 -0
- package/docs/authoring/dates-and-calendars.md +41 -3
- package/docs/authoring/frontmatter.md +2 -2
- package/docs/authoring/links-and-markup.md +325 -35
- package/docs/commands.md +12 -10
- package/docs/configuration.md +117 -14
- package/docs/content-format.md +4 -2
- package/docs/guides/book.md +5 -4
- package/docs/guides/reference-pages.md +15 -0
- package/docs/reference/format-details.md +545 -104
- package/docs/reference/note-types.md +30 -13
- package/engine/actor-compiler.mjs +23 -0
- package/engine/anchored-sections.mjs +35 -4
- package/engine/anchors.mjs +26 -30
- package/engine/art-fields.mjs +118 -0
- package/engine/art-slots.mjs +7 -1
- package/engine/asset-index.mjs +160 -10
- package/engine/asset-replacement.mjs +93 -0
- package/engine/asset-types.mjs +240 -0
- package/engine/base-compiler.mjs +139 -22
- package/engine/base-styles.mjs +149 -0
- package/engine/bundles.mjs +13 -2
- package/engine/compendiums.mjs +8 -1
- package/engine/content-blocks.mjs +410 -0
- package/engine/content-embeds.mjs +19 -0
- package/engine/content-figures.mjs +468 -0
- package/engine/content-footnotes.mjs +197 -0
- package/engine/content-format-check.mjs +15 -12
- package/engine/content-format.yaml +1 -1
- package/engine/content-images.mjs +126 -25
- package/engine/content-index.mjs +38 -12
- package/engine/content-links.mjs +14 -6
- package/engine/content-tables.mjs +45 -4
- package/engine/content-tasklists.mjs +189 -0
- package/engine/docs-checks.mjs +4 -4
- package/engine/extension-attributes.mjs +48 -2
- package/engine/field-reference.mjs +31 -10
- package/engine/field-spec.mjs +10 -8
- package/engine/foreign-catalog.mjs +143 -18
- package/engine/frontmatter-lint.mjs +120 -65
- package/engine/frontmatter.mjs +3 -3
- package/engine/heading-attributes.mjs +321 -0
- package/engine/helpers.mjs +109 -41
- package/engine/homepage.mjs +8 -8
- package/engine/index.mjs +3 -0
- package/engine/infobox-registry.mjs +4 -1
- package/engine/infobox-render.mjs +15 -3
- package/engine/infobox.mjs +129 -15
- package/engine/itinerary-scenes.mjs +4 -10
- package/engine/journals.mjs +294 -50
- package/engine/lore-events.mjs +197 -0
- package/engine/macros.mjs +1 -1
- package/engine/markdown-expressions.mjs +434 -59
- package/engine/note-dates.mjs +354 -12
- package/engine/note-frontmatter.mjs +7 -16
- package/engine/note-vocabulary.mjs +183 -42
- package/engine/page-lists.mjs +358 -0
- package/engine/pathnames.mjs +36 -0
- package/engine/pdf-build.mjs +431 -74
- package/engine/pdf-images.mjs +64 -0
- package/engine/pdf-render.mjs +563 -66
- package/engine/rank-ladder.mjs +198 -0
- package/engine/reckoning-markers.mjs +1 -1
- package/engine/retired-fields.mjs +8 -13
- package/engine/scenes.mjs +26 -7
- package/engine/site-build.mjs +166 -23
- package/engine/site-config.mjs +10 -1
- package/engine/site-index.mjs +90 -3
- package/engine/site-preview.mjs +20 -7
- package/engine/social-ties.mjs +52 -14
- package/engine/sql-tables.mjs +66 -9
- package/engine/standings.mjs +89 -6
- package/engine/system-block.mjs +56 -30
- package/engine/web-wikilinks.mjs +48 -1
- package/hm3/actors.mjs +8 -5
- package/manifest.mjs +43 -24
- package/package.json +1 -1
- package/packagebuild-metadata.jsonl +13 -13
- package/sohl/actors.mjs +22 -99
- package/sohl/item-fields.mjs +5 -5
- package/sohl/items.mjs +1 -1
- package/sohl/note-schemas.mjs +11 -21
- package/types/config.d.mts +9 -0
- package/types/content-config.d.mts +48 -1
- package/types/engine/actor-compiler.d.mts +11 -0
- package/types/engine/anchored-sections.d.mts +24 -0
- package/types/engine/anchors.d.mts +9 -4
- package/types/engine/art-fields.d.mts +64 -0
- package/types/engine/asset-index.d.mts +43 -0
- package/types/engine/asset-replacement.d.mts +22 -0
- package/types/engine/asset-types.d.mts +18 -0
- package/types/engine/base-compiler.d.mts +19 -17
- package/types/engine/base-styles.d.mts +93 -0
- package/types/engine/content-blocks.d.mts +87 -0
- package/types/engine/content-embeds.d.mts +16 -0
- package/types/engine/content-figures.d.mts +101 -0
- package/types/engine/content-footnotes.d.mts +80 -0
- package/types/engine/content-format-check.d.mts +8 -6
- package/types/engine/content-images.d.mts +78 -12
- package/types/engine/content-index.d.mts +8 -2
- package/types/engine/content-tables.d.mts +12 -4
- package/types/engine/content-tasklists.d.mts +72 -0
- package/types/engine/extension-attributes.d.mts +22 -1
- package/types/engine/field-reference.d.mts +21 -0
- package/types/engine/field-spec.d.mts +20 -16
- package/types/engine/foreign-catalog.d.mts +39 -0
- package/types/engine/frontmatter.d.mts +1 -1
- package/types/engine/heading-attributes.d.mts +179 -0
- package/types/engine/helpers.d.mts +29 -10
- package/types/engine/homepage.d.mts +7 -7
- package/types/engine/index.d.mts +1 -0
- package/types/engine/infobox-registry.d.mts +4 -1
- package/types/engine/infobox-render.d.mts +6 -1
- package/types/engine/infobox.d.mts +6 -0
- package/types/engine/journals.d.mts +41 -12
- package/types/engine/lore-events.d.mts +14 -0
- package/types/engine/markdown-expressions.d.mts +46 -2
- package/types/engine/note-dates.d.mts +64 -1
- package/types/engine/note-frontmatter.d.mts +1 -4
- package/types/engine/note-vocabulary.d.mts +82 -6
- package/types/engine/page-lists.d.mts +126 -0
- package/types/engine/pathnames.d.mts +26 -0
- package/types/engine/pdf-build.d.mts +28 -6
- package/types/engine/pdf-images.d.mts +52 -0
- package/types/engine/pdf-render.d.mts +84 -2
- package/types/engine/rank-ladder.d.mts +32 -0
- package/types/engine/retired-fields.d.mts +4 -6
- package/types/engine/site-build.d.mts +27 -4
- package/types/engine/site-config.d.mts +9 -0
- package/types/engine/site-index.d.mts +67 -1
- package/types/engine/site-preview.d.mts +1 -1
- package/types/engine/sql-tables.d.mts +31 -7
- package/types/engine/standings.d.mts +21 -3
- package/types/engine/system-block.d.mts +37 -14
- package/types/engine/web-wikilinks.d.mts +3 -1
- package/types/hm3/actors.d.mts +21 -3
- package/types/manifest.d.mts +19 -23
- package/types/sohl/actors.d.mts +6 -7
- package/engine/content-admonitions.mjs +0 -114
- package/engine/content-captions.mjs +0 -181
- package/engine/content-secrets.mjs +0 -112
- package/types/engine/content-admonitions.d.mts +0 -24
- package/types/engine/content-captions.d.mts +0 -45
- package/types/engine/content-secrets.d.mts +0 -17
package/engine/asset-index.mjs
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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}
|
|
303
|
-
*
|
|
304
|
-
* @
|
|
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(
|
|
426
|
+
function assetBlock(walkValues, provenance, type) {
|
|
307
427
|
const block = {};
|
|
308
428
|
for (const field of ASSET_RECORD_FIELDS) {
|
|
309
|
-
|
|
310
|
-
|
|
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(
|
|
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
|
+
}
|
package/engine/asset-types.mjs
CHANGED
|
@@ -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
|
+
});
|