@heroiclands/package-build 5.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 +503 -0
- package/CONTENT.md +343 -16
- package/MIGRATING.md +41 -0
- package/README.md +30 -0
- package/bin/content-build.mjs +188 -4
- package/bin/package-build.mjs +3 -1
- package/content-config.mjs +29 -7
- package/engine/address-diff.mjs +290 -0
- package/engine/base-compiler.mjs +28 -3
- package/engine/content-links.mjs +221 -2
- package/engine/content-lint.mjs +35 -5
- package/engine/diagnostics.mjs +46 -0
- package/engine/frontmatter-lint.mjs +22 -0
- package/engine/generate.mjs +156 -7
- package/engine/homepage.mjs +315 -2
- package/engine/pack-config.mjs +21 -0
- package/engine/site-build.mjs +84 -23
- package/manifest.mjs +195 -0
- package/package.json +1 -1
- package/sohl/actors.mjs +14 -1
- package/sohl/item-fields.mjs +0 -5
- package/sohl/kb-passes.mjs +81 -14
- package/types/engine/address-diff.d.mts +108 -0
- package/types/engine/base-compiler.d.mts +22 -0
- package/types/engine/content-links.d.mts +53 -2
- package/types/engine/content-lint.d.mts +4 -1
- package/types/engine/diagnostics.d.mts +28 -0
- package/types/engine/generate.d.mts +50 -2
- package/types/engine/homepage.d.mts +166 -42
- package/types/engine/pack-config.d.mts +13 -0
- package/types/engine/site-build.d.mts +38 -7
- package/types/manifest.d.mts +67 -1
- package/types/sohl/kb-passes.d.mts +5 -1
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.
|
|
@@ -366,7 +380,8 @@ function lintCommand() {
|
|
|
366
380
|
} else {
|
|
367
381
|
log.info(
|
|
368
382
|
`Addresses and frontmatter are well-formed ` +
|
|
369
|
-
`(${addresses.keys} across
|
|
383
|
+
`(${addresses.keys} address(es) across ` +
|
|
384
|
+
`${addresses.notes} note(s)).`,
|
|
370
385
|
);
|
|
371
386
|
}
|
|
372
387
|
} catch (err) {
|
|
@@ -582,6 +597,7 @@ function linksCommand() {
|
|
|
582
597
|
deadAnchors,
|
|
583
598
|
deadAddresses,
|
|
584
599
|
frontmatterLinks,
|
|
600
|
+
homepageLinks,
|
|
585
601
|
usedManifest,
|
|
586
602
|
} = auditLinks(index);
|
|
587
603
|
|
|
@@ -614,10 +630,24 @@ function linksCommand() {
|
|
|
614
630
|
});
|
|
615
631
|
}
|
|
616
632
|
|
|
633
|
+
// The package homepage. Its addresses are markdown links and
|
|
634
|
+
// `landing:` url/href fields rather than wikilinks — it is
|
|
635
|
+
// published verbatim, so nothing resolves a wikilink on it —
|
|
636
|
+
// and until #54 nothing looked at them at all.
|
|
637
|
+
for (const h of homepageLinks) {
|
|
638
|
+
emitDiagnostic({
|
|
639
|
+
file: h.note.file,
|
|
640
|
+
...positionOfLiteral(h.note.raw, h.text, h.occurrence),
|
|
641
|
+
severity: "error",
|
|
642
|
+
message: `${h.field}: ${h.message}`,
|
|
643
|
+
});
|
|
644
|
+
}
|
|
645
|
+
|
|
617
646
|
const failures =
|
|
618
647
|
deadAnchors.length +
|
|
619
648
|
deadAddresses.length +
|
|
620
|
-
frontmatterLinks.length
|
|
649
|
+
frontmatterLinks.length +
|
|
650
|
+
homepageLinks.length;
|
|
621
651
|
if (failures) {
|
|
622
652
|
log.error(
|
|
623
653
|
`${failures} link problem(s) across ${index.notes.length} note(s).`,
|
|
@@ -628,7 +658,8 @@ function linksCommand() {
|
|
|
628
658
|
`${index.notes.length} notes: every anchor link lands ` +
|
|
629
659
|
`and every qualified address resolves ` +
|
|
630
660
|
`(${usedManifest.size} cross-package reference(s) ` +
|
|
631
|
-
`via manifest), no wikilink in frontmatter
|
|
661
|
+
`via manifest), no wikilink in frontmatter, ` +
|
|
662
|
+
`every homepage address resolvable.`,
|
|
632
663
|
);
|
|
633
664
|
}
|
|
634
665
|
} catch (err) {
|
|
@@ -750,6 +781,11 @@ function siteCommand() {
|
|
|
750
781
|
});
|
|
751
782
|
const { gates } = result;
|
|
752
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);
|
|
753
789
|
for (const f of gates.frontmatterLinks) {
|
|
754
790
|
emitDiagnostic({
|
|
755
791
|
file: f.file,
|
|
@@ -1023,6 +1059,154 @@ function depsCommand() {
|
|
|
1023
1059
|
};
|
|
1024
1060
|
}
|
|
1025
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
|
+
|
|
1026
1210
|
function packageCommand() {
|
|
1027
1211
|
return {
|
|
1028
1212
|
command: "package <action> [pack] [entry]",
|
package/bin/package-build.mjs
CHANGED
|
@@ -78,7 +78,7 @@ import yargs from "yargs";
|
|
|
78
78
|
import { hideBin } from "yargs/helpers";
|
|
79
79
|
|
|
80
80
|
import { loadPackageBuildConfig } from "../config.mjs";
|
|
81
|
-
import { loadPackConfig } from "../engine/pack-config.mjs";
|
|
81
|
+
import { loadPackConfig, packConfigPath } from "../engine/pack-config.mjs";
|
|
82
82
|
import { cleanBuildArtifacts, stageAssets } from "../stage.mjs";
|
|
83
83
|
import { validateLangSource } from "../lang.mjs";
|
|
84
84
|
import {
|
|
@@ -354,6 +354,8 @@ function manifestCommand() {
|
|
|
354
354
|
artifact: config.artifact,
|
|
355
355
|
outDir: path.join(config.rootDir, config.stageDir),
|
|
356
356
|
flags,
|
|
357
|
+
// So a `packFolders` finding names the line it is about.
|
|
358
|
+
configFile: packConfigPath(),
|
|
357
359
|
});
|
|
358
360
|
console.log(
|
|
359
361
|
`✅ Wrote ${path.relative(config.rootDir, written)} ` +
|
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/base-compiler.mjs
CHANGED
|
@@ -34,9 +34,11 @@
|
|
|
34
34
|
* | {@link BasePackCompiler#finish} | Work that needs every note first. |
|
|
35
35
|
* | {@link BasePackCompiler#reportCompiled} / {@link BasePackCompiler#reportDetail} | The pass's own log lines. |
|
|
36
36
|
*
|
|
37
|
-
* plus
|
|
38
|
-
* merely skipped)
|
|
39
|
-
* `buildEntry` is converted or exactly as authored)
|
|
37
|
+
* plus three static switches — `requiresId` (a note with no id is fatal, or
|
|
38
|
+
* merely skipped), `convertsWikilinks` (whether the body reaching
|
|
39
|
+
* `buildEntry` is converted or exactly as authored) and `readsPackOutputOf`
|
|
40
|
+
* (the document types whose compiled output this pass reads, which is what the
|
|
41
|
+
* generator derives the compile order from).
|
|
40
42
|
*
|
|
41
43
|
* `selects` answers *which document type* a pass claims, and it is the same
|
|
42
44
|
* answer for every pack of that type. Which **pack of that type** a claimed
|
|
@@ -141,6 +143,29 @@ export class BasePackCompiler {
|
|
|
141
143
|
*/
|
|
142
144
|
static convertsWikilinks = true;
|
|
143
145
|
|
|
146
|
+
/**
|
|
147
|
+
* The document types whose **compiled output** this pass reads.
|
|
148
|
+
*
|
|
149
|
+
* Empty for every pass that reads only the content tree. The actors pass
|
|
150
|
+
* is the exception: a being names its embedded items by
|
|
151
|
+
* `(type, shortcode)`, and it resolves them against the JSON the item
|
|
152
|
+
* passes wrote — so an Actor pass must run after every Item pass, and it
|
|
153
|
+
* says so here.
|
|
154
|
+
*
|
|
155
|
+
* The generator derives the compile order from this (#73), so the order
|
|
156
|
+
* `packs:` declares is presentation only — it is the manifest's `packs`
|
|
157
|
+
* array as well, and a consumer orders that for a reader. A pass that
|
|
158
|
+
* reads another's output states the dependency once, in the class that
|
|
159
|
+
* does the reading, instead of every consuming repository having to know
|
|
160
|
+
* it when writing its pack list.
|
|
161
|
+
*
|
|
162
|
+
* A consumer registering a compiler of its own declares its dependencies
|
|
163
|
+
* the same way; a type no pack declares is simply not waited for.
|
|
164
|
+
*
|
|
165
|
+
* @type {readonly string[]}
|
|
166
|
+
*/
|
|
167
|
+
static readsPackOutputOf = Object.freeze([]);
|
|
168
|
+
|
|
144
169
|
/** @type {string} */
|
|
145
170
|
contentBase;
|
|
146
171
|
/** @type {string} */
|