@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 +10 -18
- package/CONTENT.md +47 -0
- package/engine/changelog-group.mjs +27 -6
- package/engine/changelog-lint.mjs +27 -7
- package/engine/homepage.mjs +9 -1
- package/engine/related-pages.mjs +161 -0
- package/engine/site-build.mjs +107 -9
- package/engine/site-index.mjs +5 -1
- package/engine/web-wikilinks.mjs +13 -2
- package/package.json +1 -1
- package/types/engine/changelog-lint.d.mts +9 -7
- package/types/engine/homepage.d.mts +8 -1
- package/types/engine/related-pages.d.mts +70 -0
- package/types/engine/site-build.d.mts +36 -3
- package/types/engine/site-index.d.mts +5 -1
- package/types/engine/web-wikilinks.d.mts +11 -2
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`
|
|
54
|
-
* line dropped; a block with nothing but the label
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
*
|
|
238
|
-
* block open
|
|
239
|
-
*
|
|
240
|
-
* `
|
|
241
|
-
*
|
|
242
|
-
*
|
|
243
|
-
*
|
|
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;
|
package/engine/homepage.mjs
CHANGED
|
@@ -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
|
+
}
|
package/engine/site-build.mjs
CHANGED
|
@@ -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,
|
|
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:
|
|
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(
|
|
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
|
-
|
|
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,
|
package/engine/site-index.mjs
CHANGED
|
@@ -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
|
|
package/engine/web-wikilinks.mjs
CHANGED
|
@@ -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
|
+
"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
|
-
*
|
|
52
|
-
* block open
|
|
53
|
-
*
|
|
54
|
-
* `
|
|
55
|
-
*
|
|
56
|
-
*
|
|
57
|
-
*
|
|
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
|
|
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;
|