@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.
Files changed (96) hide show
  1. package/CHANGELOG.md +33 -0
  2. package/README.md +2 -0
  3. package/bin/content-commands.mjs +197 -68
  4. package/docs/api.md +365 -70
  5. package/docs/authoring/first-note.md +10 -0
  6. package/docs/authoring/frontmatter.md +1 -1
  7. package/docs/authoring/links-and-markup.md +186 -49
  8. package/docs/commands.md +92 -3
  9. package/docs/content-format.md +10 -3
  10. package/docs/design.md +18 -0
  11. package/docs/guides/book.md +7 -0
  12. package/docs/guides/site.md +7 -0
  13. package/docs/reference/format-details.md +186 -15
  14. package/docs/reference/note-types.md +4 -4
  15. package/engine/anchors.mjs +43 -9
  16. package/engine/base-compiler.mjs +6 -4
  17. package/engine/changelog-group.mjs +58 -34
  18. package/engine/changelog-lint.mjs +6 -2
  19. package/engine/compendiums.mjs +15 -1
  20. package/engine/compile-corpus.mjs +14 -1
  21. package/engine/content-charset.mjs +13 -1
  22. package/engine/content-html.mjs +18 -4
  23. package/engine/content-icons.mjs +15 -1
  24. package/engine/content-images.mjs +28 -9
  25. package/engine/content-index.mjs +102 -23
  26. package/engine/content-links.mjs +68 -30
  27. package/engine/content-session.mjs +247 -0
  28. package/engine/content-sources.mjs +110 -0
  29. package/engine/content-spans.mjs +118 -14
  30. package/engine/content-tables.mjs +1 -0
  31. package/engine/content-tasklists.mjs +20 -4
  32. package/engine/diagnostics.mjs +17 -6
  33. package/engine/extension-attributes.mjs +28 -5
  34. package/engine/first-use.mjs +371 -0
  35. package/engine/foundry-entries.mjs +12 -9
  36. package/engine/generate.mjs +13 -4
  37. package/engine/helpers.mjs +47 -16
  38. package/engine/index-records.mjs +1 -0
  39. package/engine/index.mjs +3 -0
  40. package/engine/journals.mjs +9 -5
  41. package/engine/language-data.mjs +2 -2
  42. package/engine/language-rows.mjs +4 -4
  43. package/engine/language-terms.mjs +4 -4
  44. package/engine/lexical-sections.mjs +5 -5
  45. package/engine/markup-validation.mjs +7 -2
  46. package/engine/metadata-index.mjs +1 -0
  47. package/engine/name-markup.mjs +114 -0
  48. package/engine/name-rows.mjs +70 -0
  49. package/engine/names.mjs +460 -0
  50. package/engine/note-addresses.mjs +25 -4
  51. package/engine/path-charset.mjs +24 -1
  52. package/engine/pdf-render.mjs +15 -1
  53. package/engine/site-index.mjs +25 -2
  54. package/engine/sql-tables.mjs +76 -15
  55. package/engine/stub-lint.mjs +4 -3
  56. package/engine/web-wikilinks.mjs +33 -12
  57. package/engine/wikilink-syntax.mjs +65 -8
  58. package/engine/wikilinks.mjs +29 -11
  59. package/engine/yaml-lint.mjs +3 -5
  60. package/hugo-theme/layouts/partials/footer.html +2 -0
  61. package/hugo-theme/static/css/style.css +20 -0
  62. package/hugo-theme/static/js/inline-spoilers.js +23 -0
  63. package/package.json +1 -1
  64. package/types/engine/anchors.d.mts +6 -3
  65. package/types/engine/compendiums.d.mts +5 -1
  66. package/types/engine/compile-corpus.d.mts +5 -2
  67. package/types/engine/content-charset.d.mts +5 -1
  68. package/types/engine/content-html.d.mts +5 -1
  69. package/types/engine/content-icons.d.mts +5 -1
  70. package/types/engine/content-images.d.mts +5 -1
  71. package/types/engine/content-index.d.mts +39 -19
  72. package/types/engine/content-links.d.mts +22 -8
  73. package/types/engine/content-session.d.mts +108 -0
  74. package/types/engine/content-sources.d.mts +32 -0
  75. package/types/engine/content-spans.d.mts +8 -6
  76. package/types/engine/content-tasklists.d.mts +5 -1
  77. package/types/engine/diagnostics.d.mts +10 -0
  78. package/types/engine/extension-attributes.d.mts +16 -2
  79. package/types/engine/first-use.d.mts +31 -0
  80. package/types/engine/foundry-entries.d.mts +11 -4
  81. package/types/engine/generate.d.mts +5 -1
  82. package/types/engine/helpers.d.mts +30 -7
  83. package/types/engine/index.d.mts +1 -0
  84. package/types/engine/journals.d.mts +5 -2
  85. package/types/engine/lexical-sections.d.mts +3 -2
  86. package/types/engine/markup-validation.d.mts +2 -1
  87. package/types/engine/name-markup.d.mts +176 -0
  88. package/types/engine/name-rows.d.mts +35 -0
  89. package/types/engine/names.d.mts +356 -0
  90. package/types/engine/note-addresses.d.mts +3 -3
  91. package/types/engine/path-charset.d.mts +16 -1
  92. package/types/engine/site-build.d.mts +2 -2
  93. package/types/engine/site-index.d.mts +3 -1
  94. package/types/engine/sql-tables.d.mts +7 -2
  95. package/types/engine/stub-lint.d.mts +3 -1
  96. 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
+ }
@@ -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 (end < 0 || ["(", "["].includes(state.src[end + 1])) return false;
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 prefix = text.slice(0, start),
121
- line = prefix.split("\n").length,
122
- column = start - prefix.lastIndexOf("\n");
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 !== "inline" || !token.map) continue;
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 { spans, errors };
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
- markdown.slice(0, span.start) +
185
- `<span${attrs}>${renderSpans(span.text).markdown}</span>` +
186
- markdown.slice(span.end);
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
  }
@@ -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);