@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,645 @@
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 pack compilers.
16
+ *
17
+ * Content notes link to one another with wikilinks rather than file paths:
18
+ *
19
+ * `[[type/shortcode|Text]]` a document of that type
20
+ * `[[Text]]` an alias unique within the source's own type
21
+ * `[[type/shortcode#slug|T]]` a section (see below)
22
+ * `[[#slug|Text]]` a section of the source note itself
23
+ * `[[doctype/shortcode|T]]` an item's *documentation* (see below)
24
+ *
25
+ * The qualifier is the note's **type**, which with its shortcode is the system's
26
+ * logical identity: `(type, shortcode)` is unique by rule (see the Shortcode
27
+ * Integrity doc). It is deliberately not the note's directory — shortcodes are
28
+ * unique per type, not per directory, so a directory qualifier would add nothing
29
+ * to the address while breaking every inbound link the moment a note is refiled.
30
+ *
31
+ * The bare form is the same address with the qualifier left implicit: it resolves
32
+ * against the aliases of the source's **own type**, so a `doc` reaches any other
33
+ * `doc` by name wherever it is filed. Nothing narrower is consulted — a note's
34
+ * directory and its `category` play no part in resolution. Where two notes of a
35
+ * type legitimately share a name (a rules page and a user-guide page both called
36
+ * "Gear"), the bare form is ambiguous and resolves to neither; the author writes
37
+ * the full `[[type/shortcode|Text]]` form instead.
38
+ *
39
+ * At compile time each becomes a Foundry UUID enricher, routed to the pack that
40
+ * the target's type compiles into (see {@link packForType}):
41
+ *
42
+ * `@UUID[Compendium.sohl.items.Item.<id>]{Text}`
43
+ * `@UUID[Compendium.sohl.journals.JournalEntry.<id>.JournalEntryPage.<anchorId>]{Text}`
44
+ *
45
+ * **Every address is computed once, when the target is indexed** — see
46
+ * {@link buildWikilinkIndex} — and a link is resolved by looking that value up.
47
+ * Nothing here concatenates a prefix at the point of use (#1498).
48
+ *
49
+ * Section links address a **JournalEntryPage**, because Foundry UUIDs cannot
50
+ * target a position inside a page. A heading carrying `{#slug}` therefore starts
51
+ * its own page, whose id is {@link anchorPageId} — derived from the note id and
52
+ * the slug so that the link and the page agree without any shared state.
53
+ *
54
+ * **An anchor on an Item, an Actor or a Macro is a no-op** and is dropped. What
55
+ * such a link does is open that document's **sheet** — not its documentation —
56
+ * and a sheet has no sections to address. Only a JournalEntry link opens a
57
+ * journal, at its first page or at the page an anchor names. An item's pages are
58
+ * reached through its `doc<type>` counterpart, below.
59
+ *
60
+ * **A document and its documentation are two documents.** An item note
61
+ * compiles into an item — and, separately, its prose compiles into a
62
+ * JournalEntry in the journals pack (see
63
+ * {@link sohl.utils.packs.itemDocEntryId}); a macro note works the same way
64
+ * (#1514). `skill/wpnc` addresses the skill; the **virtual qualifier**
65
+ * `docskill/wpnc` addresses that skill's documentation, and
66
+ * `docskill/wpnc#crafting` a page within it. `docmacro/autoattack#script`
67
+ * reaches a macro's source. Every doc-carrying type has a `doc<type>`
68
+ * counterpart, formed by prefix and never enumerated; see
69
+ * {@link resolveItemDocType}. Without it a section link to an item note
70
+ * produced a UUID against the *items* pack, which cannot hold a
71
+ * JournalEntryPage, and dead-ended (#1362).
72
+ *
73
+ * **The two builds read the qualifier differently, by design.** In Foundry the
74
+ * item and its documentation are separate documents in separate packs, so the
75
+ * two qualifiers resolve to two different UUIDs. On the knowledgebase the item
76
+ * note renders as a single page which *is* its documentation, so `doc<type>` and
77
+ * `<type>` are aliases for the same URL and an anchor on either is an ordinary
78
+ * in-page anchor. One authored link, correct in both.
79
+ *
80
+ * Plain ESM with no Foundry and no filesystem access, so it is unit-testable.
81
+ */
82
+
83
+ import crypto from "crypto";
84
+
85
+ import {
86
+ compendiumUuid,
87
+ ITEM_PACK,
88
+ packForType,
89
+ pageUuid,
90
+ PACK_BY_TYPE,
91
+ } from "./ids.mjs";
92
+ import { hasDocEntry, itemDocEntryId } from "./item-docs.mjs";
93
+ import { replaceOutsideCode } from "./code-fences.mjs";
94
+ // The syntax lives in `./wikilink-syntax.mjs`, so the web resolver and this
95
+ // one cannot disagree about what counts as a link.
96
+ import { WIKILINK, parseWikilink } from "./wikilink-syntax.mjs";
97
+
98
+ export { ITEM_PACK, PACK_BY_TYPE, packForType };
99
+
100
+ /**
101
+ * The qualifier prefix that addresses an item's **documentation** rather than
102
+ * the item: `docskill/wpnc` is the JournalEntry that `skill/wpnc`'s prose
103
+ * compiled into. See {@link resolveItemDocType}.
104
+ */
105
+ const ITEM_DOC_PREFIX = "doc";
106
+
107
+ const norm = (s) => String(s).toLowerCase().trim();
108
+
109
+ /**
110
+ * Reads a qualifier as the **virtual `doc<type>`** form, or reports that it is
111
+ * not one.
112
+ *
113
+ * A document and its documentation are two documents in two packs, so they
114
+ * need two addresses (#1362). `skill/wpnc` is the item; `docskill/wpnc` is the
115
+ * JournalEntry its prose compiled into, and `docmacro/autoattack` is the same
116
+ * arrangement for a macro (#1514).
117
+ *
118
+ * The virtual form exists for a type that carries separate documentation
119
+ * ({@link sohl.utils.packs.docEntryTypes} — the set the journals compiler and
120
+ * the link manifest read too), **or** for one that routes to the items pack.
121
+ * The second clause is the older rule and stays: types that compile into items
122
+ * are the open, unenumerated set (#1276), and a foreign package may publish an
123
+ * item type this build has never heard of. Dropping it would silently unlink
124
+ * every `doc<type>` address into such a package.
125
+ *
126
+ * A **real** type of the same name always wins: the virtual reading is only
127
+ * consulted for a qualifier no authored note claims.
128
+ *
129
+ * @param {string} qualifier - The already-normalised text before the `/`.
130
+ * @param {Set<string>} types - Every type the content tree contains.
131
+ * @returns {string|null} The underlying document type, or `null` when the
132
+ * qualifier is not a virtual one.
133
+ */
134
+ export function resolveItemDocType(qualifier, types) {
135
+ if (types.has(qualifier)) return null; // a real type owns its own name
136
+ if (!qualifier.startsWith(ITEM_DOC_PREFIX)) return null;
137
+ const base = qualifier.slice(ITEM_DOC_PREFIX.length);
138
+ if (!base || !types.has(base)) return null;
139
+ if (hasDocEntry(base)) return base;
140
+ return packForType(base).docType === ITEM_PACK.docType ? base : null;
141
+ }
142
+
143
+ /**
144
+ * Read a link target as a **qualified** `type-shortcode` reference, or report
145
+ * that it is a bare alias instead.
146
+ *
147
+ * Two separators are accepted, and they are **not** interchangeable in how
148
+ * confidently they mark a target as qualified:
149
+ *
150
+ * - **`type-shortcode`** — the canonical form (#1398). Obsidian reads `/` inside
151
+ * a wikilink as a *path* and resolves it against the vault's folders, so a
152
+ * slash-qualified link is a broken link in the editor where the content is now
153
+ * authored. A hyphen qualifies **only when what precedes it is a known type**:
154
+ * note names contain hyphens too (`Grukar-ahk`), and those must keep resolving
155
+ * as aliases. The split is at the **first** hyphen, so a shortcode may itself
156
+ * contain one (`trauma-self-pro` → `trauma` + `self-pro`).
157
+ * - **`type/shortcode`** — the legacy form, still resolved so that a link
158
+ * written before the vault migrated does not silently die. A slash is
159
+ * *unconditionally* a qualifier: nothing else uses one, so an unknown type
160
+ * before it is an error rather than an invitation to try the alias index. The
161
+ * split is at the **last** slash, as it always was.
162
+ *
163
+ * A leading **package** segment is optional and outermost: `sohl-skill-lang` is
164
+ * `skill-lang` in the `sohl` package. It is read only when `packages` is given
165
+ * and names the segment, and only when the remainder is itself a valid address,
166
+ * so a note called "Grukar-ahk" stays an alias (#1499).
167
+ *
168
+ * @param {string} target - The link target, anchor already removed.
169
+ * @param {Set<string>} types - Every type the content tree contains.
170
+ * @param {Set<string>} [packages] - Every package an address may name. Omitted
171
+ * by callers that resolve within one package, where the form cannot occur.
172
+ * @returns {{type: string, shortcode: string, itemDoc: boolean,
173
+ * package?: string, reason?: undefined} | {reason: "unknown-type"} | null}
174
+ * The resolved qualifier; a `reason` when the target is definitely qualified
175
+ * but names no known type; or `null` when it is a bare alias.
176
+ */
177
+ export function readQualifier(target, types, packages) {
178
+ // A leading **package** segment is the optional outermost qualifier:
179
+ // `sohl-skill-lang` is `skill-lang` in the `sohl` package. It is stripped
180
+ // here so everything below reads the same `type`/`shortcode` it always did,
181
+ // and it is recognised only when what precedes the hyphen is a package this
182
+ // build knows *and* the remainder is itself a valid address — so a note
183
+ // named "Sohl-something" is still an alias (#1499).
184
+ if (packages?.size) {
185
+ const hyphen = target.indexOf("-");
186
+ if (hyphen > 0) {
187
+ const pkg = norm(target.slice(0, hyphen));
188
+ if (packages.has(pkg)) {
189
+ const rest = readQualifier(target.slice(hyphen + 1), types);
190
+ if (rest && !rest.reason) return { ...rest, package: pkg };
191
+ }
192
+ }
193
+ }
194
+
195
+ const slash = target.lastIndexOf("/");
196
+ if (slash > 0) {
197
+ const read = readTypeAndCode(
198
+ target.slice(0, slash),
199
+ target.slice(slash + 1),
200
+ types,
201
+ );
202
+ // A slash means qualified whether or not the type is real.
203
+ return read ?? { reason: "unknown-type" };
204
+ }
205
+
206
+ const hyphen = target.indexOf("-");
207
+ if (hyphen > 0) {
208
+ // A hyphen qualifies only on a known type; otherwise it is part of a name.
209
+ return readTypeAndCode(
210
+ target.slice(0, hyphen),
211
+ target.slice(hyphen + 1),
212
+ types,
213
+ );
214
+ }
215
+ return null;
216
+ }
217
+
218
+ /**
219
+ * Resolve a qualifier/shortcode pair, honouring the virtual `doc<type>` form.
220
+ *
221
+ * @param {string} rawType
222
+ * @param {string} rawCode
223
+ * @param {Set<string>} types
224
+ * @returns {{type: string, shortcode: string, itemDoc: boolean} | null}
225
+ * `null` when the qualifier names no known type, or the shortcode is empty.
226
+ */
227
+ function readTypeAndCode(rawType, rawCode, types) {
228
+ const shortcode = norm(rawCode);
229
+ if (!shortcode) return null;
230
+
231
+ let type = norm(rawType);
232
+ const base = resolveItemDocType(type, types);
233
+ if (base) return { type: base, shortcode, itemDoc: true };
234
+ if (!types.has(type)) return null;
235
+ return { type, shortcode, itemDoc: false };
236
+ }
237
+
238
+ /**
239
+ * The deterministic JournalEntryPage id for one anchor: SHA-256 of
240
+ * `"<noteId>-<anchorSlug>"`, base64-encoded, reduced to the 16 alphanumeric
241
+ * characters a Foundry id allows.
242
+ *
243
+ * Base64's `+`, `/`, and `=` are **not** legal in a Foundry document id
244
+ * (`/^[A-Za-z0-9]{16}$/`), so they are dropped before the first 16 characters
245
+ * are taken — the value stays a pure function of its two inputs, which is what
246
+ * lets the link and the page be computed independently.
247
+ *
248
+ * @param {string} noteId - The owning JournalEntry's `_id`.
249
+ * @param {string} anchorSlug - The slug declared by `{#slug}` on the heading.
250
+ * @returns {string} A 16-character alphanumeric id.
251
+ */
252
+ export function anchorPageId(noteId, anchorSlug) {
253
+ return crypto
254
+ .createHash("sha256")
255
+ .update(`${noteId}-${anchorSlug}`)
256
+ .digest("base64")
257
+ .replace(/[^A-Za-z0-9]/g, "")
258
+ .slice(0, 16);
259
+ }
260
+
261
+ /**
262
+ * Builds the link-resolution tables for a content tree.
263
+ *
264
+ * @param {Array<{type: string, id: string, shortcode?: string|null,
265
+ * aliases?: string[], name?: string, pack?: string, docPack?: string}>} docs -
266
+ * One entry per content note. `pack` / `docPack` name the packs the note's
267
+ * document and its documentation entry landed in; omitted, the conventional
268
+ * one-pack-per-type names stand in.
269
+ * @param {string} packageId - The Foundry package shipping the packs; the first
270
+ * segment of every emitted UUID.
271
+ * @param {Map<string, object>} [foreign] - Canonically keyed entries from
272
+ * vendored manifests of packages this build links into but does not publish.
273
+ * @param {string} [contentPackage] - This build's *content* package, which an
274
+ * authored address may name explicitly. Defaults to `packageId`.
275
+ * @returns {{byShortcode: Map<string, object>, byAlias: Map<string, object|null>,
276
+ * types: Set<string>}} `byAlias` holds `null` where a type-scoped alias is
277
+ * claimed by more than one document, which makes the bare `[[Text]]` form
278
+ * unusable for it. `types` is every type the tree actually contains, so a
279
+ * qualifier naming no real type can be told apart from a missing target.
280
+ */
281
+ export function buildWikilinkIndex(docs, packageId, foreign, contentPackage) {
282
+ if (!packageId) {
283
+ throw new Error(
284
+ "buildWikilinkIndex: packageId is required — it is the first " +
285
+ "segment of every emitted UUID, and defaulting it is how links " +
286
+ "came to address the wrong package (#1498).",
287
+ );
288
+ }
289
+
290
+ const byShortcode = new Map();
291
+ const byAlias = new Map();
292
+ // key -> every note claiming it. `byAlias` records only the *verdict*
293
+ // (a note, or `null` for poisoned); this records the claimants, which is
294
+ // what an ambiguity report has to name.
295
+ const aliasClaims = new Map();
296
+ const types = new Set();
297
+
298
+ // Each note's address is computed once, here, and every reference to it is
299
+ // that stored value. Nothing downstream assembles a UUID from parts, so a
300
+ // link and its target cannot disagree about where the document lives.
301
+ const uuidByDoc = new Map();
302
+
303
+ for (const d of docs) {
304
+ if (!d.id || !d.type) continue;
305
+ types.add(norm(d.type));
306
+
307
+ uuidByDoc.set(d, {
308
+ // `d.pack` is where this note's document actually landed, resolved
309
+ // by the pack router when the index was collected. A repository may
310
+ // ship several packs of one type (#1566) and a UUID carries the
311
+ // pack name, so the address cannot be derived from the type alone.
312
+ uuid: compendiumUuid(packageId, d.type, d.id, d.pack),
313
+ // An item's prose compiles into a separate JournalEntry, addressed
314
+ // by the virtual `doc<type>` qualifier. Its id is derived from the
315
+ // item's, so its address is knowable here too.
316
+ docUuid: compendiumUuid(
317
+ packageId,
318
+ "doc",
319
+ itemDocEntryId(d.id),
320
+ d.docPack,
321
+ ),
322
+ });
323
+
324
+ if (d.shortcode)
325
+ byShortcode.set(`${norm(d.type)}/${norm(d.shortcode)}`, d);
326
+ for (const a of d.aliases ?? []) {
327
+ const key = `${norm(d.type)}|${norm(a)}`;
328
+ // Second claimant poisons the alias: it can no longer be resolved.
329
+ byAlias.set(
330
+ key,
331
+ byAlias.has(key) && byAlias.get(key) !== d ? null : d,
332
+ );
333
+ // Every claimant is kept alongside, because poisoning the alias
334
+ // discards exactly the information needed to report the problem.
335
+ // The note that *cites* an ambiguous alias is innocent — whoever
336
+ // added the second claimant broke it — so a message that can only
337
+ // name the citing note points at the wrong file (#13).
338
+ const claims = aliasClaims.get(key);
339
+ if (!claims) aliasClaims.set(key, [d]);
340
+ else if (!claims.includes(d)) claims.push(d);
341
+ }
342
+ }
343
+ // Entries published by *other* packages, keyed canonically. Merged as one
344
+ // map rather than consulted separately: the keys are globally unique, so a
345
+ // foreign address resolves exactly like a local one and there is no
346
+ // precedence rule to get wrong. A foreign package's types are added to
347
+ // `types` too — without that, its addresses read as prose and silently lose
348
+ // their link (#1499).
349
+ const foreignByKey = new Map(foreign ?? []);
350
+ const foreignTypes = [];
351
+ for (const v of foreignByKey.values()) {
352
+ if (v.type) foreignTypes.push(norm(v.type));
353
+ }
354
+ // A manifest publishes `doc<type>` addresses, but `doc<type>` is a
355
+ // *virtual* qualifier formed by prefix — never a real type. Admitting it
356
+ // here would make it one, and a real type owns its own name, so the
357
+ // virtual reading would stop firing and every `[[docskill-wpnc]]` would
358
+ // resolve nowhere. The virtual form still reaches a foreign documentation
359
+ // entry: it reads as `skill` + `itemDoc`, which the manifest lookup then
360
+ // asks for as `docskill`.
361
+ //
362
+ // Real types are admitted **first**, in a pass of their own, so the test
363
+ // below sees the complete set. Done in one pass it would depend on
364
+ // manifest iteration order wherever the base type is published only by a
365
+ // foreign package — `docmacro` admitted or not according to whether
366
+ // `macro` happened to come first.
367
+ for (const t of foreignTypes) {
368
+ if (!t.startsWith(ITEM_DOC_PREFIX)) types.add(t);
369
+ }
370
+ for (const t of foreignTypes) {
371
+ if (!t.startsWith(ITEM_DOC_PREFIX)) continue;
372
+ if (types.has(t.slice(ITEM_DOC_PREFIX.length))) continue;
373
+ types.add(t);
374
+ }
375
+
376
+ // Every package an address may name: this one, plus every package a
377
+ // vendored manifest speaks for. What lets `thalorna-creature-grkrahk` be
378
+ // read as an address at all.
379
+ const packages = new Set([contentPackage ?? packageId]);
380
+ for (const v of foreignByKey.values()) {
381
+ if (v.package) packages.add(v.package);
382
+ }
383
+
384
+ return {
385
+ byShortcode,
386
+ byAlias,
387
+ aliasClaims,
388
+ types,
389
+ uuidByDoc,
390
+ packageId,
391
+ packages,
392
+ foreign: foreignByKey,
393
+ };
394
+ }
395
+
396
+ /**
397
+ * The foreign manifest entry an address names, or `null`.
398
+ *
399
+ * A package-qualified address is one lookup. A bare one names no package, so it
400
+ * resolves against whichever foreign package publishes it — and only when
401
+ * exactly one does. Claimed by two, it is genuinely ambiguous and the author
402
+ * writes the qualified form; guessing would make the build depend on which
403
+ * manifest happened to load first.
404
+ *
405
+ * @param {object} index - From {@link buildWikilinkIndex}.
406
+ * @param {object|null} read - The parsed qualifier, or `null` for a bare alias.
407
+ * @returns {object|null} The manifest entry.
408
+ */
409
+ function findForeign(index, read) {
410
+ if (!read || read.reason || !index.foreign?.size) return null;
411
+ const wanted = norm(read.itemDoc ? `doc${read.type}` : read.type);
412
+ const shortcode = norm(read.shortcode);
413
+ if (read.package) {
414
+ return (
415
+ index.foreign.get(
416
+ `${read.package}-${wanted}-${shortcode}`.toLowerCase(),
417
+ ) ?? null
418
+ );
419
+ }
420
+ const hits = [];
421
+ for (const [key, v] of index.foreign) {
422
+ const parts = key.split("-");
423
+ if (parts.length !== 3) continue;
424
+ if (parts[1] === wanted && parts[2] === shortcode) hits.push(v);
425
+ }
426
+ return hits.length === 1 ? hits[0] : null;
427
+ }
428
+
429
+ /**
430
+ * How an **unresolved** link renders.
431
+ *
432
+ * The author's text is kept, so the sentence still reads — dropping it would
433
+ * silently rewrite the prose. It is marked so a reader can tell that something
434
+ * was meant to be a link, and a maintainer can find it: the appearance lives in
435
+ * `scss/components/_unresolved-link.scss`, not here.
436
+ *
437
+ * @param {string} text - The text to show, from the link's label or target.
438
+ * @param {string} target - The address that resolved nowhere, for the tooltip.
439
+ * @returns {string} An HTML span. The markdown renderer passes raw HTML through.
440
+ */
441
+ function unresolvedLink(text, target) {
442
+ const esc = (v) =>
443
+ String(v)
444
+ .replace(/&/g, "&amp;")
445
+ .replace(/</g, "&lt;")
446
+ .replace(/>/g, "&gt;")
447
+ .replace(/"/g, "&quot;");
448
+ return (
449
+ `<span class="sohl-unresolved-link" title="Unresolved link: ` +
450
+ `${esc(target)}">${esc(text)}</span>`
451
+ );
452
+ }
453
+
454
+ /** Matches a whole wikilink, capturing its inner text. */
455
+
456
+ /**
457
+ * Rewrites every wikilink in a markdown body as a Foundry UUID enricher.
458
+ *
459
+ * A link that cannot be resolved is left exactly as it was and reported in
460
+ * `unresolved`, so a content gap degrades to visible literal text rather than
461
+ * a broken link or a failed build.
462
+ *
463
+ * **Code is verbatim.** A `[[…]]` inside a fenced or indented code block, or
464
+ * inside an inline code span, is source text an author wrote to be read as
465
+ * written, so it is left alone and not reported (#1505). Without that, a
466
+ * script sample containing `grid[[0]]` became a link — and only for some
467
+ * array shapes, `[[1,2],[3,4]]` having an inner `]` the pattern cannot cross,
468
+ * so the corruption looked arbitrary. It reaches the reader through the
469
+ * *documented* copy of a macro while the executable copy stays correct.
470
+ *
471
+ * @param {string} markdown - The note body (frontmatter already stripped).
472
+ * @param {object} ctx
473
+ * @param {string} ctx.type - The source note's `type`, which scopes a bare `[[Text]]`.
474
+ * @param {string} ctx.id - The source note's document id.
475
+ * @param {string} [ctx.pack] - The pack the source note's own document landed
476
+ * in, which addresses a `[[#slug]]` self-link — the one target with no index
477
+ * entry.
478
+ * @param {string} [ctx.docPack] - The pack the source note's documentation
479
+ * entry landed in.
480
+ * @param {{byShortcode: Map, byAlias: Map, types: Set}} ctx.index - From
481
+ * {@link buildWikilinkIndex}.
482
+ * @returns {{markdown: string, unresolved: Array<{link: string, target: string,
483
+ * offset: number, reason: "unknown"|"ambiguous"|"unknown-type"}>}} `offset`
484
+ * is the link's 0-based position in `markdown`, which is what lets a caller
485
+ * report the line and column it sits on (#17).
486
+ */
487
+ export function convertWikilinks(markdown, { type, id, pack, docPack, index }) {
488
+ const unresolved = [];
489
+
490
+ // `offset` is the third replacer argument because the pattern has exactly
491
+ // one capture group. It is what makes two identical unresolved links on
492
+ // one note tellable apart, and a position reportable at all (#17).
493
+ const out = replaceOutsideCode(
494
+ markdown,
495
+ WIKILINK,
496
+ (all, rawInner, offset) => {
497
+ const parsed = parseWikilink(rawInner);
498
+ const { labelled } = parsed;
499
+ let target = parsed.target;
500
+ // An unlabelled link shows its interior verbatim, anchor included;
501
+ // a labelled one shows its label.
502
+ let text = labelled ? (parsed.display ?? "") : parsed.inner;
503
+ const slug = parsed.anchor || null;
504
+
505
+ // Resolve the document: same-page (empty target), type-shortcode, or alias.
506
+ let doc;
507
+ // Set when the qualifier was the virtual `doc<type>` form, so the UUID
508
+ // is built against the item doc entry rather than the item itself.
509
+ let itemDoc = false;
510
+ // Set when the target was read as `type-shortcode` — an address rather
511
+ // than prose, which decides what an unlabelled link shows (#1409).
512
+ let addressed = false;
513
+ // Kept for the foreign fallback below, which needs the parsed address.
514
+ let qualifiedRead = null;
515
+ if (target === "" && slug) {
516
+ doc = { type, id, pack, docPack };
517
+ } else {
518
+ const qualified = readQualifier(
519
+ target,
520
+ index.types,
521
+ index.packages,
522
+ );
523
+ qualifiedRead = qualified;
524
+ if (qualified?.reason) {
525
+ unresolved.push({
526
+ link: all,
527
+ target,
528
+ offset,
529
+ reason: qualified.reason,
530
+ });
531
+ return unresolvedLink(text || target, target);
532
+ }
533
+ if (qualified) {
534
+ addressed = true;
535
+ itemDoc = qualified.itemDoc;
536
+ doc = index.byShortcode.get(
537
+ `${qualified.type}/${qualified.shortcode}`,
538
+ );
539
+ } else {
540
+ const aliasKey = `${norm(type)}|${norm(target)}`;
541
+ const hit = index.byAlias.get(aliasKey);
542
+ if (hit === null) {
543
+ unresolved.push({
544
+ link: all,
545
+ target,
546
+ offset,
547
+ reason: "ambiguous",
548
+ // Who claimed it, so the report can name the collision
549
+ // rather than the note that merely cites it (#13).
550
+ candidates: (
551
+ index.aliasClaims?.get(aliasKey) ?? []
552
+ ).map((d) => ({
553
+ type: d.type,
554
+ shortcode: d.shortcode,
555
+ name: d.name,
556
+ })),
557
+ });
558
+ return unresolvedLink(text || target, target);
559
+ }
560
+ doc = hit;
561
+ }
562
+ }
563
+ if (!doc) {
564
+ // Nothing local answers. A foreign package may publish this
565
+ // address, in which case the manifest hands back a complete UUID —
566
+ // including, for a section link, the anchor's own — so nothing is
567
+ // derived here.
568
+ const hit = findForeign(index, qualifiedRead);
569
+ if (hit) {
570
+ const uuid = slug ? hit.anchors?.[slug] : hit.uuid;
571
+ if (uuid) {
572
+ return `@UUID[${uuid}]{${text || hit.name || target}}`;
573
+ }
574
+ unresolved.push({
575
+ link: all,
576
+ target,
577
+ offset,
578
+ reason: "unknown-anchor",
579
+ addressed: true,
580
+ });
581
+ return unresolvedLink(text || target, target);
582
+ }
583
+ unresolved.push({
584
+ link: all,
585
+ target,
586
+ offset,
587
+ reason: "unknown",
588
+ // A *qualified* address that resolves nowhere is a typo: every
589
+ // package it could name is either built here or vendored, so
590
+ // there is no third possibility left. A bare alias is not — it
591
+ // may simply be prose.
592
+ addressed: !!qualifiedRead && !qualifiedRead.reason,
593
+ });
594
+ return unresolvedLink(text || target, target);
595
+ }
596
+
597
+ // With no explicit label, a *qualified* target has no prose to show — a
598
+ // shortcode is an address, not display text — so the document's own name
599
+ // stands in (#1409). A bare `[[Text]]` is already the prose the author
600
+ // wrote, and substituting the canonical name there would rewrite the
601
+ // sentence ("worsens the [[Shock State]]" must not render as "Shock").
602
+ // The knowledgebase build reads the same authored link the same way.
603
+ if (!text || (!labelled && addressed)) text = doc.name ?? target;
604
+
605
+ // Both addresses were computed when the target was indexed. An item
606
+ // doc lives in the journals pack under its own derived entry id, and
607
+ // its pages hash against *that* id — not the item's.
608
+ //
609
+ // The one target with no index entry is the note itself: a `[[#slug]]`
610
+ // self-link is resolved from the source's own type and id, which the
611
+ // caller supplied, so it is addressed the same way here.
612
+ const addresses = index.uuidByDoc.get(doc) ?? {
613
+ uuid: compendiumUuid(
614
+ index.packageId,
615
+ doc.type,
616
+ doc.id,
617
+ doc.pack,
618
+ ),
619
+ docUuid: compendiumUuid(
620
+ index.packageId,
621
+ "doc",
622
+ itemDocEntryId(doc.id),
623
+ doc.docPack,
624
+ ),
625
+ };
626
+ const entryUuid = itemDoc ? addresses.docUuid : addresses.uuid;
627
+ const entryId = itemDoc ? itemDocEntryId(doc.id) : doc.id;
628
+ const isJournal =
629
+ itemDoc || packForType(doc.type).docType === "JournalEntry";
630
+ // A JournalEntry link opens a journal — at its first page, or at the
631
+ // page an anchor names. An Item or Actor link opens that document's
632
+ // *sheet*, which has no sections, so the anchor has nothing to address
633
+ // and is dropped. Forging a JournalEntryPage id onto a document that
634
+ // can never hold one is what made such links dead-end (#1362); an
635
+ // item's pages are addressed through its `doc<type>` counterpart.
636
+ const uuid =
637
+ slug && isJournal ?
638
+ pageUuid(entryUuid, anchorPageId(entryId, slug))
639
+ : entryUuid;
640
+ return `@UUID[${uuid}]{${text}}`;
641
+ },
642
+ );
643
+
644
+ return { markdown: out, unresolved };
645
+ }