@heroiclands/package-build 6.0.0 → 6.1.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 +184 -0
- package/CONTENT.md +207 -3
- package/bin/content-build.mjs +168 -1
- package/content-config.mjs +29 -7
- package/engine/address-diff.mjs +290 -0
- package/engine/content-lint.mjs +23 -2
- package/engine/frontmatter-lint.mjs +22 -0
- package/engine/homepage.mjs +206 -2
- package/engine/site-build.mjs +74 -19
- package/package.json +1 -1
- package/types/engine/address-diff.d.mts +108 -0
- package/types/engine/content-lint.d.mts +4 -1
- package/types/engine/homepage.d.mts +117 -0
- package/types/engine/site-build.d.mts +28 -3
package/bin/content-build.mjs
CHANGED
|
@@ -45,6 +45,7 @@
|
|
|
45
45
|
* npx content-build manifest [root] [--out <dir>]
|
|
46
46
|
* npx content-build site [--out <dir>]
|
|
47
47
|
* npx content-build reachability <dir> [file] [--index <shortcode>]
|
|
48
|
+
* npx content-build addresses diff --from <zip|dir> [--strict]
|
|
48
49
|
*
|
|
49
50
|
* In a consuming repository, wrapped as npm scripts — SoHL spells them:
|
|
50
51
|
* npm run build:compiledb // → … package compile (all packs)
|
|
@@ -92,6 +93,14 @@ import {
|
|
|
92
93
|
walkReachability,
|
|
93
94
|
} from "../engine/content-links.mjs";
|
|
94
95
|
import { emitDiagnostic, positionOfLiteral } from "../engine/diagnostics.mjs";
|
|
96
|
+
import {
|
|
97
|
+
readItemAddresses,
|
|
98
|
+
diffItemAddresses,
|
|
99
|
+
noteFilesById,
|
|
100
|
+
locateAddressFinding,
|
|
101
|
+
addressFindingMessage,
|
|
102
|
+
} from "../engine/address-diff.mjs";
|
|
103
|
+
import { itemPackJsonDirs } from "../engine/generate.mjs";
|
|
95
104
|
import {
|
|
96
105
|
formatUnaddressableFinding,
|
|
97
106
|
unaddressableForeignPackages,
|
|
@@ -158,6 +167,7 @@ const argv = yargs(hideBin(process.argv))
|
|
|
158
167
|
.command(manifestCommand())
|
|
159
168
|
.command(siteCommand())
|
|
160
169
|
.command(reachabilityCommand())
|
|
170
|
+
.command(addressesCommand())
|
|
161
171
|
.version(ownVersion())
|
|
162
172
|
.help()
|
|
163
173
|
.alias("help", "h")
|
|
@@ -340,7 +350,11 @@ function lintCommand() {
|
|
|
340
350
|
const root = argv.root ?? config.paths.content;
|
|
341
351
|
const manifestDir = argv.manifests ?? config.paths.manifests;
|
|
342
352
|
|
|
343
|
-
|
|
353
|
+
// The package is passed for the homepage rule (#52), which
|
|
354
|
+
// names the address a tree with no front page fails to serve.
|
|
355
|
+
const addresses = lintContentTree(root, {
|
|
356
|
+
contentPackage: config.contentPackage,
|
|
357
|
+
});
|
|
344
358
|
// One index, built once, for the reference check. It is the
|
|
345
359
|
// same resolver the wikilink audit uses, so a frontmatter
|
|
346
360
|
// reference and a body link answer the same way.
|
|
@@ -767,6 +781,11 @@ function siteCommand() {
|
|
|
767
781
|
});
|
|
768
782
|
const { gates } = result;
|
|
769
783
|
|
|
784
|
+
// First, because it is decided before the tree is walked and
|
|
785
|
+
// before the output is cleared: a package with no front page,
|
|
786
|
+
// or two competing for it, has nothing to say about its pages
|
|
787
|
+
// yet (#52).
|
|
788
|
+
for (const f of gates.homepages) emitDiagnostic(f);
|
|
770
789
|
for (const f of gates.frontmatterLinks) {
|
|
771
790
|
emitDiagnostic({
|
|
772
791
|
file: f.file,
|
|
@@ -1040,6 +1059,154 @@ function depsCommand() {
|
|
|
1040
1059
|
};
|
|
1041
1060
|
}
|
|
1042
1061
|
|
|
1062
|
+
/**
|
|
1063
|
+
* `addresses diff` — report every published `(type, shortcode)` this build no
|
|
1064
|
+
* longer publishes, against a released artifact.
|
|
1065
|
+
*
|
|
1066
|
+
* The address space is a published interface (see `engine/address-diff.mjs`),
|
|
1067
|
+
* and renaming a shortcode used to cost nothing and produce no signal. This is
|
|
1068
|
+
* the signal, emitted in the repository doing the renaming while the change is
|
|
1069
|
+
* still in front of the author.
|
|
1070
|
+
*
|
|
1071
|
+
* **Its own command rather than a step of `package compile`.** It reads a
|
|
1072
|
+
* *second* artifact that the compile knows nothing about and that has to be
|
|
1073
|
+
* obtained separately, and it is a question about a release rather than about a
|
|
1074
|
+
* build — a repository between releases has nothing to compare against.
|
|
1075
|
+
*
|
|
1076
|
+
* **The baseline is named, never derived, and never downloaded.** `--from`
|
|
1077
|
+
* takes the artifact — the `.zip` a release publishes, or a directory built
|
|
1078
|
+
* from one — for the same reason `deps fetch --from` does: a command that
|
|
1079
|
+
* reaches the network on its own is not reproducible and fails strangely
|
|
1080
|
+
* offline. In a release workflow the artifact is one line ahead of it:
|
|
1081
|
+
*
|
|
1082
|
+
* ```sh
|
|
1083
|
+
* gh release download v0.8.2 -p system.zip -D build/baseline
|
|
1084
|
+
* npx content-build addresses diff --from build/baseline/system.zip
|
|
1085
|
+
* ```
|
|
1086
|
+
*
|
|
1087
|
+
* @param {object} config - The resolved build configuration.
|
|
1088
|
+
* @param {{from: string, strict?: boolean}} argv - The parsed arguments.
|
|
1089
|
+
* @returns {Promise<void>}
|
|
1090
|
+
*/
|
|
1091
|
+
async function diffAddresses(config, argv) {
|
|
1092
|
+
// The baseline is this package's own earlier self, so it is cached beside
|
|
1093
|
+
// the dependency catalogues rather than among them — a release of `sohl`
|
|
1094
|
+
// is not a dependency of `sohl`, and filing it as one would collide with a
|
|
1095
|
+
// genuine relationship of the same id.
|
|
1096
|
+
const cacheConfig = {
|
|
1097
|
+
...config,
|
|
1098
|
+
paths: {
|
|
1099
|
+
...config.paths,
|
|
1100
|
+
foreignCache: path.join(
|
|
1101
|
+
path.dirname(config.paths.foreignCache),
|
|
1102
|
+
"baseline",
|
|
1103
|
+
),
|
|
1104
|
+
},
|
|
1105
|
+
};
|
|
1106
|
+
const dir = await fetchCatalogFromPath(
|
|
1107
|
+
cacheConfig,
|
|
1108
|
+
{ id: config.foundryPackage },
|
|
1109
|
+
argv.from,
|
|
1110
|
+
);
|
|
1111
|
+
// `<id>@<version>`, which is what the diagnostics name the baseline by.
|
|
1112
|
+
const label = path.basename(dir);
|
|
1113
|
+
|
|
1114
|
+
const itemsRoot = path.join(dir, "items");
|
|
1115
|
+
const baselineDirs = fs
|
|
1116
|
+
.readdirSync(itemsRoot)
|
|
1117
|
+
.map((name) => path.join(itemsRoot, name));
|
|
1118
|
+
const currentDirs = itemPackJsonDirs(config);
|
|
1119
|
+
if (!currentDirs.length) {
|
|
1120
|
+
throw new Error(
|
|
1121
|
+
'this repository declares no pack of type "Item", so it ' +
|
|
1122
|
+
"publishes no item addresses to diff",
|
|
1123
|
+
);
|
|
1124
|
+
}
|
|
1125
|
+
|
|
1126
|
+
const findings = diffItemAddresses(
|
|
1127
|
+
readItemAddresses(baselineDirs),
|
|
1128
|
+
readItemAddresses(currentDirs),
|
|
1129
|
+
{ baseline: label },
|
|
1130
|
+
);
|
|
1131
|
+
if (!findings.length) {
|
|
1132
|
+
log.info(`Every address ${label} published is still published.`);
|
|
1133
|
+
return;
|
|
1134
|
+
}
|
|
1135
|
+
|
|
1136
|
+
// A rename is fixed in the note that made it, so findings are placed
|
|
1137
|
+
// against the tree rather than against the compiled output they were read
|
|
1138
|
+
// from.
|
|
1139
|
+
const noteFiles = noteFilesById(config.paths.content);
|
|
1140
|
+
const severity = argv.strict ? "error" : "warning";
|
|
1141
|
+
for (const finding of findings) {
|
|
1142
|
+
emitDiagnostic({
|
|
1143
|
+
...locateAddressFinding(finding, noteFiles),
|
|
1144
|
+
severity,
|
|
1145
|
+
message: addressFindingMessage(finding),
|
|
1146
|
+
});
|
|
1147
|
+
}
|
|
1148
|
+
const renamed = findings.filter((f) => f.kind === "renamed").length;
|
|
1149
|
+
log.info(
|
|
1150
|
+
`${findings.length} address(es) ${label} published are no longer ` +
|
|
1151
|
+
`published (${renamed} renamed, ${findings.length - renamed} ` +
|
|
1152
|
+
`withdrawn).`,
|
|
1153
|
+
);
|
|
1154
|
+
// Retiring content is legitimate and so is renaming; neither fails a build
|
|
1155
|
+
// unless the caller asked for a gate.
|
|
1156
|
+
if (argv.strict) process.exitCode = 1;
|
|
1157
|
+
}
|
|
1158
|
+
|
|
1159
|
+
function addressesCommand() {
|
|
1160
|
+
return {
|
|
1161
|
+
command: "addresses <action>",
|
|
1162
|
+
describe:
|
|
1163
|
+
"Compare the addresses this build publishes against a release's",
|
|
1164
|
+
builder: (yargs) => {
|
|
1165
|
+
// Required, for the reason every other action is (#57): an
|
|
1166
|
+
// optional one exits 0 having compared nothing.
|
|
1167
|
+
yargs.positional("action", {
|
|
1168
|
+
describe: "The action to perform.",
|
|
1169
|
+
type: "string",
|
|
1170
|
+
choices: ["diff"],
|
|
1171
|
+
});
|
|
1172
|
+
yargs.option("from", {
|
|
1173
|
+
describe:
|
|
1174
|
+
"The released artifact to compare against — a package " +
|
|
1175
|
+
"zip, or the directory it was built from.",
|
|
1176
|
+
type: "string",
|
|
1177
|
+
});
|
|
1178
|
+
yargs.option("strict", {
|
|
1179
|
+
describe:
|
|
1180
|
+
"Report findings as errors and exit non-zero, for a " +
|
|
1181
|
+
"release workflow that gates on them.",
|
|
1182
|
+
type: "boolean",
|
|
1183
|
+
default: false,
|
|
1184
|
+
});
|
|
1185
|
+
// Checked while parsing, so it fails in a repository whose
|
|
1186
|
+
// configuration a handler would never get far enough to resolve.
|
|
1187
|
+
yargs.check((parsed) => {
|
|
1188
|
+
if (parsed.action === "diff" && !parsed.from) {
|
|
1189
|
+
throw new Error(
|
|
1190
|
+
"`addresses diff` needs `--from <zip|dir>`: the " +
|
|
1191
|
+
"release to compare against. There is nothing to " +
|
|
1192
|
+
"derive it from — a repository between releases " +
|
|
1193
|
+
"has no previous artifact on disk.",
|
|
1194
|
+
);
|
|
1195
|
+
}
|
|
1196
|
+
return true;
|
|
1197
|
+
});
|
|
1198
|
+
},
|
|
1199
|
+
handler: async (argv) => {
|
|
1200
|
+
try {
|
|
1201
|
+
await diffAddresses(loadPackConfig(), argv);
|
|
1202
|
+
} catch (err) {
|
|
1203
|
+
log.error(err.message);
|
|
1204
|
+
process.exitCode = 1;
|
|
1205
|
+
}
|
|
1206
|
+
},
|
|
1207
|
+
};
|
|
1208
|
+
}
|
|
1209
|
+
|
|
1043
1210
|
function packageCommand() {
|
|
1044
1211
|
return {
|
|
1045
1212
|
command: "package <action> [pack] [entry]",
|
package/content-config.mjs
CHANGED
|
@@ -602,7 +602,7 @@ const SITE_KEYS = [
|
|
|
602
602
|
"backfillSections",
|
|
603
603
|
];
|
|
604
604
|
const SITE_TREE_KEYS = ["from", "section"];
|
|
605
|
-
const SECTION_META_KEYS = ["title", "banner"];
|
|
605
|
+
const SECTION_META_KEYS = ["title", "banner", "description"];
|
|
606
606
|
const DOC_PAGE_KEYS = ["title", "out", "preamble"];
|
|
607
607
|
const RELATIONSHIP_KINDS = ["systems", "requires", "recommends", "conflicts"];
|
|
608
608
|
const RELATIONSHIP_KEYS = [
|
|
@@ -973,16 +973,32 @@ function normalizeDocs(value) {
|
|
|
973
973
|
}
|
|
974
974
|
|
|
975
975
|
/**
|
|
976
|
-
* One section's landing metadata —
|
|
977
|
-
*
|
|
978
|
-
*
|
|
979
|
-
*
|
|
980
|
-
*
|
|
976
|
+
* One section's landing metadata — what a section says about itself on the
|
|
977
|
+
* `_index.md` this build generates for it.
|
|
978
|
+
*
|
|
979
|
+
* A generated landing is the *only* place a section can speak: a content
|
|
980
|
+
* package has no authored `_index.md` for `weapongear` or `affliction`, so the
|
|
981
|
+
* file the theme reads is the one the site build writes. This is therefore the
|
|
982
|
+
* whole vocabulary, and it is deliberately a **closed** one.
|
|
983
|
+
*
|
|
984
|
+
* The alternative — passing whatever a section declared straight through, as
|
|
985
|
+
* `site.landing` does — was weighed and refused. `landing` is written once, for
|
|
986
|
+
* the mount, and its keys are one landing template's own; a section entry is
|
|
987
|
+
* written fourteen to twenty times per build against a contract every package
|
|
988
|
+
* and every section shares. Unbounded there, a mistyped `descrption:` publishes
|
|
989
|
+
* into front matter, is read by nobody, and says nothing to anyone — which is
|
|
990
|
+
* the failure #91 was filed about, moved one step downstream where no build can
|
|
991
|
+
* see it. So the keys are named here, and the writers emit what this produced
|
|
992
|
+
* rather than transcribing a second list of their own (#91).
|
|
993
|
+
*
|
|
994
|
+
* `banner` and `description` are optional — the hero images are external assets
|
|
995
|
+
* and not every section has one, and a section may reasonably have nothing to
|
|
996
|
+
* add to its title. Each is left off entirely rather than written as
|
|
981
997
|
* `undefined`, which is not a value YAML can carry.
|
|
982
998
|
*
|
|
983
999
|
* @param {unknown} value - The declared entry.
|
|
984
1000
|
* @param {string} where - Dotted path, for the error.
|
|
985
|
-
* @returns {Readonly<{title: string, banner?: string}>}
|
|
1001
|
+
* @returns {Readonly<{title: string, banner?: string, description?: string}>}
|
|
986
1002
|
*/
|
|
987
1003
|
function normalizeSectionMeta(value, where) {
|
|
988
1004
|
if (!isPlainObject(value)) fail(where, "must be a mapping");
|
|
@@ -992,6 +1008,12 @@ function normalizeSectionMeta(value, where) {
|
|
|
992
1008
|
if (input.banner !== undefined) {
|
|
993
1009
|
out.banner = requireNonEmptyString(input.banner, `${where}.banner`);
|
|
994
1010
|
}
|
|
1011
|
+
if (input.description !== undefined) {
|
|
1012
|
+
out.description = requireNonEmptyString(
|
|
1013
|
+
input.description,
|
|
1014
|
+
`${where}.description`,
|
|
1015
|
+
);
|
|
1016
|
+
}
|
|
995
1017
|
return Object.freeze(out);
|
|
996
1018
|
}
|
|
997
1019
|
|
|
@@ -0,0 +1,290 @@
|
|
|
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
|
+
* Diffing a package's published item addresses against a released one (#66).
|
|
16
|
+
*
|
|
17
|
+
* A package's `(type, shortcode)` addresses are a **published interface**.
|
|
18
|
+
* Every satellite that declares `itemCatalog: true` assembles its beings out of
|
|
19
|
+
* them — `attribute:str`, `skill:awar`, `weapongear:Tabri` — resolving each one
|
|
20
|
+
* against the Item packs of the release its `compatibility.verified` pins. So
|
|
21
|
+
* renaming a shortcode is a breaking change to something other repositories
|
|
22
|
+
* consume, and until this module there was nothing that noticed: the check that
|
|
23
|
+
* got made was a repository-local grep, which cannot see the other
|
|
24
|
+
* repositories and reports the reassuring answer.
|
|
25
|
+
*
|
|
26
|
+
* `sohl` renamed one weapon's shortcode from `Tabri` to `Taburi` two days after
|
|
27
|
+
* the `v0.8.2` tag, on the stated ground that "nothing referenced the old
|
|
28
|
+
* value, so the rename is self-contained". True of that repository. Both
|
|
29
|
+
* satellites pin `v0.8.2` and address `weapongear:Tabri` on their copy of the
|
|
30
|
+
* same character — five lookups that resolve today and fail the moment either
|
|
31
|
+
* pin moves, with an error reading like a missing item.
|
|
32
|
+
*
|
|
33
|
+
* **The comparison is release-to-release, in the repository doing the
|
|
34
|
+
* renaming.** The alternative — checking a consumer's addresses against its
|
|
35
|
+
* pinned release — already exists and already fails the build (`no predefined
|
|
36
|
+
* item for "weapongear:Taburi"`); what it lacks is an explanation, and it
|
|
37
|
+
* cannot honestly produce one, because at the point of the miss all it holds is
|
|
38
|
+
* the address string. It has no document id and no name to match a candidate
|
|
39
|
+
* against, so any successor it named would be a guess at a similar-looking
|
|
40
|
+
* string. Here both sides are whole documents, so the question is decidable.
|
|
41
|
+
*
|
|
42
|
+
* **A rename is told from a removal by the document id, and that is an identity
|
|
43
|
+
* match rather than an inference.** A note authors its `_id` in frontmatter; it
|
|
44
|
+
* is not derived from the shortcode, and the `Tabri` → `Taburi` commit changed
|
|
45
|
+
* the shortcode alone. So an address that disappeared while its document is
|
|
46
|
+
* still published elsewhere *is* a rename — not "probably" one. When the id is
|
|
47
|
+
* published under no address at all, that is all this can say: **withdrawn**,
|
|
48
|
+
* with no successor named. A split, a deletion and a merge are indistinguish-
|
|
49
|
+
* able from one another at that point, and inventing a "did you mean" from
|
|
50
|
+
* string similarity would be worse than saying nothing, because a wrong one
|
|
51
|
+
* sends the reader to the wrong fix.
|
|
52
|
+
*
|
|
53
|
+
* **Severity is decided per case.** A withdrawal is legitimate — content is
|
|
54
|
+
* allowed to be retired — so it is reported and does not fail a build. A rename
|
|
55
|
+
* is equally legitimate as a decision (#1397's charset rule forces some), which
|
|
56
|
+
* is why it does not fail one either; what it must not do is happen in silence.
|
|
57
|
+
* A caller that wants a gate passes `error` and treats any finding as one.
|
|
58
|
+
*
|
|
59
|
+
* Item packs only, because that is the address space consumers resolve
|
|
60
|
+
* against: {@link foreignItemCatalogDirs} extracts nothing else, and a being's
|
|
61
|
+
* embedded items are the only cross-package resolution by `(type, shortcode)`.
|
|
62
|
+
*
|
|
63
|
+
* @module
|
|
64
|
+
*/
|
|
65
|
+
|
|
66
|
+
import fs from "node:fs";
|
|
67
|
+
import path from "node:path";
|
|
68
|
+
|
|
69
|
+
import { formatDiagnostic, positionInFrontmatter } from "./diagnostics.mjs";
|
|
70
|
+
import { positionOfLiteral } from "./diagnostics.mjs";
|
|
71
|
+
import { walkMarkdownTree } from "./helpers.mjs";
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* The address space a set of compiled Item pack directories publishes.
|
|
75
|
+
*
|
|
76
|
+
* The directories are read as one space for the same reason the actors pass
|
|
77
|
+
* reads them as one: a being names an item by `(type, shortcode)` and never by
|
|
78
|
+
* the pack it happens to ship in. Both sides of a diff are built by this one
|
|
79
|
+
* function, so a released catalogue extracted by `deps fetch` and a freshly
|
|
80
|
+
* compiled pack are indexed identically and a difference between them is a real
|
|
81
|
+
* one rather than an artefact of two readers.
|
|
82
|
+
*
|
|
83
|
+
* A missing directory throws rather than reading as an empty space: an empty
|
|
84
|
+
* baseline would report every address in the package as withdrawn, and an empty
|
|
85
|
+
* current side would report every address as gone — the loudest possible
|
|
86
|
+
* output from the quietest possible mistake.
|
|
87
|
+
*
|
|
88
|
+
* @param {readonly string[]} dirs - Directories of item JSON.
|
|
89
|
+
* @returns {Map<string, {id: string, name: string, type: string, shortcode: string, file: string}>}
|
|
90
|
+
* Every item, keyed `type:shortcode`.
|
|
91
|
+
*/
|
|
92
|
+
export function readItemAddresses(dirs) {
|
|
93
|
+
const space = new Map();
|
|
94
|
+
for (const dir of dirs) {
|
|
95
|
+
if (!fs.existsSync(dir)) {
|
|
96
|
+
throw new Error(
|
|
97
|
+
`Item source directory ${dir} does not exist — an address ` +
|
|
98
|
+
`diff reads compiled Item pack output, so those packs ` +
|
|
99
|
+
`must be compiled (or the catalogue fetched) first`,
|
|
100
|
+
);
|
|
101
|
+
}
|
|
102
|
+
for (const name of fs.readdirSync(dir)) {
|
|
103
|
+
if (!name.endsWith(".json")) continue;
|
|
104
|
+
if (name.startsWith("folder_")) continue;
|
|
105
|
+
const file = path.join(dir, name);
|
|
106
|
+
let doc;
|
|
107
|
+
try {
|
|
108
|
+
doc = JSON.parse(fs.readFileSync(file, "utf8"));
|
|
109
|
+
} catch {
|
|
110
|
+
// Unparseable output is the compile's problem to report, not
|
|
111
|
+
// this pass's; skipping it here loses one address rather than
|
|
112
|
+
// failing a diff that has nothing to do with it.
|
|
113
|
+
continue;
|
|
114
|
+
}
|
|
115
|
+
const shortcode = doc?.system?.shortcode;
|
|
116
|
+
if (!doc?.type || !shortcode || !doc?._id) continue;
|
|
117
|
+
space.set(`${doc.type}:${shortcode}`, {
|
|
118
|
+
id: doc._id,
|
|
119
|
+
name: doc.name ?? "",
|
|
120
|
+
type: doc.type,
|
|
121
|
+
shortcode,
|
|
122
|
+
file,
|
|
123
|
+
});
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
return space;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* Every address the baseline published that this build does not.
|
|
131
|
+
*
|
|
132
|
+
* An address that merely *arrived* is not a finding: adding one breaks nobody.
|
|
133
|
+
* The arrivals are read only to answer the one question that matters about a
|
|
134
|
+
* departure — is the document still here under another name?
|
|
135
|
+
*
|
|
136
|
+
* @param {Map<string, object>} baseline - The released address space.
|
|
137
|
+
* @param {Map<string, object>} current - This build's address space.
|
|
138
|
+
* @param {object} opts
|
|
139
|
+
* @param {string} opts.baseline - What the baseline is, for the message —
|
|
140
|
+
* conventionally `<package>@<version>`.
|
|
141
|
+
* @returns {Array<object>} One finding per departed address, in address order
|
|
142
|
+
* so two runs read the same. `kind` is `"renamed"` (with `to`) or
|
|
143
|
+
* `"withdrawn"`.
|
|
144
|
+
*/
|
|
145
|
+
export function diffItemAddresses(baseline, current, { baseline: label }) {
|
|
146
|
+
// A baseline that yields no address at all cannot produce a finding, so it
|
|
147
|
+
// reports a clean result for every possible input — the one failure a check
|
|
148
|
+
// like this can never catch, and the same one `foreign-manifests.mjs` exists
|
|
149
|
+
// to stop. It is a real state, not a hypothetical: `sohl-kethira-basic@0.5.3`
|
|
150
|
+
// shipped 307 items carrying no `system.shortcode` between them.
|
|
151
|
+
if (!baseline.size) {
|
|
152
|
+
throw new Error(
|
|
153
|
+
`${label} publishes no addressable item — no document in its Item ` +
|
|
154
|
+
`packs carries a \`system.shortcode\`. A diff against it can ` +
|
|
155
|
+
`only report that nothing changed, whatever this build does, ` +
|
|
156
|
+
`so it is refused rather than passed`,
|
|
157
|
+
);
|
|
158
|
+
}
|
|
159
|
+
// Where each still-published document lives now. Built once: a rename is
|
|
160
|
+
// decided by identity, so this is the whole evidence base.
|
|
161
|
+
const currentById = new Map();
|
|
162
|
+
for (const [address, entry] of current) {
|
|
163
|
+
if (!currentById.has(entry.id)) currentById.set(entry.id, address);
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
const findings = [];
|
|
167
|
+
for (const [address, entry] of baseline) {
|
|
168
|
+
if (current.has(address)) continue;
|
|
169
|
+
const to = currentById.get(entry.id);
|
|
170
|
+
findings.push({
|
|
171
|
+
kind: to ? "renamed" : "withdrawn",
|
|
172
|
+
address,
|
|
173
|
+
...(to ? { to } : {}),
|
|
174
|
+
id: entry.id,
|
|
175
|
+
name: entry.name,
|
|
176
|
+
shortcode: entry.shortcode,
|
|
177
|
+
baselineFile: entry.file,
|
|
178
|
+
baseline: label,
|
|
179
|
+
});
|
|
180
|
+
}
|
|
181
|
+
findings.sort((a, b) => (a.address < b.address ? -1 : 1));
|
|
182
|
+
return findings;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/**
|
|
186
|
+
* Every content note in a tree, indexed by the document id it authors.
|
|
187
|
+
*
|
|
188
|
+
* The address space is read from compiled output because that is what actually
|
|
189
|
+
* ships; the tree is read only to place a finding somewhere a reader can open
|
|
190
|
+
* and fix it. Each source answers the question it is good at, and the id is the
|
|
191
|
+
* exact key that joins them.
|
|
192
|
+
*
|
|
193
|
+
* @param {string} contentBase - Root of the content tree.
|
|
194
|
+
* @param {object} [opts]
|
|
195
|
+
* @param {readonly string[]} [opts.skipDirectories] - Passed to the walk.
|
|
196
|
+
* @returns {Map<string, string>} Document id → the note's absolute path.
|
|
197
|
+
*/
|
|
198
|
+
export function noteFilesById(contentBase, { skipDirectories } = {}) {
|
|
199
|
+
const byId = new Map();
|
|
200
|
+
const walkOpts = skipDirectories ? { skipDirectories } : undefined;
|
|
201
|
+
for (const { frontmatter: fm, absPath } of walkMarkdownTree(
|
|
202
|
+
contentBase,
|
|
203
|
+
walkOpts,
|
|
204
|
+
)) {
|
|
205
|
+
if (fm?.id && !byId.has(fm.id)) byId.set(fm.id, absPath);
|
|
206
|
+
}
|
|
207
|
+
return byId;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/**
|
|
211
|
+
* Where to send the reader for one finding.
|
|
212
|
+
*
|
|
213
|
+
* A rename is fixed in the note that made it, so a finding whose id is still in
|
|
214
|
+
* this tree is reported at that note's `shortcode:` line — the line the author
|
|
215
|
+
* just edited. A withdrawal has no such note by definition, so it degrades to
|
|
216
|
+
* the baseline document, which is the only artefact left that records the
|
|
217
|
+
* address existing. When neither is readable the position is **dropped**, never
|
|
218
|
+
* defaulted to `1:1`.
|
|
219
|
+
*
|
|
220
|
+
* @param {object} finding - One finding from {@link diffItemAddresses}.
|
|
221
|
+
* @param {Map<string, string>} noteFiles - From {@link noteFilesById}.
|
|
222
|
+
* @returns {{file?: string, line?: number, column?: number}} Spreadable
|
|
223
|
+
* position fields for {@link formatDiagnostic}.
|
|
224
|
+
*/
|
|
225
|
+
export function locateAddressFinding(finding, noteFiles) {
|
|
226
|
+
const note = noteFiles?.get(finding.id);
|
|
227
|
+
if (note) {
|
|
228
|
+
try {
|
|
229
|
+
const raw = fs.readFileSync(note, "utf8");
|
|
230
|
+
return { file: note, ...positionInFrontmatter(raw, "shortcode") };
|
|
231
|
+
} catch {
|
|
232
|
+
return { file: note };
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
if (!finding.baselineFile) return {};
|
|
236
|
+
try {
|
|
237
|
+
const raw = fs.readFileSync(finding.baselineFile, "utf8");
|
|
238
|
+
return {
|
|
239
|
+
file: finding.baselineFile,
|
|
240
|
+
...positionOfLiteral(raw, `"${finding.shortcode}"`),
|
|
241
|
+
};
|
|
242
|
+
} catch {
|
|
243
|
+
return { file: finding.baselineFile };
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
/**
|
|
248
|
+
* What one finding says, without a locator or a severity.
|
|
249
|
+
*
|
|
250
|
+
* The rename message names the identity it matched on, because that is what
|
|
251
|
+
* separates this from a spelling suggestion: the reader can check the id in
|
|
252
|
+
* both artefacts. The withdrawal message names no successor, because none is
|
|
253
|
+
* known — and says so, rather than leaving the reader to wonder whether one was
|
|
254
|
+
* looked for.
|
|
255
|
+
*
|
|
256
|
+
* @param {object} finding - One finding from {@link diffItemAddresses}.
|
|
257
|
+
* @returns {string} The message.
|
|
258
|
+
*/
|
|
259
|
+
export function addressFindingMessage(finding) {
|
|
260
|
+
if (finding.kind === "renamed") {
|
|
261
|
+
return (
|
|
262
|
+
`since ${finding.baseline}, ${finding.address} is no longer ` +
|
|
263
|
+
`published; the same document (${finding.id}) is now published ` +
|
|
264
|
+
`as ${finding.to}. Every package that resolves ${finding.address} ` +
|
|
265
|
+
`breaks when it moves past ${finding.baseline}`
|
|
266
|
+
);
|
|
267
|
+
}
|
|
268
|
+
return (
|
|
269
|
+
`since ${finding.baseline}, ${finding.address} is no longer ` +
|
|
270
|
+
`published, and its document (${finding.id}) is published under no ` +
|
|
271
|
+
`other address`
|
|
272
|
+
);
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
/**
|
|
276
|
+
* One finding, in the standard `file:line:column: severity: message` form.
|
|
277
|
+
*
|
|
278
|
+
* @param {object} finding - One finding from {@link diffItemAddresses}.
|
|
279
|
+
* @param {{file?: string, line?: number, column?: number}} at - From
|
|
280
|
+
* {@link locateAddressFinding}.
|
|
281
|
+
* @param {"warning"|"error"} [severity] - `error` when the caller is gating.
|
|
282
|
+
* @returns {string} The formatted diagnostic, path first on the line.
|
|
283
|
+
*/
|
|
284
|
+
export function formatAddressFinding(finding, at, severity = "warning") {
|
|
285
|
+
return formatDiagnostic({
|
|
286
|
+
...at,
|
|
287
|
+
severity,
|
|
288
|
+
message: addressFindingMessage(finding),
|
|
289
|
+
});
|
|
290
|
+
}
|
package/engine/content-lint.mjs
CHANGED
|
@@ -22,13 +22,18 @@
|
|
|
22
22
|
* disagree without anything detecting it, which the canonical-separator
|
|
23
23
|
* handling already did once on each side.
|
|
24
24
|
*
|
|
25
|
-
*
|
|
25
|
+
* Three rules, all about a note's identity:
|
|
26
26
|
*
|
|
27
27
|
* 1. **Shape** — a `shortcode` is strictly ASCII-alphanumeric. It is the
|
|
28
28
|
* identity key referenced from saved world data, and it is half of the
|
|
29
29
|
* `type-shortcode` address, whose parse depends on the separating hyphen
|
|
30
30
|
* being the only hyphen in the string.
|
|
31
31
|
* 2. **Uniqueness** — `(type, shortcode)` names one note.
|
|
32
|
+
* 3. **The package's own address** — exactly one note claims `/<package>/`,
|
|
33
|
+
* which is {@link checkHomepageCount} (#52). It belongs here for the same
|
|
34
|
+
* reason the other two do: it is a statement about which note holds which
|
|
35
|
+
* address, it needs no `site:` configuration to decide, and a package with
|
|
36
|
+
* no front page is misconfigured whether or not anyone runs a site build.
|
|
32
37
|
*
|
|
33
38
|
* **Nothing here writes.** A check reports and an author fixes.
|
|
34
39
|
*
|
|
@@ -56,6 +61,7 @@ import path from "node:path";
|
|
|
56
61
|
|
|
57
62
|
import { positionInFrontmatter } from "./diagnostics.mjs";
|
|
58
63
|
import { walkMarkdownTree } from "./helpers.mjs";
|
|
64
|
+
import { checkHomepageCount, isHomepage } from "./homepage.mjs";
|
|
59
65
|
|
|
60
66
|
/**
|
|
61
67
|
* The shape every `shortcode` must match: ASCII letters and digits only.
|
|
@@ -122,11 +128,16 @@ function collectNotes(contentBase, { skipDirectories } = {}) {
|
|
|
122
128
|
* @param {object} [opts]
|
|
123
129
|
* @param {readonly string[]} [opts.skipDirectories] - Directory names the walk
|
|
124
130
|
* ignores. Defaults to the configured list.
|
|
131
|
+
* @param {string} [opts.contentPackage] - The package this tree builds, for the
|
|
132
|
+
* homepage rule. Dropped from that finding when unknown rather than guessed.
|
|
125
133
|
* @returns {{findings: Array<{file: string, line?: number, column?: number,
|
|
126
134
|
* severity: "error"|"warning", message: string}>, notes: number,
|
|
127
135
|
* keys: number}} The findings, and what was inspected to produce them.
|
|
128
136
|
*/
|
|
129
|
-
export function lintContentTree(
|
|
137
|
+
export function lintContentTree(
|
|
138
|
+
contentBase,
|
|
139
|
+
{ skipDirectories, contentPackage } = {},
|
|
140
|
+
) {
|
|
130
141
|
const findings = [];
|
|
131
142
|
const notes = collectNotes(contentBase, { skipDirectories });
|
|
132
143
|
|
|
@@ -184,6 +195,16 @@ export function lintContentTree(contentBase, { skipDirectories } = {}) {
|
|
|
184
195
|
return { findings, notes: 0, keys: 0 };
|
|
185
196
|
}
|
|
186
197
|
|
|
198
|
+
// Deliberately after that return: a tree nobody has established exists has
|
|
199
|
+
// no homepage either, and saying so is noise about the second problem when
|
|
200
|
+
// the first is "check that the content tree is present".
|
|
201
|
+
findings.push(
|
|
202
|
+
...checkHomepageCount(
|
|
203
|
+
notes.filter((n) => isHomepage(n.fm)),
|
|
204
|
+
{ contentBase, contentPackage },
|
|
205
|
+
),
|
|
206
|
+
);
|
|
207
|
+
|
|
187
208
|
for (const [key, files] of byKey) {
|
|
188
209
|
if (files.length < 2) continue;
|
|
189
210
|
// Reported once per offending note rather than once per key: each note
|
|
@@ -40,6 +40,13 @@
|
|
|
40
40
|
* vocabulary (an adventure module ships skills, beings and magic swords), so
|
|
41
41
|
* every consumer loads all of it.
|
|
42
42
|
*
|
|
43
|
+
* The two rules that are not schema-driven sit on the *note format* side of
|
|
44
|
+
* that line, which is why they are here and not in `sohl/`: the retired
|
|
45
|
+
* top-level fields, and the address-bearing fields a `type: homepage` note
|
|
46
|
+
* refuses (#53). Each supplies its own message from the module that owns the
|
|
47
|
+
* knowledge — `retired-fields.mjs` and `homepage.mjs` — and this module only
|
|
48
|
+
* locates it in the file.
|
|
49
|
+
*
|
|
43
50
|
* **It takes a built link index rather than walking itself.** The dead-
|
|
44
51
|
* reference check has to resolve exactly as a wikilink does, cross-package
|
|
45
52
|
* manifests and all, and the way to guarantee that is to call the same
|
|
@@ -50,6 +57,7 @@
|
|
|
50
57
|
|
|
51
58
|
import { authoredFields } from "./field-spec.mjs";
|
|
52
59
|
import { positionInFrontmatter } from "./diagnostics.mjs";
|
|
60
|
+
import { checkHomepageAddressFields } from "./homepage.mjs";
|
|
53
61
|
import { RETIRED_TYPES } from "./ids.mjs";
|
|
54
62
|
import { draftRetiredMessage } from "./retired-fields.mjs";
|
|
55
63
|
|
|
@@ -237,6 +245,20 @@ export function lintNote(note, { schemas, index }) {
|
|
|
237
245
|
});
|
|
238
246
|
}
|
|
239
247
|
|
|
248
|
+
// A homepage's address is its package's, so the top-level fields that
|
|
249
|
+
// decide an address decide nothing on it (#53). Reported beside the retired
|
|
250
|
+
// fields above because it is the same kind of statement — a top-level key
|
|
251
|
+
// this note may not write — and, like them, it must survive the two early
|
|
252
|
+
// returns below: the finding stands whatever else the type is.
|
|
253
|
+
for (const { key, message } of checkHomepageAddressFields(fm)) {
|
|
254
|
+
findings.push({
|
|
255
|
+
file: note.file,
|
|
256
|
+
...at(key),
|
|
257
|
+
severity: "error",
|
|
258
|
+
message,
|
|
259
|
+
});
|
|
260
|
+
}
|
|
261
|
+
|
|
240
262
|
const replacement = RETIRED_TYPES[type];
|
|
241
263
|
if (replacement) {
|
|
242
264
|
findings.push({
|