@heroiclands/package-build 23.9.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 +26 -0
- package/README.md +2 -0
- package/bin/content-commands.mjs +197 -68
- package/docs/api.md +336 -68
- package/docs/authoring/links-and-markup.md +114 -47
- package/docs/commands.md +92 -3
- package/docs/content-format.md +6 -1
- package/docs/reference/format-details.md +129 -14
- package/docs/reference/note-types.md +4 -4
- package/engine/anchors.mjs +8 -6
- 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 +27 -12
- 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 +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 +43 -14
- 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 +5 -1
- package/engine/sql-tables.mjs +76 -15
- package/engine/stub-lint.mjs +4 -3
- 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 +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 +3 -5
- 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
|
@@ -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,6 +17,7 @@ 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.
|
|
@@ -57,7 +58,9 @@ export function spanMarkdownPlugin(md) {
|
|
|
57
58
|
if (close === state.posMax)
|
|
58
59
|
parsed.problems.push("span attributes need closing {…} braces");
|
|
59
60
|
else {
|
|
60
|
-
parsed = parseExtensionAttributes(state.src.slice(finish + 1, close)
|
|
61
|
+
parsed = parseExtensionAttributes(state.src.slice(finish + 1, close), {
|
|
62
|
+
positions: true,
|
|
63
|
+
});
|
|
61
64
|
finish = close + 1;
|
|
62
65
|
}
|
|
63
66
|
}
|
|
@@ -73,6 +76,17 @@ export function spanMarkdownPlugin(md) {
|
|
|
73
76
|
end: finish,
|
|
74
77
|
text: state.src.slice(start + 1, end),
|
|
75
78
|
problems,
|
|
79
|
+
attributePositions: Object.fromEntries(
|
|
80
|
+
Object.entries(parsed.positions ?? {}).map(([key, positions]) => [
|
|
81
|
+
key,
|
|
82
|
+
Object.fromEntries(
|
|
83
|
+
Object.entries(positions).map(([part, range]) => [
|
|
84
|
+
part,
|
|
85
|
+
{ start: range.start + end + 2, end: range.end + end + 2 },
|
|
86
|
+
]),
|
|
87
|
+
),
|
|
88
|
+
]),
|
|
89
|
+
),
|
|
76
90
|
};
|
|
77
91
|
if (parsed.id) token.attrSet("id", slugify(parsed.id));
|
|
78
92
|
if (parsed.classes.length) token.attrSet("class", parsed.classes.join(" "));
|
|
@@ -84,6 +98,11 @@ export function spanMarkdownPlugin(md) {
|
|
|
84
98
|
if (child.meta?.span) {
|
|
85
99
|
child.meta.start += start + 1;
|
|
86
100
|
child.meta.end += start + 1;
|
|
101
|
+
for (const positions of Object.values(child.meta.attributePositions ?? {}))
|
|
102
|
+
for (const range of Object.values(positions)) {
|
|
103
|
+
range.start += start + 1;
|
|
104
|
+
range.end += start + 1;
|
|
105
|
+
}
|
|
87
106
|
}
|
|
88
107
|
state.tokens.push(...nested);
|
|
89
108
|
state.push("span_close", "span", -1);
|
|
@@ -98,6 +117,7 @@ const parser = new MarkdownIt({ html: true })
|
|
|
98
117
|
.use(spanMarkdownPlugin);
|
|
99
118
|
/** Read span anchors and located attribute diagnostics outside literal code.
|
|
100
119
|
* @param {string} source - Authored Markdown.
|
|
120
|
+
* @returns {import("./name-markup.mjs").SpanScan} Actual spans, explicit names and located diagnostics.
|
|
101
121
|
*/
|
|
102
122
|
export function scanSpans(source) {
|
|
103
123
|
const text = String(source ?? ""),
|
|
@@ -112,14 +132,28 @@ export function scanSpans(source) {
|
|
|
112
132
|
starts.push(offset);
|
|
113
133
|
offset += line.length + 1;
|
|
114
134
|
}
|
|
135
|
+
const located = (start, end) => {
|
|
136
|
+
let lo = 0,
|
|
137
|
+
hi = starts.length;
|
|
138
|
+
while (lo + 1 < hi) {
|
|
139
|
+
const mid = (lo + hi) >>> 1;
|
|
140
|
+
if (starts[mid] <= start) lo = mid;
|
|
141
|
+
else hi = mid;
|
|
142
|
+
}
|
|
143
|
+
return { start, end, line: lo + 1, column: start - starts[lo] + 1 };
|
|
144
|
+
};
|
|
115
145
|
const collect = (children, position) => {
|
|
116
|
-
for (const child of children ?? []) {
|
|
146
|
+
for (const [index, child] of (children ?? []).entries()) {
|
|
117
147
|
if (!child.meta?.span) continue;
|
|
118
148
|
const start = position(child.meta.start),
|
|
119
149
|
end = position(child.meta.end);
|
|
120
|
-
const
|
|
121
|
-
|
|
122
|
-
|
|
150
|
+
const { line, column } = located(start, end);
|
|
151
|
+
let depth = 1,
|
|
152
|
+
close = index + 1;
|
|
153
|
+
for (; close < children.length; close++) {
|
|
154
|
+
if (children[close].type === "span_open") depth++;
|
|
155
|
+
if (children[close].type === "span_close" && --depth === 0) break;
|
|
156
|
+
}
|
|
123
157
|
spans.push({
|
|
124
158
|
id: child.attrGet("id") ?? "",
|
|
125
159
|
line,
|
|
@@ -127,13 +161,50 @@ export function scanSpans(source) {
|
|
|
127
161
|
start,
|
|
128
162
|
end,
|
|
129
163
|
text: child.meta.text,
|
|
164
|
+
contentEnd: position(child.meta.start + 1 + child.meta.text.length),
|
|
165
|
+
visible: inlineText(children.slice(index + 1, close)),
|
|
130
166
|
attrs: child.attrs ?? [],
|
|
167
|
+
attributePositions: Object.fromEntries(
|
|
168
|
+
Object.entries(child.meta.attributePositions ?? {}).map(([key, positions]) => [
|
|
169
|
+
key,
|
|
170
|
+
Object.fromEntries(
|
|
171
|
+
Object.entries(positions).map(([part, range]) => [
|
|
172
|
+
part,
|
|
173
|
+
located(position(range.start), position(range.end)),
|
|
174
|
+
]),
|
|
175
|
+
),
|
|
176
|
+
]),
|
|
177
|
+
),
|
|
131
178
|
});
|
|
132
179
|
for (const message of child.meta.problems) errors.push({ line, column, message });
|
|
133
180
|
}
|
|
134
181
|
};
|
|
182
|
+
let row;
|
|
135
183
|
for (const token of tokens) {
|
|
136
|
-
if (token.type
|
|
184
|
+
if (token.type === "tr_open" && token.map) {
|
|
185
|
+
const line = lines[token.map[0]],
|
|
186
|
+
offsets = [];
|
|
187
|
+
let content = "";
|
|
188
|
+
// The existing table rule removes a backslash immediately before
|
|
189
|
+
// a pipe. Retain that projection's original character positions;
|
|
190
|
+
// actual row/cell tokens still own all table parsing and trimming.
|
|
191
|
+
for (let i = 0; i < line.length; i++) {
|
|
192
|
+
if (line[i] === "\\" && line[i + 1] === "|") continue;
|
|
193
|
+
offsets.push(i);
|
|
194
|
+
content += line[i];
|
|
195
|
+
}
|
|
196
|
+
offsets.push(line.length);
|
|
197
|
+
row = { content, offsets, line: token.map[0], cursor: 0 };
|
|
198
|
+
} else if (token.type === "tr_close") row = undefined;
|
|
199
|
+
if (token.type !== "inline") continue;
|
|
200
|
+
if (!token.map) {
|
|
201
|
+
if (!row) continue;
|
|
202
|
+
const column = row.content.indexOf(token.content, row.cursor);
|
|
203
|
+
if (column < 0) continue;
|
|
204
|
+
row.cursor = column + token.content.length;
|
|
205
|
+
collect(token.children, (pos) => starts[row.line] + row.offsets[column + pos]);
|
|
206
|
+
continue;
|
|
207
|
+
}
|
|
137
208
|
const contentLines = token.content.split("\n"),
|
|
138
209
|
positions = [];
|
|
139
210
|
let local = 0;
|
|
@@ -159,7 +230,15 @@ export function scanSpans(source) {
|
|
|
159
230
|
}
|
|
160
231
|
spans.sort((a, b) => a.start - b.start);
|
|
161
232
|
errors.sort((a, b) => a.line - b.line || a.column - b.column);
|
|
162
|
-
return {
|
|
233
|
+
return {
|
|
234
|
+
spans,
|
|
235
|
+
names: spans.filter((span) =>
|
|
236
|
+
span.attrs.some(
|
|
237
|
+
([key, value]) => key === "class" && value.split(/\s+/).includes("name"),
|
|
238
|
+
),
|
|
239
|
+
),
|
|
240
|
+
errors,
|
|
241
|
+
};
|
|
163
242
|
}
|
|
164
243
|
|
|
165
244
|
/** Emit span tags while leaving their inline content as Markdown for Hugo.
|
|
@@ -182,7 +261,7 @@ export function renderSpans(source) {
|
|
|
182
261
|
const attrs = span.attrs.map(([key, value]) => ` ${key}="${escape(value)}"`).join("");
|
|
183
262
|
markdown =
|
|
184
263
|
markdown.slice(0, span.start) +
|
|
185
|
-
`<span${attrs}>${renderSpans(span.
|
|
264
|
+
`<span${attrs}>${renderSpans(markdown.slice(span.start + 1, span.contentEnd)).markdown}</span>` +
|
|
186
265
|
markdown.slice(span.end);
|
|
187
266
|
}
|
|
188
267
|
return { markdown, errors };
|
|
@@ -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);
|
package/engine/diagnostics.mjs
CHANGED
|
@@ -46,6 +46,17 @@ import fs from "node:fs";
|
|
|
46
46
|
import path from "node:path";
|
|
47
47
|
import YAML, { LineCounter } from "yaml";
|
|
48
48
|
|
|
49
|
+
/**
|
|
50
|
+
* Split opening YAML frontmatter from its untrimmed body without parsing it.
|
|
51
|
+
* LF and CRLF delimiters retain the exact source strings and their offsets.
|
|
52
|
+
* @param {string} raw - Complete Markdown source.
|
|
53
|
+
* @returns {{yaml: string, body: string}|null} Source slices, or no fence.
|
|
54
|
+
*/
|
|
55
|
+
export function splitFrontmatter(raw) {
|
|
56
|
+
const match = raw.match(/^---\r?\n([\s\S]*?)\r?\n---(?:\r?\n)?([\s\S]*)$/);
|
|
57
|
+
return match ? { yaml: match[1], body: match[2] } : null;
|
|
58
|
+
}
|
|
59
|
+
|
|
49
60
|
/**
|
|
50
61
|
* The `file:line:column` locator, with whatever is known.
|
|
51
62
|
*
|
|
@@ -201,9 +212,9 @@ export function positionInBody(body, offset, { bodyLine = 1, bodyColumn = 1, lin
|
|
|
201
212
|
*/
|
|
202
213
|
export function positionInFrontmatter(raw, key, value = undefined, { topLevel = false } = {}) {
|
|
203
214
|
if (typeof raw !== "string" || !key) return {};
|
|
204
|
-
const fence = raw
|
|
215
|
+
const fence = splitFrontmatter(raw);
|
|
205
216
|
if (!fence) return {};
|
|
206
|
-
const block = fence
|
|
217
|
+
const block = fence.yaml;
|
|
207
218
|
const lines = block.split("\n");
|
|
208
219
|
|
|
209
220
|
// A value locates the exact entry; the key alone locates its declaration.
|
|
@@ -437,10 +448,10 @@ export function yamlKeyPath(field) {
|
|
|
437
448
|
export function frontmatterValueAt(raw, keyPath) {
|
|
438
449
|
if (typeof raw !== "string" || !Array.isArray(keyPath) || keyPath.length === 0)
|
|
439
450
|
return undefined;
|
|
440
|
-
const fence = raw
|
|
451
|
+
const fence = splitFrontmatter(raw);
|
|
441
452
|
if (!fence) return undefined;
|
|
442
453
|
try {
|
|
443
|
-
const node = YAML.parseDocument(fence
|
|
454
|
+
const node = YAML.parseDocument(fence.yaml).getIn(keyPath, true);
|
|
444
455
|
const value = node?.value;
|
|
445
456
|
return ["string", "number", "boolean"].includes(typeof value) ? value : undefined;
|
|
446
457
|
} catch {
|
|
@@ -473,9 +484,9 @@ export function frontmatterValueAt(raw, keyPath) {
|
|
|
473
484
|
*/
|
|
474
485
|
export function positionOfFrontmatterPath(raw, keyPath, { key = false } = {}) {
|
|
475
486
|
if (typeof raw !== "string") return {};
|
|
476
|
-
const fence = raw
|
|
487
|
+
const fence = splitFrontmatter(raw);
|
|
477
488
|
if (!fence) return {};
|
|
478
|
-
const position = positionOfYamlPath(fence
|
|
489
|
+
const position = positionOfYamlPath(fence.yaml, keyPath, { key });
|
|
479
490
|
// +1: the fence's line 1 is the file's line 2, the opening `---` being the
|
|
480
491
|
// first. `positionOfYamlPath` already counts from 1 within the block.
|
|
481
492
|
return position.line === undefined ? {} : { ...position, line: position.line + 1 };
|