@heroiclands/package-build 6.0.0 → 7.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +798 -0
- package/CONTENT.md +228 -4
- package/bin/content-build.mjs +196 -10
- package/bin/package-build.mjs +8 -1
- package/config.mjs +25 -3
- package/content-config.mjs +283 -29
- package/engine/address-diff.mjs +290 -0
- package/engine/base-compiler.mjs +25 -0
- package/engine/content-links.mjs +132 -27
- package/engine/content-lint.mjs +23 -2
- package/engine/diagnostics.mjs +61 -1
- package/engine/frontmatter-lint.mjs +22 -0
- package/engine/generate.mjs +10 -5
- package/engine/helpers.mjs +38 -0
- package/engine/homepage.mjs +206 -2
- package/engine/journals.mjs +8 -1
- package/engine/macros.mjs +2 -0
- package/engine/pack-config.mjs +143 -13
- package/engine/prose-lint.mjs +10 -2
- package/engine/scenes.mjs +2 -2
- package/engine/site-build.mjs +74 -19
- package/engine/web-wikilinks.mjs +13 -4
- package/engine/wikilink-syntax.mjs +25 -0
- package/engine/wikilinks.mjs +6 -3
- package/manifest.mjs +37 -2
- package/package.json +5 -3
- package/sohl/actors.mjs +23 -10
- package/sohl/items.mjs +1 -1
- package/types/content-config.d.mts +14 -0
- package/types/engine/address-diff.d.mts +108 -0
- package/types/engine/base-compiler.d.mts +18 -1
- package/types/engine/content-links.d.mts +11 -3
- package/types/engine/content-lint.d.mts +4 -1
- package/types/engine/diagnostics.d.mts +33 -1
- package/types/engine/generate.d.mts +3 -2
- package/types/engine/helpers.d.mts +29 -3
- package/types/engine/homepage.d.mts +117 -0
- package/types/engine/journals.d.mts +7 -1
- package/types/engine/pack-config.d.mts +22 -0
- package/types/engine/prose-lint.d.mts +10 -2
- package/types/engine/site-build.d.mts +28 -3
- package/types/engine/wikilink-syntax.d.mts +24 -0
- package/types/sohl/actors.d.mts +3 -3
|
@@ -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
|
@@ -73,6 +73,7 @@ import {
|
|
|
73
73
|
convertNoteWikilinks,
|
|
74
74
|
collectContentDocs,
|
|
75
75
|
expandNoteTables,
|
|
76
|
+
statsForPack,
|
|
76
77
|
} from "./helpers.mjs";
|
|
77
78
|
import { emitDiagnostic } from "./diagnostics.mjs";
|
|
78
79
|
import { assertNoDeclaredPackage } from "./note-package.mjs";
|
|
@@ -237,6 +238,7 @@ export class BasePackCompiler {
|
|
|
237
238
|
dest,
|
|
238
239
|
folderResolver = () => null,
|
|
239
240
|
packName,
|
|
241
|
+
packSystem = null,
|
|
240
242
|
docType,
|
|
241
243
|
router,
|
|
242
244
|
routingReporter = false,
|
|
@@ -262,11 +264,34 @@ export class BasePackCompiler {
|
|
|
262
264
|
writable: false,
|
|
263
265
|
});
|
|
264
266
|
this.packName = packName;
|
|
267
|
+
this.packSystem = packSystem;
|
|
265
268
|
this.docType = docType;
|
|
266
269
|
this.router = router;
|
|
267
270
|
this.routingReporter = routingReporter;
|
|
268
271
|
}
|
|
269
272
|
|
|
273
|
+
/**
|
|
274
|
+
* The `_stats` block every entry this pass emits is stamped with (#48).
|
|
275
|
+
*
|
|
276
|
+
* Per pack rather than per package, because a module may ship the same
|
|
277
|
+
* content for two systems — `harn-ensemble` has an `actors-hm3` pack and an
|
|
278
|
+
* `actors-sohl` pack — and those documents were built against different
|
|
279
|
+
* system versions. A single global block stamped both identically.
|
|
280
|
+
*
|
|
281
|
+
* Memoised on the instance: one pass, one pack, one system, so the block is
|
|
282
|
+
* constant for the life of the compiler. The previous module-level memo
|
|
283
|
+
* could not be, because it was shared across passes for different packs.
|
|
284
|
+
*
|
|
285
|
+
* @returns {object} The block, built once per compiler.
|
|
286
|
+
*/
|
|
287
|
+
get stats() {
|
|
288
|
+
this.#stats ??= statsForPack(this.packSystem);
|
|
289
|
+
return this.#stats;
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
/** @type {object|undefined} */
|
|
293
|
+
#stats;
|
|
294
|
+
|
|
270
295
|
/**
|
|
271
296
|
* Whether this pass's pack is the one a claimed note belongs in.
|
|
272
297
|
*
|
package/engine/content-links.mjs
CHANGED
|
@@ -58,6 +58,7 @@ import {
|
|
|
58
58
|
canonicalKey,
|
|
59
59
|
loadForeignManifests,
|
|
60
60
|
manifestsComplete,
|
|
61
|
+
PACKAGE_BASE,
|
|
61
62
|
readCanonicalKey,
|
|
62
63
|
} from "./kb-manifest.mjs";
|
|
63
64
|
import { frontmatterWikilinks, slugify } from "./web-wikilinks.mjs";
|
|
@@ -336,6 +337,74 @@ export function buildLinkIndex(
|
|
|
336
337
|
*/
|
|
337
338
|
const SITE_HOST = /^(?:[a-z0-9-]+\.)*heroiclands\.org$/i;
|
|
338
339
|
|
|
340
|
+
/**
|
|
341
|
+
* Every package landing this build can name, as `package` → base (#87).
|
|
342
|
+
*
|
|
343
|
+
* **A landing needs no manifest, and that is what makes it work.** The link
|
|
344
|
+
* manifest indexes content notes, and a homepage is deliberately not one — it
|
|
345
|
+
* compiles to no document and is entered in no manifest. The reading that
|
|
346
|
+
* follows from this, and that left a hardcoded URL as the only authored form,
|
|
347
|
+
* is that a landing therefore cannot be addressed. It does not follow: a
|
|
348
|
+
* landing's address is not a *note's* address but the **package's**, and
|
|
349
|
+
* {@link PACKAGE_BASE} already records where each package is served. That is a
|
|
350
|
+
* frozen constant vendored into every repository, so consulting it walks no
|
|
351
|
+
* tree, reads no manifest and builds no index — which is precisely why the
|
|
352
|
+
* mechanism survives `homepage` mode, where the licensing fence means none of
|
|
353
|
+
* those exist.
|
|
354
|
+
*
|
|
355
|
+
* The roster is consulted **for landings only**. Widening the package set the
|
|
356
|
+
* other rules read would make them offer manifest-based advice about packages
|
|
357
|
+
* no manifest is vendored for.
|
|
358
|
+
*
|
|
359
|
+
* @param {string} ownPackage - The package this build publishes.
|
|
360
|
+
* @param {Iterable<string>} manifestPackages - Packages a vendored manifest
|
|
361
|
+
* names, which are addressable whether or not the roster lists them.
|
|
362
|
+
* @returns {Map<string, string>} Package to base, each base slash-terminated.
|
|
363
|
+
*/
|
|
364
|
+
function landingBases(ownPackage, manifestPackages) {
|
|
365
|
+
const bases = new Map();
|
|
366
|
+
// Convention first, roster second, so a package the roster relocates is
|
|
367
|
+
// recorded at the relocated base rather than the default one.
|
|
368
|
+
for (const pkg of [ownPackage, ...manifestPackages]) {
|
|
369
|
+
if (pkg) bases.set(pkg, `/${pkg}/`);
|
|
370
|
+
}
|
|
371
|
+
for (const [pkg, base] of Object.entries(PACKAGE_BASE)) {
|
|
372
|
+
if (typeof base === "string" && base.endsWith("/")) {
|
|
373
|
+
bases.set(pkg, base);
|
|
374
|
+
}
|
|
375
|
+
}
|
|
376
|
+
return bases;
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
/**
|
|
380
|
+
* The package whose landing an address names, or `null`.
|
|
381
|
+
*
|
|
382
|
+
* Matches the whole path, not a prefix: `/sohl/` is the landing, `/sohl/kb/`
|
|
383
|
+
* is a page inside the package and belongs to the manifest rules instead.
|
|
384
|
+
*
|
|
385
|
+
* @param {string} url - The authored address.
|
|
386
|
+
* @param {Map<string, string>} bases - From {@link landingBases}.
|
|
387
|
+
* @returns {{pkg: string, base: string}|null} The package and its base.
|
|
388
|
+
*/
|
|
389
|
+
function landingTarget(url, bases) {
|
|
390
|
+
const value = String(url ?? "").trim();
|
|
391
|
+
if (!value || !/^[a-z][a-z0-9+.-]*:/i.test(value)) return null;
|
|
392
|
+
let parsed;
|
|
393
|
+
try {
|
|
394
|
+
parsed = new URL(value);
|
|
395
|
+
} catch {
|
|
396
|
+
return null;
|
|
397
|
+
}
|
|
398
|
+
if (!/^https?:$/.test(parsed.protocol)) return null;
|
|
399
|
+
if (!SITE_HOST.test(parsed.hostname)) return null;
|
|
400
|
+
const pathname =
|
|
401
|
+
parsed.pathname.endsWith("/") ? parsed.pathname : `${parsed.pathname}/`;
|
|
402
|
+
for (const [pkg, base] of bases) {
|
|
403
|
+
if (pathname === base) return { pkg, base };
|
|
404
|
+
}
|
|
405
|
+
return null;
|
|
406
|
+
}
|
|
407
|
+
|
|
339
408
|
/**
|
|
340
409
|
* How an authored address resolves, or `null` for one nothing here can judge.
|
|
341
410
|
*
|
|
@@ -400,9 +469,17 @@ function readAddress(url, packages) {
|
|
|
400
469
|
* and what replaced it, so this is a fact rather than a guess — and it is
|
|
401
470
|
* exactly the SoHL defect.
|
|
402
471
|
* - A **hardcoded absolute URL** into this package's own prefix, or into one a
|
|
403
|
-
* vendored manifest names.
|
|
404
|
-
*
|
|
405
|
-
*
|
|
472
|
+
* vendored manifest names. Every one of them has a better form to write, which
|
|
473
|
+
* is why every one is reported — including a bare `/<package>/`, which names
|
|
474
|
+
* another package's landing (#87).
|
|
475
|
+
*
|
|
476
|
+
* That last case was exempt until the better form was identified, on the
|
|
477
|
+
* reasoning that a landing is in no link manifest so nothing could resolve it.
|
|
478
|
+
* True, and beside the point: it does not need resolving. A landing's address
|
|
479
|
+
* *is* its package prefix, so `/<package>/` is the absolute URL with the host
|
|
480
|
+
* struck off — host-free, emitted verbatim, and needing no index, which is
|
|
481
|
+
* what lets it hold in homepage-only mode where the tree is never walked. The
|
|
482
|
+
* form was already accepted here; nothing had ever named it as the one to use.
|
|
406
483
|
* - A **root-relative `url:`**, which the theme's `relURL` prefixes a second
|
|
407
484
|
* time. `href:` means "already resolved, use verbatim", so the same leading
|
|
408
485
|
* slash is correct there and is not reported.
|
|
@@ -424,6 +501,7 @@ function readAddress(url, packages) {
|
|
|
424
501
|
export function auditHomepageLinks(index) {
|
|
425
502
|
const findings = [];
|
|
426
503
|
const packages = new Set([index.contentPackage, ...index.packages]);
|
|
504
|
+
const bases = landingBases(index.contentPackage, index.packages);
|
|
427
505
|
|
|
428
506
|
for (const note of index.notes) {
|
|
429
507
|
if (!isHomepage(note.fm)) continue;
|
|
@@ -467,28 +545,43 @@ export function auditHomepageLinks(index) {
|
|
|
467
545
|
if (!address) continue;
|
|
468
546
|
const { shape, segments, prefix } = address;
|
|
469
547
|
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
548
|
+
// Landings first, and by the roster rather than by the manifest
|
|
549
|
+
// package set: a landing is addressable in a repository that
|
|
550
|
+
// vendors no manifest at all, which is the case the fence creates
|
|
551
|
+
// and the case this rule exists for (#87).
|
|
552
|
+
const landing = landingTarget(url, bases);
|
|
553
|
+
if (landing) {
|
|
554
|
+
report(
|
|
555
|
+
field,
|
|
556
|
+
url,
|
|
557
|
+
url,
|
|
558
|
+
occurrence,
|
|
559
|
+
`hardcoded absolute URL to ` +
|
|
560
|
+
(landing.pkg === index.contentPackage ?
|
|
561
|
+
`this package's own landing`
|
|
562
|
+
: `package "${landing.pkg}"'s landing`) +
|
|
563
|
+
` — write "${landing.base}", which names no host, is ` +
|
|
564
|
+
`emitted verbatim, and resolves through the package ` +
|
|
565
|
+
`roster rather than through an index, so it holds ` +
|
|
566
|
+
`where no content tree is walked`,
|
|
567
|
+
);
|
|
568
|
+
} else if (shape === "absolute" && prefix) {
|
|
474
569
|
const rest = segments.slice(1).join("/");
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
`
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
);
|
|
491
|
-
}
|
|
570
|
+
report(
|
|
571
|
+
field,
|
|
572
|
+
url,
|
|
573
|
+
url,
|
|
574
|
+
occurrence,
|
|
575
|
+
prefix === index.contentPackage ?
|
|
576
|
+
`hardcoded absolute URL into this package's own ` +
|
|
577
|
+
`address — write the package-relative ` +
|
|
578
|
+
`"${rest}/", which the landing resolves ` +
|
|
579
|
+
`against the site so the page follows the mount`
|
|
580
|
+
: `hardcoded absolute URL into package "${prefix}" ` +
|
|
581
|
+
`— resolve it through that package's link ` +
|
|
582
|
+
`manifest, whose entries carry the address, so a ` +
|
|
583
|
+
`relocation does not leave this page behind`,
|
|
584
|
+
);
|
|
492
585
|
} else if (shape === "rooted" && kind === "url") {
|
|
493
586
|
const rest =
|
|
494
587
|
prefix ? segments.slice(1).join("/") : segments.join("/");
|
|
@@ -497,9 +590,21 @@ export function auditHomepageLinks(index) {
|
|
|
497
590
|
url,
|
|
498
591
|
url,
|
|
499
592
|
occurrence,
|
|
500
|
-
`url
|
|
501
|
-
|
|
502
|
-
|
|
593
|
+
// A `url:` is package-relative by construction, so it
|
|
594
|
+
// cannot address anything outside this package at all —
|
|
595
|
+
// there is no relative spelling of another package's root.
|
|
596
|
+
// `href:` is the field for an address already resolved.
|
|
597
|
+
!rest ?
|
|
598
|
+
`url "${url}" addresses ` +
|
|
599
|
+
(prefix ?
|
|
600
|
+
`package "${prefix}"'s landing`
|
|
601
|
+
: `the site root`) +
|
|
602
|
+
`, but a landing's url: is package-relative and ` +
|
|
603
|
+
`cannot leave this package — write ` +
|
|
604
|
+
`href: "${url}", which is used verbatim`
|
|
605
|
+
: `url "${url}" is root-relative, but a landing's url: ` +
|
|
606
|
+
`is resolved against the site — write "${rest}/", ` +
|
|
607
|
+
`or href: for an address that is already resolved`,
|
|
503
608
|
);
|
|
504
609
|
}
|
|
505
610
|
|
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
|