@heroiclands/package-build 0.6.0 → 3.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.
Files changed (127) hide show
  1. package/CHANGELOG-content-build.md +965 -0
  2. package/CHANGELOG.md +95 -0
  3. package/CONTENT.md +824 -0
  4. package/MIGRATING.md +127 -0
  5. package/README.md +29 -17
  6. package/bin/content-build.mjs +1082 -0
  7. package/bin/package-build.mjs +2 -2
  8. package/bin/report.mjs +2 -2
  9. package/config.mjs +21 -18
  10. package/content-config.mjs +1424 -0
  11. package/coverage.mjs +1 -1
  12. package/e2e.mjs +1 -1
  13. package/engine/abbreviations.mjs +0 -0
  14. package/engine/base-compiler.mjs +608 -0
  15. package/engine/code-fences.mjs +311 -0
  16. package/engine/compendiums.mjs +353 -0
  17. package/engine/content-address.mjs +141 -0
  18. package/engine/content-links.mjs +438 -0
  19. package/engine/content-lint.mjs +204 -0
  20. package/engine/content-package.mjs +67 -0
  21. package/engine/content-slug.mjs +169 -0
  22. package/engine/content-tables.mjs +1216 -0
  23. package/engine/content-tree.mjs +56 -0
  24. package/engine/diagnostics.mjs +270 -0
  25. package/engine/field-reference.mjs +242 -0
  26. package/engine/field-spec.mjs +218 -0
  27. package/engine/foreign-catalog.mjs +450 -0
  28. package/engine/foreign-manifests.mjs +129 -0
  29. package/engine/frontmatter-lint.mjs +343 -0
  30. package/engine/frontmatter.mjs +258 -0
  31. package/engine/generate.mjs +305 -0
  32. package/engine/helpers.mjs +809 -0
  33. package/engine/ids.mjs +192 -0
  34. package/engine/index.mjs +142 -0
  35. package/engine/item-docs.mjs +153 -0
  36. package/engine/item-registry.mjs +129 -0
  37. package/engine/journals.mjs +374 -0
  38. package/engine/kb-manifest.mjs +473 -0
  39. package/engine/macros.mjs +333 -0
  40. package/engine/manifest-emit.mjs +339 -0
  41. package/engine/map-notes.mjs +1302 -0
  42. package/engine/pack-config.mjs +479 -0
  43. package/engine/pack-router.mjs +254 -0
  44. package/engine/prose-config.mjs +238 -0
  45. package/engine/prose-lint.mjs +294 -0
  46. package/engine/region-events.mjs +79 -0
  47. package/engine/scene-levels.mjs +420 -0
  48. package/engine/scenes.mjs +528 -0
  49. package/engine/site-build.mjs +778 -0
  50. package/engine/site-index.mjs +332 -0
  51. package/engine/web-wikilinks.mjs +330 -0
  52. package/engine/wikilink-syntax.mjs +105 -0
  53. package/engine/wikilinks.mjs +645 -0
  54. package/index.mjs +42 -25
  55. package/lang.mjs +1 -1
  56. package/manifest.mjs +79 -5
  57. package/markdownlint-config.mjs +45 -0
  58. package/package.json +78 -34
  59. package/prettier-config.mjs +34 -0
  60. package/sohl/actors.mjs +581 -0
  61. package/sohl/affiliation-standings.mjs +44 -0
  62. package/sohl/being-info.mjs +173 -0
  63. package/sohl/default-item-art.mjs +82 -0
  64. package/sohl/index.mjs +55 -0
  65. package/sohl/item-builders.mjs +114 -0
  66. package/sohl/item-fields.mjs +867 -0
  67. package/sohl/items.mjs +197 -0
  68. package/sohl/kb-passes.mjs +207 -0
  69. package/sohl/note-schemas.mjs +322 -0
  70. package/templates.mjs +1 -1
  71. package/types/config.d.mts +1 -1
  72. package/types/content-config.d.mts +571 -0
  73. package/types/engine/abbreviations.d.mts +44 -0
  74. package/types/engine/base-compiler.d.mts +320 -0
  75. package/types/engine/code-fences.d.mts +102 -0
  76. package/types/engine/compendiums.d.mts +77 -0
  77. package/types/engine/content-address.d.mts +61 -0
  78. package/types/engine/content-links.d.mts +87 -0
  79. package/types/engine/content-lint.d.mts +47 -0
  80. package/types/engine/content-package.d.mts +36 -0
  81. package/types/engine/content-slug.d.mts +59 -0
  82. package/types/engine/content-tables.d.mts +131 -0
  83. package/types/engine/content-tree.d.mts +12 -0
  84. package/types/engine/diagnostics.d.mts +152 -0
  85. package/types/engine/field-reference.d.mts +20 -0
  86. package/types/engine/field-spec.d.mts +181 -0
  87. package/types/engine/foreign-catalog.d.mts +103 -0
  88. package/types/engine/foreign-manifests.d.mts +43 -0
  89. package/types/engine/frontmatter-lint.d.mts +74 -0
  90. package/types/engine/frontmatter.d.mts +99 -0
  91. package/types/engine/generate.d.mts +56 -0
  92. package/types/engine/helpers.d.mts +348 -0
  93. package/types/engine/ids.d.mts +126 -0
  94. package/types/engine/index.d.mts +33 -0
  95. package/types/engine/item-docs.d.mts +73 -0
  96. package/types/engine/item-registry.d.mts +57 -0
  97. package/types/engine/journals.d.mts +121 -0
  98. package/types/engine/kb-manifest.d.mts +214 -0
  99. package/types/engine/macros.d.mts +201 -0
  100. package/types/engine/manifest-emit.d.mts +137 -0
  101. package/types/engine/map-notes.d.mts +283 -0
  102. package/types/engine/pack-config.d.mts +77 -0
  103. package/types/engine/pack-router.d.mts +53 -0
  104. package/types/engine/prose-config.d.mts +121 -0
  105. package/types/engine/prose-lint.d.mts +59 -0
  106. package/types/engine/region-events.d.mts +43 -0
  107. package/types/engine/scene-levels.d.mts +36 -0
  108. package/types/engine/scenes.d.mts +48 -0
  109. package/types/engine/site-build.d.mts +214 -0
  110. package/types/engine/site-index.d.mts +141 -0
  111. package/types/engine/web-wikilinks.d.mts +67 -0
  112. package/types/engine/wikilink-syntax.d.mts +101 -0
  113. package/types/engine/wikilinks.d.mts +172 -0
  114. package/types/index.d.mts +3 -0
  115. package/types/manifest.d.mts +52 -4
  116. package/types/markdownlint-config.d.mts +8 -0
  117. package/types/prettier-config.d.mts +1 -0
  118. package/types/sohl/actors.d.mts +65 -0
  119. package/types/sohl/affiliation-standings.d.mts +22 -0
  120. package/types/sohl/being-info.d.mts +78 -0
  121. package/types/sohl/default-item-art.d.mts +28 -0
  122. package/types/sohl/index.d.mts +7 -0
  123. package/types/sohl/item-builders.d.mts +11 -0
  124. package/types/sohl/item-fields.d.mts +20 -0
  125. package/types/sohl/items.d.mts +19 -0
  126. package/types/sohl/kb-passes.d.mts +71 -0
  127. 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, "&amp;")
122
+ .replace(/</g, "&lt;")
123
+ .replace(/>/g, "&gt;")
124
+ .replace(/"/g, "&quot;");
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
+ }