@heroiclands/package-build 22.22.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 +32 -0
- package/bin/content-commands.mjs +51 -14
- package/ci/content-format-reference.mjs +1 -1
- package/content-config.mjs +80 -2
- package/docs/api.md +85 -72
- package/docs/authoring/assets.md +14 -0
- package/docs/authoring/dates-and-calendars.md +37 -1
- package/docs/authoring/links-and-markup.md +112 -31
- package/docs/commands.md +12 -10
- package/docs/configuration.md +90 -13
- package/docs/content-format.md +1 -1
- package/docs/guides/book.md +5 -4
- package/docs/reference/format-details.md +323 -60
- package/docs/reference/note-types.md +9 -9
- package/engine/anchors.mjs +16 -10
- 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 +54 -2
- package/engine/bundles.mjs +13 -1
- package/engine/content-blocks.mjs +4 -4
- package/engine/content-embeds.mjs +19 -0
- package/engine/content-figures.mjs +468 -0
- package/engine/content-footnotes.mjs +10 -2
- package/engine/content-format.yaml +1 -1
- package/engine/content-images.mjs +126 -25
- package/engine/content-index.mjs +36 -10
- 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 +73 -1
- package/engine/heading-attributes.mjs +31 -18
- package/engine/helpers.mjs +97 -30
- package/engine/infobox-registry.mjs +4 -1
- package/engine/infobox.mjs +47 -8
- package/engine/itinerary-scenes.mjs +4 -10
- package/engine/journals.mjs +264 -39
- package/engine/lore-events.mjs +197 -0
- package/engine/markdown-expressions.mjs +257 -7
- package/engine/note-dates.mjs +305 -12
- package/engine/note-vocabulary.mjs +11 -5
- package/engine/pathnames.mjs +36 -0
- package/engine/pdf-build.mjs +357 -34
- package/engine/pdf-images.mjs +64 -0
- package/engine/pdf-render.mjs +160 -36
- package/engine/scenes.mjs +16 -4
- package/engine/site-build.mjs +127 -13
- package/engine/site-index.mjs +72 -1
- package/engine/site-preview.mjs +2 -0
- package/engine/system-block.mjs +44 -16
- package/hm3/actors.mjs +2 -2
- package/manifest.mjs +30 -24
- package/package.json +1 -1
- package/packagebuild-metadata.jsonl +13 -13
- package/sohl/item-fields.mjs +4 -3
- package/types/content-config.d.mts +48 -1
- 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 +0 -17
- 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 +7 -0
- package/types/engine/content-images.d.mts +78 -12
- package/types/engine/content-index.d.mts +8 -2
- 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/heading-attributes.d.mts +13 -8
- package/types/engine/helpers.d.mts +23 -7
- package/types/engine/infobox-registry.d.mts +4 -1
- package/types/engine/infobox.d.mts +6 -0
- package/types/engine/journals.d.mts +31 -9
- package/types/engine/lore-events.d.mts +14 -0
- package/types/engine/markdown-expressions.d.mts +23 -2
- package/types/engine/note-dates.d.mts +64 -1
- 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 +12 -0
- package/types/engine/site-build.d.mts +14 -3
- package/types/engine/site-index.d.mts +54 -0
- package/types/engine/system-block.d.mts +26 -2
- package/types/hm3/actors.d.mts +18 -0
- package/types/manifest.d.mts +19 -23
- package/engine/content-captions.mjs +0 -230
- package/types/engine/content-captions.d.mts +0 -58
package/engine/art-fields.mjs
CHANGED
|
@@ -44,6 +44,8 @@
|
|
|
44
44
|
* @module
|
|
45
45
|
*/
|
|
46
46
|
|
|
47
|
+
import path from "node:path";
|
|
48
|
+
|
|
47
49
|
import { encodeAddresses } from "./address-values.mjs";
|
|
48
50
|
import { ASSET_SYSTEM, isAssetType } from "./asset-types.mjs";
|
|
49
51
|
import { ASSETS_SEGMENT } from "./pathnames.mjs";
|
|
@@ -129,9 +131,49 @@ export function assetAddressIndex(records = [], { config, foreign, types = [] }
|
|
|
129
131
|
.filter(([key]) => key),
|
|
130
132
|
),
|
|
131
133
|
foreign: foreign?.index ?? new Map(),
|
|
134
|
+
// Foreign first, local last: a renderer sizing a picture looks it up
|
|
135
|
+
// by the pathname the record resolved to, never by address, and a
|
|
136
|
+
// local record is entered after a foreign one so it wins the lookup on
|
|
137
|
+
// the collision neither should ever cause.
|
|
138
|
+
byPath: assetImageInfoByPathname([
|
|
139
|
+
...(foreign?.index?.values() ?? []),
|
|
140
|
+
...records.filter(isAssetRecord),
|
|
141
|
+
]),
|
|
132
142
|
};
|
|
133
143
|
}
|
|
134
144
|
|
|
145
|
+
/**
|
|
146
|
+
* What a renderer needs to size one picture, keyed by the pathname its
|
|
147
|
+
* address resolves to.
|
|
148
|
+
*
|
|
149
|
+
* An image reaches a renderer as the resolved pathname an embed or an authored
|
|
150
|
+
* body image already carries — see {@link readAssetAddress} — never as the
|
|
151
|
+
* address that produced it, so the lookup a renderer wants is by pathname, not
|
|
152
|
+
* by {@link assetAddressIndex}'s own `assets` map.
|
|
153
|
+
*
|
|
154
|
+
* @param {readonly object[]} records - Asset records, local or foreign.
|
|
155
|
+
* @returns {Map<string, {type: string, role?: string, width: number|"", height: number|""}>}
|
|
156
|
+
* One entry per addressable file, carrying only what a renderer sizes a
|
|
157
|
+
* picture from. `role` is omitted when the record states none; `width` and
|
|
158
|
+
* `height` are always present, blank (`""`) for a vector — the record's own
|
|
159
|
+
* convention, carried through rather than collapsed into an absence a
|
|
160
|
+
* renderer could not tell apart from "no asset resolved at all".
|
|
161
|
+
*/
|
|
162
|
+
export function assetImageInfoByPathname(records = []) {
|
|
163
|
+
const map = new Map();
|
|
164
|
+
for (const record of records) {
|
|
165
|
+
if (!record?.asset?.path || !record.package) continue;
|
|
166
|
+
const key = `${record.package}/${ASSETS_SEGMENT}/${record.asset.path}`;
|
|
167
|
+
map.set(key, {
|
|
168
|
+
type: record.type,
|
|
169
|
+
role: record.asset.role || undefined,
|
|
170
|
+
width: typeof record.asset.width === "number" ? record.asset.width : "",
|
|
171
|
+
height: typeof record.asset.height === "number" ? record.asset.height : "",
|
|
172
|
+
});
|
|
173
|
+
}
|
|
174
|
+
return map;
|
|
175
|
+
}
|
|
176
|
+
|
|
135
177
|
/**
|
|
136
178
|
* The asset one authored value names, or why it names none.
|
|
137
179
|
*
|
|
@@ -200,6 +242,82 @@ export function resolveArtRecord(index, value, defaultType, accepts) {
|
|
|
200
242
|
return readAssetAddress(index, value, defaultType, accepts).record;
|
|
201
243
|
}
|
|
202
244
|
|
|
245
|
+
/**
|
|
246
|
+
* Every asset's role, keyed by the pathname it resolves to — the form a body
|
|
247
|
+
* carries once an embed's address has been rewritten into the ordinary image
|
|
248
|
+
* every surface renders ({@link module:engine/content-embeds.resolveEmbeds}).
|
|
249
|
+
*
|
|
250
|
+
* This is the lookup a `:::figure` fence's `map` counter reaches through —
|
|
251
|
+
* see {@link module:engine/content-figures.scanFigures}'s `resolveRole` —
|
|
252
|
+
* for a caller that reads a body after that rewrite: a Foundry journal, an
|
|
253
|
+
* item or actor's documentation, and the book.
|
|
254
|
+
*
|
|
255
|
+
* @param {object} index - From {@link module:engine/wikilinks.buildWikilinkIndex},
|
|
256
|
+
* or the equivalent the site and the book build.
|
|
257
|
+
* @returns {Map<string, string>} Pathname → role, one entry per asset that
|
|
258
|
+
* declares one.
|
|
259
|
+
*/
|
|
260
|
+
export function pathnameRoles(index) {
|
|
261
|
+
const roles = new Map();
|
|
262
|
+
for (const record of [
|
|
263
|
+
...(index?.assets?.values() ?? []),
|
|
264
|
+
...(index?.foreign?.values() ?? []),
|
|
265
|
+
]) {
|
|
266
|
+
const role = record?.asset?.role;
|
|
267
|
+
if (!role || !record.asset?.path || !record.package) continue;
|
|
268
|
+
roles.set(`${record.package}/${ASSETS_SEGMENT}/${record.asset.path}`, role);
|
|
269
|
+
}
|
|
270
|
+
return roles;
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
/**
|
|
274
|
+
* Flag a note whose art slot names a picture that is not the size that slot
|
|
275
|
+
* is cut to.
|
|
276
|
+
*
|
|
277
|
+
* The size is stated by the slot itself, in
|
|
278
|
+
* {@link module:engine/art-slots.ART_SLOTS}, so it is written once and a slot
|
|
279
|
+
* that states none asks nothing of the picture it names. Today the hero image
|
|
280
|
+
* is the one slot with a fixed strip to fill; a picture written in prose is
|
|
281
|
+
* fitted to the room it has and is never measured here.
|
|
282
|
+
*
|
|
283
|
+
* A vector carries no pixel size to compare, and neither does a raster whose
|
|
284
|
+
* header cannot be read; neither is flagged.
|
|
285
|
+
*
|
|
286
|
+
* @param {readonly object[]} records - The corpus, notes and assets together.
|
|
287
|
+
* @param {object} [opts]
|
|
288
|
+
* @param {object} [opts.config] - The resolved build configuration.
|
|
289
|
+
* @param {string} [opts.assetsBase] - Where the asset roots sit, so a finding
|
|
290
|
+
* names the file from the working directory as every other finding does.
|
|
291
|
+
* @returns {object[]} A finding per picture off its slot's size,
|
|
292
|
+
* `severity: "error"`.
|
|
293
|
+
*/
|
|
294
|
+
export function checkArtSlotSizes(records = [], { config, assetsBase = "" } = {}) {
|
|
295
|
+
const sized = ART_SLOTS.filter((slot) => slot.size);
|
|
296
|
+
if (!sized.length) return [];
|
|
297
|
+
const index = assetAddressIndex(records, { config });
|
|
298
|
+
const findings = [];
|
|
299
|
+
for (const record of records) {
|
|
300
|
+
const data = record?.data;
|
|
301
|
+
if (!data) continue;
|
|
302
|
+
for (const slot of sized) {
|
|
303
|
+
const value = data[slot.key];
|
|
304
|
+
if (!value) continue;
|
|
305
|
+
const asset = resolveArtRecord(index, value, slot.type, slot.accepts);
|
|
306
|
+
const { path: file, width, height } = asset?.asset ?? {};
|
|
307
|
+
if (!file || !width || !height) continue;
|
|
308
|
+
if (Number(width) === slot.size.width && Number(height) === slot.size.height) continue;
|
|
309
|
+
findings.push({
|
|
310
|
+
file: path.join(assetsBase, file),
|
|
311
|
+
severity: "error",
|
|
312
|
+
message:
|
|
313
|
+
`${width}×${height} is not the ${slot.size.width}×${slot.size.height} ` +
|
|
314
|
+
`a \`${slot.key}\` is cut to`,
|
|
315
|
+
});
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
return findings;
|
|
319
|
+
}
|
|
320
|
+
|
|
203
321
|
/**
|
|
204
322
|
* The pathname an art value names, in the form an authored one takes.
|
|
205
323
|
*
|
package/engine/art-slots.mjs
CHANGED
|
@@ -76,6 +76,12 @@ export const ART_SLOTS = Object.freeze([
|
|
|
76
76
|
type: "image",
|
|
77
77
|
accepts: ART_TYPES,
|
|
78
78
|
document: false,
|
|
79
|
-
|
|
79
|
+
// A hero image fills one fixed strip wherever it is drawn, so every
|
|
80
|
+
// banner is cut to one size and a picture of another size is resized
|
|
81
|
+
// to it. A picture written in prose fills no fixed strip and is
|
|
82
|
+
// fitted to whatever room it has, so no slot but this one states a
|
|
83
|
+
// size.
|
|
84
|
+
size: Object.freeze({ width: 1792, height: 768 }),
|
|
85
|
+
describe: "The page's hero image, cut to 1792×768. Reaches no compiled document.",
|
|
80
86
|
}),
|
|
81
87
|
]);
|
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
|
+
}
|