@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
@@ -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 };
@@ -27,11 +27,20 @@ const NAME = /^[A-Za-z][A-Za-z0-9_-]*$/;
27
27
  * @param {RegExp} [options.idPattern=NAME] - What an `#id` may be named. A
28
28
  * construct whose id is published as an HTML `id` admits a digit first and
29
29
  * passes a looser pattern; the default is the pattern every other name takes.
30
- * @returns {{id: string, classes: string[], values: Record<string, string>, problems: string[]}}
30
+ * @param {boolean} [options.positions=false] - Retain the first supplied
31
+ * attribute's key/value ranges as UTF-16 offsets within this input.
32
+ * @returns {{id: string, classes: string[], values: Record<string, string>, problems: string[],
33
+ * positions?: Record<string, {key: {start: number, end: number}, value: {start: number, end: number}}>}}
31
34
  */
32
- export function parseExtensionAttributes(source, { idPattern = NAME } = {}) {
35
+ export function parseExtensionAttributes(source, { idPattern = NAME, positions = false } = {}) {
33
36
  const text = String(source ?? "");
34
- const result = { id: "", classes: [], values: {}, problems: [] };
37
+ const result = {
38
+ id: "",
39
+ classes: [],
40
+ values: {},
41
+ problems: [],
42
+ ...(positions ? { positions: {} } : {}),
43
+ };
35
44
  let at = 0;
36
45
  while (at < text.length) {
37
46
  while (/\s/.test(text[at] ?? "")) at++;
@@ -59,13 +68,18 @@ export function parseExtensionAttributes(source, { idPattern = NAME } = {}) {
59
68
  result.problems.push(`${text.slice(start, at)} is not a key=value attribute`);
60
69
  continue;
61
70
  }
71
+ const keyEnd = at + key.length;
62
72
  at += key.length + 1;
63
- let value = "";
73
+ let value = "",
74
+ valueStart = at,
75
+ valueEnd;
64
76
  if (text[at] === '"') {
65
77
  at++;
78
+ valueStart = at;
66
79
  let closed = false;
67
80
  while (at < text.length) {
68
81
  if (text[at] === '"') {
82
+ valueEnd = at;
69
83
  at++;
70
84
  closed = true;
71
85
  break;
@@ -73,6 +87,7 @@ export function parseExtensionAttributes(source, { idPattern = NAME } = {}) {
73
87
  if (text[at] === "\\" && ["\\", '"'].includes(text[at + 1])) at++;
74
88
  value += text[at++];
75
89
  }
90
+ valueEnd ??= at;
76
91
  if (!closed) result.problems.push(`${key} has an unclosed quoted value`);
77
92
  if (at < text.length && !/\s/.test(text[at])) {
78
93
  while (at < text.length && !/\s/.test(text[at])) at++;
@@ -80,11 +95,19 @@ export function parseExtensionAttributes(source, { idPattern = NAME } = {}) {
80
95
  }
81
96
  } else {
82
97
  while (at < text.length && !/\s/.test(text[at])) value += text[at++];
98
+ valueEnd = at;
83
99
  if (/[{},"\[\]]/.test(value)) result.problems.push(`${key} needs a quoted value`);
84
100
  }
85
101
  if (!value) result.problems.push(`${key} needs a value`);
86
102
  if (Object.hasOwn(result.values, key)) result.problems.push(`${key} is written twice`);
87
- else result.values[key] = value;
103
+ else {
104
+ result.values[key] = value;
105
+ if (positions)
106
+ result.positions[key] = {
107
+ key: { start, end: keyEnd },
108
+ value: { start: valueStart, end: valueEnd },
109
+ };
110
+ }
88
111
  }
89
112
  return result;
90
113
  }
@@ -0,0 +1,371 @@
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
+ import { unified } from "unified";
15
+ import remarkParse from "remark-parse";
16
+ import remarkFrontmatter from "remark-frontmatter";
17
+ import remarkGfm from "remark-gfm";
18
+ import MarkdownIt from "markdown-it";
19
+ import { scanSpans, spanMarkdownPlugin } from "./content-spans.mjs";
20
+ import { inlineText, rebaseNameSpans } from "./name-markup.mjs";
21
+ import { createNameCatalog, nameForms as forms, normalizeName as normalize } from "./names.mjs";
22
+ import { WIKILINK, parseWikilink } from "./wikilink-syntax.mjs";
23
+
24
+ const parser = unified().use(remarkParse).use(remarkFrontmatter).use(remarkGfm);
25
+ const labelParser = new MarkdownIt({ html: true }).use(spanMarkdownPlugin);
26
+ const linkRule = labelParser.inline.ruler.getRules("").find((rule) => rule.name === "link");
27
+ labelParser.inline.ruler.at("link", (state, silent) => {
28
+ const start = state.pos,
29
+ count = state.tokens.length;
30
+ const matched = linkRule(state, silent);
31
+ if (matched && !silent) {
32
+ const opening = state.tokens.slice(count).find((token) => token.type === "link_open");
33
+ opening.meta = {
34
+ ...opening.meta,
35
+ nameLink: { source: state.src, start, end: state.pos },
36
+ };
37
+ }
38
+ return matched;
39
+ });
40
+ const blank = (text) => text.replace(/[^\r\n]/g, " ");
41
+ // A quoted bracket belongs to its attribute. This reads boundaries only;
42
+ // the separate raw-HTML check owns whether an author should write the tag.
43
+ const HTML_TOKEN =
44
+ /<!--[\s\S]*?(?:-->|$)|<\/?[A-Za-z][A-Za-z0-9-]*(?:\s(?:[^"'<>]|"[^"]*"|'[^']*')*)?\/?>/g;
45
+
46
+ /** Read an authored inline slice once with the renderer's span grammar. */
47
+ function readLabel(text, env = {}) {
48
+ const tokens = labelParser.parseInline(text, env);
49
+ const children = tokens[0]?.children ?? [];
50
+ const offsets = [];
51
+ for (let i = 0; i < text.length; i++) {
52
+ offsets.push(i);
53
+ if (text[i] === "\r" && text[i + 1] === "\n") i++;
54
+ }
55
+ offsets.push(text.length);
56
+ const normalized = text.replace(/\r\n?/g, "\n").replaceAll("\0", "\uFFFD");
57
+ const spans = [],
58
+ links = [];
59
+ for (let i = 0; i < children.length; i++) {
60
+ const token = children[i];
61
+ if (token.meta?.span) spans.push(token.meta);
62
+ else if (token.type === "span_close") spans.pop();
63
+ const link = token.meta?.nameLink;
64
+ if (!link) continue;
65
+ const parent =
66
+ link.source === normalized ?
67
+ undefined
68
+ : spans.findLast((span) => span.text === link.source);
69
+ const base = parent ? parent.start + 1 : 0;
70
+ let depth = 1,
71
+ end = i + 1;
72
+ for (; end < children.length; end++) {
73
+ if (children[end].type === "link_open") depth++;
74
+ if (children[end].type === "link_close" && --depth === 0) break;
75
+ }
76
+ links.push({
77
+ start: offsets[base + link.start],
78
+ end: offsets[base + link.end],
79
+ display: inlineText(children.slice(i + 1, end)),
80
+ });
81
+ }
82
+ return { text: inlineText(tokens), links };
83
+ }
84
+ const labelText = (text, env) => readLabel(text, env).text;
85
+
86
+ /** Join overlapping source ranges so lookups stay logarithmic. */
87
+ function ranges(values) {
88
+ const out = [];
89
+ for (const value of values.sort((a, b) => a.start - b.start || b.end - a.end)) {
90
+ const last = out.at(-1);
91
+ if (last && value.start <= last.end) last.end = Math.max(last.end, value.end);
92
+ else out.push({ start: value.start, end: value.end });
93
+ }
94
+ return out;
95
+ }
96
+
97
+ /** Find the last range opening before an offset. */
98
+ function before(values, offset) {
99
+ let lo = 0,
100
+ hi = values.length;
101
+ while (lo < hi) {
102
+ const mid = (lo + hi) >>> 1;
103
+ if (values[mid].start < offset) lo = mid + 1;
104
+ else hi = mid;
105
+ }
106
+ return values[lo - 1];
107
+ }
108
+ const contains = (values, value) => {
109
+ const range = before(values, value.start + 1);
110
+ return range && range.end >= value.end;
111
+ };
112
+ const overlaps = (values, value) => {
113
+ const range = before(values, value.end);
114
+ return range && range.end > value.start;
115
+ };
116
+
117
+ /** Explicit inline occurrences and structural exclusions at original source offsets. */
118
+ function prose(source, capturedNames) {
119
+ const links = [],
120
+ regions = [],
121
+ excluded = [],
122
+ bold = [],
123
+ html = [];
124
+ const masked = source
125
+ .replace(/!\[\[[^\]\n]+\]\]|\{\{[^}\n]+\}\}/g, (text, start) => {
126
+ excluded.push({ start, end: start + text.length });
127
+ return blank(text);
128
+ })
129
+ .replace(WIKILINK, (text, inner, start) => {
130
+ let escapes = 0;
131
+ while (source[start - escapes - 1] === "\\") escapes++;
132
+ if (escapes % 2) return text;
133
+ links.push({ ...parseWikilink(inner), start, end: start + text.length, wiki: true });
134
+ return "x".repeat(text.length);
135
+ });
136
+ const tree = parser.parse(masked);
137
+ const env = { references: Object.create(null) };
138
+ const inline = (node) => {
139
+ const start = node.position?.start.offset,
140
+ end = node.position?.end.offset;
141
+ if (["inlineCode", "image", "imageReference", "footnoteReference"].includes(node.type))
142
+ excluded.push({ start, end });
143
+ else if (node.type === "html") html.push(node);
144
+ else if (["strong", "emphasis", "delete", "link", "linkReference"].includes(node.type)) {
145
+ if (node.type === "strong") bold.push({ start, end });
146
+ if (["link", "linkReference"].includes(node.type) && source[start] === "[")
147
+ links.push({ start, end, labelled: true });
148
+ for (const child of node.children ?? []) inline(child);
149
+ }
150
+ };
151
+ const block = (node) => {
152
+ if (node.type === "definition")
153
+ env.references[labelParser.utils.normalizeReference(node.identifier)] = {
154
+ href: node.url,
155
+ title: node.title ?? "",
156
+ };
157
+ else if (node.type === "html") html.push(node);
158
+ else if (node.type === "paragraph") {
159
+ regions.push({ start: node.position.start.offset, end: node.position.end.offset });
160
+ for (const child of node.children ?? []) inline(child);
161
+ } else if (
162
+ ["root", "blockquote", "list", "listItem", "footnoteDefinition"].includes(node.type)
163
+ ) {
164
+ for (const child of node.children ?? []) block(child);
165
+ }
166
+ };
167
+ block(tree);
168
+ const stack = [];
169
+ for (const node of html) {
170
+ const htmlText = masked.slice(node.position.start.offset, node.position.end.offset);
171
+ let cursor = 0;
172
+ for (const tag of htmlText.matchAll(HTML_TOKEN)) {
173
+ const start = node.position.start.offset + tag.index;
174
+ if (tag.index > cursor)
175
+ regions.push({ start: node.position.start.offset + cursor, end: start });
176
+ cursor = tag.index + tag[0].length;
177
+ excluded.push({ start, end: start + tag[0].length });
178
+ if (tag[0].startsWith("<!--")) continue;
179
+ const name = /^<\/?([A-Za-z][\w-]*)/.exec(tag[0])[1].toLowerCase();
180
+ if (tag[0].startsWith("</")) {
181
+ const opening = stack.findLastIndex((entry) => entry.name === name);
182
+ if (opening < 0) continue;
183
+ for (const entry of stack.splice(opening))
184
+ if (entry.excluded)
185
+ excluded.push({ start: entry.start, end: start + tag[0].length });
186
+ } else if (
187
+ !/\/>$/.test(tag[0]) &&
188
+ !["br", "hr", "img", "input", "meta", "link"].includes(name)
189
+ ) {
190
+ const classes = /\bclass\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s>]+))/i.exec(tag[0]);
191
+ stack.push({
192
+ name,
193
+ start,
194
+ excluded:
195
+ /^(?:h[1-6]|table|pre|code|script|style)$/.test(name) ||
196
+ (classes?.[1] ?? classes?.[2] ?? classes?.[3] ?? "")
197
+ .split(/\s+/)
198
+ .includes("infobox"),
199
+ });
200
+ }
201
+ }
202
+ if (cursor < htmlText.length)
203
+ regions.push({
204
+ start: node.position.start.offset + cursor,
205
+ end: node.position.end.offset,
206
+ });
207
+ }
208
+ for (const entry of stack)
209
+ if (entry.excluded) excluded.push({ start: entry.start, end: source.length });
210
+ const allowed = ranges(regions),
211
+ literal = ranges(excluded);
212
+ const eligible = (value) => contains(allowed, value) && !overlaps(literal, value);
213
+ const actualLinks = links.filter(eligible).flatMap((link) => {
214
+ if (link.wiki) return [link];
215
+ const label = readLabel(source.slice(link.start, link.end), env);
216
+ return label.links.map((actual) => ({
217
+ ...actual,
218
+ labelled: true,
219
+ start: link.start + actual.start,
220
+ end: link.start + actual.end,
221
+ }));
222
+ });
223
+ const authoredLinks = [...new Map(actualLinks.map((link) => [link.start, link])).values()];
224
+ const linked = ranges(authoredLinks);
225
+ const marked = (capturedNames ?? scanSpans(source).names)
226
+ .filter((span) => eligible(span) && !contains(linked, span))
227
+ .map((span) => ({ ...span, label: span.visible }));
228
+ const explicit = ranges([...marked, ...authoredLinks]);
229
+ return {
230
+ marked,
231
+ links: authoredLinks,
232
+ bold: bold
233
+ .filter((span) => eligible(span) && !overlaps(explicit, span))
234
+ .map((span) => ({
235
+ ...span,
236
+ label: labelText(source.slice(span.start, span.end), env),
237
+ })),
238
+ };
239
+ }
240
+
241
+ /**
242
+ * Prepare indexed names once, then check only explicit occurrences without reading files.
243
+ * @param {{records: readonly object[], index: object,
244
+ * catalog?: import("./names.mjs").NameCatalog,
245
+ * parsed?: Map<string, {raw: string, body: string, bodyLine?: number, bodyColumn?: number}>,
246
+ * syntax?: import("./content-session.mjs").ContentSyntax}} options - Lint's
247
+ * records/resolver and optional captured authored-body projections.
248
+ * @returns {(note: {file: string, raw: string}) => Array<{file: string, line: number, column: number, severity: "warning", rules: string[], message: string}>} Located warning diagnostics.
249
+ */
250
+ export function createFirstUseChecker({
251
+ records,
252
+ index,
253
+ parsed,
254
+ syntax,
255
+ catalog = createNameCatalog({ records, index }),
256
+ }) {
257
+ const { recognize: recognized, resolve } = catalog;
258
+ return ({ file, raw }) => {
259
+ const authored = parsed?.get(file);
260
+ const capturedNames =
261
+ authored?.raw === raw && Number.isInteger(authored.bodyLine) ?
262
+ rebaseNameSpans(
263
+ (syntax ? syntax(authored.body).spans : scanSpans(authored.body)).names,
264
+ { raw, bodyLine: authored.bodyLine, bodyColumn: authored.bodyColumn },
265
+ )
266
+ : undefined;
267
+ const { marked, links, bold } = prose(raw, capturedNames);
268
+ const events = [
269
+ ...marked.map((span) => ({ ...span, kind: "marked" })),
270
+ ...links.map((link) => {
271
+ const target = link.wiki ? resolve(link.target) : undefined;
272
+ return {
273
+ ...link,
274
+ kind: "link",
275
+ preferred: target,
276
+ label:
277
+ link.labelled ?
278
+ link.wiki ?
279
+ labelText(link.display || target?.full || "")
280
+ : link.display
281
+ : undefined,
282
+ };
283
+ }),
284
+ ].filter((event) => typeof event.label === "string" && normalize(event.label));
285
+ const unknown = new Map();
286
+ for (const event of [...events].sort(
287
+ (a, b) => a.label.length - b.label.length || a.label.localeCompare(b.label),
288
+ )) {
289
+ if (recognized(event.label, event.preferred) || unknown.has(normalize(event.label)))
290
+ continue;
291
+ const entry = {
292
+ key: "name:" + normalize(event.label),
293
+ full: event.label,
294
+ fullUse: true,
295
+ };
296
+ for (const spelling of forms(event.label))
297
+ if (!unknown.has(spelling)) unknown.set(spelling, entry);
298
+ }
299
+ const findings = new Map();
300
+ const add = (offset, rule, message) => {
301
+ if (!findings.has(offset)) findings.set(offset, { rules: [], messages: [] });
302
+ const finding = findings.get(offset);
303
+ if (!finding.rules.includes(rule)) {
304
+ finding.rules.push(rule);
305
+ finding.messages.push(message);
306
+ }
307
+ };
308
+ const used = new Set(),
309
+ fullUsed = new Set();
310
+ for (const event of events.sort((a, b) => a.start - b.start)) {
311
+ const entry =
312
+ recognized(event.label, event.preferred) ?? unknown.get(normalize(event.label));
313
+ if (event.kind === "marked" && entry.indexed) {
314
+ const destinations = (entry.candidates ?? [entry])
315
+ .map((target) => target.key)
316
+ .sort()
317
+ .join(" or ");
318
+ add(
319
+ event.start,
320
+ "unlinked-name",
321
+ `“${event.label}” names “${entry.full}”; use a link to “${destinations}”`,
322
+ );
323
+ }
324
+ if (used.has(entry.key))
325
+ add(
326
+ event.start,
327
+ "repeated-name",
328
+ `“${entry.full}” is linked or marked more than once`,
329
+ );
330
+ if (!entry.fullUse && !fullUsed.has(entry.key))
331
+ add(
332
+ event.start,
333
+ "short-before-full",
334
+ `“${event.label}” appears before the full name “${entry.full}”`,
335
+ );
336
+ used.add(entry.key);
337
+ if (entry.fullUse) fullUsed.add(entry.key);
338
+ }
339
+ for (const span of bold) {
340
+ if (typeof span.label !== "string") continue;
341
+ const entry = recognized(span.label);
342
+ if (entry)
343
+ add(
344
+ span.start,
345
+ "bold-name",
346
+ `“${span.label}” matches an indexed name; mark or link this name`,
347
+ );
348
+ }
349
+ const lineStarts = [0];
350
+ for (let i = 0; i < raw.length; i++) if (raw[i] === "\n") lineStarts.push(i + 1);
351
+ return [...findings]
352
+ .sort(([a], [b]) => a - b)
353
+ .map(([offset, finding]) => {
354
+ let lo = 0,
355
+ hi = lineStarts.length;
356
+ while (lo + 1 < hi) {
357
+ const mid = (lo + hi) >>> 1;
358
+ if (lineStarts[mid] <= offset) lo = mid;
359
+ else hi = mid;
360
+ }
361
+ return {
362
+ file,
363
+ line: lo + 1,
364
+ column: offset - lineStarts[lo] + 1,
365
+ severity: "warning",
366
+ rules: finding.rules,
367
+ message: `first-use/${finding.rules.join("+")}: ${finding.messages.join("; ")}`,
368
+ };
369
+ });
370
+ };
371
+ }
@@ -110,15 +110,16 @@ export const LEAD_ANCHOR = "$lead";
110
110
  * @param {string} entryId - The entry's id, which page ids hash against.
111
111
  * @param {string} body - The note's markdown body.
112
112
  * @param {string} name - The note's name, used as the lead page's title.
113
+ * @param {import("./content-session.mjs").ContentSyntax} [syntax] - Session scanner projections.
113
114
  * @returns {Record<string, string>} The anchors.
114
115
  */
115
- export function anchorsOf(entryUuid, entryId, body, name) {
116
+ export function anchorsOf(entryUuid, entryId, body, name, syntax) {
116
117
  const anchors = {};
117
- splitPages(body, name).forEach((page, index) => {
118
+ splitPages(body, name, undefined, syntax).forEach((page, index) => {
118
119
  const uuid = pageUuid(entryUuid, journalPageId(entryId, page));
119
120
  const words = new Set(
120
- scanSpans(page.markdown)
121
- .spans.filter((span) =>
121
+ (syntax ? syntax(page.markdown).spans : scanSpans(page.markdown)).spans
122
+ .filter((span) =>
122
123
  span.attrs.some(
123
124
  ([key, value]) =>
124
125
  key === "class" && value.split(/\s+/).includes(DICTIONARY_ENTRY_CLASS),
@@ -128,7 +129,7 @@ export function anchorsOf(entryUuid, entryId, body, name) {
128
129
  );
129
130
  if (index === 0) anchors[LEAD_ANCHOR] = uuid;
130
131
  if (page.anchorSlug) anchors[page.anchorSlug] = uuid;
131
- for (const anchor of collectAnchors(page.markdown))
132
+ for (const anchor of collectAnchors(page.markdown, 1, undefined, syntax))
132
133
  anchors[anchor.slug] = words.has(anchor.slug) ? `${uuid}#${anchor.slug}` : uuid;
133
134
  });
134
135
  return anchors;
@@ -155,8 +156,7 @@ export function anchorsOf(entryUuid, entryId, body, name) {
155
156
  * documentation journal's is {@link itemDocEntryId} of that. Stating it here is
156
157
  * what lets the content index publish an identity it did not re-derive.
157
158
  *
158
- * @param {object} ctx - Resolved identities: `{ contentPackage,
159
- * foundryPackageId, packRouter }`.
159
+ * @param {{contentPackage: string, foundryPackageId: string, packRouter: object, web?: boolean, skipDirectories?: readonly string[], syntax?: import("./content-session.mjs").ContentSyntax}} ctx - Resolved identities and optional session scanner projections.
160
160
  * @returns {Array<object>} One or two entries, in {@link buildManifest}'s shape.
161
161
  */
162
162
  export function entriesForNote(fm, name, address, body, ctx) {
@@ -288,7 +288,10 @@ export function entriesForNote(fm, name, address, body, ctx) {
288
288
  // UUID's last segment.
289
289
  id: docEntryId,
290
290
  uuid: docUuid,
291
- anchors: docUuid ? anchorsOf(docUuid, docEntryId, body ?? "", name) : undefined,
291
+ anchors:
292
+ docUuid ?
293
+ anchorsOf(docUuid, docEntryId, body ?? "", name, ctx.syntax)
294
+ : undefined,
292
295
  },
293
296
  ];
294
297
  }
@@ -307,7 +310,7 @@ export function entriesForNote(fm, name, address, body, ctx) {
307
310
  systemUuids: systemUuidsFor(fm.type, fm.id),
308
311
  anchors:
309
312
  own && packForType(fm.type)?.docType === "JournalEntry" ?
310
- anchorsOf(own, fm.id, body ?? "", name)
313
+ anchorsOf(own, fm.id, body ?? "", name, ctx.syntax)
311
314
  : undefined,
312
315
  },
313
316
  ];
@@ -735,17 +735,25 @@ async function stageGeneratedDrawings(records, config) {
735
735
  * package's tree — and how the guard-order test below induces id drift, now
736
736
  * that the manifest is located by configuration rather than by the working
737
737
  * directory.
738
+ * @param {import("./content-session.mjs").ContentSession} [opts.session] - Same configuration, root and scope as this generation.
739
+ * @param {boolean} [opts.reportPreparation=true] - Emit shared preparation findings; false when already reported by the caller.
738
740
  * @returns {Promise<number>} Total error count across the generated packs.
739
741
  * @throws {Error} If the configured Foundry package id has drifted from the
740
742
  * shipped manifest's `id` (see `package-manifest.mjs`).
741
743
  */
742
- export async function generatePacksJson({ only, config = loadPackConfig() } = {}) {
744
+ export async function generatePacksJson({
745
+ only,
746
+ config = loadPackConfig(),
747
+ session,
748
+ reportPreparation = true,
749
+ } = {}) {
743
750
  // Before anything is generated: every UUID written below is addressed to
744
751
  // the configured `foundryPackage`, so a value that has drifted from the shipped
745
752
  // manifest's `id` produces a whole pack of links that resolve nowhere.
746
753
  // Throws rather than counting an error — there is nothing worth compiling.
747
754
  //
748
755
  const contentBase = config.paths.content;
756
+ session?.assertContext({ config, contentBase, skipDirectories: config.skipDirectories });
749
757
  if (!fs.existsSync(contentBase)) {
750
758
  log.error(`Content tree not found at ${contentBase}.`);
751
759
  return 1;
@@ -770,7 +778,7 @@ export async function generatePacksJson({ only, config = loadPackConfig() } = {}
770
778
  // a note of that type that goes nowhere. Resolved here because the corpus
771
779
  // below is derived against it, and the corpus is what every reader from
772
780
  // this point on reads.
773
- const router = routerFor(config);
781
+ const router = session?.router ?? routerFor(config);
774
782
 
775
783
  // The corpus every pass runs over, and the three whole-tree indexes built
776
784
  // over it, derived **once** for the whole compile. Each is a pure
@@ -782,15 +790,16 @@ export async function generatePacksJson({ only, config = loadPackConfig() } = {}
782
790
  // Derived here, before the first reader: the unclaimed-type check below is
783
791
  // one, and a check that walked the tree itself would be answering about a
784
792
  // different corpus from the one the passes then compile.
785
- const corpusProblems = [];
793
+ const corpusProblems = session?.problems ?? [];
786
794
  const corpus = await buildCompileCorpus({
787
795
  contentBase,
788
796
  skipDirectories: config.skipDirectories,
789
797
  router,
790
798
  config,
791
799
  problems: corpusProblems,
800
+ session,
792
801
  });
793
- for (const problem of corpusProblems) emitDiagnostic(problem);
802
+ if (reportPreparation) for (const problem of corpusProblems) emitDiagnostic(problem);
794
803
  for (const finding of await stageGeneratedDrawings(corpus.records, config))
795
804
  emitDiagnostic(finding);
796
805