@heroiclands/package-build 22.4.3 → 22.4.4

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 CHANGED
@@ -1,45 +1,37 @@
1
1
  # @heroiclands/package-build
2
2
 
3
+ ## 22.4.4
4
+
5
+ ### Patch Changes
6
+
7
+ Every page a site build publishes carries a `related` block in its front matter — the pages that link to it and the pages it links to, each named once with its title, address and type — which the theme renders as the Related card at the foot of the page. A page nothing connects to carries no block, and the homepage is counted on both sides like any other page.
8
+
9
+ `changelog group` keeps a hard-wrapped paragraph as one block instead of fragmenting it one block per line, and no longer inserts a blank line under a bold sentence a paragraph continues directly beneath.
10
+
3
11
  ## 22.4.3
4
12
 
5
13
  ### Patch Changes
6
14
 
15
+ **A new `changelog group` command folds a release's changeset blocks together by their bold label.**
7
16
  `@heroiclands/package-build/changelog` writes each changeset's summary into `CHANGELOG.md` as its
8
-
9
17
  own block, so three pull requests each touching compendium content leave three separate
10
-
11
18
  `**Compendiums**` blocks instead of one. `changelog group` rewrites the newest release section in
12
-
13
19
  place: every block sharing one label is merged into a single block, bullets kept in their original
14
-
15
20
  order with an exact duplicate kept once, and the merged blocks are ordered by a new
16
-
17
21
  `changelog.labels` configuration key — the unlabelled lead paragraph first, then every declared
18
-
19
22
  label in order, then any undeclared label last with a warning naming it. `changelog check` warns on
20
-
21
23
  a label absent from a declared `changelog.labels` too, so a drifted spelling is caught before a
22
-
23
24
  release ships it.
24
25
 
26
+ **A shipped changelog generator writes a release note without a commit hash.**
25
27
  `@heroiclands/package-build/changelog` is a Changesets changelog generator
26
-
27
28
  whose `getReleaseLine` renders a changeset's summary as a block, verbatim —
28
-
29
29
  no bullet, no indentation, separated from its neighbours by one blank line —
30
-
31
30
  and whose `getDependencyReleaseLine` writes nothing. Point
32
-
33
31
  `.changeset/config.json` at it
34
-
35
32
  (`"changelog": "@heroiclands/package-build/changelog"`) to stop every release
36
-
37
33
  note from carrying a commit-hash prefix.
38
34
 
39
- **A new `changelog group` command folds a release's changeset blocks together by their bold label.**
40
-
41
- **A shipped changelog generator writes a release note without a commit hash.**
42
-
43
35
  ## 22.4.2
44
36
 
45
37
  ### Patch Changes
package/CONTENT.md CHANGED
@@ -2333,6 +2333,53 @@ notes it lists, and link it from the homepage. Nothing is generated between
2333
2333
  the homepage and the pages, so delete the key.
2334
2334
  ````
2335
2335
 
2336
+ ### Every page carries what links to it, and what it links to
2337
+
2338
+ The build resolves every wikilink on every page through the address index, so
2339
+ at the moment it writes a page it holds the whole link graph. It writes the
2340
+ part that concerns each page into that page's front matter:
2341
+
2342
+ ```yaml
2343
+ related:
2344
+ backlinks: # pages that link to this one
2345
+ - title: Afzandah Parnâzar
2346
+ url: /thalorna/being-afzndhprnzr/
2347
+ type: being
2348
+ mentions: # pages this one links to
2349
+ - title: Kethramír
2350
+ url: /thalorna/place-kthrmr/
2351
+ type: place
2352
+ ```
2353
+
2354
+ The theme's `related.html` partial reads both lists and renders them as one
2355
+ "Related" card, grouped by `type`. What the build guarantees about them:
2356
+
2357
+ - **A page appears once per list**, however many times it is linked, and
2358
+ whichever address the link was written to: `docbeing-grod` and `being-grod`
2359
+ are one page, and a page that cites it under both lists it once. A link from
2360
+ a page to itself is not a connection and is dropped.
2361
+ - **Each list is sorted by `type`, then `title`**, so the card's grouping is
2362
+ the same on every build whatever order the notes were walked in.
2363
+ - **Both lists are present whenever either has an entry**, so a theme reads one
2364
+ shape; a page with no links either way carries **no `related` key at all**,
2365
+ which is the partial's silent-disappear convention kept at the source.
2366
+ - **`url` composes `<base><slug>/`**, like every other href this build renders
2367
+ — see [A page's URL is its address](#a-pages-url-is-its-address). The page
2368
+ states its own address as `/<slug>/`; everything pointing _at_ it, this
2369
+ included, carries the base.
2370
+ - **The homepage is a page like any other on both sides.** A content page
2371
+ reaches it through `[[homepage-root|Text]]` and is listed in its backlinks;
2372
+ its own body links are ordinary markdown, resolved against the package root
2373
+ the way a browser resolves them, so `being-grod/`, `/thalorna/being-grod/`
2374
+ and `./being-grod/#top` each count as a mention of that page. A link that
2375
+ names a host leaves the site and counts as nothing.
2376
+ - **Only a page of this site is an endpoint.** A link into another package
2377
+ resolves to an href the build renders but no page it writes, so it is listed
2378
+ on neither side; a backlink can be counted only on a page this build emits.
2379
+
2380
+ `related` is derived, never authored: what links to a page is a fact about
2381
+ every other page in the tree. A note that writes one has it replaced.
2382
+
2336
2383
  ### Why the output location is fixed
2337
2384
 
2338
2385
  The content mount is a build artifact and is **deleted on every run**, so that a
@@ -50,13 +50,22 @@ const CHANGES_HEADING_RE = /^### (?:Major|Minor|Patch) Changes$/gm;
50
50
  *
51
51
  * @param {string} blockText - One block, as {@link topLevelBlocks} collects
52
52
  * it — its first line is the label line for a labelled block.
53
- * @returns {{labelLine: string, rest: string}} `rest` has its leading blank
54
- * line dropped; a block with nothing but the label line yields `""`.
53
+ * @returns {{labelLine: string, rest: string, hadBlankLine: boolean}} `rest`
54
+ * has its leading blank line dropped; a block with nothing but the label
55
+ * line yields `""`. `hadBlankLine` says whether the label line was
56
+ * followed by a blank line in `blockText` — false for a bold sentence a
57
+ * hard-wrapped paragraph continues directly beneath, with no blank line
58
+ * of its own.
55
59
  */
56
60
  function splitLabelLine(blockText) {
57
61
  const nl = blockText.indexOf("\n");
58
- if (nl === -1) return { labelLine: blockText, rest: "" };
59
- return { labelLine: blockText.slice(0, nl), rest: blockText.slice(nl + 1).replace(/^\n+/, "") };
62
+ if (nl === -1) return { labelLine: blockText, rest: "", hadBlankLine: false };
63
+ const after = blockText.slice(nl + 1);
64
+ return {
65
+ labelLine: blockText.slice(0, nl),
66
+ rest: after.replace(/^\n+/, ""),
67
+ hadBlankLine: after.startsWith("\n"),
68
+ };
60
69
  }
61
70
 
62
71
  /**
@@ -115,6 +124,14 @@ function joinItems(items) {
115
124
  * One label's blocks (or every unlabelled one), folded into the single
116
125
  * block `group` writes for it.
117
126
  *
127
+ * A single, unmerged block — the common case, one changeset writing one
128
+ * label once — keeps its own label-to-body separator rather than forcing a
129
+ * blank line: a bold sentence a hard-wrapped paragraph continues directly
130
+ * beneath must round-trip unchanged, not gain a blank line it never had.
131
+ * Merging two or more blocks, or a label whose lone block carries several
132
+ * items (bullets), still separates the label from its body with a blank
133
+ * line — the convention every existing multi-item block already uses.
134
+ *
118
135
  * @param {Array<{label: string|null, text: string}>} blocks - Every block
119
136
  * sharing one label, in file order. All carry the same `label`.
120
137
  * @returns {string}
@@ -123,9 +140,13 @@ function mergeGroup(blocks) {
123
140
  if (blocks[0].label === null) {
124
141
  return joinItems(dedupeItems(blocks.flatMap((block) => itemsOf(block.text))));
125
142
  }
126
- const { labelLine } = splitLabelLine(blocks[0].text);
143
+ const { labelLine, hadBlankLine } = splitLabelLine(blocks[0].text);
127
144
  const items = dedupeItems(blocks.flatMap((block) => itemsOf(splitLabelLine(block.text).rest)));
128
- return items.length ? `${labelLine}\n\n${joinItems(items)}` : labelLine;
145
+ if (items.length === 0) return labelLine;
146
+ if (blocks.length === 1 && items.length === 1) {
147
+ return `${labelLine}${hadBlankLine ? "\n\n" : "\n"}${items[0]}`;
148
+ }
149
+ return `${labelLine}\n\n${joinItems(items)}`;
129
150
  }
130
151
 
131
152
  /**
@@ -234,13 +234,15 @@ export function topLevelBullets(text, codeLines) {
234
234
  * {@link topLevelBullets}, where a bullet marker *is* the top-level
235
235
  * construct — a `-` bullet at column 0 belongs to whatever block is
236
236
  * already open, since a label's bullets sit unindented directly under it.
237
- * Any other column-0 line (plain prose with no label, a bullet with no
238
- * block open yet) starts the one "lead" block (`label: null`) a changeset
239
- * with no category writes — the case `changelog group` sorts first and
240
- * `check` never flags. Nested detail — a wrapped line, a bullet's own
241
- * continuation — stays indented and belongs to whatever it follows.
242
- * `changelog group` folds same-label blocks together; `check` warns when a
243
- * block's label is not in the declared vocabulary.
237
+ * A plain column-0 line that is neither a label nor a bullet continues
238
+ * whatever block is open, because Prettier's `proseWrap` puts every line of
239
+ * a hard-wrapped paragraph at column 0 — it starts a new "lead" block
240
+ * (`label: null`) only at a paragraph boundary, the start of the section or
241
+ * a line right after a blank one, which is the case `changelog group`
242
+ * sorts first and `check` never flags. A blank line never closes a block by
243
+ * itself; it only ends the run of continuation lines a following column-0
244
+ * line can attach to. `changelog group` folds same-label blocks together;
245
+ * `check` warns when a block's label is not in the declared vocabulary.
244
246
  *
245
247
  * @param {string} text - Section text.
246
248
  * @param {Set<number>} codeLines - Lines inside a code region, from
@@ -266,6 +268,9 @@ export function topLevelBlocks(text, codeLines, scaffoldLines = new Set()) {
266
268
  });
267
269
  current = null;
268
270
  };
271
+ // A paragraph boundary — the start of the section, or right after a
272
+ // blank line — is the only place an unlabelled lead block may start.
273
+ let afterBlank = true;
269
274
 
270
275
  for (let i = 0; i < lines.length; i++) {
271
276
  const lineNo = i + 1;
@@ -273,29 +278,44 @@ export function topLevelBlocks(text, codeLines, scaffoldLines = new Set()) {
273
278
 
274
279
  if (codeLines.has(lineNo)) {
275
280
  if (current) current.parts.push(raw);
281
+ afterBlank = raw.trim() === "";
276
282
  continue;
277
283
  }
278
284
  if (scaffoldLines.has(lineNo)) {
279
285
  close();
286
+ afterBlank = true;
280
287
  continue;
281
288
  }
282
289
  const atColumnZero = raw.trim() !== "" && !/^[ \t]/.test(raw);
283
290
  if (!atColumnZero) {
284
291
  if (current) current.parts.push(raw);
292
+ afterBlank = raw.trim() === "";
285
293
  continue;
286
294
  }
287
295
  const label = LABEL_LINE_RE.exec(raw)?.[1] ?? null;
288
296
  if (label !== null) {
289
297
  close();
290
298
  current = { startLine: lineNo, label, parts: [raw] };
299
+ afterBlank = false;
291
300
  continue;
292
301
  }
293
302
  if (TOP_BULLET_RE.test(raw) && current) {
294
303
  current.parts.push(raw);
304
+ afterBlank = false;
305
+ continue;
306
+ }
307
+ // Plain prose at column 0: it continues whatever block is already
308
+ // open unless it opens a new paragraph — Prettier's `proseWrap`
309
+ // puts every line of a hard-wrapped paragraph at column 0, so a
310
+ // continuation line must never be read as a new lead block.
311
+ if (current && !afterBlank) {
312
+ current.parts.push(raw);
313
+ afterBlank = false;
295
314
  continue;
296
315
  }
297
316
  close();
298
317
  current = { startLine: lineNo, label: null, parts: [raw] };
318
+ afterBlank = false;
299
319
  }
300
320
  close();
301
321
  return blocks;
@@ -378,15 +378,23 @@ export function homepageTitle(fm, config) {
378
378
  * redirect stub at each one. The field is retired and refused before a
379
379
  * build reaches here, which makes this a guard rather than a working path.
380
380
  *
381
+ * `related` is derived like every other page's — what links to the homepage
382
+ * is a fact about the rest of the tree — so an authored value is dropped and
383
+ * the caller's block, where it has one, is written in its place.
384
+ *
381
385
  * @param {object} fm - The note's frontmatter.
382
386
  * @param {object} options - Options.
383
387
  * @param {string} options.contentPackage - The package this build publishes.
384
388
  * @param {string} options.title - The resolved title.
389
+ * @param {object} [options.related] - The homepage's backlinks and mentions,
390
+ * where the site build found either.
385
391
  * @returns {object} The frontmatter to write.
386
392
  */
387
- export function homepageFrontmatter(fm, { contentPackage, title }) {
393
+ export function homepageFrontmatter(fm, { contentPackage, title, related }) {
388
394
  const data = { ...fm, package: contentPackage, title };
389
395
  delete data.aliases;
396
+ delete data.related;
397
+ if (related) data.related = related;
390
398
  return data;
391
399
  }
392
400
 
@@ -0,0 +1,161 @@
1
+ /*
2
+ * This file is part of the Song of Heroic Lands (SoHL) system for Foundry VTT.
3
+ * Copyright (c) 2024-2026 Tom Rodriguez ("Toasty") — <toasty@heroiclands.org>
4
+ *
5
+ * This work is licensed under the GNU General Public License v3.0 (GPLv3).
6
+ * You may copy, modify, and distribute it under the terms of that license.
7
+ *
8
+ * For full terms, see the LICENSE.md file in the project root or visit:
9
+ * https://www.gnu.org/licenses/gpl-3.0.html
10
+ *
11
+ * SPDX-License-Identifier: GPL-3.0-or-later
12
+ */
13
+
14
+ /**
15
+ * What links to a page, and what it links to.
16
+ *
17
+ * The site build resolves every wikilink through the address index, so by the
18
+ * time it writes a page it holds the whole link graph. This is the half that
19
+ * turns the graph into front matter: the `related` block the theme's Related
20
+ * card reads, on every page, in one shape.
21
+ *
22
+ * **A page is identified by its URL.** An address is unique within a package
23
+ * and a page publishes at exactly one, so the URL is the one key both a
24
+ * resolved wikilink and a homepage's markdown link can be reduced to — the
25
+ * link resolver answers with a URL, and a homepage link *is* one. It is also
26
+ * what makes a `doc<type>-<shortcode>` link a link to the same page as the
27
+ * `<type>-<shortcode>` form: the site index keys one page under both, and both
28
+ * answer with one URL.
29
+ *
30
+ * **Only a page of this site is an endpoint.** A link into another package
31
+ * resolves to a URL the build renders but no page it writes, so it appears
32
+ * on neither side: a backlink can only be counted on a page this build emits,
33
+ * and listing a foreign mention beside local ones would make the two lists
34
+ * disagree about what a connection is.
35
+ *
36
+ * @module
37
+ */
38
+
39
+ import { homepageAddresses } from "./homepage.mjs";
40
+
41
+ /**
42
+ * One entry in a `related` list — the page a reader is pointed at.
43
+ *
44
+ * @typedef {object} RelatedEntry
45
+ * @property {string} title The page's published title.
46
+ * @property {string} url `<base><slug>/` — the page's address as every href
47
+ * this build renders composes it, never the
48
+ * site-root-relative form the page states for itself.
49
+ * @property {string} type The note's `type`, which the theme groups by.
50
+ */
51
+
52
+ /**
53
+ * The `related` block one page publishes.
54
+ *
55
+ * @typedef {object} Related
56
+ * @property {RelatedEntry[]} backlinks Pages that link to this one.
57
+ * @property {RelatedEntry[]} mentions Pages this one links to.
58
+ */
59
+
60
+ /**
61
+ * A stable order for a list: by type, then title.
62
+ *
63
+ * The theme groups a list by type and reads each group in the order it
64
+ * arrives, so the order is fixed here rather than left to walk order — which
65
+ * is content-path order, and would reshuffle a page's card whenever a note was
66
+ * filed elsewhere.
67
+ *
68
+ * @param {RelatedEntry} a
69
+ * @param {RelatedEntry} b
70
+ * @returns {number}
71
+ */
72
+ function byTypeThenTitle(a, b) {
73
+ return a.type.localeCompare(b.type, "en") || a.title.localeCompare(b.title, "en");
74
+ }
75
+
76
+ /**
77
+ * Inverts a link graph into each page's backlinks and mentions.
78
+ *
79
+ * An edge is `(source URL, target URL)`, and a URL that is not in `entries` is
80
+ * not a page of this site, so an edge touching one is dropped. A self-link says
81
+ * nothing a reader is not already looking at, and a page that links another
82
+ * three times is connected to it once, so both collapse.
83
+ *
84
+ * The result holds only pages with at least one connection, so the caller can
85
+ * write nothing on a page with none — the theme's silent-disappear convention,
86
+ * kept at the source rather than papered over with an empty block.
87
+ *
88
+ * @param {Iterable<readonly [string, string]>} edges - Every resolved link, as
89
+ * the source page's URL and the target page's.
90
+ * @param {ReadonlyMap<string, RelatedEntry>} entries - Every page of this
91
+ * site, keyed by URL.
92
+ * @returns {Map<string, Related>} URL → the page's `related` block, both lists
93
+ * sorted, for every page with at least one entry in either.
94
+ */
95
+ export function relatedPages(edges, entries) {
96
+ /** @type {Map<string, {backlinks: Set<string>, mentions: Set<string>}>} */
97
+ const graph = new Map();
98
+ const node = (url) => {
99
+ let found = graph.get(url);
100
+ if (!found) {
101
+ found = { backlinks: new Set(), mentions: new Set() };
102
+ graph.set(url, found);
103
+ }
104
+ return found;
105
+ };
106
+
107
+ for (const [source, target] of edges) {
108
+ if (source === target) continue;
109
+ if (!entries.has(source) || !entries.has(target)) continue;
110
+ node(source).mentions.add(target);
111
+ node(target).backlinks.add(source);
112
+ }
113
+
114
+ const list = (urls) =>
115
+ [...urls]
116
+ .map((url) => /** @type {RelatedEntry} */ (entries.get(url)))
117
+ .sort(byTypeThenTitle);
118
+
119
+ const out = new Map();
120
+ for (const [url, { backlinks, mentions }] of graph) {
121
+ out.set(url, { backlinks: list(backlinks), mentions: list(mentions) });
122
+ }
123
+ return out;
124
+ }
125
+
126
+ /**
127
+ * The pages a homepage's body links to, as URLs.
128
+ *
129
+ * A homepage is published verbatim — no wikilink on it resolves, and its links
130
+ * are ordinary markdown the browser resolves against the page's own address,
131
+ * which is the package root. So its edges are read the same way: each target
132
+ * resolved against `base`, the way a browser would. A package-relative
133
+ * `being-x/`, a root-relative `/pkg/being-x/` and a `./being-x/#anchor` all
134
+ * name the same page; a bare `#anchor` names the homepage itself.
135
+ *
136
+ * A link that names a host or a scheme leaves the site, whatever path it
137
+ * carries, and yields nothing: the site's own pages are addressed without one.
138
+ *
139
+ * @param {string} body - The homepage's markdown body.
140
+ * @param {string} base - Where the package is served — the homepage's own URL.
141
+ * @returns {string[]} The targets' URLs, in body order, repeats included.
142
+ */
143
+ export function homepageLinkTargets(body, base) {
144
+ const out = [];
145
+ // A placeholder origin, so a relative target resolves the way a browser
146
+ // resolves it from the homepage. It never reaches the result: only the
147
+ // path is read, and only when the target stayed on this origin.
148
+ const origin = "http://site.invalid";
149
+ for (const { url } of homepageAddresses(body)) {
150
+ if (/^[a-z][a-z0-9+.-]*:/i.test(url) || url.startsWith("//")) continue;
151
+ let resolved;
152
+ try {
153
+ resolved = new URL(url, `${origin}${base}`);
154
+ } catch {
155
+ continue;
156
+ }
157
+ if (resolved.origin !== origin) continue;
158
+ out.push(resolved.pathname);
159
+ }
160
+ return out;
161
+ }
@@ -81,6 +81,7 @@ import {
81
81
  } from "./homepage.mjs";
82
82
  import { publishesContentPages } from "../content-config.mjs";
83
83
  import { HUGO_CONTENT } from "./site-config.mjs";
84
+ import { homepageLinkTargets, relatedPages } from "./related-pages.mjs";
84
85
 
85
86
  const require = createRequire(import.meta.url);
86
87
 
@@ -291,18 +292,30 @@ export function collectHomepages(contentBase, ctx) {
291
292
  * states no `url` — the home kind has none to state — and the mount below it
292
293
  * holds pages and nothing else, so this is the only `_index.md` in the tree.
293
294
  *
295
+ * **Verbatim, but not unconnected.** The homepage is a page like any other on
296
+ * both sides of the link graph: a content page reaches it through
297
+ * `[[homepage-root|Text]]`, and its own markdown links name content pages. So
298
+ * it carries the same `related` block every content page does — handed in by
299
+ * the caller, since the graph is read off the content render and homepage-only
300
+ * mode has none.
301
+ *
294
302
  * @param {string} outRoot - The package's site root — the content mount's
295
303
  * root, `build/hugo/content`, one level above the mount itself.
296
304
  * @param {readonly object[]} pages - From {@link collectHomepages}.
297
305
  * @param {object} config - The resolved configuration, for the package name and
298
306
  * the default title.
307
+ * @param {object} [options] - Options.
308
+ * @param {import("./related-pages.mjs").Related} [options.related] - The
309
+ * homepage's backlinks and mentions. Absent where nothing connects to it,
310
+ * and in homepage-only mode, where no link resolves.
299
311
  * @returns {number} How many pages were written.
300
312
  */
301
- export function writeHomepages(outRoot, pages, config) {
313
+ export function writeHomepages(outRoot, pages, config, { related } = {}) {
302
314
  for (const page of pages) {
303
315
  const data = homepageFrontmatter(page.fm, {
304
316
  contentPackage: config.contentPackage,
305
317
  title: homepageTitle(page.fm, config),
318
+ related,
306
319
  });
307
320
  const dest = path.join(outRoot, HOMEPAGE_DESTINATION);
308
321
  fs.mkdirSync(path.dirname(dest), { recursive: true });
@@ -472,6 +485,12 @@ export function tableUniverse(pages) {
472
485
  * self-describing and makes sweeping the field out of a content tree
473
486
  * output-preserving for a site as it already is for the packs.
474
487
  *
488
+ * **`related` is derived, never authored.** What links to a page is a fact
489
+ * about every other page, known only once the whole tree has resolved — see
490
+ * {@link module:engine/related-pages} — so an authored value is dropped the
491
+ * way `aliases` is, and {@link renderPages} writes the derived block once it
492
+ * holds the graph.
493
+ *
475
494
  * @param {object} page - The page.
476
495
  * @param {object} options
477
496
  * @param {(data: object, page: object) => void} [options.decorate] - Called
@@ -486,7 +505,7 @@ export function tableUniverse(pages) {
486
505
  * @returns {object} The frontmatter to write.
487
506
  */
488
507
  export function pageFrontmatter(page, { decorate, webSrc, artSrc }) {
489
- const { fm, name, slug } = page;
508
+ const { fm, slug } = page;
490
509
  const data = {
491
510
  ...fm,
492
511
  // Spread after the note's own frontmatter. Guarded because
@@ -500,15 +519,31 @@ export function pageFrontmatter(page, { decorate, webSrc, artSrc }) {
500
519
  // from the filename.
501
520
  slug,
502
521
  url: `/${slug}/`,
503
- title: fm.title ?? name,
522
+ title: pageTitle(page),
504
523
  kbfolder: page.folder,
505
524
  };
506
525
  if (decorate) decorate(data, page);
507
526
  delete data.aliases;
527
+ delete data.related;
508
528
  if (webSrc && artSrc) resolveArtFields(data, webSrc, artSrc);
509
529
  return data;
510
530
  }
511
531
 
532
+ /**
533
+ * The title a content page publishes under: an authored `title`, else the
534
+ * note's name.
535
+ *
536
+ * One rule, read by the page's own front matter and by every `related` entry
537
+ * that names the page, so a card lists a page by exactly the title its heading
538
+ * shows.
539
+ *
540
+ * @param {object} page - The page, from {@link collectContentPages}.
541
+ * @returns {string} The title.
542
+ */
543
+ function pageTitle(page) {
544
+ return page.fm.title ?? page.name;
545
+ }
546
+
512
547
  /**
513
548
  * Rewrite a page's artwork addresses into the URLs the website serves.
514
549
  *
@@ -585,10 +620,23 @@ export function pageDestination(page) {
585
620
  * touches, so the repository's own rewrite runs before the shared one
586
621
  * rather than replacing it.
587
622
  *
623
+ * **Every page is rendered before any is written.** What links *to* a page is
624
+ * known only once every other page has resolved, and it is written into the
625
+ * page's own front matter, so the render is one pass over the tree and the
626
+ * write is a second — with the link graph read off the first, at the one
627
+ * point the resolver answers, and inverted once between them. Nothing is
628
+ * resolved twice.
629
+ *
588
630
  * @param {object[]} pages - Every page.
589
- * @param {object} options - Everything the render needs.
631
+ * @param {object} options - Everything the render needs. `homepages` is the
632
+ * homepage as the index knows it — `{ fm, body, url, title }` — which takes
633
+ * no part in the render and every part in the graph: a content page links
634
+ * it by wikilink, and its own markdown links name content pages.
590
635
  * @returns {{written: number, byKind: Record<string, number>, tableErrors: object[],
591
- * wikiErrors: object[], imageErrors: object[]}}
636
+ * wikiErrors: object[], imageErrors: object[],
637
+ * related: Map<string, import("./related-pages.mjs").Related>}} `related`
638
+ * is keyed by page URL, and holds the homepage's block beside every content
639
+ * page's, so the caller can write it on the page this render does not.
592
640
  */
593
641
  export function renderPages(pages, options) {
594
642
  const {
@@ -602,6 +650,7 @@ export function renderPages(pages, options) {
602
650
  sqlTables,
603
651
  config,
604
652
  records = [],
653
+ homepages = [],
605
654
  } = options;
606
655
 
607
656
  // The address space the art slots and the body's embeds resolve against:
@@ -616,6 +665,29 @@ export function renderPages(pages, options) {
616
665
  const wikiErrors = [];
617
666
  const imageErrors = [];
618
667
  const byKind = {};
668
+ // The link graph, as `(source URL, target URL)` — read off each page's
669
+ // resolution below, and off the homepage's markdown links.
670
+ /** @type {Array<[string, string]>} */
671
+ const edges = [];
672
+ // Every page of this site, as a `related` entry names it.
673
+ /** @type {Map<string, import("./related-pages.mjs").RelatedEntry>} */
674
+ const entries = new Map();
675
+ for (const page of pages) {
676
+ entries.set(page.url, {
677
+ title: pageTitle(page),
678
+ url: page.url,
679
+ type: String(page.fm.type),
680
+ });
681
+ }
682
+ for (const home of homepages) {
683
+ entries.set(home.url, { title: home.title, url: home.url, type: String(home.fm.type) });
684
+ for (const target of homepageLinkTargets(home.body, home.url)) {
685
+ edges.push([home.url, target]);
686
+ }
687
+ }
688
+ // What each page renders as, held until the graph is whole.
689
+ /** @type {Array<{page: object, body: string, data: object}>} */
690
+ const rendered = [];
619
691
 
620
692
  /**
621
693
  * The address the website serves for one authored pathname.
@@ -661,6 +733,8 @@ export function renderPages(pages, options) {
661
733
  // The page's path in the tree an author edits, below the content
662
734
  // root.
663
735
  const src = page.relPath ?? page.base;
736
+ // Every index entry a link on this page resolved to.
737
+ const resolved = [];
664
738
  const ctx = wikiContext(index, {
665
739
  src,
666
740
  file: page.file,
@@ -668,6 +742,7 @@ export function renderPages(pages, options) {
668
742
  errors: wikiErrors,
669
743
  foreignIndex: foreign.index,
670
744
  assets: artIndex,
745
+ resolved,
671
746
  });
672
747
 
673
748
  const webSrc = webAddresses(page.file);
@@ -699,13 +774,26 @@ export function renderPages(pages, options) {
699
774
  tableErrors.push(...errors);
700
775
 
701
776
  const data = pageFrontmatter(page, { decorate, webSrc, artSrc });
777
+ rendered.push({ page, body: protectCode(body, resolve), data });
778
+ // A resolved target with no URL is a pack-only package's address:
779
+ // real, and nowhere on the web, so not an edge.
780
+ for (const hit of resolved) {
781
+ if (hit.url) edges.push([page.url, hit.url]);
782
+ }
783
+ }
784
+
785
+ const related = relatedPages(edges, entries);
786
+
787
+ for (const { page, body, data } of rendered) {
788
+ const block = related.get(page.url);
789
+ if (block) data.related = block;
702
790
  const dest = path.join(outRoot, pageDestination(page));
703
791
  fs.mkdirSync(path.dirname(dest), { recursive: true });
704
- fs.writeFileSync(dest, matter.stringify(protectCode(body, resolve), data));
792
+ fs.writeFileSync(dest, matter.stringify(body, data));
705
793
  byKind[page.kind] = (byKind[page.kind] ?? 0) + 1;
706
794
  }
707
795
 
708
- return { written: pages.length, byKind, tableErrors, wikiErrors, imageErrors };
796
+ return { written: pages.length, byKind, tableErrors, wikiErrors, imageErrors, related };
709
797
  }
710
798
 
711
799
  /**
@@ -892,11 +980,17 @@ export function buildSite({ config, sqlTables } = {}) {
892
980
  // homepage is authored markdown published verbatim, with no table
893
981
  // expansion and no link resolution of its own, and routing it through
894
982
  // that pipeline would buy it a pass it has no input for.
983
+ //
984
+ // It is a node of the link graph all the same: `renderPages` reads its
985
+ // markdown links for the edges it authors, and the resolver records the
986
+ // ones that land on it.
895
987
  const homepageEntries = homepages.map((page) => ({
896
988
  kind: "content",
897
989
  fm: page.fm,
990
+ body: page.body,
898
991
  pkg: resolved.contentPackage,
899
992
  name: page.fm.name?.full ?? homepageTitle(page.fm, resolved),
993
+ title: homepageTitle(page.fm, resolved),
900
994
  slug: addressSlug(page.fm),
901
995
  base: path.basename(page.file),
902
996
  url: base,
@@ -926,6 +1020,7 @@ export function buildSite({ config, sqlTables } = {}) {
926
1020
  index: gates.index,
927
1021
  foreign: gates.foreign,
928
1022
  universe: tableUniverse(pages),
1023
+ homepages: homepageEntries,
929
1024
  pass,
930
1025
  // What counts as a being is the toolchain's to say, not a consumer's.
931
1026
  // Asking in a consumer's script is how one came to still be checking
@@ -947,8 +1042,11 @@ export function buildSite({ config, sqlTables } = {}) {
947
1042
  });
948
1043
 
949
1044
  // Last, and outside the mount: the package's front page is not part of the
950
- // content tree it introduces.
951
- const homepagesWritten = writeHomepages(homeRoot, homepages, resolved);
1045
+ // content tree it introduces. It publishes at `base`, which is where the
1046
+ // graph keys its block.
1047
+ const homepagesWritten = writeHomepages(homeRoot, homepages, resolved, {
1048
+ related: rendered.related.get(base),
1049
+ });
952
1050
 
953
1051
  return {
954
1052
  gates,
@@ -289,6 +289,9 @@ export function buildSiteIndex(
289
289
  * resolvers that distinguish a foreign hit from a local one.
290
290
  * @param {object} [options.assets] - The address space an `![[…]]` embed
291
291
  * resolves against, shaped as every asset resolver reads one.
292
+ * @param {object[]} [options.resolved] - Collector the resolver appends every
293
+ * resolved target's index entry to — the page's outbound edges, read off
294
+ * the one pass that resolves them.
292
295
  * @returns {object} The resolver context.
293
296
  *
294
297
  * There is deliberately **no `manifestsComplete`**. It used to let a resolver
@@ -299,7 +302,7 @@ export function buildSiteIndex(
299
302
  */
300
303
  export function wikiContext(
301
304
  built,
302
- { src, file, type = null, errors, foreignIndex = new Map(), assets },
305
+ { src, file, type = null, errors, foreignIndex = new Map(), assets, resolved },
303
306
  ) {
304
307
  return {
305
308
  index: built.index,
@@ -321,6 +324,7 @@ export function wikiContext(
321
324
  errors,
322
325
  src,
323
326
  file,
327
+ resolved,
324
328
  };
325
329
  }
326
330
 
@@ -311,9 +311,17 @@ function isPlainMap(value) {
311
311
  * {@link module:engine/content-images.renderImageFigures} turns into a figure
312
312
  * for the website, and what the book reads its staging list out of.
313
313
  *
314
+ * ## Every hit is recorded, so the page's edges come out of one pass
315
+ *
316
+ * A caller that hands in `ctx.resolved` is given every index entry a link on
317
+ * the page resolved to, in body order, repeats included. That is the link
318
+ * graph, read at the one point where it exists: the build never has to walk a
319
+ * page a second time to learn what it links to. Nothing is recorded for a
320
+ * same-page anchor or for a target that resolved nowhere.
321
+ *
314
322
  * @param {string} body - The markdown body.
315
323
  * @param {object} ctx - `{ index, assets, collide, contentTypes,
316
- * packages, noIndexPackages, foreign, type, errors, src, file }`.
324
+ * packages, noIndexPackages, foreign, type, errors, src, file, resolved }`.
317
325
  * `packages` is every package an address may name, without which the leading
318
326
  * package segment of a canonical address reads as an unknown type;
319
327
  * `noIndexPackages` is every package declared `contentIndex: false`, so a
@@ -322,7 +330,8 @@ function isPlainMap(value) {
322
330
  * is the cross-package manifest index; `assets` is the address space an embed
323
331
  * resolves against. `src` is the page's display
324
332
  * path and `file` the source file a diagnostic should name — absent, `src`
325
- * stands in.
333
+ * stands in. `resolved`, when supplied, is the array every resolved
334
+ * target's index entry is appended to.
326
335
  * @returns {string} The body with embeds and wikilinks rewritten.
327
336
  */
328
337
  export function resolveWebWikilinks(body, ctx) {
@@ -425,6 +434,8 @@ export function resolveWebWikilinks(body, ctx) {
425
434
  lookupRead(ctx.foreign, read, ctx.contentPackage)
426
435
  : undefined);
427
436
  if (hit) {
437
+ // The edge, for a caller reading the link graph off this pass.
438
+ ctx.resolved?.push(hit);
428
439
  // An address with an *empty* label has no prose to show (a
429
440
  // shortcode is not display text), so the document's **current**
430
441
  // name stands in and a rename shows at every citation.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@heroiclands/package-build",
3
- "version": "22.4.3",
3
+ "version": "22.4.4",
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",
@@ -48,13 +48,15 @@ export function topLevelBullets(text: string, codeLines: Set<number>): Array<{
48
48
  * {@link topLevelBullets}, where a bullet marker *is* the top-level
49
49
  * construct — a `-` bullet at column 0 belongs to whatever block is
50
50
  * already open, since a label's bullets sit unindented directly under it.
51
- * Any other column-0 line (plain prose with no label, a bullet with no
52
- * block open yet) starts the one "lead" block (`label: null`) a changeset
53
- * with no category writes — the case `changelog group` sorts first and
54
- * `check` never flags. Nested detail — a wrapped line, a bullet's own
55
- * continuation — stays indented and belongs to whatever it follows.
56
- * `changelog group` folds same-label blocks together; `check` warns when a
57
- * block's label is not in the declared vocabulary.
51
+ * A plain column-0 line that is neither a label nor a bullet continues
52
+ * whatever block is open, because Prettier's `proseWrap` puts every line of
53
+ * a hard-wrapped paragraph at column 0 — it starts a new "lead" block
54
+ * (`label: null`) only at a paragraph boundary, the start of the section or
55
+ * a line right after a blank one, which is the case `changelog group`
56
+ * sorts first and `check` never flags. A blank line never closes a block by
57
+ * itself; it only ends the run of continuation lines a following column-0
58
+ * line can attach to. `changelog group` folds same-label blocks together;
59
+ * `check` warns when a block's label is not in the declared vocabulary.
58
60
  *
59
61
  * @param {string} text - Section text.
60
62
  * @param {Set<number>} codeLines - Lines inside a code region, from
@@ -148,15 +148,22 @@ export function homepageTitle(fm: object | null | undefined, config: object): st
148
148
  * redirect stub at each one. The field is retired and refused before a
149
149
  * build reaches here, which makes this a guard rather than a working path.
150
150
  *
151
+ * `related` is derived like every other page's — what links to the homepage
152
+ * is a fact about the rest of the tree — so an authored value is dropped and
153
+ * the caller's block, where it has one, is written in its place.
154
+ *
151
155
  * @param {object} fm - The note's frontmatter.
152
156
  * @param {object} options - Options.
153
157
  * @param {string} options.contentPackage - The package this build publishes.
154
158
  * @param {string} options.title - The resolved title.
159
+ * @param {object} [options.related] - The homepage's backlinks and mentions,
160
+ * where the site build found either.
155
161
  * @returns {object} The frontmatter to write.
156
162
  */
157
- export function homepageFrontmatter(fm: object, { contentPackage, title }: {
163
+ export function homepageFrontmatter(fm: object, { contentPackage, title, related }: {
158
164
  contentPackage: string;
159
165
  title: string;
166
+ related?: object | undefined;
160
167
  }): object;
161
168
  /**
162
169
  * Every address a homepage carries: the markdown links in its body.
@@ -0,0 +1,70 @@
1
+ /**
2
+ * Inverts a link graph into each page's backlinks and mentions.
3
+ *
4
+ * An edge is `(source URL, target URL)`, and a URL that is not in `entries` is
5
+ * not a page of this site, so an edge touching one is dropped. A self-link says
6
+ * nothing a reader is not already looking at, and a page that links another
7
+ * three times is connected to it once, so both collapse.
8
+ *
9
+ * The result holds only pages with at least one connection, so the caller can
10
+ * write nothing on a page with none — the theme's silent-disappear convention,
11
+ * kept at the source rather than papered over with an empty block.
12
+ *
13
+ * @param {Iterable<readonly [string, string]>} edges - Every resolved link, as
14
+ * the source page's URL and the target page's.
15
+ * @param {ReadonlyMap<string, RelatedEntry>} entries - Every page of this
16
+ * site, keyed by URL.
17
+ * @returns {Map<string, Related>} URL → the page's `related` block, both lists
18
+ * sorted, for every page with at least one entry in either.
19
+ */
20
+ export function relatedPages(edges: Iterable<readonly [string, string]>, entries: ReadonlyMap<string, RelatedEntry>): Map<string, Related>;
21
+ /**
22
+ * The pages a homepage's body links to, as URLs.
23
+ *
24
+ * A homepage is published verbatim — no wikilink on it resolves, and its links
25
+ * are ordinary markdown the browser resolves against the page's own address,
26
+ * which is the package root. So its edges are read the same way: each target
27
+ * resolved against `base`, the way a browser would. A package-relative
28
+ * `being-x/`, a root-relative `/pkg/being-x/` and a `./being-x/#anchor` all
29
+ * name the same page; a bare `#anchor` names the homepage itself.
30
+ *
31
+ * A link that names a host or a scheme leaves the site, whatever path it
32
+ * carries, and yields nothing: the site's own pages are addressed without one.
33
+ *
34
+ * @param {string} body - The homepage's markdown body.
35
+ * @param {string} base - Where the package is served — the homepage's own URL.
36
+ * @returns {string[]} The targets' URLs, in body order, repeats included.
37
+ */
38
+ export function homepageLinkTargets(body: string, base: string): string[];
39
+ /**
40
+ * One entry in a `related` list — the page a reader is pointed at.
41
+ */
42
+ export type RelatedEntry = {
43
+ /**
44
+ * The page's published title.
45
+ */
46
+ title: string;
47
+ /**
48
+ * `<base><slug>/` — the page's address as every href
49
+ * this build renders composes it, never the
50
+ * site-root-relative form the page states for itself.
51
+ */
52
+ url: string;
53
+ /**
54
+ * The note's `type`, which the theme groups by.
55
+ */
56
+ type: string;
57
+ };
58
+ /**
59
+ * The `related` block one page publishes.
60
+ */
61
+ export type Related = {
62
+ /**
63
+ * Pages that link to this one.
64
+ */
65
+ backlinks: RelatedEntry[];
66
+ /**
67
+ * Pages this one links to.
68
+ */
69
+ mentions: RelatedEntry[];
70
+ };
@@ -65,14 +65,27 @@ export function collectHomepages(contentBase: string, ctx: object): {
65
65
  * states no `url` — the home kind has none to state — and the mount below it
66
66
  * holds pages and nothing else, so this is the only `_index.md` in the tree.
67
67
  *
68
+ * **Verbatim, but not unconnected.** The homepage is a page like any other on
69
+ * both sides of the link graph: a content page reaches it through
70
+ * `[[homepage-root|Text]]`, and its own markdown links name content pages. So
71
+ * it carries the same `related` block every content page does — handed in by
72
+ * the caller, since the graph is read off the content render and homepage-only
73
+ * mode has none.
74
+ *
68
75
  * @param {string} outRoot - The package's site root — the content mount's
69
76
  * root, `build/hugo/content`, one level above the mount itself.
70
77
  * @param {readonly object[]} pages - From {@link collectHomepages}.
71
78
  * @param {object} config - The resolved configuration, for the package name and
72
79
  * the default title.
80
+ * @param {object} [options] - Options.
81
+ * @param {import("./related-pages.mjs").Related} [options.related] - The
82
+ * homepage's backlinks and mentions. Absent where nothing connects to it,
83
+ * and in homepage-only mode, where no link resolves.
73
84
  * @returns {number} How many pages were written.
74
85
  */
75
- export function writeHomepages(outRoot: string, pages: readonly object[], config: object): number;
86
+ export function writeHomepages(outRoot: string, pages: readonly object[], config: object, { related }?: {
87
+ related?: import("./related-pages.mjs").Related | undefined;
88
+ }): number;
76
89
  /**
77
90
  * The integrity gates a site build runs before it writes anything.
78
91
  *
@@ -159,6 +172,12 @@ export function tableUniverse(pages: object[]): Map<string, object[]>;
159
172
  * self-describing and makes sweeping the field out of a content tree
160
173
  * output-preserving for a site as it already is for the packs.
161
174
  *
175
+ * **`related` is derived, never authored.** What links to a page is a fact
176
+ * about every other page, known only once the whole tree has resolved — see
177
+ * {@link module:engine/related-pages} — so an authored value is dropped the
178
+ * way `aliases` is, and {@link renderPages} writes the derived block once it
179
+ * holds the graph.
180
+ *
162
181
  * @param {object} page - The page.
163
182
  * @param {object} options
164
183
  * @param {(data: object, page: object) => void} [options.decorate] - Called
@@ -210,10 +229,23 @@ export function pageDestination(page: object): string;
210
229
  * touches, so the repository's own rewrite runs before the shared one
211
230
  * rather than replacing it.
212
231
  *
232
+ * **Every page is rendered before any is written.** What links *to* a page is
233
+ * known only once every other page has resolved, and it is written into the
234
+ * page's own front matter, so the render is one pass over the tree and the
235
+ * write is a second — with the link graph read off the first, at the one
236
+ * point the resolver answers, and inverted once between them. Nothing is
237
+ * resolved twice.
238
+ *
213
239
  * @param {object[]} pages - Every page.
214
- * @param {object} options - Everything the render needs.
240
+ * @param {object} options - Everything the render needs. `homepages` is the
241
+ * homepage as the index knows it — `{ fm, body, url, title }` — which takes
242
+ * no part in the render and every part in the graph: a content page links
243
+ * it by wikilink, and its own markdown links name content pages.
215
244
  * @returns {{written: number, byKind: Record<string, number>, tableErrors: object[],
216
- * wikiErrors: object[], imageErrors: object[]}}
245
+ * wikiErrors: object[], imageErrors: object[],
246
+ * related: Map<string, import("./related-pages.mjs").Related>}} `related`
247
+ * is keyed by page URL, and holds the homepage's block beside every content
248
+ * page's, so the caller can write it on the page this render does not.
217
249
  */
218
250
  export function renderPages(pages: object[], options: object): {
219
251
  written: number;
@@ -221,6 +253,7 @@ export function renderPages(pages: object[], options: object): {
221
253
  tableErrors: object[];
222
254
  wikiErrors: object[];
223
255
  imageErrors: object[];
256
+ related: Map<string, import("./related-pages.mjs").Related>;
224
257
  };
225
258
  /**
226
259
  * Resolves `site.pass` to its bundle.
@@ -41,6 +41,9 @@ export function buildSiteIndex(entries: readonly SiteEntry[], { foreignIndex, no
41
41
  * resolvers that distinguish a foreign hit from a local one.
42
42
  * @param {object} [options.assets] - The address space an `![[…]]` embed
43
43
  * resolves against, shaped as every asset resolver reads one.
44
+ * @param {object[]} [options.resolved] - Collector the resolver appends every
45
+ * resolved target's index entry to — the page's outbound edges, read off
46
+ * the one pass that resolves them.
44
47
  * @returns {object} The resolver context.
45
48
  *
46
49
  * There is deliberately **no `manifestsComplete`**. It used to let a resolver
@@ -49,13 +52,14 @@ export function buildSiteIndex(entries: readonly SiteEntry[], { foreignIndex, no
49
52
  * checker never had it and one authored link must not get two verdicts. A
50
53
  * caller still passing it is ignored rather than obeyed.
51
54
  */
52
- export function wikiContext(built: SiteIndex, { src, file, type, errors, foreignIndex, assets }: {
55
+ export function wikiContext(built: SiteIndex, { src, file, type, errors, foreignIndex, assets, resolved }: {
53
56
  src: string;
54
57
  type?: string | null | undefined;
55
58
  errors: object[];
56
59
  file?: string | undefined;
57
60
  foreignIndex?: Map<string, object> | undefined;
58
61
  assets?: object | undefined;
62
+ resolved?: object[] | undefined;
59
63
  }): object;
60
64
  /**
61
65
  * Resolve one infobox reference against a site index.
@@ -76,9 +76,17 @@ export function frontmatterWikilinks(fm: unknown): Array<{
76
76
  * {@link module:engine/content-images.renderImageFigures} turns into a figure
77
77
  * for the website, and what the book reads its staging list out of.
78
78
  *
79
+ * ## Every hit is recorded, so the page's edges come out of one pass
80
+ *
81
+ * A caller that hands in `ctx.resolved` is given every index entry a link on
82
+ * the page resolved to, in body order, repeats included. That is the link
83
+ * graph, read at the one point where it exists: the build never has to walk a
84
+ * page a second time to learn what it links to. Nothing is recorded for a
85
+ * same-page anchor or for a target that resolved nowhere.
86
+ *
79
87
  * @param {string} body - The markdown body.
80
88
  * @param {object} ctx - `{ index, assets, collide, contentTypes,
81
- * packages, noIndexPackages, foreign, type, errors, src, file }`.
89
+ * packages, noIndexPackages, foreign, type, errors, src, file, resolved }`.
82
90
  * `packages` is every package an address may name, without which the leading
83
91
  * package segment of a canonical address reads as an unknown type;
84
92
  * `noIndexPackages` is every package declared `contentIndex: false`, so a
@@ -87,7 +95,8 @@ export function frontmatterWikilinks(fm: unknown): Array<{
87
95
  * is the cross-package manifest index; `assets` is the address space an embed
88
96
  * resolves against. `src` is the page's display
89
97
  * path and `file` the source file a diagnostic should name — absent, `src`
90
- * stands in.
98
+ * stands in. `resolved`, when supplied, is the array every resolved
99
+ * target's index entry is appended to.
91
100
  * @returns {string} The body with embeds and wikilinks rewritten.
92
101
  */
93
102
  export function resolveWebWikilinks(body: string, ctx: object): string;