@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.
Files changed (85) hide show
  1. package/CHANGELOG.md +26 -0
  2. package/README.md +2 -0
  3. package/bin/content-commands.mjs +197 -68
  4. package/docs/api.md +336 -68
  5. package/docs/authoring/links-and-markup.md +114 -47
  6. package/docs/commands.md +92 -3
  7. package/docs/content-format.md +6 -1
  8. package/docs/reference/format-details.md +129 -14
  9. package/docs/reference/note-types.md +4 -4
  10. package/engine/anchors.mjs +8 -6
  11. package/engine/base-compiler.mjs +6 -4
  12. package/engine/changelog-group.mjs +58 -34
  13. package/engine/changelog-lint.mjs +6 -2
  14. package/engine/compendiums.mjs +15 -1
  15. package/engine/compile-corpus.mjs +14 -1
  16. package/engine/content-charset.mjs +13 -1
  17. package/engine/content-html.mjs +18 -4
  18. package/engine/content-icons.mjs +15 -1
  19. package/engine/content-images.mjs +28 -9
  20. package/engine/content-index.mjs +102 -23
  21. package/engine/content-links.mjs +27 -12
  22. package/engine/content-session.mjs +247 -0
  23. package/engine/content-sources.mjs +110 -0
  24. package/engine/content-spans.mjs +87 -8
  25. package/engine/content-tables.mjs +1 -0
  26. package/engine/content-tasklists.mjs +20 -4
  27. package/engine/diagnostics.mjs +17 -6
  28. package/engine/extension-attributes.mjs +28 -5
  29. package/engine/first-use.mjs +371 -0
  30. package/engine/foundry-entries.mjs +12 -9
  31. package/engine/generate.mjs +13 -4
  32. package/engine/helpers.mjs +43 -14
  33. package/engine/index-records.mjs +1 -0
  34. package/engine/index.mjs +3 -0
  35. package/engine/journals.mjs +9 -5
  36. package/engine/language-data.mjs +2 -2
  37. package/engine/language-rows.mjs +4 -4
  38. package/engine/language-terms.mjs +4 -4
  39. package/engine/lexical-sections.mjs +5 -5
  40. package/engine/markup-validation.mjs +7 -2
  41. package/engine/metadata-index.mjs +1 -0
  42. package/engine/name-markup.mjs +114 -0
  43. package/engine/name-rows.mjs +70 -0
  44. package/engine/names.mjs +460 -0
  45. package/engine/note-addresses.mjs +25 -4
  46. package/engine/path-charset.mjs +24 -1
  47. package/engine/pdf-render.mjs +15 -1
  48. package/engine/site-index.mjs +5 -1
  49. package/engine/sql-tables.mjs +76 -15
  50. package/engine/stub-lint.mjs +4 -3
  51. package/engine/yaml-lint.mjs +3 -5
  52. package/hugo-theme/static/css/style.css +4 -0
  53. package/package.json +1 -1
  54. package/types/engine/anchors.d.mts +4 -2
  55. package/types/engine/compendiums.d.mts +5 -1
  56. package/types/engine/compile-corpus.d.mts +5 -2
  57. package/types/engine/content-charset.d.mts +5 -1
  58. package/types/engine/content-html.d.mts +5 -1
  59. package/types/engine/content-icons.d.mts +5 -1
  60. package/types/engine/content-images.d.mts +5 -1
  61. package/types/engine/content-index.d.mts +39 -19
  62. package/types/engine/content-links.d.mts +22 -8
  63. package/types/engine/content-session.d.mts +108 -0
  64. package/types/engine/content-sources.d.mts +32 -0
  65. package/types/engine/content-spans.d.mts +3 -5
  66. package/types/engine/content-tasklists.d.mts +5 -1
  67. package/types/engine/diagnostics.d.mts +10 -0
  68. package/types/engine/extension-attributes.d.mts +16 -2
  69. package/types/engine/first-use.d.mts +31 -0
  70. package/types/engine/foundry-entries.d.mts +11 -4
  71. package/types/engine/generate.d.mts +5 -1
  72. package/types/engine/helpers.d.mts +30 -7
  73. package/types/engine/index.d.mts +1 -0
  74. package/types/engine/journals.d.mts +5 -2
  75. package/types/engine/lexical-sections.d.mts +3 -2
  76. package/types/engine/markup-validation.d.mts +2 -1
  77. package/types/engine/name-markup.d.mts +176 -0
  78. package/types/engine/name-rows.d.mts +35 -0
  79. package/types/engine/names.d.mts +356 -0
  80. package/types/engine/note-addresses.d.mts +3 -3
  81. package/types/engine/path-charset.d.mts +16 -1
  82. package/types/engine/site-build.d.mts +2 -2
  83. package/types/engine/site-index.d.mts +3 -1
  84. package/types/engine/sql-tables.d.mts +7 -2
  85. 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
+ }
@@ -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 prefix = text.slice(0, start),
121
- line = prefix.split("\n").length,
122
- column = start - prefix.lastIndexOf("\n");
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 !== "inline" || !token.map) continue;
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 { spans, errors };
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.text).markdown}</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 };
@@ -154,6 +154,7 @@ export function expandContentTables(markdown, { source = "", sqlTables, pageList
154
154
  authored,
155
155
  lineMap,
156
156
  generatedFrom,
157
+ syntax: sqlTables?.syntax,
157
158
  }),
158
159
  errors,
159
160
  warnings,
@@ -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.match(/^---\n([\s\S]*?)\n---\n?([\s\S]*)$/);
190
+ const match = splitFrontmatter(content);
175
191
  if (!match) return [content, file, { bodyLine: 1, bodyColumn: 1 }];
176
192
 
177
- const raw = match[2];
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);
@@ -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.match(/^---\n([\s\S]*?)\n---/);
215
+ const fence = splitFrontmatter(raw);
205
216
  if (!fence) return {};
206
- const block = fence[1];
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.match(/^---\n([\s\S]*?)\n---/);
451
+ const fence = splitFrontmatter(raw);
441
452
  if (!fence) return undefined;
442
453
  try {
443
- const node = YAML.parseDocument(fence[1]).getIn(keyPath, true);
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.match(/^---\n([\s\S]*?)\n---/);
487
+ const fence = splitFrontmatter(raw);
477
488
  if (!fence) return {};
478
- const position = positionOfYamlPath(fence[1], keyPath, { key });
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 };