@heroiclands/package-build 6.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,14 +49,18 @@
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
+
59
62
  import { matchAllOutsideCode } from "./code-fences.mjs";
63
+ import { formatLocator, positionInFrontmatter } from "./diagnostics.mjs";
60
64
 
61
65
  /**
62
66
  * The note type that compiles to the package homepage.
@@ -98,6 +102,206 @@ export function isHomepage(fm) {
98
102
  return Boolean(fm) && fm.type === HOMEPAGE_TYPE;
99
103
  }
100
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
+
101
305
  /**
102
306
  * The title a homepage publishes under.
103
307
  *
@@ -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 }`.
@@ -345,6 +346,10 @@ export function writeHomepages(outRoot, pages, config) {
345
346
  */
346
347
  export function siteGates(pages, findings, { manifestDir }) {
347
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: [],
348
353
  frontmatterLinks: findings.fmLinkFindings ?? [],
349
354
  slugErrors: findings.slugFindings ?? [],
350
355
  collisions: [],
@@ -403,6 +408,7 @@ export function siteGates(pages, findings, { manifestDir }) {
403
408
  */
404
409
  export function emptyGates() {
405
410
  return {
411
+ homepages: [],
406
412
  frontmatterLinks: [],
407
413
  slugErrors: [],
408
414
  collisions: [],
@@ -418,6 +424,7 @@ export function emptyGates() {
418
424
  /** Whether any gate produced a finding. */
419
425
  export function gatesFailed(gates) {
420
426
  return Boolean(
427
+ gates.homepages.length ||
421
428
  gates.frontmatterLinks.length ||
422
429
  gates.slugErrors.length ||
423
430
  gates.collisions.length ||
@@ -455,6 +462,39 @@ export function tableUniverse(pages) {
455
462
  return byPackage;
456
463
  }
457
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
+
458
498
  /**
459
499
  * The frontmatter a page publishes with.
460
500
  *
@@ -494,12 +534,11 @@ export function pageFrontmatter(page, { readmeSections = {}, decorate }) {
494
534
  if (decorate) decorate(data, page);
495
535
  if (isReadme) {
496
536
  const meta = readmeSections[sec];
497
- if (meta) {
498
- data.title = meta.title;
499
- // Guarded: a title-only entry would otherwise emit
500
- // `banner: undefined`, which the YAML serializer rejects.
501
- if (meta.banner) data.banner = meta.banner;
502
- }
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));
503
542
  }
504
543
  } else {
505
544
  // A tree's own landing describes the *mount*, and nothing beneath it. A
@@ -509,7 +548,7 @@ export function pageFrontmatter(page, { readmeSections = {}, decorate }) {
509
548
  const isSectionRoot = path.posix.dirname(page.rel) === ".";
510
549
  const meta = isReadme && isSectionRoot ? readmeSections[sec] : null;
511
550
  data = { ...fm, title: meta?.title ?? fm.title ?? name };
512
- if (meta?.banner) data.banner = meta.banner;
551
+ if (meta) Object.assign(data, sectionFrontmatter(meta));
513
552
  }
514
553
  delete data.aliases;
515
554
  return data;
@@ -660,15 +699,11 @@ export function writeSectionLandings(
660
699
  for (const [sec, meta] of Object.entries(sections)) {
661
700
  const dir = path.join(outRoot, sec);
662
701
  fs.mkdirSync(dir, { recursive: true });
663
- // A section may have no hero: the images are CDN assets and not every
664
- // section has one. An explicit `banner: undefined` is not a value YAML
665
- // 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.
666
704
  fs.writeFileSync(
667
705
  path.join(dir, "_index.md"),
668
- matter.stringify("", {
669
- title: meta.title,
670
- ...(meta.banner ? { banner: meta.banner } : {}),
671
- }),
706
+ matter.stringify("", sectionFrontmatter(meta)),
672
707
  );
673
708
  written += 1;
674
709
  }
@@ -848,12 +883,32 @@ export function buildSite({ config, outRoot } = {}) {
848
883
  scheme,
849
884
  };
850
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
+
851
908
  // The whole tree is a build artifact, regenerated every run: a page whose
852
909
  // note was deleted or renamed would otherwise linger and keep publishing.
853
910
  fs.rmSync(outBase, { recursive: true, force: true });
854
911
 
855
- const homepages = collectHomepages(resolved.paths.content, ctx).pages;
856
-
857
912
  // Homepage-only stops here, and stopping is the point: nothing below reads
858
913
  // the content tree for pages, so `sohl-kethira-basic` and `harn-adventures`
859
914
  // cannot publish one whatever else their `site:` block declares (#55).
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@heroiclands/package-build",
3
- "version": "6.0.0",
3
+ "version": "6.1.0",
4
4
  "description": "Shared toolchain for building and shipping a HeroicLands Foundry VTT package — content compilation, manifest, localization, staging, bundle, release and deployment.",
5
5
  "license": "GPL-3.0-or-later",
6
6
  "type": "module",
@@ -0,0 +1,108 @@
1
+ /**
2
+ * The address space a set of compiled Item pack directories publishes.
3
+ *
4
+ * The directories are read as one space for the same reason the actors pass
5
+ * reads them as one: a being names an item by `(type, shortcode)` and never by
6
+ * the pack it happens to ship in. Both sides of a diff are built by this one
7
+ * function, so a released catalogue extracted by `deps fetch` and a freshly
8
+ * compiled pack are indexed identically and a difference between them is a real
9
+ * one rather than an artefact of two readers.
10
+ *
11
+ * A missing directory throws rather than reading as an empty space: an empty
12
+ * baseline would report every address in the package as withdrawn, and an empty
13
+ * current side would report every address as gone — the loudest possible
14
+ * output from the quietest possible mistake.
15
+ *
16
+ * @param {readonly string[]} dirs - Directories of item JSON.
17
+ * @returns {Map<string, {id: string, name: string, type: string, shortcode: string, file: string}>}
18
+ * Every item, keyed `type:shortcode`.
19
+ */
20
+ export function readItemAddresses(dirs: readonly string[]): Map<string, {
21
+ id: string;
22
+ name: string;
23
+ type: string;
24
+ shortcode: string;
25
+ file: string;
26
+ }>;
27
+ /**
28
+ * Every address the baseline published that this build does not.
29
+ *
30
+ * An address that merely *arrived* is not a finding: adding one breaks nobody.
31
+ * The arrivals are read only to answer the one question that matters about a
32
+ * departure — is the document still here under another name?
33
+ *
34
+ * @param {Map<string, object>} baseline - The released address space.
35
+ * @param {Map<string, object>} current - This build's address space.
36
+ * @param {object} opts
37
+ * @param {string} opts.baseline - What the baseline is, for the message —
38
+ * conventionally `<package>@<version>`.
39
+ * @returns {Array<object>} One finding per departed address, in address order
40
+ * so two runs read the same. `kind` is `"renamed"` (with `to`) or
41
+ * `"withdrawn"`.
42
+ */
43
+ export function diffItemAddresses(baseline: Map<string, object>, current: Map<string, object>, { baseline: label }: {
44
+ baseline: string;
45
+ }): Array<object>;
46
+ /**
47
+ * Every content note in a tree, indexed by the document id it authors.
48
+ *
49
+ * The address space is read from compiled output because that is what actually
50
+ * ships; the tree is read only to place a finding somewhere a reader can open
51
+ * and fix it. Each source answers the question it is good at, and the id is the
52
+ * exact key that joins them.
53
+ *
54
+ * @param {string} contentBase - Root of the content tree.
55
+ * @param {object} [opts]
56
+ * @param {readonly string[]} [opts.skipDirectories] - Passed to the walk.
57
+ * @returns {Map<string, string>} Document id → the note's absolute path.
58
+ */
59
+ export function noteFilesById(contentBase: string, { skipDirectories }?: {
60
+ skipDirectories?: readonly string[] | undefined;
61
+ }): Map<string, string>;
62
+ /**
63
+ * Where to send the reader for one finding.
64
+ *
65
+ * A rename is fixed in the note that made it, so a finding whose id is still in
66
+ * this tree is reported at that note's `shortcode:` line — the line the author
67
+ * just edited. A withdrawal has no such note by definition, so it degrades to
68
+ * the baseline document, which is the only artefact left that records the
69
+ * address existing. When neither is readable the position is **dropped**, never
70
+ * defaulted to `1:1`.
71
+ *
72
+ * @param {object} finding - One finding from {@link diffItemAddresses}.
73
+ * @param {Map<string, string>} noteFiles - From {@link noteFilesById}.
74
+ * @returns {{file?: string, line?: number, column?: number}} Spreadable
75
+ * position fields for {@link formatDiagnostic}.
76
+ */
77
+ export function locateAddressFinding(finding: object, noteFiles: Map<string, string>): {
78
+ file?: string;
79
+ line?: number;
80
+ column?: number;
81
+ };
82
+ /**
83
+ * What one finding says, without a locator or a severity.
84
+ *
85
+ * The rename message names the identity it matched on, because that is what
86
+ * separates this from a spelling suggestion: the reader can check the id in
87
+ * both artefacts. The withdrawal message names no successor, because none is
88
+ * known — and says so, rather than leaving the reader to wonder whether one was
89
+ * looked for.
90
+ *
91
+ * @param {object} finding - One finding from {@link diffItemAddresses}.
92
+ * @returns {string} The message.
93
+ */
94
+ export function addressFindingMessage(finding: object): string;
95
+ /**
96
+ * One finding, in the standard `file:line:column: severity: message` form.
97
+ *
98
+ * @param {object} finding - One finding from {@link diffItemAddresses}.
99
+ * @param {{file?: string, line?: number, column?: number}} at - From
100
+ * {@link locateAddressFinding}.
101
+ * @param {"warning"|"error"} [severity] - `error` when the caller is gating.
102
+ * @returns {string} The formatted diagnostic, path first on the line.
103
+ */
104
+ export function formatAddressFinding(finding: object, at: {
105
+ file?: string;
106
+ line?: number;
107
+ column?: number;
108
+ }, severity?: "warning" | "error"): string;
@@ -16,12 +16,15 @@ export function isValidShortcode(value: unknown): boolean;
16
16
  * @param {object} [opts]
17
17
  * @param {readonly string[]} [opts.skipDirectories] - Directory names the walk
18
18
  * ignores. Defaults to the configured list.
19
+ * @param {string} [opts.contentPackage] - The package this tree builds, for the
20
+ * homepage rule. Dropped from that finding when unknown rather than guessed.
19
21
  * @returns {{findings: Array<{file: string, line?: number, column?: number,
20
22
  * severity: "error"|"warning", message: string}>, notes: number,
21
23
  * keys: number}} The findings, and what was inspected to produce them.
22
24
  */
23
- export function lintContentTree(contentBase: string, { skipDirectories }?: {
25
+ export function lintContentTree(contentBase: string, { skipDirectories, contentPackage }?: {
24
26
  skipDirectories?: readonly string[] | undefined;
27
+ contentPackage?: string | undefined;
25
28
  }): {
26
29
  findings: Array<{
27
30
  file: string;
@@ -5,6 +5,87 @@
5
5
  * @returns {boolean} Whether it is a homepage note.
6
6
  */
7
7
  export function isHomepage(fm: object | null | undefined): boolean;
8
+ /**
9
+ * The address-bearing fields one note authors, in the order it authored them.
10
+ *
11
+ * Authoring order rather than declaration order, so a caller emitting one
12
+ * diagnostic per finding emits them top to bottom down the file — the order a
13
+ * reader and a compiler-output parser both expect.
14
+ *
15
+ * Presence is the whole test: `shortcode:` authored empty still says "this page
16
+ * has an address of its own", and a value cannot make the claim true.
17
+ *
18
+ * Returned without a locator, because the two things that would supply one —
19
+ * the raw note text and the position helper — belong to the caller. This
20
+ * mirrors {@link module:engine/retired-fields}, whose retired-field messages
21
+ * are likewise positioned by whoever reports them.
22
+ *
23
+ * @param {object|null|undefined} fm - Parsed frontmatter.
24
+ * @returns {Array<{key: string, message: string}>} One entry per field the note
25
+ * authored, empty for any note that is not a homepage.
26
+ */
27
+ export function checkHomepageAddressFields(fm: object | null | undefined): Array<{
28
+ key: string;
29
+ message: string;
30
+ }>;
31
+ /**
32
+ * Require exactly one homepage note in a content tree (#52).
33
+ *
34
+ * "Exactly one" is two rules, and they are **one severity** because they are
35
+ * one defect: a package whose front page is not the page a person chose.
36
+ *
37
+ * - _None_ and the package serves nothing at `/<package>/`. That is the failure
38
+ * #50 exists to prevent, and it is silent — the site build reports `wrote 0
39
+ * homepage(s)` and exits 0.
40
+ * - _Two_ and it serves a page nobody chose. Every homepage is written to the
41
+ * same {@link HOMEPAGE_DESTINATION}, so the second overwrites the first and
42
+ * the package's front page is decided by the order the walk happened to reach
43
+ * the files in — by *filename*, on a type whose whole point is that it is
44
+ * routed by frontmatter. There is no "first wins" convention to fall back on,
45
+ * so nothing here can pick the right one.
46
+ *
47
+ * Neither has a safe default, so neither is a warning. A warning is the right
48
+ * severity for something a build can proceed past correctly, and a build that
49
+ * proceeds past either of these publishes the wrong front page while reporting
50
+ * success — which is the exact outcome a warning would be tolerating.
51
+ *
52
+ * **Two is reported once per note, not once for the tree.** Each note is a
53
+ * place an author has to open and edit, and a single finding saying "there are
54
+ * two" sends them hunting for the second.
55
+ *
56
+ * **None is located at the tree, honestly.** There is no file to name, so the
57
+ * locator is the content root — the directory the note is missing from, which
58
+ * is a real path and the one the author adds it to. No line and no column are
59
+ * invented for it, per the diagnostic rules in
60
+ * {@link module:engine/diagnostics}. {@link lintContentTree} already reports an
61
+ * empty walk against the same locator.
62
+ *
63
+ * The rule reads no `site:` configuration and does not vary by
64
+ * `publish.site`: that setting chooses whether the *content* surfaces are
65
+ * published, and the homepage is the floor underneath both modes.
66
+ *
67
+ * @param {ReadonlyArray<{file: string}>} found - The homepage notes, in walk
68
+ * order. Paths may be absolute or relative to the working directory.
69
+ * @param {object} options - Options.
70
+ * @param {string} options.contentBase - Root of the content tree, for the
71
+ * locator when there is no file to name.
72
+ * @param {string} [options.contentPackage] - The package this tree builds.
73
+ * Dropped from the message when unknown rather than guessed.
74
+ * @returns {Array<{file: string, line?: number, column?: number,
75
+ * severity: "error", message: string}>} The findings, one per offending note.
76
+ */
77
+ export function checkHomepageCount(found: ReadonlyArray<{
78
+ file: string;
79
+ }>, { contentBase, contentPackage }: {
80
+ contentBase: string;
81
+ contentPackage?: string | undefined;
82
+ }): Array<{
83
+ file: string;
84
+ line?: number;
85
+ column?: number;
86
+ severity: "error";
87
+ message: string;
88
+ }>;
8
89
  /**
9
90
  * The title a homepage publishes under.
10
91
  *
@@ -107,6 +188,42 @@ export const HOMEPAGE_FIELDS: readonly import("./field-spec.mjs").FieldSpec[];
107
188
  * @type {string}
108
189
  */
109
190
  export const HOMEPAGE_DESTINATION: string;
191
+ /**
192
+ * The top-level fields a homepage refuses, and what each one would decide (#53).
193
+ *
194
+ * A note's URL derives from `name.full` and its identity from
195
+ * `(type, shortcode)`. The homepage is the one page for which neither holds: it
196
+ * publishes at `/<package>/`, fixed by the package id. An author fluent in the
197
+ * conventions writes them here expecting exactly what they do everywhere else,
198
+ * and gets none of it.
199
+ *
200
+ * **They were never inert, which is why ignoring them was the wrong answer.** A
201
+ * `shortcode` puts the note in the address index and in the `dataview` link
202
+ * universe, so `[[homepage-<shortcode>]]` resolves *green* — to
203
+ * `homepage/<slug>/`, an address derived from `name.full` and published by
204
+ * nothing, because a homepage is written to {@link HOMEPAGE_DESTINATION} at the
205
+ * package root. A build that reports a live link to a 404 is worse than one
206
+ * that says nothing. It also inflates `content-build lint`'s address tally, so
207
+ * the lint and the link manifest disagree about what the package publishes.
208
+ *
209
+ * **A named class, not an allow-list, and that boundary is the decision.** The
210
+ * documented envelope is `type` plus an optional `title`, and `landing`,
211
+ * `description` and `banner` are legitimate beside them — but a homepage's
212
+ * frontmatter is *emitted into the published page*
213
+ * ({@link homepageFrontmatter}), so an unrecognised key is a Hugo or theme
214
+ * parameter this build has never heard of and has no standing to refuse.
215
+ * Rejecting unknown keys would make every new theme parameter wait on a
216
+ * package-build release. What is refused is the specific class that makes a
217
+ * false claim about *where this page is*.
218
+ *
219
+ * `aliases` is deliberately not in the class: {@link homepageFrontmatter}
220
+ * already drops it from every emitted page, with a reason of its own, so
221
+ * authoring one is the same no-op it is on any other page rather than a wrong
222
+ * belief about this one's address.
223
+ *
224
+ * @type {ReadonlyMap<string, string>}
225
+ */
226
+ export const HOMEPAGE_REFUSED_FIELDS: ReadonlyMap<string, string>;
110
227
  /**
111
228
  * The two frontmatter keys that hold an address, and what each one means.
112
229
  *