@heroiclands/package-build 22.4.3 → 22.4.5
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 +28 -18
- package/CONTENT.md +77 -1
- package/README.md +9 -0
- package/bin/content-build.mjs +143 -0
- package/bin/package-build.mjs +18 -4
- package/content-config.mjs +7 -0
- package/docs/api.md +84 -0
- package/docs/commands.md +182 -2
- package/docs/configuration.md +19 -1
- package/docs/content-format.md +105 -6
- package/docs/diagnostics.md +1 -0
- package/docs/project-setup.md +7 -6
- package/engine/changelog-group.mjs +27 -6
- package/engine/changelog-lint.mjs +27 -7
- package/engine/frontmatter-lint.mjs +11 -1
- package/engine/homepage.mjs +9 -1
- package/engine/index.mjs +18 -0
- package/engine/map-build.mjs +240 -0
- package/engine/map-dot.mjs +685 -0
- package/engine/map-graphviz.mjs +138 -0
- package/engine/map-layout.mjs +712 -0
- package/engine/map-places.mjs +561 -0
- package/engine/metadata-index.mjs +7 -0
- package/engine/note-vocabulary.mjs +28 -0
- package/engine/place-relations.mjs +637 -0
- package/engine/related-pages.mjs +161 -0
- package/engine/site-build.mjs +107 -9
- package/engine/site-config.mjs +1 -1
- package/engine/site-index.mjs +5 -1
- package/engine/site-root.mjs +147 -6
- package/engine/web-wikilinks.mjs +13 -2
- package/package.json +3 -2
- package/types/engine/changelog-lint.d.mts +9 -7
- package/types/engine/homepage.d.mts +8 -1
- package/types/engine/index.d.mts +6 -0
- package/types/engine/map-build.d.mts +78 -0
- package/types/engine/map-dot.d.mts +130 -0
- package/types/engine/map-graphviz.d.mts +61 -0
- package/types/engine/map-layout.d.mts +359 -0
- package/types/engine/map-places.d.mts +259 -0
- package/types/engine/note-vocabulary.d.mts +12 -0
- package/types/engine/place-relations.d.mts +95 -0
- package/types/engine/related-pages.d.mts +70 -0
- package/types/engine/site-build.d.mts +36 -3
- package/types/engine/site-config.d.mts +1 -1
- package/types/engine/site-index.d.mts +5 -1
- package/types/engine/site-root.d.mts +59 -7
- package/types/engine/web-wikilinks.d.mts +11 -2
|
@@ -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-config.mjs
CHANGED
|
@@ -57,7 +57,7 @@ export const HUGO_CONTENT = `${HUGO_SOURCE}/content`;
|
|
|
57
57
|
* The directory that is deployed, relative to the repository root.
|
|
58
58
|
*
|
|
59
59
|
* Hugo renders into `<DEPLOY_ROOT>/<contentPackage>/`; `package-build
|
|
60
|
-
* site-root` writes `_headers` and
|
|
60
|
+
* site-root` writes `_headers` beside it and the search index inside it.
|
|
61
61
|
*/
|
|
62
62
|
export const DEPLOY_ROOT = "build/site";
|
|
63
63
|
|
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/site-root.mjs
CHANGED
|
@@ -20,6 +20,18 @@
|
|
|
20
20
|
* `_redirects` is written. One left beside the site by an earlier build is
|
|
21
21
|
* removed rather than left to send every reader somewhere nothing publishes.
|
|
22
22
|
*
|
|
23
|
+
* **And one directory inside the prefix, `pagefind/`.** Every package site
|
|
24
|
+
* ends the same way — Hugo renders into the output directory, this
|
|
25
|
+
* post-processes it there — so the search index is built in this step and
|
|
26
|
+
* every site gets one without each repository adding a dependency and a
|
|
27
|
+
* script. Pagefind indexes the rendered pages and writes `<package>/pagefind/`
|
|
28
|
+
* beside them, served at `/<package>/pagefind/` with the rest of the site;
|
|
29
|
+
* the theme loads it relative to the site's base URL like any other asset.
|
|
30
|
+
* `site.search: false` skips the step, and removes an index an earlier build
|
|
31
|
+
* left. What is indexed is whatever the theme rendered: the theme marks its
|
|
32
|
+
* own chrome `data-pagefind-ignore`, and this package's part is only to run
|
|
33
|
+
* the indexer over the result.
|
|
34
|
+
*
|
|
23
35
|
* **One implementation, because it is one policy.** What is indexable is a
|
|
24
36
|
* decision about the hosting rather than about any one package. Held in each
|
|
25
37
|
* consumer it is the same file with one constant changed, which is a file that
|
|
@@ -31,6 +43,21 @@
|
|
|
31
43
|
|
|
32
44
|
import fs from "node:fs";
|
|
33
45
|
import path from "node:path";
|
|
46
|
+
import * as pagefind from "pagefind";
|
|
47
|
+
|
|
48
|
+
import { formatDiagnostic } from "./diagnostics.mjs";
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* The directory the search index is written to, inside the rendered site.
|
|
52
|
+
*
|
|
53
|
+
* Pagefind's own default, and the one its browser bundle derives the site's
|
|
54
|
+
* base URL from: `pagefind.js` at `/<package>/pagefind/pagefind.js` means
|
|
55
|
+
* every result's `url` is joined onto `/<package>/`, so nothing about the
|
|
56
|
+
* prefix is configured on either side.
|
|
57
|
+
*
|
|
58
|
+
* @type {string}
|
|
59
|
+
*/
|
|
60
|
+
export const SEARCH_DIR = "pagefind";
|
|
34
61
|
|
|
35
62
|
/**
|
|
36
63
|
* The namespace the routing layer derives a package's origin in.
|
|
@@ -89,23 +116,112 @@ export function headers() {
|
|
|
89
116
|
}
|
|
90
117
|
|
|
91
118
|
/**
|
|
92
|
-
*
|
|
119
|
+
* What runs the search indexer over a rendered site.
|
|
120
|
+
*
|
|
121
|
+
* {@link indexSite} is the one the command uses; a test hands
|
|
122
|
+
* {@link writeSiteRoot} a stand-in, so the step's own contract — what it is
|
|
123
|
+
* given, when it runs, how its failure is reported — is checked without a
|
|
124
|
+
* binary.
|
|
125
|
+
*
|
|
126
|
+
* @callback SiteIndexer
|
|
127
|
+
* @param {{site: string, output: string}} where - The rendered site, and the
|
|
128
|
+
* directory inside it the index is written to.
|
|
129
|
+
* @returns {Promise<{pages: number}>} How many pages were indexed.
|
|
130
|
+
*/
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* Build the search index of a rendered site with Pagefind.
|
|
134
|
+
*
|
|
135
|
+
* Pagefind's Node API drives the binary the `pagefind` package installs for
|
|
136
|
+
* the platform as an optional dependency, so there is nothing to put on a
|
|
137
|
+
* `PATH`. Three things it does not do on its own are done here, because each
|
|
138
|
+
* is a quiet failure otherwise:
|
|
139
|
+
*
|
|
140
|
+
* - A directory that is not there indexes as zero pages and no error, so the
|
|
141
|
+
* site is checked first.
|
|
142
|
+
* - Zero pages from a directory that _is_ there means the pages were not
|
|
143
|
+
* read — an empty index is deployable and finds nothing — so it is refused.
|
|
144
|
+
* - The binary missing is reported by the package as an install failure of
|
|
145
|
+
* its own; it is rethrown naming the platform package to install.
|
|
146
|
+
*
|
|
147
|
+
* The output directory is emptied first, so an index of an earlier build's
|
|
148
|
+
* pages never survives beside the current one.
|
|
149
|
+
*
|
|
150
|
+
* @type {SiteIndexer}
|
|
151
|
+
*/
|
|
152
|
+
export async function indexSite({ site, output }) {
|
|
153
|
+
if (!fs.statSync(site, { throwIfNoEntry: false })?.isDirectory()) {
|
|
154
|
+
throw new Error(`${site} is not a directory, so there is nothing to index`);
|
|
155
|
+
}
|
|
156
|
+
fs.rmSync(output, { recursive: true, force: true });
|
|
157
|
+
|
|
158
|
+
let index;
|
|
159
|
+
try {
|
|
160
|
+
({ index } = await pagefind.createIndex({ verbose: false }));
|
|
161
|
+
} catch (err) {
|
|
162
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
163
|
+
// The package's own wording for "no binary resolved for this
|
|
164
|
+
// platform", the one failure of the step that is not the site's.
|
|
165
|
+
if (/Failed to install/.test(message)) {
|
|
166
|
+
const platform = process.platform === "win32" ? "windows" : process.platform;
|
|
167
|
+
throw new Error(
|
|
168
|
+
`the pagefind binary for ${platform}-${process.arch} is not installed — ` +
|
|
169
|
+
`\`npm ci\` installs it as the optional dependency ` +
|
|
170
|
+
`@pagefind/${platform}-${process.arch} of pagefind; on a platform it ` +
|
|
171
|
+
`has no build for, set PAGEFIND_BINARY_PATH to a binary from ` +
|
|
172
|
+
`https://github.com/Pagefind/pagefind/releases`,
|
|
173
|
+
);
|
|
174
|
+
}
|
|
175
|
+
throw err;
|
|
176
|
+
}
|
|
177
|
+
try {
|
|
178
|
+
const added = await index.addDirectory({ path: site });
|
|
179
|
+
if (added.errors.length) throw new Error(added.errors.join("; "));
|
|
180
|
+
const pages = added.page_count;
|
|
181
|
+
if (!pages) {
|
|
182
|
+
throw new Error(`no page under ${site} was indexed — the site holds no HTML`);
|
|
183
|
+
}
|
|
184
|
+
const written = await index.writeFiles({ outputPath: output });
|
|
185
|
+
if (written.errors.length) throw new Error(written.errors.join("; "));
|
|
186
|
+
return { pages };
|
|
187
|
+
} finally {
|
|
188
|
+
await pagefind.close();
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/**
|
|
193
|
+
* Write `_headers` beside the rendered site, remove any `_redirects`, and
|
|
194
|
+
* index the site for search.
|
|
93
195
|
*
|
|
94
196
|
* The removal is part of owning the root: a `_redirects` this build did not
|
|
95
197
|
* write is one an earlier build left, and Cloudflare Pages applies whatever
|
|
96
198
|
* sits there. Left in place it would redirect the prefix root — the homepage —
|
|
97
|
-
* to an address nothing publishes.
|
|
199
|
+
* to an address nothing publishes. The same holds for a `pagefind/` left by an
|
|
200
|
+
* earlier build when `search` is off: deployed, it is a working search over
|
|
201
|
+
* pages that may no longer exist, so it goes too.
|
|
202
|
+
*
|
|
203
|
+
* `_headers` is written before the index is built, so a failed index leaves
|
|
204
|
+
* the root files as they would be — the failure is the indexer's, reported as
|
|
205
|
+
* one finding naming the rendered site, and nothing else about the deployment
|
|
206
|
+
* is in doubt.
|
|
98
207
|
*
|
|
99
208
|
* @param {object} options - Options.
|
|
100
209
|
* @param {string} options.pkg - The content package name, which is also the
|
|
101
210
|
* directory Hugo rendered into.
|
|
102
211
|
* @param {string} options.out - The directory that is deployed.
|
|
103
|
-
* @
|
|
212
|
+
* @param {boolean} [options.search] - Whether to build the search index;
|
|
213
|
+
* `site.search` in the configuration. Default `true`.
|
|
214
|
+
* @param {SiteIndexer} [options.indexer] - What builds it. Default
|
|
215
|
+
* {@link indexSite}.
|
|
216
|
+
* @returns {Promise<{files: string[], search: {dir: string, pages: number}|null}>}
|
|
217
|
+
* The files written, and the index — where it is and how many pages it
|
|
218
|
+
* holds — or `null` when none was built.
|
|
104
219
|
* @throws {Error} When no rendered site is there, which means the site build
|
|
105
220
|
* has not run and writing root files would publish a deployment with nothing
|
|
106
|
-
* under the prefix
|
|
221
|
+
* under the prefix; or, located at the rendered site, when the indexer
|
|
222
|
+
* fails.
|
|
107
223
|
*/
|
|
108
|
-
export function writeSiteRoot({ pkg, out }) {
|
|
224
|
+
export async function writeSiteRoot({ pkg, out, search = true, indexer = indexSite }) {
|
|
109
225
|
const root = path.resolve(out);
|
|
110
226
|
const rendered = path.join(root, pkg);
|
|
111
227
|
if (!fs.existsSync(path.join(rendered, "index.html"))) {
|
|
@@ -117,5 +233,30 @@ export function writeSiteRoot({ pkg, out }) {
|
|
|
117
233
|
const file = path.join(root, "_headers");
|
|
118
234
|
fs.writeFileSync(file, headers());
|
|
119
235
|
fs.rmSync(path.join(root, "_redirects"), { force: true });
|
|
120
|
-
|
|
236
|
+
|
|
237
|
+
const dir = path.join(rendered, SEARCH_DIR);
|
|
238
|
+
if (!search) {
|
|
239
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
240
|
+
return { files: [file], search: null };
|
|
241
|
+
}
|
|
242
|
+
let pages;
|
|
243
|
+
try {
|
|
244
|
+
({ pages } = await indexer({ site: rendered, output: dir }));
|
|
245
|
+
} catch (err) {
|
|
246
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
247
|
+
// A directory, so the locator is the file field alone: there is no
|
|
248
|
+
// line to name, and `1:1` would send a reader to the homepage's
|
|
249
|
+
// markup for a failure that is the indexer's.
|
|
250
|
+
throw Object.assign(
|
|
251
|
+
new Error(
|
|
252
|
+
formatDiagnostic({
|
|
253
|
+
file: rendered,
|
|
254
|
+
severity: "error",
|
|
255
|
+
message: `search index: ${message}`,
|
|
256
|
+
}),
|
|
257
|
+
),
|
|
258
|
+
{ located: true, file: rendered },
|
|
259
|
+
);
|
|
260
|
+
}
|
|
261
|
+
return { files: [file], search: { dir, pages } };
|
|
121
262
|
}
|
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.
|