@heroiclands/package-build 22.4.2 → 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 +32 -0
- package/CONTENT.md +53 -6
- package/README.md +6 -6
- package/bin/package-build.mjs +68 -18
- package/changelog.cjs +55 -0
- package/content-config.mjs +34 -0
- package/docs/api.md +16 -0
- package/docs/commands.md +70 -2
- package/docs/configuration.md +43 -3
- package/docs/project-setup.md +22 -13
- package/engine/changelog-group.mjs +277 -0
- package/engine/changelog-lint.mjs +188 -9
- 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 +4 -3
- package/types/engine/changelog-group.d.mts +28 -0
- package/types/engine/changelog-lint.d.mts +109 -2
- 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
|
@@ -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",
|
|
@@ -91,6 +91,7 @@
|
|
|
91
91
|
"types": "./types/templates.d.mts",
|
|
92
92
|
"import": "./templates.mjs"
|
|
93
93
|
},
|
|
94
|
+
"./changelog": "./changelog.cjs",
|
|
94
95
|
"./package.json": "./package.json"
|
|
95
96
|
},
|
|
96
97
|
"bin": {
|
|
@@ -106,6 +107,7 @@
|
|
|
106
107
|
"packagebuild-metadata.jsonl",
|
|
107
108
|
"bin",
|
|
108
109
|
"bundle.mjs",
|
|
110
|
+
"changelog.cjs",
|
|
109
111
|
"ci",
|
|
110
112
|
"config.mjs",
|
|
111
113
|
"container.mjs",
|
|
@@ -177,8 +179,7 @@
|
|
|
177
179
|
"lint:content-format:schema": "node bin/content-build.mjs content-format schema --schema sohl=tests/fixtures/content-format/schema-sohl.json --schema hm3=tests/fixtures/content-format/schema-hm3.json",
|
|
178
180
|
"lint:content-format:fields": "node bin/content-build.mjs content-format fields --fields sohl && node bin/content-build.mjs content-format fields --fields hm3",
|
|
179
181
|
"changeset": "changeset",
|
|
180
|
-
"changeset:
|
|
181
|
-
"changeset:version": "changeset version && npm install --package-lock-only",
|
|
182
|
+
"changeset:version": "changeset version && node bin/package-build.mjs changelog group && npm install --package-lock-only",
|
|
182
183
|
"prepare": "git config core.hooksPath .githooks || true"
|
|
183
184
|
},
|
|
184
185
|
"engines": {
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `changelog group`: fold the newest release's changeset blocks together by
|
|
3
|
+
* their bold label, order them, and file an undeclared one last.
|
|
4
|
+
*
|
|
5
|
+
* Every earlier release section is untouched, byte for byte — only the
|
|
6
|
+
* first `## <version>` section's `### <Bump> Changes` bodies are rewritten,
|
|
7
|
+
* each independently (a label groups within its own bump level, never
|
|
8
|
+
* across one). Running this on its own output is a no-op: a release already
|
|
9
|
+
* in label order, with each label merged to one block, groups to itself.
|
|
10
|
+
*
|
|
11
|
+
* @param {string} text - The changelog's full contents.
|
|
12
|
+
* @param {object} [opts]
|
|
13
|
+
* @param {readonly string[]|null} [opts.labels] - `changelog.labels`, in
|
|
14
|
+
* display order. `null`/absent orders every group by first appearance
|
|
15
|
+
* instead, lead paragraph first, and files nothing as unknown.
|
|
16
|
+
* @returns {{text: string, findings: Array<{line: number,
|
|
17
|
+
* severity: "warning", message: string}>}}
|
|
18
|
+
*/
|
|
19
|
+
export function groupChangelogText(text: string, { labels }?: {
|
|
20
|
+
labels?: readonly string[] | null | undefined;
|
|
21
|
+
}): {
|
|
22
|
+
text: string;
|
|
23
|
+
findings: Array<{
|
|
24
|
+
line: number;
|
|
25
|
+
severity: "warning";
|
|
26
|
+
message: string;
|
|
27
|
+
}>;
|
|
28
|
+
};
|
|
@@ -1,11 +1,91 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Where a character offset in `text` falls, as a 1-based line and column.
|
|
3
|
+
*
|
|
4
|
+
* @param {string} text - The text the offset indexes into.
|
|
5
|
+
* @param {number} index - 0-based character offset.
|
|
6
|
+
* @returns {{line: number, column: number}}
|
|
7
|
+
*/
|
|
8
|
+
export function lineColOf(text: string, index: number): {
|
|
9
|
+
line: number;
|
|
10
|
+
column: number;
|
|
11
|
+
};
|
|
12
|
+
/**
|
|
13
|
+
* Every code region in `text`, as a set of 1-based line numbers it spans.
|
|
14
|
+
*
|
|
15
|
+
* Block-level only (`spans: false`): an inline code span does not remove a
|
|
16
|
+
* whole line from consideration, only the characters it covers, which the
|
|
17
|
+
* text-scanning rules mask separately.
|
|
18
|
+
*
|
|
19
|
+
* @param {string} text - The section text.
|
|
20
|
+
* @returns {Set<number>} Lines that fall inside a fenced or indented block.
|
|
21
|
+
*/
|
|
22
|
+
export function codeLineSet(text: string): Set<number>;
|
|
23
|
+
/**
|
|
24
|
+
* Every top-level bullet, as its own contiguous run of lines.
|
|
25
|
+
*
|
|
26
|
+
* A bullet's continuation — a wrapped line, a second paragraph, a nested
|
|
27
|
+
* elaboration — is indented under it and belongs to it; a line back at
|
|
28
|
+
* column 0 that is not itself a bullet (a bold subsection label, ordinary
|
|
29
|
+
* prose) closes it.
|
|
30
|
+
*
|
|
31
|
+
* @param {string} text - Section text.
|
|
32
|
+
* @param {Set<number>} codeLines - Lines inside a code region, from
|
|
33
|
+
* {@link codeLineSet}.
|
|
34
|
+
* @returns {Array<{startLine: number, text: string}>}
|
|
35
|
+
*/
|
|
36
|
+
export function topLevelBullets(text: string, codeLines: Set<number>): Array<{
|
|
37
|
+
startLine: number;
|
|
38
|
+
text: string;
|
|
39
|
+
}>;
|
|
40
|
+
/**
|
|
41
|
+
* Every top-level block of release prose — one rendered changeset entry, or
|
|
42
|
+
* one unlabelled paragraph standing in for one.
|
|
43
|
+
*
|
|
44
|
+
* `@heroiclands/package-build/changelog` (`changelog.cjs`) writes a
|
|
45
|
+
* changeset's whole summary as one block, verbatim, so a block here holds
|
|
46
|
+
* together the same way that summary is authored: a bold label opening a
|
|
47
|
+
* line at column 0 (`**Compendiums**`) starts a new block, and — unlike
|
|
48
|
+
* {@link topLevelBullets}, where a bullet marker *is* the top-level
|
|
49
|
+
* construct — a `-` bullet at column 0 belongs to whatever block is
|
|
50
|
+
* already open, since a label's bullets sit unindented directly under it.
|
|
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.
|
|
60
|
+
*
|
|
61
|
+
* @param {string} text - Section text.
|
|
62
|
+
* @param {Set<number>} codeLines - Lines inside a code region, from
|
|
63
|
+
* {@link codeLineSet}.
|
|
64
|
+
* @param {Set<number>} [scaffoldLines] - Generated heading lines that close
|
|
65
|
+
* whatever block is open without starting one, from
|
|
66
|
+
* {@link scaffoldLineSet} — empty for a caller that already isolated one
|
|
67
|
+
* `### <Bump> Changes` body, since no heading falls inside it.
|
|
68
|
+
* @returns {Array<{startLine: number, label: string|null, text: string}>}
|
|
69
|
+
*/
|
|
70
|
+
export function topLevelBlocks(text: string, codeLines: Set<number>, scaffoldLines?: Set<number>): Array<{
|
|
71
|
+
startLine: number;
|
|
72
|
+
label: string | null;
|
|
73
|
+
text: string;
|
|
74
|
+
}>;
|
|
1
75
|
/**
|
|
2
76
|
* Lint one pending changeset (`.changeset/*.md`).
|
|
3
77
|
*
|
|
4
78
|
* @param {string} text - The file's full contents, frontmatter included.
|
|
79
|
+
* @param {object} [opts]
|
|
80
|
+
* @param {readonly string[]|null} [opts.labels] - `changelog.labels`, in
|
|
81
|
+
* display order, or `null`/absent when the repository declares none —
|
|
82
|
+
* {@link checkUnknownLabels} checks nothing in that case.
|
|
5
83
|
* @returns {{findings: Array<{line: number, column?: number,
|
|
6
84
|
* severity: "error"|"warning", message: string}>}}
|
|
7
85
|
*/
|
|
8
|
-
export function lintChangesetText(text: string
|
|
86
|
+
export function lintChangesetText(text: string, { labels }?: {
|
|
87
|
+
labels?: readonly string[] | null | undefined;
|
|
88
|
+
}): {
|
|
9
89
|
findings: Array<{
|
|
10
90
|
line: number;
|
|
11
91
|
column?: number;
|
|
@@ -17,10 +97,16 @@ export function lintChangesetText(text: string): {
|
|
|
17
97
|
* Lint the first `## <version>` release section of a `CHANGELOG.md`.
|
|
18
98
|
*
|
|
19
99
|
* @param {string} text - The changelog's full contents.
|
|
100
|
+
* @param {object} [opts]
|
|
101
|
+
* @param {readonly string[]|null} [opts.labels] - `changelog.labels`, in
|
|
102
|
+
* display order, or `null`/absent when the repository declares none —
|
|
103
|
+
* {@link checkUnknownLabels} checks nothing in that case.
|
|
20
104
|
* @returns {{findings: Array<{line?: number, column?: number,
|
|
21
105
|
* severity: "error"|"warning", message: string}>}}
|
|
22
106
|
*/
|
|
23
|
-
export function lintReleaseText(text: string
|
|
107
|
+
export function lintReleaseText(text: string, { labels }?: {
|
|
108
|
+
labels?: readonly string[] | null | undefined;
|
|
109
|
+
}): {
|
|
24
110
|
findings: Array<{
|
|
25
111
|
line?: number;
|
|
26
112
|
column?: number;
|
|
@@ -28,6 +114,27 @@ export function lintReleaseText(text: string): {
|
|
|
28
114
|
message: string;
|
|
29
115
|
}>;
|
|
30
116
|
};
|
|
117
|
+
/**
|
|
118
|
+
* The first `## <version>` release section of a changelog, as raw character
|
|
119
|
+
* offsets rather than {@link extractReleaseSection}'s line-joined copy.
|
|
120
|
+
*
|
|
121
|
+
* `extractReleaseSection` rebuilds its `body` by joining a slice of
|
|
122
|
+
* `text.split("\n")`, which is fine for reporting a line number but drops
|
|
123
|
+
* the exact byte the next `## ` heading sits after — a caller rewriting the
|
|
124
|
+
* file in place, such as `changelog group`, needs `text.slice(start, end)`
|
|
125
|
+
* to be the section verbatim, so it can splice a replacement back in without
|
|
126
|
+
* guessing at the whitespace on either side.
|
|
127
|
+
*
|
|
128
|
+
* @param {string} text - The changelog's full contents.
|
|
129
|
+
* @returns {{start: number, end: number}|null} `null` when no `## ` heading
|
|
130
|
+
* is present. `text.slice(start, end)` is the section, byte-exact,
|
|
131
|
+
* including whatever separates it from the next `## ` heading or the end
|
|
132
|
+
* of the file.
|
|
133
|
+
*/
|
|
134
|
+
export function releaseSectionRange(text: string): {
|
|
135
|
+
start: number;
|
|
136
|
+
end: number;
|
|
137
|
+
} | null;
|
|
31
138
|
/**
|
|
32
139
|
* The finding one rule reports, before its line is mapped into the caller's
|
|
33
140
|
* file.
|