@heroiclands/package-build 23.9.0 → 23.11.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 +33 -0
- package/README.md +2 -0
- package/bin/content-commands.mjs +197 -68
- package/docs/api.md +365 -70
- package/docs/authoring/first-note.md +10 -0
- package/docs/authoring/frontmatter.md +1 -1
- package/docs/authoring/links-and-markup.md +186 -49
- package/docs/commands.md +92 -3
- package/docs/content-format.md +10 -3
- package/docs/design.md +18 -0
- package/docs/guides/book.md +7 -0
- package/docs/guides/site.md +7 -0
- package/docs/reference/format-details.md +186 -15
- package/docs/reference/note-types.md +4 -4
- package/engine/anchors.mjs +43 -9
- 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 +14 -1
- package/engine/content-charset.mjs +13 -1
- 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 +102 -23
- package/engine/content-links.mjs +68 -30
- package/engine/content-session.mjs +247 -0
- package/engine/content-sources.mjs +110 -0
- package/engine/content-spans.mjs +118 -14
- package/engine/content-tables.mjs +1 -0
- package/engine/content-tasklists.mjs +20 -4
- package/engine/diagnostics.mjs +17 -6
- package/engine/extension-attributes.mjs +28 -5
- package/engine/first-use.mjs +371 -0
- package/engine/foundry-entries.mjs +12 -9
- package/engine/generate.mjs +13 -4
- package/engine/helpers.mjs +47 -16
- package/engine/index-records.mjs +1 -0
- package/engine/index.mjs +3 -0
- package/engine/journals.mjs +9 -5
- package/engine/language-data.mjs +2 -2
- package/engine/language-rows.mjs +4 -4
- package/engine/language-terms.mjs +4 -4
- package/engine/lexical-sections.mjs +5 -5
- 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/path-charset.mjs +24 -1
- package/engine/pdf-render.mjs +15 -1
- package/engine/site-index.mjs +25 -2
- package/engine/sql-tables.mjs +76 -15
- package/engine/stub-lint.mjs +4 -3
- package/engine/web-wikilinks.mjs +33 -12
- package/engine/wikilink-syntax.mjs +65 -8
- package/engine/wikilinks.mjs +29 -11
- package/engine/yaml-lint.mjs +3 -5
- package/hugo-theme/layouts/partials/footer.html +2 -0
- package/hugo-theme/static/css/style.css +20 -0
- package/hugo-theme/static/js/inline-spoilers.js +23 -0
- package/package.json +1 -1
- package/types/engine/anchors.d.mts +6 -3
- 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 +39 -19
- 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 +8 -6
- package/types/engine/content-tasklists.d.mts +5 -1
- package/types/engine/diagnostics.d.mts +10 -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 +11 -4
- package/types/engine/generate.d.mts +5 -1
- package/types/engine/helpers.d.mts +30 -7
- package/types/engine/index.d.mts +1 -0
- package/types/engine/journals.d.mts +5 -2
- package/types/engine/lexical-sections.d.mts +3 -2
- 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/path-charset.d.mts +16 -1
- package/types/engine/site-build.d.mts +2 -2
- package/types/engine/site-index.d.mts +3 -1
- package/types/engine/sql-tables.d.mts +7 -2
- package/types/engine/stub-lint.d.mts +3 -1
- package/types/engine/wikilink-syntax.d.mts +40 -3
|
@@ -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
|
+
}
|
package/engine/content-spans.mjs
CHANGED
|
@@ -17,24 +17,45 @@ import { slugify } from "./content-slug.mjs";
|
|
|
17
17
|
import { scanPoetry } from "./content-poetry.mjs";
|
|
18
18
|
import footnotePlugin from "markdown-it-footnote";
|
|
19
19
|
import { parseExtensionAttributes, refusedAttributes } from "./extension-attributes.mjs";
|
|
20
|
+
import { inlineText } from "./name-markup.mjs";
|
|
20
21
|
|
|
21
22
|
/** Add bracket spans after recognized Markdown links and footnotes.
|
|
22
23
|
* @param {import("markdown-it")} md - Markdown parser to extend.
|
|
24
|
+
* @param {object} [options] - Surface presentation.
|
|
25
|
+
* @param {boolean} [options.spoilers] - Add visible static Secret labels (Foundry).
|
|
23
26
|
*/
|
|
24
|
-
export function spanMarkdownPlugin(md) {
|
|
27
|
+
export function spanMarkdownPlugin(md, { spoilers = false } = {}) {
|
|
28
|
+
if (spoilers) {
|
|
29
|
+
md.renderer.rules.span_open = (tokens, index, options, env, renderer) => {
|
|
30
|
+
const token = tokens[index];
|
|
31
|
+
const secret = (token.attrGet("class") ?? "").split(/\s+/).includes("secret");
|
|
32
|
+
return (
|
|
33
|
+
renderer.renderToken(tokens, index, options) +
|
|
34
|
+
(secret ? '<strong class="inline-secret-label">Secret:</strong> ' : "")
|
|
35
|
+
);
|
|
36
|
+
};
|
|
37
|
+
}
|
|
25
38
|
md.inline.ruler.after("link", "bracket_span", (state, silent) => {
|
|
26
39
|
const start = state.pos;
|
|
27
40
|
if (
|
|
28
41
|
state.src[start] !== "[" ||
|
|
42
|
+
// Raw wikilinks are handled before spans render. Refuse their
|
|
43
|
+
// adjacent closing brackets without parsing every label twice.
|
|
44
|
+
(state.src[start + 1] === "[" &&
|
|
45
|
+
state.src[state.src.indexOf("]", start + 2) + 1] === "]") ||
|
|
29
46
|
/@[A-Za-z]+$/.test(state.src.slice(0, start)) ||
|
|
30
|
-
state.src[start + 1] === "[" ||
|
|
31
47
|
state.src[start + 1] === "^" ||
|
|
32
48
|
state.src[start + 1] === "!" ||
|
|
33
49
|
["!", "[", "]"].includes(state.src[start - 1])
|
|
34
50
|
)
|
|
35
51
|
return false;
|
|
36
52
|
const end = state.md.helpers.parseLinkLabel(state, start, false);
|
|
37
|
-
if (
|
|
53
|
+
if (
|
|
54
|
+
end < 0 ||
|
|
55
|
+
["(", "["].includes(state.src[end + 1]) ||
|
|
56
|
+
(state.src[start + 1] === "[" && state.src[end + 1] !== "{")
|
|
57
|
+
)
|
|
58
|
+
return false;
|
|
38
59
|
let finish = end + 1;
|
|
39
60
|
let parsed = { id: "", classes: [], values: {}, problems: [] };
|
|
40
61
|
if (state.src[finish] === "{") {
|
|
@@ -57,7 +78,9 @@ export function spanMarkdownPlugin(md) {
|
|
|
57
78
|
if (close === state.posMax)
|
|
58
79
|
parsed.problems.push("span attributes need closing {…} braces");
|
|
59
80
|
else {
|
|
60
|
-
parsed = parseExtensionAttributes(state.src.slice(finish + 1, close)
|
|
81
|
+
parsed = parseExtensionAttributes(state.src.slice(finish + 1, close), {
|
|
82
|
+
positions: true,
|
|
83
|
+
});
|
|
61
84
|
finish = close + 1;
|
|
62
85
|
}
|
|
63
86
|
}
|
|
@@ -73,6 +96,17 @@ export function spanMarkdownPlugin(md) {
|
|
|
73
96
|
end: finish,
|
|
74
97
|
text: state.src.slice(start + 1, end),
|
|
75
98
|
problems,
|
|
99
|
+
attributePositions: Object.fromEntries(
|
|
100
|
+
Object.entries(parsed.positions ?? {}).map(([key, positions]) => [
|
|
101
|
+
key,
|
|
102
|
+
Object.fromEntries(
|
|
103
|
+
Object.entries(positions).map(([part, range]) => [
|
|
104
|
+
part,
|
|
105
|
+
{ start: range.start + end + 2, end: range.end + end + 2 },
|
|
106
|
+
]),
|
|
107
|
+
),
|
|
108
|
+
]),
|
|
109
|
+
),
|
|
76
110
|
};
|
|
77
111
|
if (parsed.id) token.attrSet("id", slugify(parsed.id));
|
|
78
112
|
if (parsed.classes.length) token.attrSet("class", parsed.classes.join(" "));
|
|
@@ -84,6 +118,11 @@ export function spanMarkdownPlugin(md) {
|
|
|
84
118
|
if (child.meta?.span) {
|
|
85
119
|
child.meta.start += start + 1;
|
|
86
120
|
child.meta.end += start + 1;
|
|
121
|
+
for (const positions of Object.values(child.meta.attributePositions ?? {}))
|
|
122
|
+
for (const range of Object.values(positions)) {
|
|
123
|
+
range.start += start + 1;
|
|
124
|
+
range.end += start + 1;
|
|
125
|
+
}
|
|
87
126
|
}
|
|
88
127
|
state.tokens.push(...nested);
|
|
89
128
|
state.push("span_close", "span", -1);
|
|
@@ -98,6 +137,7 @@ const parser = new MarkdownIt({ html: true })
|
|
|
98
137
|
.use(spanMarkdownPlugin);
|
|
99
138
|
/** Read span anchors and located attribute diagnostics outside literal code.
|
|
100
139
|
* @param {string} source - Authored Markdown.
|
|
140
|
+
* @returns {import("./name-markup.mjs").SpanScan} Actual spans, explicit names and located diagnostics.
|
|
101
141
|
*/
|
|
102
142
|
export function scanSpans(source) {
|
|
103
143
|
const text = String(source ?? ""),
|
|
@@ -112,14 +152,28 @@ export function scanSpans(source) {
|
|
|
112
152
|
starts.push(offset);
|
|
113
153
|
offset += line.length + 1;
|
|
114
154
|
}
|
|
155
|
+
const located = (start, end) => {
|
|
156
|
+
let lo = 0,
|
|
157
|
+
hi = starts.length;
|
|
158
|
+
while (lo + 1 < hi) {
|
|
159
|
+
const mid = (lo + hi) >>> 1;
|
|
160
|
+
if (starts[mid] <= start) lo = mid;
|
|
161
|
+
else hi = mid;
|
|
162
|
+
}
|
|
163
|
+
return { start, end, line: lo + 1, column: start - starts[lo] + 1 };
|
|
164
|
+
};
|
|
115
165
|
const collect = (children, position) => {
|
|
116
|
-
for (const child of children ?? []) {
|
|
166
|
+
for (const [index, child] of (children ?? []).entries()) {
|
|
117
167
|
if (!child.meta?.span) continue;
|
|
118
168
|
const start = position(child.meta.start),
|
|
119
169
|
end = position(child.meta.end);
|
|
120
|
-
const
|
|
121
|
-
|
|
122
|
-
|
|
170
|
+
const { line, column } = located(start, end);
|
|
171
|
+
let depth = 1,
|
|
172
|
+
close = index + 1;
|
|
173
|
+
for (; close < children.length; close++) {
|
|
174
|
+
if (children[close].type === "span_open") depth++;
|
|
175
|
+
if (children[close].type === "span_close" && --depth === 0) break;
|
|
176
|
+
}
|
|
123
177
|
spans.push({
|
|
124
178
|
id: child.attrGet("id") ?? "",
|
|
125
179
|
line,
|
|
@@ -127,13 +181,50 @@ export function scanSpans(source) {
|
|
|
127
181
|
start,
|
|
128
182
|
end,
|
|
129
183
|
text: child.meta.text,
|
|
184
|
+
contentEnd: position(child.meta.start + 1 + child.meta.text.length),
|
|
185
|
+
visible: inlineText(children.slice(index + 1, close)),
|
|
130
186
|
attrs: child.attrs ?? [],
|
|
187
|
+
attributePositions: Object.fromEntries(
|
|
188
|
+
Object.entries(child.meta.attributePositions ?? {}).map(([key, positions]) => [
|
|
189
|
+
key,
|
|
190
|
+
Object.fromEntries(
|
|
191
|
+
Object.entries(positions).map(([part, range]) => [
|
|
192
|
+
part,
|
|
193
|
+
located(position(range.start), position(range.end)),
|
|
194
|
+
]),
|
|
195
|
+
),
|
|
196
|
+
]),
|
|
197
|
+
),
|
|
131
198
|
});
|
|
132
199
|
for (const message of child.meta.problems) errors.push({ line, column, message });
|
|
133
200
|
}
|
|
134
201
|
};
|
|
202
|
+
let row;
|
|
135
203
|
for (const token of tokens) {
|
|
136
|
-
if (token.type
|
|
204
|
+
if (token.type === "tr_open" && token.map) {
|
|
205
|
+
const line = lines[token.map[0]],
|
|
206
|
+
offsets = [];
|
|
207
|
+
let content = "";
|
|
208
|
+
// The existing table rule removes a backslash immediately before
|
|
209
|
+
// a pipe. Retain that projection's original character positions;
|
|
210
|
+
// actual row/cell tokens still own all table parsing and trimming.
|
|
211
|
+
for (let i = 0; i < line.length; i++) {
|
|
212
|
+
if (line[i] === "\\" && line[i + 1] === "|") continue;
|
|
213
|
+
offsets.push(i);
|
|
214
|
+
content += line[i];
|
|
215
|
+
}
|
|
216
|
+
offsets.push(line.length);
|
|
217
|
+
row = { content, offsets, line: token.map[0], cursor: 0 };
|
|
218
|
+
} else if (token.type === "tr_close") row = undefined;
|
|
219
|
+
if (token.type !== "inline") continue;
|
|
220
|
+
if (!token.map) {
|
|
221
|
+
if (!row) continue;
|
|
222
|
+
const column = row.content.indexOf(token.content, row.cursor);
|
|
223
|
+
if (column < 0) continue;
|
|
224
|
+
row.cursor = column + token.content.length;
|
|
225
|
+
collect(token.children, (pos) => starts[row.line] + row.offsets[column + pos]);
|
|
226
|
+
continue;
|
|
227
|
+
}
|
|
137
228
|
const contentLines = token.content.split("\n"),
|
|
138
229
|
positions = [];
|
|
139
230
|
let local = 0;
|
|
@@ -159,7 +250,15 @@ export function scanSpans(source) {
|
|
|
159
250
|
}
|
|
160
251
|
spans.sort((a, b) => a.start - b.start);
|
|
161
252
|
errors.sort((a, b) => a.line - b.line || a.column - b.column);
|
|
162
|
-
return {
|
|
253
|
+
return {
|
|
254
|
+
spans,
|
|
255
|
+
names: spans.filter((span) =>
|
|
256
|
+
span.attrs.some(
|
|
257
|
+
([key, value]) => key === "class" && value.split(/\s+/).includes("name"),
|
|
258
|
+
),
|
|
259
|
+
),
|
|
260
|
+
errors,
|
|
261
|
+
};
|
|
163
262
|
}
|
|
164
263
|
|
|
165
264
|
/** Emit span tags while leaving their inline content as Markdown for Hugo.
|
|
@@ -180,10 +279,15 @@ export function renderSpans(source) {
|
|
|
180
279
|
)
|
|
181
280
|
.sort((a, b) => b.start - a.start)) {
|
|
182
281
|
const attrs = span.attrs.map(([key, value]) => ` ${key}="${escape(value)}"`).join("");
|
|
183
|
-
markdown
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
282
|
+
const inner = renderSpans(markdown.slice(span.start + 1, span.contentEnd)).markdown;
|
|
283
|
+
const secret = span.attrs.some(
|
|
284
|
+
([key, value]) => key === "class" && value.split(/\s+/).includes("secret"),
|
|
285
|
+
);
|
|
286
|
+
const content =
|
|
287
|
+
secret ?
|
|
288
|
+
`<span${attrs} data-inline-secret><button type="button" class="inline-secret-toggle" aria-label="Show spoiler" aria-expanded="false">Show spoiler</button><span class="inline-secret-content" hidden>${inner}</span></span>`
|
|
289
|
+
: `<span${attrs}>${inner}</span>`;
|
|
290
|
+
markdown = markdown.slice(0, span.start) + content + markdown.slice(span.end);
|
|
187
291
|
}
|
|
188
292
|
return { markdown, errors };
|
|
189
293
|
}
|
|
@@ -43,9 +43,10 @@
|
|
|
43
43
|
|
|
44
44
|
import fs from "node:fs";
|
|
45
45
|
import path from "node:path";
|
|
46
|
+
import { capturedSources } from "./content-sources.mjs";
|
|
46
47
|
|
|
47
48
|
import { matchAllOutsideCode } from "./code-fences.mjs";
|
|
48
|
-
import { positionInBody } from "./diagnostics.mjs";
|
|
49
|
+
import { positionInBody, splitFrontmatter } from "./diagnostics.mjs";
|
|
49
50
|
|
|
50
51
|
/**
|
|
51
52
|
* A list item's marker, immediately followed by `[ ]` or `[x]` and a space —
|
|
@@ -114,15 +115,30 @@ export function checkTaskLists(body, file, { bodyLine = 1, bodyColumn = 1 } = {}
|
|
|
114
115
|
* @param {object} [opts]
|
|
115
116
|
* @param {readonly string[]} [opts.skipDirectories] - Directory names to ignore
|
|
116
117
|
* in addition to the dot-directories always skipped.
|
|
118
|
+
* @param {Map<string, {raw: string}>} [opts.sources] - Exact captured source strings; omitted, walks and reads the tree.
|
|
117
119
|
* @returns {{findings: Array<{file: string, line: number, column: number,
|
|
118
120
|
* severity: "warning", message: string}>, files: number}} The findings, and
|
|
119
121
|
* how many files were read.
|
|
120
122
|
*/
|
|
121
|
-
export function lintContentTaskLists(contentBase, { skipDirectories = [] } = {}) {
|
|
123
|
+
export function lintContentTaskLists(contentBase, { skipDirectories = [], sources } = {}) {
|
|
122
124
|
const skip = new Set(skipDirectories);
|
|
123
125
|
/** @type {Array<{file: string, line: number, column: number, severity: "warning", message: string}>} */
|
|
124
126
|
const findings = [];
|
|
125
127
|
let files = 0;
|
|
128
|
+
if (sources) {
|
|
129
|
+
for (const [full, content] of capturedSources(
|
|
130
|
+
contentBase,
|
|
131
|
+
sources,
|
|
132
|
+
skipDirectories,
|
|
133
|
+
new Set([".md", ".markdown"]),
|
|
134
|
+
)) {
|
|
135
|
+
files++;
|
|
136
|
+
findings.push(
|
|
137
|
+
...checkTaskLists(...bodyOf(content, path.relative(process.cwd(), full))),
|
|
138
|
+
);
|
|
139
|
+
}
|
|
140
|
+
return { findings, files };
|
|
141
|
+
}
|
|
126
142
|
|
|
127
143
|
/** @param {string} dir - Directory to descend into. */
|
|
128
144
|
const walk = (dir) => {
|
|
@@ -171,10 +187,10 @@ export function lintContentTaskLists(contentBase, { skipDirectories = [] } = {})
|
|
|
171
187
|
* arguments {@link checkTaskLists} takes.
|
|
172
188
|
*/
|
|
173
189
|
function bodyOf(content, file) {
|
|
174
|
-
const match = content
|
|
190
|
+
const match = splitFrontmatter(content);
|
|
175
191
|
if (!match) return [content, file, { bodyLine: 1, bodyColumn: 1 }];
|
|
176
192
|
|
|
177
|
-
const raw = match
|
|
193
|
+
const raw = match.body;
|
|
178
194
|
const body = raw.trim();
|
|
179
195
|
const bodyStart = content.length - raw.length + (raw.length - raw.trimStart().length);
|
|
180
196
|
const before = content.slice(0, bodyStart);
|