@heroiclands/package-build 23.8.0 → 23.10.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (114) hide show
  1. package/CHANGELOG.md +53 -0
  2. package/README.md +2 -0
  3. package/bin/content-commands.mjs +207 -70
  4. package/docs/api.md +646 -78
  5. package/docs/authoring/first-note.md +1 -1
  6. package/docs/authoring/links-and-markup.md +253 -102
  7. package/docs/commands.md +111 -13
  8. package/docs/content-format.md +7 -2
  9. package/docs/diagnostics.md +1 -1
  10. package/docs/guides/packs.md +2 -1
  11. package/docs/guides/reference-pages.md +86 -63
  12. package/docs/reference/format-details.md +398 -137
  13. package/docs/reference/note-types.md +188 -174
  14. package/engine/anchors.mjs +15 -26
  15. package/engine/base-compiler.mjs +6 -4
  16. package/engine/changelog-group.mjs +58 -34
  17. package/engine/changelog-lint.mjs +6 -2
  18. package/engine/compendiums.mjs +15 -1
  19. package/engine/compile-corpus.mjs +17 -3
  20. package/engine/content-charset.mjs +13 -1
  21. package/engine/content-format.yaml +1 -0
  22. package/engine/content-html.mjs +18 -4
  23. package/engine/content-icons.mjs +15 -1
  24. package/engine/content-images.mjs +28 -9
  25. package/engine/content-index.mjs +496 -16
  26. package/engine/content-links.mjs +70 -27
  27. package/engine/content-session.mjs +247 -0
  28. package/engine/content-sources.mjs +110 -0
  29. package/engine/content-spans.mjs +87 -8
  30. package/engine/content-tables.mjs +9 -4
  31. package/engine/content-tasklists.mjs +20 -4
  32. package/engine/culture-reach.mjs +61 -34
  33. package/engine/data-keys.mjs +3 -1
  34. package/engine/diagnostics.mjs +17 -6
  35. package/engine/dictionary-layout.mjs +151 -19
  36. package/engine/dictionary-rows.mjs +129 -79
  37. package/engine/extension-attributes.mjs +28 -5
  38. package/engine/first-use.mjs +371 -0
  39. package/engine/foundry-entries.mjs +29 -8
  40. package/engine/generate.mjs +13 -4
  41. package/engine/glossary-entries.mjs +11 -11
  42. package/engine/heading-attributes.mjs +21 -28
  43. package/engine/helpers.mjs +54 -19
  44. package/engine/index-records.mjs +1 -0
  45. package/engine/index.mjs +3 -0
  46. package/engine/journals.mjs +13 -6
  47. package/engine/language-data.mjs +55 -2
  48. package/engine/language-rows.mjs +4 -4
  49. package/engine/language-terms.mjs +90 -4
  50. package/engine/language-words.mjs +2 -2
  51. package/engine/lexical-sections.mjs +247 -0
  52. package/engine/markup-validation.mjs +7 -2
  53. package/engine/metadata-index.mjs +1 -0
  54. package/engine/name-markup.mjs +114 -0
  55. package/engine/name-rows.mjs +70 -0
  56. package/engine/names.mjs +460 -0
  57. package/engine/note-addresses.mjs +25 -4
  58. package/engine/note-claims.mjs +2 -0
  59. package/engine/note-state.mjs +9 -3
  60. package/engine/note-vocabulary.mjs +7 -6
  61. package/engine/path-charset.mjs +24 -1
  62. package/engine/pdf-build.mjs +9 -9
  63. package/engine/pdf-render.mjs +15 -1
  64. package/engine/site-build.mjs +16 -2
  65. package/engine/site-index.mjs +5 -1
  66. package/engine/sql-tables.mjs +324 -44
  67. package/engine/stub-lint.mjs +10 -5
  68. package/engine/web-wikilinks.mjs +8 -6
  69. package/engine/word-rows.mjs +20 -12
  70. package/engine/word-terms.mjs +15 -6
  71. package/engine/yaml-lint.mjs +3 -5
  72. package/hugo-theme/static/css/style.css +4 -0
  73. package/package.json +1 -1
  74. package/types/engine/anchors.d.mts +4 -2
  75. package/types/engine/compendiums.d.mts +5 -1
  76. package/types/engine/compile-corpus.d.mts +5 -2
  77. package/types/engine/content-charset.d.mts +5 -1
  78. package/types/engine/content-html.d.mts +5 -1
  79. package/types/engine/content-icons.d.mts +5 -1
  80. package/types/engine/content-images.d.mts +5 -1
  81. package/types/engine/content-index.d.mts +102 -18
  82. package/types/engine/content-links.d.mts +22 -8
  83. package/types/engine/content-session.d.mts +108 -0
  84. package/types/engine/content-sources.d.mts +32 -0
  85. package/types/engine/content-spans.d.mts +3 -5
  86. package/types/engine/content-tables.d.mts +2 -1
  87. package/types/engine/content-tasklists.d.mts +5 -1
  88. package/types/engine/culture-reach.d.mts +22 -2
  89. package/types/engine/data-keys.d.mts +7 -0
  90. package/types/engine/diagnostics.d.mts +10 -0
  91. package/types/engine/dictionary-layout.d.mts +35 -4
  92. package/types/engine/dictionary-rows.d.mts +9 -0
  93. package/types/engine/extension-attributes.d.mts +16 -2
  94. package/types/engine/first-use.d.mts +31 -0
  95. package/types/engine/foundry-entries.d.mts +14 -5
  96. package/types/engine/generate.d.mts +5 -1
  97. package/types/engine/heading-attributes.d.mts +2 -2
  98. package/types/engine/helpers.d.mts +33 -7
  99. package/types/engine/index.d.mts +1 -0
  100. package/types/engine/journals.d.mts +5 -2
  101. package/types/engine/language-terms.d.mts +7 -0
  102. package/types/engine/lexical-sections.d.mts +96 -0
  103. package/types/engine/markup-validation.d.mts +2 -1
  104. package/types/engine/name-markup.d.mts +176 -0
  105. package/types/engine/name-rows.d.mts +35 -0
  106. package/types/engine/names.d.mts +356 -0
  107. package/types/engine/note-addresses.d.mts +3 -3
  108. package/types/engine/note-state.d.mts +2 -1
  109. package/types/engine/path-charset.d.mts +16 -1
  110. package/types/engine/site-build.d.mts +6 -3
  111. package/types/engine/site-index.d.mts +3 -1
  112. package/types/engine/sql-tables.d.mts +39 -4
  113. package/types/engine/stub-lint.d.mts +3 -1
  114. package/types/engine/word-rows.d.mts +9 -7
@@ -59,6 +59,7 @@ import { cultureRecordProblems, cultureInheritedFields } from "./culture-parents
59
59
  import path from "node:path";
60
60
 
61
61
  import { matchAllOutsideCode } from "./code-fences.mjs";
62
+ import { splitFrontmatter } from "./diagnostics.mjs";
62
63
  import { expandContentTables } from "./content-tables.mjs";
63
64
  import { collectAnchors } from "./anchors.mjs";
64
65
  // The corpus, and everything derived from it, read from the one place that
@@ -90,7 +91,7 @@ import {
90
91
  import { loadForeignIndexes, noContentIndexPackages } from "./metadata-index.mjs";
91
92
  import { frontmatterWikilinks, slugify } from "./web-wikilinks.mjs";
92
93
  import { homepageAddresses, isHomepage } from "./homepage.mjs";
93
- import { parseWikilink, WIKILINK } from "./wikilink-syntax.mjs";
94
+ import { isEventAnchor, parseWikilink, WIKILINK } from "./wikilink-syntax.mjs";
94
95
  import { readQualifier } from "./wikilinks.mjs";
95
96
 
96
97
  /**
@@ -129,13 +130,11 @@ export function anchorsOf(body) {
129
130
  * not hypothetical: this module carried its own anchor reader until the anchor
130
131
  * anchor half, and the two disagreed about which anchors existed.
131
132
  *
132
- * **The file is opened for its bytes and nothing else.** The index deliberately
133
- * carries no note *body*, and a link lives in the body — so each note is read
134
- * once, here, for the prose. Everything *about* the note — its frontmatter, its
135
- * addresses, its anchors — is already in the record, and none of it is derived
136
- * a second time. That is one read per note rather than the two this module did
137
- * before, since the walk read the file and it then read it again for the raw
138
- * text.
133
+ * **Raw sources can come from the caller's capture.** The published index
134
+ * carries no note body, while a link lives in the prose. A caller that already
135
+ * captured the sources supplies `parsed`, so this pass uses those exact raw
136
+ * strings. Without a captured string, it opens the file for its bytes.
137
+ * Frontmatter, addresses and anchors still come from the records.
139
138
  *
140
139
  * The index holds each compiled document under its complete Address. Readable
141
140
  * content uses `note`, and system documents use their declared system.
@@ -153,14 +152,27 @@ export function anchorsOf(body) {
153
152
  * @param {object[]} [opts.records] - Index records the caller already derived,
154
153
  * so a command that also needs them — every one of them does, to answer its
155
154
  * `sql` tables — enumerates the corpus once rather than twice.
155
+ * @param {Map<string, {raw: string}>} [opts.parsed] - Sources captured alongside
156
+ * `records` in this invocation, keyed by absolute note path. A raw string is
157
+ * used verbatim; a missing string falls back to reading the file. The caller
158
+ * owns the map, which this function only reads.
156
159
  * @param {object[]} [opts.problems] - Collects the notes the index cannot
157
160
  * record, as diagnostics, instead of letting one of them abort the check
158
161
  * before it has reported anything else.
162
+ * @param {ReturnType<import("./metadata-index.mjs").loadForeignIndexes>} [opts.foreign] - Captured dependency address and reference views.
159
163
  * @returns {object} The notes, the index, and the resolvers built over it.
160
164
  */
161
165
  export function buildLinkIndex(
162
166
  contentBase,
163
- { config, skipDirectories, sqlTables, records, problems } = {},
167
+ {
168
+ config,
169
+ skipDirectories,
170
+ sqlTables,
171
+ records,
172
+ parsed,
173
+ problems,
174
+ foreign: capturedForeign,
175
+ } = {},
164
176
  ) {
165
177
  const notes = [];
166
178
  const frontmatterLinks = [];
@@ -221,8 +233,10 @@ export function buildLinkIndex(
221
233
  const absPath = noteFile(contentBase, record);
222
234
  // The raw text is kept beside the parsed body: a consumer's own checks
223
235
  // may need what frontmatter carried, which the body has dropped.
224
- const raw = fs.readFileSync(absPath, "utf8");
225
- const body = raw.replace(/^---\n[\s\S]*?\n---\n?/, "");
236
+ const capturedRaw = parsed?.get(absPath)?.raw;
237
+ const raw =
238
+ typeof capturedRaw === "string" ? capturedRaw : fs.readFileSync(absPath, "utf8");
239
+ const body = splitFrontmatter(raw)?.body ?? raw;
226
240
  const type = record.type.toLowerCase();
227
241
  const note = { file: absPath, rel, fm, body, raw, type };
228
242
  const hierarchyProblems = cultureRecordProblems(record);
@@ -243,7 +257,10 @@ export function buildLinkIndex(
243
257
  // same headings — the disagreement the anchor half removes.
244
258
  anchors.set(
245
259
  note,
246
- new Set((record.anchors ?? []).filter((a) => a.kind === "prose").map((a) => a.slug)),
260
+ new Set([
261
+ ...(record.anchors ?? []).filter((a) => a.kind === "prose").map((a) => a.slug),
262
+ ...(sqlTables?.get(absPath)?.anchors ?? []).map((a) => a.slug),
263
+ ]),
247
264
  );
248
265
  eventAnchorsOf.set(
249
266
  note,
@@ -292,9 +309,10 @@ export function buildLinkIndex(
292
309
  // is never checked at all.
293
310
  const localPackages = new Set([pkg]);
294
311
  const foreign =
295
- config ?
312
+ capturedForeign ??
313
+ (config ?
296
314
  loadForeignIndexes(config, localPackages)
297
- : { index: new Map(), packages: new Set(), stale: [] };
315
+ : { index: new Map(), packages: new Set(), stale: [] });
298
316
  for (const v of foreign.index.values()) if (v.type) types.add(v.type);
299
317
 
300
318
  // A stub counts. It publishes no page, so nothing may *link* to one, but its
@@ -331,7 +349,13 @@ export function buildLinkIndex(
331
349
  */
332
350
  function expandedBody(note) {
333
351
  const body = note.body;
334
- if (!/^[ \t]*(?:`{3,}|~{3,})[ \t]*(?:sql|pagelist)\b/im.test(body)) return body;
352
+ const prepared = sqlTables?.get(note.file);
353
+ if (
354
+ !prepared?.generated &&
355
+ !prepared?.lexical?.length &&
356
+ !/^[ \t]*(?:`{3,}|~{3,})[ \t]*(?:sql|pagelist)\b/im.test(body)
357
+ )
358
+ return body;
335
359
  return expandContentTables(body, {
336
360
  source: note.file,
337
361
  // A generated link is checked like an authored one, so both
@@ -940,18 +964,37 @@ export function auditLinks(index) {
940
964
  for (const note of notes) {
941
965
  for (const { target, rawTarget, anchor, text, occurrence, labelled } of linksOf(note)) {
942
966
  if (!anchor || !labelled) continue;
943
- const dest = target ? resolve(target) : note;
944
- // An unresolvable target is reported by the pass below; its anchor
945
- // has nothing to be checked against. Neither has a file: an asset
946
- // resolves but has no body, so it declares the empty set of anchors
947
- // and every `#section` on one is dead.
948
- if (!dest) continue;
949
- if (!(anchors.get(dest) ?? new Set()).has(slugify(anchor))) {
967
+ const local = target ? resolve(target) : note;
968
+ const foreign = !local && target ? manifestHit(target) : null;
969
+ // An unresolved address is reported below. A foreign sheet opens
970
+ // its system document, so only the readable note view names prose
971
+ // sections. A public note's GM target is an audience refusal below.
972
+ if (!local) {
973
+ if (!foreign || (foreign.gm && !isGmNote(note.fm))) continue;
974
+ const qualified = readQualifier(
975
+ target,
976
+ index.types,
977
+ index.packages,
978
+ index.noIndexPackages,
979
+ );
980
+ if (qualified?.system !== NOTE_SYSTEM) continue;
981
+ }
982
+ const dest = local ?? foreign;
983
+ const slug = local ? slugify(anchor) : anchor;
984
+ const event =
985
+ local ?
986
+ (index.eventAnchorsOf?.get(local) ?? new Set()).has(anchor)
987
+ : isEventAnchor(foreign, anchor);
988
+ // Fetched note anchors are the publisher's declaration, independent
989
+ // of a site URL or a Foundry UUID. An asset has no body and therefore
990
+ // declares no sections; an absent foreign anchor list does too.
991
+ const declared =
992
+ local ?
993
+ (anchors.get(local) ?? new Set()).has(slug)
994
+ : foreign.noteAnchors?.some((one) => one.slug === anchor && one.kind === "prose");
995
+ if (!declared || (!local && event)) {
950
996
  deadAnchors.push({
951
- reason:
952
- (index.eventAnchorsOf?.get(dest) ?? new Set()).has(anchor) ?
953
- "event-anchor"
954
- : "unknown-anchor",
997
+ reason: event ? "event-anchor" : "unknown-anchor",
955
998
  note,
956
999
  link: `${rawTarget}#${anchor}`,
957
1000
  // The two the shared `unknown-anchor` wording reads, in the
@@ -960,7 +1003,7 @@ export function auditLinks(index) {
960
1003
  // declare. Carrying them is what lets one sentence serve
961
1004
  // the checker and the build.
962
1005
  target: rawTarget,
963
- anchor: slugify(anchor),
1006
+ anchor: slug,
964
1007
  dest,
965
1008
  text,
966
1009
  occurrence,
@@ -0,0 +1,247 @@
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
+ /** One invocation's authored sources and context-specific prepared views. */
15
+ import path from "node:path";
16
+ import * as contentIndex from "./content-index.mjs";
17
+ import * as blocks from "./content-blocks.mjs";
18
+ import * as alerts from "./content-alerts.mjs";
19
+ import * as spans from "./content-spans.mjs";
20
+ import * as sql from "./sql-tables.mjs";
21
+ import { captureContentSources } from "./content-sources.mjs";
22
+ import { buildLinkIndex } from "./content-links.mjs";
23
+ import { buildContentLinkIndex } from "./helpers.mjs";
24
+ import { loadForeignIndexes } from "./metadata-index.mjs";
25
+ import { loadPackConfig } from "./pack-config.mjs";
26
+ import { routerFor } from "./pack-router.mjs";
27
+ import { lintNoteStates } from "./stub-lint.mjs";
28
+
29
+ /**
30
+ * @typedef {"index"|"lint"|"links"|"compile"} ContentTarget
31
+ * @typedef {(source: string) => {blocks: ReturnType<typeof blocks.scanBlocks>, alerts: ReturnType<typeof alerts.scanAlerts>, spans: ReturnType<typeof spans.scanSpans>}} ContentSyntax
32
+ * @typedef {{config?: import("../content-config.mjs").ContentBuildConfig, contentBase?: string, assetsBase?: string, skipDirectories?: readonly string[], router?: ReturnType<typeof routerFor>}} ContentSessionContext
33
+ * @typedef {{frontmatter: object|null, body: string, raw: string, bodyLine?: number, bodyColumn?: number}} ContentNoteSource
34
+ * @typedef {object} ContentSession
35
+ * @property {import("../content-config.mjs").ContentBuildConfig} config
36
+ * @property {string} contentBase
37
+ * @property {string} assetsBase
38
+ * @property {readonly string[]} skipDirectories
39
+ * @property {ReturnType<typeof routerFor>} router
40
+ * @property {ReturnType<typeof captureContentSources>} inventory
41
+ * @property {Map<string, {raw: string}>} sources
42
+ * @property {object[]} records
43
+ * @property {Map<string, ContentNoteSource>} parsed
44
+ * @property {object[]} problems
45
+ * @property {ReturnType<typeof loadForeignIndexes>} foreign
46
+ * @property {ContentSyntax} syntax
47
+ * @property {(options?: ContentSessionContext) => void} assertContext
48
+ * @property {(audience?: "all"|"public") => ReturnType<typeof sql.prepareTreeSqlTables>} sqlTables
49
+ * @property {() => ReturnType<typeof sql.prepareLinkSqlTables>} linkTables
50
+ * @property {(kind?: "lint"|"links") => Promise<ReturnType<typeof buildLinkIndex>>} audit
51
+ * @property {() => Promise<import("./content-index.mjs").PreparedContentIndex>} publication
52
+ * @property {() => ReturnType<import("./compile-corpus.mjs").buildCompileCorpus>} compileCorpus
53
+ * @property {(targets: readonly ContentTarget[]) => Promise<ContentSession>} prepare
54
+ * @property {() => void} dispose
55
+ */
56
+
57
+ /**
58
+ * The explicit runner's target vocabulary and execution order.
59
+ * @type {readonly ContentTarget[]}
60
+ */
61
+ export const CONTENT_TARGETS = Object.freeze(["index", "lint", "links", "compile"]);
62
+
63
+ /**
64
+ * Capture one source tree for index, lint, links and compilation.
65
+ * No source or parser state is persisted or reused by another invocation.
66
+ * Prepared SQL databases close within their existing preparation functions.
67
+ * @param {object} [options]
68
+ * @param {import("../content-config.mjs").ContentBuildConfig} [options.config] - Exact resolved configuration.
69
+ * @param {string} [options.contentBase] - Content root, defaulting to configuration.
70
+ * @param {string} [options.assetsBase] - Assets root, defaulting to configuration.
71
+ * @param {readonly string[]} [options.skipDirectories] - Resolved content scope.
72
+ * @param {ReturnType<typeof routerFor>} [options.router] - One router shared by this session's compile.
73
+ * @returns {Promise<ContentSession>} Captured sources and lazy prepared views; dispose after use.
74
+ */
75
+ export async function createContentSession({
76
+ config = loadPackConfig(),
77
+ contentBase = config.paths.content,
78
+ assetsBase = config.paths.assets,
79
+ skipDirectories = config.skipDirectories,
80
+ router = routerFor(config),
81
+ } = {}) {
82
+ const tree = path.resolve(contentBase);
83
+ const assets = path.resolve(assetsBase);
84
+ const scope = [...skipDirectories];
85
+ const inventory = captureContentSources(tree, assets, scope);
86
+ const parsed = new Map();
87
+ const problems = [];
88
+ const foreign = loadForeignIndexes(config, [config.contentPackage]);
89
+ const syntaxCache = new Map();
90
+ const prepared = new Map();
91
+ let disposed = false;
92
+ const active = () => {
93
+ if (disposed) throw new Error("Content session has been disposed");
94
+ };
95
+ const syntax = (source) => {
96
+ active();
97
+ const text = String(source ?? "");
98
+ if (!syntaxCache.has(text))
99
+ syntaxCache.set(text, {
100
+ blocks: blocks.scanBlocks(text),
101
+ alerts: alerts.scanAlerts(text),
102
+ spans: spans.scanSpans(text),
103
+ });
104
+ return syntaxCache.get(text);
105
+ };
106
+ const records = contentIndex.indexRecordsFor({
107
+ config,
108
+ contentBase: tree,
109
+ assetsBase: assets,
110
+ skipDirectories: scope,
111
+ sources: inventory.indexSources,
112
+ parsed,
113
+ problems,
114
+ foreignIndex: foreign.index,
115
+ syntax,
116
+ });
117
+ const memo = (key, make) => {
118
+ active();
119
+ if (!prepared.has(key)) prepared.set(key, Promise.resolve().then(make));
120
+ return prepared.get(key);
121
+ };
122
+ const session = {
123
+ config,
124
+ contentBase: tree,
125
+ assetsBase: assets,
126
+ skipDirectories: scope,
127
+ router,
128
+ inventory,
129
+ sources: inventory.sources,
130
+ records,
131
+ parsed,
132
+ problems,
133
+ foreign,
134
+ syntax,
135
+ /** Refuse use with a different configuration, root, scope or router. */
136
+ assertContext(options = {}) {
137
+ active();
138
+ if (
139
+ (options.config !== undefined && options.config !== config) ||
140
+ (options.contentBase !== undefined && path.resolve(options.contentBase) !== tree) ||
141
+ (options.assetsBase !== undefined && path.resolve(options.assetsBase) !== assets) ||
142
+ (options.skipDirectories !== undefined &&
143
+ JSON.stringify(options.skipDirectories) !== JSON.stringify(scope)) ||
144
+ (options.router !== undefined && options.router !== router)
145
+ )
146
+ throw new Error(
147
+ "Content session context does not match configuration, roots, scope or router",
148
+ );
149
+ },
150
+ async sqlTables(audience = "all") {
151
+ if (audience !== "all" && audience !== "public")
152
+ throw new Error(`Unknown SQL audience: ${audience}`);
153
+ return memo(`sql:${audience}`, () =>
154
+ sql.prepareTreeSqlTables(tree, {
155
+ config,
156
+ records,
157
+ parsed,
158
+ audience,
159
+ skipDirectories: scope,
160
+ foreignIndex: foreign.index,
161
+ syntax,
162
+ }),
163
+ );
164
+ },
165
+ async linkTables() {
166
+ return memo("link-tables", () =>
167
+ sql.prepareLinkSqlTables(tree, {
168
+ config,
169
+ records,
170
+ parsed,
171
+ skipDirectories: scope,
172
+ foreignIndex: foreign.index,
173
+ prepareAudience: (audience) => session.sqlTables(audience),
174
+ }),
175
+ );
176
+ },
177
+ async audit(kind = "links") {
178
+ if (kind !== "lint" && kind !== "links") throw new Error(`Unknown audit view: ${kind}`);
179
+ return memo(`audit:${kind}`, async () =>
180
+ buildLinkIndex(tree, {
181
+ config,
182
+ records,
183
+ parsed,
184
+ skipDirectories: scope,
185
+ foreign,
186
+ sqlTables:
187
+ kind === "lint" ?
188
+ await session.sqlTables("all")
189
+ : await session.linkTables(),
190
+ }),
191
+ );
192
+ },
193
+ async publication() {
194
+ return memo("publication", () =>
195
+ contentIndex.prepareContentIndex({ config, contentBase: tree, session }),
196
+ );
197
+ },
198
+ async compileCorpus() {
199
+ return memo("compile", async () => {
200
+ const sqlTables = await session.sqlTables("all");
201
+ return {
202
+ records,
203
+ parsed,
204
+ problems,
205
+ sqlTables,
206
+ syntax,
207
+ linkIndex: buildContentLinkIndex(tree, router, {
208
+ config,
209
+ records,
210
+ sqlTables,
211
+ foreign,
212
+ }),
213
+ };
214
+ });
215
+ },
216
+ /** Complete selected preparations before a caller starts a writer. */
217
+ async prepare(targets) {
218
+ active();
219
+ for (const target of targets)
220
+ if (!CONTENT_TARGETS.includes(target))
221
+ throw new Error(`Unknown content target: ${target}`);
222
+ if (targets.includes("index") || targets.includes("compile")) {
223
+ const stateErrors = lintNoteStates(tree, {
224
+ records,
225
+ parsed,
226
+ contentPackage: config.contentPackage,
227
+ }).findings.filter((finding) => finding.severity === "error");
228
+ const findings = [...problems, ...stateErrors];
229
+ if (findings.length)
230
+ throw Object.assign(new Error("Content preparation failed"), { findings });
231
+ }
232
+ for (const target of CONTENT_TARGETS.filter((target) => targets.includes(target))) {
233
+ if (target === "index") await session.publication();
234
+ else if (target === "compile") await session.compileCorpus();
235
+ else await session.audit(target);
236
+ }
237
+ return session;
238
+ },
239
+ /** Release this invocation's derived views; captured inputs stay unchanged. */
240
+ dispose() {
241
+ disposed = true;
242
+ prepared.clear();
243
+ syntaxCache.clear();
244
+ },
245
+ };
246
+ return session;
247
+ }
@@ -0,0 +1,110 @@
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
+ /** Physical source capture, independent of whether a note can be indexed. */
15
+ import fs from "node:fs";
16
+ import path from "node:path";
17
+ import log from "loglevel";
18
+ import { DEPLOYED_ASSET_TYPES } from "./asset-types.mjs";
19
+
20
+ const TEXT_EXTENSIONS = new Set([".md", ".markdown", ".yaml", ".yml", ".json"]);
21
+
22
+ /**
23
+ * Capture exact text once and retain directory entries for physical checks.
24
+ * The index walk and raw check walks retain their established traversal orders.
25
+ * @param {string} contentBase - Absolute content root.
26
+ * @param {string} assetsBase - Absolute assets root.
27
+ * @param {readonly string[]} skipDirectories - Resolved directory scope.
28
+ * @returns {{sources: Map<string, {raw: string}>, indexSources: Map<string, {raw: string}>, paths: Array<{root: string, file: string, name: string, directory: boolean}>}} Captured input and physical walk order.
29
+ */
30
+ export function captureContentSources(contentBase, assetsBase, skipDirectories) {
31
+ const sources = new Map();
32
+ const directories = new Map();
33
+ const readErrors = new Map();
34
+ const paths = [];
35
+ const skip = new Set(skipDirectories);
36
+ const walk = (dir, text, includeHidden = false, root = dir) => {
37
+ if (!fs.existsSync(dir)) return;
38
+ let entries;
39
+ try {
40
+ entries = fs.readdirSync(dir, { withFileTypes: true });
41
+ } catch (error) {
42
+ if (text) log.warn(`Cannot read directory ${dir}: ${error.message}`);
43
+ return;
44
+ }
45
+ if (text) directories.set(dir, entries);
46
+ for (const entry of entries) {
47
+ if (
48
+ (!includeHidden && entry.name.startsWith(".")) ||
49
+ (entry.isDirectory() && skip.has(entry.name))
50
+ )
51
+ continue;
52
+ const file = path.join(dir, entry.name);
53
+ paths.push({ root, file, name: entry.name, directory: entry.isDirectory() });
54
+ if (entry.isDirectory()) walk(file, text, includeHidden, root);
55
+ else if (text && TEXT_EXTENSIONS.has(path.extname(entry.name).toLowerCase())) {
56
+ try {
57
+ sources.set(file, { raw: fs.readFileSync(file, "utf8") });
58
+ } catch (error) {
59
+ // Selected Markdown must retain the index reader's refusal.
60
+ // Only the independent raw-checker inputs skip read errors.
61
+ if (entry.isFile() && entry.name.endsWith(".md")) readErrors.set(file, error);
62
+ }
63
+ }
64
+ }
65
+ };
66
+ // The index scope includes dot paths; individual raw checks
67
+ // filter those paths through capturedSources below.
68
+ walk(contentBase, true, true);
69
+ for (const root of new Set(
70
+ DEPLOYED_ASSET_TYPES.map((entry) => path.resolve(assetsBase, entry.root)),
71
+ ))
72
+ if (root !== path.resolve(contentBase)) walk(root, false);
73
+ const indexSources = new Map();
74
+ const stack = [contentBase];
75
+ while (stack.length) {
76
+ const dir = stack.pop();
77
+ for (const entry of directories.get(dir) ?? []) {
78
+ if (entry.isDirectory() && skip.has(entry.name)) continue;
79
+ const file = path.join(dir, entry.name);
80
+ if (entry.isDirectory()) stack.push(file);
81
+ else if (readErrors.has(file)) throw readErrors.get(file);
82
+ else if (entry.isFile() && entry.name.endsWith(".md") && sources.has(file))
83
+ indexSources.set(file, sources.get(file));
84
+ }
85
+ }
86
+ return { sources, indexSources, paths };
87
+ }
88
+
89
+ /**
90
+ * Select the exact physical scope of a raw checker from a captured map.
91
+ * @param {string} contentBase - Content root.
92
+ * @param {Map<string, {raw: string}>} sources - Captured physical sources.
93
+ * @param {readonly string[]} skipDirectories - Directory names to ignore.
94
+ * @param {Set<string>} extensions - Lowercase extensions accepted by the caller.
95
+ */
96
+ export function* capturedSources(contentBase, sources, skipDirectories, extensions) {
97
+ const root = path.resolve(contentBase);
98
+ for (const [file, source] of sources) {
99
+ const relative = path.relative(root, file);
100
+ if (relative.startsWith(`..${path.sep}`) || path.isAbsolute(relative)) continue;
101
+ if (
102
+ relative
103
+ .split(path.sep)
104
+ .some((name) => name.startsWith(".") || skipDirectories.includes(name))
105
+ )
106
+ continue;
107
+ if (extensions.has(path.extname(file).toLowerCase()) && typeof source.raw === "string")
108
+ yield [file, source.raw];
109
+ }
110
+ }