@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.
- package/CHANGELOG.md +53 -0
- package/README.md +2 -0
- package/bin/content-commands.mjs +207 -70
- package/docs/api.md +646 -78
- package/docs/authoring/first-note.md +1 -1
- package/docs/authoring/links-and-markup.md +253 -102
- package/docs/commands.md +111 -13
- package/docs/content-format.md +7 -2
- package/docs/diagnostics.md +1 -1
- package/docs/guides/packs.md +2 -1
- package/docs/guides/reference-pages.md +86 -63
- package/docs/reference/format-details.md +398 -137
- package/docs/reference/note-types.md +188 -174
- package/engine/anchors.mjs +15 -26
- package/engine/base-compiler.mjs +6 -4
- package/engine/changelog-group.mjs +58 -34
- package/engine/changelog-lint.mjs +6 -2
- package/engine/compendiums.mjs +15 -1
- package/engine/compile-corpus.mjs +17 -3
- package/engine/content-charset.mjs +13 -1
- package/engine/content-format.yaml +1 -0
- package/engine/content-html.mjs +18 -4
- package/engine/content-icons.mjs +15 -1
- package/engine/content-images.mjs +28 -9
- package/engine/content-index.mjs +496 -16
- package/engine/content-links.mjs +70 -27
- package/engine/content-session.mjs +247 -0
- package/engine/content-sources.mjs +110 -0
- package/engine/content-spans.mjs +87 -8
- package/engine/content-tables.mjs +9 -4
- package/engine/content-tasklists.mjs +20 -4
- package/engine/culture-reach.mjs +61 -34
- package/engine/data-keys.mjs +3 -1
- package/engine/diagnostics.mjs +17 -6
- package/engine/dictionary-layout.mjs +151 -19
- package/engine/dictionary-rows.mjs +129 -79
- package/engine/extension-attributes.mjs +28 -5
- package/engine/first-use.mjs +371 -0
- package/engine/foundry-entries.mjs +29 -8
- package/engine/generate.mjs +13 -4
- package/engine/glossary-entries.mjs +11 -11
- package/engine/heading-attributes.mjs +21 -28
- package/engine/helpers.mjs +54 -19
- package/engine/index-records.mjs +1 -0
- package/engine/index.mjs +3 -0
- package/engine/journals.mjs +13 -6
- package/engine/language-data.mjs +55 -2
- package/engine/language-rows.mjs +4 -4
- package/engine/language-terms.mjs +90 -4
- package/engine/language-words.mjs +2 -2
- package/engine/lexical-sections.mjs +247 -0
- package/engine/markup-validation.mjs +7 -2
- package/engine/metadata-index.mjs +1 -0
- package/engine/name-markup.mjs +114 -0
- package/engine/name-rows.mjs +70 -0
- package/engine/names.mjs +460 -0
- package/engine/note-addresses.mjs +25 -4
- package/engine/note-claims.mjs +2 -0
- package/engine/note-state.mjs +9 -3
- package/engine/note-vocabulary.mjs +7 -6
- package/engine/path-charset.mjs +24 -1
- package/engine/pdf-build.mjs +9 -9
- package/engine/pdf-render.mjs +15 -1
- package/engine/site-build.mjs +16 -2
- package/engine/site-index.mjs +5 -1
- package/engine/sql-tables.mjs +324 -44
- package/engine/stub-lint.mjs +10 -5
- package/engine/web-wikilinks.mjs +8 -6
- package/engine/word-rows.mjs +20 -12
- package/engine/word-terms.mjs +15 -6
- package/engine/yaml-lint.mjs +3 -5
- package/hugo-theme/static/css/style.css +4 -0
- package/package.json +1 -1
- package/types/engine/anchors.d.mts +4 -2
- package/types/engine/compendiums.d.mts +5 -1
- package/types/engine/compile-corpus.d.mts +5 -2
- package/types/engine/content-charset.d.mts +5 -1
- package/types/engine/content-html.d.mts +5 -1
- package/types/engine/content-icons.d.mts +5 -1
- package/types/engine/content-images.d.mts +5 -1
- package/types/engine/content-index.d.mts +102 -18
- package/types/engine/content-links.d.mts +22 -8
- package/types/engine/content-session.d.mts +108 -0
- package/types/engine/content-sources.d.mts +32 -0
- package/types/engine/content-spans.d.mts +3 -5
- package/types/engine/content-tables.d.mts +2 -1
- package/types/engine/content-tasklists.d.mts +5 -1
- package/types/engine/culture-reach.d.mts +22 -2
- package/types/engine/data-keys.d.mts +7 -0
- package/types/engine/diagnostics.d.mts +10 -0
- package/types/engine/dictionary-layout.d.mts +35 -4
- package/types/engine/dictionary-rows.d.mts +9 -0
- package/types/engine/extension-attributes.d.mts +16 -2
- package/types/engine/first-use.d.mts +31 -0
- package/types/engine/foundry-entries.d.mts +14 -5
- package/types/engine/generate.d.mts +5 -1
- package/types/engine/heading-attributes.d.mts +2 -2
- package/types/engine/helpers.d.mts +33 -7
- package/types/engine/index.d.mts +1 -0
- package/types/engine/journals.d.mts +5 -2
- package/types/engine/language-terms.d.mts +7 -0
- package/types/engine/lexical-sections.d.mts +96 -0
- package/types/engine/markup-validation.d.mts +2 -1
- package/types/engine/name-markup.d.mts +176 -0
- package/types/engine/name-rows.d.mts +35 -0
- package/types/engine/names.d.mts +356 -0
- package/types/engine/note-addresses.d.mts +3 -3
- package/types/engine/note-state.d.mts +2 -1
- package/types/engine/path-charset.d.mts +16 -1
- package/types/engine/site-build.d.mts +6 -3
- package/types/engine/site-index.d.mts +3 -1
- package/types/engine/sql-tables.d.mts +39 -4
- package/types/engine/stub-lint.d.mts +3 -1
- package/types/engine/word-rows.d.mts +9 -7
package/engine/content-links.mjs
CHANGED
|
@@ -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
|
-
* **
|
|
133
|
-
* carries no note
|
|
134
|
-
*
|
|
135
|
-
*
|
|
136
|
-
*
|
|
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
|
-
{
|
|
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
|
|
225
|
-
const
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
944
|
-
|
|
945
|
-
//
|
|
946
|
-
//
|
|
947
|
-
//
|
|
948
|
-
if (!
|
|
949
|
-
|
|
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:
|
|
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
|
+
}
|