@heroiclands/package-build 20.7.0 → 21.0.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 +159 -0
- package/CONTENT.md +134 -44
- package/bin/content-build.mjs +37 -5
- package/bin/package-build.mjs +77 -0
- package/content-config.mjs +59 -1
- package/docs/api.md +149 -19
- package/docs/commands.md +75 -0
- package/docs/configuration.md +37 -10
- package/docs/content-format.md +450 -49
- package/engine/content-format.mjs +52 -3
- package/engine/content-images.mjs +699 -0
- package/engine/dependency-bump.mjs +218 -0
- package/engine/frontmatter-lint.mjs +89 -2
- package/engine/helpers.mjs +81 -142
- package/engine/index.mjs +15 -0
- package/engine/infobox-registry.mjs +81 -0
- package/engine/infobox-render.mjs +381 -0
- package/engine/infobox.mjs +963 -0
- package/engine/item-registry.mjs +5 -5
- package/engine/journals.mjs +22 -1
- package/engine/map-notes.mjs +11 -5
- package/engine/metadata-index.mjs +5 -0
- package/engine/note-vocabulary.mjs +57 -2
- package/engine/pathnames.mjs +374 -0
- package/engine/pdf-build.mjs +208 -9
- package/engine/pdf-render.mjs +461 -21
- package/engine/pdf-toc.mjs +77 -5
- package/engine/scenes.mjs +2 -1
- package/engine/site-build.mjs +106 -7
- package/engine/site-index.mjs +93 -4
- package/engine/wikilinks.mjs +93 -0
- package/hm3/default-item-art.mjs +14 -15
- package/hm3/index.mjs +3 -0
- package/hm3/infobox.mjs +64 -0
- package/package.json +1 -1
- package/sohl/default-item-art.mjs +18 -16
- package/sohl/index.mjs +3 -0
- package/sohl/infobox.mjs +499 -0
- package/types/content-config.d.mts +7 -0
- package/types/engine/content-format.d.mts +36 -0
- package/types/engine/content-images.d.mts +281 -0
- package/types/engine/dependency-bump.d.mts +89 -0
- package/types/engine/frontmatter-lint.d.mts +23 -0
- package/types/engine/helpers.d.mts +30 -72
- package/types/engine/index.d.mts +5 -0
- package/types/engine/infobox-registry.d.mts +36 -0
- package/types/engine/infobox-render.d.mts +87 -0
- package/types/engine/infobox.d.mts +443 -0
- package/types/engine/item-registry.d.mts +5 -5
- package/types/engine/journals.d.mts +9 -1
- package/types/engine/note-vocabulary.d.mts +51 -0
- package/types/engine/pathnames.d.mts +189 -0
- package/types/engine/pdf-build.d.mts +46 -0
- package/types/engine/pdf-render.d.mts +99 -1
- package/types/engine/pdf-toc.d.mts +10 -5
- package/types/engine/site-build.d.mts +11 -3
- package/types/engine/site-index.d.mts +35 -3
- package/types/engine/wikilinks.d.mts +22 -0
- package/types/hm3/default-item-art.d.mts +5 -6
- package/types/hm3/index.d.mts +1 -0
- package/types/hm3/infobox.d.mts +22 -0
- package/types/sohl/index.d.mts +1 -0
- 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 {
|
|
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
|
package/engine/helpers.mjs
CHANGED
|
@@ -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 })
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
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
|
-
*
|
|
427
|
-
*
|
|
428
|
-
* Foundry
|
|
429
|
-
*
|
|
430
|
-
*
|
|
431
|
-
*
|
|
432
|
-
*
|
|
433
|
-
*
|
|
434
|
-
*
|
|
435
|
-
*
|
|
436
|
-
*
|
|
437
|
-
*
|
|
438
|
-
*
|
|
439
|
-
*
|
|
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
|
|
473
|
+
* that disagree about what empty means.
|
|
532
474
|
*
|
|
533
|
-
*
|
|
534
|
-
*
|
|
535
|
-
*
|
|
536
|
-
*
|
|
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
|
-
*
|
|
543
|
-
*
|
|
544
|
-
*
|
|
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 -
|
|
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
|
|
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
|
-
|
|
558
|
-
|
|
559
|
-
if (
|
|
560
|
-
|
|
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: \`${
|
|
568
|
-
|
|
569
|
-
`Foundry installs no such package. Address
|
|
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
|
-
|
|
574
|
-
|
|
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
|
|