@heroiclands/package-build 20.7.0 → 21.1.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 (63) hide show
  1. package/CHANGELOG.md +183 -0
  2. package/CONTENT.md +132 -44
  3. package/bin/content-build.mjs +37 -5
  4. package/bin/package-build.mjs +77 -0
  5. package/content-config.mjs +59 -1
  6. package/docs/api.md +149 -19
  7. package/docs/commands.md +75 -0
  8. package/docs/configuration.md +37 -10
  9. package/docs/content-format.md +450 -49
  10. package/engine/content-format.mjs +52 -3
  11. package/engine/content-images.mjs +699 -0
  12. package/engine/dependency-bump.mjs +218 -0
  13. package/engine/frontmatter-lint.mjs +89 -2
  14. package/engine/helpers.mjs +81 -142
  15. package/engine/index.mjs +15 -0
  16. package/engine/infobox-registry.mjs +81 -0
  17. package/engine/infobox-render.mjs +382 -0
  18. package/engine/infobox.mjs +963 -0
  19. package/engine/item-registry.mjs +5 -5
  20. package/engine/journals.mjs +22 -1
  21. package/engine/map-notes.mjs +11 -5
  22. package/engine/metadata-index.mjs +5 -0
  23. package/engine/note-vocabulary.mjs +57 -2
  24. package/engine/pathnames.mjs +374 -0
  25. package/engine/pdf-build.mjs +206 -9
  26. package/engine/pdf-render.mjs +453 -20
  27. package/engine/pdf-toc.mjs +77 -5
  28. package/engine/scenes.mjs +2 -1
  29. package/engine/site-build.mjs +106 -7
  30. package/engine/site-index.mjs +93 -4
  31. package/engine/wikilinks.mjs +93 -0
  32. package/hm3/default-item-art.mjs +14 -15
  33. package/hm3/index.mjs +3 -0
  34. package/hm3/infobox.mjs +64 -0
  35. package/package.json +1 -1
  36. package/sohl/default-item-art.mjs +18 -16
  37. package/sohl/index.mjs +3 -0
  38. package/sohl/infobox.mjs +499 -0
  39. package/types/content-config.d.mts +7 -0
  40. package/types/engine/content-format.d.mts +36 -0
  41. package/types/engine/content-images.d.mts +281 -0
  42. package/types/engine/dependency-bump.d.mts +89 -0
  43. package/types/engine/frontmatter-lint.d.mts +23 -0
  44. package/types/engine/helpers.d.mts +30 -72
  45. package/types/engine/index.d.mts +5 -0
  46. package/types/engine/infobox-registry.d.mts +36 -0
  47. package/types/engine/infobox-render.d.mts +87 -0
  48. package/types/engine/infobox.d.mts +443 -0
  49. package/types/engine/item-registry.d.mts +5 -5
  50. package/types/engine/journals.d.mts +9 -1
  51. package/types/engine/note-vocabulary.d.mts +51 -0
  52. package/types/engine/pathnames.d.mts +189 -0
  53. package/types/engine/pdf-build.d.mts +46 -0
  54. package/types/engine/pdf-render.d.mts +97 -1
  55. package/types/engine/pdf-toc.d.mts +10 -5
  56. package/types/engine/site-build.d.mts +11 -3
  57. package/types/engine/site-index.d.mts +35 -3
  58. package/types/engine/wikilinks.d.mts +22 -0
  59. package/types/hm3/default-item-art.d.mts +5 -6
  60. package/types/hm3/index.d.mts +1 -0
  61. package/types/hm3/infobox.d.mts +22 -0
  62. package/types/sohl/index.d.mts +1 -0
  63. package/types/sohl/infobox.d.mts +145 -0
@@ -0,0 +1,218 @@
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
+ * Taking a newer version of a dependency, without reformatting the lockfile.
16
+ *
17
+ * npm resolves the bump; this module's whole job is what npm does badly here.
18
+ * Every repository in the constellation indents `package-lock.json` with four
19
+ * spaces and prettier-ignores it, and npm rewrites it with two — so a
20
+ * three-line version change arrives as a seventeen-thousand-line diff that no
21
+ * reviewer can read past.
22
+ *
23
+ * The alternative — editing the three `version` / `resolved` / `integrity`
24
+ * lines by hand — is only correct while the new version's dependency set is
25
+ * identical to the old one's, and nothing tells the author when it is not. npm
26
+ * is what knows; so npm does the resolution and the indentation is restored
27
+ * afterwards.
28
+ */
29
+
30
+ import fs from "node:fs";
31
+ import path from "node:path";
32
+ import { execFileSync } from "node:child_process";
33
+
34
+ /** Dependency sections a bump may read a package's declared range from. */
35
+ const DEPENDENCY_SECTIONS = ["dependencies", "devDependencies", "optionalDependencies"];
36
+
37
+ /**
38
+ * The indentation a JSON file already uses, as the literal string to indent
39
+ * with.
40
+ *
41
+ * Read from the first indented line rather than assumed, because the two-space
42
+ * default is right for some consumers and wrong for most of them. A file whose
43
+ * second line is not indented — a one-line document — reports the npm default,
44
+ * which is what rewriting it would produce anyway.
45
+ *
46
+ * @param {string} text - The file's contents.
47
+ * @returns {string} The indent string, e.g. `" "` or `" "`.
48
+ */
49
+ export function detectJsonIndent(text) {
50
+ const match = /\n([ \t]+)\S/.exec(text);
51
+ return match ? match[1] : " ";
52
+ }
53
+
54
+ /**
55
+ * Rewrite a JSON file with a given indent, leaving its content untouched.
56
+ *
57
+ * @param {string} file - Absolute path to the JSON file.
58
+ * @param {string} indent - The indent string to write with.
59
+ * @returns {boolean} Whether the file's bytes changed.
60
+ */
61
+ export function reindentJsonFile(file, indent) {
62
+ if (!fs.existsSync(file)) return false;
63
+ const before = fs.readFileSync(file, "utf8");
64
+ const trailingNewline = before.endsWith("\n") ? "\n" : "";
65
+ const after = JSON.stringify(JSON.parse(before), null, indent) + trailingNewline;
66
+ if (after === before) return false;
67
+ fs.writeFileSync(file, after);
68
+ return true;
69
+ }
70
+
71
+ /**
72
+ * Every package name a manifest declares a range for, in declaration order.
73
+ *
74
+ * @param {object} manifest - The parsed `package.json`.
75
+ * @returns {string[]} The declared package names.
76
+ */
77
+ export function declaredDependencies(manifest) {
78
+ const names = [];
79
+ for (const section of DEPENDENCY_SECTIONS) {
80
+ for (const name of Object.keys(manifest?.[section] ?? {})) {
81
+ if (!names.includes(name)) names.push(name);
82
+ }
83
+ }
84
+ return names;
85
+ }
86
+
87
+ /**
88
+ * The packages a bump targets when the caller names none.
89
+ *
90
+ * The first-party scope, because that is the bump a consumer runs by hand: a
91
+ * third-party one arrives from Dependabot on its own schedule, while a
92
+ * first-party release is taken the moment it publishes, usually to unblock the
93
+ * very change that prompted it.
94
+ *
95
+ * @param {object} manifest - The parsed `package.json`.
96
+ * @param {string} [scope] - The scope prefix to match.
97
+ * @returns {string[]} The package names to bump.
98
+ */
99
+ export function firstPartyDependencies(manifest, scope = "@heroiclands/") {
100
+ return declaredDependencies(manifest).filter((name) => name.startsWith(scope));
101
+ }
102
+
103
+ /**
104
+ * The version the lockfile currently resolves a package to.
105
+ *
106
+ * Read from the lockfile rather than the manifest, because the manifest states
107
+ * a *range* and the lockfile states what `npm ci` will actually install — which
108
+ * is the thing a bump moves. A caret range that already admits the new version
109
+ * needs no manifest change at all, and reporting the range would then show a
110
+ * bump as changing nothing.
111
+ *
112
+ * @param {object} lock - The parsed `package-lock.json`.
113
+ * @param {string} name - The package name.
114
+ * @returns {string|undefined} The locked version, if the lockfile holds one.
115
+ */
116
+ export function lockedVersion(lock, name) {
117
+ return lock?.packages?.[`node_modules/${name}`]?.version;
118
+ }
119
+
120
+ /**
121
+ * Take the newest published version of one or more dependencies.
122
+ *
123
+ * npm performs the resolution — so a bump that changes the dependency set is
124
+ * as correct as one that moves three lines — and both JSON files are restored
125
+ * to the indentation they already used.
126
+ *
127
+ * @param {object} options - Options.
128
+ * @param {string} options.rootDir - The repository root holding `package.json`.
129
+ * @param {string[]} [options.packages] - Packages to bump. Defaults to every
130
+ * first-party dependency the manifest declares.
131
+ * @param {string} [options.tag] - The dist-tag to take. Defaults to `latest`.
132
+ * @param {boolean} [options.check] - Report what would change and write nothing.
133
+ * @param {(command: string, args: string[], cwd: string) => void} [options.run] -
134
+ * How to invoke npm. Injected by the tests, which have no registry.
135
+ * @returns {{changes: Array<{name: string, from: string|undefined, to: string|undefined}>,
136
+ * unchanged: string[], reindented: string[], checked: boolean}} What moved.
137
+ */
138
+ export function bumpDependencies({ rootDir, packages, tag = "latest", check = false, run }) {
139
+ const manifestPath = path.join(rootDir, "package.json");
140
+ const lockPath = path.join(rootDir, "package-lock.json");
141
+
142
+ if (!fs.existsSync(manifestPath)) {
143
+ throw new Error(`no package.json at ${rootDir}`);
144
+ }
145
+ if (!fs.existsSync(lockPath)) {
146
+ throw new Error(
147
+ `no package-lock.json at ${rootDir}: this bump moves the lockfile, ` +
148
+ `which is what \`npm ci\` resolves from`,
149
+ );
150
+ }
151
+
152
+ const manifestText = fs.readFileSync(manifestPath, "utf8");
153
+ const lockText = fs.readFileSync(lockPath, "utf8");
154
+ const manifest = JSON.parse(manifestText);
155
+ const lockBefore = JSON.parse(lockText);
156
+
157
+ const declared = declaredDependencies(manifest);
158
+ const targets = packages?.length ? packages : firstPartyDependencies(manifest);
159
+
160
+ if (targets.length === 0) {
161
+ return { changes: [], unchanged: [], reindented: [], checked: check };
162
+ }
163
+
164
+ const unknown = targets.filter((name) => !declared.includes(name));
165
+ if (unknown.length > 0) {
166
+ throw new Error(
167
+ `not a declared dependency: ${unknown.join(", ")} — ` +
168
+ `a bump takes a newer version of something already depended on`,
169
+ );
170
+ }
171
+
172
+ const before = new Map(targets.map((name) => [name, lockedVersion(lockBefore, name)]));
173
+
174
+ if (check) {
175
+ const latest = (name) =>
176
+ execFileSync("npm", ["view", `${name}@${tag}`, "version"], {
177
+ cwd: rootDir,
178
+ encoding: "utf8",
179
+ }).trim();
180
+ const changes = [];
181
+ const unchanged = [];
182
+ for (const name of targets) {
183
+ const to = latest(name);
184
+ if (to && to !== before.get(name)) changes.push({ name, from: before.get(name), to });
185
+ else unchanged.push(name);
186
+ }
187
+ return { changes, unchanged, reindented: [], checked: true };
188
+ }
189
+
190
+ const invoke =
191
+ run ?? ((command, args, cwd) => execFileSync(command, args, { cwd, stdio: "pipe" }));
192
+
193
+ invoke(
194
+ "npm",
195
+ ["install", "--package-lock-only", ...targets.map((name) => `${name}@${tag}`)],
196
+ rootDir,
197
+ );
198
+
199
+ // Restore what npm reformatted. Both files, because npm rewrites the
200
+ // manifest too when a range has to move — which below 1.0 it always does,
201
+ // a caret there being locked to the minor.
202
+ const reindented = [];
203
+ if (reindentJsonFile(lockPath, detectJsonIndent(lockText))) reindented.push(lockPath);
204
+ if (reindentJsonFile(manifestPath, detectJsonIndent(manifestText))) {
205
+ reindented.push(manifestPath);
206
+ }
207
+
208
+ const lockAfter = JSON.parse(fs.readFileSync(lockPath, "utf8"));
209
+ const changes = [];
210
+ const unchanged = [];
211
+ for (const name of targets) {
212
+ const to = lockedVersion(lockAfter, name);
213
+ if (to !== before.get(name)) changes.push({ name, from: before.get(name), to });
214
+ else unchanged.push(name);
215
+ }
216
+
217
+ return { changes, unchanged, reindented, checked: false };
218
+ }
@@ -63,6 +63,7 @@ import {
63
63
  unknownBlockKeys,
64
64
  } from "./system-block.mjs";
65
65
  import { positionInFrontmatter, positionOfFrontmatterPath } from "./diagnostics.mjs";
66
+ import { pathnameProblem } from "./pathnames.mjs";
66
67
  import { checkHomepageAddressFields } from "./homepage.mjs";
67
68
  import { RETIRED_TYPES, RENAMED_TYPES, currentType, renamedTypeMessage } from "./ids.mjs";
68
69
  import { isAddressSegment } from "./address-charset.mjs";
@@ -70,7 +71,12 @@ import { isAddressSegment } from "./address-charset.mjs";
70
71
  // than repeated, because a linter holding its own copy of what the compiler
71
72
  // reads is exactly the disagreement to avoid.
72
73
  import { DEFAULT_PARENT } from "./folder-notes.mjs";
73
- import { declaredTags, subTypeCharsetMessage, typeCharsetMessage } from "./note-vocabulary.mjs";
74
+ import {
75
+ declaredTags,
76
+ exclusiveTagGroups,
77
+ subTypeCharsetMessage,
78
+ typeCharsetMessage,
79
+ } from "./note-vocabulary.mjs";
74
80
  import {
75
81
  RETIRED_FIELD_ALIASES,
76
82
  declaresRetiredAlias,
@@ -672,6 +678,14 @@ function checkSubType(note, { type, entry }) {
672
678
  * findings above become none while a settlement tagged `vilage` is still
673
679
  * caught.
674
680
  *
681
+ * **A group declared as a slot is checked twice over.** Its tags are the
682
+ * alternative answers to one question — a being is a `character` or a
683
+ * `creature` — so two of them on one note is refused outright, by
684
+ * {@link checkExclusiveTags}, on top of the near miss every group gets. An
685
+ * *unfilled* slot is not a finding: the kind is authored deliberately, and
686
+ * nothing can tell a being nobody has classified yet from one the author means
687
+ * to leave unclassified.
688
+ *
675
689
  * @param {object} note - The note.
676
690
  * @param {object} opts
677
691
  * @param {string} opts.type - The note's declared `type`, which scopes the
@@ -706,6 +720,55 @@ function checkTags(note, { type }) {
706
720
  return findings;
707
721
  }
708
722
 
723
+ /**
724
+ * Refuse a note that fills one single-valued tag slot twice.
725
+ *
726
+ * This is the whole of what "a closed tag vocabulary" can mean while `tags:`
727
+ * stays open. A slot's values are alternatives, not attributes: a being is a
728
+ * `character` or a `creature`, so a note carrying both has answered the
729
+ * question twice and every reader of the tag — an index, a query, a renderer —
730
+ * gets to pick. That is the same silent wrongness a misspelt classifying tag
731
+ * is, and it gets the same answer.
732
+ *
733
+ * **An error, not a warning**, because a warning does not change the build's
734
+ * exit code and a contradiction that still ships is a contradiction nobody
735
+ * fixes.
736
+ *
737
+ * **Located on the `tags` key**, because the fault is the list rather than
738
+ * either entry in it — both values are correctly spelt, and pointing at one of
739
+ * them would say the wrong one is the wrong one.
740
+ *
741
+ * @param {object} note - The note.
742
+ * @param {object} opts
743
+ * @param {string} opts.type - The note's declared `type`, which scopes the
744
+ * slots checked.
745
+ * @returns {object[]} Findings.
746
+ */
747
+ function checkExclusiveTags(note, { type }) {
748
+ const authored = (note.fm ?? {}).tags;
749
+ if (!Array.isArray(authored)) return [];
750
+
751
+ const carried = new Set(
752
+ authored.filter((t) => typeof t === "string" && t.trim()).map((t) => t.trim()),
753
+ );
754
+ const findings = [];
755
+ for (const { slot, tags } of exclusiveTagGroups(type)) {
756
+ const filled = tags.filter((t) => carried.has(t));
757
+ if (filled.length < 2) continue;
758
+ findings.push({
759
+ file: note.file,
760
+ ...positionInFrontmatter(note.raw ?? "", "tags"),
761
+ severity: "error",
762
+ message:
763
+ `a ${type}'s ${slot} is one tag, and this note carries ` +
764
+ `${filled.map((t) => `"${t}"`).join(" and ")}. Those are the alternative ` +
765
+ `answers to one question (${tags.join(", ")}), so carrying two of them ` +
766
+ `states no ${slot} at all. Keep the one that is true, or neither`,
767
+ });
768
+ }
769
+ return findings;
770
+ }
771
+
709
772
  /**
710
773
  * The frontmatter fields that name artwork, and so resolve through
711
774
  * {@link module:engine/helpers.resolveImg}.
@@ -725,7 +788,7 @@ function checkTags(note, { type }) {
725
788
  *
726
789
  * @type {readonly {key: string, inData: boolean}[]}
727
790
  */
728
- const ART_FIELDS = Object.freeze([
791
+ export const ART_FIELDS = Object.freeze([
729
792
  Object.freeze({ key: "img", inData: false }),
730
793
  Object.freeze({ key: "portrait", inData: true }),
731
794
  ]);
@@ -1120,6 +1183,29 @@ export function lintNote(
1120
1183
  });
1121
1184
  }
1122
1185
 
1186
+ // A pathname written in Foundry's own spelling. It resolves for Foundry and
1187
+ // for neither of the other two surfaces, which have no such directory — so
1188
+ // the website serves a 404 and the book prints a caption with no picture,
1189
+ // and nothing in either build has any reason to look twice at a string that
1190
+ // parses. An **error**: the replacement is mechanical and named in the
1191
+ // message, and a tree that has not been swept should stop rather than
1192
+ // publish two broken surfaces out of three.
1193
+ for (const { key, inData } of ART_FIELDS) {
1194
+ const authored = authoredValue(fm, key, {
1195
+ inData,
1196
+ blockCollides: blockCollisions.has(key),
1197
+ });
1198
+ if (typeof authored !== "string") continue;
1199
+ const problem = pathnameProblem(authored);
1200
+ if (!problem) continue;
1201
+ findings.push({
1202
+ file: note.file,
1203
+ ...at(key, authored),
1204
+ severity: "error",
1205
+ message: problem,
1206
+ });
1207
+ }
1208
+
1123
1209
  for (const { key, inData } of ART_FIELDS) {
1124
1210
  // Reported above, and the distinction this draws does not exist there:
1125
1211
  // where nothing is emitted, `""` and `null` are equally inert and the
@@ -1219,6 +1305,7 @@ export function lintNote(
1219
1305
  // type's property — `draft` belongs to any note and `village` to a place —
1220
1306
  // so the finding must survive the early returns below.
1221
1307
  findings.push(...checkTags(note, { type }));
1308
+ findings.push(...checkExclusiveTags(note, { type }));
1222
1309
 
1223
1310
  // A refused field must be one the note *wrote*: `resolveNoteId` fills
1224
1311
  // `fm.id` in place, so the parsed frontmatter carries a derived id the
@@ -32,6 +32,8 @@ import yaml from "yaml";
32
32
  import unidecode from "unidecode";
33
33
  import markdownit from "markdown-it";
34
34
  import { iconPlugin } from "./content-icons.mjs";
35
+ import { imagePlugin } from "./content-images.mjs";
36
+ import { resolvePathname } from "./pathnames.mjs";
35
37
  import log from "loglevel";
36
38
 
37
39
  import { loadPackConfig } from "./pack-config.mjs";
@@ -77,19 +79,41 @@ export {
77
79
  * instruction to write `<i class="fa-solid …">` by hand: that would render on
78
80
  * the two HTML surfaces and be silently dropped by the third.
79
81
  */
80
- export const md = markdownit({ html: true }).use(
81
- // Resolved per render, not at import: this constant is built before any
82
- // configuration is read, and a package's own icons live in the
83
- // configuration. A tree with none or a caller with no configuration to
84
- // findfalls back to the shipped table.
85
- iconPlugin(() => {
86
- try {
87
- return loadPackConfig().icons;
88
- } catch {
89
- return undefined;
90
- }
91
- }),
92
- );
82
+ export const md = markdownit({ html: true })
83
+ .use(
84
+ // Resolved per render, not at import: this constant is built before any
85
+ // configuration is read, and a package's own icons live in the
86
+ // configuration. A tree with none or a caller with no configuration to
87
+ // find — falls back to the shipped table.
88
+ iconPlugin(() => {
89
+ try {
90
+ return loadPackConfig().icons;
91
+ } catch {
92
+ return undefined;
93
+ }
94
+ }),
95
+ )
96
+ // The width and position an image states, honoured as a figure. The
97
+ // vocabularies are closed and need no configuration; the address does, and
98
+ // is resolved per render for the reason the icon registry is — Foundry
99
+ // serves a file from inside the install, so a body image's address is
100
+ // translated by the same rule `img:` follows.
101
+ .use(
102
+ imagePlugin((src) => {
103
+ // **Reported elsewhere, never here.** A renderer has no channel to
104
+ // report through, and this one runs inside the very passes whose
105
+ // job is to collect findings — a throw would take the whole lint
106
+ // down and lose every other finding in the tree. A pathname this
107
+ // rule refuses is a Foundry address, so emitting it unchanged is
108
+ // right on the one surface this renderer serves, and the passes
109
+ // that own the other three refuse it with a line and a column.
110
+ try {
111
+ return resolveImg(src, loadPackConfig()) ?? src;
112
+ } catch {
113
+ return src;
114
+ }
115
+ }),
116
+ );
93
117
 
94
118
  /**
95
119
  * Parses a markdown file with YAML frontmatter.
@@ -423,155 +447,66 @@ export function makeFilename(name, id) {
423
447
  }
424
448
 
425
449
  /**
426
- * The path prefixes that name a package other than the one being compiled.
427
- *
428
- * Foundry serves every installed package from a root named for its kind, so a
429
- * path opening with one of these is already a served address and belongs to
430
- * somebody else most often `systems/sohl/assets/…`, where every default this
431
- * toolchain ships lives, and which a module's content cites as readily as the
432
- * system's own does.
433
- *
434
- * **Two, not "the ones we happen to use".** `worlds/` is left out on purpose: a
435
- * package may not ship art out of a world, so a note that writes one has made a
436
- * mistake, and prefixing it yields a plainly broken path rather than a
437
- * plausible one that fails silently much later.
438
- *
439
- * @type {readonly string[]}
440
- */
441
- const FOREIGN_PACKAGE_ROOTS = Object.freeze(["systems/", "modules/"]);
442
-
443
- /**
444
- * Whether a path already addresses something this package does not own, and so
445
- * must be emitted exactly as authored.
446
- *
447
- * Three shapes qualify, each a different kind of "not mine":
448
- *
449
- * - **Another package** — `systems/…` or `modules/…`, per
450
- * {@link FOREIGN_PACKAGE_ROOTS}.
451
- * - **Somewhere off this install** — a URI scheme (`https:`, `data:`) or a
452
- * protocol-relative `//cdn…`.
453
- * - **The data root itself** — a leading `/`, which Foundry serves from the
454
- * install rather than from any package.
455
- *
456
- * @param {string} s - A non-empty authored path.
457
- * @returns {boolean} Whether it passes through untranslated.
458
- */
459
- function addressesAnotherPackage(s) {
460
- if (FOREIGN_PACKAGE_ROOTS.some((root) => s.startsWith(root))) return true;
461
- // `//host/x.png` — protocol-relative, so it leaves this origin entirely.
462
- // Checked before the single-slash case, which would otherwise claim it.
463
- if (s.startsWith("//")) return true;
464
- // `/x.png` — rooted at the Foundry data root, not at any package.
465
- if (s.startsWith("/")) return true;
466
- // `https://…`, `data:…`, `file:…` — a scheme, so not a path at all.
467
- return /^[a-z][a-z0-9+.-]*:/i.test(s);
468
- }
469
-
470
- /**
471
- * Translate a content-relative image path into its Foundry-relative form.
472
- *
473
- * Content frontmatter (`img` / `portrait`) authors a single path that has to
474
- * work for Foundry, the knowledgebase, and the website. **Its first segment
475
- * says which package owns the file**, and there are exactly three
476
- * answers:
477
- *
478
- * | Authored path starts with | Owner | Emitted |
479
- * | ------------------------- | --------------------- | -------------------- |
480
- * | `systems/` | a separate **system** | unchanged |
481
- * | `modules/` | a separate **module** | unchanged |
482
- * | anything else | **this package** | `<assetRoot>/<path>` |
483
- *
484
- * So `icons/relic.svg` compiles to `systems/sohl/assets/icons/relic.svg` here
485
- * and to `modules/sohl-thalorna/assets/icons/relic.svg` in a module — the asset
486
- * root is derived from the configuration, and is the one place `systems/sohl`
487
- * is ever spelled. An authored
488
- * `systems/sohl/assets/icons/noun/shield.svg` is left exactly as written,
489
- * whichever package is compiling it.
490
- *
491
- * **This is a rule about ownership, not an allowlist of directories.** It used
492
- * to prefix `icons/…` and `images/…` and pass everything else through — the
493
- * same answer for every path any tree authors today, and the wrong one for the
494
- * next directory a package ships. `sohl-kethira-basic` keeps art under
495
- * `assets/artwork/`, so an authored `artwork/deity.webp` would have shipped
496
- * unprefixed: a 404 in Foundry, reported by nothing. That a package owns its
497
- * own tree is the fact; the directory names inside it are that package's
498
- * business.
499
- *
500
- * **Off-install addresses pass through too**, which is the same rule rather
501
- * than a fourth: a URL, a `data:` URI, or a `/`-rooted path names something no
502
- * package owns. See {@link addressesAnotherPackage}.
503
- *
504
- * **`banner:` does not follow this rule, deliberately.** It is not an
505
- * asset path inside a Foundry install at all: it reaches no compiled document,
506
- * and its only consumer is the Hugo theme, which prefixes a relative value with
507
- * `images/` and joins it onto `params.cdnBaseURL`. The two fields look alike
508
- * and address different places — `img:` a file Foundry serves, `banner:` a file
509
- * the CDN serves — so they are documented apart rather than reconciled into one
510
- * rule that would be true of neither.
511
- *
512
- * **Two empties, and they mean opposite things.** `null` — or an absent
513
- * key, which reaches here as `undefined` — means _unset_: the note names no art
514
- * and the caller's default applies. `""` means _blank on purpose_: the note
515
- * names no art **and wants none**, so no default may replace it. Both come back
450
+ * Translate an authored pathname into the address a Foundry install serves.
451
+ *
452
+ * The Foundry half of {@link module:engine/pathnames.resolvePathname}, which
453
+ * states the rule and derives the other three surfaces from the same
454
+ * statement. Kept as its own function because the compilers want one address
455
+ * and nothing else, and because the default a caller applies to an unset one is
456
+ * domain-specific actors default differently from items, and gear differently
457
+ * again — so each compiler owns its default and applies it with **nullish**
458
+ * coalescing: `resolveImg(fm.img) ?? <default>`. Not `||`, which would collapse
459
+ * a deliberate blank back into the default.
460
+ *
461
+ * **Two empties, and they mean opposite things.** `null` — or an absent key,
462
+ * which reaches here as `undefined` — means _unset_: the note names no art and
463
+ * the caller's default applies. `""` means _blank on purpose_: the note names
464
+ * no art **and wants none**, so no default may replace it. Both come back
516
465
  * distinguishable, `null` and `""` respectively, and neither is invented from
517
466
  * the other.
518
467
  *
519
- * This used to open `if (!raw) return ""`, which made the two one case: every
520
- * caller then applied its default with `||`, so a deliberate blank was
521
- * unspellable and an unset key and an empty string compiled identically. That
522
- * is the convention the project already rejects for an optional "not specified"
523
- * DataModel string, where `nullable, initial: null` keeps "unset" a single
524
- * honest value rather than two.
525
- *
526
468
  * **`title` does not follow this rule**, and must not be made to. On a
527
469
  * `type: affiliation` note `title` is *also* a declared item field whose default
528
470
  * is `""` (`sohl/item-fields.mjs`), resolved from the very same shared top-level
529
471
  * key the site emitter reads as the page title — so `title: null` stringifies
530
472
  * into the compiled document as the literal `"null"`. One key, two destinations
531
- * that disagree about what empty means; see.
473
+ * that disagree about what empty means.
532
474
  *
533
- * This is translation only: the default for an unset path is domain-specific
534
- * (actors default differently from items, and gear differently again), so each
535
- * compiler owns its own default and applies it to the result with **nullish**
536
- * coalescing `resolveImg(fm.img) ?? <default>`. Not `||`: that would collapse
537
- * a deliberate blank back into the default and undo the distinction. For items
538
- * that default is the art paired with the type's builder, reached through
539
- * `itemArt()`, which runs the path back through this function so a registry
540
- * entry and a note's `img:` are spelled the same way (#7).
475
+ * **`banner:` does not follow it either, deliberately.** It is not a file
476
+ * inside a Foundry install: it reaches no compiled document and no book, its
477
+ * only consumer is the Hugo theme, and the theme resolves it against the site's
478
+ * own asset root. See `docs/content-format.md`.
541
479
  *
542
- * **A package with no asset root cannot answer at all.** `assetRoot` is derived
543
- * from the package kind, and a `documentation` package has none: Foundry serves
544
- * no files for it. Only a compiling pass reaches here, and a documentation
545
- * package runs none, so a path arriving with no root to put it under is a pass
546
- * running where it should not — reported as that, rather than emitted as
547
- * `null/icons/relic.svg` into a document nobody would check.
480
+ * For items, the default is the art paired with the type's builder, reached
481
+ * through `itemArt()`, which runs the pathname back through this function so a
482
+ * registry entry and a note's `img:` are spelled the same way.
548
483
  *
549
- * @param {string | null | undefined} raw - content-relative path from frontmatter.
484
+ * @param {string | null | undefined} raw - The pathname from frontmatter.
550
485
  * @param {{assetRoot: string|null}} [config] - The resolved build configuration.
551
486
  * Defaults to this repository's.
552
487
  * @returns {string | null} the Foundry-relative path; `""` for a deliberate
553
488
  * blank, and `null` when the note names no art at all.
554
- * @throws {Error} When the configuration has no asset root.
489
+ * @throws {Error} When no Foundry address can be derived — a `documentation`
490
+ * package, which Foundry installs nothing of, or a pathname naming a package
491
+ * this build declares no relationship with.
555
492
  */
556
493
  export function resolveImg(raw, config = loadPackConfig()) {
557
- // Unset the caller's default applies. An absent key arrives as
558
- // `undefined`, an authored one as `null`; they say the same thing.
559
- if (raw == null) return null;
560
- const s = String(raw);
561
- // Blank on purpose — the caller's default must not apply.
562
- if (s === "") return "";
563
- // Somebody else's to serve — emit it exactly as authored.
564
- if (addressesAnotherPackage(s)) return s;
565
- if (!config.assetRoot) {
494
+ const forms = resolvePathname(raw, config);
495
+ if (forms === null) return null;
496
+ if (forms.foundry !== null) return forms.foundry;
497
+ if (forms.own) {
566
498
  throw new Error(
567
- `package-build: \`${s}\` names a file this package serves, and a ` +
568
- `\`documentation\` package has no asset root to serve it from — ` +
569
- `Foundry installs no such package. Address the owning package ` +
570
- `(\`systems/…\`, \`modules/…\`) or a URL.`,
499
+ `package-build: \`${forms.authored}\` names a file this package serves, ` +
500
+ `and a \`documentation\` package has no asset root to serve it from — ` +
501
+ `Foundry installs no such package. Address a \`/\`-rooted path or a URL.`,
571
502
  );
572
503
  }
573
- // Ours, so root it where Foundry serves this package's files from.
574
- return `${config.assetRoot}/${s}`;
504
+ throw new Error(
505
+ `package-build: \`${forms.authored}\` names a file the \`${forms.package}\` ` +
506
+ `package ships, and this build declares no relationship with a package of ` +
507
+ `that name, so there is no Foundry address to derive. Declare it under ` +
508
+ `\`relationships\`, or address the file by a \`/\`-rooted path.`,
509
+ );
575
510
  }
576
511
 
577
512
  /**
@@ -804,6 +739,10 @@ export function buildContentLinkIndex(
804
739
  pack: router.resolveOrNull(fm, packForType(fm.type).docType),
805
740
  docPack: router.resolveOrNull(fm, "JournalEntry"),
806
741
  shortcode: fm.shortcode ?? null,
742
+ // What the note *is*, carried so a caller resolving a reference
743
+ // can group by the family its target declares rather than only by
744
+ // where the target lives.
745
+ subType: fm.subType ?? null,
807
746
  name: fm.name?.full ?? base,
808
747
  // Whether the note is tagged `draft`. Read from the tag
809
748
  // vocabulary that declares it, and used for one thing: a link
package/engine/index.mjs CHANGED
@@ -96,6 +96,15 @@ export * as contentAddress from "./content-address.mjs";
96
96
  /** Which note-type → document-subtype maps this toolchain ships. */
97
97
  export * as subtypeRegistry from "./subtype-registry.mjs";
98
98
 
99
+ /** The declared infobox: what a note's summary panel holds, in every medium. */
100
+ export * as infobox from "./infobox.mjs";
101
+
102
+ /** The infobox declarations this toolchain ships, one per system. */
103
+ export * as infoboxRegistry from "./infobox-registry.mjs";
104
+
105
+ /** Drawing a declared infobox as HTML for Foundry and as Typst for the book. */
106
+ export * as infoboxRender from "./infobox-render.mjs";
107
+
99
108
  /** The id a note's document is filed under: its pin, or its address. */
100
109
  export * as noteIds from "./note-ids.mjs";
101
110
 
@@ -126,6 +135,12 @@ export * as contentIcons from "./content-icons.mjs";
126
135
  /** Raw HTML in a note's prose, which no book renderer can read. */
127
136
  export * as contentHtml from "./content-html.mjs";
128
137
 
138
+ /** An image saying how wide it is and where it sits, in two closed vocabularies. */
139
+ export * as contentImages from "./content-images.mjs";
140
+
141
+ /** One authored pathname, and the four addresses the surfaces derive from it. */
142
+ export * as pathnames from "./pathnames.mjs";
143
+
129
144
  /** Resolving every link in a tree, and the ones that land nowhere. */
130
145
  export * as contentLinks from "./content-links.mjs";
131
146