@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.
- package/CHANGELOG-content-build.md +965 -0
- package/CHANGELOG.md +95 -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 -34
- 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,809 @@
|
|
|
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
|
+
* Shared helpers for the pack compilers in `packages/content-build/`.
|
|
16
|
+
*
|
|
17
|
+
* The HeroicLands vault is authoritative for compendium item data. Pack
|
|
18
|
+
* compilers walk the vault, read markdown files with YAML frontmatter, and
|
|
19
|
+
* emit Foundry-compatible JSON. These helpers handle the common shape:
|
|
20
|
+
* markdown parsing, frontmatter access (including the nested `sohl:` block),
|
|
21
|
+
* filename generation, and slug normalization.
|
|
22
|
+
*
|
|
23
|
+
* Not a standalone script — a shared helper module imported by the pack
|
|
24
|
+
* generation orchestrator and compilers (generate.mjs, items.mjs,
|
|
25
|
+
* journals.mjs, actors.mjs).
|
|
26
|
+
*/
|
|
27
|
+
|
|
28
|
+
import fs from "fs";
|
|
29
|
+
import crypto from "crypto";
|
|
30
|
+
import path from "path";
|
|
31
|
+
import yaml from "yaml";
|
|
32
|
+
import unidecode from "unidecode";
|
|
33
|
+
import markdownit from "markdown-it";
|
|
34
|
+
import log from "loglevel";
|
|
35
|
+
|
|
36
|
+
import { loadPackConfig } from "./pack-config.mjs";
|
|
37
|
+
import { packRouter } from "./pack-router.mjs";
|
|
38
|
+
import { contentPackage, foundryPackageId } from "./content-package.mjs";
|
|
39
|
+
import { loadForeignManifests, PACKAGE_BASE } from "./kb-manifest.mjs";
|
|
40
|
+
import { buildWikilinkIndex, convertWikilinks } from "./wikilinks.mjs";
|
|
41
|
+
import { expandContentTables } from "./content-tables.mjs";
|
|
42
|
+
import { emitDiagnostic, positionInBody } from "./diagnostics.mjs";
|
|
43
|
+
// The pure `sohl:` frontmatter readers live in a leaf module so the item-type
|
|
44
|
+
// registry can import them without reaching back through this one (#1504).
|
|
45
|
+
// Re-exported here so every existing importer keeps its single import path.
|
|
46
|
+
import { getFrontmatter } from "./frontmatter.mjs";
|
|
47
|
+
export {
|
|
48
|
+
getFrontmatter,
|
|
49
|
+
sohlField,
|
|
50
|
+
resolveCharges,
|
|
51
|
+
resolveSkillAptitudes,
|
|
52
|
+
resolveRelation,
|
|
53
|
+
requireSubType,
|
|
54
|
+
parseValueDesc,
|
|
55
|
+
} from "./frontmatter.mjs";
|
|
56
|
+
|
|
57
|
+
export const md = markdownit({ html: true });
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Parses a markdown file with YAML frontmatter.
|
|
61
|
+
*
|
|
62
|
+
* Returns `{ frontmatter, body, description, bodyLine, bodyColumn }` where
|
|
63
|
+
* `body` is the trimmed raw markdown after the frontmatter block, and
|
|
64
|
+
* `description` is `body` rendered to HTML. `bodyLine` / `bodyColumn` are the
|
|
65
|
+
* 1-based **file** position of the body's first character, which is what turns
|
|
66
|
+
* an offset within `body` into a position a diagnostic can name (#17) — see
|
|
67
|
+
* {@link positionInBody}. If the file has no frontmatter block, returns
|
|
68
|
+
* `{ frontmatter: null, body: "", description: "" }` with a warn log, and no
|
|
69
|
+
* position: there is no body to have one.
|
|
70
|
+
*/
|
|
71
|
+
export function parseMarkdownFile(filePath) {
|
|
72
|
+
const content = fs.readFileSync(filePath, "utf8");
|
|
73
|
+
const fmMatch = content.match(/^---\n([\s\S]*?)\n---\n?([\s\S]*)$/);
|
|
74
|
+
if (!fmMatch) {
|
|
75
|
+
return { frontmatter: null, body: "", description: "" };
|
|
76
|
+
}
|
|
77
|
+
let frontmatter;
|
|
78
|
+
try {
|
|
79
|
+
frontmatter = yaml.parse(fmMatch[1]) || {};
|
|
80
|
+
} catch (err) {
|
|
81
|
+
log.warn(`YAML parse error in ${filePath}: ${err.message}`);
|
|
82
|
+
return { frontmatter: null, body: "", description: "" };
|
|
83
|
+
}
|
|
84
|
+
const raw = fmMatch[2];
|
|
85
|
+
const body = raw.trim();
|
|
86
|
+
const description = body ? md.render(body) : "";
|
|
87
|
+
// Where the trimmed body starts in the *file*, so an offset within it can
|
|
88
|
+
// be reported as a file position (#17). The frontmatter's lines and the
|
|
89
|
+
// blank lines `trim()` removes both sit in between, and the trim can take
|
|
90
|
+
// indentation off the first line as well — hence a column, not just a line.
|
|
91
|
+
const bodyStart =
|
|
92
|
+
content.length - raw.length + (raw.length - raw.trimStart().length);
|
|
93
|
+
const before = content.slice(0, bodyStart);
|
|
94
|
+
const bodyLine = before.split("\n").length;
|
|
95
|
+
const bodyColumn = bodyStart - before.lastIndexOf("\n");
|
|
96
|
+
return { frontmatter, body, description, bodyLine, bodyColumn };
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Recursively yields every `.md` file under `rootDir`, parsed.
|
|
101
|
+
* Yields `{ frontmatter, body, description, file, absPath, bodyLine,
|
|
102
|
+
* bodyColumn }` for each match — the last two from
|
|
103
|
+
* {@link parseMarkdownFile}, so a caller can report a position inside the
|
|
104
|
+
* body as a position in the file (#17).
|
|
105
|
+
* Silently skips directories that don't exist.
|
|
106
|
+
*
|
|
107
|
+
* Directory names in `skipDirectories` are ignored wherever they appear. The
|
|
108
|
+
* walk itself knows nothing about what they mean: `Templates/` is an Obsidian
|
|
109
|
+
* templater convention this repository's vault happens to use, not a property
|
|
110
|
+
* of a content tree, so it is configured rather than hard-coded (#1508).
|
|
111
|
+
*
|
|
112
|
+
* @param {string} rootDir - Root of the tree to walk.
|
|
113
|
+
* @param {object} [opts]
|
|
114
|
+
* @param {readonly string[]} [opts.skipDirectories] - Directory names to ignore.
|
|
115
|
+
* Defaults to the configured list.
|
|
116
|
+
*/
|
|
117
|
+
export function* walkMarkdownTree(
|
|
118
|
+
rootDir,
|
|
119
|
+
{ skipDirectories = loadPackConfig().skipDirectories } = {},
|
|
120
|
+
) {
|
|
121
|
+
if (!fs.existsSync(rootDir)) return;
|
|
122
|
+
const stack = [rootDir];
|
|
123
|
+
while (stack.length > 0) {
|
|
124
|
+
const dir = stack.pop();
|
|
125
|
+
let entries;
|
|
126
|
+
try {
|
|
127
|
+
entries = fs.readdirSync(dir, { withFileTypes: true });
|
|
128
|
+
} catch (err) {
|
|
129
|
+
log.warn(`Cannot read directory ${dir}: ${err.message}`);
|
|
130
|
+
continue;
|
|
131
|
+
}
|
|
132
|
+
for (const entry of entries) {
|
|
133
|
+
const absPath = path.join(dir, entry.name);
|
|
134
|
+
if (entry.isDirectory()) {
|
|
135
|
+
if (skipDirectories.includes(entry.name)) continue;
|
|
136
|
+
stack.push(absPath);
|
|
137
|
+
} else if (entry.isFile() && entry.name.endsWith(".md")) {
|
|
138
|
+
yield {
|
|
139
|
+
...parseMarkdownFile(absPath),
|
|
140
|
+
file: entry.name,
|
|
141
|
+
absPath,
|
|
142
|
+
};
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* Resolve the required `sohl.archetype` frontmatter for an Item/Actor entry
|
|
150
|
+
* (see the archetype contract, #604 — `flags.sohl.docArchetype`). The property
|
|
151
|
+
* is a nullable number that authors must state explicitly:
|
|
152
|
+
* - a number → the document is an archetype of that priority.
|
|
153
|
+
* - `null` → the document is not an archetype.
|
|
154
|
+
* - absent → an authoring error (throws), so "not an archetype" is never
|
|
155
|
+
* silently assumed.
|
|
156
|
+
*
|
|
157
|
+
* Reads `sohl.archetype`, falling back to a top-level `archetype` key to match
|
|
158
|
+
* {@link sohlField}'s nested-then-top-level resolution.
|
|
159
|
+
*
|
|
160
|
+
* @param {object} fm Parsed frontmatter.
|
|
161
|
+
* @param {string} label Human-readable context for error messages.
|
|
162
|
+
* @returns {number|undefined} The archetype priority, or `undefined` when null.
|
|
163
|
+
* @throws {Error} When `sohl.archetype` is absent or is not a number/null.
|
|
164
|
+
*/
|
|
165
|
+
export function resolveArchetype(fm, label) {
|
|
166
|
+
const sohl = fm != null && typeof fm.sohl === "object" ? fm.sohl : null;
|
|
167
|
+
const inSohl = sohl != null && "archetype" in sohl;
|
|
168
|
+
const inTop = fm != null && typeof fm === "object" && "archetype" in fm;
|
|
169
|
+
if (!inSohl && !inTop) {
|
|
170
|
+
throw new Error(
|
|
171
|
+
`Missing required sohl.archetype for ${label} — set a number (this is an archetype) or null (it is not)`,
|
|
172
|
+
);
|
|
173
|
+
}
|
|
174
|
+
const raw = inSohl ? sohl.archetype : fm.archetype;
|
|
175
|
+
if (raw === null) return undefined;
|
|
176
|
+
if (typeof raw !== "number" || !Number.isFinite(raw)) {
|
|
177
|
+
throw new Error(
|
|
178
|
+
`Invalid sohl.archetype for ${label}: expected a number or null, got ${JSON.stringify(raw)}`,
|
|
179
|
+
);
|
|
180
|
+
}
|
|
181
|
+
return raw;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/**
|
|
185
|
+
* Merge the required `sohl.archetype` frontmatter into a document's `flags`,
|
|
186
|
+
* returning a new object (the input is never mutated). A numeric archetype
|
|
187
|
+
* seeds `flags.sohl.docArchetype`; `null` omits the flag (and clears any stale
|
|
188
|
+
* `docArchetype` while preserving sibling `sohl` flags); an absent value
|
|
189
|
+
* throws. See {@link resolveArchetype}.
|
|
190
|
+
*
|
|
191
|
+
* @param {object} fm Parsed frontmatter.
|
|
192
|
+
* @param {object} [flags] The entry's existing flags (e.g. `fm.flags`).
|
|
193
|
+
* @param {string} label Human-readable context for error messages.
|
|
194
|
+
* @returns {object} The flags object with the archetype applied.
|
|
195
|
+
* @throws {Error} When `sohl.archetype` is absent or invalid.
|
|
196
|
+
*/
|
|
197
|
+
export function withArchetypeFlag(fm, flags, label) {
|
|
198
|
+
const archetype = resolveArchetype(fm, label);
|
|
199
|
+
const out = { ...(flags || {}) };
|
|
200
|
+
const sohl = { ...(out.sohl || {}) };
|
|
201
|
+
if (archetype === undefined) {
|
|
202
|
+
delete sohl.docArchetype;
|
|
203
|
+
} else {
|
|
204
|
+
sohl.docArchetype = archetype;
|
|
205
|
+
}
|
|
206
|
+
if (Object.keys(sohl).length > 0) out.sohl = sohl;
|
|
207
|
+
else delete out.sohl;
|
|
208
|
+
return out;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/**
|
|
212
|
+
* Generates a compendium-source filename: `Name_id.json` with non-
|
|
213
|
+
* alphanumeric runs replaced by underscores.
|
|
214
|
+
*/
|
|
215
|
+
export function makeFilename(name, id) {
|
|
216
|
+
return `${unidecode(name)}_${id}`.replace(/[^0-9a-zA-Z]+/g, "_") + ".json";
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
/**
|
|
220
|
+
* Standardize a name into a slug: lowercase, apostrophes removed,
|
|
221
|
+
* non-alphanumerics collapsed to single hyphens.
|
|
222
|
+
*/
|
|
223
|
+
|
|
224
|
+
/**
|
|
225
|
+
* Translate a content-relative image path into its Foundry-relative form.
|
|
226
|
+
*
|
|
227
|
+
* Content frontmatter (`img` / `portrait`) authors a single path that has to
|
|
228
|
+
* work for Foundry, the knowledgebase, and the website. For Foundry the bundled
|
|
229
|
+
* asset roots — `icons/...` and `images/...` — are served from the package
|
|
230
|
+
* directory, so they are rewritten to `<assetRoot>/<path>` — `systems/sohl/assets`
|
|
231
|
+
* for this repository, `modules/<id>/assets` for a module (#1508). Any other
|
|
232
|
+
* path (already package-rooted, an absolute URL) is returned unchanged, and an
|
|
233
|
+
* empty path yields `""`.
|
|
234
|
+
*
|
|
235
|
+
* This is translation only: the per-type default for an empty result is
|
|
236
|
+
* domain-specific (actors default differently from items, and gear differently
|
|
237
|
+
* again), so each compiler owns its own default and applies it to the result —
|
|
238
|
+
* `resolveImg(fm.img) || <default>`. For items that default is the art paired
|
|
239
|
+
* with the type's builder, reached through `itemArt()`, which runs the path
|
|
240
|
+
* back through this function so a registry entry and a note's `img:` are
|
|
241
|
+
* spelled the same way (#7).
|
|
242
|
+
*
|
|
243
|
+
* @param {string | null | undefined} raw - content-relative path from frontmatter.
|
|
244
|
+
* @param {{assetRoot: string}} [config] - The resolved build configuration.
|
|
245
|
+
* Defaults to this repository's.
|
|
246
|
+
* @returns {string} the Foundry-relative path, or `""` when `raw` is empty.
|
|
247
|
+
*/
|
|
248
|
+
export function resolveImg(raw, config = loadPackConfig()) {
|
|
249
|
+
if (!raw) return "";
|
|
250
|
+
const s = String(raw);
|
|
251
|
+
if (s.startsWith("icons/") || s.startsWith("images/")) {
|
|
252
|
+
return `${config.assetRoot}/${s}`;
|
|
253
|
+
}
|
|
254
|
+
return s;
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
/**
|
|
258
|
+
* Resolves the display name from frontmatter, preferring `name.full`,
|
|
259
|
+
* falling back to `name` (if string), then `defaultValue`.
|
|
260
|
+
*/
|
|
261
|
+
export function resolveName(fm, defaultValue = "Unnamed") {
|
|
262
|
+
const fullName = getFrontmatter(fm, "name.full", null);
|
|
263
|
+
if (fullName) return String(fullName);
|
|
264
|
+
if (typeof fm?.name === "string") return fm.name;
|
|
265
|
+
return defaultValue;
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
/**
|
|
269
|
+
* The oldest Foundry core this package supports, stamped into every compiled
|
|
270
|
+
* document as `_stats.coreVersion`.
|
|
271
|
+
*
|
|
272
|
+
* **Derived, never written twice.** `_stats.coreVersion` is what Foundry gates
|
|
273
|
+
* its migration shims on: a record stamped older than a shim is rewritten by it
|
|
274
|
+
* on load. Every pack once shipped `coreVersion: "14"`, which sorts *below*
|
|
275
|
+
* every v14 build and so left all shipped content permanently eligible for
|
|
276
|
+
* every v14 migration (#1533).
|
|
277
|
+
*
|
|
278
|
+
* **Read from the configuration, not from the shipped manifest.** It used to
|
|
279
|
+
* open `paths.packageManifest` and take `compatibility.minimum` out of it,
|
|
280
|
+
* because the configuration had no way to state the fact. Now that it does, the
|
|
281
|
+
* manifest is generated *from* the configuration — so reading it back would be
|
|
282
|
+
* a round trip through an artifact that need not exist yet: `build:db` can run
|
|
283
|
+
* before the manifest is written.
|
|
284
|
+
*
|
|
285
|
+
* Absent is a hard failure, not a default. A guessed floor is stamped into
|
|
286
|
+
* every document in the pack and stays invisible until something migrates on
|
|
287
|
+
* it, which is exactly why the manifest read threw rather than falling back.
|
|
288
|
+
*
|
|
289
|
+
* @param {{compatibility: {minimum: string}|null}} [config] - The resolved
|
|
290
|
+
* configuration. Defaults to this repository's.
|
|
291
|
+
* @returns {string} The declared `compatibility.minimum`.
|
|
292
|
+
* @throws {Error} When the configuration declares no `compatibility.minimum`.
|
|
293
|
+
*/
|
|
294
|
+
export function supportedCoreVersion(config = loadPackConfig()) {
|
|
295
|
+
const minimum = config.compatibility?.minimum;
|
|
296
|
+
if (!minimum) {
|
|
297
|
+
throw new Error(
|
|
298
|
+
"package-build: the configuration declares no " +
|
|
299
|
+
"`compatibility.minimum`, so compiled documents have no " +
|
|
300
|
+
"honest core version to stamp. Declare it at the top level of " +
|
|
301
|
+
"package-build.config.yaml.",
|
|
302
|
+
);
|
|
303
|
+
}
|
|
304
|
+
return String(minimum);
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
/**
|
|
308
|
+
* Default `_stats` block for compiled compendium entries.
|
|
309
|
+
*
|
|
310
|
+
* Every stamped identity is configuration (#1508): four compilers used to pass
|
|
311
|
+
* the same frozen `"0.6.0"` literal, and `systemId` / `lastModifiedBy` were
|
|
312
|
+
* written into this function. `coreVersion` alone is *not* configuration — it
|
|
313
|
+
* comes from {@link supportedCoreVersion}, the configured Foundry floor,
|
|
314
|
+
* so a document never claims to predate the migrations that would rewrite it.
|
|
315
|
+
*
|
|
316
|
+
* @param {string} [systemVersion] - The system version to stamp. Defaults to the
|
|
317
|
+
* configured one.
|
|
318
|
+
* @param {{stats: {systemId: string, systemVersion: string,
|
|
319
|
+
* lastModifiedBy: string}, paths: {packageManifest: string}}} [config] -
|
|
320
|
+
* The resolved build configuration. Defaults to this repository's.
|
|
321
|
+
* @returns {object} The `_stats` block.
|
|
322
|
+
*/
|
|
323
|
+
export function buildStats(
|
|
324
|
+
systemVersion = undefined,
|
|
325
|
+
config = loadPackConfig(),
|
|
326
|
+
) {
|
|
327
|
+
return {
|
|
328
|
+
systemId: config.stats.systemId,
|
|
329
|
+
systemVersion: systemVersion ?? config.stats.systemVersion,
|
|
330
|
+
coreVersion: supportedCoreVersion(config),
|
|
331
|
+
createdTime: 0,
|
|
332
|
+
modifiedTime: 0,
|
|
333
|
+
lastModifiedBy: config.stats.lastModifiedBy,
|
|
334
|
+
};
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
/** Memoised {@link defaultStats}. */
|
|
338
|
+
let cachedDefaultStats;
|
|
339
|
+
|
|
340
|
+
/**
|
|
341
|
+
* The `_stats` block every compiler stamps on an entry it emits, built once.
|
|
342
|
+
*
|
|
343
|
+
* Each compiler used to hoist `const STATS = buildStats()` at module scope,
|
|
344
|
+
* which read the shipped package manifest the moment the module was imported —
|
|
345
|
+
* so importing a compiler required a manifest to exist even when nothing was
|
|
346
|
+
* going to be compiled (#2). Deferred to first use and memoised here, the
|
|
347
|
+
* cost and the identity are what they always were; only the moment moved.
|
|
348
|
+
*
|
|
349
|
+
* @returns {object} The default `_stats` block, shared by every compiler.
|
|
350
|
+
*/
|
|
351
|
+
export function defaultStats() {
|
|
352
|
+
cachedDefaultStats ??= buildStats();
|
|
353
|
+
return cachedDefaultStats;
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
/**
|
|
357
|
+
* Stable 16-char hex id derived from `${namespace}:${value}`.
|
|
358
|
+
*
|
|
359
|
+
* Defined in {@link sohl.utils.packs.ids} — a leaf module, so that the link
|
|
360
|
+
* resolver this one imports can derive ids too — and re-exported here for the
|
|
361
|
+
* passes that have always reached it through `helpers`.
|
|
362
|
+
*/
|
|
363
|
+
// The one slug rule, re-exported so callers keep a single import path.
|
|
364
|
+
export { slugify } from "./content-slug.mjs";
|
|
365
|
+
|
|
366
|
+
export { makeId } from "./ids.mjs";
|
|
367
|
+
|
|
368
|
+
// The content-type → document-type map, which decides *which* pack list a
|
|
369
|
+
// note's own document is routed against.
|
|
370
|
+
import { assertTypeNotRetired, packForType } from "./ids.mjs";
|
|
371
|
+
|
|
372
|
+
/* ------------------------------------------------------------------------ */
|
|
373
|
+
/* Wikilink resolution: the content-wide link index */
|
|
374
|
+
/* ------------------------------------------------------------------------ */
|
|
375
|
+
|
|
376
|
+
/**
|
|
377
|
+
* Indexes **every** note in the content tree so any pack compiler can resolve a
|
|
378
|
+
* wikilink to any other document. Shared by all three compilers: a skill links
|
|
379
|
+
* to another skill, a journal to a creature, a creature to a rules page, and
|
|
380
|
+
* each target's own **type** decides which pack the UUID points into.
|
|
381
|
+
*
|
|
382
|
+
* Each note's pack is resolved here, once, and stored on its index entry: a
|
|
383
|
+
* UUID carries a pack name, so a repository shipping several packs of one type
|
|
384
|
+
* (#1566) would otherwise address every one of them as the first. A note whose
|
|
385
|
+
* declaration is unroutable is indexed against the conventional name and left
|
|
386
|
+
* for the compile pass to report — the index has no business failing a build,
|
|
387
|
+
* and the pass fails it with a far better message. The one exception is a
|
|
388
|
+
* **retired** content type (SoHL#1580): this walk is the first to see every
|
|
389
|
+
* note together with its path, and unlike an unroutable declaration there is
|
|
390
|
+
* no pass that would ever claim such a note and report it.
|
|
391
|
+
*
|
|
392
|
+
* @param {string} contentBase - Root of the content tree.
|
|
393
|
+
* @param {object} [router] - The pack router. Supplied by the calling pass so
|
|
394
|
+
* the index and the compile agree about where each note landed; defaults to
|
|
395
|
+
* this repository's own.
|
|
396
|
+
* @returns {{byShortcode: Map, byAlias: Map}} From `buildWikilinkIndex`.
|
|
397
|
+
*/
|
|
398
|
+
export function buildContentLinkIndex(contentBase, router = packRouter()) {
|
|
399
|
+
const docs = [];
|
|
400
|
+
for (const { frontmatter: fm, absPath } of walkMarkdownTree(contentBase)) {
|
|
401
|
+
if (!fm?.id) continue;
|
|
402
|
+
// The first walk of every note in the tree, and the only one holding
|
|
403
|
+
// both the declared type and the file that declares it — so a note
|
|
404
|
+
// left on a retired type is reported here, by name, rather than
|
|
405
|
+
// several frames deeper with nothing to go on (SoHL#1580).
|
|
406
|
+
assertTypeNotRetired(fm.type, absPath);
|
|
407
|
+
const base = path.basename(absPath, ".md").replace(/_/g, " ");
|
|
408
|
+
docs.push({
|
|
409
|
+
type: fm.type,
|
|
410
|
+
id: fm.id,
|
|
411
|
+
// Where this note's own document lands, and where the JournalEntry
|
|
412
|
+
// its prose compiles into lands — two documents, two packs (#1362).
|
|
413
|
+
pack: router.resolveOrNull(fm, packForType(fm.type).docType),
|
|
414
|
+
docPack: router.resolveOrNull(fm, "JournalEntry"),
|
|
415
|
+
shortcode: fm.shortcode ?? null,
|
|
416
|
+
name: fm.name?.full ?? base,
|
|
417
|
+
aliases: [
|
|
418
|
+
...(Array.isArray(fm.aliases) ? fm.aliases : []),
|
|
419
|
+
...(fm.name?.full ? [fm.name.full] : []),
|
|
420
|
+
...(Array.isArray(fm.name?.aliases) ? fm.name.aliases : []),
|
|
421
|
+
base,
|
|
422
|
+
].filter(Boolean),
|
|
423
|
+
});
|
|
424
|
+
}
|
|
425
|
+
// Packages this build links *into* but does not publish. Their manifests
|
|
426
|
+
// are vendored and committed, so a contributor without every repository
|
|
427
|
+
// checked out resolves the same links CI does (#1446, #1499).
|
|
428
|
+
// Packages this repository links into but does not publish; their vendored
|
|
429
|
+
// manifests live at the configured location (#1446, #1499).
|
|
430
|
+
const { index: foreign, stale } = loadForeignManifests(
|
|
431
|
+
loadPackConfig().paths.manifests,
|
|
432
|
+
[contentPackage()],
|
|
433
|
+
PACKAGE_BASE,
|
|
434
|
+
);
|
|
435
|
+
if (stale.length) {
|
|
436
|
+
for (const st of stale) {
|
|
437
|
+
log.error(
|
|
438
|
+
`Unusable link manifest for "${st.package}": ${st.reason}`,
|
|
439
|
+
);
|
|
440
|
+
}
|
|
441
|
+
throw new Error(
|
|
442
|
+
"Cross-package links cannot be resolved from a stale manifest; " +
|
|
443
|
+
"re-vendor it from that package's build.",
|
|
444
|
+
);
|
|
445
|
+
}
|
|
446
|
+
log.debug(
|
|
447
|
+
`Wikilink index: ${docs.length} local document(s), ` +
|
|
448
|
+
`${foreign.size} foreign address(es)`,
|
|
449
|
+
);
|
|
450
|
+
return buildWikilinkIndex(
|
|
451
|
+
docs,
|
|
452
|
+
foundryPackageId(),
|
|
453
|
+
foreign,
|
|
454
|
+
contentPackage(),
|
|
455
|
+
);
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
/**
|
|
459
|
+
* Converts the wikilinks in one note's markdown, reporting any that have no
|
|
460
|
+
* target in the content tree. Every compiler funnels through this so the
|
|
461
|
+
* diagnostic text and the leave-it-alone fallback are identical everywhere.
|
|
462
|
+
*
|
|
463
|
+
* Each report names the **file, line and column** the link sits on (#17), so
|
|
464
|
+
* it can be opened and fixed — and so two identical links on one note are
|
|
465
|
+
* tellable apart. That needs `file` and the note's `bodyLine` / `bodyColumn`;
|
|
466
|
+
* without them the diagnostic still reports, one field shorter, rather than
|
|
467
|
+
* inventing a position.
|
|
468
|
+
*
|
|
469
|
+
* @param {string} body - The note's markdown body, tables already expanded.
|
|
470
|
+
* @param {object} ctx - `{ type, id, pack, docPack, index, name }` — `name` is
|
|
471
|
+
* used in the message, and the two pack names address a `[[#slug]]`
|
|
472
|
+
* self-link, whose target is the source note itself and so has no index
|
|
473
|
+
* entry. Position is carried by `{ file, bodyLine, bodyColumn, lineMap }`,
|
|
474
|
+
* the last from {@link expandNoteTables}.
|
|
475
|
+
* @returns {{markdown: string, unresolved: Array<object>}}
|
|
476
|
+
* @throws {Error} On an ambiguous alias or a dead qualified address. The error
|
|
477
|
+
* carries `file` and `position`, so a caller reports it in the same form
|
|
478
|
+
* rather than re-deriving one.
|
|
479
|
+
*/
|
|
480
|
+
export function convertNoteWikilinks(
|
|
481
|
+
body,
|
|
482
|
+
{
|
|
483
|
+
type,
|
|
484
|
+
id,
|
|
485
|
+
pack,
|
|
486
|
+
docPack,
|
|
487
|
+
index,
|
|
488
|
+
name,
|
|
489
|
+
file,
|
|
490
|
+
bodyLine,
|
|
491
|
+
bodyColumn,
|
|
492
|
+
lineMap,
|
|
493
|
+
},
|
|
494
|
+
) {
|
|
495
|
+
const result = convertWikilinks(body ?? "", {
|
|
496
|
+
type,
|
|
497
|
+
id,
|
|
498
|
+
pack,
|
|
499
|
+
docPack,
|
|
500
|
+
index,
|
|
501
|
+
});
|
|
502
|
+
/**
|
|
503
|
+
* Where one unresolved link sits, in file coordinates.
|
|
504
|
+
*
|
|
505
|
+
* @param {object} u - An entry of `result.unresolved`.
|
|
506
|
+
* @returns {{line?: number, column?: number, generated?: boolean}} Empty
|
|
507
|
+
* when the caller supplied no position to resolve against.
|
|
508
|
+
*/
|
|
509
|
+
const locate = (u) =>
|
|
510
|
+
bodyLine === undefined || u.offset === undefined ?
|
|
511
|
+
{}
|
|
512
|
+
: positionInBody(body ?? "", u.offset, {
|
|
513
|
+
bodyLine,
|
|
514
|
+
bodyColumn,
|
|
515
|
+
lineMap,
|
|
516
|
+
});
|
|
517
|
+
|
|
518
|
+
/**
|
|
519
|
+
* Fails the note, carrying the position for the caller to report.
|
|
520
|
+
*
|
|
521
|
+
* @param {object} u - The offending link.
|
|
522
|
+
* @param {string} message - What is wrong.
|
|
523
|
+
* @returns {never}
|
|
524
|
+
*/
|
|
525
|
+
const fail = (u, message) => {
|
|
526
|
+
const err = new Error(message);
|
|
527
|
+
err.file = file;
|
|
528
|
+
err.position = locate(u);
|
|
529
|
+
throw err;
|
|
530
|
+
};
|
|
531
|
+
|
|
532
|
+
for (const u of result.unresolved) {
|
|
533
|
+
// An ambiguous alias matched real content — twice. There is no
|
|
534
|
+
// defensible way to pick one, and the correction is mechanical: write
|
|
535
|
+
// the qualified form. So it fails rather than warning, which also puts
|
|
536
|
+
// the failure in front of whoever created the collision instead of
|
|
537
|
+
// leaving it in a log line attributed to an innocent citing note.
|
|
538
|
+
// The knowledgebase build has always treated this as fatal; agreeing
|
|
539
|
+
// means one authored note cannot get two verdicts (#13).
|
|
540
|
+
if (u.reason === "ambiguous") {
|
|
541
|
+
const claims = u.candidates ?? [];
|
|
542
|
+
const named =
|
|
543
|
+
claims.length ?
|
|
544
|
+
claims
|
|
545
|
+
.map((c) => `"${c.name}" (${c.type}-${c.shortcode})`)
|
|
546
|
+
.join(" and ")
|
|
547
|
+
: "two or more notes";
|
|
548
|
+
fail(
|
|
549
|
+
u,
|
|
550
|
+
`ambiguous wikilink ${u.link} in "${name}" — claimed by ` +
|
|
551
|
+
`${named}. Rename one alias, or address the intended one ` +
|
|
552
|
+
`as [[type-shortcode|Text]].`,
|
|
553
|
+
);
|
|
554
|
+
}
|
|
555
|
+
// A qualified address resolving nowhere is a typo, now that every
|
|
556
|
+
// linkable package is either built here or vendored (#1499) — so it
|
|
557
|
+
// fails the note rather than degrading to text. A bare alias stays a
|
|
558
|
+
// warning: it may be ordinary prose that merely looks like a link.
|
|
559
|
+
if (u.addressed) {
|
|
560
|
+
fail(
|
|
561
|
+
u,
|
|
562
|
+
`unresolved address ${u.link} in "${name}" — no package ` +
|
|
563
|
+
`publishes it. Fix the shortcode, or re-vendor that ` +
|
|
564
|
+
`package's manifest into assets/manifests/.`,
|
|
565
|
+
);
|
|
566
|
+
}
|
|
567
|
+
const at = locate(u);
|
|
568
|
+
emitDiagnostic({
|
|
569
|
+
file,
|
|
570
|
+
line: at.line,
|
|
571
|
+
column: at.column,
|
|
572
|
+
severity: "warning",
|
|
573
|
+
message:
|
|
574
|
+
`unresolved wikilink ${u.link} (${u.reason}) in "${name}"` +
|
|
575
|
+
// A link this build wrote is not at any authored position, so
|
|
576
|
+
// say where it came from instead of implying an edit site.
|
|
577
|
+
(at.generated ?
|
|
578
|
+
" — emitted by the content table on this line"
|
|
579
|
+
: ""),
|
|
580
|
+
});
|
|
581
|
+
}
|
|
582
|
+
return result;
|
|
583
|
+
}
|
|
584
|
+
|
|
585
|
+
/* ------------------------------------------------------------------------ */
|
|
586
|
+
/* Generated tables: the searchable content universe */
|
|
587
|
+
/* ------------------------------------------------------------------------ */
|
|
588
|
+
|
|
589
|
+
/**
|
|
590
|
+
* Every note in the content tree, in the shape the `dataview` table expander
|
|
591
|
+
* searches: its frontmatter plus where it sits in the tree. Ordered by path so
|
|
592
|
+
* a table that leaves rows tied still emits identically on every build.
|
|
593
|
+
*
|
|
594
|
+
* @param {string} contentBase - Root of the content tree.
|
|
595
|
+
* @returns {Array<{fm: object, path: string, tld: string, folder: string,
|
|
596
|
+
* absPath: string}>}
|
|
597
|
+
*/
|
|
598
|
+
export function collectContentDocs(contentBase) {
|
|
599
|
+
const docs = [];
|
|
600
|
+
for (const { frontmatter: fm, absPath } of walkMarkdownTree(contentBase)) {
|
|
601
|
+
if (!fm) continue;
|
|
602
|
+
const segments = path.relative(contentBase, absPath).split(path.sep);
|
|
603
|
+
docs.push({
|
|
604
|
+
fm,
|
|
605
|
+
// POSIX-separated and relative to the content root — what a
|
|
606
|
+
// `path:` search term globs, on every platform.
|
|
607
|
+
path: segments.join("/"),
|
|
608
|
+
tld: segments[0],
|
|
609
|
+
folder: segments[segments.length - 2] ?? segments[0],
|
|
610
|
+
absPath,
|
|
611
|
+
});
|
|
612
|
+
}
|
|
613
|
+
docs.sort((a, b) =>
|
|
614
|
+
a.absPath < b.absPath ? -1
|
|
615
|
+
: a.absPath > b.absPath ? 1
|
|
616
|
+
: 0,
|
|
617
|
+
);
|
|
618
|
+
log.debug(`Content table index: ${docs.length} searchable note(s)`);
|
|
619
|
+
return docs;
|
|
620
|
+
}
|
|
621
|
+
|
|
622
|
+
/**
|
|
623
|
+
* A note is linkable from a generated table cell when it carries the identity
|
|
624
|
+
* {@link convertWikilinks} addresses it by — a `type` and a `shortcode`. Every
|
|
625
|
+
* type routes to a pack ({@link packForType}), so nothing else can make a note
|
|
626
|
+
* unlinkable; a note missing either renders as plain text rather than shipping a
|
|
627
|
+
* literal wikilink into a journal.
|
|
628
|
+
*/
|
|
629
|
+
const packLinkable = (doc) =>
|
|
630
|
+
Boolean(doc.fm?.shortcode) && Boolean(doc.fm?.type);
|
|
631
|
+
|
|
632
|
+
/**
|
|
633
|
+
* Expand the fenced `dataview` tables in one note's markdown, before wikilinks
|
|
634
|
+
* are resolved — so a generated cell may itself be a wikilink.
|
|
635
|
+
*
|
|
636
|
+
* A table searches only notes of the source note's own `package`, so a SoHL
|
|
637
|
+
* page never tabulates setting-package content (and vice versa).
|
|
638
|
+
*
|
|
639
|
+
* @param {string} body - The note's markdown body.
|
|
640
|
+
* @param {object} ctx
|
|
641
|
+
* @param {Array<object>} ctx.docs - From {@link collectContentDocs}.
|
|
642
|
+
* @param {string} ctx.name - The note, for the error message.
|
|
643
|
+
* @param {string} [ctx.pkg] - The source note's `package`.
|
|
644
|
+
* @param {object} [ctx.fm] - The source note's frontmatter, which is what a
|
|
645
|
+
* query's `this` reads. Its entry in `docs` supplies the path as well.
|
|
646
|
+
* @param {number} [ctx.bodyLine] - 1-based file line of the body's first line,
|
|
647
|
+
* so a failing directive can be reported at its position in the file.
|
|
648
|
+
* @returns {{markdown: string, lineMap: Array<{line: number,
|
|
649
|
+
* generated: boolean}>}} The body with every table expanded, and where each
|
|
650
|
+
* emitted line came from — which is what lets a diagnostic about the
|
|
651
|
+
* expanded body name an authored position (#17).
|
|
652
|
+
* @throws {Error} When a query is malformed or unsupported — the note fails to
|
|
653
|
+
* compile rather than shipping a table-shaped hole. The error carries
|
|
654
|
+
* `position`, the directive's own line.
|
|
655
|
+
*/
|
|
656
|
+
export function expandNoteTables(body, { docs, name, pkg, fm, bodyLine }) {
|
|
657
|
+
const scoped = pkg ? docs.filter((d) => d.fm?.package === pkg) : docs;
|
|
658
|
+
const self =
|
|
659
|
+
fm ?
|
|
660
|
+
(docs.find((d) => d.fm?.id && d.fm.id === fm.id) ?? { fm })
|
|
661
|
+
: undefined;
|
|
662
|
+
const { markdown, errors, lineMap } = expandContentTables(body ?? "", {
|
|
663
|
+
docs: scoped,
|
|
664
|
+
linkable: packLinkable,
|
|
665
|
+
source: name,
|
|
666
|
+
self,
|
|
667
|
+
});
|
|
668
|
+
if (errors.length) {
|
|
669
|
+
const err = new Error(
|
|
670
|
+
errors.map((e) => `content table — ${e.reason}`).join("; "),
|
|
671
|
+
);
|
|
672
|
+
// The first failing directive's line. Reporting one position for a
|
|
673
|
+
// message that may name several is honest here: a caller opens the
|
|
674
|
+
// file at the first thing to fix, and the message lists the rest.
|
|
675
|
+
if (bodyLine !== undefined && errors[0].line !== undefined) {
|
|
676
|
+
err.position = { line: bodyLine + errors[0].line };
|
|
677
|
+
}
|
|
678
|
+
throw err;
|
|
679
|
+
}
|
|
680
|
+
return { markdown, lineMap };
|
|
681
|
+
}
|
|
682
|
+
|
|
683
|
+
/* ------------------------------------------------------------------------ */
|
|
684
|
+
/* Folder hierarchy: loading, resolution, emission */
|
|
685
|
+
/* ------------------------------------------------------------------------ */
|
|
686
|
+
|
|
687
|
+
/**
|
|
688
|
+
* Loads a folders.yaml file as an array of folder entries. Returns []
|
|
689
|
+
* when the file is missing (logging a warning) so packs without folders
|
|
690
|
+
* can opt out simply by not committing the file.
|
|
691
|
+
*/
|
|
692
|
+
export function loadFolders(foldersFile) {
|
|
693
|
+
if (!fs.existsSync(foldersFile)) {
|
|
694
|
+
log.warn(
|
|
695
|
+
`No folders.yaml at ${foldersFile}; no folders will be emitted`,
|
|
696
|
+
);
|
|
697
|
+
return [];
|
|
698
|
+
}
|
|
699
|
+
const raw = fs.readFileSync(foldersFile, "utf8");
|
|
700
|
+
const parsed = yaml.parse(raw);
|
|
701
|
+
if (parsed == null) return [];
|
|
702
|
+
if (!Array.isArray(parsed)) {
|
|
703
|
+
throw new Error(
|
|
704
|
+
`folders.yaml must contain a YAML list; got ${typeof parsed}`,
|
|
705
|
+
);
|
|
706
|
+
}
|
|
707
|
+
return parsed;
|
|
708
|
+
}
|
|
709
|
+
|
|
710
|
+
/**
|
|
711
|
+
* Validates folder invariants and returns a resolver function that maps a
|
|
712
|
+
* folder id to the same id (after verifying it exists). Returns `null` for
|
|
713
|
+
* a null/empty input; throws for an unknown id.
|
|
714
|
+
*
|
|
715
|
+
* Invariants:
|
|
716
|
+
* - Every folder must have a non-empty id
|
|
717
|
+
* - Every folder must have a name
|
|
718
|
+
* - Sibling folders (same parentFolderId) must have unique names
|
|
719
|
+
* - Every parentFolderId must match an existing folder id (or be "")
|
|
720
|
+
*
|
|
721
|
+
* Returns { resolver, folders } where folders is the validated list.
|
|
722
|
+
*/
|
|
723
|
+
export function buildFolderResolver(folders) {
|
|
724
|
+
const byId = new Map();
|
|
725
|
+
for (const f of folders) {
|
|
726
|
+
if (!f.id) {
|
|
727
|
+
throw new Error(`Folder missing id: ${JSON.stringify(f)}`);
|
|
728
|
+
}
|
|
729
|
+
if (!f.name) {
|
|
730
|
+
throw new Error(`Folder ${f.id} missing name`);
|
|
731
|
+
}
|
|
732
|
+
if (byId.has(f.id)) {
|
|
733
|
+
throw new Error(`Duplicate folder id ${f.id}`);
|
|
734
|
+
}
|
|
735
|
+
byId.set(f.id, f);
|
|
736
|
+
}
|
|
737
|
+
|
|
738
|
+
const siblingsByParent = new Map();
|
|
739
|
+
for (const f of folders) {
|
|
740
|
+
const parentId = f.parentFolderId || "";
|
|
741
|
+
if (parentId && !byId.has(parentId)) {
|
|
742
|
+
throw new Error(
|
|
743
|
+
`Folder ${f.id} (${f.name}) references unknown parentFolderId ${parentId}`,
|
|
744
|
+
);
|
|
745
|
+
}
|
|
746
|
+
if (!siblingsByParent.has(parentId)) {
|
|
747
|
+
siblingsByParent.set(parentId, new Set());
|
|
748
|
+
}
|
|
749
|
+
const siblings = siblingsByParent.get(parentId);
|
|
750
|
+
if (siblings.has(f.name)) {
|
|
751
|
+
throw new Error(
|
|
752
|
+
`Sibling folders share name "${f.name}" under parent ${parentId || "(root)"} — names must be unique among siblings`,
|
|
753
|
+
);
|
|
754
|
+
}
|
|
755
|
+
siblings.add(f.name);
|
|
756
|
+
}
|
|
757
|
+
|
|
758
|
+
function resolver(folderId) {
|
|
759
|
+
if (folderId == null || folderId === "") return null;
|
|
760
|
+
const id = String(folderId).trim();
|
|
761
|
+
if (!id) return null;
|
|
762
|
+
if (!byId.has(id)) {
|
|
763
|
+
throw new Error(`Unknown folder id "${id}"`);
|
|
764
|
+
}
|
|
765
|
+
return id;
|
|
766
|
+
}
|
|
767
|
+
|
|
768
|
+
return { resolver, folders };
|
|
769
|
+
}
|
|
770
|
+
|
|
771
|
+
/**
|
|
772
|
+
* Builds a compendium-source filename for a folder JSON document:
|
|
773
|
+
* `folder_Name_id.json` with non-alphanumeric runs replaced by
|
|
774
|
+
* underscores.
|
|
775
|
+
*/
|
|
776
|
+
export function folderFilename(name, id) {
|
|
777
|
+
return (
|
|
778
|
+
`folder_${unidecode(name)}_${id}`.replace(/[^0-9a-zA-Z]+/g, "_") +
|
|
779
|
+
".json"
|
|
780
|
+
);
|
|
781
|
+
}
|
|
782
|
+
|
|
783
|
+
/**
|
|
784
|
+
* Writes one JSON document per folder into `destDir`. `documentType`
|
|
785
|
+
* determines the folder's Foundry `type` field — `"Item"` for the items
|
|
786
|
+
* pack, `"JournalEntry"` for the journals pack.
|
|
787
|
+
*/
|
|
788
|
+
export function writeFolderDocs(folders, stats, destDir, documentType) {
|
|
789
|
+
for (const folder of folders) {
|
|
790
|
+
const doc = {
|
|
791
|
+
name: folder.name,
|
|
792
|
+
sorting: "a",
|
|
793
|
+
folder: folder.parentFolderId || null,
|
|
794
|
+
type: documentType,
|
|
795
|
+
_id: folder.id,
|
|
796
|
+
sort: 0,
|
|
797
|
+
color: folder.color,
|
|
798
|
+
flags: folder.flags || {},
|
|
799
|
+
_stats: stats,
|
|
800
|
+
_key: `!folders!${folder.id}`,
|
|
801
|
+
};
|
|
802
|
+
const outPath = path.join(
|
|
803
|
+
destDir,
|
|
804
|
+
folderFilename(folder.name, folder.id),
|
|
805
|
+
);
|
|
806
|
+
fs.writeFileSync(outPath, JSON.stringify(doc, null, 2), "utf8");
|
|
807
|
+
}
|
|
808
|
+
log.info(`Emitted ${folders.length} folder document(s) to ${destDir}`);
|
|
809
|
+
}
|