@heroiclands/package-build 10.0.1 → 11.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.
Files changed (44) hide show
  1. package/CHANGELOG.md +279 -0
  2. package/CONTENT.md +218 -71
  3. package/MIGRATING.md +64 -0
  4. package/bin/content-build.mjs +59 -75
  5. package/docs/content-format.md +90 -67
  6. package/engine/base-compiler.mjs +7 -1
  7. package/engine/content-address.mjs +71 -18
  8. package/engine/content-format-check.mjs +1 -1
  9. package/engine/content-links.mjs +93 -112
  10. package/engine/content-lint.mjs +14 -10
  11. package/engine/content-slug.mjs +39 -105
  12. package/engine/diagnostics.mjs +16 -2
  13. package/engine/frontmatter-lint.mjs +26 -13
  14. package/engine/helpers.mjs +31 -68
  15. package/engine/homepage.mjs +131 -86
  16. package/engine/index.mjs +2 -5
  17. package/engine/manifest-emit.mjs +23 -4
  18. package/engine/note-vocabulary.mjs +58 -1
  19. package/engine/retired-fields.mjs +117 -6
  20. package/engine/site-build.mjs +182 -59
  21. package/engine/site-index.mjs +57 -102
  22. package/engine/web-wikilinks.mjs +183 -127
  23. package/engine/wikilink-syntax.mjs +174 -34
  24. package/engine/wikilinks.mjs +159 -117
  25. package/package.json +1 -1
  26. package/types/engine/base-compiler.d.mts +1 -1
  27. package/types/engine/content-address.d.mts +46 -14
  28. package/types/engine/content-links.d.mts +13 -17
  29. package/types/engine/content-slug.d.mts +11 -48
  30. package/types/engine/diagnostics.d.mts +14 -1
  31. package/types/engine/helpers.d.mts +4 -3
  32. package/types/engine/homepage.d.mts +96 -60
  33. package/types/engine/index.d.mts +0 -1
  34. package/types/engine/note-vocabulary.d.mts +43 -0
  35. package/types/engine/retired-fields.d.mts +78 -1
  36. package/types/engine/site-build.d.mts +70 -17
  37. package/types/engine/site-index.d.mts +19 -21
  38. package/types/engine/web-wikilinks.d.mts +29 -28
  39. package/types/engine/wikilink-syntax.d.mts +126 -40
  40. package/types/engine/wikilinks.d.mts +29 -24
  41. package/engine/abbreviations.mjs +0 -0
  42. package/engine/alias-index.mjs +0 -153
  43. package/types/engine/abbreviations.d.mts +0 -44
  44. package/types/engine/alias-index.d.mts +0 -122
@@ -49,8 +49,8 @@ import path from "node:path";
49
49
  import { createRequire } from "node:module";
50
50
  import matter from "gray-matter";
51
51
 
52
- import { contentSlug, findSlugCollisions, slugify } from "./content-slug.mjs";
53
- import { sectionOf } from "./content-address.mjs";
52
+ import { slugify } from "./content-slug.mjs";
53
+ import { addressSlug, sectionOf } from "./content-address.mjs";
54
54
  import { protectCode } from "./code-fences.mjs";
55
55
  import { expandContentTables } from "./content-tables.mjs";
56
56
  import { buildSiteIndex, wikiContext } from "./site-index.mjs";
@@ -61,8 +61,8 @@ import { deriveBeingInfo, isBeing } from "../sohl/being-info.mjs";
61
61
  import { loadPackConfig } from "./pack-config.mjs";
62
62
  import { searchableFrontmatter } from "./note-package.mjs";
63
63
  import {
64
- HOMEPAGE_DESTINATION,
65
64
  checkHomepageCount,
65
+ homepageDestination,
66
66
  homepageFrontmatter,
67
67
  homepageTitle,
68
68
  isHomepage,
@@ -75,10 +75,12 @@ const require = createRequire(import.meta.url);
75
75
  * Every `.md` file under `dir`, depth-first in directory order.
76
76
  *
77
77
  * Deliberately *not* {@link walkMarkdownTree}, whose stack-based walk yields a
78
- * tree in reverse. Order is load-bearing here and nowhere else: the address
79
- * index resolves a bare `[[Name]]` on a first-writer-wins basis, so reversing
80
- * the walk silently changes which page an ambiguous name resolves to. A pack
81
- * compile has no such dependency, which is why the two walks can differ.
78
+ * tree in reverse. Order was load-bearing here when the address index carried
79
+ * first-writer-wins fallbacks for a page's name, filename and slug reversing
80
+ * the walk silently changed which page an ambiguous name resolved to. Those
81
+ * fallbacks are gone with the bare `[[Name]]` form (#180), so this is now
82
+ * ordinary reading order rather than a dependency; it is kept because a site's
83
+ * emitted pages should not reorder for no reason.
82
84
  *
83
85
  * @param {string} dir - Directory to walk.
84
86
  * @param {readonly string[]} skip - Directory names to ignore at any depth.
@@ -123,14 +125,25 @@ function readNote(file) {
123
125
  * The content tree's pages, and what could not be addressed.
124
126
  *
125
127
  * @param {string} contentBase - Absolute path to the content tree.
126
- * @param {object} ctx - `{ packages, contentPackage, skipDirectories, mount,
127
- * scheme }`. `contentPackage` is the package a note that declares none
128
- * belongs to.
129
- * @returns {{pages: object[], slugFindings: object[], fmLinkFindings: object[]}}
128
+ * @param {object} ctx - `{ packages, contentPackage, skipDirectories, base,
129
+ * mount, scheme }`. `contentPackage` is the package a note that declares none
130
+ * belongs to; `base` is where the package is served and `mount` is where its
131
+ * content tree sits inside it.
132
+ * @returns {{pages: object[], addressFindings: object[], fmLinkFindings: object[]}}
130
133
  */
131
134
  export function collectContentPages(contentBase, ctx) {
135
+ // Where an addressed page publishes. The section below is guarded so a note
136
+ // is never "written to `undefined/`"; the same reasoning applies here, and a
137
+ // missing `base` would put *every* page there rather than one. It is the
138
+ // caller's contract rather than a note's defect, so it throws instead of
139
+ // being collected as a finding (#195).
140
+ if (typeof ctx.base !== "string" || !ctx.base) {
141
+ throw new TypeError(
142
+ "collectContentPages: `ctx.base` must be a non-empty string — it is the package address every page's URL is built on",
143
+ );
144
+ }
132
145
  const pages = [];
133
- const slugFindings = [];
146
+ const addressFindings = [];
134
147
  const fmLinkFindings = [];
135
148
 
136
149
  for (const file of walkSiteTree(contentBase, ctx.skipDirectories)) {
@@ -142,9 +155,10 @@ export function collectContentPages(contentBase, ctx) {
142
155
  // (#56).
143
156
  const pkg = ctx.contentPackage;
144
157
  if (!ctx.packages.has(pkg) || !fm.type) continue;
145
- // A homepage is addressed by the *package*, not by its own name, so it
146
- // never takes a section and a slug (#51). {@link collectHomepages}
147
- // gathers it instead.
158
+ // A homepage is addressed like any other note (#182), but it is
159
+ // gathered by {@link collectHomepages} rather than here: it is the
160
+ // whole of a homepage-only build, which never walks the tree for
161
+ // content pages at all (#55).
148
162
  if (isHomepage(fm)) continue;
149
163
 
150
164
  for (const hit of frontmatterWikilinks(fm)) {
@@ -152,24 +166,40 @@ export function collectContentPages(contentBase, ctx) {
152
166
  }
153
167
 
154
168
  const name = fm.name?.full ?? path.basename(file, ".md");
155
- // The URL segment derives from the name (#1278), never from the
156
- // shortcode, which is identity referenced by saved world data rather
157
- // than presentation.
169
+ // The page's address, and therefore its URL (#181). `name` is the
170
+ // display string and nothing else: it titles the page and labels an
171
+ // inbound link, and moving it moves no address.
158
172
  let slug;
159
173
  try {
160
- slug = contentSlug(name);
174
+ slug = addressSlug(fm);
161
175
  } catch (err) {
162
- slugFindings.push({ file, reason: err.message });
176
+ addressFindings.push({ file, reason: err.message });
163
177
  continue;
164
178
  }
165
179
 
166
180
  const base = path.basename(file);
167
181
  const isReadme = base.toLowerCase() === "readme.md";
168
182
  const sec = sectionOf(fm);
183
+ // A page's URL no longer contains its section, but the section is still
184
+ // what decides the directory the file is written to — and Hugo derives
185
+ // a page's section from that directory, not from its URL. So a note
186
+ // with none is still a note with nowhere to be published, and is
187
+ // reported rather than written to `undefined/`.
188
+ if (typeof sec !== "string" || !sec) {
189
+ addressFindings.push({
190
+ file,
191
+ reason: `type "${fm.type}" has no section, so there is nowhere to file the page`,
192
+ });
193
+ continue;
194
+ }
169
195
  const rel = path.relative(contentBase, file);
170
196
  pages.push({
171
197
  kind: "content",
172
198
  fm,
199
+ // The note's own path on disk. Carried so a link finding can be
200
+ // reported as `file:line:column:` against the source an author
201
+ // edits, rather than against the page this build emits (#184).
202
+ file,
173
203
  // The page's package, recorded once here so every consumer — the
174
204
  // index's canonical keys, the table universe, the local-package set
175
205
  // — reads one configured value and never frontmatter (#56).
@@ -189,11 +219,17 @@ export function collectContentPages(contentBase, ctx) {
189
219
  // authoring folder, for grouped landings.
190
220
  folder: path.basename(path.dirname(file)),
191
221
  sec,
192
- url: isReadme ? `${ctx.mount}${sec}/` : `${ctx.mount}${sec}/${slug}/`,
222
+ // A landing page **is** its section, so it is addressed by the
223
+ // mount the section lives at; every other page is addressed by
224
+ // `(type, shortcode)` at the package root, which takes no mount
225
+ // (#181). The file is still written into `<sec>/` either way — see
226
+ // {@link pageDestination} — and the front matter carries this `url`
227
+ // so Hugo publishes it at its address rather than at its path.
228
+ url: isReadme ? `${ctx.mount}${sec}/` : `${ctx.base}${slug}/`,
193
229
  isReadme,
194
230
  });
195
231
  }
196
- return { pages, slugFindings, fmLinkFindings };
232
+ return { pages, addressFindings, fmLinkFindings };
197
233
  }
198
234
 
199
235
  /**
@@ -234,6 +270,8 @@ export function collectTreePages(tree, ctx) {
234
270
  kind: "tree",
235
271
  tree,
236
272
  fm,
273
+ // As above: the source file, for a located link diagnostic (#184).
274
+ file,
237
275
  // The H1 is stripped: the page title renders it.
238
276
  body: body.replace(/^\s*#\s+.*$\r?\n?/m, ""),
239
277
  name,
@@ -266,22 +304,39 @@ export function collectTreePages(tree, ctx) {
266
304
  * count is what {@link checkHomepageCount} judges (#52) — this walk reports
267
305
  * what it found, and {@link buildSite} decides whether that is one.
268
306
  *
307
+ * A homepage that declares no `shortcode` has no address (#182), and is
308
+ * reported rather than written: it is the same finding a content page's missing
309
+ * shortcode produces, and it has to be available in homepage-only mode, where
310
+ * no other gate runs.
311
+ *
312
+ * **It is still counted.** An unaddressable homepage is a homepage — dropping
313
+ * it from the list would make {@link checkHomepageCount} report a tree with one
314
+ * as having none, sending its author to write a second front page instead of a
315
+ * line of frontmatter.
316
+ *
269
317
  * @param {string} contentBase - Absolute path to the content tree.
270
318
  * @param {object} ctx - `{ skipDirectories }`.
271
- * @returns {{pages: object[]}} The homepage notes, in walk order.
319
+ * @returns {{pages: object[], addressFindings: object[]}} The homepage notes,
320
+ * in walk order, and the ones among them that could not be addressed.
272
321
  */
273
322
  export function collectHomepages(contentBase, ctx) {
274
323
  const pages = [];
324
+ const addressFindings = [];
275
325
  for (const file of walkSiteTree(contentBase, ctx.skipDirectories)) {
276
326
  const note = readNote(file);
277
327
  if (!note || !isHomepage(note.fm)) continue;
328
+ try {
329
+ addressSlug(note.fm);
330
+ } catch (err) {
331
+ addressFindings.push({ file, reason: err.message });
332
+ }
278
333
  pages.push({ kind: "homepage", file, fm: note.fm, body: note.body });
279
334
  }
280
- return { pages };
335
+ return { pages, addressFindings };
281
336
  }
282
337
 
283
338
  /**
284
- * Writes each homepage at the package's own root.
339
+ * Writes each homepage at its address, below the package's own root.
285
340
  *
286
341
  * Its own writer, deliberately small. A homepage is authored markdown published
287
342
  * verbatim — no table expansion, no section landing and no link resolution — so
@@ -295,20 +350,31 @@ export function collectHomepages(contentBase, ctx) {
295
350
  * {@link auditHomepageLinks} reads the `landing:` addresses and the body's
296
351
  * markdown links, and reports a wikilink on the page rather than resolving one.
297
352
  *
353
+ * **Its destination is no longer fixed** (#182). The file is written at the
354
+ * note's address, flat at the package's site root, and the page states that
355
+ * address as its `url` — the same separation of file from URL every other page
356
+ * has. Nothing is written at `/<package>/` itself: that becomes a redirect the
357
+ * package's own repository authors, which is a routing fact rather than a page.
358
+ *
298
359
  * @param {string} outRoot - The package's site root — the configured `site.out`,
299
360
  * one level above the content mount.
300
361
  * @param {readonly object[]} pages - From {@link collectHomepages}.
301
362
  * @param {object} config - The resolved configuration, for the package name and
302
363
  * the default title.
364
+ * @param {object} [options] - Options.
365
+ * @param {string} [options.base] - Where the package is served; defaults to the
366
+ * configured `site.base`, and to `/<contentPackage>/` below that.
303
367
  * @returns {number} How many pages were written.
304
368
  */
305
- export function writeHomepages(outRoot, pages, config) {
369
+ export function writeHomepages(outRoot, pages, config, { base } = {}) {
370
+ const at = base || config.site?.base || `/${config.contentPackage}/`;
306
371
  for (const page of pages) {
307
372
  const data = homepageFrontmatter(page.fm, {
308
373
  contentPackage: config.contentPackage,
309
374
  title: homepageTitle(page.fm, config),
375
+ base: at,
310
376
  });
311
- const dest = path.join(outRoot, HOMEPAGE_DESTINATION);
377
+ const dest = path.join(outRoot, homepageDestination(page.fm));
312
378
  fs.mkdirSync(path.dirname(dest), { recursive: true });
313
379
  fs.writeFileSync(dest, matter.stringify(page.body, data));
314
380
  }
@@ -324,15 +390,19 @@ export function writeHomepages(outRoot, pages, config) {
324
390
  *
325
391
  * - **Frontmatter wikilinks** first, because frontmatter is copied to the page
326
392
  * verbatim and a link written in one reaches the reader as literal `[[…]]`.
327
- * - **Slugs and collisions** next: a note that derives no URL, or two that
328
- * derive the same one, would silently drop or overwrite a page.
393
+ * - **Addresses** next: a note that has no address no shortcode to be
394
+ * addressed by, or no section to be filed under — would silently drop a page.
395
+ * There is no collision gate beside it: an address is `(type, shortcode)`,
396
+ * which is unique within a package by rule, so two pages cannot claim one URL
397
+ * (#181).
329
398
  * - **Foreign manifests** last, in two steps. *Unusable* is a file this build
330
399
  * cannot read; *unaddressable* is one it can read but cannot look anything up
331
400
  * in — a distinction worth keeping, because the second surfaces as a pile of
332
401
  * dead links blaming the notes that cite them rather than the file at fault.
333
402
  *
334
403
  * @param {object[]} pages - Every page, from both walks.
335
- * @param {object} findings - `{ slugFindings, fmLinkFindings }` from collection.
404
+ * @param {object} findings - `{ addressFindings, fmLinkFindings }` from
405
+ * collection.
336
406
  * @param {object} options - `{ manifestDir }`.
337
407
  * @returns {object} The gate results and, when they pass, the built index.
338
408
  */
@@ -343,8 +413,7 @@ export function siteGates(pages, findings, { manifestDir }) {
343
413
  // reaching these gates (#52). Present so every caller reads one shape.
344
414
  homepages: [],
345
415
  frontmatterLinks: findings.fmLinkFindings ?? [],
346
- slugErrors: findings.slugFindings ?? [],
347
- collisions: [],
416
+ addressErrors: findings.addressFindings ?? [],
348
417
  staleManifests: [],
349
418
  unaddressable: [],
350
419
  conflicts: [],
@@ -352,18 +421,9 @@ export function siteGates(pages, findings, { manifestDir }) {
352
421
  foreign: null,
353
422
  manifests: null,
354
423
  };
355
- if (out.frontmatterLinks.length || out.slugErrors.length) return out;
424
+ if (out.frontmatterLinks.length || out.addressErrors.length) return out;
356
425
 
357
426
  const content = pages.filter((p) => p.kind === "content");
358
- out.collisions = findSlugCollisions(
359
- content.map((p) => ({
360
- sec: p.sec,
361
- slug: p.slug,
362
- src: `${p.tld}/${p.base}`,
363
- })),
364
- );
365
- if (out.collisions.length) return out;
366
-
367
427
  // Which packages are *local* is what decides which manifests are foreign,
368
428
  // and that is only known once the tree is walked — reading it from a
369
429
  // configured list instead silently discarded the manifest of any package
@@ -402,8 +462,7 @@ export function emptyGates() {
402
462
  return {
403
463
  homepages: [],
404
464
  frontmatterLinks: [],
405
- slugErrors: [],
406
- collisions: [],
465
+ addressErrors: [],
407
466
  staleManifests: [],
408
467
  unaddressable: [],
409
468
  conflicts: [],
@@ -418,8 +477,7 @@ export function gatesFailed(gates) {
418
477
  return Boolean(
419
478
  gates.homepages.length ||
420
479
  gates.frontmatterLinks.length ||
421
- gates.slugErrors.length ||
422
- gates.collisions.length ||
480
+ gates.addressErrors.length ||
423
481
  gates.staleManifests.length ||
424
482
  gates.unaddressable.length ||
425
483
  gates.conflicts.length,
@@ -490,12 +548,22 @@ export function sectionFrontmatter(meta) {
490
548
  /**
491
549
  * The frontmatter a page publishes with.
492
550
  *
493
- * An authored `aliases` is Obsidian's a list of *names* a reader might call
551
+ * An authored `aliases` is retired (#180) and refused before a build reaches
552
+ * here, which makes this a guard rather than a working path. It was Obsidian's
553
+ * — a list of *names* a reader might call
494
554
  * the note, which is vault addressing and stays in the vault. Hugo reads
495
555
  * `aliases` as **URL redirects**, so passing them through would publish a
496
556
  * redirect stub at each name. They are dropped, and this build emits no
497
557
  * redirects of its own.
498
558
  *
559
+ * A content page states its own **`url`**, which is its address rather than its
560
+ * path (#181). Hugo would otherwise publish it where the file sits — under the
561
+ * mount, inside its section directory — and the file sits there for a reason:
562
+ * Hugo derives a page's section from its directory, which is what gives the
563
+ * section its landing page, `.CurrentSection` and its per-section layout
564
+ * lookup. So the directory stays and the address is stated, and the two are
565
+ * free to differ.
566
+ *
499
567
  * A content page carries the package the build **derived** (#65). No note
500
568
  * declares one — `package:` is retired (#56) — so the note's frontmatter alone
501
569
  * would publish a page that does not say which package it belongs to. The
@@ -519,7 +587,13 @@ export function pageFrontmatter(page, { readmeSections = {}, decorate }) {
519
587
  // Spread after the note's own frontmatter. Guarded because
520
588
  // `package: undefined` is not a value YAML can carry.
521
589
  ...(page.pkg ? { package: page.pkg } : {}),
590
+ // The address, stated. `slug` is written beside it because it is
591
+ // the last segment of that address and Hugo's own key for one; it
592
+ // decides nothing while `url` is present, but a page that carried
593
+ // only `url` would report a slug Hugo had inferred from the
594
+ // filename.
522
595
  slug,
596
+ url: page.url,
523
597
  title: fm.title ?? name,
524
598
  kbfolder: page.folder,
525
599
  };
@@ -546,7 +620,20 @@ export function pageFrontmatter(page, { readmeSections = {}, decorate }) {
546
620
  return data;
547
621
  }
548
622
 
549
- /** Where a page is written, relative to the output root. */
623
+ /**
624
+ * Where a page is written, relative to the output root.
625
+ *
626
+ * **Into its section directory, which is not where it publishes** (#181). A
627
+ * content page's URL is its address — `/<package>/<type>-<shortcode>/` — and it
628
+ * is stated in the front matter; the file still goes to `<section>/`, because
629
+ * Hugo reads a page's section from its path and nothing else. Flattening the
630
+ * tree to match the URL would take the section landings, `.CurrentSection` and
631
+ * every per-section layout with it.
632
+ *
633
+ * The filename is the address rather than the section-relative half of it, so
634
+ * two sections cannot fight over one file: a `doc` note routes by its `subType`,
635
+ * which may be spelled the same as another note's `type`.
636
+ */
550
637
  export function pageDestination(page) {
551
638
  if (page.kind === "content") {
552
639
  return page.isReadme ?
@@ -582,7 +669,6 @@ export function renderPages(pages, options) {
582
669
  outRoot,
583
670
  index,
584
671
  foreign,
585
- manifests,
586
672
  universe,
587
673
  pass = {},
588
674
  readmeSections,
@@ -598,10 +684,10 @@ export function renderPages(pages, options) {
598
684
  const src = page.rel ?? `${page.sec}/${page.base}`;
599
685
  const ctx = wikiContext(index, {
600
686
  src,
687
+ file: page.file,
601
688
  type: page.fm.type ?? null,
602
689
  errors: wikiErrors,
603
690
  foreignIndex: foreign.index,
604
- manifestsComplete: manifests.complete,
605
691
  });
606
692
 
607
693
  const resolve = (text) => {
@@ -841,9 +927,9 @@ export function buildSite({ config, outRoot } = {}) {
841
927
  // Homepage-only has no content mount, so the package's root *is*
842
928
  // the output root and `--out` redirects the whole of it.
843
929
  : outBase;
844
- // The homepage publishes at `/<contentPackage>/`, which is the package's
845
- // own root — one level above the content mount, and the same directory in
846
- // homepage-only mode.
930
+ // The homepage publishes at `/<contentPackage>/<type>-<shortcode>/`, so its
931
+ // file goes at the package's own root — one level above the content mount,
932
+ // and the same directory in homepage-only mode.
847
933
  const homeRoot = publishesContent ? outBase : out;
848
934
 
849
935
  const packages = new Set(site.packages.length ? site.packages : [resolved.contentPackage]);
@@ -854,11 +940,16 @@ export function buildSite({ config, outRoot } = {}) {
854
940
  // retired, so this is the only source of it (#56).
855
941
  contentPackage: resolved.contentPackage,
856
942
  skipDirectories: resolved.skipDirectories,
943
+ // Where the package is served, which is where an addressed page
944
+ // publishes: an address is `(type, shortcode)`, a package-wide
945
+ // identity that takes no content mount (#181).
946
+ base,
857
947
  mount,
858
948
  scheme,
859
949
  };
860
950
 
861
- const homepages = collectHomepages(resolved.paths.content, ctx).pages;
951
+ const collected = collectHomepages(resolved.paths.content, ctx);
952
+ const homepages = collected.pages;
862
953
 
863
954
  // Exactly one homepage, and checked here — before the output tree is
864
955
  // cleared and before either mode branches (#52). Before the clear, because
@@ -866,10 +957,24 @@ export function buildSite({ config, outRoot } = {}) {
866
957
  // bad tree. Before the branch, because the requirement does not vary by
867
958
  // mode: `publish.site` chooses whether the *content* surfaces are
868
959
  // published, and the homepage is the floor beneath both.
869
- const homepageFindings = checkHomepageCount(homepages, {
960
+ //
961
+ // The count is judged first, and alone when it fires: a tree with two
962
+ // homepages does not need to be told about each one's address as well, and
963
+ // a tree with none has no address to report.
964
+ const counted = checkHomepageCount(homepages, {
870
965
  contentBase: resolved.paths.content,
871
966
  contentPackage: resolved.contentPackage,
872
967
  });
968
+ // A homepage that cannot be addressed is reported in the same place, and
969
+ // reaches homepage-only mode — which runs no other gate at all (#182).
970
+ const homepageFindings =
971
+ counted.length ? counted : (
972
+ collected.addressFindings.map((f) => ({
973
+ file: f.file,
974
+ severity: "error",
975
+ message: `${f.reason}, so there is no page to publish at \`${base}\``,
976
+ }))
977
+ );
873
978
  if (homepageFindings.length) {
874
979
  return {
875
980
  gates: { ...emptyGates(), homepages: homepageFindings },
@@ -894,7 +999,7 @@ export function buildSite({ config, outRoot } = {}) {
894
999
  tableErrors: [],
895
1000
  wikiErrors: [],
896
1001
  stats: {
897
- homepages: writeHomepages(homeRoot, homepages, resolved),
1002
+ homepages: writeHomepages(homeRoot, homepages, resolved, { base }),
898
1003
  landings: 0,
899
1004
  out: homeRoot,
900
1005
  },
@@ -905,6 +1010,25 @@ export function buildSite({ config, outRoot } = {}) {
905
1010
  const pages = [...content.pages];
906
1011
  const fmLinkFindings = [...content.fmLinkFindings];
907
1012
 
1013
+ // The homepage is **indexed but not rendered** (#182). Now that it has an
1014
+ // address, `[[homepage-root|Text]]` is an ordinary wikilink and has to
1015
+ // resolve to the page the build publishes — which means the address index
1016
+ // must hold it. It still takes no part in `renderPages`: a homepage is
1017
+ // authored markdown published verbatim, with no table expansion and no link
1018
+ // resolution of its own, and routing it through that pipeline would buy it
1019
+ // a pass it has no input for.
1020
+ const homepageEntries = homepages.map((page) => ({
1021
+ kind: "content",
1022
+ fm: page.fm,
1023
+ pkg: resolved.contentPackage,
1024
+ name: page.fm.name?.full ?? homepageTitle(page.fm, resolved),
1025
+ slug: addressSlug(page.fm),
1026
+ base: path.basename(page.file),
1027
+ sec: sectionOf(page.fm),
1028
+ url: `${base}${addressSlug(page.fm)}/`,
1029
+ isReadme: false,
1030
+ }));
1031
+
908
1032
  const trees = site.trees.map((t) => ({
909
1033
  ...t,
910
1034
  from: path.resolve(resolved.rootDir, t.from),
@@ -917,7 +1041,7 @@ export function buildSite({ config, outRoot } = {}) {
917
1041
  }
918
1042
 
919
1043
  const gates = siteGates(
920
- pages,
1044
+ [...pages, ...homepageEntries],
921
1045
  { ...content, fmLinkFindings },
922
1046
  { manifestDir: resolved.paths.manifests },
923
1047
  );
@@ -940,7 +1064,6 @@ export function buildSite({ config, outRoot } = {}) {
940
1064
  outRoot: out,
941
1065
  index: gates.index,
942
1066
  foreign: gates.foreign,
943
- manifests: gates.manifests,
944
1067
  universe: tableUniverse(pages),
945
1068
  pass,
946
1069
  readmeSections: site.readmeSections,
@@ -963,7 +1086,7 @@ export function buildSite({ config, outRoot } = {}) {
963
1086
 
964
1087
  // Last, and outside the mount: the package's front page is not part of the
965
1088
  // content tree it introduces.
966
- const homepagesWritten = writeHomepages(homeRoot, homepages, resolved);
1089
+ const homepagesWritten = writeHomepages(homeRoot, homepages, resolved, { base });
967
1090
 
968
1091
  return {
969
1092
  gates,