@heroiclands/package-build 10.0.1 → 11.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +279 -0
- package/CONTENT.md +218 -71
- package/MIGRATING.md +64 -0
- package/bin/content-build.mjs +59 -75
- package/docs/content-format.md +90 -67
- package/engine/base-compiler.mjs +7 -1
- package/engine/content-address.mjs +71 -18
- package/engine/content-format-check.mjs +1 -1
- package/engine/content-links.mjs +93 -112
- package/engine/content-lint.mjs +14 -10
- package/engine/content-slug.mjs +39 -105
- package/engine/diagnostics.mjs +16 -2
- package/engine/frontmatter-lint.mjs +26 -13
- package/engine/helpers.mjs +31 -68
- package/engine/homepage.mjs +131 -86
- package/engine/index.mjs +2 -5
- package/engine/manifest-emit.mjs +23 -4
- package/engine/note-vocabulary.mjs +58 -1
- package/engine/retired-fields.mjs +117 -6
- package/engine/site-build.mjs +182 -59
- package/engine/site-index.mjs +57 -102
- package/engine/web-wikilinks.mjs +183 -127
- package/engine/wikilink-syntax.mjs +174 -34
- package/engine/wikilinks.mjs +159 -117
- package/package.json +1 -1
- package/types/engine/base-compiler.d.mts +1 -1
- package/types/engine/content-address.d.mts +46 -14
- package/types/engine/content-links.d.mts +13 -17
- package/types/engine/content-slug.d.mts +11 -48
- package/types/engine/diagnostics.d.mts +14 -1
- package/types/engine/helpers.d.mts +4 -3
- package/types/engine/homepage.d.mts +96 -60
- package/types/engine/index.d.mts +0 -1
- package/types/engine/note-vocabulary.d.mts +43 -0
- package/types/engine/retired-fields.d.mts +78 -1
- package/types/engine/site-build.d.mts +70 -17
- package/types/engine/site-index.d.mts +19 -21
- package/types/engine/web-wikilinks.d.mts +29 -28
- package/types/engine/wikilink-syntax.d.mts +126 -40
- package/types/engine/wikilinks.d.mts +29 -24
- package/engine/abbreviations.mjs +0 -0
- package/engine/alias-index.mjs +0 -153
- package/types/engine/abbreviations.d.mts +0 -44
- package/types/engine/alias-index.d.mts +0 -122
package/engine/wikilinks.mjs
CHANGED
|
@@ -18,15 +18,14 @@
|
|
|
18
18
|
*
|
|
19
19
|
* `[[type-shortcode|Text]]` a document of that type
|
|
20
20
|
* `[[type-shortcode|]]` the same, showing the target's current name
|
|
21
|
-
* `[[Text]]` an alias unique within the source's own type
|
|
22
21
|
* `[[type-shortcode#slug|T]]` a section (see below)
|
|
23
22
|
* `[[#slug|Text]]` a section of the source note itself
|
|
24
23
|
* `[[doctype-shortcode|T]]` an item's *documentation* (see below)
|
|
25
24
|
*
|
|
26
|
-
* **
|
|
27
|
-
*
|
|
28
|
-
* states the rule for both builds.
|
|
29
|
-
*
|
|
25
|
+
* **Every link is an address, and every address carries a label** (#180). A
|
|
26
|
+
* link written without one addresses nothing and is reported — see
|
|
27
|
+
* {@link unlabelledLinkMessage}, which states the rule for both builds. The
|
|
28
|
+
* bare `[[Alias]]` form and the index it was looked up in are retired.
|
|
30
29
|
*
|
|
31
30
|
* The qualifier is the note's **type**, which with its shortcode is the system's
|
|
32
31
|
* logical identity: `(type, shortcode)` is unique by rule (see the Shortcode
|
|
@@ -34,13 +33,11 @@
|
|
|
34
33
|
* unique per type, not per directory, so a directory qualifier would add nothing
|
|
35
34
|
* to the address while breaking every inbound link the moment a note is refiled.
|
|
36
35
|
*
|
|
37
|
-
*
|
|
38
|
-
*
|
|
39
|
-
*
|
|
40
|
-
*
|
|
41
|
-
*
|
|
42
|
-
* "Gear"), the bare form is ambiguous and resolves to neither; the author writes
|
|
43
|
-
* the `[[type-shortcode|Text]]` address instead.
|
|
36
|
+
* Nothing narrower than `(type, shortcode)` is consulted — a note's directory
|
|
37
|
+
* and its `category` play no part in resolution — and nothing wider: a note's
|
|
38
|
+
* *name* is not an address, so two notes of a type may share a display name
|
|
39
|
+
* ("Gear" as a rules page and as a user-guide page) with nothing to disambiguate
|
|
40
|
+
* (#179, #180).
|
|
44
41
|
*
|
|
45
42
|
* At compile time each becomes a Foundry UUID enricher, routed to the pack that
|
|
46
43
|
* the target's type compiles into (see {@link packForType}):
|
|
@@ -93,10 +90,12 @@ import { hasDocEntry, itemDocEntryId } from "./item-docs.mjs";
|
|
|
93
90
|
import { replaceOutsideCode } from "./code-fences.mjs";
|
|
94
91
|
// The syntax lives in `./wikilink-syntax.mjs`, so the web resolver and this
|
|
95
92
|
// one cannot disagree about what counts as a link.
|
|
96
|
-
import {
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
93
|
+
import {
|
|
94
|
+
authoredLabel,
|
|
95
|
+
WIKILINK,
|
|
96
|
+
parseWikilink,
|
|
97
|
+
unlabelledLinkMessage,
|
|
98
|
+
} from "./wikilink-syntax.mjs";
|
|
100
99
|
|
|
101
100
|
export { ITEM_PACK, PACK_BY_TYPE, packForType };
|
|
102
101
|
|
|
@@ -145,7 +144,7 @@ export function resolveItemDocType(qualifier, types) {
|
|
|
145
144
|
|
|
146
145
|
/**
|
|
147
146
|
* Read a link target as a **qualified** `type-shortcode` reference, or report
|
|
148
|
-
* that it
|
|
147
|
+
* that it does not parse as one.
|
|
149
148
|
*
|
|
150
149
|
* Two separators are accepted, and they are **not** interchangeable in how
|
|
151
150
|
* confidently they mark a target as qualified:
|
|
@@ -154,19 +153,20 @@ export function resolveItemDocType(qualifier, types) {
|
|
|
154
153
|
* a wikilink as a *path* and resolves it against the vault's folders, so a
|
|
155
154
|
* slash-qualified link is a broken link in the editor where the content is now
|
|
156
155
|
* authored. A hyphen qualifies **only when what precedes it is a known type**:
|
|
157
|
-
* note names contain hyphens too (`Grukar-ahk`), and
|
|
158
|
-
* as
|
|
159
|
-
*
|
|
156
|
+
* note names contain hyphens too (`Grukar-ahk`), and a target that is one is
|
|
157
|
+
* reported as not an address rather than split at an arbitrary place. The
|
|
158
|
+
* split is at the **first** hyphen, so a shortcode may itself contain one
|
|
159
|
+
* (`trauma-self-pro` → `trauma` + `self-pro`).
|
|
160
160
|
* - **`type/shortcode`** — the legacy form, still resolved so that a link
|
|
161
161
|
* written before the vault migrated does not silently die. A slash is
|
|
162
162
|
* *unconditionally* a qualifier: nothing else uses one, so an unknown type
|
|
163
|
-
* before it is
|
|
164
|
-
*
|
|
163
|
+
* before it is reported rather than guessed at. The split is at the **last**
|
|
164
|
+
* slash, as it always was.
|
|
165
165
|
*
|
|
166
166
|
* A leading **package** segment is optional and outermost: `sohl-skill-lang` is
|
|
167
167
|
* `skill-lang` in the `sohl` package. It is read only when `packages` is given
|
|
168
168
|
* and names the segment, and only when the remainder is itself a valid address,
|
|
169
|
-
* so a note called "Grukar-ahk"
|
|
169
|
+
* so a note called "Grukar-ahk" is not mistaken for one (#1499).
|
|
170
170
|
*
|
|
171
171
|
* @param {string} target - The link target, anchor already removed.
|
|
172
172
|
* @param {Set<string>} types - Every type the content tree contains.
|
|
@@ -175,7 +175,7 @@ export function resolveItemDocType(qualifier, types) {
|
|
|
175
175
|
* @returns {{type: string, shortcode: string, itemDoc: boolean,
|
|
176
176
|
* package?: string, reason?: undefined} | {reason: "unknown-type"} | null}
|
|
177
177
|
* The resolved qualifier; a `reason` when the target is definitely qualified
|
|
178
|
-
* but names no known type; or `null` when it is
|
|
178
|
+
* but names no known type; or `null` when it is not an address at all.
|
|
179
179
|
*/
|
|
180
180
|
export function readQualifier(target, types, packages) {
|
|
181
181
|
// A leading **package** segment is the optional outermost qualifier:
|
|
@@ -183,7 +183,7 @@ export function readQualifier(target, types, packages) {
|
|
|
183
183
|
// here so everything below reads the same `type`/`shortcode` it always did,
|
|
184
184
|
// and it is recognised only when what precedes the hyphen is a package this
|
|
185
185
|
// build knows *and* the remainder is itself a valid address — so a note
|
|
186
|
-
// named "Sohl-something" is
|
|
186
|
+
// named "Sohl-something" is not mistaken for one (#1499).
|
|
187
187
|
if (packages?.size) {
|
|
188
188
|
const hyphen = target.indexOf("-");
|
|
189
189
|
if (hyphen > 0) {
|
|
@@ -204,7 +204,8 @@ export function readQualifier(target, types, packages) {
|
|
|
204
204
|
|
|
205
205
|
const hyphen = target.indexOf("-");
|
|
206
206
|
if (hyphen > 0) {
|
|
207
|
-
// A hyphen qualifies only on a known type; otherwise it is part of a
|
|
207
|
+
// A hyphen qualifies only on a known type; otherwise it is part of a
|
|
208
|
+
// name, and a name is not an address.
|
|
208
209
|
return readTypeAndCode(target.slice(0, hyphen), target.slice(hyphen + 1), types);
|
|
209
210
|
}
|
|
210
211
|
return null;
|
|
@@ -257,21 +258,21 @@ export function anchorPageId(noteId, anchorSlug) {
|
|
|
257
258
|
* Builds the link-resolution tables for a content tree.
|
|
258
259
|
*
|
|
259
260
|
* @param {Array<{type: string, id: string, shortcode?: string|null,
|
|
260
|
-
*
|
|
261
|
+
* name?: string, pack?: string, docPack?: string,
|
|
262
|
+
* draft?: boolean}>} docs -
|
|
261
263
|
* One entry per content note. `pack` / `docPack` name the packs the note's
|
|
262
264
|
* document and its documentation entry landed in; omitted, the conventional
|
|
263
|
-
* one-pack-per-type names stand in.
|
|
265
|
+
* one-pack-per-type names stand in. `draft` says the note carries the `draft`
|
|
266
|
+
* tag, which marks links *into* it and changes nothing else (#183).
|
|
264
267
|
* @param {string} packageId - The Foundry package shipping the packs; the first
|
|
265
268
|
* segment of every emitted UUID.
|
|
266
269
|
* @param {Map<string, object>} [foreign] - Canonically keyed entries from
|
|
267
270
|
* vendored manifests of packages this build links into but does not publish.
|
|
268
271
|
* @param {string} [contentPackage] - This build's *content* package, which an
|
|
269
272
|
* authored address may name explicitly. Defaults to `packageId`.
|
|
270
|
-
* @returns {{byShortcode: Map<string, object>,
|
|
271
|
-
*
|
|
272
|
-
*
|
|
273
|
-
* unusable for it. `types` is every type the tree actually contains, so a
|
|
274
|
-
* qualifier naming no real type can be told apart from a missing target.
|
|
273
|
+
* @returns {{byShortcode: Map<string, object>, types: Set<string>}} `types` is
|
|
274
|
+
* every type the tree actually contains, so a qualifier naming no real type
|
|
275
|
+
* can be told apart from a missing target.
|
|
275
276
|
*/
|
|
276
277
|
export function buildWikilinkIndex(docs, packageId, foreign, contentPackage) {
|
|
277
278
|
if (!packageId) {
|
|
@@ -283,11 +284,6 @@ export function buildWikilinkIndex(docs, packageId, foreign, contentPackage) {
|
|
|
283
284
|
}
|
|
284
285
|
|
|
285
286
|
const byShortcode = new Map();
|
|
286
|
-
const byAlias = new Map();
|
|
287
|
-
// key -> every note claiming it. `byAlias` records only the *verdict*
|
|
288
|
-
// (a note, or `null` for poisoned); this records the claimants, which is
|
|
289
|
-
// what an ambiguity report has to name.
|
|
290
|
-
const aliasClaims = new Map();
|
|
291
287
|
const types = new Set();
|
|
292
288
|
|
|
293
289
|
// Each note's address is computed once, here, and every reference to it is
|
|
@@ -312,19 +308,6 @@ export function buildWikilinkIndex(docs, packageId, foreign, contentPackage) {
|
|
|
312
308
|
});
|
|
313
309
|
|
|
314
310
|
if (d.shortcode) byShortcode.set(`${norm(d.type)}/${norm(d.shortcode)}`, d);
|
|
315
|
-
for (const a of d.aliases ?? []) {
|
|
316
|
-
const key = aliasKey(d.type, a);
|
|
317
|
-
// Second claimant poisons the alias: it can no longer be resolved.
|
|
318
|
-
byAlias.set(key, byAlias.has(key) && byAlias.get(key) !== d ? null : d);
|
|
319
|
-
// Every claimant is kept alongside, because poisoning the alias
|
|
320
|
-
// discards exactly the information needed to report the problem.
|
|
321
|
-
// The note that *cites* an ambiguous alias is innocent — whoever
|
|
322
|
-
// added the second claimant broke it — so a message that can only
|
|
323
|
-
// name the citing note points at the wrong file (#13).
|
|
324
|
-
const claims = aliasClaims.get(key);
|
|
325
|
-
if (!claims) aliasClaims.set(key, [d]);
|
|
326
|
-
else if (!claims.includes(d)) claims.push(d);
|
|
327
|
-
}
|
|
328
311
|
}
|
|
329
312
|
// Entries published by *other* packages, keyed canonically. Merged as one
|
|
330
313
|
// map rather than consulted separately: the keys are globally unique, so a
|
|
@@ -369,8 +352,6 @@ export function buildWikilinkIndex(docs, packageId, foreign, contentPackage) {
|
|
|
369
352
|
|
|
370
353
|
return {
|
|
371
354
|
byShortcode,
|
|
372
|
-
byAlias,
|
|
373
|
-
aliasClaims,
|
|
374
355
|
types,
|
|
375
356
|
uuidByDoc,
|
|
376
357
|
packageId,
|
|
@@ -382,22 +363,43 @@ export function buildWikilinkIndex(docs, packageId, foreign, contentPackage) {
|
|
|
382
363
|
/**
|
|
383
364
|
* The foreign manifest entry an address names, or `null`.
|
|
384
365
|
*
|
|
385
|
-
* A package-qualified address is one lookup.
|
|
366
|
+
* A package-qualified address is one lookup. An unqualified one names no
|
|
367
|
+
* package, so it
|
|
386
368
|
* resolves against whichever foreign package publishes it — and only when
|
|
387
369
|
* exactly one does. Claimed by two, it is genuinely ambiguous and the author
|
|
388
370
|
* writes the qualified form; guessing would make the build depend on which
|
|
389
371
|
* manifest happened to load first.
|
|
390
372
|
*
|
|
391
373
|
* @param {object} index - From {@link buildWikilinkIndex}.
|
|
392
|
-
* @param {object|null} read - The parsed qualifier, or `null`
|
|
374
|
+
* @param {object|null} read - The parsed qualifier, or `null` when the target
|
|
375
|
+
* did not parse as an address.
|
|
393
376
|
* @returns {object|null} The manifest entry.
|
|
394
377
|
*/
|
|
395
378
|
function findForeign(index, read) {
|
|
396
|
-
|
|
379
|
+
const hits = foreignHits(index, read);
|
|
380
|
+
return hits.length === 1 ? hits[0] : null;
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
/**
|
|
384
|
+
* Every foreign manifest entry an address names.
|
|
385
|
+
*
|
|
386
|
+
* The count is what separates *nothing publishes this* from *two packages do*,
|
|
387
|
+
* and those are different findings with different fixes (#184), so the caller
|
|
388
|
+
* gets the list rather than a single answer that has already collapsed the
|
|
389
|
+
* distinction.
|
|
390
|
+
*
|
|
391
|
+
* @param {object} index - From {@link buildWikilinkIndex}.
|
|
392
|
+
* @param {object|null} read - The parsed qualifier, or `null` when the target
|
|
393
|
+
* did not parse as an address.
|
|
394
|
+
* @returns {object[]} The manifest entries.
|
|
395
|
+
*/
|
|
396
|
+
function foreignHits(index, read) {
|
|
397
|
+
if (!read || read.reason || !index.foreign?.size) return [];
|
|
397
398
|
const wanted = norm(read.itemDoc ? `doc${read.type}` : read.type);
|
|
398
399
|
const shortcode = norm(read.shortcode);
|
|
399
400
|
if (read.package) {
|
|
400
|
-
|
|
401
|
+
const one = index.foreign.get(`${read.package}-${wanted}-${shortcode}`.toLowerCase());
|
|
402
|
+
return one ? [one] : [];
|
|
401
403
|
}
|
|
402
404
|
const hits = [];
|
|
403
405
|
for (const [key, v] of index.foreign) {
|
|
@@ -405,7 +407,7 @@ function findForeign(index, read) {
|
|
|
405
407
|
if (parts.length !== 3) continue;
|
|
406
408
|
if (parts[1] === wanted && parts[2] === shortcode) hits.push(v);
|
|
407
409
|
}
|
|
408
|
-
return hits
|
|
410
|
+
return hits;
|
|
409
411
|
}
|
|
410
412
|
|
|
411
413
|
/**
|
|
@@ -433,6 +435,36 @@ function unresolvedLink(text, target) {
|
|
|
433
435
|
);
|
|
434
436
|
}
|
|
435
437
|
|
|
438
|
+
/**
|
|
439
|
+
* How a link to a **draft** note renders (#183).
|
|
440
|
+
*
|
|
441
|
+
* A note tagged `draft` exists so a link into it is not dead, and nothing more.
|
|
442
|
+
* Unmarked, a reader follows a promising link into an empty page and an author
|
|
443
|
+
* cannot see which of their links still owe content.
|
|
444
|
+
*
|
|
445
|
+
* **The wrapper carries the cue and nothing else.** The link itself is
|
|
446
|
+
* untouched — Foundry enriches inside HTML, so the `@UUID` still becomes a live
|
|
447
|
+
* content link, and the note is in the packs, the manifest and the index
|
|
448
|
+
* exactly as any other. Nothing here resembles the retired `draft:` field,
|
|
449
|
+
* which moved a note from published to unresolvable without saying so.
|
|
450
|
+
*
|
|
451
|
+
* The appearance lives in `scss/components/_draft-link.scss`, beside the
|
|
452
|
+
* unresolved-link partial, not here.
|
|
453
|
+
*
|
|
454
|
+
* **Byte-identical with the site build's copy** in `web-wikilinks.mjs`, down to
|
|
455
|
+
* the class name and the `title` wording — one authored link renders on two
|
|
456
|
+
* surfaces, and the two builds have drifted before over exactly this kind of
|
|
457
|
+
* detail (#1409). The argument is already-built markup and is deliberately not
|
|
458
|
+
* escaped; the *authored* text inside it was escaped, or made into a link, by
|
|
459
|
+
* whichever resolver called this.
|
|
460
|
+
*
|
|
461
|
+
* @param {string} inner - The resolved link, as that build emits it.
|
|
462
|
+
* @returns {string} An inline HTML span wrapping it.
|
|
463
|
+
*/
|
|
464
|
+
function draftLink(inner) {
|
|
465
|
+
return `<span class="sohl-draft-link" title="Draft — not yet written">${inner}</span>`;
|
|
466
|
+
}
|
|
467
|
+
|
|
436
468
|
/** Matches a whole wikilink, capturing its inner text. */
|
|
437
469
|
|
|
438
470
|
/**
|
|
@@ -452,19 +484,23 @@ function unresolvedLink(text, target) {
|
|
|
452
484
|
*
|
|
453
485
|
* @param {string} markdown - The note body (frontmatter already stripped).
|
|
454
486
|
* @param {object} ctx
|
|
455
|
-
* @param {string} ctx.type - The source note's `type`, which
|
|
487
|
+
* @param {string} ctx.type - The source note's `type`, which addresses a
|
|
488
|
+
* `[[#slug]]` self-link.
|
|
456
489
|
* @param {string} ctx.id - The source note's document id.
|
|
457
490
|
* @param {string} [ctx.pack] - The pack the source note's own document landed
|
|
458
491
|
* in, which addresses a `[[#slug]]` self-link — the one target with no index
|
|
459
492
|
* entry.
|
|
460
493
|
* @param {string} [ctx.docPack] - The pack the source note's documentation
|
|
461
494
|
* entry landed in.
|
|
462
|
-
* @param {{byShortcode: Map,
|
|
495
|
+
* @param {{byShortcode: Map, types: Set}} ctx.index - From
|
|
463
496
|
* {@link buildWikilinkIndex}.
|
|
464
497
|
* @returns {{markdown: string, unresolved: Array<{link: string, target: string,
|
|
465
|
-
* offset: number, reason:
|
|
466
|
-
*
|
|
467
|
-
*
|
|
498
|
+
* offset: number, reason: string, packages?: string[], anchor?: string}>}}
|
|
499
|
+
* Each `reason` is one of {@link LINK_FINDING_REASONS}, the vocabulary all
|
|
500
|
+
* three resolvers share (#184) — `ambiguous` carries the claiming `packages`
|
|
501
|
+
* and `unknown-anchor` the section it named. `offset` is the link's 0-based
|
|
502
|
+
* position in `markdown`, which is what lets a caller report the line and
|
|
503
|
+
* column it sits on (#17).
|
|
468
504
|
*/
|
|
469
505
|
export function convertWikilinks(markdown, { type, id, pack, docPack, index }) {
|
|
470
506
|
const unresolved = [];
|
|
@@ -474,37 +510,47 @@ export function convertWikilinks(markdown, { type, id, pack, docPack, index }) {
|
|
|
474
510
|
// one note tellable apart, and a position reportable at all (#17).
|
|
475
511
|
const out = replaceOutsideCode(markdown, WIKILINK, (all, rawInner, offset) => {
|
|
476
512
|
const parsed = parseWikilink(rawInner);
|
|
477
|
-
const
|
|
478
|
-
let target = parsed.target;
|
|
479
|
-
// An unlabelled link shows its interior verbatim, anchor included;
|
|
480
|
-
// a labelled one shows its label. An *empty* label is not a label
|
|
481
|
-
// — `[[x|]]` means "show the target's name" — and that reading
|
|
482
|
-
// comes from {@link authoredLabel} so the web resolver cannot draw
|
|
483
|
-
// the line somewhere else (#113).
|
|
484
|
-
let text = labelled ? (authoredLabel(parsed) ?? "") : parsed.inner;
|
|
513
|
+
const target = parsed.target;
|
|
485
514
|
const slug = parsed.anchor || null;
|
|
486
515
|
|
|
487
|
-
//
|
|
488
|
-
//
|
|
489
|
-
//
|
|
516
|
+
// **Every link carries a label** (#180). Without one there is nothing
|
|
517
|
+
// to resolve against: the alias namespace a bare `[[Text]]` was looked
|
|
518
|
+
// up in is retired, and a shortcode is an address rather than prose, so
|
|
519
|
+
// the link has neither a target this build can find nor text to show.
|
|
520
|
+
// Reported before anything else, including the same-page form, because
|
|
521
|
+
// it is a statement about how the link is *written* — `[[#slug]]` needs
|
|
522
|
+
// the pipe exactly as `[[skill-clmb]]` does.
|
|
523
|
+
if (!parsed.labelled) {
|
|
524
|
+
unresolved.push({
|
|
525
|
+
link: all,
|
|
526
|
+
target: target || (slug ? `#${slug}` : ""),
|
|
527
|
+
offset,
|
|
528
|
+
reason: "unlabelled",
|
|
529
|
+
addressed: false,
|
|
530
|
+
});
|
|
531
|
+
return unresolvedLink(parsed.inner, parsed.inner);
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
// An *empty* label is not a label — `[[x|]]` means "show the target's
|
|
535
|
+
// name" — and that reading comes from {@link authoredLabel} so the web
|
|
536
|
+
// resolver cannot draw the line somewhere else (#113).
|
|
537
|
+
let text = authoredLabel(parsed) ?? "";
|
|
538
|
+
|
|
539
|
+
// Resolve the document: the source note itself for an empty target, or
|
|
540
|
+
// the address the target parses as.
|
|
490
541
|
let doc;
|
|
491
542
|
// Set when the qualifier was the virtual `doc<type>` form, so the UUID
|
|
492
543
|
// is built against the item doc entry rather than the item itself.
|
|
493
544
|
let itemDoc = false;
|
|
494
|
-
// Set when the target was read as an address, which is what decides
|
|
495
|
-
// whether a foreign manifest is consulted for it below.
|
|
496
|
-
let addressed = false;
|
|
497
545
|
// Kept for the foreign fallback below, which needs the parsed address.
|
|
498
546
|
let qualifiedRead = null;
|
|
499
547
|
if (target === "" && slug) {
|
|
500
548
|
doc = { type, id, pack, docPack };
|
|
501
|
-
} else
|
|
549
|
+
} else {
|
|
502
550
|
const qualified = readQualifier(target, index.types, index.packages);
|
|
503
551
|
qualifiedRead = qualified;
|
|
504
|
-
//
|
|
505
|
-
//
|
|
506
|
-
// under the old resolve-by-shape rule, an invitation to try the
|
|
507
|
-
// alias index — which is what let a note *name* resolve here.
|
|
552
|
+
// A target that does not parse as an address is a defect: there is
|
|
553
|
+
// no second namespace left to fall through to (#180).
|
|
508
554
|
if (!qualified || qualified.reason) {
|
|
509
555
|
unresolved.push({
|
|
510
556
|
link: all,
|
|
@@ -515,36 +561,30 @@ export function convertWikilinks(markdown, { type, id, pack, docPack, index }) {
|
|
|
515
561
|
});
|
|
516
562
|
return unresolvedLink(text || target, target);
|
|
517
563
|
}
|
|
518
|
-
addressed = true;
|
|
519
564
|
itemDoc = qualified.itemDoc;
|
|
520
565
|
doc = index.byShortcode.get(`${qualified.type}/${qualified.shortcode}`);
|
|
521
|
-
}
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
566
|
+
}
|
|
567
|
+
if (!doc) {
|
|
568
|
+
// Nothing local answers. A foreign package may publish this
|
|
569
|
+
// address, in which case the manifest hands back a complete UUID —
|
|
570
|
+
// including, for a section link, the anchor's own — so nothing is
|
|
571
|
+
// derived here.
|
|
572
|
+
const hits = foreignHits(index, qualifiedRead);
|
|
573
|
+
if (hits.length > 1) {
|
|
574
|
+
// Two packages publish the short address, so it names neither.
|
|
575
|
+
// Its own class: the fix is the package-qualified form, not a
|
|
576
|
+
// corrected shortcode (#184).
|
|
525
577
|
unresolved.push({
|
|
526
578
|
link: all,
|
|
527
579
|
target,
|
|
528
580
|
offset,
|
|
529
581
|
reason: "ambiguous",
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
candidates: (index.aliasClaims?.get(key) ?? []).map((d) => ({
|
|
533
|
-
type: d.type,
|
|
534
|
-
shortcode: d.shortcode,
|
|
535
|
-
name: d.name,
|
|
536
|
-
})),
|
|
582
|
+
packages: hits.map((h) => h.package).filter(Boolean),
|
|
583
|
+
addressed: true,
|
|
537
584
|
});
|
|
538
585
|
return unresolvedLink(text || target, target);
|
|
539
586
|
}
|
|
540
|
-
|
|
541
|
-
}
|
|
542
|
-
if (!doc) {
|
|
543
|
-
// Nothing local answers. A foreign package may publish this
|
|
544
|
-
// address, in which case the manifest hands back a complete UUID —
|
|
545
|
-
// including, for a section link, the anchor's own — so nothing is
|
|
546
|
-
// derived here.
|
|
547
|
-
const hit = findForeign(index, qualifiedRead);
|
|
587
|
+
const hit = hits[0] ?? null;
|
|
548
588
|
if (hit) {
|
|
549
589
|
const uuid = slug ? hit.anchors?.[slug] : hit.uuid;
|
|
550
590
|
if (uuid) {
|
|
@@ -555,6 +595,7 @@ export function convertWikilinks(markdown, { type, id, pack, docPack, index }) {
|
|
|
555
595
|
target,
|
|
556
596
|
offset,
|
|
557
597
|
reason: "unknown-anchor",
|
|
598
|
+
anchor: slug,
|
|
558
599
|
addressed: true,
|
|
559
600
|
});
|
|
560
601
|
return unresolvedLink(text || target, target);
|
|
@@ -563,24 +604,20 @@ export function convertWikilinks(markdown, { type, id, pack, docPack, index }) {
|
|
|
563
604
|
link: all,
|
|
564
605
|
target,
|
|
565
606
|
offset,
|
|
566
|
-
reason: "
|
|
567
|
-
// An
|
|
568
|
-
//
|
|
569
|
-
//
|
|
570
|
-
|
|
571
|
-
addressed,
|
|
607
|
+
reason: "unresolved",
|
|
608
|
+
// An address that resolves nowhere is a typo: every package it
|
|
609
|
+
// could name is either built here or vendored, so there is no
|
|
610
|
+
// third possibility left.
|
|
611
|
+
addressed: true,
|
|
572
612
|
});
|
|
573
613
|
return unresolvedLink(text || target, target);
|
|
574
614
|
}
|
|
575
615
|
|
|
576
|
-
// An address with
|
|
577
|
-
// a shortcode being an address rather than display text, so
|
|
578
|
-
// document's **current** name stands in and a rename shows at every
|
|
579
|
-
// citation with no link edited (#1409
|
|
580
|
-
//
|
|
581
|
-
// name there would rewrite the sentence ("worsens the [[Shock State]]"
|
|
582
|
-
// must not render as "Shock"). The knowledgebase build reads the same
|
|
583
|
-
// authored link the same way.
|
|
616
|
+
// An address with an *empty* label — `[[skill-clmb|]]` — has no prose
|
|
617
|
+
// to show, a shortcode being an address rather than display text, so
|
|
618
|
+
// the document's **current** name stands in and a rename shows at every
|
|
619
|
+
// citation with no link edited (#1409). The knowledgebase build reads
|
|
620
|
+
// the same authored link the same way.
|
|
584
621
|
if (!text) text = doc.name ?? target;
|
|
585
622
|
|
|
586
623
|
// Both addresses were computed when the target was indexed. An item
|
|
@@ -605,7 +642,12 @@ export function convertWikilinks(markdown, { type, id, pack, docPack, index }) {
|
|
|
605
642
|
// item's pages are addressed through its `doc<type>` counterpart.
|
|
606
643
|
const uuid =
|
|
607
644
|
slug && isJournal ? pageUuid(entryUuid, anchorPageId(entryId, slug)) : entryUuid;
|
|
608
|
-
|
|
645
|
+
const link = `@UUID[${uuid}]{${text}}`;
|
|
646
|
+
// A link into a note that exists but is not written renders marked
|
|
647
|
+
// (#183). Presentation only — the UUID above is unchanged, and a
|
|
648
|
+
// `[[#slug]]` self-link is not marked because the reader is already in
|
|
649
|
+
// the note it would be telling them about.
|
|
650
|
+
return doc.draft ? draftLink(link) : link;
|
|
609
651
|
});
|
|
610
652
|
|
|
611
653
|
return { markdown: out, unresolved };
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@heroiclands/package-build",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "11.0.0",
|
|
4
4
|
"description": "Shared toolchain for building and shipping a HeroicLands Foundry VTT package — content compilation, manifest, localization, staging, bundle, release and deployment.",
|
|
5
5
|
"license": "GPL-3.0-or-later",
|
|
6
6
|
"type": "module",
|
|
@@ -10,32 +10,64 @@
|
|
|
10
10
|
*/
|
|
11
11
|
export function sectionOf(fm: object): string | undefined;
|
|
12
12
|
/**
|
|
13
|
-
*
|
|
13
|
+
* The single path segment a note is addressed by: `type-shortcode`.
|
|
14
|
+
*
|
|
15
|
+
* Lowercased, so it is exactly the tail of the note's canonical key
|
|
16
|
+
* (`canonicalKey` in `engine/kb-manifest.mjs` lowercases too) — which is what
|
|
17
|
+
* makes a manifest entry's `path` derivable from the key it is filed under
|
|
18
|
+
* rather than transported beside it.
|
|
19
|
+
*
|
|
20
|
+
* The hyphen is a separator and never occurs inside a segment: a shortcode is
|
|
21
|
+
* `^[A-Za-z0-9]+$` (`ADDRESS_SEGMENT_PATTERN`, enforced by `content-lint.mjs`)
|
|
22
|
+
* and a type is a bare word. That is the same charset guarantee positional key
|
|
23
|
+
* parsing rests on, so the address and the key cannot disagree about where one
|
|
24
|
+
* ends and the other begins.
|
|
25
|
+
*
|
|
26
|
+
* @param {object} fm - Parsed frontmatter.
|
|
27
|
+
* @returns {string} The address segment, e.g. `weapongear-dagger`.
|
|
28
|
+
* @throws {Error} When the note declares no type or no shortcode — either way
|
|
29
|
+
* it has no address, which is reported rather than papered over: inventing
|
|
30
|
+
* one would publish a page nothing can link to and record a manifest entry
|
|
31
|
+
* pointing at it.
|
|
32
|
+
*/
|
|
33
|
+
export function addressSlug(fm: object): string;
|
|
34
|
+
/**
|
|
35
|
+
* A note's address below the knowledgebase mount, e.g. `affliction-aconite/`.
|
|
14
36
|
*
|
|
15
37
|
* A `README.md` **is** its section's landing page rather than a page within it,
|
|
16
|
-
* so it addresses the section itself and has no
|
|
38
|
+
* so it addresses the section itself and has no address of its own.
|
|
17
39
|
*
|
|
18
40
|
* @param {object} fm - Parsed frontmatter.
|
|
19
|
-
* @param {string} name - The note's display name; the slug derives from it
|
|
20
|
-
* (#1278), never from the shortcode, which is identity rather than
|
|
21
|
-
* presentation.
|
|
22
41
|
* @param {boolean} isReadme - Whether the file is a `README.md`.
|
|
23
|
-
* @returns {string} The
|
|
24
|
-
* @throws {Error} When the
|
|
42
|
+
* @returns {string} The mount-relative address, with a trailing slash.
|
|
43
|
+
* @throws {Error} When the note has no address.
|
|
25
44
|
*/
|
|
26
|
-
export function contentAddress(fm: object,
|
|
45
|
+
export function contentAddress(fm: object, isReadme: boolean): string;
|
|
27
46
|
/**
|
|
28
|
-
* A note's address relative to its **package**, e.g. `
|
|
47
|
+
* A note's address relative to its **package**, e.g. `affliction-aconite/`.
|
|
29
48
|
*
|
|
30
49
|
* This is the form the link manifest records and the site build emits pages at,
|
|
31
50
|
* and it is one function because those two must agree — a manifest asserting an
|
|
32
51
|
* address the site does not publish resolves at build time and 404s for the
|
|
33
52
|
* reader, which is the failure this module exists to prevent.
|
|
34
53
|
*
|
|
54
|
+
* **The prefix does not apply to a page's own address.** `prefix` says where the
|
|
55
|
+
* content tree *mounts inside the package* — where its section directories and
|
|
56
|
+
* their landing pages live — and a landing page is addressed by that mount
|
|
57
|
+
* (`kb/rules/`). An ordinary page is addressed by `(type, shortcode)`, which is
|
|
58
|
+
* a package-wide identity and takes no mount: `sohl` publishes
|
|
59
|
+
* `/sohl/affliction-aconite/` while its section landings stay at
|
|
60
|
+
* `/sohl/kb/affliction/`. The `type-` half is what keeps that flat namespace
|
|
61
|
+
* clear of the package's fixed mounts — `/<package>/` for the landing,
|
|
62
|
+
* `/<package>/api/` for generated API docs, neither of which contains a hyphen
|
|
63
|
+
* or names a type.
|
|
64
|
+
*
|
|
65
|
+
* **The section still decides where the *file* is written**, which is why a
|
|
66
|
+
* note without one still has no address: Hugo derives a section from a page's
|
|
67
|
+
* directory rather than from its URL, so a page with nowhere to be filed is a
|
|
68
|
+
* page with no section landing, no `.CurrentSection` and no per-section layout.
|
|
69
|
+
*
|
|
35
70
|
* @param {object} fm - Parsed frontmatter.
|
|
36
|
-
* @param {string} name - The note's display name; a page slug derives from it
|
|
37
|
-
* (#1278), never from the shortcode, which is identity rather than
|
|
38
|
-
* presentation.
|
|
39
71
|
* @param {object} [options] - Options.
|
|
40
72
|
* @param {boolean} [options.isReadme] - Whether the file is a `README.md`.
|
|
41
73
|
* @param {{prefix?: string, landing?: string}} [options.scheme] - The
|
|
@@ -43,11 +75,11 @@ export function contentAddress(fm: object, name: string, isReadme: boolean): str
|
|
|
43
75
|
* @returns {string} The package-relative address, with a trailing slash and no
|
|
44
76
|
* leading one.
|
|
45
77
|
* @throws {Error} When the note has no address — no section, a landing page
|
|
46
|
-
* naming no section, or
|
|
78
|
+
* naming no section, or no shortcode to be addressed by. Each is a note that
|
|
47
79
|
* is not published, and inventing an address for one would put a dead entry
|
|
48
80
|
* in the manifest.
|
|
49
81
|
*/
|
|
50
|
-
export function packageAddress(fm: object,
|
|
82
|
+
export function packageAddress(fm: object, { isReadme, scheme }?: {
|
|
51
83
|
isReadme?: boolean | undefined;
|
|
52
84
|
scheme?: {
|
|
53
85
|
prefix?: string;
|
|
@@ -86,30 +86,26 @@ export function auditHomepageLinks(index: ReturnType<typeof buildLinkIndex>): Ar
|
|
|
86
86
|
/**
|
|
87
87
|
* Every link in a tree that lands nowhere.
|
|
88
88
|
*
|
|
89
|
-
* **
|
|
90
|
-
*
|
|
91
|
-
*
|
|
92
|
-
*
|
|
93
|
-
*
|
|
94
|
-
* worldbuilding placeholder, which is a long-standing convention in the
|
|
95
|
-
* setting trees. So the first is an error and the second a warning, and the
|
|
96
|
-
* caller can tell them apart without parsing a message.
|
|
89
|
+
* **How the link is *written* is a separate finding from where it points**, and
|
|
90
|
+
* the two are kept apart because the corrections differ. An unlabelled link
|
|
91
|
+
* (#180) has to become `[[type-shortcode|Text]]`; a labelled one whose target
|
|
92
|
+
* resolves nowhere has a shortcode to fix. Reporting a bare `[[Name]]` as a
|
|
93
|
+
* dead address would send an author hunting for a note that was never named.
|
|
97
94
|
*
|
|
98
95
|
* @param {ReturnType<typeof buildLinkIndex>} index - The built index.
|
|
99
96
|
* @returns {{deadAnchors: object[], deadAddresses: object[],
|
|
100
|
-
*
|
|
101
|
-
*
|
|
102
|
-
*
|
|
103
|
-
*
|
|
104
|
-
* `"not-an-address"`
|
|
105
|
-
* `"
|
|
106
|
-
*
|
|
97
|
+
* unlabelledLinks: object[], frontmatterLinks: object[],
|
|
98
|
+
* homepageLinks: object[], usedManifest: Set<string>}} The findings, and
|
|
99
|
+
* which addresses a foreign manifest answered. Each `deadAddresses` entry
|
|
100
|
+
* carries a `reason` from {@link LINK_FINDING_REASONS} —
|
|
101
|
+
* `"not-an-address"`, `"unknown-type"`, `"ambiguous"` (with the claiming
|
|
102
|
+
* `packages`), or `"unresolved"` — and every one of them is an **error**:
|
|
103
|
+
* the three resolvers agree on severity for every class (#184).
|
|
107
104
|
*/
|
|
108
105
|
export function auditLinks(index: ReturnType<typeof buildLinkIndex>): {
|
|
109
106
|
deadAnchors: object[];
|
|
110
107
|
deadAddresses: object[];
|
|
111
|
-
|
|
112
|
-
aliasCollisions: object[];
|
|
108
|
+
unlabelledLinks: object[];
|
|
113
109
|
frontmatterLinks: object[];
|
|
114
110
|
homepageLinks: object[];
|
|
115
111
|
usedManifest: Set<string>;
|