@heroiclands/package-build 0.6.1 → 3.0.1
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-content-build.md +965 -0
- package/CHANGELOG.md +68 -0
- package/CONTENT.md +824 -0
- package/MIGRATING.md +127 -0
- package/README.md +29 -17
- package/bin/content-build.mjs +1082 -0
- package/bin/package-build.mjs +2 -2
- package/bin/report.mjs +2 -2
- package/config.mjs +21 -18
- package/content-config.mjs +1424 -0
- package/coverage.mjs +1 -1
- package/e2e.mjs +1 -1
- package/engine/abbreviations.mjs +0 -0
- package/engine/base-compiler.mjs +608 -0
- package/engine/code-fences.mjs +311 -0
- package/engine/compendiums.mjs +353 -0
- package/engine/content-address.mjs +141 -0
- package/engine/content-links.mjs +438 -0
- package/engine/content-lint.mjs +204 -0
- package/engine/content-package.mjs +67 -0
- package/engine/content-slug.mjs +169 -0
- package/engine/content-tables.mjs +1216 -0
- package/engine/content-tree.mjs +56 -0
- package/engine/diagnostics.mjs +270 -0
- package/engine/field-reference.mjs +242 -0
- package/engine/field-spec.mjs +218 -0
- package/engine/foreign-catalog.mjs +450 -0
- package/engine/foreign-manifests.mjs +129 -0
- package/engine/frontmatter-lint.mjs +343 -0
- package/engine/frontmatter.mjs +258 -0
- package/engine/generate.mjs +305 -0
- package/engine/helpers.mjs +809 -0
- package/engine/ids.mjs +192 -0
- package/engine/index.mjs +142 -0
- package/engine/item-docs.mjs +153 -0
- package/engine/item-registry.mjs +129 -0
- package/engine/journals.mjs +374 -0
- package/engine/kb-manifest.mjs +473 -0
- package/engine/macros.mjs +333 -0
- package/engine/manifest-emit.mjs +339 -0
- package/engine/map-notes.mjs +1302 -0
- package/engine/pack-config.mjs +479 -0
- package/engine/pack-router.mjs +254 -0
- package/engine/prose-config.mjs +238 -0
- package/engine/prose-lint.mjs +294 -0
- package/engine/region-events.mjs +79 -0
- package/engine/scene-levels.mjs +420 -0
- package/engine/scenes.mjs +528 -0
- package/engine/site-build.mjs +778 -0
- package/engine/site-index.mjs +332 -0
- package/engine/web-wikilinks.mjs +330 -0
- package/engine/wikilink-syntax.mjs +105 -0
- package/engine/wikilinks.mjs +645 -0
- package/index.mjs +42 -25
- package/lang.mjs +1 -1
- package/manifest.mjs +79 -5
- package/markdownlint-config.mjs +45 -0
- package/package.json +78 -35
- package/prettier-config.mjs +34 -0
- package/sohl/actors.mjs +581 -0
- package/sohl/affiliation-standings.mjs +44 -0
- package/sohl/being-info.mjs +173 -0
- package/sohl/default-item-art.mjs +82 -0
- package/sohl/index.mjs +55 -0
- package/sohl/item-builders.mjs +114 -0
- package/sohl/item-fields.mjs +867 -0
- package/sohl/items.mjs +197 -0
- package/sohl/kb-passes.mjs +207 -0
- package/sohl/note-schemas.mjs +322 -0
- package/templates.mjs +1 -1
- package/types/config.d.mts +1 -1
- package/types/content-config.d.mts +571 -0
- package/types/engine/abbreviations.d.mts +44 -0
- package/types/engine/base-compiler.d.mts +320 -0
- package/types/engine/code-fences.d.mts +102 -0
- package/types/engine/compendiums.d.mts +77 -0
- package/types/engine/content-address.d.mts +61 -0
- package/types/engine/content-links.d.mts +87 -0
- package/types/engine/content-lint.d.mts +47 -0
- package/types/engine/content-package.d.mts +36 -0
- package/types/engine/content-slug.d.mts +59 -0
- package/types/engine/content-tables.d.mts +131 -0
- package/types/engine/content-tree.d.mts +12 -0
- package/types/engine/diagnostics.d.mts +152 -0
- package/types/engine/field-reference.d.mts +20 -0
- package/types/engine/field-spec.d.mts +181 -0
- package/types/engine/foreign-catalog.d.mts +103 -0
- package/types/engine/foreign-manifests.d.mts +43 -0
- package/types/engine/frontmatter-lint.d.mts +74 -0
- package/types/engine/frontmatter.d.mts +99 -0
- package/types/engine/generate.d.mts +56 -0
- package/types/engine/helpers.d.mts +348 -0
- package/types/engine/ids.d.mts +126 -0
- package/types/engine/index.d.mts +33 -0
- package/types/engine/item-docs.d.mts +73 -0
- package/types/engine/item-registry.d.mts +57 -0
- package/types/engine/journals.d.mts +121 -0
- package/types/engine/kb-manifest.d.mts +214 -0
- package/types/engine/macros.d.mts +201 -0
- package/types/engine/manifest-emit.d.mts +137 -0
- package/types/engine/map-notes.d.mts +283 -0
- package/types/engine/pack-config.d.mts +77 -0
- package/types/engine/pack-router.d.mts +53 -0
- package/types/engine/prose-config.d.mts +121 -0
- package/types/engine/prose-lint.d.mts +59 -0
- package/types/engine/region-events.d.mts +43 -0
- package/types/engine/scene-levels.d.mts +36 -0
- package/types/engine/scenes.d.mts +48 -0
- package/types/engine/site-build.d.mts +214 -0
- package/types/engine/site-index.d.mts +141 -0
- package/types/engine/web-wikilinks.d.mts +67 -0
- package/types/engine/wikilink-syntax.d.mts +101 -0
- package/types/engine/wikilinks.d.mts +172 -0
- package/types/index.d.mts +3 -0
- package/types/manifest.d.mts +52 -4
- package/types/markdownlint-config.d.mts +8 -0
- package/types/prettier-config.d.mts +1 -0
- package/types/sohl/actors.d.mts +65 -0
- package/types/sohl/affiliation-standings.d.mts +22 -0
- package/types/sohl/being-info.d.mts +78 -0
- package/types/sohl/default-item-art.d.mts +28 -0
- package/types/sohl/index.d.mts +7 -0
- package/types/sohl/item-builders.d.mts +11 -0
- package/types/sohl/item-fields.d.mts +20 -0
- package/types/sohl/items.d.mts +19 -0
- package/types/sohl/kb-passes.d.mts +71 -0
- package/types/sohl/note-schemas.d.mts +7 -0
|
@@ -0,0 +1,332 @@
|
|
|
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 address index a site build resolves its wikilinks against.**
|
|
16
|
+
*
|
|
17
|
+
* Every consumer that publishes a content tree as a website has to answer the
|
|
18
|
+
* same question — given `[[Something]]`, which page? — and every one of them
|
|
19
|
+
* answered it with its own copy of the same 150 lines. `sohl`'s and
|
|
20
|
+
* `sohl-thalorna`'s site builds still share 147 identical lines of it, comments
|
|
21
|
+
* and indentation aside. This is that shared half, lifted out whole.
|
|
22
|
+
*
|
|
23
|
+
* **What stays with the consumer: how a page gets its address.** The URL
|
|
24
|
+
* scheme, the section a note is filed under, whether developer docs are part of
|
|
25
|
+
* the site at all — those genuinely differ, and the two builds differ on all
|
|
26
|
+
* three. So this takes *entries that already know their own URL* and does the
|
|
27
|
+
* part that is the same everywhere: index them, index their aliases, merge the
|
|
28
|
+
* foreign packages in, and report what cannot be addressed unambiguously.
|
|
29
|
+
*
|
|
30
|
+
* **Three key spaces, one map.**
|
|
31
|
+
*
|
|
32
|
+
* - `section/slug` and `type/shortcode` are unique by construction, so they
|
|
33
|
+
* always resolve. `type/shortcode` is the authored form; the canonical
|
|
34
|
+
* `package-type-shortcode` is set alongside it, which is what a cross-package
|
|
35
|
+
* link and every merged foreign entry use (#1499).
|
|
36
|
+
* - A bare name, filename, or slug is a **collision-aware fallback**: a key
|
|
37
|
+
* that would map to two different pages is dropped and remembered, so
|
|
38
|
+
* `[[Name]]` on it fails the build rather than silently picking one. The
|
|
39
|
+
* author disambiguates with `[[section/slug|Label]]`.
|
|
40
|
+
* - Aliases are indexed **scoped to their type**, which is what makes a bare
|
|
41
|
+
* `[[Shock]]` resolvable when "Shock" is both a rules page and a trauma item.
|
|
42
|
+
* Two notes *of the same type* sharing a name poison it, and the author
|
|
43
|
+
* writes `[[type/shortcode|Text]]`.
|
|
44
|
+
*
|
|
45
|
+
* **It reports rather than exits.** A build script owns its diagnostics and its
|
|
46
|
+
* exit code; this returns what it found. That is the same rule the rest of the
|
|
47
|
+
* engine follows, and it is what lets these cases be tested at all.
|
|
48
|
+
*
|
|
49
|
+
* @module
|
|
50
|
+
*/
|
|
51
|
+
|
|
52
|
+
import path from "node:path";
|
|
53
|
+
|
|
54
|
+
import { canonicalKey, readCanonicalKey } from "./kb-manifest.mjs";
|
|
55
|
+
import { hasDocEntry } from "./item-docs.mjs";
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* One page the site will publish, as the index needs to see it.
|
|
59
|
+
*
|
|
60
|
+
* @typedef {object} SiteEntry
|
|
61
|
+
* @property {string} kind `"content"` for a note compiled from the content
|
|
62
|
+
* tree, anything else for a page that carries no
|
|
63
|
+
* `type`/`shortcode` (a developer doc, say). Only
|
|
64
|
+
* content entries take part in type-scoped indexing.
|
|
65
|
+
* @property {object} fm The note's frontmatter.
|
|
66
|
+
* @property {string} name Display name.
|
|
67
|
+
* @property {string} slug URL segment.
|
|
68
|
+
* @property {string} sec Section the page is filed under.
|
|
69
|
+
* @property {string} base Source file's basename, e.g. `Climbing.md`.
|
|
70
|
+
* @property {string} url The page's published address.
|
|
71
|
+
* @property {boolean} isReadme Whether the page is its section's landing.
|
|
72
|
+
*/
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* The resolved index and everything a wikilink resolver reads beside it.
|
|
76
|
+
*
|
|
77
|
+
* @typedef {object} SiteIndex
|
|
78
|
+
* @property {Map<string, {url: string, name?: string}>} index Address → page.
|
|
79
|
+
* @property {Set<string>} ambiguous Keys claimed by two pages, and so
|
|
80
|
+
* deliberately absent from `index`.
|
|
81
|
+
* @property {Map<string, {url: string, name?: string}>} typeAlias `type|alias`.
|
|
82
|
+
* @property {Set<string>} typeCollide Type-scoped aliases claimed twice.
|
|
83
|
+
* @property {Set<string>} contentTypes Every type the resolver should read as
|
|
84
|
+
* an address qualifier, local and foreign.
|
|
85
|
+
* @property {Set<string>} sections Section names, lowercased.
|
|
86
|
+
* @property {Map<string, {name: string, url: string}>} refIndex `type:shortcode`
|
|
87
|
+
* → page, for callers resolving embedded
|
|
88
|
+
* references (a being's items, say).
|
|
89
|
+
* @property {{key: string, package: string}[]} conflicts Addresses claimed by
|
|
90
|
+
* more than one package. Non-empty is a
|
|
91
|
+
* build failure; the caller reports it.
|
|
92
|
+
*/
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Add a collision-aware fallback key.
|
|
96
|
+
*
|
|
97
|
+
* First writer wins *until* a second, different page claims the key — at which
|
|
98
|
+
* point the key is removed and blacklisted, so neither page answers to it. That
|
|
99
|
+
* is deliberate: resolving to whichever note happened to be walked first is a
|
|
100
|
+
* silently wrong link, and a failed build is not.
|
|
101
|
+
*
|
|
102
|
+
* @param {Map<string, object>} index - The index being built.
|
|
103
|
+
* @param {Set<string>} collide - Keys already found ambiguous.
|
|
104
|
+
* @param {string} key - The candidate key, in any case.
|
|
105
|
+
* @param {{url: string}} value - The page it would resolve to.
|
|
106
|
+
*/
|
|
107
|
+
function addFallback(index, collide, key, value) {
|
|
108
|
+
const k = String(key).toLowerCase();
|
|
109
|
+
if (collide.has(k)) return;
|
|
110
|
+
const cur = index.get(k);
|
|
111
|
+
if (cur && cur.url !== value.url) {
|
|
112
|
+
index.delete(k);
|
|
113
|
+
collide.add(k);
|
|
114
|
+
} else if (!cur) {
|
|
115
|
+
index.set(k, value);
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Merge the packages this build does not publish into the local index.
|
|
121
|
+
*
|
|
122
|
+
* Every canonical key is globally unique, so a foreign manifest merges straight
|
|
123
|
+
* in — one map, one lookup, no precedence rule. A key already present is a
|
|
124
|
+
* genuine conflict: two packages claiming one address, which is the case the
|
|
125
|
+
* canonical form exists to make detectable.
|
|
126
|
+
*
|
|
127
|
+
* The short `type/shortcode` form is merged too, because a bare `[[doc-xyz]]`
|
|
128
|
+
* carries no package and must still find a foreign note when exactly one
|
|
129
|
+
* package publishes that address. Claimed by two, it is genuinely ambiguous and
|
|
130
|
+
* the author writes the qualified form — the same rule the type-scoped aliases
|
|
131
|
+
* use, one level out. **Local wins**: a live build is authoritative and a
|
|
132
|
+
* vendored manifest can only be staler, so a short key the local tree already
|
|
133
|
+
* claims is left alone.
|
|
134
|
+
*
|
|
135
|
+
* @param {Map<string, object>} index - The local index, mutated.
|
|
136
|
+
* @param {Map<string, {package: string, type?: string}>} foreignIndex - Merged in.
|
|
137
|
+
* @returns {{key: string, package: string}[]} Addresses claimed twice.
|
|
138
|
+
*/
|
|
139
|
+
function mergeForeign(index, foreignIndex) {
|
|
140
|
+
const conflicts = [];
|
|
141
|
+
const short = new Map();
|
|
142
|
+
const ambiguous = new Set();
|
|
143
|
+
|
|
144
|
+
for (const [key, value] of foreignIndex) {
|
|
145
|
+
if (index.has(key)) {
|
|
146
|
+
conflicts.push({ key, package: value.package });
|
|
147
|
+
continue;
|
|
148
|
+
}
|
|
149
|
+
index.set(key, value);
|
|
150
|
+
|
|
151
|
+
const parts = readCanonicalKey(key);
|
|
152
|
+
if (!parts) continue;
|
|
153
|
+
const shortKey = `${parts.type}/${parts.shortcode}`;
|
|
154
|
+
if (
|
|
155
|
+
short.has(shortKey) &&
|
|
156
|
+
short.get(shortKey).package !== value.package
|
|
157
|
+
) {
|
|
158
|
+
ambiguous.add(shortKey);
|
|
159
|
+
} else {
|
|
160
|
+
short.set(shortKey, value);
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
for (const key of ambiguous) short.delete(key);
|
|
165
|
+
for (const [key, value] of short) {
|
|
166
|
+
if (!index.has(key)) index.set(key, value);
|
|
167
|
+
}
|
|
168
|
+
return conflicts;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* Build the address index a site's wikilink resolver reads.
|
|
173
|
+
*
|
|
174
|
+
* @param {readonly SiteEntry[]} entries - Every page the site will publish,
|
|
175
|
+
* each already knowing its own `url`.
|
|
176
|
+
* @param {object} [options] - Cross-package inputs.
|
|
177
|
+
* @param {Map<string, {package: string, type?: string}>} [options.foreignIndex]
|
|
178
|
+
* The merged index from `loadForeignManifests`. Omit when the build publishes
|
|
179
|
+
* no cross-package links.
|
|
180
|
+
* @returns {SiteIndex} The index, and what could not be addressed unambiguously.
|
|
181
|
+
*/
|
|
182
|
+
export function buildSiteIndex(entries, { foreignIndex = new Map() } = {}) {
|
|
183
|
+
const index = new Map();
|
|
184
|
+
const ambiguous = new Set();
|
|
185
|
+
const typeAlias = new Map();
|
|
186
|
+
const typeCollide = new Set();
|
|
187
|
+
const contentTypes = new Set();
|
|
188
|
+
const sections = new Set();
|
|
189
|
+
const refIndex = new Map();
|
|
190
|
+
|
|
191
|
+
// `section/slug` is unique by construction; the rest are fallbacks.
|
|
192
|
+
for (const e of entries) {
|
|
193
|
+
sections.add(String(e.sec).toLowerCase());
|
|
194
|
+
const value = { url: e.url, name: e.name };
|
|
195
|
+
index.set(`${e.sec}/${e.slug}`.toLowerCase(), value);
|
|
196
|
+
addFallback(index, ambiguous, e.name, value);
|
|
197
|
+
if (!e.isReadme) {
|
|
198
|
+
addFallback(index, ambiguous, path.basename(e.base, ".md"), value);
|
|
199
|
+
}
|
|
200
|
+
addFallback(index, ambiguous, e.slug, value);
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
// A foreign package may use a type this build has never seen. Seeding those
|
|
204
|
+
// is what lets the resolver recognise `polity-xyz` as an address at all —
|
|
205
|
+
// without it the link reads as prose and silently loses its href.
|
|
206
|
+
for (const value of foreignIndex.values()) {
|
|
207
|
+
if (value.type) contentTypes.add(value.type);
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
// Merged *before* the local type-scoped pass below, so a local page always
|
|
211
|
+
// ends up owning its own canonical `package-type-shortcode` address: the
|
|
212
|
+
// local write lands last and wins. `loadForeignManifests` already excludes
|
|
213
|
+
// the local packages, so a manifest should never carry one — this is what
|
|
214
|
+
// makes that a belt-and-braces rather than the only thing standing between
|
|
215
|
+
// a stale vendored manifest and a shadowed local page.
|
|
216
|
+
//
|
|
217
|
+
// The corollary is that a conflict can only be reported against the keys
|
|
218
|
+
// that exist at this point — the addressing ones, `section/slug` and the
|
|
219
|
+
// bare fallbacks — which is precisely the overlap worth refusing.
|
|
220
|
+
const conflicts = mergeForeign(index, foreignIndex);
|
|
221
|
+
|
|
222
|
+
for (const e of entries) {
|
|
223
|
+
// A page with no type or shortcode — a developer doc — is addressable
|
|
224
|
+
// by section and name, and takes no part in type-scoped indexing.
|
|
225
|
+
if (e.kind !== "content") continue;
|
|
226
|
+
const type = String(e.fm.type).toLowerCase();
|
|
227
|
+
contentTypes.add(type);
|
|
228
|
+
const value = { url: e.url, name: e.name };
|
|
229
|
+
|
|
230
|
+
const shortcode = e.fm.shortcode;
|
|
231
|
+
if (typeof shortcode === "string" && shortcode) {
|
|
232
|
+
refIndex.set(`${e.fm.type}:${shortcode}`, {
|
|
233
|
+
name: e.name,
|
|
234
|
+
url: e.url,
|
|
235
|
+
});
|
|
236
|
+
index.set(`${type}/${shortcode}`.toLowerCase(), value);
|
|
237
|
+
// The canonical address alongside the short one. The short form
|
|
238
|
+
// stays because a bare `[[skill-lang]]` defaults to the citing
|
|
239
|
+
// note's own package and must keep resolving unchanged; the
|
|
240
|
+
// canonical form is what cross-package links use (#1499).
|
|
241
|
+
index.set(canonicalKey(e.fm.package, type, shortcode), value);
|
|
242
|
+
// In Foundry an item and its documentation are two documents, so
|
|
243
|
+
// `skill/wpnc` and `docskill/wpnc` are two UUIDs (#1362). Here the
|
|
244
|
+
// item note renders as one page which *is* its documentation, so
|
|
245
|
+
// the two qualifiers alias one URL and an anchor on either is an
|
|
246
|
+
// ordinary in-page anchor. One authored link, correct in both
|
|
247
|
+
// builds — restricted to the types that actually have an item doc,
|
|
248
|
+
// so a qualifier the packs would reject is reported broken here too.
|
|
249
|
+
if (hasDocEntry(type)) {
|
|
250
|
+
contentTypes.add(`doc${type}`);
|
|
251
|
+
index.set(`doc${type}/${shortcode}`.toLowerCase(), value);
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
const aliases = [
|
|
256
|
+
...(Array.isArray(e.fm.aliases) ? e.fm.aliases : []),
|
|
257
|
+
...(Array.isArray(e.fm.name?.aliases) ? e.fm.name.aliases : []),
|
|
258
|
+
e.name,
|
|
259
|
+
path.basename(e.base, ".md").replace(/_/g, " "),
|
|
260
|
+
].filter((a) => typeof a === "string" && a);
|
|
261
|
+
|
|
262
|
+
for (const alias of aliases) {
|
|
263
|
+
const key = `${type}|${alias}`.toLowerCase();
|
|
264
|
+
if (typeCollide.has(key)) continue;
|
|
265
|
+
const cur = typeAlias.get(key);
|
|
266
|
+
if (cur && cur.url !== value.url) {
|
|
267
|
+
typeAlias.delete(key);
|
|
268
|
+
typeCollide.add(key);
|
|
269
|
+
} else if (!cur) {
|
|
270
|
+
typeAlias.set(key, value);
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
return {
|
|
276
|
+
index,
|
|
277
|
+
ambiguous,
|
|
278
|
+
typeAlias,
|
|
279
|
+
typeCollide,
|
|
280
|
+
contentTypes,
|
|
281
|
+
sections,
|
|
282
|
+
refIndex,
|
|
283
|
+
conflicts,
|
|
284
|
+
};
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
/**
|
|
288
|
+
* The per-page context a wikilink resolver takes.
|
|
289
|
+
*
|
|
290
|
+
* Assembled here so a consumer spells out only what is genuinely its own — the
|
|
291
|
+
* source path, the citing note's type, and where errors collect — instead of
|
|
292
|
+
* restating the whole index every call. Both site builds wrote this object by
|
|
293
|
+
* hand, identically.
|
|
294
|
+
*
|
|
295
|
+
* @param {SiteIndex} built - The result of {@link buildSiteIndex}.
|
|
296
|
+
* @param {object} options - Per-page inputs.
|
|
297
|
+
* @param {string} options.src - Source path of the page being resolved, for
|
|
298
|
+
* diagnostics.
|
|
299
|
+
* @param {string|null} [options.type] - The citing note's type, which scopes a
|
|
300
|
+
* bare alias lookup.
|
|
301
|
+
* @param {object[]} options.errors - Collector the resolver appends to.
|
|
302
|
+
* @param {Map<string, object>} [options.foreignIndex] - The foreign index, for
|
|
303
|
+
* resolvers that distinguish a foreign hit from a local one.
|
|
304
|
+
* @param {boolean} [options.manifestsComplete] - Whether every package this
|
|
305
|
+
* build links into supplied a manifest. When false, a resolver may soften an
|
|
306
|
+
* unresolved cross-package link rather than fail.
|
|
307
|
+
* @returns {object} The resolver context.
|
|
308
|
+
*/
|
|
309
|
+
export function wikiContext(
|
|
310
|
+
built,
|
|
311
|
+
{
|
|
312
|
+
src,
|
|
313
|
+
type = null,
|
|
314
|
+
errors,
|
|
315
|
+
foreignIndex = new Map(),
|
|
316
|
+
manifestsComplete = true,
|
|
317
|
+
},
|
|
318
|
+
) {
|
|
319
|
+
return {
|
|
320
|
+
index: built.index,
|
|
321
|
+
foreign: foreignIndex,
|
|
322
|
+
manifestsComplete,
|
|
323
|
+
collide: built.ambiguous,
|
|
324
|
+
sections: built.sections,
|
|
325
|
+
typeAlias: built.typeAlias,
|
|
326
|
+
typeCollide: built.typeCollide,
|
|
327
|
+
contentTypes: built.contentTypes,
|
|
328
|
+
type,
|
|
329
|
+
errors,
|
|
330
|
+
src,
|
|
331
|
+
};
|
|
332
|
+
}
|
|
@@ -0,0 +1,330 @@
|
|
|
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
|
+
* Wikilink resolution for the knowledgebase build.
|
|
16
|
+
*
|
|
17
|
+
* The same authored links the pack compilers turn into Foundry `@UUID` enrichers
|
|
18
|
+
* (see `./wikilinks.mjs`) become site-local hrefs here:
|
|
19
|
+
*
|
|
20
|
+
* `[[type/shortcode|Text]]` → `[Text](/section/slug/)`
|
|
21
|
+
* `[[Text]]` → the same, via a type-scoped alias
|
|
22
|
+
* `[[type/shortcode#slug|Text]]` → `[Text](/section/slug/#slug)`
|
|
23
|
+
* `[[#slug|Text]]` → `[Text](#slug)`
|
|
24
|
+
*
|
|
25
|
+
* The KB *section* is not always the type: prose pages (`type: doc`) route by
|
|
26
|
+
* their `category`, so `doc/quickstart` lands on `/user-guide/sohl-quickstart/`.
|
|
27
|
+
* The caller supplies that mapping already resolved, in the index it builds.
|
|
28
|
+
*
|
|
29
|
+
* Lives here rather than in a consumer so every package resolves a link the
|
|
30
|
+
* same way. `sohl-thalorna` carried a forked copy of this as
|
|
31
|
+
* `utils/site-wikilinks.mjs`, already 3 KB adrift from the original, which is
|
|
32
|
+
* exactly the drift one rule with two implementations produces (#20).
|
|
33
|
+
*/
|
|
34
|
+
|
|
35
|
+
// Whether a target is an *address* rather than prose is read with the pack
|
|
36
|
+
// build's own rule, so the two builds cannot drift apart on it: they disagreed
|
|
37
|
+
// once over the unlabelled hyphen form, which the packs showed as a raw
|
|
38
|
+
// shortcode and the knowledgebase as a name (#1409).
|
|
39
|
+
import { readQualifier } from "./wikilinks.mjs";
|
|
40
|
+
import { replaceOutsideCode } from "./code-fences.mjs";
|
|
41
|
+
// One slug rule for the whole build — see `./content-slug.mjs`. This module
|
|
42
|
+
// carried a copy that dropped non-ASCII letters rather than transliterating
|
|
43
|
+
// them, so a link to a heading named `Kûrbúl Helm` pointed at `#k-rb-l-helm`.
|
|
44
|
+
import { slugify } from "./content-slug.mjs";
|
|
45
|
+
|
|
46
|
+
// Re-exported so a site build keeps one import path for the whole of link
|
|
47
|
+
// resolution: the same rule that names a page also names an anchor within it.
|
|
48
|
+
export { slugify };
|
|
49
|
+
import { WIKILINK, isSamePage, parseWikilink } from "./wikilink-syntax.mjs";
|
|
50
|
+
|
|
51
|
+
/** KB heading/anchor slug: lowercase, non-alphanumerics to single hyphens. */
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Whether a link target addresses a document as `type-shortcode` (or the legacy
|
|
55
|
+
* `type/shortcode`) rather than naming it in prose.
|
|
56
|
+
*
|
|
57
|
+
* Delegates to the pack build's {@link readQualifier} so one rule serves both
|
|
58
|
+
* builds. A `reason` is as much an address as a resolved qualifier is — the
|
|
59
|
+
* target is qualified either way, it just names no known type — and the caller
|
|
60
|
+
* only ever asks this of a target that already resolved.
|
|
61
|
+
*
|
|
62
|
+
* @param {string} target - The link target, anchor already removed.
|
|
63
|
+
* @param {Set<string>} [contentTypes] - Every content type the KB build saw.
|
|
64
|
+
* @returns {boolean} `true` when the target is an address.
|
|
65
|
+
*/
|
|
66
|
+
function isAddress(target, contentTypes) {
|
|
67
|
+
return readQualifier(target, contentTypes ?? new Set()) !== null;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* The `type/shortcode` index key a qualified target resolves to, or `null`.
|
|
72
|
+
*
|
|
73
|
+
* The KB index is keyed by the canonical `type/shortcode`, so a target written
|
|
74
|
+
* in the hyphen separator — which is what the vault authors (#1398) — has to be
|
|
75
|
+
* rewritten to it before lookup. Uses the same {@link readQualifier} as
|
|
76
|
+
* {@link isAddress}, so recognising an address and resolving one can never
|
|
77
|
+
* disagree: the first-hyphen split and the known-type condition that keeps
|
|
78
|
+
* `[[Grukar-ahk]]` an alias are stated once, in the pack build.
|
|
79
|
+
*
|
|
80
|
+
* The build indexes an item note under both `skill/climb` and `docskill/climb`,
|
|
81
|
+
* and `contentTypes` carries both qualifiers, so either form finds the page.
|
|
82
|
+
*
|
|
83
|
+
* @param {string} target - The link target, anchor already removed.
|
|
84
|
+
* @param {Set<string>} [contentTypes] - Every content type the KB build saw.
|
|
85
|
+
* @returns {string | null} The index key, or `null` when not qualified.
|
|
86
|
+
*/
|
|
87
|
+
function qualifiedKey(target, contentTypes) {
|
|
88
|
+
const read = readQualifier(target, contentTypes ?? new Set());
|
|
89
|
+
if (!read || read.reason) return null;
|
|
90
|
+
return `${read.type}/${read.shortcode}`.toLowerCase();
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* How an **unresolved** link renders.
|
|
95
|
+
*
|
|
96
|
+
* The author's text is kept, so the sentence still reads — dropping it would
|
|
97
|
+
* silently rewrite the prose. It is marked so a reader can tell that something
|
|
98
|
+
* was meant to be a link, and an author can find it: the appearance lives in
|
|
99
|
+
* `scss/components/_unresolved-link.scss` for Foundry and in the Hugo theme for
|
|
100
|
+
* the website, not here.
|
|
101
|
+
*
|
|
102
|
+
* This is deliberately identical to the pack compiler's own `unresolvedLink`,
|
|
103
|
+
* down to the class name and the `title` wording. One authored link renders on
|
|
104
|
+
* two surfaces, and the two builds have drifted before over exactly this kind
|
|
105
|
+
* of detail (#1409) — matching markup is what keeps a reader's cue the same in
|
|
106
|
+
* a journal and on the page. Duplicated rather than imported only because the
|
|
107
|
+
* function is not exported from `@heroiclands/package-build`; hoisting it there
|
|
108
|
+
* is HeroicLands/content-build#13.
|
|
109
|
+
*
|
|
110
|
+
* The knowledgebase renders with `unsafe = true` (`kb/hugo.toml`), so raw HTML
|
|
111
|
+
* in generated markdown reaches the page. That makes escaping obligatory: this
|
|
112
|
+
* is the one path where *authored* text becomes markup rather than content.
|
|
113
|
+
*
|
|
114
|
+
* @param {string} text - The text to show, from the link's label or target.
|
|
115
|
+
* @param {string} target - The address that resolved nowhere, for the tooltip.
|
|
116
|
+
* @returns {string} An inline HTML span, safe to sit in a markdown table cell.
|
|
117
|
+
*/
|
|
118
|
+
function unresolvedLink(text, target) {
|
|
119
|
+
const esc = (v) =>
|
|
120
|
+
String(v)
|
|
121
|
+
.replace(/&/g, "&")
|
|
122
|
+
.replace(/</g, "<")
|
|
123
|
+
.replace(/>/g, ">")
|
|
124
|
+
.replace(/"/g, """);
|
|
125
|
+
return (
|
|
126
|
+
`<span class="sohl-unresolved-link" title="Unresolved link: ` +
|
|
127
|
+
`${esc(target)}">${esc(text)}</span>`
|
|
128
|
+
);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* A wikilink, as it is written, anywhere in a value that is not markdown.
|
|
133
|
+
*
|
|
134
|
+
* Deliberately its own pattern rather than the body resolver's: nothing here is
|
|
135
|
+
* markdown, so there is no fence or code span to step around, and a frontmatter
|
|
136
|
+
* value is a single line by construction (`[^\]\n]` keeps a runaway match from
|
|
137
|
+
* swallowing the rest of a folded scalar).
|
|
138
|
+
*/
|
|
139
|
+
// The same syntax the body scan reads — see `./wikilink-syntax.mjs`.
|
|
140
|
+
const FRONTMATTER_WIKILINK = new RegExp(WIKILINK.source, "g");
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* Every wikilink authored inside a frontmatter value (#1428).
|
|
144
|
+
*
|
|
145
|
+
* Wikilinks are resolved in a note's **body** — by {@link resolveWebWikilinks}
|
|
146
|
+
* here, and by the pack compilers' `convertWikilinks` for Foundry. Frontmatter
|
|
147
|
+
* is not markdown and is never walked by either, so a link written in one is
|
|
148
|
+
* copied through verbatim and reaches the reader as literal `[[…]]` text, in
|
|
149
|
+
* whatever the theme renders that field as (an infobox row, a description, a
|
|
150
|
+
* card subtitle). Nothing downstream notices: the value is a valid string, the
|
|
151
|
+
* page builds, and the defect is visible only to someone who looks at it.
|
|
152
|
+
*
|
|
153
|
+
* So the form is refused rather than resolved. Resolving it would mean choosing
|
|
154
|
+
* an output syntax for a field whose renderer is unknown to this build — a
|
|
155
|
+
* markdown link is inert in a Hugo template that prints the value as text, and
|
|
156
|
+
* an `<a>` is unusable in one that escapes it — and would quietly bless an
|
|
157
|
+
* authoring habit that the pack build has no way to honour at all. Frontmatter
|
|
158
|
+
* carries data; a link belongs in prose.
|
|
159
|
+
*
|
|
160
|
+
* Values are read from the *parsed* frontmatter, so a `[[` inside a YAML comment
|
|
161
|
+
* is not a hit, and every hit can be named by the path a reader would look at.
|
|
162
|
+
*
|
|
163
|
+
* @param {unknown} fm - Parsed frontmatter, as `gray-matter` returns it.
|
|
164
|
+
* @returns {Array<{path: string, link: string}>} In reading order; `path` is the
|
|
165
|
+
* dotted key path of the offending value (`government.summary`, `aliases.1`).
|
|
166
|
+
*/
|
|
167
|
+
export function frontmatterWikilinks(fm) {
|
|
168
|
+
const hits = [];
|
|
169
|
+
const visit = (value, trail) => {
|
|
170
|
+
if (typeof value === "string") {
|
|
171
|
+
for (const m of value.matchAll(FRONTMATTER_WIKILINK)) {
|
|
172
|
+
hits.push({ path: trail, link: m[0] });
|
|
173
|
+
}
|
|
174
|
+
} else if (Array.isArray(value)) {
|
|
175
|
+
value.forEach((v, i) => visit(v, `${trail}.${i}`));
|
|
176
|
+
} else if (isPlainMap(value)) {
|
|
177
|
+
for (const [k, v] of Object.entries(value)) {
|
|
178
|
+
visit(v, trail ? `${trail}.${k}` : k);
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
};
|
|
182
|
+
if (!isPlainMap(fm)) return hits;
|
|
183
|
+
visit(fm, "");
|
|
184
|
+
return hits;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/**
|
|
188
|
+
* Whether a value is a YAML mapping rather than a scalar the parser built into
|
|
189
|
+
* an object of its own (a `Date`, which is what an unquoted date becomes).
|
|
190
|
+
*
|
|
191
|
+
* @param {unknown} value
|
|
192
|
+
* @returns {boolean}
|
|
193
|
+
*/
|
|
194
|
+
function isPlainMap(value) {
|
|
195
|
+
return (
|
|
196
|
+
typeof value === "object" &&
|
|
197
|
+
value !== null &&
|
|
198
|
+
!Array.isArray(value) &&
|
|
199
|
+
!(value instanceof Date)
|
|
200
|
+
);
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/**
|
|
204
|
+
* Rewrites the wikilinks in a markdown body as KB-local markdown links.
|
|
205
|
+
*
|
|
206
|
+
* A target is looked up case-insensitively: first as an alias scoped to the
|
|
207
|
+
* source's own **type** (`ctx.typeAlias`, keyed `type|alias`) — a note's
|
|
208
|
+
* directory and `category` play no part — then in the KB-wide `ctx.index` (keyed
|
|
209
|
+
* by the unambiguous `section/slug` and `type/shortcode`, plus name/filename/slug
|
|
210
|
+
* fallbacks).
|
|
211
|
+
*
|
|
212
|
+
* An unresolved target fails the build only when it is a genuine intra-KB
|
|
213
|
+
* problem — an ambiguous alias, or a qualified `prefix/key` whose prefix is a
|
|
214
|
+
* real KB section or content directory. Anything else is treated as an external
|
|
215
|
+
* reference — until every package's manifest is present, after which any
|
|
216
|
+
* `type-shortcode` address resolving nowhere fails too. Failures are collected
|
|
217
|
+
* in `ctx.errors`.
|
|
218
|
+
*
|
|
219
|
+
* Whether or not it fails the build, a target that resolves nowhere renders
|
|
220
|
+
* through {@link unresolvedLink} rather than as bare prose (#1665): the author's
|
|
221
|
+
* text is kept, marked so a reader can see a link was intended. Not failing the
|
|
222
|
+
* build is a statement that the link *may* be legitimate prose — it was never a
|
|
223
|
+
* reason to make a dead link indistinguishable from the sentence around it.
|
|
224
|
+
*
|
|
225
|
+
* A target that **resolved** to an entry with no page is not this case and is
|
|
226
|
+
* not marked: a pack-only package (#1516) publishes Foundry addresses and no
|
|
227
|
+
* web pages, so the author wrote a real address and there is simply nothing to
|
|
228
|
+
* link to.
|
|
229
|
+
*
|
|
230
|
+
* @param {string} body - The markdown body.
|
|
231
|
+
* @param {object} ctx - `{ index, typeAlias, collide, typeCollide, sections,
|
|
232
|
+
* contentTypes, foreign, manifestsComplete, type, errors, src }`. `foreign`
|
|
233
|
+
* is the cross-package manifest index (#1446); `manifestsComplete` says
|
|
234
|
+
* whether every linkable package is accounted for. Together they decide
|
|
235
|
+
* whether an unresolved address is a typo or a package merely absent.
|
|
236
|
+
* @returns {string} The body with wikilinks rewritten.
|
|
237
|
+
*/
|
|
238
|
+
export function resolveWebWikilinks(body, ctx) {
|
|
239
|
+
// Code is verbatim: a `[[…]]` inside a code fence, an indented block or an
|
|
240
|
+
// inline span is source text, not a link (#1505).
|
|
241
|
+
return replaceOutsideCode(body, WIKILINK, (_m, rawInner) => {
|
|
242
|
+
const { target, anchor, display } = parseWikilink(rawInner);
|
|
243
|
+
|
|
244
|
+
// `[[#section-slug|Text]]` — a section of this same page.
|
|
245
|
+
if (isSamePage({ target, anchor })) {
|
|
246
|
+
return `[${display ?? anchor}](#${slugify(anchor)})`;
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
const key = target.toLowerCase();
|
|
250
|
+
const typeKey = ctx.type ? `${ctx.type}|${key}`.toLowerCase() : null;
|
|
251
|
+
// The canonical separator (#1398) has to be resolved, not merely
|
|
252
|
+
// recognised. Without this the form resolved only when source and
|
|
253
|
+
// target shared a type, by way of the seeded alias below; every
|
|
254
|
+
// *cross-type* link written in it silently lost its href.
|
|
255
|
+
const hyphenKey = qualifiedKey(target, ctx.contentTypes);
|
|
256
|
+
const hit =
|
|
257
|
+
(typeKey ? ctx.typeAlias.get(typeKey) : undefined) ??
|
|
258
|
+
ctx.index.get(key) ??
|
|
259
|
+
(hyphenKey ? ctx.index.get(hyphenKey) : undefined) ??
|
|
260
|
+
// A manifest entry carries the same `{ url, name }` shape as a
|
|
261
|
+
// local one (#1446), so a cross-package hit needs no special case
|
|
262
|
+
// below. Local wins: a live build is authoritative and a vendored
|
|
263
|
+
// manifest can only be staler.
|
|
264
|
+
(hyphenKey ? ctx.foreign?.get(hyphenKey) : undefined);
|
|
265
|
+
if (hit) {
|
|
266
|
+
// With no explicit label, a *qualified* target has no prose to show
|
|
267
|
+
// (a shortcode is not display text), so fall back to the document's
|
|
268
|
+
// name. A bare `[[Text]]` is already the prose the author wrote —
|
|
269
|
+
// substituting the canonical name there would rewrite the sentence
|
|
270
|
+
// ("worsens the [[Shock State]]" must not render as "Shock").
|
|
271
|
+
// Both separators qualify: `type-shortcode` is the canonical form
|
|
272
|
+
// (#1398), and a hyphen inside a note *name* ("Grukar-ahk") is not
|
|
273
|
+
// one, which is why the rule is the packs' own (#1409).
|
|
274
|
+
const text =
|
|
275
|
+
display ??
|
|
276
|
+
(isAddress(target, ctx.contentTypes) ? hit.name : target);
|
|
277
|
+
// A pack-only package publishes Foundry addresses and no pages
|
|
278
|
+
// (#1516), so its entries carry no `path` and resolve to no URL.
|
|
279
|
+
// The address is real — this is not a typo and must not fail the
|
|
280
|
+
// build — but there is nothing on the web to point at, so the
|
|
281
|
+
// reader gets the text and no href. Emitting the href anyway is
|
|
282
|
+
// what the manifest exists to prevent: `[Name](undefined)` renders
|
|
283
|
+
// as a link and goes nowhere.
|
|
284
|
+
if (!hit.url) return text;
|
|
285
|
+
return `[${text}](${anchor ? `${hit.url}#${slugify(anchor)}` : hit.url})`;
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
const slash = target.indexOf("/");
|
|
289
|
+
const prefix =
|
|
290
|
+
slash === -1 ? null : target.slice(0, slash).toLowerCase();
|
|
291
|
+
// Deliberately *not* extended to the hyphen form, which is also how a
|
|
292
|
+
// note addresses content in a package this build does not publish
|
|
293
|
+
// (`Rules/Bestiary.md` → `being-grkrahk`, a real note in the `thalorna`
|
|
294
|
+
// package). Nothing in the syntax separates that from a typo,
|
|
295
|
+
// so failing here would break the build on correct content.
|
|
296
|
+
//
|
|
297
|
+
// A dead address is caught instead by `lint:content-links` (#1414),
|
|
298
|
+
// which holds the reviewed list of cross-package exceptions — and which,
|
|
299
|
+
// unlike this build, runs as part of `npm run lint` on every change.
|
|
300
|
+
const badQualified =
|
|
301
|
+
prefix !== null &&
|
|
302
|
+
(ctx.sections.has(prefix) || ctx.contentTypes.has(prefix));
|
|
303
|
+
// The hyphen form is the canonical address (#1398) and is what the
|
|
304
|
+
// authored content writes. It could not be guarded while some packages
|
|
305
|
+
// were invisible here: `Rules/Bestiary.md` addresses `being-grkrahk`,
|
|
306
|
+
// a real note in the `thalorna` package, and nothing in the syntax
|
|
307
|
+
// separated that legitimate cross-package reference from a typo — so
|
|
308
|
+
// treating the form as definitely-local would have failed the build on
|
|
309
|
+
// correct content.
|
|
310
|
+
//
|
|
311
|
+
// The link manifest settles it (#1446). Once every linkable package is
|
|
312
|
+
// accounted for — built here or vendored as a manifest — an address
|
|
313
|
+
// resolving in none of them is a typo and nothing else. Until then
|
|
314
|
+
// `manifestsComplete` is false and the form stays unguarded, so the
|
|
315
|
+
// check returns exactly when it becomes decidable rather than on a date
|
|
316
|
+
// someone has to remember.
|
|
317
|
+
const badAddress = ctx.manifestsComplete === true && hyphenKey !== null;
|
|
318
|
+
|
|
319
|
+
if ((typeKey && ctx.typeCollide.has(typeKey)) || ctx.collide.has(key)) {
|
|
320
|
+
ctx.errors.push({ file: ctx.src, target, reason: "ambiguous" });
|
|
321
|
+
} else if (badQualified || badAddress) {
|
|
322
|
+
ctx.errors.push({
|
|
323
|
+
file: ctx.src,
|
|
324
|
+
target,
|
|
325
|
+
reason: "broken type/shortcode",
|
|
326
|
+
});
|
|
327
|
+
}
|
|
328
|
+
return unresolvedLink(display ?? target, target);
|
|
329
|
+
});
|
|
330
|
+
}
|