@heroiclands/package-build 0.6.1 → 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.
- package/CHANGELOG-content-build.md +965 -0
- package/CHANGELOG.md +58 -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,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, "&")
|
|
445
|
+
.replace(/</g, "<")
|
|
446
|
+
.replace(/>/g, ">")
|
|
447
|
+
.replace(/"/g, """);
|
|
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
|
+
}
|