@heroiclands/package-build 4.0.0 → 6.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 +401 -0
- package/CONTENT.md +207 -23
- package/MIGRATING.md +114 -0
- package/README.md +30 -0
- package/bin/content-build.mjs +26 -5
- package/bin/package-build.mjs +3 -1
- package/content-config.mjs +109 -6
- package/engine/base-compiler.mjs +28 -3
- package/engine/content-links.mjs +221 -2
- package/engine/content-lint.mjs +12 -3
- package/engine/diagnostics.mjs +46 -0
- package/engine/generate.mjs +156 -7
- package/engine/homepage.mjs +259 -0
- package/engine/index.mjs +6 -0
- package/engine/manifest-emit.mjs +2 -1
- package/engine/note-schemas.mjs +44 -0
- package/engine/pack-config.mjs +21 -0
- package/engine/site-build.mjs +140 -4
- package/manifest.mjs +195 -0
- package/package.json +1 -1
- package/sohl/actors.mjs +14 -1
- package/sohl/item-fields.mjs +0 -5
- package/sohl/kb-passes.mjs +81 -14
- package/sohl/note-schemas.mjs +8 -0
- package/types/content-config.d.mts +105 -4
- package/types/engine/base-compiler.d.mts +22 -0
- package/types/engine/content-links.d.mts +53 -2
- package/types/engine/diagnostics.d.mts +28 -0
- package/types/engine/generate.d.mts +50 -2
- package/types/engine/homepage.d.mts +125 -0
- package/types/engine/index.d.mts +2 -0
- package/types/engine/note-schemas.d.mts +6 -0
- package/types/engine/pack-config.d.mts +13 -0
- package/types/engine/site-build.d.mts +54 -0
- package/types/manifest.d.mts +67 -1
- package/types/sohl/kb-passes.d.mts +5 -1
- package/types/sohl/note-schemas.d.mts +6 -0
package/engine/generate.mjs
CHANGED
|
@@ -23,10 +23,15 @@
|
|
|
23
23
|
* Each `*` compiler walks the whole content tree and selects its own entries by
|
|
24
24
|
* the note's `type` — every note in the tree belongs to this repository's
|
|
25
25
|
* `contentPackage` (#56) — so routing is directory-agnostic: a file lands in a
|
|
26
|
-
* pack because of its `type`, not its location. Which packs exist
|
|
27
|
-
* hierarchy each one loads are
|
|
28
|
-
* folder files live under the content root and are
|
|
29
|
-
* frontmatter via `sohl.folder: <id>`.
|
|
26
|
+
* pack because of its `type`, not its location. Which packs exist and which
|
|
27
|
+
* folder hierarchy each one loads are declared in
|
|
28
|
+
* `package-build.config.yaml`; folder files live under the content root and are
|
|
29
|
+
* referenced from entry frontmatter via `sohl.folder: <id>`.
|
|
30
|
+
*
|
|
31
|
+
* **The order the passes run in is derived, not declared** — see
|
|
32
|
+
* {@link orderPassesByDependency}. The declared list is the manifest's `packs`
|
|
33
|
+
* array as well, so it is ordered for a reader; a pass that reads another's
|
|
34
|
+
* output states that on its compiler and is scheduled after it (#73).
|
|
30
35
|
*
|
|
31
36
|
* This replaces the retired `packs:export` (vault → committed `_source/`); the
|
|
32
37
|
* HeroicLands vault is no longer a build input for SoHL content.
|
|
@@ -50,6 +55,7 @@ import {
|
|
|
50
55
|
writeFolderDocs,
|
|
51
56
|
} from "./helpers.mjs";
|
|
52
57
|
import { countContentNotes } from "./content-tree.mjs";
|
|
58
|
+
import { emitDiagnostic } from "./diagnostics.mjs";
|
|
53
59
|
import { loadPackConfig } from "./pack-config.mjs";
|
|
54
60
|
import { routerFor } from "./pack-router.mjs";
|
|
55
61
|
|
|
@@ -89,8 +95,9 @@ export const packJsonDir = (name, config = loadPackConfig()) =>
|
|
|
89
95
|
* and an actor's embedded items may be sourced from any of them. Finding one
|
|
90
96
|
* pack and stopping is how embedded-item resolution would silently miss every
|
|
91
97
|
* item that landed in another. Returned in configured order, which is also the
|
|
92
|
-
* order they compile in
|
|
93
|
-
* is written
|
|
98
|
+
* order they compile in — {@link orderPassesByDependency} keeps the declared
|
|
99
|
+
* order among packs of one type — and every one of them is written before the
|
|
100
|
+
* actors pass that reads them.
|
|
94
101
|
*
|
|
95
102
|
* @param {object} [config] - The resolved build configuration. Defaults to this
|
|
96
103
|
* repository's.
|
|
@@ -104,6 +111,126 @@ export function itemPackJsonDirs(config = loadPackConfig()) {
|
|
|
104
111
|
.map((pack) => packJsonDir(pack.name, config));
|
|
105
112
|
}
|
|
106
113
|
|
|
114
|
+
/**
|
|
115
|
+
* The document types whose compiled output a pass of this type reads.
|
|
116
|
+
*
|
|
117
|
+
* Asked of the compiler rather than held in a table here, so the dependency
|
|
118
|
+
* lives in the class that does the reading and a consumer's own compiler can
|
|
119
|
+
* declare its own. A type no compiler is registered for waits on nothing —
|
|
120
|
+
* {@link generatePack} reports it as an unknown type, which is the better
|
|
121
|
+
* message.
|
|
122
|
+
*
|
|
123
|
+
* @param {string} type - A pack's document type.
|
|
124
|
+
* @returns {readonly string[]} The types it reads the output of.
|
|
125
|
+
*/
|
|
126
|
+
function readsOutputOf(type) {
|
|
127
|
+
return COMPILERS[type]?.readsPackOutputOf ?? [];
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* The passes to run, ordered so that each one follows the output it reads.
|
|
132
|
+
*
|
|
133
|
+
* **Declaration order is presentation, not compile order (#73).** The same
|
|
134
|
+
* `packs:` list is the manifest's `packs` array, which a consumer orders for a
|
|
135
|
+
* reader browsing compendiums; the actors pass, meanwhile, resolves each
|
|
136
|
+
* being's embedded items against the item passes' *output*. Making one list
|
|
137
|
+
* satisfy both meant an Actor pack declared first compiled only where a
|
|
138
|
+
* previous run had already left `build/packs-json` populated — green on a warm
|
|
139
|
+
* tree, exit 1 on every fresh checkout and every CI runner, and `build/` is
|
|
140
|
+
* gitignored so that is the state CI always starts from.
|
|
141
|
+
*
|
|
142
|
+
* **The reordering is the smallest one that works.** Each step takes the
|
|
143
|
+
* *earliest declared* pass whose dependencies are all already emitted, so a
|
|
144
|
+
* list that was already in a workable order comes back untouched, and one that
|
|
145
|
+
* was not moves exactly the passes that had to move. A dependency is satisfied
|
|
146
|
+
* only when **every** pack of that type has run: a being addresses an item by
|
|
147
|
+
* `(type, shortcode)` without knowing which Item pack ships it, so waiting for
|
|
148
|
+
* one of several would resolve some beings and silently fail others.
|
|
149
|
+
*
|
|
150
|
+
* A dependency on a type this configuration declares no pack of is not waited
|
|
151
|
+
* for. A package may ship an Actor pack and no Item pack; the pass that needs
|
|
152
|
+
* one refuses on its own, with a message about items rather than about order.
|
|
153
|
+
*
|
|
154
|
+
* @param {readonly object[]} packs - The passes to be run, as declared.
|
|
155
|
+
* @returns {object[]} A new list, in compile order. The input is untouched.
|
|
156
|
+
* @throws {Error} If the passes read each other's output in a cycle, which no
|
|
157
|
+
* order can satisfy. Only reachable from a mis-declared compiler, so it names
|
|
158
|
+
* the passes rather than blaming the pack list.
|
|
159
|
+
*/
|
|
160
|
+
export function orderPassesByDependency(packs) {
|
|
161
|
+
const declared = new Set(packs.map((pack) => pack.type));
|
|
162
|
+
const remaining = [...packs];
|
|
163
|
+
const emitted = new Set();
|
|
164
|
+
/** @type {object[]} */
|
|
165
|
+
const ordered = [];
|
|
166
|
+
|
|
167
|
+
/** Whether every pack this pass reads the output of has already run. */
|
|
168
|
+
const ready = (pack) =>
|
|
169
|
+
readsOutputOf(pack.type).every(
|
|
170
|
+
(dependency) =>
|
|
171
|
+
!declared.has(dependency) ||
|
|
172
|
+
packs.every(
|
|
173
|
+
(other) =>
|
|
174
|
+
other.type !== dependency || emitted.has(other.name),
|
|
175
|
+
),
|
|
176
|
+
);
|
|
177
|
+
|
|
178
|
+
while (remaining.length) {
|
|
179
|
+
const next = remaining.findIndex(ready);
|
|
180
|
+
if (next === -1) {
|
|
181
|
+
throw new Error(
|
|
182
|
+
`package-build: the configured passes read each other's ` +
|
|
183
|
+
`output in a cycle (` +
|
|
184
|
+
`${remaining.map((pack) => `${pack.name} (${pack.type})`).join(", ")}` +
|
|
185
|
+
`); no compile order can satisfy that.`,
|
|
186
|
+
);
|
|
187
|
+
}
|
|
188
|
+
const [pack] = remaining.splice(next, 1);
|
|
189
|
+
emitted.add(pack.name);
|
|
190
|
+
ordered.push(pack);
|
|
191
|
+
}
|
|
192
|
+
return ordered;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/**
|
|
196
|
+
* The dependencies this run cannot satisfy by ordering, because the pass that
|
|
197
|
+
* would produce them is not in it.
|
|
198
|
+
*
|
|
199
|
+
* Ordering answers the whole-package build; a run restricted to one pack
|
|
200
|
+
* (`content-build package compile <name>`) cannot conjure the passes it left
|
|
201
|
+
* out. Where their output is already on disk from an earlier run that is fine
|
|
202
|
+
* — it is how compiling one pack at a time is meant to work — so this reports
|
|
203
|
+
* only what is genuinely absent, and names the pack that would write it rather
|
|
204
|
+
* than the directory that is missing.
|
|
205
|
+
*
|
|
206
|
+
* @param {readonly object[]} running - The passes this run will execute.
|
|
207
|
+
* @param {object} config - The resolved build configuration.
|
|
208
|
+
* @returns {string[]} One message per unsatisfiable dependency.
|
|
209
|
+
*/
|
|
210
|
+
export function unsatisfiedPassDependencies(running, config) {
|
|
211
|
+
const included = new Set(running.map((pack) => pack.name));
|
|
212
|
+
/** @type {string[]} */
|
|
213
|
+
const messages = [];
|
|
214
|
+
for (const pack of running) {
|
|
215
|
+
for (const dependency of readsOutputOf(pack.type)) {
|
|
216
|
+
for (const producer of config.packs) {
|
|
217
|
+
if (producer.type !== dependency) continue;
|
|
218
|
+
if (included.has(producer.name)) continue;
|
|
219
|
+
const dir = packJsonDir(producer.name, config);
|
|
220
|
+
if (fs.existsSync(dir)) continue;
|
|
221
|
+
messages.push(
|
|
222
|
+
`pack "${pack.name}" (${pack.type}) reads the compiled ` +
|
|
223
|
+
`output of the ${producer.type} pack ` +
|
|
224
|
+
`"${producer.name}", which this run does not compile ` +
|
|
225
|
+
`and which ${dir} does not hold — compile the whole ` +
|
|
226
|
+
`package, or compile "${producer.name}" first`,
|
|
227
|
+
);
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
return messages;
|
|
232
|
+
}
|
|
233
|
+
|
|
107
234
|
/**
|
|
108
235
|
* Generate the per-entry JSON for one pack into `build/packs-json/<name>/`.
|
|
109
236
|
*
|
|
@@ -280,9 +407,31 @@ export async function generatePacksJson({
|
|
|
280
407
|
if (!firstOfType.has(pack.type)) firstOfType.set(pack.type, pack.name);
|
|
281
408
|
}
|
|
282
409
|
|
|
410
|
+
// Compile order is derived from what each pass reads, not from the order
|
|
411
|
+
// `packs:` declares — that list is also the manifest's, which a consumer
|
|
412
|
+
// orders for a reader (#73).
|
|
413
|
+
const ordered = orderPassesByDependency(packs);
|
|
414
|
+
if (ordered.some((pack, index) => pack !== packs[index])) {
|
|
415
|
+
log.info(
|
|
416
|
+
`Pass order: ${ordered.map((pack) => pack.name).join(", ")} — a ` +
|
|
417
|
+
`pass that reads another's output compiles after it, whatever ` +
|
|
418
|
+
`order \`packs:\` declares.`,
|
|
419
|
+
);
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
// What ordering cannot reach: a run restricted to one pack, whose
|
|
423
|
+
// dependencies are simply not in it and not on disk either.
|
|
424
|
+
const unsatisfied = unsatisfiedPassDependencies(ordered, config);
|
|
425
|
+
if (unsatisfied.length) {
|
|
426
|
+
for (const message of unsatisfied) {
|
|
427
|
+
emitDiagnostic({ severity: "error", message });
|
|
428
|
+
}
|
|
429
|
+
return unsatisfied.length;
|
|
430
|
+
}
|
|
431
|
+
|
|
283
432
|
let totalErrors = 0;
|
|
284
433
|
const passes = [];
|
|
285
|
-
for (const pack of
|
|
434
|
+
for (const pack of ordered) {
|
|
286
435
|
const { errors, compiled } = await generatePack(
|
|
287
436
|
pack,
|
|
288
437
|
config,
|
|
@@ -0,0 +1,259 @@
|
|
|
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
|
+
* The package homepage — a note that compiles to a **page** rather than to a
|
|
16
|
+
* compendium document (#51).
|
|
17
|
+
*
|
|
18
|
+
* Every HeroicLands package is reachable at `https://www.heroiclands.org/<contentPackage>/`,
|
|
19
|
+
* and what a reader finds there is one markdown file in the content tree,
|
|
20
|
+
* written by a person: what the module is, which system it needs, how to install
|
|
21
|
+
* it, where its source lives. Nothing about it is derived.
|
|
22
|
+
*
|
|
23
|
+
* **Authored, not assembled.** An earlier sketch generated the page in tiers —
|
|
24
|
+
* identity and licence from the manifest, install URL from the release address,
|
|
25
|
+
* "requires" links from `relationships`, a card per configured section. It would
|
|
26
|
+
* have worked and needed almost no authoring, and it produces a page nobody
|
|
27
|
+
* chose the contents of. The things that matter most on these pages cannot be
|
|
28
|
+
* derived: that Kethira requires buying the book from Keléstia, what Thalorna's
|
|
29
|
+
* setting *is*, which of twenty sections a reader should start with. So the only
|
|
30
|
+
* thing defaulted here is the title, from `packageBuild.manifest.title`, so that
|
|
31
|
+
* the package's name is not written twice.
|
|
32
|
+
*
|
|
33
|
+
* **Dispatched by `type`, not by filename.** A fixed `homepage.md` the walker
|
|
34
|
+
* special-cased would be the anomaly: notes are routed by frontmatter, not by
|
|
35
|
+
* location, and `NOTE_SCHEMAS` already routes `doc`, `macro`, `being` and the
|
|
36
|
+
* map types. `homepage` is one more entry whose compile step emits a page. It is
|
|
37
|
+
* deliberately not `README.md`: `landing: readme` already means "a `README.md`
|
|
38
|
+
* is its section's landing page", and `sohl-thalorna/assets/content/README.md`
|
|
39
|
+
* is a developer explainer about the source tree — adopting that name would make
|
|
40
|
+
* Thalorna's public front page its build documentation.
|
|
41
|
+
*
|
|
42
|
+
* **Engine, not `sohl/`.** The `engine/` ÷ `sohl/` line separates *note-format*
|
|
43
|
+
* knowledge from *game-system* knowledge, and a homepage is note format: it
|
|
44
|
+
* carries no `system` block, mirrors no item builder, and would mean the same
|
|
45
|
+
* thing for a game system that is not SoHL. Reachability is the symptom that
|
|
46
|
+
* makes it obvious — `HarnMaster-3-FoundryVTT` declares no `itemBuilders`, so a
|
|
47
|
+
* type living in the SoHL registry would be unavailable to HM3 and to every HM3
|
|
48
|
+
* module, which is most of the packages that need a homepage and nothing else.
|
|
49
|
+
*
|
|
50
|
+
* **Its address is the package's, not the note's.** A homepage publishes at
|
|
51
|
+
* `/<contentPackage>/` because that is where the package is, so `name.full`,
|
|
52
|
+
* `shortcode` and `id` decide nothing on it (#53 refuses them outright; this
|
|
53
|
+
* module simply never reads them). It compiles into no document, so it carries
|
|
54
|
+
* no compendium UUID and appears in no pack and in no link-manifest entry.
|
|
55
|
+
*
|
|
56
|
+
* @module
|
|
57
|
+
*/
|
|
58
|
+
|
|
59
|
+
import { matchAllOutsideCode } from "./code-fences.mjs";
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* The note type that compiles to the package homepage.
|
|
63
|
+
*
|
|
64
|
+
* @type {string}
|
|
65
|
+
*/
|
|
66
|
+
export const HOMEPAGE_TYPE = "homepage";
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* What a homepage note may write under `sohl:` — nothing.
|
|
70
|
+
*
|
|
71
|
+
* Empty on purpose, and declared rather than omitted: a type with no vocabulary
|
|
72
|
+
* and a type that is unknown are different findings, and only the second is an
|
|
73
|
+
* authoring error. The whole envelope is the two top-level keys `type` and an
|
|
74
|
+
* optional `title`; there is no game-system data on a page that compiles to no
|
|
75
|
+
* document.
|
|
76
|
+
*
|
|
77
|
+
* @type {readonly import("./field-spec.mjs").FieldSpec[]}
|
|
78
|
+
*/
|
|
79
|
+
export const HOMEPAGE_FIELDS = Object.freeze([]);
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Where a homepage is written, relative to the package's site root.
|
|
83
|
+
*
|
|
84
|
+
* Hugo's section landing, because the page *is* the package's landing: the
|
|
85
|
+
* package root is a section and this is its index.
|
|
86
|
+
*
|
|
87
|
+
* @type {string}
|
|
88
|
+
*/
|
|
89
|
+
export const HOMEPAGE_DESTINATION = "_index.md";
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Whether a note's frontmatter declares the homepage type.
|
|
93
|
+
*
|
|
94
|
+
* @param {object|null|undefined} fm - Parsed frontmatter.
|
|
95
|
+
* @returns {boolean} Whether it is a homepage note.
|
|
96
|
+
*/
|
|
97
|
+
export function isHomepage(fm) {
|
|
98
|
+
return Boolean(fm) && fm.type === HOMEPAGE_TYPE;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* The title a homepage publishes under.
|
|
103
|
+
*
|
|
104
|
+
* The one defaulted value on the page, and it defaults to the package's own
|
|
105
|
+
* `packageBuild.manifest.title` — the name Foundry already shows for the
|
|
106
|
+
* package — so a homepage that adds nothing to it need not restate it. An
|
|
107
|
+
* authored `title` wins, because a front page is allowed to greet a reader
|
|
108
|
+
* differently from a package browser.
|
|
109
|
+
*
|
|
110
|
+
* Falls back to `contentPackage` last, so a package that has no manifest of its
|
|
111
|
+
* own still yields a titled page rather than a blank heading.
|
|
112
|
+
*
|
|
113
|
+
* @param {object|null|undefined} fm - The note's frontmatter.
|
|
114
|
+
* @param {object} config - The resolved configuration.
|
|
115
|
+
* @returns {string} The title.
|
|
116
|
+
*/
|
|
117
|
+
export function homepageTitle(fm, config) {
|
|
118
|
+
const authored = fm?.title;
|
|
119
|
+
if (typeof authored === "string" && authored.trim()) return authored;
|
|
120
|
+
const manifest = /** @type {Record<string, unknown>|undefined} */ (
|
|
121
|
+
config?.packageBuild?.manifest
|
|
122
|
+
);
|
|
123
|
+
const title = manifest?.title;
|
|
124
|
+
return typeof title === "string" && title.trim() ?
|
|
125
|
+
title
|
|
126
|
+
: config.contentPackage;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* The frontmatter a homepage publishes with.
|
|
131
|
+
*
|
|
132
|
+
* The note's own, plus the two derived values every emitted page carries: the
|
|
133
|
+
* resolved `title`, and the package the build derived — no note declares one
|
|
134
|
+
* (`package:` is retired, #56) and the theme's breadcrumb partial reads
|
|
135
|
+
* `.Params.package`.
|
|
136
|
+
*
|
|
137
|
+
* An authored `aliases` is dropped for the same reason it is on every other
|
|
138
|
+
* page: Obsidian reads it as names a reader might call the note, Hugo reads it
|
|
139
|
+
* as URL redirects, and passing it through would publish a redirect stub at
|
|
140
|
+
* each one.
|
|
141
|
+
*
|
|
142
|
+
* @param {object} fm - The note's frontmatter.
|
|
143
|
+
* @param {object} options - Options.
|
|
144
|
+
* @param {string} options.contentPackage - The package this build publishes.
|
|
145
|
+
* @param {string} options.title - The resolved title.
|
|
146
|
+
* @returns {object} The frontmatter to write.
|
|
147
|
+
*/
|
|
148
|
+
export function homepageFrontmatter(fm, { contentPackage, title }) {
|
|
149
|
+
const data = { ...fm, package: contentPackage, title };
|
|
150
|
+
delete data.aliases;
|
|
151
|
+
return data;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* An inline markdown link — `[text](target)`, but not an image.
|
|
156
|
+
*
|
|
157
|
+
* Reference-style links are deliberately not matched: a landing's prose fields
|
|
158
|
+
* are single YAML scalars with nowhere to put a link definition, so a `[x][y]`
|
|
159
|
+
* in one could never resolve and is not an address anybody wrote.
|
|
160
|
+
*
|
|
161
|
+
* @type {RegExp}
|
|
162
|
+
*/
|
|
163
|
+
const MARKDOWN_LINK = /(?<!!)\[[^\]]*\]\(\s*([^)\s]+)(?:\s+"[^"]*")?\s*\)/g;
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* The two frontmatter keys that hold an address, and what each one means.
|
|
167
|
+
*
|
|
168
|
+
* They are **not** interchangeable, and a check that treated them as one would
|
|
169
|
+
* be wrong about both. The theme resolves a `url` against the site with
|
|
170
|
+
* `relURL`, so a package writes `kb/rules/` and is served `/sohl/kb/rules/`
|
|
171
|
+
* without ever naming its own prefix. An `href` is an address that is *already*
|
|
172
|
+
* resolved and is used verbatim — which is what `cards.source: sections` fills
|
|
173
|
+
* in, since a section's permalink already carries the prefix.
|
|
174
|
+
*
|
|
175
|
+
* So a leading `/` is a defect in a `url` (it is prefixed a second time) and
|
|
176
|
+
* correct in an `href`.
|
|
177
|
+
*
|
|
178
|
+
* @type {ReadonlySet<string>}
|
|
179
|
+
*/
|
|
180
|
+
export const HOMEPAGE_ADDRESS_KEYS = Object.freeze(new Set(["url", "href"]));
|
|
181
|
+
|
|
182
|
+
/**
|
|
183
|
+
* Collect the markdown links in one prose value.
|
|
184
|
+
*
|
|
185
|
+
* @param {string} text - The value.
|
|
186
|
+
* @param {string} field - Where it came from.
|
|
187
|
+
* @param {string} kind - The address kind to record.
|
|
188
|
+
* @param {object[]} out - Accumulator.
|
|
189
|
+
* @param {boolean} [skipCode] - Whether to ignore links inside code.
|
|
190
|
+
*/
|
|
191
|
+
function collectProse(text, field, kind, out, skipCode = false) {
|
|
192
|
+
const pattern = new RegExp(MARKDOWN_LINK.source, "g");
|
|
193
|
+
const matches =
|
|
194
|
+
skipCode ?
|
|
195
|
+
matchAllOutsideCode(text, pattern)
|
|
196
|
+
: [...text.matchAll(pattern)];
|
|
197
|
+
for (const m of matches) out.push({ field, url: m[1], kind });
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/**
|
|
201
|
+
* Every address a homepage carries, wherever it is written.
|
|
202
|
+
*
|
|
203
|
+
* **Both halves of the page are in scope, and that is the finding rather than
|
|
204
|
+
* the assumption.** Of the six homepages authored today, four carry every link
|
|
205
|
+
* in the body as ordinary markdown and two carry them in `landing:` — and the
|
|
206
|
+
* one whose dead links prompted the check has an *empty body*, so a body-only
|
|
207
|
+
* reading would have found nothing at all on it. A dead link in a card is
|
|
208
|
+
* exactly as broken as one in a paragraph.
|
|
209
|
+
*
|
|
210
|
+
* Three shapes are gathered, and the caller needs to tell them apart because
|
|
211
|
+
* the rules differ:
|
|
212
|
+
*
|
|
213
|
+
* - **`url`** — package-relative, resolved against the site by the theme.
|
|
214
|
+
* - **`href`** — already resolved, used verbatim.
|
|
215
|
+
* - **prose and body markdown links** — emitted as written and resolved by the
|
|
216
|
+
* browser against the landing's own address, which *is* the package root, so
|
|
217
|
+
* a relative one means the same thing a `url` does.
|
|
218
|
+
*
|
|
219
|
+
* `banner:` is not an address: it is an image path resolved through the CDN
|
|
220
|
+
* base, and `banner: none` is a sentinel rather than a target. Top-level
|
|
221
|
+
* `title` and `description` are not walked either — they are set as text, never
|
|
222
|
+
* rendered as markdown.
|
|
223
|
+
*
|
|
224
|
+
* @param {object|null|undefined} fm - The note's frontmatter.
|
|
225
|
+
* @param {string} [body] - The note's markdown body.
|
|
226
|
+
* @returns {Array<{field: string, url: string, kind: string}>} Every address,
|
|
227
|
+
* frontmatter first and then the body, each with the dotted path it was
|
|
228
|
+
* written at.
|
|
229
|
+
*/
|
|
230
|
+
export function homepageAddresses(fm, body = "") {
|
|
231
|
+
const out = [];
|
|
232
|
+
|
|
233
|
+
const walk = (value, field) => {
|
|
234
|
+
if (typeof value === "string") {
|
|
235
|
+
collectProse(value, field, "prose", out);
|
|
236
|
+
return;
|
|
237
|
+
}
|
|
238
|
+
if (Array.isArray(value)) {
|
|
239
|
+
value.forEach((v, i) => walk(v, `${field}[${i}]`));
|
|
240
|
+
return;
|
|
241
|
+
}
|
|
242
|
+
if (!value || typeof value !== "object") return;
|
|
243
|
+
for (const [key, v] of Object.entries(value)) {
|
|
244
|
+
const child = `${field}.${key}`;
|
|
245
|
+
// An address field holds an address, not prose: reading it for
|
|
246
|
+
// markdown links as well would report the same target twice
|
|
247
|
+
// whenever one happened to look like a link.
|
|
248
|
+
if (HOMEPAGE_ADDRESS_KEYS.has(key) && typeof v === "string") {
|
|
249
|
+
out.push({ field: child, url: v, kind: key });
|
|
250
|
+
continue;
|
|
251
|
+
}
|
|
252
|
+
walk(v, child);
|
|
253
|
+
}
|
|
254
|
+
};
|
|
255
|
+
|
|
256
|
+
walk(fm?.landing, "landing");
|
|
257
|
+
collectProse(String(body ?? ""), "body", "body", out, true);
|
|
258
|
+
return out;
|
|
259
|
+
}
|
package/engine/index.mjs
CHANGED
|
@@ -60,6 +60,12 @@ export * as notePackage from "./note-package.mjs";
|
|
|
60
60
|
/** Frontmatter fields a note may no longer declare, and the refusal of them. */
|
|
61
61
|
export * as retiredFields from "./retired-fields.mjs";
|
|
62
62
|
|
|
63
|
+
/** The package homepage: the note type that compiles to a page, not a document. */
|
|
64
|
+
export * as homepage from "./homepage.mjs";
|
|
65
|
+
|
|
66
|
+
/** The note types the engine itself declares, whatever a consumer registers. */
|
|
67
|
+
export * as noteSchemas from "./note-schemas.mjs";
|
|
68
|
+
|
|
63
69
|
/** The shipped Foundry manifest: locating it, reading it, guarding its id. */
|
|
64
70
|
|
|
65
71
|
/** The URL a content note is published at — the one web-address rule. */
|
package/engine/manifest-emit.mjs
CHANGED
|
@@ -59,6 +59,7 @@ import { assertNoDraftField } from "./retired-fields.mjs";
|
|
|
59
59
|
import { journalPageId, splitPages } from "./journals.mjs";
|
|
60
60
|
import { routerFor } from "./pack-router.mjs";
|
|
61
61
|
import { loadPackConfig } from "./pack-config.mjs";
|
|
62
|
+
import { publishesContentPages } from "../content-config.mjs";
|
|
62
63
|
|
|
63
64
|
/**
|
|
64
65
|
* The reserved anchor name for a journal's **first** page.
|
|
@@ -271,7 +272,7 @@ export function manifestContext(config = loadPackConfig()) {
|
|
|
271
272
|
foundryPackageId: config.foundryPackage,
|
|
272
273
|
packRouter: routerFor(config),
|
|
273
274
|
scheme: config.publish.address,
|
|
274
|
-
web: config
|
|
275
|
+
web: publishesContentPages(config),
|
|
275
276
|
// The walk's own configuration, threaded through rather than left to
|
|
276
277
|
// its default, so a caller that passes a config drives every read.
|
|
277
278
|
skipDirectories: config.skipDirectories,
|
|
@@ -0,0 +1,44 @@
|
|
|
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
|
+
* The note types the **engine** declares — the ones whose vocabulary is a fact
|
|
16
|
+
* about the note format rather than about any game system (#51).
|
|
17
|
+
*
|
|
18
|
+
* `sohl/note-schemas.mjs` is the other half, and the line between them is the
|
|
19
|
+
* `engine/` ÷ `sohl/` line everywhere else in this package: note-format
|
|
20
|
+
* knowledge here, game-system knowledge there. It is not a permission boundary
|
|
21
|
+
* between consumers — every content project authors the full vocabulary — but it
|
|
22
|
+
* is a reachability one in exactly one direction. A package that declares no
|
|
23
|
+
* `itemBuilders` (`HarnMaster-3-FoundryVTT`, and every HM3 module) uses only the
|
|
24
|
+
* packaging half of the toolchain, so a type declared in the SoHL registry would
|
|
25
|
+
* be unavailable to it. These are the types every package has, whatever it
|
|
26
|
+
* ships.
|
|
27
|
+
*
|
|
28
|
+
* One entry today. A consumer merges it under its own registry —
|
|
29
|
+
* `{ ...ENGINE_NOTE_SCHEMAS, ...NOTE_SCHEMAS }` — so a game system may extend
|
|
30
|
+
* these but the engine's declaration stands wherever no registry is configured.
|
|
31
|
+
*
|
|
32
|
+
* @module
|
|
33
|
+
*/
|
|
34
|
+
|
|
35
|
+
import { HOMEPAGE_FIELDS, HOMEPAGE_TYPE } from "./homepage.mjs";
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Every engine-level content type, and what a note of that type may write.
|
|
39
|
+
*
|
|
40
|
+
* @type {Readonly<Record<string, readonly import("./field-spec.mjs").FieldSpec[]>>}
|
|
41
|
+
*/
|
|
42
|
+
export const ENGINE_NOTE_SCHEMAS = Object.freeze({
|
|
43
|
+
[HOMEPAGE_TYPE]: HOMEPAGE_FIELDS,
|
|
44
|
+
});
|
package/engine/pack-config.mjs
CHANGED
|
@@ -453,6 +453,9 @@ function loadCodeConfig(configPath) {
|
|
|
453
453
|
/** The loaded configuration, memoised — the file is read at most once. */
|
|
454
454
|
let loaded;
|
|
455
455
|
|
|
456
|
+
/** The file {@link loadPackConfig} read, alongside the memoised result. */
|
|
457
|
+
let loadedFrom;
|
|
458
|
+
|
|
456
459
|
/**
|
|
457
460
|
* The consuming repository's resolved, frozen configuration.
|
|
458
461
|
*
|
|
@@ -492,5 +495,23 @@ export function loadPackConfig() {
|
|
|
492
495
|
YAML.parse(fs.readFileSync(configPath, "utf8")),
|
|
493
496
|
configPath,
|
|
494
497
|
);
|
|
498
|
+
loadedFrom = configPath;
|
|
495
499
|
return loaded;
|
|
496
500
|
}
|
|
501
|
+
|
|
502
|
+
/**
|
|
503
|
+
* The file {@link loadPackConfig} resolved the configuration from.
|
|
504
|
+
*
|
|
505
|
+
* A diagnostic about a *configured* value has to name the file it was declared
|
|
506
|
+
* in, and re-deriving that path at the point of the finding would be a second
|
|
507
|
+
* resolution free to disagree with the first — the `PACKAGE_BUILD_CONFIG`
|
|
508
|
+
* override, the upward walk and the one-file-per-directory rule all have to
|
|
509
|
+
* come out the same way. This reports the path actually read.
|
|
510
|
+
*
|
|
511
|
+
* @returns {string} Its absolute path.
|
|
512
|
+
* @throws {Error} As {@link loadPackConfig}, when there is no configuration.
|
|
513
|
+
*/
|
|
514
|
+
export function packConfigPath() {
|
|
515
|
+
loadPackConfig();
|
|
516
|
+
return /** @type {string} */ (loadedFrom);
|
|
517
|
+
}
|