@heroiclands/package-build 0.6.0 → 3.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG-content-build.md +965 -0
- package/CHANGELOG.md +95 -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 -34
- 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,129 @@
|
|
|
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
|
+
* Whether a vendored manifest can still be *addressed*, as distinct from read.
|
|
16
|
+
*
|
|
17
|
+
* A consuming build vendors each foreign package's link manifest and resolves
|
|
18
|
+
* cross-package links through it. The lookup is by canonical key, so it needs
|
|
19
|
+
* both sides to agree on the key's shape: when they agree the link resolves,
|
|
20
|
+
* and when they drift apart the lookup cannot match on *any* input — while the
|
|
21
|
+
* page still reads correctly, because an unresolved wikilink falls through to
|
|
22
|
+
* its own display text. That is how a v3 key change left one repository reading
|
|
23
|
+
* 2,367 entries through a lookup that could never hit one of them (#1499).
|
|
24
|
+
*
|
|
25
|
+
* Bridging the lookup is not enough on its own: it fails silently again the next
|
|
26
|
+
* time either side moves. So the shapes are *checked* rather than merely
|
|
27
|
+
* converted, and a manifest that yields no addressable key at all fails the
|
|
28
|
+
* build. A lookup that cannot match anything reports nothing, which is the one
|
|
29
|
+
* failure a dead-link check can never catch.
|
|
30
|
+
*
|
|
31
|
+
* This guards {@link kbManifest}'s own key format, which is why it lives beside
|
|
32
|
+
* it rather than in whichever consumer happens to load a manifest.
|
|
33
|
+
*
|
|
34
|
+
* @module
|
|
35
|
+
*/
|
|
36
|
+
|
|
37
|
+
import fs from "node:fs";
|
|
38
|
+
import path from "node:path";
|
|
39
|
+
|
|
40
|
+
import { formatDiagnostic, positionOfLiteral } from "./diagnostics.mjs";
|
|
41
|
+
import { readCanonicalKey } from "./kb-manifest.mjs";
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Every foreign package whose manifest entries a build cannot address.
|
|
45
|
+
*
|
|
46
|
+
* A package is reported only when it contributes entries and **none** of them
|
|
47
|
+
* yields a readable canonical key — the total, silent failure described above.
|
|
48
|
+
* Partial drift is deliberately not reported here: it resolves something, and
|
|
49
|
+
* whatever it fails to resolve surfaces as an ordinary dead address, pointed at
|
|
50
|
+
* the note that cites it. A package contributing no entries at all is likewise
|
|
51
|
+
* not a finding; a pack-only package publishes no addressable pages by design
|
|
52
|
+
* (#1516), and one being brought up publishes nothing yet.
|
|
53
|
+
*
|
|
54
|
+
* @param {Map<string, {package?: string}>} foreignIndex - `foreign.index` as
|
|
55
|
+
* returned by `loadForeignManifests`, keyed by canonical key.
|
|
56
|
+
* @returns {Array<{package: string, entries: number, sampleKey: string}>} One
|
|
57
|
+
* finding per drifted package, in the order the index first names each.
|
|
58
|
+
*/
|
|
59
|
+
export function unaddressableForeignPackages(foreignIndex) {
|
|
60
|
+
const byPackage = new Map();
|
|
61
|
+
for (const [key, value] of foreignIndex ?? new Map()) {
|
|
62
|
+
// The package is read from the entry rather than the key, since the key
|
|
63
|
+
// is the very thing under suspicion — deriving it from a shape that may
|
|
64
|
+
// not parse would report the finding against `undefined`.
|
|
65
|
+
const pkg = value?.package;
|
|
66
|
+
if (!pkg) continue;
|
|
67
|
+
const seen = byPackage.get(pkg) ?? {
|
|
68
|
+
entries: 0,
|
|
69
|
+
readable: 0,
|
|
70
|
+
sampleKey: key,
|
|
71
|
+
};
|
|
72
|
+
seen.entries += 1;
|
|
73
|
+
if (readCanonicalKey(key)) seen.readable += 1;
|
|
74
|
+
byPackage.set(pkg, seen);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const findings = [];
|
|
78
|
+
for (const [pkg, seen] of byPackage) {
|
|
79
|
+
if (seen.entries > 0 && seen.readable === 0) {
|
|
80
|
+
findings.push({
|
|
81
|
+
package: pkg,
|
|
82
|
+
entries: seen.entries,
|
|
83
|
+
sampleKey: seen.sampleKey,
|
|
84
|
+
});
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
return findings;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* One finding, in the standard `file:line:column: severity: message` form.
|
|
92
|
+
*
|
|
93
|
+
* The position is recovered by locating the offending key in the manifest text:
|
|
94
|
+
* the finding is about a literal the reader can see in the file, so its position
|
|
95
|
+
* is implicit rather than absent. When the file cannot be read, or the key is
|
|
96
|
+
* not in it, the locator degrades to the file alone — a dropped field, never a
|
|
97
|
+
* guessed `1:1` that would send the reader to the top of a 500 KB manifest for a
|
|
98
|
+
* finding that is not there.
|
|
99
|
+
*
|
|
100
|
+
* @param {{package: string, entries: number, sampleKey: string}} finding - One
|
|
101
|
+
* finding from {@link unaddressableForeignPackages}.
|
|
102
|
+
* @param {string} manifestDir - The directory the manifests were loaded from.
|
|
103
|
+
* @returns {string} The formatted diagnostic, path first on the line.
|
|
104
|
+
*/
|
|
105
|
+
export function formatUnaddressableFinding(finding, manifestDir) {
|
|
106
|
+
const file = path.join(manifestDir, `${finding.package}.json`);
|
|
107
|
+
let at = {};
|
|
108
|
+
try {
|
|
109
|
+
at = positionOfLiteral(
|
|
110
|
+
fs.readFileSync(file, "utf8"),
|
|
111
|
+
`"${finding.sampleKey}"`,
|
|
112
|
+
);
|
|
113
|
+
} catch {
|
|
114
|
+
// Unreadable here is not itself the finding — `loadForeignManifests`
|
|
115
|
+
// already reports that as a stale manifest. The file is simply all that
|
|
116
|
+
// is known about where this one is.
|
|
117
|
+
}
|
|
118
|
+
return formatDiagnostic({
|
|
119
|
+
file,
|
|
120
|
+
...at,
|
|
121
|
+
severity: "error",
|
|
122
|
+
message:
|
|
123
|
+
"no key in this manifest is a canonical " +
|
|
124
|
+
`\`package-type-shortcode\` address (${finding.entries} ` +
|
|
125
|
+
`${finding.entries === 1 ? "entry" : "entries"}, none addressable; ` +
|
|
126
|
+
`first is \`${finding.sampleKey}\`) — every cross-package link to ` +
|
|
127
|
+
`${finding.package} would resolve to nothing, silently`,
|
|
128
|
+
});
|
|
129
|
+
}
|
|
@@ -0,0 +1,343 @@
|
|
|
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
|
+
* Checking a note's **frontmatter** against the schema its `type` declares
|
|
16
|
+
* (#19).
|
|
17
|
+
*
|
|
18
|
+
* Until now nothing did. A note's type decides which properties are required,
|
|
19
|
+
* what shape each value has, and which name another note — but that knowledge
|
|
20
|
+
* existed only implicitly, spread across the readers that happen to consume
|
|
21
|
+
* each field. So an authoring mistake was never reported where it was made:
|
|
22
|
+
*
|
|
23
|
+
* - a **missing required property** surfaced as a downstream failure whose
|
|
24
|
+
* message was about something else, or as a silently empty field;
|
|
25
|
+
* - a **wrong type** threw deep inside a builder, or was coerced;
|
|
26
|
+
* - a **misspelled property** was simply *ignored* — the builders are an
|
|
27
|
+
* allow-list, so an unrecognised `sohl:` key is dropped without a word. That
|
|
28
|
+
* is how 204 kethira mystical abilities shipped with no affiliation (#3), and
|
|
29
|
+
* why the silence mattered more than the missing field: an author could not
|
|
30
|
+
* tell a builder that forgot the field apart from a field that does
|
|
31
|
+
* not belong on the type at all;
|
|
32
|
+
* - a **dead shortcode reference** in frontmatter was not checked at all, only
|
|
33
|
+
* wikilinks in the body were.
|
|
34
|
+
*
|
|
35
|
+
* **This module is vocabulary-agnostic.** It validates a note against whatever
|
|
36
|
+
* schema its type declares and knows no type names of its own; the SoHL
|
|
37
|
+
* vocabulary lives in `sohl/note-schemas.mjs`. That line is between knowledge
|
|
38
|
+
* of the *game system* and knowledge of the *note format* — not a permission
|
|
39
|
+
* boundary between consumers. Every content project authors the full type
|
|
40
|
+
* vocabulary (an adventure module ships skills, beings and magic swords), so
|
|
41
|
+
* every consumer loads all of it.
|
|
42
|
+
*
|
|
43
|
+
* **It takes a built link index rather than walking itself.** The dead-
|
|
44
|
+
* reference check has to resolve exactly as a wikilink does, cross-package
|
|
45
|
+
* manifests and all, and the way to guarantee that is to call the same
|
|
46
|
+
* resolver rather than write a second one.
|
|
47
|
+
*
|
|
48
|
+
* @module
|
|
49
|
+
*/
|
|
50
|
+
|
|
51
|
+
import { authoredFields } from "./field-spec.mjs";
|
|
52
|
+
import { positionInFrontmatter } from "./diagnostics.mjs";
|
|
53
|
+
import { RETIRED_TYPES } from "./ids.mjs";
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* `sohl:` keys every type accepts, whatever its schema says.
|
|
57
|
+
*
|
|
58
|
+
* Neither is part of a type's vocabulary — both are read for *any* note, by
|
|
59
|
+
* passes that run before a builder sees it — so neither appears in a field
|
|
60
|
+
* declaration and both would otherwise be reported as unknown on every note
|
|
61
|
+
* that uses them.
|
|
62
|
+
*
|
|
63
|
+
* - `folder` — the compendium folder the document is filed in.
|
|
64
|
+
* - `pack` — which compendium of the note's document type receives it, for a
|
|
65
|
+
* repository shipping several (#1566).
|
|
66
|
+
* - `archetype` — the archetype flag, read for any note by the walk itself.
|
|
67
|
+
* - `kbcat` — the knowledgebase category a note is grouped under.
|
|
68
|
+
*
|
|
69
|
+
* `kbcat` is the one that matters for what this check *is*. **The pack build is
|
|
70
|
+
* not the only reader of a note's frontmatter.** A note also feeds a
|
|
71
|
+
* knowledgebase and a website, and those surfaces consume classification the
|
|
72
|
+
* compiler never emits — `kbcat` alone is read 51 times across SoHL's
|
|
73
|
+
* knowledgebase layouts. A check that equated "the vocabulary" with "what the
|
|
74
|
+
* builder compiles" would report every one of those as an unknown property and
|
|
75
|
+
* turn a correct tree red, which is exactly what it did on first run: 4,241
|
|
76
|
+
* findings against SoHL's own content, none of them a defect.
|
|
77
|
+
*
|
|
78
|
+
* So a type's schema declares what a note **may write**, which is broader than
|
|
79
|
+
* what any one consumer reads.
|
|
80
|
+
*
|
|
81
|
+
* @type {ReadonlySet<string>}
|
|
82
|
+
*/
|
|
83
|
+
export const UNIVERSAL_KEYS = Object.freeze(
|
|
84
|
+
new Set(["folder", "pack", "archetype", "kbcat"]),
|
|
85
|
+
);
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Edit distance, capped — enough to answer "did you mean".
|
|
89
|
+
*
|
|
90
|
+
* A misspelled property is the failure class this check exists for, and a
|
|
91
|
+
* finding that names the key the author *meant* turns a hunt through the
|
|
92
|
+
* reference into a one-character fix.
|
|
93
|
+
*
|
|
94
|
+
* @param {string} a - One string.
|
|
95
|
+
* @param {string} b - The other.
|
|
96
|
+
* @returns {number} The Levenshtein distance.
|
|
97
|
+
*/
|
|
98
|
+
function distance(a, b) {
|
|
99
|
+
const rows = a.length + 1;
|
|
100
|
+
const cols = b.length + 1;
|
|
101
|
+
let prev = Array.from({ length: cols }, (_, j) => j);
|
|
102
|
+
for (let i = 1; i < rows; i += 1) {
|
|
103
|
+
const row = [i];
|
|
104
|
+
for (let j = 1; j < cols; j += 1) {
|
|
105
|
+
row[j] = Math.min(
|
|
106
|
+
prev[j] + 1,
|
|
107
|
+
row[j - 1] + 1,
|
|
108
|
+
prev[j - 1] + (a[i - 1] === b[j - 1] ? 0 : 1),
|
|
109
|
+
);
|
|
110
|
+
}
|
|
111
|
+
prev = row;
|
|
112
|
+
}
|
|
113
|
+
return prev[cols - 1];
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* The declared key an unknown one was most likely meant to be.
|
|
118
|
+
*
|
|
119
|
+
* @param {string} key - The unknown key.
|
|
120
|
+
* @param {Iterable<string>} candidates - The declared keys.
|
|
121
|
+
* @returns {string|undefined} The nearest, when it is near enough to suggest.
|
|
122
|
+
*/
|
|
123
|
+
function nearest(key, candidates) {
|
|
124
|
+
let best;
|
|
125
|
+
let bestAt = Infinity;
|
|
126
|
+
for (const candidate of candidates) {
|
|
127
|
+
const d = distance(key.toLowerCase(), candidate.toLowerCase());
|
|
128
|
+
if (d < bestAt) {
|
|
129
|
+
bestAt = d;
|
|
130
|
+
best = candidate;
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
// A third of the key's length, so a suggestion is a plausible typo rather
|
|
134
|
+
// than the least-bad of a list of unrelated words.
|
|
135
|
+
return bestAt <= Math.max(1, Math.floor(key.length / 3)) ? best : undefined;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* Whether a value satisfies a declared {@link FieldSpec.kind}.
|
|
140
|
+
*
|
|
141
|
+
* Deliberately lenient about the spellings YAML makes ambiguous: `"12"` is a
|
|
142
|
+
* number, because a quoted scalar is how a number arrives from many editors,
|
|
143
|
+
* and a map authored as an empty list is a map, because Obsidian's property
|
|
144
|
+
* editor serialises an emptied map that way (#8). What it rejects is a value
|
|
145
|
+
* that cannot mean what the field is for — `weight: heavy`.
|
|
146
|
+
*
|
|
147
|
+
* @param {unknown} value - The authored value.
|
|
148
|
+
* @param {string} kind - The declared kind.
|
|
149
|
+
* @returns {boolean} Whether it is acceptable.
|
|
150
|
+
*/
|
|
151
|
+
export function matchesKind(value, kind) {
|
|
152
|
+
switch (kind) {
|
|
153
|
+
case "number":
|
|
154
|
+
return typeof value === "number" ?
|
|
155
|
+
Number.isFinite(value)
|
|
156
|
+
: typeof value === "string" &&
|
|
157
|
+
value.trim() !== "" &&
|
|
158
|
+
Number.isFinite(Number(value));
|
|
159
|
+
case "boolean":
|
|
160
|
+
return (
|
|
161
|
+
typeof value === "boolean" ||
|
|
162
|
+
value === "true" ||
|
|
163
|
+
value === "false"
|
|
164
|
+
);
|
|
165
|
+
case "string":
|
|
166
|
+
return typeof value !== "object" || value === null;
|
|
167
|
+
case "list":
|
|
168
|
+
return Array.isArray(value);
|
|
169
|
+
case "map":
|
|
170
|
+
// An emptied map arrives as `[]` from the property editor, and
|
|
171
|
+
// means "this note authors no entries" — the same thing `{}` means.
|
|
172
|
+
return (
|
|
173
|
+
(typeof value === "object" &&
|
|
174
|
+
value !== null &&
|
|
175
|
+
!Array.isArray(value)) ||
|
|
176
|
+
(Array.isArray(value) && value.length === 0)
|
|
177
|
+
);
|
|
178
|
+
default:
|
|
179
|
+
return true;
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/**
|
|
184
|
+
* The `sohl:` block a note authored, or an empty one.
|
|
185
|
+
*
|
|
186
|
+
* @param {object} fm - The note's frontmatter.
|
|
187
|
+
* @returns {object} The block.
|
|
188
|
+
*/
|
|
189
|
+
function sohlBlock(fm) {
|
|
190
|
+
const block = fm?.sohl;
|
|
191
|
+
return block && typeof block === "object" && !Array.isArray(block) ?
|
|
192
|
+
block
|
|
193
|
+
: {};
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/**
|
|
197
|
+
* Check one note against its type's schema.
|
|
198
|
+
*
|
|
199
|
+
* @param {object} note - A note from the link index (`{fm, file, raw, type}`).
|
|
200
|
+
* @param {object} opts
|
|
201
|
+
* @param {Record<string, readonly object[]>} opts.schemas - Type → declaration.
|
|
202
|
+
* @param {object} [opts.index] - The link index, for the reference check. Its
|
|
203
|
+
* absence skips that check rather than reporting every reference as dead.
|
|
204
|
+
* @returns {object[]} Findings, each with a locator where one is obtainable.
|
|
205
|
+
*/
|
|
206
|
+
export function lintNote(note, { schemas, index }) {
|
|
207
|
+
const findings = [];
|
|
208
|
+
const fm = note.fm ?? {};
|
|
209
|
+
const type = String(fm.type ?? "");
|
|
210
|
+
const raw = () => note.raw ?? "";
|
|
211
|
+
const at = (key, literal) =>
|
|
212
|
+
positionInFrontmatter(raw(), key, literal ?? undefined);
|
|
213
|
+
|
|
214
|
+
const replacement = RETIRED_TYPES[type];
|
|
215
|
+
if (replacement) {
|
|
216
|
+
findings.push({
|
|
217
|
+
file: note.file,
|
|
218
|
+
...at("type", type),
|
|
219
|
+
severity: "error",
|
|
220
|
+
message:
|
|
221
|
+
`content type "${type}" was retired in favour of ` +
|
|
222
|
+
`"${replacement}"; both compiled to the same document, so the ` +
|
|
223
|
+
`fix is mechanical: write "${replacement}"`,
|
|
224
|
+
});
|
|
225
|
+
return findings;
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
const schema = schemas[type];
|
|
229
|
+
if (!schema) {
|
|
230
|
+
findings.push({
|
|
231
|
+
file: note.file,
|
|
232
|
+
...at("type", type),
|
|
233
|
+
severity: "error",
|
|
234
|
+
message:
|
|
235
|
+
`no schema is declared for content type "${type}", so nothing ` +
|
|
236
|
+
`can say what this note may write; declare it, or correct the type`,
|
|
237
|
+
});
|
|
238
|
+
return findings;
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
const fields = authoredFields(schema);
|
|
242
|
+
const block = sohlBlock(fm);
|
|
243
|
+
/** First segment of each declared name — `impact.die` is authored as `impact`. */
|
|
244
|
+
const declared = new Set(fields.map((f) => f.name.split(".")[0]));
|
|
245
|
+
|
|
246
|
+
for (const key of Object.keys(block)) {
|
|
247
|
+
if (declared.has(key) || UNIVERSAL_KEYS.has(key)) continue;
|
|
248
|
+
const guess = nearest(key, declared);
|
|
249
|
+
findings.push({
|
|
250
|
+
file: note.file,
|
|
251
|
+
...at(key),
|
|
252
|
+
severity: "error",
|
|
253
|
+
message:
|
|
254
|
+
`"${key}" is not a property of a ${type}; it is discarded at ` +
|
|
255
|
+
`compile with no warning` +
|
|
256
|
+
(guess ? `. Did you mean "${guess}"?` : ""),
|
|
257
|
+
});
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
for (const field of fields) {
|
|
261
|
+
// Only top-level names are read here: a nested one (`impact.die`) is
|
|
262
|
+
// reached through its parent, and reporting the parent twice — once as
|
|
263
|
+
// itself and once as its child — helps nobody.
|
|
264
|
+
const [head, ...rest] = field.name.split(".");
|
|
265
|
+
let value = block[head];
|
|
266
|
+
for (const segment of rest) {
|
|
267
|
+
value =
|
|
268
|
+
value && typeof value === "object" ? value[segment] : undefined;
|
|
269
|
+
}
|
|
270
|
+
const absent = value === undefined || value === null;
|
|
271
|
+
|
|
272
|
+
if (field.required && absent) {
|
|
273
|
+
findings.push({
|
|
274
|
+
file: note.file,
|
|
275
|
+
...at("type", type),
|
|
276
|
+
severity: "error",
|
|
277
|
+
message: `a ${type} must declare \`sohl.${field.name}\` — ${field.describe}`,
|
|
278
|
+
});
|
|
279
|
+
continue;
|
|
280
|
+
}
|
|
281
|
+
if (absent) continue;
|
|
282
|
+
|
|
283
|
+
if (field.kind && !matchesKind(value, field.kind)) {
|
|
284
|
+
findings.push({
|
|
285
|
+
file: note.file,
|
|
286
|
+
...at(head),
|
|
287
|
+
severity: "error",
|
|
288
|
+
message:
|
|
289
|
+
`\`sohl.${field.name}\` should be ${field.shape ?? field.kind}, ` +
|
|
290
|
+
`but reads ${JSON.stringify(value)}`,
|
|
291
|
+
});
|
|
292
|
+
continue;
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
// A reference names another note by shortcode. Resolved through the
|
|
296
|
+
// link index's own resolver, so a cross-package reference answered by a
|
|
297
|
+
// vendored manifest lands exactly as the same address in a wikilink
|
|
298
|
+
// would — rather than through a second, subtly different rule.
|
|
299
|
+
if (field.ref && index && typeof value === "string" && value) {
|
|
300
|
+
const target = `${field.ref}-${value}`;
|
|
301
|
+
if (!index.resolve(note, target) && !index.manifestHit(target)) {
|
|
302
|
+
findings.push({
|
|
303
|
+
file: note.file,
|
|
304
|
+
...at(head, value),
|
|
305
|
+
severity: "error",
|
|
306
|
+
message:
|
|
307
|
+
`\`sohl.${field.name}\` names ${field.ref} ` +
|
|
308
|
+
`"${value}", and no note or vendored manifest declares it`,
|
|
309
|
+
});
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
return findings;
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
/**
|
|
318
|
+
* Check every note in a built index against its type's schema.
|
|
319
|
+
*
|
|
320
|
+
* @param {object} index - From `buildLinkIndex`.
|
|
321
|
+
* @param {object} opts
|
|
322
|
+
* @param {Record<string, readonly object[]>} opts.schemas - Type → declaration.
|
|
323
|
+
* @param {boolean} [opts.references=true] - Whether to check references.
|
|
324
|
+
* @returns {{findings: object[], notes: number}} The findings, and how many
|
|
325
|
+
* notes were inspected.
|
|
326
|
+
*/
|
|
327
|
+
export function lintFrontmatter(index, { schemas, references = true }) {
|
|
328
|
+
const findings = [];
|
|
329
|
+
const notes = [...index.notes].sort((a, b) =>
|
|
330
|
+
a.file < b.file ? -1
|
|
331
|
+
: a.file > b.file ? 1
|
|
332
|
+
: 0,
|
|
333
|
+
);
|
|
334
|
+
for (const note of notes) {
|
|
335
|
+
findings.push(
|
|
336
|
+
...lintNote(note, {
|
|
337
|
+
schemas,
|
|
338
|
+
index: references ? index : undefined,
|
|
339
|
+
}),
|
|
340
|
+
);
|
|
341
|
+
}
|
|
342
|
+
return { findings, notes: notes.length };
|
|
343
|
+
}
|