@heroiclands/package-build 0.6.1 → 3.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (127) hide show
  1. package/CHANGELOG-content-build.md +965 -0
  2. package/CHANGELOG.md +68 -0
  3. package/CONTENT.md +824 -0
  4. package/MIGRATING.md +127 -0
  5. package/README.md +29 -17
  6. package/bin/content-build.mjs +1082 -0
  7. package/bin/package-build.mjs +2 -2
  8. package/bin/report.mjs +2 -2
  9. package/config.mjs +21 -18
  10. package/content-config.mjs +1424 -0
  11. package/coverage.mjs +1 -1
  12. package/e2e.mjs +1 -1
  13. package/engine/abbreviations.mjs +0 -0
  14. package/engine/base-compiler.mjs +608 -0
  15. package/engine/code-fences.mjs +311 -0
  16. package/engine/compendiums.mjs +353 -0
  17. package/engine/content-address.mjs +141 -0
  18. package/engine/content-links.mjs +438 -0
  19. package/engine/content-lint.mjs +204 -0
  20. package/engine/content-package.mjs +67 -0
  21. package/engine/content-slug.mjs +169 -0
  22. package/engine/content-tables.mjs +1216 -0
  23. package/engine/content-tree.mjs +56 -0
  24. package/engine/diagnostics.mjs +270 -0
  25. package/engine/field-reference.mjs +242 -0
  26. package/engine/field-spec.mjs +218 -0
  27. package/engine/foreign-catalog.mjs +450 -0
  28. package/engine/foreign-manifests.mjs +129 -0
  29. package/engine/frontmatter-lint.mjs +343 -0
  30. package/engine/frontmatter.mjs +258 -0
  31. package/engine/generate.mjs +305 -0
  32. package/engine/helpers.mjs +809 -0
  33. package/engine/ids.mjs +192 -0
  34. package/engine/index.mjs +142 -0
  35. package/engine/item-docs.mjs +153 -0
  36. package/engine/item-registry.mjs +129 -0
  37. package/engine/journals.mjs +374 -0
  38. package/engine/kb-manifest.mjs +473 -0
  39. package/engine/macros.mjs +333 -0
  40. package/engine/manifest-emit.mjs +339 -0
  41. package/engine/map-notes.mjs +1302 -0
  42. package/engine/pack-config.mjs +479 -0
  43. package/engine/pack-router.mjs +254 -0
  44. package/engine/prose-config.mjs +238 -0
  45. package/engine/prose-lint.mjs +294 -0
  46. package/engine/region-events.mjs +79 -0
  47. package/engine/scene-levels.mjs +420 -0
  48. package/engine/scenes.mjs +528 -0
  49. package/engine/site-build.mjs +778 -0
  50. package/engine/site-index.mjs +332 -0
  51. package/engine/web-wikilinks.mjs +330 -0
  52. package/engine/wikilink-syntax.mjs +105 -0
  53. package/engine/wikilinks.mjs +645 -0
  54. package/index.mjs +42 -25
  55. package/lang.mjs +1 -1
  56. package/manifest.mjs +79 -5
  57. package/markdownlint-config.mjs +45 -0
  58. package/package.json +78 -35
  59. package/prettier-config.mjs +34 -0
  60. package/sohl/actors.mjs +581 -0
  61. package/sohl/affiliation-standings.mjs +44 -0
  62. package/sohl/being-info.mjs +173 -0
  63. package/sohl/default-item-art.mjs +82 -0
  64. package/sohl/index.mjs +55 -0
  65. package/sohl/item-builders.mjs +114 -0
  66. package/sohl/item-fields.mjs +867 -0
  67. package/sohl/items.mjs +197 -0
  68. package/sohl/kb-passes.mjs +207 -0
  69. package/sohl/note-schemas.mjs +322 -0
  70. package/templates.mjs +1 -1
  71. package/types/config.d.mts +1 -1
  72. package/types/content-config.d.mts +571 -0
  73. package/types/engine/abbreviations.d.mts +44 -0
  74. package/types/engine/base-compiler.d.mts +320 -0
  75. package/types/engine/code-fences.d.mts +102 -0
  76. package/types/engine/compendiums.d.mts +77 -0
  77. package/types/engine/content-address.d.mts +61 -0
  78. package/types/engine/content-links.d.mts +87 -0
  79. package/types/engine/content-lint.d.mts +47 -0
  80. package/types/engine/content-package.d.mts +36 -0
  81. package/types/engine/content-slug.d.mts +59 -0
  82. package/types/engine/content-tables.d.mts +131 -0
  83. package/types/engine/content-tree.d.mts +12 -0
  84. package/types/engine/diagnostics.d.mts +152 -0
  85. package/types/engine/field-reference.d.mts +20 -0
  86. package/types/engine/field-spec.d.mts +181 -0
  87. package/types/engine/foreign-catalog.d.mts +103 -0
  88. package/types/engine/foreign-manifests.d.mts +43 -0
  89. package/types/engine/frontmatter-lint.d.mts +74 -0
  90. package/types/engine/frontmatter.d.mts +99 -0
  91. package/types/engine/generate.d.mts +56 -0
  92. package/types/engine/helpers.d.mts +348 -0
  93. package/types/engine/ids.d.mts +126 -0
  94. package/types/engine/index.d.mts +33 -0
  95. package/types/engine/item-docs.d.mts +73 -0
  96. package/types/engine/item-registry.d.mts +57 -0
  97. package/types/engine/journals.d.mts +121 -0
  98. package/types/engine/kb-manifest.d.mts +214 -0
  99. package/types/engine/macros.d.mts +201 -0
  100. package/types/engine/manifest-emit.d.mts +137 -0
  101. package/types/engine/map-notes.d.mts +283 -0
  102. package/types/engine/pack-config.d.mts +77 -0
  103. package/types/engine/pack-router.d.mts +53 -0
  104. package/types/engine/prose-config.d.mts +121 -0
  105. package/types/engine/prose-lint.d.mts +59 -0
  106. package/types/engine/region-events.d.mts +43 -0
  107. package/types/engine/scene-levels.d.mts +36 -0
  108. package/types/engine/scenes.d.mts +48 -0
  109. package/types/engine/site-build.d.mts +214 -0
  110. package/types/engine/site-index.d.mts +141 -0
  111. package/types/engine/web-wikilinks.d.mts +67 -0
  112. package/types/engine/wikilink-syntax.d.mts +101 -0
  113. package/types/engine/wikilinks.d.mts +172 -0
  114. package/types/index.d.mts +3 -0
  115. package/types/manifest.d.mts +52 -4
  116. package/types/markdownlint-config.d.mts +8 -0
  117. package/types/prettier-config.d.mts +1 -0
  118. package/types/sohl/actors.d.mts +65 -0
  119. package/types/sohl/affiliation-standings.d.mts +22 -0
  120. package/types/sohl/being-info.d.mts +78 -0
  121. package/types/sohl/default-item-art.d.mts +28 -0
  122. package/types/sohl/index.d.mts +7 -0
  123. package/types/sohl/item-builders.d.mts +11 -0
  124. package/types/sohl/item-fields.d.mts +20 -0
  125. package/types/sohl/items.d.mts +19 -0
  126. package/types/sohl/kb-passes.d.mts +71 -0
  127. package/types/sohl/note-schemas.d.mts +7 -0
@@ -0,0 +1,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
+ }