@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/engine/homepage.mjs
CHANGED
|
@@ -49,13 +49,19 @@
|
|
|
49
49
|
*
|
|
50
50
|
* **Its address is the package's, not the note's.** A homepage publishes at
|
|
51
51
|
* `/<contentPackage>/` because that is where the package is, so `name.full`,
|
|
52
|
-
* `shortcode` and `id` decide nothing on it
|
|
53
|
-
*
|
|
52
|
+
* `shortcode` and `id` decide nothing on it — nothing here reads them, and
|
|
53
|
+
* {@link HOMEPAGE_REFUSED_FIELDS} refuses them outright rather than leaving an
|
|
54
|
+
* author to believe they worked (#53). It compiles into no document, so it carries
|
|
54
55
|
* no compendium UUID and appears in no pack and in no link-manifest entry.
|
|
55
56
|
*
|
|
56
57
|
* @module
|
|
57
58
|
*/
|
|
58
59
|
|
|
60
|
+
import fs from "node:fs";
|
|
61
|
+
|
|
62
|
+
import { matchAllOutsideCode } from "./code-fences.mjs";
|
|
63
|
+
import { formatLocator, positionInFrontmatter } from "./diagnostics.mjs";
|
|
64
|
+
|
|
59
65
|
/**
|
|
60
66
|
* The note type that compiles to the package homepage.
|
|
61
67
|
*
|
|
@@ -96,6 +102,206 @@ export function isHomepage(fm) {
|
|
|
96
102
|
return Boolean(fm) && fm.type === HOMEPAGE_TYPE;
|
|
97
103
|
}
|
|
98
104
|
|
|
105
|
+
/**
|
|
106
|
+
* The top-level fields a homepage refuses, and what each one would decide (#53).
|
|
107
|
+
*
|
|
108
|
+
* A note's URL derives from `name.full` and its identity from
|
|
109
|
+
* `(type, shortcode)`. The homepage is the one page for which neither holds: it
|
|
110
|
+
* publishes at `/<package>/`, fixed by the package id. An author fluent in the
|
|
111
|
+
* conventions writes them here expecting exactly what they do everywhere else,
|
|
112
|
+
* and gets none of it.
|
|
113
|
+
*
|
|
114
|
+
* **They were never inert, which is why ignoring them was the wrong answer.** A
|
|
115
|
+
* `shortcode` puts the note in the address index and in the `dataview` link
|
|
116
|
+
* universe, so `[[homepage-<shortcode>]]` resolves *green* — to
|
|
117
|
+
* `homepage/<slug>/`, an address derived from `name.full` and published by
|
|
118
|
+
* nothing, because a homepage is written to {@link HOMEPAGE_DESTINATION} at the
|
|
119
|
+
* package root. A build that reports a live link to a 404 is worse than one
|
|
120
|
+
* that says nothing. It also inflates `content-build lint`'s address tally, so
|
|
121
|
+
* the lint and the link manifest disagree about what the package publishes.
|
|
122
|
+
*
|
|
123
|
+
* **A named class, not an allow-list, and that boundary is the decision.** The
|
|
124
|
+
* documented envelope is `type` plus an optional `title`, and `landing`,
|
|
125
|
+
* `description` and `banner` are legitimate beside them — but a homepage's
|
|
126
|
+
* frontmatter is *emitted into the published page*
|
|
127
|
+
* ({@link homepageFrontmatter}), so an unrecognised key is a Hugo or theme
|
|
128
|
+
* parameter this build has never heard of and has no standing to refuse.
|
|
129
|
+
* Rejecting unknown keys would make every new theme parameter wait on a
|
|
130
|
+
* package-build release. What is refused is the specific class that makes a
|
|
131
|
+
* false claim about *where this page is*.
|
|
132
|
+
*
|
|
133
|
+
* `aliases` is deliberately not in the class: {@link homepageFrontmatter}
|
|
134
|
+
* already drops it from every emitted page, with a reason of its own, so
|
|
135
|
+
* authoring one is the same no-op it is on any other page rather than a wrong
|
|
136
|
+
* belief about this one's address.
|
|
137
|
+
*
|
|
138
|
+
* @type {ReadonlyMap<string, string>}
|
|
139
|
+
*/
|
|
140
|
+
export const HOMEPAGE_REFUSED_FIELDS = Object.freeze(
|
|
141
|
+
new Map([
|
|
142
|
+
[
|
|
143
|
+
"name",
|
|
144
|
+
"`name` decides nothing on a `type: homepage` note: a page's slug " +
|
|
145
|
+
"derives from `name.full`, and a homepage's destination is " +
|
|
146
|
+
`fixed — it is written to \`${HOMEPAGE_DESTINATION}\` at the ` +
|
|
147
|
+
"package's own address, `/<package>/`. Write `title:` for what " +
|
|
148
|
+
"the page is called, and delete `name`",
|
|
149
|
+
],
|
|
150
|
+
[
|
|
151
|
+
"shortcode",
|
|
152
|
+
"`shortcode` decides nothing on a `type: homepage` note: this " +
|
|
153
|
+
"page's address is the package's own, `/<package>/`, fixed by " +
|
|
154
|
+
"the package id. It is not ignored either — it puts the note " +
|
|
155
|
+
"in the address index, so `[[homepage-<shortcode>]]` resolves " +
|
|
156
|
+
"to a page the site build never writes. Delete it",
|
|
157
|
+
],
|
|
158
|
+
[
|
|
159
|
+
"id",
|
|
160
|
+
"`id` decides nothing on a `type: homepage` note: it is the " +
|
|
161
|
+
"Foundry document id a compendium UUID is built from, and a " +
|
|
162
|
+
"homepage compiles into no document — it appears in no pack " +
|
|
163
|
+
"and in no link manifest. Delete it",
|
|
164
|
+
],
|
|
165
|
+
]),
|
|
166
|
+
);
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* The address-bearing fields one note authors, in the order it authored them.
|
|
170
|
+
*
|
|
171
|
+
* Authoring order rather than declaration order, so a caller emitting one
|
|
172
|
+
* diagnostic per finding emits them top to bottom down the file — the order a
|
|
173
|
+
* reader and a compiler-output parser both expect.
|
|
174
|
+
*
|
|
175
|
+
* Presence is the whole test: `shortcode:` authored empty still says "this page
|
|
176
|
+
* has an address of its own", and a value cannot make the claim true.
|
|
177
|
+
*
|
|
178
|
+
* Returned without a locator, because the two things that would supply one —
|
|
179
|
+
* the raw note text and the position helper — belong to the caller. This
|
|
180
|
+
* mirrors {@link module:engine/retired-fields}, whose retired-field messages
|
|
181
|
+
* are likewise positioned by whoever reports them.
|
|
182
|
+
*
|
|
183
|
+
* @param {object|null|undefined} fm - Parsed frontmatter.
|
|
184
|
+
* @returns {Array<{key: string, message: string}>} One entry per field the note
|
|
185
|
+
* authored, empty for any note that is not a homepage.
|
|
186
|
+
*/
|
|
187
|
+
export function checkHomepageAddressFields(fm) {
|
|
188
|
+
if (!isHomepage(fm)) return [];
|
|
189
|
+
const out = [];
|
|
190
|
+
for (const key of Object.keys(fm)) {
|
|
191
|
+
const message = HOMEPAGE_REFUSED_FIELDS.get(key);
|
|
192
|
+
if (message) out.push({ key, message });
|
|
193
|
+
}
|
|
194
|
+
return out;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/**
|
|
198
|
+
* Require exactly one homepage note in a content tree (#52).
|
|
199
|
+
*
|
|
200
|
+
* "Exactly one" is two rules, and they are **one severity** because they are
|
|
201
|
+
* one defect: a package whose front page is not the page a person chose.
|
|
202
|
+
*
|
|
203
|
+
* - _None_ and the package serves nothing at `/<package>/`. That is the failure
|
|
204
|
+
* #50 exists to prevent, and it is silent — the site build reports `wrote 0
|
|
205
|
+
* homepage(s)` and exits 0.
|
|
206
|
+
* - _Two_ and it serves a page nobody chose. Every homepage is written to the
|
|
207
|
+
* same {@link HOMEPAGE_DESTINATION}, so the second overwrites the first and
|
|
208
|
+
* the package's front page is decided by the order the walk happened to reach
|
|
209
|
+
* the files in — by *filename*, on a type whose whole point is that it is
|
|
210
|
+
* routed by frontmatter. There is no "first wins" convention to fall back on,
|
|
211
|
+
* so nothing here can pick the right one.
|
|
212
|
+
*
|
|
213
|
+
* Neither has a safe default, so neither is a warning. A warning is the right
|
|
214
|
+
* severity for something a build can proceed past correctly, and a build that
|
|
215
|
+
* proceeds past either of these publishes the wrong front page while reporting
|
|
216
|
+
* success — which is the exact outcome a warning would be tolerating.
|
|
217
|
+
*
|
|
218
|
+
* **Two is reported once per note, not once for the tree.** Each note is a
|
|
219
|
+
* place an author has to open and edit, and a single finding saying "there are
|
|
220
|
+
* two" sends them hunting for the second.
|
|
221
|
+
*
|
|
222
|
+
* **None is located at the tree, honestly.** There is no file to name, so the
|
|
223
|
+
* locator is the content root — the directory the note is missing from, which
|
|
224
|
+
* is a real path and the one the author adds it to. No line and no column are
|
|
225
|
+
* invented for it, per the diagnostic rules in
|
|
226
|
+
* {@link module:engine/diagnostics}. {@link lintContentTree} already reports an
|
|
227
|
+
* empty walk against the same locator.
|
|
228
|
+
*
|
|
229
|
+
* The rule reads no `site:` configuration and does not vary by
|
|
230
|
+
* `publish.site`: that setting chooses whether the *content* surfaces are
|
|
231
|
+
* published, and the homepage is the floor underneath both modes.
|
|
232
|
+
*
|
|
233
|
+
* @param {ReadonlyArray<{file: string}>} found - The homepage notes, in walk
|
|
234
|
+
* order. Paths may be absolute or relative to the working directory.
|
|
235
|
+
* @param {object} options - Options.
|
|
236
|
+
* @param {string} options.contentBase - Root of the content tree, for the
|
|
237
|
+
* locator when there is no file to name.
|
|
238
|
+
* @param {string} [options.contentPackage] - The package this tree builds.
|
|
239
|
+
* Dropped from the message when unknown rather than guessed.
|
|
240
|
+
* @returns {Array<{file: string, line?: number, column?: number,
|
|
241
|
+
* severity: "error", message: string}>} The findings, one per offending note.
|
|
242
|
+
*/
|
|
243
|
+
export function checkHomepageCount(found, { contentBase, contentPackage }) {
|
|
244
|
+
const pages = found ?? [];
|
|
245
|
+
const named = contentPackage ? ` "${contentPackage}"` : "";
|
|
246
|
+
const address = contentPackage ? ` /${contentPackage}/` : "";
|
|
247
|
+
|
|
248
|
+
if (pages.length === 0) {
|
|
249
|
+
return [
|
|
250
|
+
{
|
|
251
|
+
file: contentBase,
|
|
252
|
+
severity: "error",
|
|
253
|
+
message:
|
|
254
|
+
`holds no \`type: homepage\` note, so ` +
|
|
255
|
+
`${contentPackage ? `package${named}` : "this package"} ` +
|
|
256
|
+
`publishes nothing at its own address${address} — a ` +
|
|
257
|
+
`package's front page is one authored note in this tree, ` +
|
|
258
|
+
`routed by \`type:\` rather than by filename`,
|
|
259
|
+
},
|
|
260
|
+
];
|
|
261
|
+
}
|
|
262
|
+
if (pages.length === 1) return [];
|
|
263
|
+
|
|
264
|
+
return pages.map((page) => {
|
|
265
|
+
const others = pages
|
|
266
|
+
.filter((p) => p !== page)
|
|
267
|
+
.map((p) => formatLocator({ file: p.file }));
|
|
268
|
+
return {
|
|
269
|
+
file: page.file,
|
|
270
|
+
...positionOfType(page.file),
|
|
271
|
+
severity: "error",
|
|
272
|
+
message:
|
|
273
|
+
`duplicate \`type: homepage\` note, also declared by ` +
|
|
274
|
+
`${others.join(", ")}; a package has one front page` +
|
|
275
|
+
`${contentPackage ? `, at${address},` : ""} and every ` +
|
|
276
|
+
`homepage is written to the same \`${HOMEPAGE_DESTINATION}\` — ` +
|
|
277
|
+
`so the one the walk reaches last silently overwrites the rest`,
|
|
278
|
+
};
|
|
279
|
+
});
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
/**
|
|
283
|
+
* Where a note declares `type: homepage`, when the file can still be read.
|
|
284
|
+
*
|
|
285
|
+
* A separate read rather than a raw text threaded through every caller: the
|
|
286
|
+
* two call sites hold different shapes (a lint note, a collected page) and this
|
|
287
|
+
* runs only on a tree that is already failing.
|
|
288
|
+
*
|
|
289
|
+
* @param {string} file - Path to the note.
|
|
290
|
+
* @returns {{line?: number, column?: number}} Spreadable position fields, empty
|
|
291
|
+
* when the file cannot be read — dropped rather than guessed.
|
|
292
|
+
*/
|
|
293
|
+
function positionOfType(file) {
|
|
294
|
+
try {
|
|
295
|
+
return positionInFrontmatter(
|
|
296
|
+
fs.readFileSync(file, "utf8"),
|
|
297
|
+
"type",
|
|
298
|
+
HOMEPAGE_TYPE,
|
|
299
|
+
);
|
|
300
|
+
} catch {
|
|
301
|
+
return {};
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
|
|
99
305
|
/**
|
|
100
306
|
* The title a homepage publishes under.
|
|
101
307
|
*
|
|
@@ -148,3 +354,110 @@ export function homepageFrontmatter(fm, { contentPackage, title }) {
|
|
|
148
354
|
delete data.aliases;
|
|
149
355
|
return data;
|
|
150
356
|
}
|
|
357
|
+
|
|
358
|
+
/**
|
|
359
|
+
* An inline markdown link — `[text](target)`, but not an image.
|
|
360
|
+
*
|
|
361
|
+
* Reference-style links are deliberately not matched: a landing's prose fields
|
|
362
|
+
* are single YAML scalars with nowhere to put a link definition, so a `[x][y]`
|
|
363
|
+
* in one could never resolve and is not an address anybody wrote.
|
|
364
|
+
*
|
|
365
|
+
* @type {RegExp}
|
|
366
|
+
*/
|
|
367
|
+
const MARKDOWN_LINK = /(?<!!)\[[^\]]*\]\(\s*([^)\s]+)(?:\s+"[^"]*")?\s*\)/g;
|
|
368
|
+
|
|
369
|
+
/**
|
|
370
|
+
* The two frontmatter keys that hold an address, and what each one means.
|
|
371
|
+
*
|
|
372
|
+
* They are **not** interchangeable, and a check that treated them as one would
|
|
373
|
+
* be wrong about both. The theme resolves a `url` against the site with
|
|
374
|
+
* `relURL`, so a package writes `kb/rules/` and is served `/sohl/kb/rules/`
|
|
375
|
+
* without ever naming its own prefix. An `href` is an address that is *already*
|
|
376
|
+
* resolved and is used verbatim — which is what `cards.source: sections` fills
|
|
377
|
+
* in, since a section's permalink already carries the prefix.
|
|
378
|
+
*
|
|
379
|
+
* So a leading `/` is a defect in a `url` (it is prefixed a second time) and
|
|
380
|
+
* correct in an `href`.
|
|
381
|
+
*
|
|
382
|
+
* @type {ReadonlySet<string>}
|
|
383
|
+
*/
|
|
384
|
+
export const HOMEPAGE_ADDRESS_KEYS = Object.freeze(new Set(["url", "href"]));
|
|
385
|
+
|
|
386
|
+
/**
|
|
387
|
+
* Collect the markdown links in one prose value.
|
|
388
|
+
*
|
|
389
|
+
* @param {string} text - The value.
|
|
390
|
+
* @param {string} field - Where it came from.
|
|
391
|
+
* @param {string} kind - The address kind to record.
|
|
392
|
+
* @param {object[]} out - Accumulator.
|
|
393
|
+
* @param {boolean} [skipCode] - Whether to ignore links inside code.
|
|
394
|
+
*/
|
|
395
|
+
function collectProse(text, field, kind, out, skipCode = false) {
|
|
396
|
+
const pattern = new RegExp(MARKDOWN_LINK.source, "g");
|
|
397
|
+
const matches =
|
|
398
|
+
skipCode ?
|
|
399
|
+
matchAllOutsideCode(text, pattern)
|
|
400
|
+
: [...text.matchAll(pattern)];
|
|
401
|
+
for (const m of matches) out.push({ field, url: m[1], kind });
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
/**
|
|
405
|
+
* Every address a homepage carries, wherever it is written.
|
|
406
|
+
*
|
|
407
|
+
* **Both halves of the page are in scope, and that is the finding rather than
|
|
408
|
+
* the assumption.** Of the six homepages authored today, four carry every link
|
|
409
|
+
* in the body as ordinary markdown and two carry them in `landing:` — and the
|
|
410
|
+
* one whose dead links prompted the check has an *empty body*, so a body-only
|
|
411
|
+
* reading would have found nothing at all on it. A dead link in a card is
|
|
412
|
+
* exactly as broken as one in a paragraph.
|
|
413
|
+
*
|
|
414
|
+
* Three shapes are gathered, and the caller needs to tell them apart because
|
|
415
|
+
* the rules differ:
|
|
416
|
+
*
|
|
417
|
+
* - **`url`** — package-relative, resolved against the site by the theme.
|
|
418
|
+
* - **`href`** — already resolved, used verbatim.
|
|
419
|
+
* - **prose and body markdown links** — emitted as written and resolved by the
|
|
420
|
+
* browser against the landing's own address, which *is* the package root, so
|
|
421
|
+
* a relative one means the same thing a `url` does.
|
|
422
|
+
*
|
|
423
|
+
* `banner:` is not an address: it is an image path resolved through the CDN
|
|
424
|
+
* base, and `banner: none` is a sentinel rather than a target. Top-level
|
|
425
|
+
* `title` and `description` are not walked either — they are set as text, never
|
|
426
|
+
* rendered as markdown.
|
|
427
|
+
*
|
|
428
|
+
* @param {object|null|undefined} fm - The note's frontmatter.
|
|
429
|
+
* @param {string} [body] - The note's markdown body.
|
|
430
|
+
* @returns {Array<{field: string, url: string, kind: string}>} Every address,
|
|
431
|
+
* frontmatter first and then the body, each with the dotted path it was
|
|
432
|
+
* written at.
|
|
433
|
+
*/
|
|
434
|
+
export function homepageAddresses(fm, body = "") {
|
|
435
|
+
const out = [];
|
|
436
|
+
|
|
437
|
+
const walk = (value, field) => {
|
|
438
|
+
if (typeof value === "string") {
|
|
439
|
+
collectProse(value, field, "prose", out);
|
|
440
|
+
return;
|
|
441
|
+
}
|
|
442
|
+
if (Array.isArray(value)) {
|
|
443
|
+
value.forEach((v, i) => walk(v, `${field}[${i}]`));
|
|
444
|
+
return;
|
|
445
|
+
}
|
|
446
|
+
if (!value || typeof value !== "object") return;
|
|
447
|
+
for (const [key, v] of Object.entries(value)) {
|
|
448
|
+
const child = `${field}.${key}`;
|
|
449
|
+
// An address field holds an address, not prose: reading it for
|
|
450
|
+
// markdown links as well would report the same target twice
|
|
451
|
+
// whenever one happened to look like a link.
|
|
452
|
+
if (HOMEPAGE_ADDRESS_KEYS.has(key) && typeof v === "string") {
|
|
453
|
+
out.push({ field: child, url: v, kind: key });
|
|
454
|
+
continue;
|
|
455
|
+
}
|
|
456
|
+
walk(v, child);
|
|
457
|
+
}
|
|
458
|
+
};
|
|
459
|
+
|
|
460
|
+
walk(fm?.landing, "landing");
|
|
461
|
+
collectProse(String(body ?? ""), "body", "body", out, true);
|
|
462
|
+
return out;
|
|
463
|
+
}
|
package/engine/pack-config.mjs
CHANGED
|
@@ -453,6 +453,9 @@ function loadCodeConfig(configPath) {
|
|
|
453
453
|
/** The loaded configuration, memoised — the file is read at most once. */
|
|
454
454
|
let loaded;
|
|
455
455
|
|
|
456
|
+
/** The file {@link loadPackConfig} read, alongside the memoised result. */
|
|
457
|
+
let loadedFrom;
|
|
458
|
+
|
|
456
459
|
/**
|
|
457
460
|
* The consuming repository's resolved, frozen configuration.
|
|
458
461
|
*
|
|
@@ -492,5 +495,23 @@ export function loadPackConfig() {
|
|
|
492
495
|
YAML.parse(fs.readFileSync(configPath, "utf8")),
|
|
493
496
|
configPath,
|
|
494
497
|
);
|
|
498
|
+
loadedFrom = configPath;
|
|
495
499
|
return loaded;
|
|
496
500
|
}
|
|
501
|
+
|
|
502
|
+
/**
|
|
503
|
+
* The file {@link loadPackConfig} resolved the configuration from.
|
|
504
|
+
*
|
|
505
|
+
* A diagnostic about a *configured* value has to name the file it was declared
|
|
506
|
+
* in, and re-deriving that path at the point of the finding would be a second
|
|
507
|
+
* resolution free to disagree with the first — the `PACKAGE_BUILD_CONFIG`
|
|
508
|
+
* override, the upward walk and the one-file-per-directory rule all have to
|
|
509
|
+
* come out the same way. This reports the path actually read.
|
|
510
|
+
*
|
|
511
|
+
* @returns {string} Its absolute path.
|
|
512
|
+
* @throws {Error} As {@link loadPackConfig}, when there is no configuration.
|
|
513
|
+
*/
|
|
514
|
+
export function packConfigPath() {
|
|
515
|
+
loadPackConfig();
|
|
516
|
+
return /** @type {string} */ (loadedFrom);
|
|
517
|
+
}
|
package/engine/site-build.mjs
CHANGED
|
@@ -65,6 +65,7 @@ import { loadPackConfig } from "./pack-config.mjs";
|
|
|
65
65
|
import { searchableFrontmatter } from "./note-package.mjs";
|
|
66
66
|
import {
|
|
67
67
|
HOMEPAGE_DESTINATION,
|
|
68
|
+
checkHomepageCount,
|
|
68
69
|
homepageFrontmatter,
|
|
69
70
|
homepageTitle,
|
|
70
71
|
isHomepage,
|
|
@@ -269,9 +270,9 @@ export function collectTreePages(tree, ctx) {
|
|
|
269
270
|
* packages ship under is a property of the code path rather than of a
|
|
270
271
|
* configuration that happens to be empty (#55).
|
|
271
272
|
*
|
|
272
|
-
* Returned as a list rather than as the one note there should be
|
|
273
|
-
*
|
|
274
|
-
* found
|
|
273
|
+
* Returned as a list rather than as the one note there should be, because the
|
|
274
|
+
* count is what {@link checkHomepageCount} judges (#52) — this walk reports
|
|
275
|
+
* what it found, and {@link buildSite} decides whether that is one.
|
|
275
276
|
*
|
|
276
277
|
* @param {string} contentBase - Absolute path to the content tree.
|
|
277
278
|
* @param {object} ctx - `{ skipDirectories }`.
|
|
@@ -291,10 +292,16 @@ export function collectHomepages(contentBase, ctx) {
|
|
|
291
292
|
* Writes each homepage at the package's own root.
|
|
292
293
|
*
|
|
293
294
|
* Its own writer, deliberately small. A homepage is authored markdown published
|
|
294
|
-
* verbatim — no table expansion, no section landing
|
|
295
|
-
*
|
|
296
|
-
*
|
|
297
|
-
*
|
|
295
|
+
* verbatim — no table expansion, no section landing and no link resolution — so
|
|
296
|
+
* routing it through {@link renderPages} would buy it a pipeline it has no input
|
|
297
|
+
* for, and would make homepage-only mode depend on the index, the foreign
|
|
298
|
+
* manifests and the table universe that mode exists to not build.
|
|
299
|
+
*
|
|
300
|
+
* **Verbatim is the answer to #54, not a gap left by it.** A landing's links
|
|
301
|
+
* could not be *resolved* here without giving `homepage` mode the index its
|
|
302
|
+
* licensing fence exists to not build, so they are **checked** instead:
|
|
303
|
+
* {@link auditHomepageLinks} reads the `landing:` addresses and the body's
|
|
304
|
+
* markdown links, and reports a wikilink on the page rather than resolving one.
|
|
298
305
|
*
|
|
299
306
|
* @param {string} outRoot - The package's site root — the configured `site.out`,
|
|
300
307
|
* one level above the content mount.
|
|
@@ -339,6 +346,10 @@ export function writeHomepages(outRoot, pages, config) {
|
|
|
339
346
|
*/
|
|
340
347
|
export function siteGates(pages, findings, { manifestDir }) {
|
|
341
348
|
const out = {
|
|
349
|
+
// Always empty here: the homepage count is decided in `buildSite`
|
|
350
|
+
// before the content walk, and a failing count returns without ever
|
|
351
|
+
// reaching these gates (#52). Present so every caller reads one shape.
|
|
352
|
+
homepages: [],
|
|
342
353
|
frontmatterLinks: findings.fmLinkFindings ?? [],
|
|
343
354
|
slugErrors: findings.slugFindings ?? [],
|
|
344
355
|
collisions: [],
|
|
@@ -397,6 +408,7 @@ export function siteGates(pages, findings, { manifestDir }) {
|
|
|
397
408
|
*/
|
|
398
409
|
export function emptyGates() {
|
|
399
410
|
return {
|
|
411
|
+
homepages: [],
|
|
400
412
|
frontmatterLinks: [],
|
|
401
413
|
slugErrors: [],
|
|
402
414
|
collisions: [],
|
|
@@ -412,6 +424,7 @@ export function emptyGates() {
|
|
|
412
424
|
/** Whether any gate produced a finding. */
|
|
413
425
|
export function gatesFailed(gates) {
|
|
414
426
|
return Boolean(
|
|
427
|
+
gates.homepages.length ||
|
|
415
428
|
gates.frontmatterLinks.length ||
|
|
416
429
|
gates.slugErrors.length ||
|
|
417
430
|
gates.collisions.length ||
|
|
@@ -449,6 +462,39 @@ export function tableUniverse(pages) {
|
|
|
449
462
|
return byPackage;
|
|
450
463
|
}
|
|
451
464
|
|
|
465
|
+
/**
|
|
466
|
+
* The front matter a section's landing states about itself.
|
|
467
|
+
*
|
|
468
|
+
* The section metadata a configuration resolved, ready to be written or merged
|
|
469
|
+
* onto a page. Two things happen here and nothing else does:
|
|
470
|
+
*
|
|
471
|
+
* - **`title` leads.** It is the one key every landing has carried since the
|
|
472
|
+
* first one, and a landing whose block opened with `banner:` would be a
|
|
473
|
+
* gratuitous diff on every consumer's tree.
|
|
474
|
+
* - **An absent value is left off**, not written as `undefined` — which is not
|
|
475
|
+
* a value YAML can carry, and would abort the serializer.
|
|
476
|
+
*
|
|
477
|
+
* Everything else the section declared is passed through. That is the point of
|
|
478
|
+
* the function: before #91 both writers transcribed `title` and `banner` by
|
|
479
|
+
* name, so the vocabulary lived in three places — the schema that admits a key
|
|
480
|
+
* and the two writers that copy it — and a key added to the schema alone
|
|
481
|
+
* validated cleanly and then reached no page. The *schema* is the bound worth
|
|
482
|
+
* keeping (see `normalizeSectionMeta`, which refuses a key it does not know and
|
|
483
|
+
* names it); a second, silent bound in the writers is not.
|
|
484
|
+
*
|
|
485
|
+
* @param {object} meta - A resolved `site.sections` / `site.readmeSections`
|
|
486
|
+
* entry.
|
|
487
|
+
* @returns {object} Its front matter, `title` first.
|
|
488
|
+
*/
|
|
489
|
+
export function sectionFrontmatter(meta) {
|
|
490
|
+
const data = { title: meta.title };
|
|
491
|
+
for (const [key, value] of Object.entries(meta)) {
|
|
492
|
+
if (key === "title" || value === undefined) continue;
|
|
493
|
+
data[key] = value;
|
|
494
|
+
}
|
|
495
|
+
return data;
|
|
496
|
+
}
|
|
497
|
+
|
|
452
498
|
/**
|
|
453
499
|
* The frontmatter a page publishes with.
|
|
454
500
|
*
|
|
@@ -488,12 +534,11 @@ export function pageFrontmatter(page, { readmeSections = {}, decorate }) {
|
|
|
488
534
|
if (decorate) decorate(data, page);
|
|
489
535
|
if (isReadme) {
|
|
490
536
|
const meta = readmeSections[sec];
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
}
|
|
537
|
+
// What the section says about itself wins over what its README
|
|
538
|
+
// happens to carry — the landing has to match the card linking to
|
|
539
|
+
// it. Assigned rather than transcribed key by key, so a section's
|
|
540
|
+
// vocabulary is decided in one place (#91).
|
|
541
|
+
if (meta) Object.assign(data, sectionFrontmatter(meta));
|
|
497
542
|
}
|
|
498
543
|
} else {
|
|
499
544
|
// A tree's own landing describes the *mount*, and nothing beneath it. A
|
|
@@ -503,7 +548,7 @@ export function pageFrontmatter(page, { readmeSections = {}, decorate }) {
|
|
|
503
548
|
const isSectionRoot = path.posix.dirname(page.rel) === ".";
|
|
504
549
|
const meta = isReadme && isSectionRoot ? readmeSections[sec] : null;
|
|
505
550
|
data = { ...fm, title: meta?.title ?? fm.title ?? name };
|
|
506
|
-
if (meta
|
|
551
|
+
if (meta) Object.assign(data, sectionFrontmatter(meta));
|
|
507
552
|
}
|
|
508
553
|
delete data.aliases;
|
|
509
554
|
return data;
|
|
@@ -654,15 +699,11 @@ export function writeSectionLandings(
|
|
|
654
699
|
for (const [sec, meta] of Object.entries(sections)) {
|
|
655
700
|
const dir = path.join(outRoot, sec);
|
|
656
701
|
fs.mkdirSync(dir, { recursive: true });
|
|
657
|
-
//
|
|
658
|
-
//
|
|
659
|
-
// can carry, so the key is left off entirely.
|
|
702
|
+
// Whatever the section declared, not a list of keys named here — see
|
|
703
|
+
// {@link sectionFrontmatter} for why the two lists were one too many.
|
|
660
704
|
fs.writeFileSync(
|
|
661
705
|
path.join(dir, "_index.md"),
|
|
662
|
-
matter.stringify("",
|
|
663
|
-
title: meta.title,
|
|
664
|
-
...(meta.banner ? { banner: meta.banner } : {}),
|
|
665
|
-
}),
|
|
706
|
+
matter.stringify("", sectionFrontmatter(meta)),
|
|
666
707
|
);
|
|
667
708
|
written += 1;
|
|
668
709
|
}
|
|
@@ -842,12 +883,32 @@ export function buildSite({ config, outRoot } = {}) {
|
|
|
842
883
|
scheme,
|
|
843
884
|
};
|
|
844
885
|
|
|
886
|
+
const homepages = collectHomepages(resolved.paths.content, ctx).pages;
|
|
887
|
+
|
|
888
|
+
// Exactly one homepage, and checked here — before the output tree is
|
|
889
|
+
// cleared and before either mode branches (#52). Before the clear, because
|
|
890
|
+
// a gate that fired after it would have destroyed a good site to report a
|
|
891
|
+
// bad tree. Before the branch, because the requirement does not vary by
|
|
892
|
+
// mode: `publish.site` chooses whether the *content* surfaces are
|
|
893
|
+
// published, and the homepage is the floor beneath both.
|
|
894
|
+
const homepageFindings = checkHomepageCount(homepages, {
|
|
895
|
+
contentBase: resolved.paths.content,
|
|
896
|
+
contentPackage: resolved.contentPackage,
|
|
897
|
+
});
|
|
898
|
+
if (homepageFindings.length) {
|
|
899
|
+
return {
|
|
900
|
+
gates: { ...emptyGates(), homepages: homepageFindings },
|
|
901
|
+
manifests: null,
|
|
902
|
+
tableErrors: [],
|
|
903
|
+
wikiErrors: [],
|
|
904
|
+
stats: null,
|
|
905
|
+
};
|
|
906
|
+
}
|
|
907
|
+
|
|
845
908
|
// The whole tree is a build artifact, regenerated every run: a page whose
|
|
846
909
|
// note was deleted or renamed would otherwise linger and keep publishing.
|
|
847
910
|
fs.rmSync(outBase, { recursive: true, force: true });
|
|
848
911
|
|
|
849
|
-
const homepages = collectHomepages(resolved.paths.content, ctx).pages;
|
|
850
|
-
|
|
851
912
|
// Homepage-only stops here, and stopping is the point: nothing below reads
|
|
852
913
|
// the content tree for pages, so `sohl-kethira-basic` and `harn-adventures`
|
|
853
914
|
// cannot publish one whatever else their `site:` block declares (#55).
|