@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.
@@ -0,0 +1,161 @@
1
+ /*
2
+ * This file is part of the Song of Heroic Lands (SoHL) system for Foundry VTT.
3
+ * Copyright (c) 2024-2026 Tom Rodriguez ("Toasty") — <toasty@heroiclands.org>
4
+ *
5
+ * This work is licensed under the GNU General Public License v3.0 (GPLv3).
6
+ * You may copy, modify, and distribute it under the terms of that license.
7
+ *
8
+ * For full terms, see the LICENSE.md file in the project root or visit:
9
+ * https://www.gnu.org/licenses/gpl-3.0.html
10
+ *
11
+ * SPDX-License-Identifier: GPL-3.0-or-later
12
+ */
13
+
14
+ /**
15
+ * What links to a page, and what it links to.
16
+ *
17
+ * The site build resolves every wikilink through the address index, so by the
18
+ * time it writes a page it holds the whole link graph. This is the half that
19
+ * turns the graph into front matter: the `related` block the theme's Related
20
+ * card reads, on every page, in one shape.
21
+ *
22
+ * **A page is identified by its URL.** An address is unique within a package
23
+ * and a page publishes at exactly one, so the URL is the one key both a
24
+ * resolved wikilink and a homepage's markdown link can be reduced to — the
25
+ * link resolver answers with a URL, and a homepage link *is* one. It is also
26
+ * what makes a `doc<type>-<shortcode>` link a link to the same page as the
27
+ * `<type>-<shortcode>` form: the site index keys one page under both, and both
28
+ * answer with one URL.
29
+ *
30
+ * **Only a page of this site is an endpoint.** A link into another package
31
+ * resolves to a URL the build renders but no page it writes, so it appears
32
+ * on neither side: a backlink can only be counted on a page this build emits,
33
+ * and listing a foreign mention beside local ones would make the two lists
34
+ * disagree about what a connection is.
35
+ *
36
+ * @module
37
+ */
38
+
39
+ import { homepageAddresses } from "./homepage.mjs";
40
+
41
+ /**
42
+ * One entry in a `related` list — the page a reader is pointed at.
43
+ *
44
+ * @typedef {object} RelatedEntry
45
+ * @property {string} title The page's published title.
46
+ * @property {string} url `<base><slug>/` — the page's address as every href
47
+ * this build renders composes it, never the
48
+ * site-root-relative form the page states for itself.
49
+ * @property {string} type The note's `type`, which the theme groups by.
50
+ */
51
+
52
+ /**
53
+ * The `related` block one page publishes.
54
+ *
55
+ * @typedef {object} Related
56
+ * @property {RelatedEntry[]} backlinks Pages that link to this one.
57
+ * @property {RelatedEntry[]} mentions Pages this one links to.
58
+ */
59
+
60
+ /**
61
+ * A stable order for a list: by type, then title.
62
+ *
63
+ * The theme groups a list by type and reads each group in the order it
64
+ * arrives, so the order is fixed here rather than left to walk order — which
65
+ * is content-path order, and would reshuffle a page's card whenever a note was
66
+ * filed elsewhere.
67
+ *
68
+ * @param {RelatedEntry} a
69
+ * @param {RelatedEntry} b
70
+ * @returns {number}
71
+ */
72
+ function byTypeThenTitle(a, b) {
73
+ return a.type.localeCompare(b.type, "en") || a.title.localeCompare(b.title, "en");
74
+ }
75
+
76
+ /**
77
+ * Inverts a link graph into each page's backlinks and mentions.
78
+ *
79
+ * An edge is `(source URL, target URL)`, and a URL that is not in `entries` is
80
+ * not a page of this site, so an edge touching one is dropped. A self-link says
81
+ * nothing a reader is not already looking at, and a page that links another
82
+ * three times is connected to it once, so both collapse.
83
+ *
84
+ * The result holds only pages with at least one connection, so the caller can
85
+ * write nothing on a page with none — the theme's silent-disappear convention,
86
+ * kept at the source rather than papered over with an empty block.
87
+ *
88
+ * @param {Iterable<readonly [string, string]>} edges - Every resolved link, as
89
+ * the source page's URL and the target page's.
90
+ * @param {ReadonlyMap<string, RelatedEntry>} entries - Every page of this
91
+ * site, keyed by URL.
92
+ * @returns {Map<string, Related>} URL → the page's `related` block, both lists
93
+ * sorted, for every page with at least one entry in either.
94
+ */
95
+ export function relatedPages(edges, entries) {
96
+ /** @type {Map<string, {backlinks: Set<string>, mentions: Set<string>}>} */
97
+ const graph = new Map();
98
+ const node = (url) => {
99
+ let found = graph.get(url);
100
+ if (!found) {
101
+ found = { backlinks: new Set(), mentions: new Set() };
102
+ graph.set(url, found);
103
+ }
104
+ return found;
105
+ };
106
+
107
+ for (const [source, target] of edges) {
108
+ if (source === target) continue;
109
+ if (!entries.has(source) || !entries.has(target)) continue;
110
+ node(source).mentions.add(target);
111
+ node(target).backlinks.add(source);
112
+ }
113
+
114
+ const list = (urls) =>
115
+ [...urls]
116
+ .map((url) => /** @type {RelatedEntry} */ (entries.get(url)))
117
+ .sort(byTypeThenTitle);
118
+
119
+ const out = new Map();
120
+ for (const [url, { backlinks, mentions }] of graph) {
121
+ out.set(url, { backlinks: list(backlinks), mentions: list(mentions) });
122
+ }
123
+ return out;
124
+ }
125
+
126
+ /**
127
+ * The pages a homepage's body links to, as URLs.
128
+ *
129
+ * A homepage is published verbatim — no wikilink on it resolves, and its links
130
+ * are ordinary markdown the browser resolves against the page's own address,
131
+ * which is the package root. So its edges are read the same way: each target
132
+ * resolved against `base`, the way a browser would. A package-relative
133
+ * `being-x/`, a root-relative `/pkg/being-x/` and a `./being-x/#anchor` all
134
+ * name the same page; a bare `#anchor` names the homepage itself.
135
+ *
136
+ * A link that names a host or a scheme leaves the site, whatever path it
137
+ * carries, and yields nothing: the site's own pages are addressed without one.
138
+ *
139
+ * @param {string} body - The homepage's markdown body.
140
+ * @param {string} base - Where the package is served — the homepage's own URL.
141
+ * @returns {string[]} The targets' URLs, in body order, repeats included.
142
+ */
143
+ export function homepageLinkTargets(body, base) {
144
+ const out = [];
145
+ // A placeholder origin, so a relative target resolves the way a browser
146
+ // resolves it from the homepage. It never reaches the result: only the
147
+ // path is read, and only when the target stayed on this origin.
148
+ const origin = "http://site.invalid";
149
+ for (const { url } of homepageAddresses(body)) {
150
+ if (/^[a-z][a-z0-9+.-]*:/i.test(url) || url.startsWith("//")) continue;
151
+ let resolved;
152
+ try {
153
+ resolved = new URL(url, `${origin}${base}`);
154
+ } catch {
155
+ continue;
156
+ }
157
+ if (resolved.origin !== origin) continue;
158
+ out.push(resolved.pathname);
159
+ }
160
+ return out;
161
+ }
@@ -81,6 +81,7 @@ import {
81
81
  } from "./homepage.mjs";
82
82
  import { publishesContentPages } from "../content-config.mjs";
83
83
  import { HUGO_CONTENT } from "./site-config.mjs";
84
+ import { homepageLinkTargets, relatedPages } from "./related-pages.mjs";
84
85
 
85
86
  const require = createRequire(import.meta.url);
86
87
 
@@ -291,18 +292,30 @@ export function collectHomepages(contentBase, ctx) {
291
292
  * states no `url` — the home kind has none to state — and the mount below it
292
293
  * holds pages and nothing else, so this is the only `_index.md` in the tree.
293
294
  *
295
+ * **Verbatim, but not unconnected.** The homepage is a page like any other on
296
+ * both sides of the link graph: a content page reaches it through
297
+ * `[[homepage-root|Text]]`, and its own markdown links name content pages. So
298
+ * it carries the same `related` block every content page does — handed in by
299
+ * the caller, since the graph is read off the content render and homepage-only
300
+ * mode has none.
301
+ *
294
302
  * @param {string} outRoot - The package's site root — the content mount's
295
303
  * root, `build/hugo/content`, one level above the mount itself.
296
304
  * @param {readonly object[]} pages - From {@link collectHomepages}.
297
305
  * @param {object} config - The resolved configuration, for the package name and
298
306
  * the default title.
307
+ * @param {object} [options] - Options.
308
+ * @param {import("./related-pages.mjs").Related} [options.related] - The
309
+ * homepage's backlinks and mentions. Absent where nothing connects to it,
310
+ * and in homepage-only mode, where no link resolves.
299
311
  * @returns {number} How many pages were written.
300
312
  */
301
- export function writeHomepages(outRoot, pages, config) {
313
+ export function writeHomepages(outRoot, pages, config, { related } = {}) {
302
314
  for (const page of pages) {
303
315
  const data = homepageFrontmatter(page.fm, {
304
316
  contentPackage: config.contentPackage,
305
317
  title: homepageTitle(page.fm, config),
318
+ related,
306
319
  });
307
320
  const dest = path.join(outRoot, HOMEPAGE_DESTINATION);
308
321
  fs.mkdirSync(path.dirname(dest), { recursive: true });
@@ -472,6 +485,12 @@ export function tableUniverse(pages) {
472
485
  * self-describing and makes sweeping the field out of a content tree
473
486
  * output-preserving for a site as it already is for the packs.
474
487
  *
488
+ * **`related` is derived, never authored.** What links to a page is a fact
489
+ * about every other page, known only once the whole tree has resolved — see
490
+ * {@link module:engine/related-pages} — so an authored value is dropped the
491
+ * way `aliases` is, and {@link renderPages} writes the derived block once it
492
+ * holds the graph.
493
+ *
475
494
  * @param {object} page - The page.
476
495
  * @param {object} options
477
496
  * @param {(data: object, page: object) => void} [options.decorate] - Called
@@ -486,7 +505,7 @@ export function tableUniverse(pages) {
486
505
  * @returns {object} The frontmatter to write.
487
506
  */
488
507
  export function pageFrontmatter(page, { decorate, webSrc, artSrc }) {
489
- const { fm, name, slug } = page;
508
+ const { fm, slug } = page;
490
509
  const data = {
491
510
  ...fm,
492
511
  // Spread after the note's own frontmatter. Guarded because
@@ -500,15 +519,31 @@ export function pageFrontmatter(page, { decorate, webSrc, artSrc }) {
500
519
  // from the filename.
501
520
  slug,
502
521
  url: `/${slug}/`,
503
- title: fm.title ?? name,
522
+ title: pageTitle(page),
504
523
  kbfolder: page.folder,
505
524
  };
506
525
  if (decorate) decorate(data, page);
507
526
  delete data.aliases;
527
+ delete data.related;
508
528
  if (webSrc && artSrc) resolveArtFields(data, webSrc, artSrc);
509
529
  return data;
510
530
  }
511
531
 
532
+ /**
533
+ * The title a content page publishes under: an authored `title`, else the
534
+ * note's name.
535
+ *
536
+ * One rule, read by the page's own front matter and by every `related` entry
537
+ * that names the page, so a card lists a page by exactly the title its heading
538
+ * shows.
539
+ *
540
+ * @param {object} page - The page, from {@link collectContentPages}.
541
+ * @returns {string} The title.
542
+ */
543
+ function pageTitle(page) {
544
+ return page.fm.title ?? page.name;
545
+ }
546
+
512
547
  /**
513
548
  * Rewrite a page's artwork addresses into the URLs the website serves.
514
549
  *
@@ -585,10 +620,23 @@ export function pageDestination(page) {
585
620
  * touches, so the repository's own rewrite runs before the shared one
586
621
  * rather than replacing it.
587
622
  *
623
+ * **Every page is rendered before any is written.** What links *to* a page is
624
+ * known only once every other page has resolved, and it is written into the
625
+ * page's own front matter, so the render is one pass over the tree and the
626
+ * write is a second — with the link graph read off the first, at the one
627
+ * point the resolver answers, and inverted once between them. Nothing is
628
+ * resolved twice.
629
+ *
588
630
  * @param {object[]} pages - Every page.
589
- * @param {object} options - Everything the render needs.
631
+ * @param {object} options - Everything the render needs. `homepages` is the
632
+ * homepage as the index knows it — `{ fm, body, url, title }` — which takes
633
+ * no part in the render and every part in the graph: a content page links
634
+ * it by wikilink, and its own markdown links name content pages.
590
635
  * @returns {{written: number, byKind: Record<string, number>, tableErrors: object[],
591
- * wikiErrors: object[], imageErrors: object[]}}
636
+ * wikiErrors: object[], imageErrors: object[],
637
+ * related: Map<string, import("./related-pages.mjs").Related>}} `related`
638
+ * is keyed by page URL, and holds the homepage's block beside every content
639
+ * page's, so the caller can write it on the page this render does not.
592
640
  */
593
641
  export function renderPages(pages, options) {
594
642
  const {
@@ -602,6 +650,7 @@ export function renderPages(pages, options) {
602
650
  sqlTables,
603
651
  config,
604
652
  records = [],
653
+ homepages = [],
605
654
  } = options;
606
655
 
607
656
  // The address space the art slots and the body's embeds resolve against:
@@ -616,6 +665,29 @@ export function renderPages(pages, options) {
616
665
  const wikiErrors = [];
617
666
  const imageErrors = [];
618
667
  const byKind = {};
668
+ // The link graph, as `(source URL, target URL)` — read off each page's
669
+ // resolution below, and off the homepage's markdown links.
670
+ /** @type {Array<[string, string]>} */
671
+ const edges = [];
672
+ // Every page of this site, as a `related` entry names it.
673
+ /** @type {Map<string, import("./related-pages.mjs").RelatedEntry>} */
674
+ const entries = new Map();
675
+ for (const page of pages) {
676
+ entries.set(page.url, {
677
+ title: pageTitle(page),
678
+ url: page.url,
679
+ type: String(page.fm.type),
680
+ });
681
+ }
682
+ for (const home of homepages) {
683
+ entries.set(home.url, { title: home.title, url: home.url, type: String(home.fm.type) });
684
+ for (const target of homepageLinkTargets(home.body, home.url)) {
685
+ edges.push([home.url, target]);
686
+ }
687
+ }
688
+ // What each page renders as, held until the graph is whole.
689
+ /** @type {Array<{page: object, body: string, data: object}>} */
690
+ const rendered = [];
619
691
 
620
692
  /**
621
693
  * The address the website serves for one authored pathname.
@@ -661,6 +733,8 @@ export function renderPages(pages, options) {
661
733
  // The page's path in the tree an author edits, below the content
662
734
  // root.
663
735
  const src = page.relPath ?? page.base;
736
+ // Every index entry a link on this page resolved to.
737
+ const resolved = [];
664
738
  const ctx = wikiContext(index, {
665
739
  src,
666
740
  file: page.file,
@@ -668,6 +742,7 @@ export function renderPages(pages, options) {
668
742
  errors: wikiErrors,
669
743
  foreignIndex: foreign.index,
670
744
  assets: artIndex,
745
+ resolved,
671
746
  });
672
747
 
673
748
  const webSrc = webAddresses(page.file);
@@ -699,13 +774,26 @@ export function renderPages(pages, options) {
699
774
  tableErrors.push(...errors);
700
775
 
701
776
  const data = pageFrontmatter(page, { decorate, webSrc, artSrc });
777
+ rendered.push({ page, body: protectCode(body, resolve), data });
778
+ // A resolved target with no URL is a pack-only package's address:
779
+ // real, and nowhere on the web, so not an edge.
780
+ for (const hit of resolved) {
781
+ if (hit.url) edges.push([page.url, hit.url]);
782
+ }
783
+ }
784
+
785
+ const related = relatedPages(edges, entries);
786
+
787
+ for (const { page, body, data } of rendered) {
788
+ const block = related.get(page.url);
789
+ if (block) data.related = block;
702
790
  const dest = path.join(outRoot, pageDestination(page));
703
791
  fs.mkdirSync(path.dirname(dest), { recursive: true });
704
- fs.writeFileSync(dest, matter.stringify(protectCode(body, resolve), data));
792
+ fs.writeFileSync(dest, matter.stringify(body, data));
705
793
  byKind[page.kind] = (byKind[page.kind] ?? 0) + 1;
706
794
  }
707
795
 
708
- return { written: pages.length, byKind, tableErrors, wikiErrors, imageErrors };
796
+ return { written: pages.length, byKind, tableErrors, wikiErrors, imageErrors, related };
709
797
  }
710
798
 
711
799
  /**
@@ -892,11 +980,17 @@ export function buildSite({ config, sqlTables } = {}) {
892
980
  // homepage is authored markdown published verbatim, with no table
893
981
  // expansion and no link resolution of its own, and routing it through
894
982
  // that pipeline would buy it a pass it has no input for.
983
+ //
984
+ // It is a node of the link graph all the same: `renderPages` reads its
985
+ // markdown links for the edges it authors, and the resolver records the
986
+ // ones that land on it.
895
987
  const homepageEntries = homepages.map((page) => ({
896
988
  kind: "content",
897
989
  fm: page.fm,
990
+ body: page.body,
898
991
  pkg: resolved.contentPackage,
899
992
  name: page.fm.name?.full ?? homepageTitle(page.fm, resolved),
993
+ title: homepageTitle(page.fm, resolved),
900
994
  slug: addressSlug(page.fm),
901
995
  base: path.basename(page.file),
902
996
  url: base,
@@ -926,6 +1020,7 @@ export function buildSite({ config, sqlTables } = {}) {
926
1020
  index: gates.index,
927
1021
  foreign: gates.foreign,
928
1022
  universe: tableUniverse(pages),
1023
+ homepages: homepageEntries,
929
1024
  pass,
930
1025
  // What counts as a being is the toolchain's to say, not a consumer's.
931
1026
  // Asking in a consumer's script is how one came to still be checking
@@ -947,8 +1042,11 @@ export function buildSite({ config, sqlTables } = {}) {
947
1042
  });
948
1043
 
949
1044
  // Last, and outside the mount: the package's front page is not part of the
950
- // content tree it introduces.
951
- const homepagesWritten = writeHomepages(homeRoot, homepages, resolved);
1045
+ // content tree it introduces. It publishes at `base`, which is where the
1046
+ // graph keys its block.
1047
+ const homepagesWritten = writeHomepages(homeRoot, homepages, resolved, {
1048
+ related: rendered.related.get(base),
1049
+ });
952
1050
 
953
1051
  return {
954
1052
  gates,
@@ -289,6 +289,9 @@ export function buildSiteIndex(
289
289
  * resolvers that distinguish a foreign hit from a local one.
290
290
  * @param {object} [options.assets] - The address space an `![[…]]` embed
291
291
  * resolves against, shaped as every asset resolver reads one.
292
+ * @param {object[]} [options.resolved] - Collector the resolver appends every
293
+ * resolved target's index entry to — the page's outbound edges, read off
294
+ * the one pass that resolves them.
292
295
  * @returns {object} The resolver context.
293
296
  *
294
297
  * There is deliberately **no `manifestsComplete`**. It used to let a resolver
@@ -299,7 +302,7 @@ export function buildSiteIndex(
299
302
  */
300
303
  export function wikiContext(
301
304
  built,
302
- { src, file, type = null, errors, foreignIndex = new Map(), assets },
305
+ { src, file, type = null, errors, foreignIndex = new Map(), assets, resolved },
303
306
  ) {
304
307
  return {
305
308
  index: built.index,
@@ -321,6 +324,7 @@ export function wikiContext(
321
324
  errors,
322
325
  src,
323
326
  file,
327
+ resolved,
324
328
  };
325
329
  }
326
330
 
@@ -311,9 +311,17 @@ function isPlainMap(value) {
311
311
  * {@link module:engine/content-images.renderImageFigures} turns into a figure
312
312
  * for the website, and what the book reads its staging list out of.
313
313
  *
314
+ * ## Every hit is recorded, so the page's edges come out of one pass
315
+ *
316
+ * A caller that hands in `ctx.resolved` is given every index entry a link on
317
+ * the page resolved to, in body order, repeats included. That is the link
318
+ * graph, read at the one point where it exists: the build never has to walk a
319
+ * page a second time to learn what it links to. Nothing is recorded for a
320
+ * same-page anchor or for a target that resolved nowhere.
321
+ *
314
322
  * @param {string} body - The markdown body.
315
323
  * @param {object} ctx - `{ index, assets, collide, contentTypes,
316
- * packages, noIndexPackages, foreign, type, errors, src, file }`.
324
+ * packages, noIndexPackages, foreign, type, errors, src, file, resolved }`.
317
325
  * `packages` is every package an address may name, without which the leading
318
326
  * package segment of a canonical address reads as an unknown type;
319
327
  * `noIndexPackages` is every package declared `contentIndex: false`, so a
@@ -322,7 +330,8 @@ function isPlainMap(value) {
322
330
  * is the cross-package manifest index; `assets` is the address space an embed
323
331
  * resolves against. `src` is the page's display
324
332
  * path and `file` the source file a diagnostic should name — absent, `src`
325
- * stands in.
333
+ * stands in. `resolved`, when supplied, is the array every resolved
334
+ * target's index entry is appended to.
326
335
  * @returns {string} The body with embeds and wikilinks rewritten.
327
336
  */
328
337
  export function resolveWebWikilinks(body, ctx) {
@@ -425,6 +434,8 @@ export function resolveWebWikilinks(body, ctx) {
425
434
  lookupRead(ctx.foreign, read, ctx.contentPackage)
426
435
  : undefined);
427
436
  if (hit) {
437
+ // The edge, for a caller reading the link graph off this pass.
438
+ ctx.resolved?.push(hit);
428
439
  // An address with an *empty* label has no prose to show (a
429
440
  // shortcode is not display text), so the document's **current**
430
441
  // name stands in and a rename shows at every citation.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@heroiclands/package-build",
3
- "version": "22.4.2",
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:check": "changeset status --since=origin/main",
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.