@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,333 @@
|
|
|
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
|
+
* Macros pack compiler — produces JSON pack files for the "macros" Foundry
|
|
16
|
+
* compendium from markdown notes in the `assets/content/` tree.
|
|
17
|
+
*
|
|
18
|
+
* A `type: macro` note compiles into **two** documents, and this module writes
|
|
19
|
+
* only the first of them:
|
|
20
|
+
*
|
|
21
|
+
* 1. the **Macro**, whose `command` is the script the note carries; and
|
|
22
|
+
* 2. a **JournalEntry** holding the note's documentation, compiled by the
|
|
23
|
+
* journals pass exactly as an item's prose is (see `item-docs.mjs`) and
|
|
24
|
+
* addressed by the virtual `docmacro/<shortcode>` qualifier.
|
|
25
|
+
*
|
|
26
|
+
* **The command is read from the raw markdown, never from the journal
|
|
27
|
+
* pipeline's output.** The two copies diverge on purpose: the journal's is
|
|
28
|
+
* table-expanded and wikilink-converted, so it is prose *about* the script,
|
|
29
|
+
* while the executable copy must be exactly what the author typed. The
|
|
30
|
+
* command is the first **language-tagged** JS fence on the page anchored
|
|
31
|
+
* `{#script}`; prose around it and any later fence are ignored here and still
|
|
32
|
+
* render in the journal.
|
|
33
|
+
*
|
|
34
|
+
* **Why an anchored page rather than "the first fence in the note".** The
|
|
35
|
+
* anchor is what makes the script addressable — `[[docmacro-autoattack#script]]`
|
|
36
|
+
* opens the page holding it — and what lets a note document its macro with
|
|
37
|
+
* example snippets that are plainly not the macro.
|
|
38
|
+
*
|
|
39
|
+
* **This does not compile data into code.** A Macro's `command` is authored
|
|
40
|
+
* source shipped as content and executed by Foundry's own macro runner under
|
|
41
|
+
* the user's permission — the mechanism the security model already blesses.
|
|
42
|
+
* Nothing here evaluates, compiles, or revives anything.
|
|
43
|
+
*
|
|
44
|
+
* Not a standalone script — exports the `Macros` compiler class, imported and
|
|
45
|
+
* driven by `packages/content-build/engine/generate.mjs` (via `npm run build:compiledb`).
|
|
46
|
+
*
|
|
47
|
+
* The walk itself — filtering by package and type, skipping drafts,
|
|
48
|
+
* expanding tables, converting wikilinks, writing the JSON and counting
|
|
49
|
+
* errors — belongs to {@link sohl.utils.packs.BasePackCompiler}; this module
|
|
50
|
+
* states only what makes this pass its own (#1509).
|
|
51
|
+
*/
|
|
52
|
+
|
|
53
|
+
import log from "loglevel";
|
|
54
|
+
|
|
55
|
+
import {
|
|
56
|
+
sohlField,
|
|
57
|
+
resolveName,
|
|
58
|
+
resolveImg,
|
|
59
|
+
defaultStats,
|
|
60
|
+
} from "./helpers.mjs";
|
|
61
|
+
import { BasePackCompiler } from "./base-compiler.mjs";
|
|
62
|
+
import { splitPages } from "./journals.mjs";
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* The anchor the executable script lives under: `# Script {#script}`.
|
|
66
|
+
*
|
|
67
|
+
* A reserved slug rather than a heading name, because the heading is prose an
|
|
68
|
+
* author may word freely ("The Script", "Source") while the address must be
|
|
69
|
+
* stable — it is what the compiler looks for and what an inbound section link
|
|
70
|
+
* spells.
|
|
71
|
+
*/
|
|
72
|
+
export const MACRO_SCRIPT_ANCHOR = "script";
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* The Foundry macro types (`CONST.MACRO_TYPES`).
|
|
76
|
+
*
|
|
77
|
+
* Only `script` compiles. `chat` is a real Foundry type but a different
|
|
78
|
+
* document altogether — its `command` is chat text, not source, so none of the
|
|
79
|
+
* fence rules above apply to it — and half-implementing it would ship a macro
|
|
80
|
+
* whose body was a code block posted verbatim into chat.
|
|
81
|
+
*/
|
|
82
|
+
export const MACRO_TYPES = Object.freeze(["script", "chat"]);
|
|
83
|
+
|
|
84
|
+
/** The Foundry macro scopes (`CONST.MACRO_SCOPES`), in schema order. */
|
|
85
|
+
export const MACRO_SCOPES = Object.freeze(["global", "actors", "actor"]);
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Foundry's own default macro artwork, used when a note authors no `img`.
|
|
89
|
+
*
|
|
90
|
+
* A core path, deliberately: it is not translated by {@link resolveImg} (which
|
|
91
|
+
* roots `icons/…` under this system's assets), so it must be stated after that
|
|
92
|
+
* translation rather than as authored frontmatter.
|
|
93
|
+
*/
|
|
94
|
+
export const DEFAULT_MACRO_IMG = "icons/svg/dice-target.svg";
|
|
95
|
+
|
|
96
|
+
/** The fence tags that mark a block as the macro's executable source. */
|
|
97
|
+
const JS_FENCE_TAGS = new Set(["js", "javascript"]);
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* The body of the first **language-tagged** JavaScript fence in a markdown
|
|
101
|
+
* block, verbatim.
|
|
102
|
+
*
|
|
103
|
+
* "Language-tagged" is the whole rule: an untagged fence is a code sample
|
|
104
|
+
* whose language nobody stated, and treating it as the macro's source would
|
|
105
|
+
* make an author's illustrative snippet executable. A fence tagged for another
|
|
106
|
+
* language is skipped for the same reason.
|
|
107
|
+
*
|
|
108
|
+
* The opening delimiter may be longer than three backticks, so a script may
|
|
109
|
+
* itself contain a fence; the closing delimiter must be at least as long, as
|
|
110
|
+
* CommonMark requires.
|
|
111
|
+
*
|
|
112
|
+
* @param {string} markdown - The markdown to search.
|
|
113
|
+
* @returns {string|null} The fence's contents, with no trailing newline, or
|
|
114
|
+
* `null` when the block holds no tagged JS fence.
|
|
115
|
+
*/
|
|
116
|
+
export function extractJsFence(markdown) {
|
|
117
|
+
const lines = String(markdown ?? "").split("\n");
|
|
118
|
+
for (let i = 0; i < lines.length; i++) {
|
|
119
|
+
const open = lines[i].match(/^\s*(`{3,})\s*([^\s`]*)/);
|
|
120
|
+
if (!open) continue;
|
|
121
|
+
const [, delim, info] = open;
|
|
122
|
+
// Every fence is consumed, tagged or not — the scan resumes after its
|
|
123
|
+
// close, so a snippet inside an untagged block can never be read as
|
|
124
|
+
// the macro's source.
|
|
125
|
+
let end = -1;
|
|
126
|
+
for (let j = i + 1; j < lines.length; j++) {
|
|
127
|
+
if (new RegExp(`^\\s*\`{${delim.length},}\\s*$`).test(lines[j])) {
|
|
128
|
+
end = j;
|
|
129
|
+
break;
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
// An unterminated fence closes nothing, so there is no verbatim body
|
|
133
|
+
// to take and nothing after it to keep scanning.
|
|
134
|
+
if (end === -1) return null;
|
|
135
|
+
if (JS_FENCE_TAGS.has(info.toLowerCase())) {
|
|
136
|
+
return lines.slice(i + 1, end).join("\n");
|
|
137
|
+
}
|
|
138
|
+
i = end;
|
|
139
|
+
}
|
|
140
|
+
return null;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* The `command` a macro note compiles to: the first tagged JS fence on its
|
|
145
|
+
* `{#script}` page.
|
|
146
|
+
*
|
|
147
|
+
* Read from the **raw** note body, before tables are expanded and wikilinks
|
|
148
|
+
* converted, so the executable copy is exactly what the author wrote even
|
|
149
|
+
* where the journal's rendered copy of the same fence is not.
|
|
150
|
+
*
|
|
151
|
+
* @param {string} body - The note's markdown body, frontmatter stripped.
|
|
152
|
+
* @param {string} name - The macro's name, for the error messages.
|
|
153
|
+
* @returns {string} The macro's command.
|
|
154
|
+
* @throws {Error} When the note declares no `{#script}` page, or that page
|
|
155
|
+
* holds no language-tagged JS fence. Either is a build error: a macro with
|
|
156
|
+
* no command is a macro-bar button that does nothing.
|
|
157
|
+
*/
|
|
158
|
+
export function macroCommand(body, name) {
|
|
159
|
+
const page = splitPages(String(body ?? ""), name).find(
|
|
160
|
+
(p) => p.anchorSlug === MACRO_SCRIPT_ANCHOR,
|
|
161
|
+
);
|
|
162
|
+
if (!page) {
|
|
163
|
+
throw new Error(
|
|
164
|
+
`macro "${name}": no page declares the {#${MACRO_SCRIPT_ANCHOR}} ` +
|
|
165
|
+
`anchor — a macro's source lives under a heading carrying it, ` +
|
|
166
|
+
`e.g. "# Script {#${MACRO_SCRIPT_ANCHOR}}"`,
|
|
167
|
+
);
|
|
168
|
+
}
|
|
169
|
+
const command = extractJsFence(page.markdown);
|
|
170
|
+
if (command === null) {
|
|
171
|
+
throw new Error(
|
|
172
|
+
`macro "${name}": the {#${MACRO_SCRIPT_ANCHOR}} page holds no ` +
|
|
173
|
+
`language-tagged JavaScript fence — tag it \`\`\`js (an ` +
|
|
174
|
+
`untagged fence is a code sample, not the macro's source)`,
|
|
175
|
+
);
|
|
176
|
+
}
|
|
177
|
+
return command;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/**
|
|
181
|
+
* The **Foundry** macro type a note compiles to — not the note's `type:`,
|
|
182
|
+
* which stays `macro` because that is what routes it to this pack.
|
|
183
|
+
*
|
|
184
|
+
* Foundry's schema initialises `type` to `CHAT`, so a script macro has to say
|
|
185
|
+
* so explicitly; this states it for every note and defaults the authored field
|
|
186
|
+
* to `script`, which is the only kind that compiles.
|
|
187
|
+
*
|
|
188
|
+
* @param {object} fm - The note's frontmatter.
|
|
189
|
+
* @param {string} label - The macro, for the error message.
|
|
190
|
+
* @returns {"script"} The macro type.
|
|
191
|
+
* @throws {Error} For `chat`, and for any value Foundry does not define.
|
|
192
|
+
*/
|
|
193
|
+
export function resolveMacroType(fm, label) {
|
|
194
|
+
const raw = String(sohlField(fm, "macroType", "script") ?? "script");
|
|
195
|
+
if (raw === "script") return "script";
|
|
196
|
+
if (raw === "chat") {
|
|
197
|
+
throw new Error(
|
|
198
|
+
`macro "${label}": sohl.macroType "chat" is not supported — a chat ` +
|
|
199
|
+
`macro's command is chat text rather than source, so none of ` +
|
|
200
|
+
`the {#${MACRO_SCRIPT_ANCHOR}} fence rules apply to it`,
|
|
201
|
+
);
|
|
202
|
+
}
|
|
203
|
+
throw new Error(
|
|
204
|
+
`macro "${label}": unknown sohl.macroType "${raw}" — Foundry defines ` +
|
|
205
|
+
`${MACRO_TYPES.join(", ")}`,
|
|
206
|
+
);
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/**
|
|
210
|
+
* The Foundry macro scope a note compiles to.
|
|
211
|
+
*
|
|
212
|
+
* @param {object} fm - The note's frontmatter.
|
|
213
|
+
* @param {string} label - The macro, for the error message.
|
|
214
|
+
* @returns {string} One of {@link MACRO_SCOPES}.
|
|
215
|
+
* @throws {Error} When the authored scope is not one Foundry defines — it
|
|
216
|
+
* would fail the schema's `choices` validation at load and be replaced by
|
|
217
|
+
* the default, shipping a macro whose authored reach had quietly widened.
|
|
218
|
+
*/
|
|
219
|
+
export function resolveMacroScope(fm, label) {
|
|
220
|
+
const raw = String(sohlField(fm, "macroScope", "global") ?? "global");
|
|
221
|
+
if (!MACRO_SCOPES.includes(raw)) {
|
|
222
|
+
throw new Error(
|
|
223
|
+
`macro "${label}": unknown sohl.macroScope "${raw}" — Foundry ` +
|
|
224
|
+
`defines ${MACRO_SCOPES.join(", ")}`,
|
|
225
|
+
);
|
|
226
|
+
}
|
|
227
|
+
return raw;
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
/**
|
|
231
|
+
* A compiled Macro document, in the shape the LevelDB packer consumes.
|
|
232
|
+
*
|
|
233
|
+
* @typedef {object} MacroDocument
|
|
234
|
+
* @property {string} name - The macro's display name.
|
|
235
|
+
* @property {string} type - The Foundry macro type; always `script`.
|
|
236
|
+
* @property {null} author - No authoring user; Foundry's field is nullable.
|
|
237
|
+
* @property {string} img - The Foundry-relative artwork path.
|
|
238
|
+
* @property {string} scope - One of {@link MACRO_SCOPES}.
|
|
239
|
+
* @property {string} command - The script the macro runs.
|
|
240
|
+
* @property {string|null} folder - The folder id, or `null` for the root.
|
|
241
|
+
* @property {number} sort - Sort order within its folder.
|
|
242
|
+
* @property {{default: number}} ownership - Default ownership level.
|
|
243
|
+
* @property {object} flags - Document flags from frontmatter.
|
|
244
|
+
* @property {string} _id - The Foundry document id.
|
|
245
|
+
* @property {object} _stats - The `_stats` block.
|
|
246
|
+
* @property {string} _key - The LevelDB key, `!macros!<id>`.
|
|
247
|
+
*/
|
|
248
|
+
|
|
249
|
+
/**
|
|
250
|
+
* The compendium envelope for one Macro.
|
|
251
|
+
*
|
|
252
|
+
* @param {object} fm - The note's frontmatter.
|
|
253
|
+
* @param {object} opts
|
|
254
|
+
* @param {string} opts.command - The command, from {@link macroCommand}.
|
|
255
|
+
* @param {string|null} [opts.folder] - The resolved folder id.
|
|
256
|
+
* @param {object} [opts.stats] - The `_stats` block.
|
|
257
|
+
* @returns {MacroDocument} The Macro document.
|
|
258
|
+
* @throws {Error} When the frontmatter's macro type or scope is unusable.
|
|
259
|
+
*/
|
|
260
|
+
export function buildMacroEntry(
|
|
261
|
+
fm,
|
|
262
|
+
{ command, folder = null, stats = defaultStats() },
|
|
263
|
+
) {
|
|
264
|
+
const name = resolveName(fm);
|
|
265
|
+
const id = fm.id;
|
|
266
|
+
return {
|
|
267
|
+
name,
|
|
268
|
+
// Stated rather than defaulted: Foundry's schema initialises `type` to
|
|
269
|
+
// CHAT, so an omitted type ships a macro that posts its own source
|
|
270
|
+
// into chat instead of running.
|
|
271
|
+
type: resolveMacroType(fm, name),
|
|
272
|
+
author: null,
|
|
273
|
+
img: resolveImg(fm.img) || DEFAULT_MACRO_IMG,
|
|
274
|
+
scope: resolveMacroScope(fm, name),
|
|
275
|
+
command,
|
|
276
|
+
folder,
|
|
277
|
+
sort: 0,
|
|
278
|
+
ownership: { default: 0 },
|
|
279
|
+
flags: fm.flags || {},
|
|
280
|
+
_id: id,
|
|
281
|
+
_stats: stats,
|
|
282
|
+
_key: `!macros!${id}`,
|
|
283
|
+
};
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
/**
|
|
287
|
+
* Macros pack compiler.
|
|
288
|
+
*
|
|
289
|
+
* Walks the content tree and compiles every `package: sohl`, `type: macro`
|
|
290
|
+
* note into one Macro document. The same note's documentation is compiled by
|
|
291
|
+
* the journals pass; neither pass reads the other's output.
|
|
292
|
+
*/
|
|
293
|
+
export class Macros extends BasePackCompiler {
|
|
294
|
+
static id = "macros";
|
|
295
|
+
static label = "macro";
|
|
296
|
+
|
|
297
|
+
/**
|
|
298
|
+
* The command must be exactly what the author typed, so this pass reads the
|
|
299
|
+
* note as authored: no table expansion, no wikilink conversion, and no
|
|
300
|
+
* content-wide link index it would never consult. The journals pass
|
|
301
|
+
* compiles the converted copy of the same body independently.
|
|
302
|
+
*/
|
|
303
|
+
static convertsWikilinks = false;
|
|
304
|
+
|
|
305
|
+
/**
|
|
306
|
+
* @param {object} fm - The note's frontmatter.
|
|
307
|
+
* @returns {boolean} True for a `macro` note.
|
|
308
|
+
*/
|
|
309
|
+
selects(fm) {
|
|
310
|
+
return fm.type === "macro";
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
/**
|
|
314
|
+
* Compile one note into a Macro.
|
|
315
|
+
*
|
|
316
|
+
* @param {object} fm - The note's frontmatter.
|
|
317
|
+
* @param {string} body - The note body, frontmatter stripped and otherwise
|
|
318
|
+
* exactly as authored.
|
|
319
|
+
* @returns {MacroDocument} The Macro document.
|
|
320
|
+
*/
|
|
321
|
+
buildEntry(fm, body) {
|
|
322
|
+
const name = resolveName(fm);
|
|
323
|
+
return buildMacroEntry(fm, {
|
|
324
|
+
command: macroCommand(body, name),
|
|
325
|
+
folder: this.folderResolver(sohlField(fm, "folder", null)),
|
|
326
|
+
});
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
/** @inheritdoc */
|
|
330
|
+
reportDetail(stats) {
|
|
331
|
+
log.debug(`Skipped ${stats.skippedOther} non-macro file(s)`);
|
|
332
|
+
}
|
|
333
|
+
}
|
|
@@ -0,0 +1,339 @@
|
|
|
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
|
+
* Emitting this package's cross-package link manifest (#58).
|
|
16
|
+
*
|
|
17
|
+
* `engine/kb-manifest.mjs` owns the *format* — what an entry may say, how a
|
|
18
|
+
* version is read, how a foreign file resolves. This module owns the *pass*:
|
|
19
|
+
* walking a content tree and deriving, for every note it publishes, the
|
|
20
|
+
* addresses that entry states. The two halves were split across the format
|
|
21
|
+
* module and a hand-written script in each consuming repository, which is how
|
|
22
|
+
* the two scripts came to differ in ways nobody chose — one routes its UUIDs
|
|
23
|
+
* through the pack router and one does not, and neither knew.
|
|
24
|
+
*
|
|
25
|
+
* **The base is not an input.** Both scripts built a site-absolute URL and
|
|
26
|
+
* handed {@link buildManifest} the base it was built from, whose first act is
|
|
27
|
+
* to strip that same prefix back off; the value never reached the file. So
|
|
28
|
+
* nothing here composes one. An address is derived package-relative from the
|
|
29
|
+
* start, by {@link packageAddress}, and the emitting build's mount point is not
|
|
30
|
+
* a fact it has to be told (#1465).
|
|
31
|
+
*
|
|
32
|
+
* **The address scheme is configuration, and it is shared with the site build.**
|
|
33
|
+
* Where the content tree mounts inside the package and which note is a section's
|
|
34
|
+
* landing page differ between repositories and are both load-bearing — `sohl`
|
|
35
|
+
* records `kb/affliction/aconite/` and `thalorna` records
|
|
36
|
+
* `affiliation/the-aerarium-imperii/`. Reading one setting here and in the page
|
|
37
|
+
* emitter is what stops a manifest asserting an address the site does not
|
|
38
|
+
* publish, which resolves at build time and 404s for the reader.
|
|
39
|
+
*
|
|
40
|
+
* **Anchors are computed, not approximated.** The pass that splits a note into
|
|
41
|
+
* journal pages is {@link splitPages}, a pure function over the markdown body,
|
|
42
|
+
* so running it costs a parse and no I/O. Both scripts already ran it. An entry
|
|
43
|
+
* that silently lost its anchors would degrade every cross-package section link
|
|
44
|
+
* in every consumer, so there is no mode in which they are skipped.
|
|
45
|
+
*
|
|
46
|
+
* @module
|
|
47
|
+
*/
|
|
48
|
+
|
|
49
|
+
import fs from "node:fs";
|
|
50
|
+
import path from "node:path";
|
|
51
|
+
|
|
52
|
+
import { packageAddress } from "./content-address.mjs";
|
|
53
|
+
import { canonicalKey, writeManifests } from "./kb-manifest.mjs";
|
|
54
|
+
import { walkMarkdownTree } from "./helpers.mjs";
|
|
55
|
+
import { compendiumUuid, packForType, pageUuid } from "./ids.mjs";
|
|
56
|
+
import { hasDocEntry, itemDocEntryId } from "./item-docs.mjs";
|
|
57
|
+
import { journalPageId, splitPages } from "./journals.mjs";
|
|
58
|
+
import { routerFor } from "./pack-router.mjs";
|
|
59
|
+
import { loadPackConfig } from "./pack-config.mjs";
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* The reserved anchor name for a journal's **first** page.
|
|
63
|
+
*
|
|
64
|
+
* Every journal has one and it is what an item's `docHtml` points at, but it
|
|
65
|
+
* carries no authored `{#slug}` — so without a reserved name the one page that
|
|
66
|
+
* always exists would be the one page the manifest could not address. It cannot
|
|
67
|
+
* collide with an authored slug, which is `[a-z0-9-]+`.
|
|
68
|
+
*/
|
|
69
|
+
export const LEAD_ANCHOR = "$lead";
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Every page of a note's journal, as `anchorName → whole UUID`.
|
|
73
|
+
*
|
|
74
|
+
* Whole, not a fragment appended to the entry's UUID: nothing owns a page
|
|
75
|
+
* address, so a complete link restates no fact, and it keeps the page-id hash
|
|
76
|
+
* out of the published contract entirely — a consumer resolves
|
|
77
|
+
* `[[docaffliction-aconite#crafting]]` with a lookup instead of reimplementing
|
|
78
|
+
* a sha256/base64/truncate rule.
|
|
79
|
+
*
|
|
80
|
+
* @param {string} entryUuid - The journal entry's UUID.
|
|
81
|
+
* @param {string} entryId - The entry's id, which page ids hash against.
|
|
82
|
+
* @param {string} body - The note's markdown body.
|
|
83
|
+
* @param {string} name - The note's name, used as the lead page's title.
|
|
84
|
+
* @returns {Record<string, string>} The anchors.
|
|
85
|
+
*/
|
|
86
|
+
export function anchorsOf(entryUuid, entryId, body, name) {
|
|
87
|
+
const anchors = {};
|
|
88
|
+
splitPages(body, name).forEach((page, index) => {
|
|
89
|
+
const uuid = pageUuid(entryUuid, journalPageId(entryId, page, index));
|
|
90
|
+
if (index === 0) anchors[LEAD_ANCHOR] = uuid;
|
|
91
|
+
if (page.anchorSlug) anchors[page.anchorSlug] = uuid;
|
|
92
|
+
});
|
|
93
|
+
return anchors;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* The manifest entries a single note produces.
|
|
98
|
+
*
|
|
99
|
+
* An item note produces **two**: the item, and separately the JournalEntry its
|
|
100
|
+
* prose compiles into. They are two documents with two UUIDs, so they get two
|
|
101
|
+
* addresses; the item's entry points at the other by address rather than
|
|
102
|
+
* repeating its UUID, because the doc entry owns that fact (#1499). A `macro`
|
|
103
|
+
* note is the same arrangement (#1514), which is why the type set comes from
|
|
104
|
+
* {@link hasDocEntry} rather than being spelled here — the journals compiler
|
|
105
|
+
* reads the same one, so a manifest cannot claim documentation nothing compiled.
|
|
106
|
+
*
|
|
107
|
+
* @param {object} fm - Parsed frontmatter.
|
|
108
|
+
* @param {string} name - The note's display name.
|
|
109
|
+
* @param {string} address - The note's package-relative address.
|
|
110
|
+
* @param {string} body - The note's markdown body.
|
|
111
|
+
* @param {object} ctx - Resolved identities: `{ contentPackage,
|
|
112
|
+
* foundryPackageId, packRouter }`.
|
|
113
|
+
* @returns {Array<object>} One or two entries, in {@link buildManifest}'s shape.
|
|
114
|
+
*/
|
|
115
|
+
export function entriesForNote(fm, name, address, body, ctx) {
|
|
116
|
+
const { contentPackage, foundryPackageId, packRouter } = ctx;
|
|
117
|
+
const key = canonicalKey(contentPackage, fm.type, fm.shortcode);
|
|
118
|
+
// `buildManifest` records `packageRelative(url, base)`, so the pair it is
|
|
119
|
+
// given has to round-trip. The address is already package-relative, so the
|
|
120
|
+
// honest pair is the address under a base of `"/"` — which strips straight
|
|
121
|
+
// back off. Composing a real mount point here and removing it again is what
|
|
122
|
+
// the two consumer scripts did, and the value provably never reached the
|
|
123
|
+
// file.
|
|
124
|
+
const url = `/${address}`;
|
|
125
|
+
|
|
126
|
+
// A published address must name the pack the document actually shipped in:
|
|
127
|
+
// a consumer resolves the UUID verbatim, and a repository may ship several
|
|
128
|
+
// packs of one type (#1566).
|
|
129
|
+
const uuidFor = (type, id, routeFm) =>
|
|
130
|
+
id ?
|
|
131
|
+
compendiumUuid(
|
|
132
|
+
foundryPackageId,
|
|
133
|
+
type,
|
|
134
|
+
id,
|
|
135
|
+
routeFm ?
|
|
136
|
+
packRouter.resolveOrNull(routeFm, packForType(type).docType)
|
|
137
|
+
: packRouter.defaultOf("JournalEntry"),
|
|
138
|
+
)
|
|
139
|
+
: undefined;
|
|
140
|
+
|
|
141
|
+
if (hasDocEntry(fm.type)) {
|
|
142
|
+
const docKey = canonicalKey(
|
|
143
|
+
contentPackage,
|
|
144
|
+
`doc${fm.type}`,
|
|
145
|
+
fm.shortcode,
|
|
146
|
+
);
|
|
147
|
+
const docEntryId = fm.id ? itemDocEntryId(fm.id) : undefined;
|
|
148
|
+
const docUuid = uuidFor("doc", docEntryId);
|
|
149
|
+
return [
|
|
150
|
+
{
|
|
151
|
+
key,
|
|
152
|
+
fm,
|
|
153
|
+
name,
|
|
154
|
+
url,
|
|
155
|
+
uuid: uuidFor(fm.type, fm.id, fm),
|
|
156
|
+
doc: docKey,
|
|
157
|
+
},
|
|
158
|
+
{
|
|
159
|
+
key: docKey,
|
|
160
|
+
fm,
|
|
161
|
+
name,
|
|
162
|
+
// On the web the item note renders as one page which *is* its
|
|
163
|
+
// documentation, so both addresses resolve to the same URL.
|
|
164
|
+
url,
|
|
165
|
+
uuid: docUuid,
|
|
166
|
+
anchors:
|
|
167
|
+
docUuid ?
|
|
168
|
+
anchorsOf(docUuid, docEntryId, body ?? "", name)
|
|
169
|
+
: undefined,
|
|
170
|
+
},
|
|
171
|
+
];
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
// Everything else is one document. A `doc` note compiles into a journal in
|
|
175
|
+
// its own right, so its anchors sit on its own entry.
|
|
176
|
+
const own = uuidFor(fm.type, fm.id, fm);
|
|
177
|
+
return [
|
|
178
|
+
{
|
|
179
|
+
key,
|
|
180
|
+
fm,
|
|
181
|
+
name,
|
|
182
|
+
url,
|
|
183
|
+
uuid: own,
|
|
184
|
+
anchors:
|
|
185
|
+
own && fm.type === "doc" ?
|
|
186
|
+
anchorsOf(own, fm.id, body ?? "", name)
|
|
187
|
+
: undefined,
|
|
188
|
+
},
|
|
189
|
+
];
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/**
|
|
193
|
+
* Every note this package publishes, as manifest entries.
|
|
194
|
+
*
|
|
195
|
+
* Drafts are excluded because the site does not publish them, and an entry for
|
|
196
|
+
* an unpublished page is exactly the dead link the manifest exists to prevent.
|
|
197
|
+
* A note belonging to another content package is skipped for the same reason in
|
|
198
|
+
* reverse: this build is not authoritative for it, and its own build says where
|
|
199
|
+
* it lives.
|
|
200
|
+
*
|
|
201
|
+
* A note that has no address is **reported, not guessed** — the finding carries
|
|
202
|
+
* the file and the reason, so a caller can print it or fail on it. Inventing an
|
|
203
|
+
* address would put an entry in the manifest asserting a page that does not
|
|
204
|
+
* exist.
|
|
205
|
+
*
|
|
206
|
+
* @param {string} contentBase - Absolute path to the content tree.
|
|
207
|
+
* @param {object} ctx - `{ contentPackage, foundryPackageId, packRouter,
|
|
208
|
+
* scheme }`.
|
|
209
|
+
* @returns {{entries: Array<object>, notes: number,
|
|
210
|
+
* skipped: Array<{file: string, reason: string}>}}
|
|
211
|
+
*/
|
|
212
|
+
export function collectManifestEntries(contentBase, ctx) {
|
|
213
|
+
const entries = [];
|
|
214
|
+
const skipped = [];
|
|
215
|
+
// Counted separately because they are genuinely different numbers: an item
|
|
216
|
+
// note yields two entries, so reporting one as the other overstates how
|
|
217
|
+
// much of the tree is published.
|
|
218
|
+
let notes = 0;
|
|
219
|
+
for (const { frontmatter: fm, body, absPath } of walkMarkdownTree(
|
|
220
|
+
contentBase,
|
|
221
|
+
{ skipDirectories: ctx.skipDirectories },
|
|
222
|
+
)) {
|
|
223
|
+
if (!fm || fm.package !== ctx.contentPackage) continue;
|
|
224
|
+
if (fm.draft === true) continue;
|
|
225
|
+
if (!fm.type || !fm.shortcode) continue;
|
|
226
|
+
|
|
227
|
+
const rel = path.relative(contentBase, absPath);
|
|
228
|
+
const base = path.basename(absPath);
|
|
229
|
+
const name = fm.name?.full ?? path.basename(absPath, ".md");
|
|
230
|
+
|
|
231
|
+
let address;
|
|
232
|
+
try {
|
|
233
|
+
address = packageAddress(fm, name, {
|
|
234
|
+
isReadme: base.toLowerCase() === "readme.md",
|
|
235
|
+
scheme: ctx.scheme,
|
|
236
|
+
});
|
|
237
|
+
} catch (err) {
|
|
238
|
+
skipped.push({ file: rel, reason: err.message });
|
|
239
|
+
continue;
|
|
240
|
+
}
|
|
241
|
+
notes += 1;
|
|
242
|
+
entries.push(...entriesForNote(fm, name, address, body ?? "", ctx));
|
|
243
|
+
}
|
|
244
|
+
return { entries, notes, skipped };
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
/**
|
|
248
|
+
* The identities and scheme an emission runs against, from configuration.
|
|
249
|
+
*
|
|
250
|
+
* Resolved in one place and passed down, rather than read at each use, so the
|
|
251
|
+
* pass itself is a pure function of its context and a test can drive it without
|
|
252
|
+
* standing up a configuration.
|
|
253
|
+
*
|
|
254
|
+
* @param {object} [config] - A resolved configuration; loaded when omitted.
|
|
255
|
+
* @returns {{contentPackage: string, foundryPackageId: string, packRouter: object,
|
|
256
|
+
* scheme: {prefix: string, landing: string}, web: boolean,
|
|
257
|
+
* skipDirectories: readonly string[]}}
|
|
258
|
+
*/
|
|
259
|
+
export function manifestContext(config = loadPackConfig()) {
|
|
260
|
+
return {
|
|
261
|
+
contentPackage: config.contentPackage,
|
|
262
|
+
foundryPackageId: config.foundryPackage,
|
|
263
|
+
packRouter: routerFor(config),
|
|
264
|
+
scheme: config.publish.address,
|
|
265
|
+
web: config.publish.site,
|
|
266
|
+
// The walk's own configuration, threaded through rather than left to
|
|
267
|
+
// its default, so a caller that passes a config drives every read.
|
|
268
|
+
skipDirectories: config.skipDirectories,
|
|
269
|
+
};
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
/**
|
|
273
|
+
* Emits this package's link manifest.
|
|
274
|
+
*
|
|
275
|
+
* One package, because a configuration declares exactly one `contentPackage`
|
|
276
|
+
* and nothing in the surface can express a second. {@link writeManifests} keeps
|
|
277
|
+
* its package→entries map — it is the general writer — but there is no setting
|
|
278
|
+
* here to choose with.
|
|
279
|
+
*
|
|
280
|
+
* @param {object} [options] - Options.
|
|
281
|
+
* @param {string} [options.contentBase] - The content tree; defaults to the
|
|
282
|
+
* configured `paths.content`.
|
|
283
|
+
* @param {string} [options.outDir] - Where to write; defaults to the configured
|
|
284
|
+
* `paths.manifestOut`.
|
|
285
|
+
* @param {object} [options.config] - A resolved configuration; loaded when
|
|
286
|
+
* omitted.
|
|
287
|
+
* @returns {{written: Array<{package: string, file: string, count: number}>,
|
|
288
|
+
* entries: number, notes: number,
|
|
289
|
+
* skipped: Array<{file: string, reason: string}>}}
|
|
290
|
+
* @throws {Error} When the repository does not declare that it publishes a
|
|
291
|
+
* manifest, when the tree is absent, or when it yields no published note — a
|
|
292
|
+
* manifest claiming this package publishes nothing is worse than none, since
|
|
293
|
+
* a consumer reads it as authoritative and turns every link into this package
|
|
294
|
+
* into a reported typo.
|
|
295
|
+
*/
|
|
296
|
+
export function emitLinkManifest({ contentBase, outDir, config } = {}) {
|
|
297
|
+
const resolved = config ?? loadPackConfig();
|
|
298
|
+
const tree = contentBase ?? resolved.paths.content;
|
|
299
|
+
const dir = outDir ?? resolved.paths.manifestOut;
|
|
300
|
+
const ctx = manifestContext(resolved);
|
|
301
|
+
|
|
302
|
+
// A repository that has not declared it publishes a manifest must not
|
|
303
|
+
// produce one: the file is vendored by consumers and read as authoritative,
|
|
304
|
+
// so emitting it is a statement about this package rather than a local
|
|
305
|
+
// convenience. Checked here rather than in the command, so a library caller
|
|
306
|
+
// cannot route around the declaration.
|
|
307
|
+
if (!resolved.publish.manifests.publish) {
|
|
308
|
+
throw new Error(
|
|
309
|
+
`this repository does not publish a link manifest — set ` +
|
|
310
|
+
`\`publish.manifests.publish: true\` in its content-build ` +
|
|
311
|
+
`configuration to change that`,
|
|
312
|
+
);
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
if (!fs.existsSync(tree)) {
|
|
316
|
+
throw new Error(`no content tree at ${tree}`);
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
const { entries, notes, skipped } = collectManifestEntries(tree, ctx);
|
|
320
|
+
if (entries.length === 0) {
|
|
321
|
+
throw new Error(
|
|
322
|
+
`${tree} yielded no published notes, so the manifest would ` +
|
|
323
|
+
`claim this package publishes nothing`,
|
|
324
|
+
);
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
const written = writeManifests(
|
|
328
|
+
new Map([[ctx.contentPackage, entries]]),
|
|
329
|
+
dir,
|
|
330
|
+
// The one surviving role of a base: `undefined` is the statement "this
|
|
331
|
+
// build publishes no pages", and no entry then carries a `path`
|
|
332
|
+
// (#1516). The value itself cancels — every address above is already
|
|
333
|
+
// package-relative — so it is a sentinel, not a location.
|
|
334
|
+
ctx.web ? { [ctx.contentPackage]: "/" } : undefined,
|
|
335
|
+
{ [ctx.contentPackage]: ctx.foundryPackageId },
|
|
336
|
+
);
|
|
337
|
+
|
|
338
|
+
return { written, entries: entries.length, notes, skipped };
|
|
339
|
+
}
|