@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,141 @@
|
|
|
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
|
+
* Where a content note publishes on the web.
|
|
16
|
+
*
|
|
17
|
+
* One rule, in one place, because two builds need the same answer: the
|
|
18
|
+
* knowledgebase build renders the page, and the link manifest records the
|
|
19
|
+
* address other packages link to. Stating it twice is how a manifest comes to
|
|
20
|
+
* assert a URL that resolves at build time and 404s for the reader.
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
import { contentSlug } from "./content-slug.mjs";
|
|
24
|
+
// The scheme vocabulary is part of the configuration contract — a
|
|
25
|
+
// repository names its scheme in `package-build.config.yaml` — so it is
|
|
26
|
+
// declared beside the rest of that vocabulary rather than here, and this
|
|
27
|
+
// module reads it. `config.mjs` is the leaf entry point and imports nothing
|
|
28
|
+
// but `node:path` and `engine/ids.mjs`, so the direction cannot close a
|
|
29
|
+
// cycle (see `engine/pack-config.mjs`).
|
|
30
|
+
import { DEFAULT_ADDRESS_SCHEME, LANDING_RULES } from "../content-config.mjs";
|
|
31
|
+
|
|
32
|
+
export { DEFAULT_ADDRESS_SCHEME, LANDING_RULES };
|
|
33
|
+
|
|
34
|
+
/** The knowledgebase's mount within this package's site (#1470). */
|
|
35
|
+
export const KB_PREFIX = "kb/";
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* The URL section a note routes to.
|
|
39
|
+
*
|
|
40
|
+
* A `doc` is narrative content whose only identity is its subtype label, so it
|
|
41
|
+
* routes by `category`; every other type names its own section.
|
|
42
|
+
*
|
|
43
|
+
* @param {object} fm - Parsed frontmatter.
|
|
44
|
+
* @returns {string|undefined} The section, or `undefined` when the note has
|
|
45
|
+
* none — a `doc` with no category has no address and is not published.
|
|
46
|
+
*/
|
|
47
|
+
export function sectionOf(fm) {
|
|
48
|
+
return fm.type === "doc" ? fm.category : fm.type;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* A note's address below the knowledgebase mount, e.g. `affliction/aconite/`.
|
|
53
|
+
*
|
|
54
|
+
* A `README.md` **is** its section's landing page rather than a page within it,
|
|
55
|
+
* so it addresses the section itself and has no slug of its own.
|
|
56
|
+
*
|
|
57
|
+
* @param {object} fm - Parsed frontmatter.
|
|
58
|
+
* @param {string} name - The note's display name; the slug derives from it
|
|
59
|
+
* (#1278), never from the shortcode, which is identity rather than
|
|
60
|
+
* presentation.
|
|
61
|
+
* @param {boolean} isReadme - Whether the file is a `README.md`.
|
|
62
|
+
* @returns {string} The section-relative address, with a trailing slash.
|
|
63
|
+
* @throws {Error} When the name yields no usable slug.
|
|
64
|
+
*/
|
|
65
|
+
export function contentAddress(fm, name, isReadme) {
|
|
66
|
+
const sec = sectionOf(fm);
|
|
67
|
+
return isReadme ? `${sec}/` : `${sec}/${contentSlug(name)}/`;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Whether a note is a landing page under a scheme, and what it lands at.
|
|
72
|
+
*
|
|
73
|
+
* @param {object} fm - Parsed frontmatter.
|
|
74
|
+
* @param {boolean} isReadme - Whether the file is a `README.md`.
|
|
75
|
+
* @param {string} landing - The landing rule, one of {@link LANDING_RULES}.
|
|
76
|
+
* @returns {{landing: true, segment: string}|{landing: false}|{landing: true, segment: undefined}}
|
|
77
|
+
* `segment` is the single path segment the note addresses. A `collection`
|
|
78
|
+
* note that declares no `section` is a landing page with no segment — an
|
|
79
|
+
* error rather than a page, since it names nowhere to land.
|
|
80
|
+
*/
|
|
81
|
+
function landingOf(fm, isReadme, landing) {
|
|
82
|
+
if (landing === "readme") {
|
|
83
|
+
return isReadme ?
|
|
84
|
+
{ landing: true, segment: sectionOf(fm) }
|
|
85
|
+
: { landing: false };
|
|
86
|
+
}
|
|
87
|
+
// `collection`. The section is authored rather than derived: it is the
|
|
88
|
+
// identity of the section being introduced, and the note's own title
|
|
89
|
+
// ("Creatures") is presentation, which would slug to something else.
|
|
90
|
+
if (fm.type === "doc" && fm.category === "collection") {
|
|
91
|
+
return { landing: true, segment: fm.section || fm.slug };
|
|
92
|
+
}
|
|
93
|
+
return { landing: false };
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* A note's address relative to its **package**, e.g. `kb/affliction/aconite/`.
|
|
98
|
+
*
|
|
99
|
+
* This is the form the link manifest records and the site build emits pages at,
|
|
100
|
+
* and it is one function because those two must agree — a manifest asserting an
|
|
101
|
+
* address the site does not publish resolves at build time and 404s for the
|
|
102
|
+
* reader, which is the failure this module exists to prevent.
|
|
103
|
+
*
|
|
104
|
+
* @param {object} fm - Parsed frontmatter.
|
|
105
|
+
* @param {string} name - The note's display name; a page slug derives from it
|
|
106
|
+
* (#1278), never from the shortcode, which is identity rather than
|
|
107
|
+
* presentation.
|
|
108
|
+
* @param {object} [options] - Options.
|
|
109
|
+
* @param {boolean} [options.isReadme] - Whether the file is a `README.md`.
|
|
110
|
+
* @param {{prefix?: string, landing?: string}} [options.scheme] - The
|
|
111
|
+
* repository's address scheme; defaults to {@link DEFAULT_ADDRESS_SCHEME}.
|
|
112
|
+
* @returns {string} The package-relative address, with a trailing slash and no
|
|
113
|
+
* leading one.
|
|
114
|
+
* @throws {Error} When the note has no address — no section, a landing page
|
|
115
|
+
* naming no section, or a name yielding no usable slug. Each is a note that
|
|
116
|
+
* is not published, and inventing an address for one would put a dead entry
|
|
117
|
+
* in the manifest.
|
|
118
|
+
*/
|
|
119
|
+
export function packageAddress(fm, name, { isReadme = false, scheme } = {}) {
|
|
120
|
+
const { prefix, landing } = { ...DEFAULT_ADDRESS_SCHEME, ...scheme };
|
|
121
|
+
if (!LANDING_RULES.includes(landing)) {
|
|
122
|
+
throw new Error(
|
|
123
|
+
`unknown landing rule ${JSON.stringify(landing)} — expected one ` +
|
|
124
|
+
`of ${LANDING_RULES.join(", ")}`,
|
|
125
|
+
);
|
|
126
|
+
}
|
|
127
|
+
const land = landingOf(fm, isReadme, landing);
|
|
128
|
+
if (land.landing) {
|
|
129
|
+
if (typeof land.segment !== "string" || !land.segment) {
|
|
130
|
+
throw new Error(
|
|
131
|
+
`landing note declares no section, so it lands nowhere`,
|
|
132
|
+
);
|
|
133
|
+
}
|
|
134
|
+
return `${prefix}${land.segment}/`;
|
|
135
|
+
}
|
|
136
|
+
const sec = sectionOf(fm);
|
|
137
|
+
if (typeof sec !== "string" || !sec) {
|
|
138
|
+
throw new Error(`type "${fm.type}" has no section`);
|
|
139
|
+
}
|
|
140
|
+
return `${prefix}${sec}/${contentSlug(name)}/`;
|
|
141
|
+
}
|
|
@@ -0,0 +1,438 @@
|
|
|
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
|
+
* Resolving every link in a content tree, and reporting the ones that land
|
|
16
|
+
* nowhere.
|
|
17
|
+
*
|
|
18
|
+
* Three link defects survive both content builds silently, so neither the pack
|
|
19
|
+
* compilers nor a site build catches them:
|
|
20
|
+
*
|
|
21
|
+
* 1. **A dead `#anchor`.** A page id is derived by hashing the note id and the
|
|
22
|
+
* anchor slug; nothing checks that a heading declaring that slug exists. A
|
|
23
|
+
* link to an anchor nobody declares compiles cleanly, emits an enricher, and
|
|
24
|
+
* dead-ends for the reader.
|
|
25
|
+
* 2. **A dead address.** A *qualified* `type-shortcode` target resolving to no
|
|
26
|
+
* note is a typo. A bare `[[Name]]` that finds nothing is not — that is a
|
|
27
|
+
* worldbuilding placeholder by long-standing convention, and is left alone.
|
|
28
|
+
* 3. **A wikilink authored in frontmatter.** Both builds walk a note's *body*
|
|
29
|
+
* and copy frontmatter through verbatim, so a link written in a
|
|
30
|
+
* `description` is never resolved and publishes as literal `[[…]]` text.
|
|
31
|
+
*
|
|
32
|
+
* **This resolves links the way the builds do**, calling the same
|
|
33
|
+
* {@link readQualifier} and the same {@link parseWikilink} rather than a second
|
|
34
|
+
* copy of either. It did carry its own copy of the wikilink pattern — the third
|
|
35
|
+
* in this codebase, and the same drifted one that let an unclosed bracket
|
|
36
|
+
* swallow a document — so the checker parsed more loosely than the compilers it
|
|
37
|
+
* was checking.
|
|
38
|
+
*
|
|
39
|
+
* **What this deliberately does not do.** Corpus reachability — "every rules
|
|
40
|
+
* document is reachable from the book's root" — is a statement about what one
|
|
41
|
+
* package publishes, not about the note format, so it belongs with the
|
|
42
|
+
* publishing it describes; so does a retired hostname. Both are served by the
|
|
43
|
+
* link graph returned here rather than implemented here (#20).
|
|
44
|
+
*
|
|
45
|
+
* @module
|
|
46
|
+
*/
|
|
47
|
+
|
|
48
|
+
import fs from "node:fs";
|
|
49
|
+
import path from "node:path";
|
|
50
|
+
|
|
51
|
+
import { matchAllOutsideCode } from "./code-fences.mjs";
|
|
52
|
+
import { expandContentTables } from "./content-tables.mjs";
|
|
53
|
+
import { walkMarkdownTree } from "./helpers.mjs";
|
|
54
|
+
import { hasDocEntry } from "./item-docs.mjs";
|
|
55
|
+
import {
|
|
56
|
+
canonicalKey,
|
|
57
|
+
loadForeignManifests,
|
|
58
|
+
manifestsComplete,
|
|
59
|
+
readCanonicalKey,
|
|
60
|
+
} from "./kb-manifest.mjs";
|
|
61
|
+
import { frontmatterWikilinks, slugify } from "./web-wikilinks.mjs";
|
|
62
|
+
import { parseWikilink, WIKILINK } from "./wikilink-syntax.mjs";
|
|
63
|
+
import { readQualifier } from "./wikilinks.mjs";
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Every `{#anchor}` a note declares on a heading.
|
|
67
|
+
*
|
|
68
|
+
* @param {string} body - The note's markdown body.
|
|
69
|
+
* @returns {Set<string>} The declared anchor slugs.
|
|
70
|
+
*/
|
|
71
|
+
export function anchorsOf(body) {
|
|
72
|
+
const found = new Set();
|
|
73
|
+
for (const line of String(body ?? "").split("\n")) {
|
|
74
|
+
const m = /^#{1,6}\s+.*\{#([a-z0-9-]+)\}\s*$/.exec(line.trim());
|
|
75
|
+
if (m) found.add(m[1]);
|
|
76
|
+
}
|
|
77
|
+
return found;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Load a content tree and build the index a link resolves against.
|
|
82
|
+
*
|
|
83
|
+
* The index mirrors what both builds construct, including the two addresses a
|
|
84
|
+
* doc-carrying note answers to: `type/shortcode` for the document, and
|
|
85
|
+
* `doc<type>/shortcode` for the JournalEntry its prose compiles into. Once a
|
|
86
|
+
* manifest publishes `doc<type>` entries that prefix is a *known type*, and the
|
|
87
|
+
* virtual reading that used to answer for it no longer fires — a real type owns
|
|
88
|
+
* its own name — so the note is indexed under both.
|
|
89
|
+
*
|
|
90
|
+
* @param {string} contentBase - Root of the content tree.
|
|
91
|
+
* @param {object} [opts]
|
|
92
|
+
* @param {string} [opts.manifestDir] - Where vendored foreign manifests live.
|
|
93
|
+
* Omitted, no cross-package address resolves.
|
|
94
|
+
* @param {readonly string[]} [opts.skipDirectories] - Passed to the walk.
|
|
95
|
+
* @returns {object} The notes, the index, and the resolvers built over it.
|
|
96
|
+
*/
|
|
97
|
+
export function buildLinkIndex(
|
|
98
|
+
contentBase,
|
|
99
|
+
{ manifestDir, skipDirectories } = {},
|
|
100
|
+
) {
|
|
101
|
+
const notes = [];
|
|
102
|
+
const frontmatterLinks = [];
|
|
103
|
+
const walkOpts = skipDirectories ? { skipDirectories } : undefined;
|
|
104
|
+
|
|
105
|
+
for (const { frontmatter: fm, absPath } of walkMarkdownTree(
|
|
106
|
+
contentBase,
|
|
107
|
+
walkOpts,
|
|
108
|
+
)) {
|
|
109
|
+
if (!fm || typeof fm.type !== "string") continue;
|
|
110
|
+
// The raw text is kept beside the parsed body: a consumer's own checks
|
|
111
|
+
// may need what frontmatter carried, which the body has dropped.
|
|
112
|
+
const raw = fs.readFileSync(absPath, "utf8");
|
|
113
|
+
const body = raw.replace(/^---\n[\s\S]*?\n---\n?/, "");
|
|
114
|
+
const note = {
|
|
115
|
+
file: absPath,
|
|
116
|
+
rel: path.relative(contentBase, absPath).split(path.sep).join("/"),
|
|
117
|
+
fm,
|
|
118
|
+
body,
|
|
119
|
+
raw,
|
|
120
|
+
type: fm.type.toLowerCase(),
|
|
121
|
+
};
|
|
122
|
+
for (const hit of frontmatterWikilinks(fm)) {
|
|
123
|
+
frontmatterLinks.push({ note, ...hit });
|
|
124
|
+
}
|
|
125
|
+
notes.push(note);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
const byKey = new Map();
|
|
129
|
+
const byAlias = new Map();
|
|
130
|
+
const aliasCollide = new Set();
|
|
131
|
+
|
|
132
|
+
for (const note of notes) {
|
|
133
|
+
const { fm, type } = note;
|
|
134
|
+
if (typeof fm.shortcode === "string" && fm.shortcode) {
|
|
135
|
+
byKey.set(`${type}/${fm.shortcode}`.toLowerCase(), note);
|
|
136
|
+
// The canonical, fully qualified address alongside the short one,
|
|
137
|
+
// so a package-qualified link checks the same way a bare one does.
|
|
138
|
+
if (fm.package) {
|
|
139
|
+
byKey.set(canonicalKey(fm.package, type, fm.shortcode), note);
|
|
140
|
+
}
|
|
141
|
+
if (hasDocEntry(type)) {
|
|
142
|
+
byKey.set(`doc${type}/${fm.shortcode}`.toLowerCase(), note);
|
|
143
|
+
if (fm.package) {
|
|
144
|
+
byKey.set(
|
|
145
|
+
canonicalKey(fm.package, `doc${type}`, fm.shortcode),
|
|
146
|
+
note,
|
|
147
|
+
);
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
const aliases = [
|
|
152
|
+
...(Array.isArray(fm.aliases) ? fm.aliases : []),
|
|
153
|
+
...(Array.isArray(fm.name?.aliases) ? fm.name.aliases : []),
|
|
154
|
+
fm.name?.full,
|
|
155
|
+
path.basename(note.file, ".md").replace(/_/g, " "),
|
|
156
|
+
].filter((a) => typeof a === "string" && a);
|
|
157
|
+
for (const a of aliases) {
|
|
158
|
+
const k = `${type}|${a}`.toLowerCase();
|
|
159
|
+
if (aliasCollide.has(k)) continue;
|
|
160
|
+
const cur = byAlias.get(k);
|
|
161
|
+
if (cur && cur !== note) {
|
|
162
|
+
byAlias.delete(k);
|
|
163
|
+
aliasCollide.add(k);
|
|
164
|
+
} else if (!cur) {
|
|
165
|
+
byAlias.set(k, note);
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
const types = new Set(notes.map((n) => n.type));
|
|
171
|
+
|
|
172
|
+
// A foreign package may use a type this tree has never seen, so its types
|
|
173
|
+
// join `types` — otherwise `readQualifier` reads the link as prose and it
|
|
174
|
+
// is never checked at all.
|
|
175
|
+
const localPackages = new Set(
|
|
176
|
+
notes.map((n) => n.fm?.package).filter(Boolean),
|
|
177
|
+
);
|
|
178
|
+
const foreign =
|
|
179
|
+
manifestDir ?
|
|
180
|
+
loadForeignManifests(manifestDir, localPackages)
|
|
181
|
+
: { index: new Map(), packages: new Set(), stale: [] };
|
|
182
|
+
for (const v of foreign.index.values()) if (v.type) types.add(v.type);
|
|
183
|
+
|
|
184
|
+
const packages = new Set([
|
|
185
|
+
...[...byKey.values()].map((n) => n.fm?.package).filter(Boolean),
|
|
186
|
+
...foreign.packages,
|
|
187
|
+
]);
|
|
188
|
+
|
|
189
|
+
/** The searchable universe a `dataview` table draws its rows from. */
|
|
190
|
+
const tableDocs = notes.map((n) => ({
|
|
191
|
+
fm: n.fm,
|
|
192
|
+
path: n.rel,
|
|
193
|
+
tld: n.rel.split("/")[0],
|
|
194
|
+
folder: path.dirname(n.rel).split("/").pop(),
|
|
195
|
+
}));
|
|
196
|
+
|
|
197
|
+
const anchors = new Map(notes.map((n) => [n, anchorsOf(n.body)]));
|
|
198
|
+
|
|
199
|
+
/**
|
|
200
|
+
* Every wikilink in a note body, with its `dataview` tables expanded.
|
|
201
|
+
*
|
|
202
|
+
* @param {object} note - A note from this index.
|
|
203
|
+
* @returns {Array<{target: string, anchor: string, text: string,
|
|
204
|
+
* occurrence: number}>} `target` is `""` for a same-page `[[#anchor]]`.
|
|
205
|
+
*/
|
|
206
|
+
function linksOf(note) {
|
|
207
|
+
let body = note.body;
|
|
208
|
+
if (/^[ \t]*(?:`{3,}|~{3,})[ \t]*dataview\b/im.test(body)) {
|
|
209
|
+
body = expandContentTables(body, {
|
|
210
|
+
docs: tableDocs.filter((d) => d.fm.package === note.fm.package),
|
|
211
|
+
linkable: (d) => Boolean(d.fm.shortcode),
|
|
212
|
+
source: note.file,
|
|
213
|
+
}).markdown;
|
|
214
|
+
}
|
|
215
|
+
const out = [];
|
|
216
|
+
// How many times each authored link has been seen, so two identical
|
|
217
|
+
// links in one note are reported at their own positions.
|
|
218
|
+
const seen = new Map();
|
|
219
|
+
// Code is verbatim, so a `[[…]]` inside a fence, an indented block or
|
|
220
|
+
// an inline span is not a link — the compilers make none of it either.
|
|
221
|
+
for (const [all, rawInner] of matchAllOutsideCode(
|
|
222
|
+
body,
|
|
223
|
+
new RegExp(WIKILINK.source, "g"),
|
|
224
|
+
)) {
|
|
225
|
+
const { target, anchor } = parseWikilink(rawInner);
|
|
226
|
+
const occurrence = (seen.get(all) ?? 0) + 1;
|
|
227
|
+
seen.set(all, occurrence);
|
|
228
|
+
// `text` is the link exactly as authored, which is what locates it
|
|
229
|
+
// in the file. A link a table generated is not in the file at all,
|
|
230
|
+
// so the search simply fails and a finding names the file.
|
|
231
|
+
out.push({ target, anchor, text: all, occurrence });
|
|
232
|
+
}
|
|
233
|
+
return out;
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
/**
|
|
237
|
+
* Resolve a link target the way both builds do, or `undefined`.
|
|
238
|
+
*
|
|
239
|
+
* The qualifier is read with {@link readQualifier} rather than a second
|
|
240
|
+
* copy of the rule, so this cannot drift from what the builds do — the two
|
|
241
|
+
* separators, the first-hyphen split, and the known-type condition that
|
|
242
|
+
* keeps a hyphenated *name* an alias.
|
|
243
|
+
*
|
|
244
|
+
* That condition is why the type-scoped alias index is not enough alone: it
|
|
245
|
+
* reaches only a target of the source's **own** type, so a cross-type
|
|
246
|
+
* `[[type-shortcode#anchor]]` would resolve to nothing and its anchor go
|
|
247
|
+
* unchecked — silently, since an unresolvable target is treated as
|
|
248
|
+
* external.
|
|
249
|
+
*
|
|
250
|
+
* @param {object} note - The note the link is written in.
|
|
251
|
+
* @param {string} target - The link target.
|
|
252
|
+
* @returns {object|undefined} The note it addresses.
|
|
253
|
+
*/
|
|
254
|
+
function resolve(note, target) {
|
|
255
|
+
const direct =
|
|
256
|
+
byAlias.get(`${note.type}|${target}`.toLowerCase()) ??
|
|
257
|
+
byKey.get(target.toLowerCase());
|
|
258
|
+
if (direct) return direct;
|
|
259
|
+
const qualified = readQualifier(target, types, packages);
|
|
260
|
+
if (!qualified || qualified.reason) return undefined;
|
|
261
|
+
return byKey.get(
|
|
262
|
+
qualified.package ?
|
|
263
|
+
canonicalKey(
|
|
264
|
+
qualified.package,
|
|
265
|
+
qualified.type,
|
|
266
|
+
qualified.shortcode,
|
|
267
|
+
)
|
|
268
|
+
: `${qualified.type}/${qualified.shortcode}`.toLowerCase(),
|
|
269
|
+
);
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
/**
|
|
273
|
+
* The manifest entry a qualified address names in another package, or null.
|
|
274
|
+
*
|
|
275
|
+
* @param {string} target - The link target.
|
|
276
|
+
* @returns {object|null} The foreign entry.
|
|
277
|
+
*/
|
|
278
|
+
function manifestHit(target) {
|
|
279
|
+
const q = readQualifier(target, types, packages);
|
|
280
|
+
if (!q || q.reason) return null;
|
|
281
|
+
if (q.package) {
|
|
282
|
+
return (
|
|
283
|
+
foreign.index.get(
|
|
284
|
+
canonicalKey(q.package, q.type, q.shortcode),
|
|
285
|
+
) ?? null
|
|
286
|
+
);
|
|
287
|
+
}
|
|
288
|
+
// A bare address names no package, so it resolves against any foreign
|
|
289
|
+
// one that publishes it. Claimed by two, it is ambiguous and the author
|
|
290
|
+
// must write the qualified form.
|
|
291
|
+
const type = String(q.type).toLowerCase();
|
|
292
|
+
const shortcode = String(q.shortcode).toLowerCase();
|
|
293
|
+
const hits = [...foreign.index].filter(([k]) => {
|
|
294
|
+
const parts = readCanonicalKey(k);
|
|
295
|
+
return parts?.type === type && parts.shortcode === shortcode;
|
|
296
|
+
});
|
|
297
|
+
return hits.length === 1 ? hits[0][1] : null;
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
return {
|
|
301
|
+
notes,
|
|
302
|
+
frontmatterLinks,
|
|
303
|
+
anchors,
|
|
304
|
+
types,
|
|
305
|
+
packages,
|
|
306
|
+
foreign,
|
|
307
|
+
manifests: manifestsComplete(localPackages, foreign.packages),
|
|
308
|
+
linksOf,
|
|
309
|
+
resolve,
|
|
310
|
+
manifestHit,
|
|
311
|
+
/** Whether a target reads as a qualified address at all. */
|
|
312
|
+
isAddress: (target) => Boolean(readQualifier(target, types, packages)),
|
|
313
|
+
};
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
/**
|
|
317
|
+
* Every link in a tree that lands nowhere.
|
|
318
|
+
*
|
|
319
|
+
* @param {ReturnType<typeof buildLinkIndex>} index - The built index.
|
|
320
|
+
* @returns {{deadAnchors: object[], deadAddresses: object[],
|
|
321
|
+
* frontmatterLinks: object[], usedManifest: Set<string>}} The findings, and
|
|
322
|
+
* which addresses a foreign manifest answered.
|
|
323
|
+
*/
|
|
324
|
+
export function auditLinks(index) {
|
|
325
|
+
const { notes, anchors, linksOf, resolve, manifestHit, isAddress } = index;
|
|
326
|
+
|
|
327
|
+
const deadAnchors = [];
|
|
328
|
+
for (const note of notes) {
|
|
329
|
+
for (const { target, anchor, text, occurrence } of linksOf(note)) {
|
|
330
|
+
if (!anchor) continue;
|
|
331
|
+
const dest = target ? resolve(note, target) : note;
|
|
332
|
+
// An unresolvable target is an external reference, not this
|
|
333
|
+
// check's business.
|
|
334
|
+
if (!dest) continue;
|
|
335
|
+
if (!anchors.get(dest).has(slugify(anchor))) {
|
|
336
|
+
deadAnchors.push({
|
|
337
|
+
note,
|
|
338
|
+
link: `${target}#${anchor}`,
|
|
339
|
+
dest,
|
|
340
|
+
text,
|
|
341
|
+
occurrence,
|
|
342
|
+
});
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
const deadAddresses = [];
|
|
348
|
+
const usedManifest = new Set();
|
|
349
|
+
for (const note of notes) {
|
|
350
|
+
for (const { target, text, occurrence } of linksOf(note)) {
|
|
351
|
+
if (!target) continue; // a same-page `[[#anchor]]`
|
|
352
|
+
// Only a *qualified* target is an address. A bare `[[Name]]` that
|
|
353
|
+
// finds nothing is a worldbuilding placeholder by long-standing
|
|
354
|
+
// convention, and is deliberately left alone.
|
|
355
|
+
if (!isAddress(target)) continue;
|
|
356
|
+
if (resolve(note, target)) continue;
|
|
357
|
+
// A manifest answers with the target package's own build output
|
|
358
|
+
// rather than a reviewed guess.
|
|
359
|
+
if (manifestHit(target)) {
|
|
360
|
+
usedManifest.add(target.toLowerCase());
|
|
361
|
+
continue;
|
|
362
|
+
}
|
|
363
|
+
deadAddresses.push({ note, target, text, occurrence });
|
|
364
|
+
}
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
return {
|
|
368
|
+
deadAnchors,
|
|
369
|
+
deadAddresses,
|
|
370
|
+
frontmatterLinks: index.frontmatterLinks,
|
|
371
|
+
usedManifest,
|
|
372
|
+
};
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
/**
|
|
376
|
+
* Walk a corpus from its root and report what nothing links to.
|
|
377
|
+
*
|
|
378
|
+
* A documentation set is a **book, not a pile of notes**: it has a page one,
|
|
379
|
+
* and everything in it should follow from that page by reading. A note with no
|
|
380
|
+
* inbound link still compiles into a pack and still publishes — it is simply
|
|
381
|
+
* impossible to arrive at. Nothing else in either build notices, because every
|
|
382
|
+
* other check asks whether a link *lands*, never whether a document is
|
|
383
|
+
* *reached*.
|
|
384
|
+
*
|
|
385
|
+
* **Which documents belong to the corpus is the caller's to say.** A
|
|
386
|
+
* repository's corpora are its own — one publishes rules and a user guide,
|
|
387
|
+
* another a setting gazetteer — so `scope` decides membership and this decides
|
|
388
|
+
* only reachability. Links out of the corpus are followed as real links; they
|
|
389
|
+
* are simply not pages of it.
|
|
390
|
+
*
|
|
391
|
+
* **`stopAt` marks a page walked *to* but not *through*.** An index page links
|
|
392
|
+
* to nearly everything it covers, so traversing one makes the whole check
|
|
393
|
+
* vacuous: a chapter could stop linking one of its own pages and the walk would
|
|
394
|
+
* still reach it by way of the index. Reachability has to hold along the
|
|
395
|
+
* reading path, which is why the exception exists and why it is deliberately
|
|
396
|
+
* narrow.
|
|
397
|
+
*
|
|
398
|
+
* @param {ReturnType<typeof buildLinkIndex>} index - The built index.
|
|
399
|
+
* @param {object} opts
|
|
400
|
+
* @param {string} opts.root - The corpus's entry page, as a tree-relative path.
|
|
401
|
+
* @param {(note: object) => boolean} opts.scope - Whether a note belongs to the
|
|
402
|
+
* corpus.
|
|
403
|
+
* @param {(note: object) => boolean} [opts.stopAt] - Whether a note is walked
|
|
404
|
+
* to but not through.
|
|
405
|
+
* @returns {{root: object, reached: Set<object>, orphans: object[]}} The root,
|
|
406
|
+
* everything reached from it, and the corpus members that were not.
|
|
407
|
+
* @throws {Error} When no note sits at `root` — a corpus with no page one
|
|
408
|
+
* cannot be walked, and silently reporting every page as an orphan would
|
|
409
|
+
* bury the actual mistake.
|
|
410
|
+
*/
|
|
411
|
+
export function walkReachability(index, { root, scope, stopAt = () => false }) {
|
|
412
|
+
const rootNote = index.notes.find((n) => n.rel === root);
|
|
413
|
+
if (!rootNote) {
|
|
414
|
+
throw new Error(
|
|
415
|
+
`no note at ${root}, so the corpus has no page to be read from`,
|
|
416
|
+
);
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
const reached = new Set([rootNote]);
|
|
420
|
+
const queue = [rootNote];
|
|
421
|
+
while (queue.length) {
|
|
422
|
+
const note = queue.shift();
|
|
423
|
+
if (stopAt(note)) continue;
|
|
424
|
+
for (const { target } of index.linksOf(note)) {
|
|
425
|
+
if (!target) continue;
|
|
426
|
+
const dest = index.resolve(note, target);
|
|
427
|
+
if (!dest || !scope(dest) || reached.has(dest)) continue;
|
|
428
|
+
reached.add(dest);
|
|
429
|
+
queue.push(dest);
|
|
430
|
+
}
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
return {
|
|
434
|
+
root: rootNote,
|
|
435
|
+
reached,
|
|
436
|
+
orphans: index.notes.filter((n) => scope(n) && !reached.has(n)),
|
|
437
|
+
};
|
|
438
|
+
}
|