@heroiclands/package-build 0.6.0 → 3.0.0

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.
Files changed (127) hide show
  1. package/CHANGELOG-content-build.md +965 -0
  2. package/CHANGELOG.md +95 -0
  3. package/CONTENT.md +824 -0
  4. package/MIGRATING.md +127 -0
  5. package/README.md +29 -17
  6. package/bin/content-build.mjs +1082 -0
  7. package/bin/package-build.mjs +2 -2
  8. package/bin/report.mjs +2 -2
  9. package/config.mjs +21 -18
  10. package/content-config.mjs +1424 -0
  11. package/coverage.mjs +1 -1
  12. package/e2e.mjs +1 -1
  13. package/engine/abbreviations.mjs +0 -0
  14. package/engine/base-compiler.mjs +608 -0
  15. package/engine/code-fences.mjs +311 -0
  16. package/engine/compendiums.mjs +353 -0
  17. package/engine/content-address.mjs +141 -0
  18. package/engine/content-links.mjs +438 -0
  19. package/engine/content-lint.mjs +204 -0
  20. package/engine/content-package.mjs +67 -0
  21. package/engine/content-slug.mjs +169 -0
  22. package/engine/content-tables.mjs +1216 -0
  23. package/engine/content-tree.mjs +56 -0
  24. package/engine/diagnostics.mjs +270 -0
  25. package/engine/field-reference.mjs +242 -0
  26. package/engine/field-spec.mjs +218 -0
  27. package/engine/foreign-catalog.mjs +450 -0
  28. package/engine/foreign-manifests.mjs +129 -0
  29. package/engine/frontmatter-lint.mjs +343 -0
  30. package/engine/frontmatter.mjs +258 -0
  31. package/engine/generate.mjs +305 -0
  32. package/engine/helpers.mjs +809 -0
  33. package/engine/ids.mjs +192 -0
  34. package/engine/index.mjs +142 -0
  35. package/engine/item-docs.mjs +153 -0
  36. package/engine/item-registry.mjs +129 -0
  37. package/engine/journals.mjs +374 -0
  38. package/engine/kb-manifest.mjs +473 -0
  39. package/engine/macros.mjs +333 -0
  40. package/engine/manifest-emit.mjs +339 -0
  41. package/engine/map-notes.mjs +1302 -0
  42. package/engine/pack-config.mjs +479 -0
  43. package/engine/pack-router.mjs +254 -0
  44. package/engine/prose-config.mjs +238 -0
  45. package/engine/prose-lint.mjs +294 -0
  46. package/engine/region-events.mjs +79 -0
  47. package/engine/scene-levels.mjs +420 -0
  48. package/engine/scenes.mjs +528 -0
  49. package/engine/site-build.mjs +778 -0
  50. package/engine/site-index.mjs +332 -0
  51. package/engine/web-wikilinks.mjs +330 -0
  52. package/engine/wikilink-syntax.mjs +105 -0
  53. package/engine/wikilinks.mjs +645 -0
  54. package/index.mjs +42 -25
  55. package/lang.mjs +1 -1
  56. package/manifest.mjs +79 -5
  57. package/markdownlint-config.mjs +45 -0
  58. package/package.json +78 -34
  59. package/prettier-config.mjs +34 -0
  60. package/sohl/actors.mjs +581 -0
  61. package/sohl/affiliation-standings.mjs +44 -0
  62. package/sohl/being-info.mjs +173 -0
  63. package/sohl/default-item-art.mjs +82 -0
  64. package/sohl/index.mjs +55 -0
  65. package/sohl/item-builders.mjs +114 -0
  66. package/sohl/item-fields.mjs +867 -0
  67. package/sohl/items.mjs +197 -0
  68. package/sohl/kb-passes.mjs +207 -0
  69. package/sohl/note-schemas.mjs +322 -0
  70. package/templates.mjs +1 -1
  71. package/types/config.d.mts +1 -1
  72. package/types/content-config.d.mts +571 -0
  73. package/types/engine/abbreviations.d.mts +44 -0
  74. package/types/engine/base-compiler.d.mts +320 -0
  75. package/types/engine/code-fences.d.mts +102 -0
  76. package/types/engine/compendiums.d.mts +77 -0
  77. package/types/engine/content-address.d.mts +61 -0
  78. package/types/engine/content-links.d.mts +87 -0
  79. package/types/engine/content-lint.d.mts +47 -0
  80. package/types/engine/content-package.d.mts +36 -0
  81. package/types/engine/content-slug.d.mts +59 -0
  82. package/types/engine/content-tables.d.mts +131 -0
  83. package/types/engine/content-tree.d.mts +12 -0
  84. package/types/engine/diagnostics.d.mts +152 -0
  85. package/types/engine/field-reference.d.mts +20 -0
  86. package/types/engine/field-spec.d.mts +181 -0
  87. package/types/engine/foreign-catalog.d.mts +103 -0
  88. package/types/engine/foreign-manifests.d.mts +43 -0
  89. package/types/engine/frontmatter-lint.d.mts +74 -0
  90. package/types/engine/frontmatter.d.mts +99 -0
  91. package/types/engine/generate.d.mts +56 -0
  92. package/types/engine/helpers.d.mts +348 -0
  93. package/types/engine/ids.d.mts +126 -0
  94. package/types/engine/index.d.mts +33 -0
  95. package/types/engine/item-docs.d.mts +73 -0
  96. package/types/engine/item-registry.d.mts +57 -0
  97. package/types/engine/journals.d.mts +121 -0
  98. package/types/engine/kb-manifest.d.mts +214 -0
  99. package/types/engine/macros.d.mts +201 -0
  100. package/types/engine/manifest-emit.d.mts +137 -0
  101. package/types/engine/map-notes.d.mts +283 -0
  102. package/types/engine/pack-config.d.mts +77 -0
  103. package/types/engine/pack-router.d.mts +53 -0
  104. package/types/engine/prose-config.d.mts +121 -0
  105. package/types/engine/prose-lint.d.mts +59 -0
  106. package/types/engine/region-events.d.mts +43 -0
  107. package/types/engine/scene-levels.d.mts +36 -0
  108. package/types/engine/scenes.d.mts +48 -0
  109. package/types/engine/site-build.d.mts +214 -0
  110. package/types/engine/site-index.d.mts +141 -0
  111. package/types/engine/web-wikilinks.d.mts +67 -0
  112. package/types/engine/wikilink-syntax.d.mts +101 -0
  113. package/types/engine/wikilinks.d.mts +172 -0
  114. package/types/index.d.mts +3 -0
  115. package/types/manifest.d.mts +52 -4
  116. package/types/markdownlint-config.d.mts +8 -0
  117. package/types/prettier-config.d.mts +1 -0
  118. package/types/sohl/actors.d.mts +65 -0
  119. package/types/sohl/affiliation-standings.d.mts +22 -0
  120. package/types/sohl/being-info.d.mts +78 -0
  121. package/types/sohl/default-item-art.d.mts +28 -0
  122. package/types/sohl/index.d.mts +7 -0
  123. package/types/sohl/item-builders.d.mts +11 -0
  124. package/types/sohl/item-fields.d.mts +20 -0
  125. package/types/sohl/items.d.mts +19 -0
  126. package/types/sohl/kb-passes.d.mts +71 -0
  127. package/types/sohl/note-schemas.d.mts +7 -0
@@ -0,0 +1,204 @@
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
+ * Linting a content tree's **addresses** — the rules every package's notes are
16
+ * authored against, wherever those notes live.
17
+ *
18
+ * These rules used to live in the SoHL repository's `utils/`, which had two
19
+ * consequences and no upside (#20). `thalorna` and `kethira` notes were checked
20
+ * by nothing at all, so the packages most likely to carry authoring mistakes
21
+ * were the ones nothing inspected. And one rule with two implementations can
22
+ * disagree without anything detecting it, which the canonical-separator
23
+ * handling already did once on each side.
24
+ *
25
+ * Two rules, both about a note's identity:
26
+ *
27
+ * 1. **Shape** — a `shortcode` is strictly ASCII-alphanumeric. It is the
28
+ * identity key referenced from saved world data, and it is half of the
29
+ * `type-shortcode` address, whose parse depends on the separating hyphen
30
+ * being the only hyphen in the string.
31
+ * 2. **Uniqueness** — `(type, shortcode)` names one note.
32
+ *
33
+ * **Nothing here writes.** A check reports and an author fixes.
34
+ *
35
+ * **A third rule was retired (#79).** Every note used to be required to repeat
36
+ * its own `type-shortcode` address in the top-level `aliases:` list. That
37
+ * served exactly one reader — **Obsidian**, so `[[type-shortcode]]` resolved in
38
+ * the editor — and nothing else ever read it: both resolvers parse the hyphen
39
+ * qualifier themselves, and the alias list feeds only the bare-alias fallback
40
+ * index. The project no longer authors in Obsidian, so the rule required a line
41
+ * of frontmatter per note for a reader that does not exist. Removing it was
42
+ * verified output-neutral beforehand: across 1,735 stripped notes,
43
+ * `package compile` produced byte-identical `build/packs-json` and the site
44
+ * build byte-identical `site/content`.
45
+ *
46
+ * **What is deliberately absent.** Corpus reachability — "every Rules document
47
+ * is reachable from the book's root" — is a statement about what one package
48
+ * publishes, not about the note format, and belongs with the publishing it
49
+ * describes. So do retired hostnames.
50
+ *
51
+ * @module
52
+ */
53
+
54
+ import fs from "node:fs";
55
+ import path from "node:path";
56
+
57
+ import { positionInFrontmatter } from "./diagnostics.mjs";
58
+ import { walkMarkdownTree } from "./helpers.mjs";
59
+
60
+ /**
61
+ * The shape every `shortcode` must match: ASCII letters and digits only.
62
+ *
63
+ * Case is deliberately **not** constrained: hundreds of authored shortcodes are
64
+ * mixed-case and collide with nothing, so tightening that is a separate
65
+ * decision from this one.
66
+ *
67
+ * A consuming system's *runtime* keeps its own copy of this pattern — it cannot
68
+ * import a build-time dependency into shipped code — and is expected to pin the
69
+ * two together with a test rather than trust that they still agree.
70
+ */
71
+ export const SHORTCODE_PATTERN = /^[A-Za-z0-9]+$/;
72
+
73
+ /**
74
+ * Whether a value is a well-formed shortcode.
75
+ *
76
+ * A blank value is **not** valid here. Blank is handled separately wherever a
77
+ * key is derived from a document's name, so this predicate answers only "is
78
+ * this an acceptable key", never "is this key present".
79
+ *
80
+ * @param {unknown} value - The candidate shortcode.
81
+ * @returns {boolean} `true` when it matches {@link SHORTCODE_PATTERN}.
82
+ */
83
+ export function isValidShortcode(value) {
84
+ return typeof value === "string" && SHORTCODE_PATTERN.test(value);
85
+ }
86
+
87
+ /**
88
+ * Collect the notes a lint pass reasons about.
89
+ *
90
+ * Only notes carrying a `type` are content notes. Vault scaffolding —
91
+ * `Templates/`, a `README`, a repository's own `CLAUDE.md` — has no type, is
92
+ * neither addressed nor addressable, and would fail rules it can never satisfy.
93
+ *
94
+ * @param {string} contentBase - Root of the content tree.
95
+ * @param {object} [opts]
96
+ * @param {readonly string[]} [opts.skipDirectories] - Passed to the walk.
97
+ * @returns {Array<{fm: object, absPath: string, file: string}>} The notes, in
98
+ * path order so findings read top to bottom.
99
+ */
100
+ function collectNotes(contentBase, { skipDirectories } = {}) {
101
+ const notes = [];
102
+ const walkOpts = skipDirectories ? { skipDirectories } : undefined;
103
+ for (const { frontmatter: fm, absPath } of walkMarkdownTree(
104
+ contentBase,
105
+ walkOpts,
106
+ )) {
107
+ if (!fm || !fm.type) continue;
108
+ notes.push({
109
+ fm,
110
+ absPath,
111
+ file: path.relative(process.cwd(), absPath),
112
+ });
113
+ }
114
+ notes.sort((a, b) => (a.absPath < b.absPath ? -1 : 1));
115
+ return notes;
116
+ }
117
+
118
+ /**
119
+ * Lint every address in a content tree.
120
+ *
121
+ * @param {string} contentBase - Root of the content tree.
122
+ * @param {object} [opts]
123
+ * @param {readonly string[]} [opts.skipDirectories] - Directory names the walk
124
+ * ignores. Defaults to the configured list.
125
+ * @returns {{findings: Array<{file: string, line?: number, column?: number,
126
+ * severity: "error"|"warning", message: string}>, notes: number,
127
+ * keys: number}} The findings, and what was inspected to produce them.
128
+ */
129
+ export function lintContentTree(contentBase, { skipDirectories } = {}) {
130
+ const findings = [];
131
+ const notes = collectNotes(contentBase, { skipDirectories });
132
+
133
+ /** @type {Map<string, Array<{file: string, absPath: string}>>} */
134
+ const byKey = new Map();
135
+
136
+ for (const { fm, absPath, file } of notes) {
137
+ const shortcode = fm.shortcode;
138
+ // Folder documents and keyless entries carry no address at all.
139
+ if (!shortcode) continue;
140
+
141
+ // Read only when there is something to say about the note, so a clean
142
+ // tree costs one pass rather than two.
143
+ const raw = () => fs.readFileSync(absPath, "utf8");
144
+
145
+ const key = `${fm.type}:${shortcode}`;
146
+ const seen = byKey.get(key);
147
+ if (seen) seen.push({ file, absPath });
148
+ else byKey.set(key, [{ file, absPath }]);
149
+
150
+ if (!isValidShortcode(shortcode)) {
151
+ findings.push({
152
+ file,
153
+ ...positionInFrontmatter(raw(), "shortcode", String(shortcode)),
154
+ severity: "error",
155
+ message:
156
+ `shortcode "${shortcode}" is not strictly alphanumeric; it ` +
157
+ `is the identity key and half of the ` +
158
+ `"${fm.type}-${shortcode}" address, whose parse needs the ` +
159
+ `separator to be the only hyphen`,
160
+ });
161
+ }
162
+ }
163
+
164
+ // "Every one of nothing is unique" is a vacuous pass, and it is exactly
165
+ // what a tree that failed to check out produces — so the lint would go
166
+ // green on the one state it most needs to catch.
167
+ if (byKey.size === 0) {
168
+ findings.push({
169
+ file: path.relative(process.cwd(), contentBase) || contentBase,
170
+ severity: "error",
171
+ message:
172
+ "holds no keyed content, so every rule here is vacuous — " +
173
+ "check that the content tree is present and that this is its root",
174
+ });
175
+ return { findings, notes: notes.length, keys: 0 };
176
+ }
177
+
178
+ for (const [key, files] of byKey) {
179
+ if (files.length < 2) continue;
180
+ // Reported once per offending note rather than once per key: each note
181
+ // is a place an author has to go and edit, and a finding naming only
182
+ // the key sends them hunting for the other one.
183
+ for (const { file, absPath } of files) {
184
+ const others = files
185
+ .filter((f) => f.file !== file)
186
+ .map((f) => f.file);
187
+ findings.push({
188
+ file,
189
+ ...positionInFrontmatter(
190
+ fs.readFileSync(absPath, "utf8"),
191
+ "shortcode",
192
+ ),
193
+ severity: "error",
194
+ message:
195
+ `duplicate address "${key}", also declared by ` +
196
+ `${others.join(", ")}; a document is addressed by ` +
197
+ `(type, shortcode) across every pack of its document type, ` +
198
+ `so routing them to different packs does not separate them`,
199
+ });
200
+ }
201
+ }
202
+
203
+ return { findings, notes: notes.length, keys: byKey.size };
204
+ }
@@ -0,0 +1,67 @@
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
+ * Which package's notes this repository compiles, and which Foundry package
16
+ * ships them — read from the repository's `package-build.config.yaml`.
17
+ *
18
+ * Both values are **derived**, not declared: `config.mjs` is the single place
19
+ * the configuration is resolved (#1508). This module survives as the import
20
+ * path the link resolver and the compilers have always used, so that the values
21
+ * can still be mocked in one place and so no consumer has to learn a new
22
+ * spelling for them.
23
+ */
24
+
25
+ import { loadPackConfig } from "./pack-config.mjs";
26
+
27
+ /**
28
+ * The **content** package: the distribution unit a note declares in its
29
+ * `package:` frontmatter. The pack compilers select their entries by it.
30
+ *
31
+ * Stable across compilation targets. If this content were ever compiled for a
32
+ * second game system, its notes would still declare `package: sohl` — only the
33
+ * Foundry package below would differ.
34
+ *
35
+ * An accessor rather than a hoisted constant, so that importing this module
36
+ * needs no configuration (#2).
37
+ *
38
+ * @returns {string} The configured `contentPackage`.
39
+ */
40
+ export function contentPackage() {
41
+ return loadPackConfig().contentPackage;
42
+ }
43
+
44
+ /**
45
+ * The **Foundry package** this repository's packs are shipped in — the `id` in
46
+ * `assets/templates/system.template.json`, and the first segment of every
47
+ * compendium UUID the compilers emit.
48
+ *
49
+ * Distinct from {@link contentPackage}, and equal to it only by coincidence
50
+ * here: a note says `package: sohl` and its documents are addressed as
51
+ * `Compendium.sohl.<pack>.<Type>.<id>`. In `sohl-thalorna` the two differ
52
+ * (`thalorna` vs `sohl-thalorna`), which is why they are separate values rather
53
+ * than one — treating them as interchangeable is what #1498 was.
54
+ *
55
+ * Configured rather than read from the manifest so the link resolver stays
56
+ * filesystem-free and unit-testable. `assertPackageIdMatchesManifestFile` in
57
+ * `package-manifest.mjs` — called from `generatePacksJson`, before any entry is
58
+ * written — fails the build if this value and the manifest's `id` ever drift.
59
+ *
60
+ * An accessor rather than a hoisted constant, so that importing this module
61
+ * needs no configuration (#2).
62
+ *
63
+ * @returns {string} The configured `foundryPackage`.
64
+ */
65
+ export function foundryPackageId() {
66
+ return loadPackConfig().foundryPackage;
67
+ }
@@ -0,0 +1,169 @@
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
+ * The URL segment of a content note — derived from its name.
16
+ *
17
+ * Content notes carry no authored `slug`: it was a hand-maintained second
18
+ * spelling of something already determined, free to drift from the page it
19
+ * named. The URL is derived instead.
20
+ *
21
+ * It is derived from the **name**, deliberately not from the `shortcode`, even
22
+ * though `(type, shortcode)` is unique by rule and would be a tempting key. A
23
+ * shortcode is *identity*: it is referenced from saved world data — actions,
24
+ * cohorts, expressions, archetypes, pack lookups — so binding the public URL to
25
+ * it would make a cosmetic URL change into a data migration. A URL is
26
+ * presentation, and it should read like one (`/creature/nusvorroth/`, not
27
+ * `/creature/nsvrroth/`). Renames are what a URL must survive, and they do:
28
+ * every change appends to the legacy-URL map, which emits a redirect.
29
+ *
30
+ * **One normalisation, for every slug this build makes.** {@link slugify} is it.
31
+ * Two things are layered on top of it for **document identity** only, in
32
+ * {@link contentSlug}:
33
+ *
34
+ * - it must produce something, and throws when a name yields no slug;
35
+ * - it **abbreviates** — see {@link ABBREVIATIONS}.
36
+ *
37
+ * Abbreviation stops at the document's own address on purpose. A heading anchor
38
+ * is not a name the build invents: an author writes the matching key by hand —
39
+ * a map note pins `locations.stair-foot` at a heading called *Stair Foot* — so a
40
+ * slug that silently became `stair-ft` would break a reference nobody could
41
+ * have predicted. The same goes for a pack filename, which is only ever read
42
+ * back by the unpacker. Shortening either buys nothing and costs the author's
43
+ * ability to guess the key.
44
+ *
45
+ * This header used to claim the opposite — that anchor slugs, filename slugs and
46
+ * this one were deliberately separate operations. Three of them had drifted into
47
+ * dropping non-ASCII letters instead of transliterating them, so `Kûrbúl Helm`
48
+ * addressed a page at `kurbul-helm` while its pack file was `k-rb-l-helm` and a
49
+ * link to a heading of the same name pointed at `#k-rb-l-helm`. Twenty-two of
50
+ * this repository's notes were affected. That was not a design; it was three
51
+ * copies of a regex, and the differences between them were all mistakes.
52
+ *
53
+ * What the rule does, and why:
54
+ *
55
+ * - **Transliterate, don't discard.** `unidecode` carries every non-ASCII letter
56
+ * to its ASCII sense — `æ` → `ae`, `þ` → `th`, `œ` → `oe`, `ß` → `ss`,
57
+ * `ö` → `o`, `¾` → `3/4`. A rule that merely strips them turns a name into
58
+ * punctuation.
59
+ * - **An apostrophe elides.** `’` and `'` mark a pronunciation break — a glottal
60
+ * stop — inside one word, so `Kenbet’Pat` is `kenbetpat`, not `kenbet-pat`.
61
+ * - **Everything else non-alphanumeric becomes a hyphen**, collapsed and
62
+ * trimmed.
63
+ *
64
+ * `slugifyShortcode` in the SoHL runtime is genuinely a different operation and
65
+ * stays separate: it runs the other way, suggesting a shortcode *from* a name
66
+ * when an item is created.
67
+ *
68
+ * Plain ESM with no Foundry and no filesystem access, so it is unit-testable.
69
+ */
70
+
71
+ import unidecode from "unidecode";
72
+
73
+ import { abbreviateTokens } from "./abbreviations.mjs";
74
+
75
+ /**
76
+ * The URL segment for one content note.
77
+ *
78
+ * The name is **transliterated** before it is reduced, so an accented character
79
+ * is carried across rather than dropped — dropping is what turned `Nüsvōrroth`
80
+ * into `n-sv-rroth` and forced a hand-written slug. Ligatures expand the way a
81
+ * reader would spell them out: `þ`→`th`, `æ`→`ae`, `œ`→`oe`, `ß`→`ss`, `ij`→`ij`,
82
+ * `fi`→`fi`, and eth (`ð`) follows the Icelandic convention of a bare `d`.
83
+ *
84
+ * Two reductions are ours rather than the transliterator's:
85
+ *
86
+ * - **apostrophes are removed**, not treated as separators (`Armorer's Kit` →
87
+ * `armorers-kit`), matching the URLs these pages already publish at;
88
+ * - **a fraction keeps its digits together** — a vulgar fraction expands to
89
+ * `3/4`, and the solidus would otherwise split it into `3-4`, so a slash
90
+ * *between digits* is closed up (`Kûrbúl ¾-Helm` → `kurbul-34-helm`).
91
+ *
92
+ * @param {string | undefined} name - The note's display name (`name.full`),
93
+ * which a malformed note may not have at all.
94
+ * @returns {string} The URL segment (never empty).
95
+ * @throws {Error} When there is no name, or the name carries no URL-safe
96
+ * characters — either way the note cannot be addressed, which is a content
97
+ * error rather than something to paper over with a fallback.
98
+ */
99
+ export function slugify(text) {
100
+ const raw = typeof text === "string" ? text.trim() : "";
101
+ if (!raw) return "";
102
+ const tokens = unidecode(raw)
103
+ .toLowerCase()
104
+ // An apostrophe marks a pronunciation break, not a word boundary:
105
+ // `Kenbet\u2019Pat` is one name said with a catch in it, so it elides
106
+ // rather than becoming a hyphen.
107
+ .replace(/['\u2019]/g, "")
108
+ // A vulgar fraction transliterates to its digits (`\u00be` \u2192 `3/4`); the
109
+ // solidus between them is not a word boundary either.
110
+ .replace(/(\d)\/(\d)/g, "$1$2")
111
+ .split(/[^a-z0-9]+/)
112
+ .filter(Boolean);
113
+
114
+ return tokens.join("-");
115
+ }
116
+
117
+ /**
118
+ * The URL segment a content note publishes at.
119
+ *
120
+ * {@link slugify} with the rule that a document *must* be addressable: a note
121
+ * that yields no slug is a content error, not something to paper over with a
122
+ * fallback, because the alternative is a page nobody can reach.
123
+ *
124
+ * @param {string | undefined} name - The note's display name (`name.full`),
125
+ * which a malformed note may not have at all.
126
+ * @returns {string} The URL segment (never empty).
127
+ * @throws {Error} When there is no name, or the name carries no URL-safe
128
+ * characters.
129
+ */
130
+ export function contentSlug(name) {
131
+ const raw = typeof name === "string" ? name.trim() : "";
132
+ if (!raw) {
133
+ throw new Error("content note has no name, so it has no URL");
134
+ }
135
+ const normalised = slugify(raw);
136
+ const slug = abbreviateTokens(normalised.split("-").filter(Boolean)).join(
137
+ "-",
138
+ );
139
+ if (!slug) {
140
+ throw new Error(
141
+ `name "${raw}" has no URL-safe characters, so it cannot address a page`,
142
+ );
143
+ }
144
+ return slug;
145
+ }
146
+
147
+ /**
148
+ * Find pages that would publish to the same URL.
149
+ *
150
+ * Nothing constrains two notes in one section from sharing a name, and a
151
+ * collision silently overwrites one page with the other. This turns it into a
152
+ * build failure that names every claimant, so the fix is a more specific title.
153
+ * (The content tree has no collisions today.)
154
+ *
155
+ * @param {Array<{sec: string, slug: string, src: string}>} pages
156
+ * @returns {Array<{url: string, sources: string[]}>} One entry per collision, in
157
+ * first-claim order; empty when every URL is unique.
158
+ */
159
+ export function findSlugCollisions(pages) {
160
+ const byUrl = new Map();
161
+ for (const { sec, slug, src } of pages) {
162
+ const url = `/${sec}/${slug}/`;
163
+ if (!byUrl.has(url)) byUrl.set(url, []);
164
+ byUrl.get(url).push(src);
165
+ }
166
+ return [...byUrl.entries()]
167
+ .filter(([, sources]) => sources.length > 1)
168
+ .map(([url, sources]) => ({ url, sources }));
169
+ }