@heroiclands/package-build 0.6.1 → 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.
- package/CHANGELOG-content-build.md +965 -0
- package/CHANGELOG.md +58 -0
- package/CONTENT.md +824 -0
- package/MIGRATING.md +127 -0
- package/README.md +29 -17
- package/bin/content-build.mjs +1082 -0
- package/bin/package-build.mjs +2 -2
- package/bin/report.mjs +2 -2
- package/config.mjs +21 -18
- package/content-config.mjs +1424 -0
- package/coverage.mjs +1 -1
- package/e2e.mjs +1 -1
- package/engine/abbreviations.mjs +0 -0
- package/engine/base-compiler.mjs +608 -0
- package/engine/code-fences.mjs +311 -0
- package/engine/compendiums.mjs +353 -0
- package/engine/content-address.mjs +141 -0
- package/engine/content-links.mjs +438 -0
- package/engine/content-lint.mjs +204 -0
- package/engine/content-package.mjs +67 -0
- package/engine/content-slug.mjs +169 -0
- package/engine/content-tables.mjs +1216 -0
- package/engine/content-tree.mjs +56 -0
- package/engine/diagnostics.mjs +270 -0
- package/engine/field-reference.mjs +242 -0
- package/engine/field-spec.mjs +218 -0
- package/engine/foreign-catalog.mjs +450 -0
- package/engine/foreign-manifests.mjs +129 -0
- package/engine/frontmatter-lint.mjs +343 -0
- package/engine/frontmatter.mjs +258 -0
- package/engine/generate.mjs +305 -0
- package/engine/helpers.mjs +809 -0
- package/engine/ids.mjs +192 -0
- package/engine/index.mjs +142 -0
- package/engine/item-docs.mjs +153 -0
- package/engine/item-registry.mjs +129 -0
- package/engine/journals.mjs +374 -0
- package/engine/kb-manifest.mjs +473 -0
- package/engine/macros.mjs +333 -0
- package/engine/manifest-emit.mjs +339 -0
- package/engine/map-notes.mjs +1302 -0
- package/engine/pack-config.mjs +479 -0
- package/engine/pack-router.mjs +254 -0
- package/engine/prose-config.mjs +238 -0
- package/engine/prose-lint.mjs +294 -0
- package/engine/region-events.mjs +79 -0
- package/engine/scene-levels.mjs +420 -0
- package/engine/scenes.mjs +528 -0
- package/engine/site-build.mjs +778 -0
- package/engine/site-index.mjs +332 -0
- package/engine/web-wikilinks.mjs +330 -0
- package/engine/wikilink-syntax.mjs +105 -0
- package/engine/wikilinks.mjs +645 -0
- package/index.mjs +42 -25
- package/lang.mjs +1 -1
- package/manifest.mjs +79 -5
- package/markdownlint-config.mjs +45 -0
- package/package.json +78 -35
- package/prettier-config.mjs +34 -0
- package/sohl/actors.mjs +581 -0
- package/sohl/affiliation-standings.mjs +44 -0
- package/sohl/being-info.mjs +173 -0
- package/sohl/default-item-art.mjs +82 -0
- package/sohl/index.mjs +55 -0
- package/sohl/item-builders.mjs +114 -0
- package/sohl/item-fields.mjs +867 -0
- package/sohl/items.mjs +197 -0
- package/sohl/kb-passes.mjs +207 -0
- package/sohl/note-schemas.mjs +322 -0
- package/templates.mjs +1 -1
- package/types/config.d.mts +1 -1
- package/types/content-config.d.mts +571 -0
- package/types/engine/abbreviations.d.mts +44 -0
- package/types/engine/base-compiler.d.mts +320 -0
- package/types/engine/code-fences.d.mts +102 -0
- package/types/engine/compendiums.d.mts +77 -0
- package/types/engine/content-address.d.mts +61 -0
- package/types/engine/content-links.d.mts +87 -0
- package/types/engine/content-lint.d.mts +47 -0
- package/types/engine/content-package.d.mts +36 -0
- package/types/engine/content-slug.d.mts +59 -0
- package/types/engine/content-tables.d.mts +131 -0
- package/types/engine/content-tree.d.mts +12 -0
- package/types/engine/diagnostics.d.mts +152 -0
- package/types/engine/field-reference.d.mts +20 -0
- package/types/engine/field-spec.d.mts +181 -0
- package/types/engine/foreign-catalog.d.mts +103 -0
- package/types/engine/foreign-manifests.d.mts +43 -0
- package/types/engine/frontmatter-lint.d.mts +74 -0
- package/types/engine/frontmatter.d.mts +99 -0
- package/types/engine/generate.d.mts +56 -0
- package/types/engine/helpers.d.mts +348 -0
- package/types/engine/ids.d.mts +126 -0
- package/types/engine/index.d.mts +33 -0
- package/types/engine/item-docs.d.mts +73 -0
- package/types/engine/item-registry.d.mts +57 -0
- package/types/engine/journals.d.mts +121 -0
- package/types/engine/kb-manifest.d.mts +214 -0
- package/types/engine/macros.d.mts +201 -0
- package/types/engine/manifest-emit.d.mts +137 -0
- package/types/engine/map-notes.d.mts +283 -0
- package/types/engine/pack-config.d.mts +77 -0
- package/types/engine/pack-router.d.mts +53 -0
- package/types/engine/prose-config.d.mts +121 -0
- package/types/engine/prose-lint.d.mts +59 -0
- package/types/engine/region-events.d.mts +43 -0
- package/types/engine/scene-levels.d.mts +36 -0
- package/types/engine/scenes.d.mts +48 -0
- package/types/engine/site-build.d.mts +214 -0
- package/types/engine/site-index.d.mts +141 -0
- package/types/engine/web-wikilinks.d.mts +67 -0
- package/types/engine/wikilink-syntax.d.mts +101 -0
- package/types/engine/wikilinks.d.mts +172 -0
- package/types/index.d.mts +3 -0
- package/types/manifest.d.mts +52 -4
- package/types/markdownlint-config.d.mts +8 -0
- package/types/prettier-config.d.mts +1 -0
- package/types/sohl/actors.d.mts +65 -0
- package/types/sohl/affiliation-standings.d.mts +22 -0
- package/types/sohl/being-info.d.mts +78 -0
- package/types/sohl/default-item-art.d.mts +28 -0
- package/types/sohl/index.d.mts +7 -0
- package/types/sohl/item-builders.d.mts +11 -0
- package/types/sohl/item-fields.d.mts +20 -0
- package/types/sohl/items.d.mts +19 -0
- package/types/sohl/kb-passes.d.mts +71 -0
- 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
|
+
}
|