@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.
@@ -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 refuses them outright; this
53
- * module simply never reads them). It compiles into no document, so it carries
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
+ }
@@ -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
+ }
@@ -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. Requiring
273
- * exactly one is #52's, and it is a separate decision — this reports what it
274
- * found so a count is visible either way.
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, and (until #54) no link
295
- * resolution — so routing it through {@link renderPages} would buy it a pipeline
296
- * it has no input for, and would make homepage-only mode depend on the index,
297
- * the foreign manifests and the table universe that mode exists to not build.
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
- if (meta) {
492
- data.title = meta.title;
493
- // Guarded: a title-only entry would otherwise emit
494
- // `banner: undefined`, which the YAML serializer rejects.
495
- if (meta.banner) data.banner = meta.banner;
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?.banner) data.banner = meta.banner;
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
- // A section may have no hero: the images are CDN assets and not every
658
- // section has one. An explicit `banner: undefined` is not a value YAML
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).