@heroiclands/package-build 4.0.0 → 6.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 +401 -0
- package/CONTENT.md +207 -23
- package/MIGRATING.md +114 -0
- package/README.md +30 -0
- package/bin/content-build.mjs +26 -5
- package/bin/package-build.mjs +3 -1
- package/content-config.mjs +109 -6
- package/engine/base-compiler.mjs +28 -3
- package/engine/content-links.mjs +221 -2
- package/engine/content-lint.mjs +12 -3
- package/engine/diagnostics.mjs +46 -0
- package/engine/generate.mjs +156 -7
- package/engine/homepage.mjs +259 -0
- package/engine/index.mjs +6 -0
- package/engine/manifest-emit.mjs +2 -1
- package/engine/note-schemas.mjs +44 -0
- package/engine/pack-config.mjs +21 -0
- package/engine/site-build.mjs +140 -4
- 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/sohl/note-schemas.mjs +8 -0
- package/types/content-config.d.mts +105 -4
- package/types/engine/base-compiler.d.mts +22 -0
- package/types/engine/content-links.d.mts +53 -2
- package/types/engine/diagnostics.d.mts +28 -0
- package/types/engine/generate.d.mts +50 -2
- package/types/engine/homepage.d.mts +125 -0
- package/types/engine/index.d.mts +2 -0
- package/types/engine/note-schemas.d.mts +6 -0
- package/types/engine/pack-config.d.mts +13 -0
- package/types/engine/site-build.d.mts +54 -0
- package/types/manifest.d.mts +67 -1
- package/types/sohl/kb-passes.d.mts +5 -1
- package/types/sohl/note-schemas.d.mts +6 -0
package/content-config.mjs
CHANGED
|
@@ -33,7 +33,7 @@
|
|
|
33
33
|
* assets:
|
|
34
34
|
* - { from: assets/icons, to: assets/icons }
|
|
35
35
|
* publish:
|
|
36
|
-
* site:
|
|
36
|
+
* site: content
|
|
37
37
|
* manifests: { publish: true, consume: true }
|
|
38
38
|
* ```
|
|
39
39
|
*
|
|
@@ -153,6 +153,62 @@ export const DEFAULT_ADDRESS_SCHEME = Object.freeze({
|
|
|
153
153
|
landing: "readme",
|
|
154
154
|
});
|
|
155
155
|
|
|
156
|
+
/**
|
|
157
|
+
* How much of a package reaches the web.
|
|
158
|
+
*
|
|
159
|
+
* Every HeroicLands package publishes something: a top-level, human-authored
|
|
160
|
+
* homepage at `https://www.heroiclands.org/<contentPackage>/` saying what the
|
|
161
|
+
* module is, which system it needs and how to install it (#50). So there is no
|
|
162
|
+
* value here meaning *no web presence at all* — homepage-only is the **floor**,
|
|
163
|
+
* and the default.
|
|
164
|
+
*
|
|
165
|
+
* - `homepage` — the authored homepage, and **no other page**. The content tree
|
|
166
|
+
* is not walked for pages, `site.sections` / `site.trees` / `site.landing`
|
|
167
|
+
* emit nothing, and link-manifest entries carry no web `path`.
|
|
168
|
+
* - `content` — the homepage *plus* every page the content tree publishes: the
|
|
169
|
+
* knowledgebase, the extra trees, the section landings.
|
|
170
|
+
*
|
|
171
|
+
* **Homepage-only is a first-class mode, not an accommodation.**
|
|
172
|
+
* `sohl-kethira-basic` (unofficial Hârn fan material under Keléstia Productions'
|
|
173
|
+
* Fan Material Guidelines) and `harn-adventures` (HârnFanon under Lythia's
|
|
174
|
+
* terms) must each publish a homepage and nothing beneath it — two packages
|
|
175
|
+
* under two different fan-content licences. The boundary is **published
|
|
176
|
+
* content**: journal text, artwork, item descriptions, compiled notes. A
|
|
177
|
+
* human-authored page announcing the module discloses none of it. Because the
|
|
178
|
+
* failure mode is silent — a `site:` block added later ships licensed content
|
|
179
|
+
* with nobody noticing — the mode fences the content surfaces off rather than
|
|
180
|
+
* trusting a configuration to stay empty.
|
|
181
|
+
*
|
|
182
|
+
* This was a boolean until 5.0.0, and `false` read as "no web presence", which
|
|
183
|
+
* no longer describes any package. Both spellings are refused rather than
|
|
184
|
+
* mapped: a value silently reinterpreted reads to its author as though it still
|
|
185
|
+
* means what it said.
|
|
186
|
+
*
|
|
187
|
+
* @typedef {"homepage" | "content"} SiteMode
|
|
188
|
+
*/
|
|
189
|
+
|
|
190
|
+
/**
|
|
191
|
+
* The publishing modes {@link PublishSwitches.site} may name, floor first.
|
|
192
|
+
*
|
|
193
|
+
* @satisfies {readonly SiteMode[]}
|
|
194
|
+
*/
|
|
195
|
+
export const SITE_MODES = /** @type {const} */ (["homepage", "content"]);
|
|
196
|
+
|
|
197
|
+
/**
|
|
198
|
+
* Whether this package publishes the pages its content tree compiles to.
|
|
199
|
+
*
|
|
200
|
+
* The one question every reader of the mode actually asks — the site build, to
|
|
201
|
+
* decide whether to walk the tree at all, and the link-manifest emitter, to
|
|
202
|
+
* decide whether an entry carries a web `path`. Written once here so the two
|
|
203
|
+
* cannot come to disagree about what a mode means.
|
|
204
|
+
*
|
|
205
|
+
* @param {{publish: {site: SiteMode}}} config - A resolved configuration.
|
|
206
|
+
* @returns {boolean} Whether content pages are published.
|
|
207
|
+
*/
|
|
208
|
+
export function publishesContentPages(config) {
|
|
209
|
+
return config.publish.site === "content";
|
|
210
|
+
}
|
|
211
|
+
|
|
156
212
|
/**
|
|
157
213
|
* @typedef {"systems" | "modules"} PackageKind
|
|
158
214
|
*/
|
|
@@ -300,7 +356,8 @@ export const DEFAULT_ADDRESS_SCHEME = Object.freeze({
|
|
|
300
356
|
|
|
301
357
|
/**
|
|
302
358
|
* @typedef {object} PublishSwitches
|
|
303
|
-
* @property {
|
|
359
|
+
* @property {SiteMode} site How much of this package reaches the web.
|
|
360
|
+
* See {@link SITE_MODES}.
|
|
304
361
|
* @property {ManifestSwitches} manifests
|
|
305
362
|
*/
|
|
306
363
|
|
|
@@ -387,8 +444,15 @@ export const DEFAULT_ADDRESS_SCHEME = Object.freeze({
|
|
|
387
444
|
|
|
388
445
|
/**
|
|
389
446
|
* @typedef {object} PublishSwitchesInput
|
|
390
|
-
* @property {
|
|
447
|
+
* @property {SiteMode} [site]
|
|
391
448
|
* @property {ManifestSwitchesInput} [manifests]
|
|
449
|
+
* @property {AddressSchemeInput} [address]
|
|
450
|
+
*/
|
|
451
|
+
|
|
452
|
+
/**
|
|
453
|
+
* @typedef {object} AddressSchemeInput
|
|
454
|
+
* @property {string} [prefix] Where the content tree mounts inside the package.
|
|
455
|
+
* @property {string} [landing] Which note addresses a whole section.
|
|
392
456
|
*/
|
|
393
457
|
|
|
394
458
|
/**
|
|
@@ -454,7 +518,9 @@ export const DEFAULT_ADDRESS_SCHEME = Object.freeze({
|
|
|
454
518
|
* which has none to invent.
|
|
455
519
|
* @property {Relationships} [relationships] What this package declares about
|
|
456
520
|
* others, in Foundry's own shape.
|
|
457
|
-
* @property {PublishSwitchesInput} [publish] Publishing switches.
|
|
521
|
+
* @property {PublishSwitchesInput} [publish] Publishing switches. The manifest
|
|
522
|
+
* switches default to off; `site`
|
|
523
|
+
* defaults to `homepage`, the floor.
|
|
458
524
|
*/
|
|
459
525
|
|
|
460
526
|
/**
|
|
@@ -1272,6 +1338,43 @@ function normalizeItemBuilders(value) {
|
|
|
1272
1338
|
};
|
|
1273
1339
|
}
|
|
1274
1340
|
|
|
1341
|
+
/**
|
|
1342
|
+
* The publishing mode, refusing the boolean this setting used to be.
|
|
1343
|
+
*
|
|
1344
|
+
* A boolean is refused rather than mapped onto the nearest mode, because the
|
|
1345
|
+
* reading `false` invited — *this package has no web presence* — is exactly the
|
|
1346
|
+
* belief the change exists to correct, and a value quietly reinterpreted reads
|
|
1347
|
+
* to its author as though it still means what it said. So the message names the
|
|
1348
|
+
* mode to write instead of the value to fix.
|
|
1349
|
+
*
|
|
1350
|
+
* @param {unknown} value - The authored `publish.site`.
|
|
1351
|
+
* @returns {SiteMode} The mode.
|
|
1352
|
+
*/
|
|
1353
|
+
function normalizeSiteMode(value) {
|
|
1354
|
+
if (value === undefined) return "homepage";
|
|
1355
|
+
if (typeof value === "boolean") {
|
|
1356
|
+
fail(
|
|
1357
|
+
"publish.site",
|
|
1358
|
+
`is no longer a boolean — write \`site: ${value ? "content" : "homepage"}\`. ` +
|
|
1359
|
+
`Every package publishes an authored homepage at ` +
|
|
1360
|
+
`/<contentPackage>/, so no value means "no web presence": ` +
|
|
1361
|
+
`\`homepage\` publishes that page and nothing else, and ` +
|
|
1362
|
+
`\`content\` publishes it plus every page the content tree ` +
|
|
1363
|
+
`compiles to`,
|
|
1364
|
+
);
|
|
1365
|
+
}
|
|
1366
|
+
if (
|
|
1367
|
+
typeof value !== "string" ||
|
|
1368
|
+
!(/** @type {readonly string[]} */ (SITE_MODES).includes(value))
|
|
1369
|
+
) {
|
|
1370
|
+
fail(
|
|
1371
|
+
"publish.site",
|
|
1372
|
+
`must be one of ${SITE_MODES.join(", ")} (got ${JSON.stringify(value)})`,
|
|
1373
|
+
);
|
|
1374
|
+
}
|
|
1375
|
+
return /** @type {SiteMode} */ (value);
|
|
1376
|
+
}
|
|
1377
|
+
|
|
1275
1378
|
/**
|
|
1276
1379
|
* @param {unknown} value
|
|
1277
1380
|
* @returns {Readonly<PublishSwitches>}
|
|
@@ -1279,7 +1382,7 @@ function normalizeItemBuilders(value) {
|
|
|
1279
1382
|
function normalizePublish(value) {
|
|
1280
1383
|
if (value === undefined) {
|
|
1281
1384
|
return Object.freeze({
|
|
1282
|
-
site:
|
|
1385
|
+
site: "homepage",
|
|
1283
1386
|
manifests: Object.freeze({ publish: false, consume: false }),
|
|
1284
1387
|
address: Object.freeze({ ...DEFAULT_ADDRESS_SCHEME }),
|
|
1285
1388
|
});
|
|
@@ -1332,7 +1435,7 @@ function normalizePublish(value) {
|
|
|
1332
1435
|
}
|
|
1333
1436
|
|
|
1334
1437
|
return Object.freeze({
|
|
1335
|
-
site:
|
|
1438
|
+
site: normalizeSiteMode(publish.site),
|
|
1336
1439
|
address: Object.freeze({ prefix, landing }),
|
|
1337
1440
|
manifests: Object.freeze({
|
|
1338
1441
|
publish: optionalBoolean(
|
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} */
|
package/engine/content-links.mjs
CHANGED
|
@@ -61,6 +61,8 @@ import {
|
|
|
61
61
|
readCanonicalKey,
|
|
62
62
|
} from "./kb-manifest.mjs";
|
|
63
63
|
import { frontmatterWikilinks, slugify } from "./web-wikilinks.mjs";
|
|
64
|
+
import { homepageAddresses, isHomepage } from "./homepage.mjs";
|
|
65
|
+
import { RETIRED_TYPES } from "./ids.mjs";
|
|
64
66
|
import { parseWikilink, WIKILINK } from "./wikilink-syntax.mjs";
|
|
65
67
|
import { readQualifier } from "./wikilinks.mjs";
|
|
66
68
|
|
|
@@ -305,6 +307,12 @@ export function buildLinkIndex(
|
|
|
305
307
|
anchors,
|
|
306
308
|
types,
|
|
307
309
|
packages,
|
|
310
|
+
/**
|
|
311
|
+
* The one package this tree publishes. Distinct from `packages`, which
|
|
312
|
+
* is the set an address may name and which a homepage-only tree leaves
|
|
313
|
+
* this package out of, having no keyed note to put it there.
|
|
314
|
+
*/
|
|
315
|
+
contentPackage: pkg,
|
|
308
316
|
foreign,
|
|
309
317
|
manifests: manifestsComplete(localPackages, foreign.packages),
|
|
310
318
|
linksOf,
|
|
@@ -315,13 +323,223 @@ export function buildLinkIndex(
|
|
|
315
323
|
};
|
|
316
324
|
}
|
|
317
325
|
|
|
326
|
+
/**
|
|
327
|
+
* The site this project publishes on, as a host pattern.
|
|
328
|
+
*
|
|
329
|
+
* Hardcoded, as it is in {@link module:engine/homepage} already: every package's
|
|
330
|
+
* address is `https://www.heroiclands.org/<contentPackage>/`, and the whole
|
|
331
|
+
* point of the rule below is that an author *should not* be writing that host
|
|
332
|
+
* into a page. A configurable host would be a second place to write down the
|
|
333
|
+
* thing being discouraged.
|
|
334
|
+
*
|
|
335
|
+
* @type {RegExp}
|
|
336
|
+
*/
|
|
337
|
+
const SITE_HOST = /^(?:[a-z0-9-]+\.)*heroiclands\.org$/i;
|
|
338
|
+
|
|
339
|
+
/**
|
|
340
|
+
* How an authored address resolves, or `null` for one nothing here can judge.
|
|
341
|
+
*
|
|
342
|
+
* Three shapes reach the site and one does not, and the distinction is the
|
|
343
|
+
* whole of what is checkable. An address into this site can be reasoned about
|
|
344
|
+
* from the package roster alone; an address to `github.com`, `kelestia.com` or
|
|
345
|
+
* `discord.gg` cannot be reasoned about at all without fetching it, and a build
|
|
346
|
+
* must not depend on a third party being up.
|
|
347
|
+
*
|
|
348
|
+
* @param {string} url - The authored address.
|
|
349
|
+
* @param {ReadonlySet<string>} packages - Package prefixes this build can name.
|
|
350
|
+
* @returns {{shape: string, segments: string[], prefix: string|null}|null} The
|
|
351
|
+
* shape, the path segments, and the package prefix the address starts with.
|
|
352
|
+
*/
|
|
353
|
+
function readAddress(url, packages) {
|
|
354
|
+
const value = String(url ?? "").trim();
|
|
355
|
+
if (!value || value.startsWith("#")) return null;
|
|
356
|
+
|
|
357
|
+
let segments;
|
|
358
|
+
let shape;
|
|
359
|
+
if (/^[a-z][a-z0-9+.-]*:/i.test(value)) {
|
|
360
|
+
let parsed;
|
|
361
|
+
try {
|
|
362
|
+
parsed = new URL(value);
|
|
363
|
+
} catch {
|
|
364
|
+
return null;
|
|
365
|
+
}
|
|
366
|
+
if (!/^https?:$/.test(parsed.protocol)) return null;
|
|
367
|
+
if (!SITE_HOST.test(parsed.hostname)) return null;
|
|
368
|
+
shape = "absolute";
|
|
369
|
+
segments = parsed.pathname.split("/").filter(Boolean);
|
|
370
|
+
} else if (value.startsWith("/")) {
|
|
371
|
+
shape = "rooted";
|
|
372
|
+
segments = value.split("?")[0].split("#")[0].split("/").filter(Boolean);
|
|
373
|
+
} else {
|
|
374
|
+
shape = "relative";
|
|
375
|
+
segments = value.split("?")[0].split("#")[0].split("/").filter(Boolean);
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
const prefix =
|
|
379
|
+
shape !== "relative" && packages.has(segments[0]) ? segments[0] : null;
|
|
380
|
+
return { shape, segments, prefix };
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
/**
|
|
384
|
+
* Every defect in the addresses a package homepage carries.
|
|
385
|
+
*
|
|
386
|
+
* **Why the homepage needs its own audit at all.** Every other note addresses
|
|
387
|
+
* the corpus with wikilinks, which {@link auditLinks} resolves. A homepage does
|
|
388
|
+
* not and cannot: it is published *verbatim* by every publishing mode, including
|
|
389
|
+
* the homepage-only mode two fan-licensed packages ship under, where the content
|
|
390
|
+
* tree is never walked and there is no index for a wikilink to resolve against.
|
|
391
|
+
* So a landing addresses the web the way the web does — markdown links and
|
|
392
|
+
* `url:` fields — and nothing was looking at those. SoHL's landing pointed at
|
|
393
|
+
* `kb/creature/` and `kb/character/` from the day those types merged into
|
|
394
|
+
* `being`: two 404s on the package's front page, through every build.
|
|
395
|
+
*
|
|
396
|
+
* **What is checkable, stated plainly.** Only an address into this site is, and
|
|
397
|
+
* only against facts this build already holds:
|
|
398
|
+
*
|
|
399
|
+
* - A **retired content type** in the path. The engine knows what used to exist
|
|
400
|
+
* and what replaced it, so this is a fact rather than a guess — and it is
|
|
401
|
+
* exactly the SoHL defect.
|
|
402
|
+
* - A **hardcoded absolute URL** into this package's own prefix, or into one a
|
|
403
|
+
* vendored manifest names. Both have a better form to write, which is why they
|
|
404
|
+
* are reported; a bare `/<package>/` is left alone, because a package
|
|
405
|
+
* homepage is in no manifest and there is nothing better to write.
|
|
406
|
+
* - A **root-relative `url:`**, which the theme's `relURL` prefixes a second
|
|
407
|
+
* time. `href:` means "already resolved, use verbatim", so the same leading
|
|
408
|
+
* slash is correct there and is not reported.
|
|
409
|
+
* - A **wikilink**, which nothing on this page will ever resolve.
|
|
410
|
+
*
|
|
411
|
+
* **What is not checkable, and is not attempted.** Whether an external URL
|
|
412
|
+
* answers — there is no network at build time, and a build must not fail because
|
|
413
|
+
* a third party is down. And whether a live in-site address names a page that
|
|
414
|
+
* exists: several of the surfaces a landing routes to are produced by other
|
|
415
|
+
* tools entirely (generated API documentation, hand-authored Hugo sections), so
|
|
416
|
+
* this build does not hold the set of published pages and would report a working
|
|
417
|
+
* link as dead.
|
|
418
|
+
*
|
|
419
|
+
* @param {ReturnType<typeof buildLinkIndex>} index - The built index.
|
|
420
|
+
* @returns {Array<{note: object, field: string, url: string, text: string,
|
|
421
|
+
* occurrence: number, message: string}>} One finding per defect, `text` and
|
|
422
|
+
* `occurrence` locating it in the note's raw source.
|
|
423
|
+
*/
|
|
424
|
+
export function auditHomepageLinks(index) {
|
|
425
|
+
const findings = [];
|
|
426
|
+
const packages = new Set([index.contentPackage, ...index.packages]);
|
|
427
|
+
|
|
428
|
+
for (const note of index.notes) {
|
|
429
|
+
if (!isHomepage(note.fm)) continue;
|
|
430
|
+
|
|
431
|
+
// How many times each literal has been seen, so two identical
|
|
432
|
+
// addresses are located at their own positions.
|
|
433
|
+
const seen = new Map();
|
|
434
|
+
const at = (text) => {
|
|
435
|
+
const occurrence = (seen.get(text) ?? 0) + 1;
|
|
436
|
+
seen.set(text, occurrence);
|
|
437
|
+
return occurrence;
|
|
438
|
+
};
|
|
439
|
+
const report = (field, url, text, occurrence, message) =>
|
|
440
|
+
findings.push({ note, field, url, text, occurrence, message });
|
|
441
|
+
|
|
442
|
+
for (const [all, rawInner] of matchAllOutsideCode(
|
|
443
|
+
note.body,
|
|
444
|
+
new RegExp(WIKILINK.source, "g"),
|
|
445
|
+
)) {
|
|
446
|
+
const { target } = parseWikilink(rawInner);
|
|
447
|
+
report(
|
|
448
|
+
"body",
|
|
449
|
+
target,
|
|
450
|
+
all,
|
|
451
|
+
at(all),
|
|
452
|
+
`wikilink ${all} on the package homepage — a homepage is ` +
|
|
453
|
+
`published verbatim in every publishing mode, so nothing ` +
|
|
454
|
+
`resolves it; write a markdown link, package-relative`,
|
|
455
|
+
);
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
for (const { field, url, kind } of homepageAddresses(
|
|
459
|
+
note.fm,
|
|
460
|
+
note.body,
|
|
461
|
+
)) {
|
|
462
|
+
// Counted for every address, checked or not, so the count is
|
|
463
|
+
// the literal's nth appearance in the file rather than the nth
|
|
464
|
+
// *finding* about it — two rules can fire on one address.
|
|
465
|
+
const occurrence = at(url);
|
|
466
|
+
const address = readAddress(url, packages);
|
|
467
|
+
if (!address) continue;
|
|
468
|
+
const { shape, segments, prefix } = address;
|
|
469
|
+
|
|
470
|
+
if (shape === "absolute" && prefix) {
|
|
471
|
+
// A bare `/<package>/` is a package's homepage, which is in no
|
|
472
|
+
// link manifest and has no relative form from another package.
|
|
473
|
+
// A finding with no fix is noise.
|
|
474
|
+
const rest = segments.slice(1).join("/");
|
|
475
|
+
if (rest) {
|
|
476
|
+
report(
|
|
477
|
+
field,
|
|
478
|
+
url,
|
|
479
|
+
url,
|
|
480
|
+
occurrence,
|
|
481
|
+
prefix === index.contentPackage ?
|
|
482
|
+
`hardcoded absolute URL into this package's own ` +
|
|
483
|
+
`address — write the package-relative ` +
|
|
484
|
+
`"${rest}/", which the landing resolves ` +
|
|
485
|
+
`against the site so the page follows the mount`
|
|
486
|
+
: `hardcoded absolute URL into package "${prefix}" ` +
|
|
487
|
+
`— resolve it through that package's link ` +
|
|
488
|
+
`manifest, whose entries carry the address, so a ` +
|
|
489
|
+
`relocation does not leave this page behind`,
|
|
490
|
+
);
|
|
491
|
+
}
|
|
492
|
+
} else if (shape === "rooted" && kind === "url") {
|
|
493
|
+
const rest =
|
|
494
|
+
prefix ? segments.slice(1).join("/") : segments.join("/");
|
|
495
|
+
report(
|
|
496
|
+
field,
|
|
497
|
+
url,
|
|
498
|
+
url,
|
|
499
|
+
occurrence,
|
|
500
|
+
`url "${url}" is root-relative, but a landing's url: is ` +
|
|
501
|
+
`resolved against the site — write "${rest}/", or ` +
|
|
502
|
+
`href: for an address that is already resolved`,
|
|
503
|
+
);
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
// The retired-type rule reads the path *inside* the package, so an
|
|
507
|
+
// address that named one is fixed the same way wherever it was
|
|
508
|
+
// written.
|
|
509
|
+
const inPackage = prefix ? segments.slice(1) : segments;
|
|
510
|
+
for (const [i, segment] of inPackage.entries()) {
|
|
511
|
+
// `hasOwn`, not a plain lookup: a path segment spelled
|
|
512
|
+
// `constructor` would otherwise inherit a truthy answer from
|
|
513
|
+
// `Object.prototype` and be reported as retired.
|
|
514
|
+
if (!Object.hasOwn(RETIRED_TYPES, segment)) continue;
|
|
515
|
+
const replacement = RETIRED_TYPES[segment];
|
|
516
|
+
const fixed = [...inPackage];
|
|
517
|
+
fixed[i] = replacement;
|
|
518
|
+
report(
|
|
519
|
+
field,
|
|
520
|
+
url,
|
|
521
|
+
url,
|
|
522
|
+
occurrence,
|
|
523
|
+
`address "${url}" names content type "${segment}", ` +
|
|
524
|
+
`retired in favour of "${replacement}" — both ` +
|
|
525
|
+
`compiled to the same document, so the fix is ` +
|
|
526
|
+
`mechanical: "${fixed.join("/")}/"`,
|
|
527
|
+
);
|
|
528
|
+
}
|
|
529
|
+
}
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
return findings;
|
|
533
|
+
}
|
|
534
|
+
|
|
318
535
|
/**
|
|
319
536
|
* Every link in a tree that lands nowhere.
|
|
320
537
|
*
|
|
321
538
|
* @param {ReturnType<typeof buildLinkIndex>} index - The built index.
|
|
322
539
|
* @returns {{deadAnchors: object[], deadAddresses: object[],
|
|
323
|
-
* frontmatterLinks: object[],
|
|
324
|
-
* which addresses a foreign
|
|
540
|
+
* frontmatterLinks: object[], homepageLinks: object[],
|
|
541
|
+
* usedManifest: Set<string>}} The findings, and which addresses a foreign
|
|
542
|
+
* manifest answered.
|
|
325
543
|
*/
|
|
326
544
|
export function auditLinks(index) {
|
|
327
545
|
const { notes, anchors, linksOf, resolve, manifestHit, isAddress } = index;
|
|
@@ -370,6 +588,7 @@ export function auditLinks(index) {
|
|
|
370
588
|
deadAnchors,
|
|
371
589
|
deadAddresses,
|
|
372
590
|
frontmatterLinks: index.frontmatterLinks,
|
|
591
|
+
homepageLinks: auditHomepageLinks(index),
|
|
373
592
|
usedManifest,
|
|
374
593
|
};
|
|
375
594
|
}
|
package/engine/content-lint.mjs
CHANGED
|
@@ -164,15 +164,24 @@ export function lintContentTree(contentBase, { skipDirectories } = {}) {
|
|
|
164
164
|
// "Every one of nothing is unique" is a vacuous pass, and it is exactly
|
|
165
165
|
// what a tree that failed to check out produces — so the lint would go
|
|
166
166
|
// green on the one state it most needs to catch.
|
|
167
|
-
|
|
167
|
+
//
|
|
168
|
+
// The state that catches is an **empty walk**, not an empty key set (#77).
|
|
169
|
+
// A note may be keyless by design: a homepage carries no `shortcode`
|
|
170
|
+
// because it is addressed by the package rather than by a slug, so a
|
|
171
|
+
// package in `publish.site: homepage` mode has a content tree that is
|
|
172
|
+
// populated, correct, and permanently unkeyed. Reporting that as a missing
|
|
173
|
+
// checkout trains its author to stop reading the output — the one thing
|
|
174
|
+
// this guard needs them to do. A tree holding notes is therefore a tree;
|
|
175
|
+
// only a tree holding none is the absent one.
|
|
176
|
+
if (notes.length === 0) {
|
|
168
177
|
findings.push({
|
|
169
178
|
file: path.relative(process.cwd(), contentBase) || contentBase,
|
|
170
179
|
severity: "error",
|
|
171
180
|
message:
|
|
172
|
-
"holds no
|
|
181
|
+
"holds no content notes, so every rule here is vacuous — " +
|
|
173
182
|
"check that the content tree is present and that this is its root",
|
|
174
183
|
});
|
|
175
|
-
return { findings, notes:
|
|
184
|
+
return { findings, notes: 0, keys: 0 };
|
|
176
185
|
}
|
|
177
186
|
|
|
178
187
|
for (const [key, files] of byKey) {
|
package/engine/diagnostics.mjs
CHANGED
|
@@ -44,6 +44,7 @@
|
|
|
44
44
|
*/
|
|
45
45
|
|
|
46
46
|
import path from "node:path";
|
|
47
|
+
import YAML, { LineCounter } from "yaml";
|
|
47
48
|
|
|
48
49
|
/**
|
|
49
50
|
* The `file:line:column` locator, with whatever is known.
|
|
@@ -268,3 +269,48 @@ export function positionOfLiteral(text, needle, occurrence = 1) {
|
|
|
268
269
|
column: at - before.lastIndexOf("\n"),
|
|
269
270
|
};
|
|
270
271
|
}
|
|
272
|
+
|
|
273
|
+
/**
|
|
274
|
+
* Where a **key or value in a YAML document** sits, addressed by its path.
|
|
275
|
+
*
|
|
276
|
+
* {@link positionOfLiteral} is the plain search, and it is the wrong tool for a
|
|
277
|
+
* configuration file: a pack name like `items` or `actors` appears in the
|
|
278
|
+
* `packs:` block, in a folder's list, in a path, and often in prose, so the
|
|
279
|
+
* first occurrence is routinely not the one the finding is about — which is the
|
|
280
|
+
* one thing the located form exists to prevent.
|
|
281
|
+
*
|
|
282
|
+
* A path resolves the exact node instead. The document is re-parsed here rather
|
|
283
|
+
* than threaded from the loader because the loader returns plain data: `yaml`
|
|
284
|
+
* discards ranges once a document is materialised, and carrying a parallel
|
|
285
|
+
* position tree through configuration resolution would be a second
|
|
286
|
+
* representation of the same file to keep in step.
|
|
287
|
+
*
|
|
288
|
+
* Every failure — unparseable text, an `.mjs` configuration, a path that
|
|
289
|
+
* resolves to nothing — yields `{}`, so a caller spreads the result and the
|
|
290
|
+
* position is dropped rather than guessed.
|
|
291
|
+
*
|
|
292
|
+
* @param {string} text - The document's contents.
|
|
293
|
+
* @param {ReadonlyArray<string|number>} keyPath - Path to the node: map keys as
|
|
294
|
+
* strings, sequence entries as numbers.
|
|
295
|
+
* @returns {{line?: number, column?: number}} Spreadable position fields.
|
|
296
|
+
*/
|
|
297
|
+
export function positionOfYamlPath(text, keyPath) {
|
|
298
|
+
if (typeof text !== "string" || !text) return {};
|
|
299
|
+
if (!Array.isArray(keyPath) || keyPath.length === 0) return {};
|
|
300
|
+
|
|
301
|
+
let node;
|
|
302
|
+
const counter = new LineCounter();
|
|
303
|
+
try {
|
|
304
|
+
const doc = YAML.parseDocument(text, { lineCounter: counter });
|
|
305
|
+
// `keepScalar` returns the Scalar node rather than its value, which is
|
|
306
|
+
// the only form carrying a range.
|
|
307
|
+
node = doc.getIn(keyPath, true);
|
|
308
|
+
} catch {
|
|
309
|
+
return {};
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
const start = node?.range?.[0];
|
|
313
|
+
if (!Number.isFinite(start)) return {};
|
|
314
|
+
const { line, col } = counter.linePos(start);
|
|
315
|
+
return { line, column: col };
|
|
316
|
+
}
|