@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
@@ -85,6 +85,7 @@
85
85
 
86
86
  import fs from "node:fs";
87
87
  import path from "node:path";
88
+ import { capturedSources } from "./content-sources.mjs";
88
89
 
89
90
  /**
90
91
  * Tier 1 — the letters, and the two whitespace characters a file is made of.
@@ -407,16 +408,27 @@ export function checkText(text, file) {
407
408
  * @param {readonly string[]} [opts.skipDirectories] - Directory names to ignore
408
409
  * in addition to the dot-directories always skipped.
409
410
  * @param {readonly string[]} [opts.extensions] - File extensions to read.
411
+ * @param {Map<string, {raw: string}>} [opts.sources] - Exact captured source strings; omitted, walks and reads the tree.
410
412
  * @returns {{findings: Array<{file: string, line: number, column: number,
411
413
  * severity: "warning", message: string}>, files: number}} The findings, and how
412
414
  * many files produced them.
413
415
  */
414
- export function lintContentCharset(contentBase, { skipDirectories = [], extensions } = {}) {
416
+ export function lintContentCharset(
417
+ contentBase,
418
+ { skipDirectories = [], extensions, sources } = {},
419
+ ) {
415
420
  const exts = new Set(extensions ?? [".md", ".markdown", ".yaml", ".yml", ".json"]);
416
421
  const skip = new Set(skipDirectories);
417
422
  /** @type {Array<{file: string, line: number, column: number, severity: "warning", message: string}>} */
418
423
  const findings = [];
419
424
  let files = 0;
425
+ if (sources) {
426
+ for (const [full, text] of capturedSources(contentBase, sources, skipDirectories, exts)) {
427
+ files++;
428
+ findings.push(...checkText(text, path.relative(process.cwd(), full)));
429
+ }
430
+ return { findings, files };
431
+ }
420
432
 
421
433
  /** @param {string} dir - Directory to descend into. */
422
434
  const walk = (dir) => {
@@ -52,9 +52,10 @@
52
52
 
53
53
  import fs from "node:fs";
54
54
  import path from "node:path";
55
+ import { capturedSources } from "./content-sources.mjs";
55
56
 
56
57
  import { matchAllOutsideCode } from "./code-fences.mjs";
57
- import { positionInBody } from "./diagnostics.mjs";
58
+ import { positionInBody, splitFrontmatter } from "./diagnostics.mjs";
58
59
 
59
60
  /**
60
61
  * A raw HTML tag, opening, closing or self-closing.
@@ -133,15 +134,28 @@ export function checkHtml(body, file, { bodyLine = 1, bodyColumn = 1 } = {}) {
133
134
  * @param {object} [opts]
134
135
  * @param {readonly string[]} [opts.skipDirectories] - Directory names to ignore
135
136
  * in addition to the dot-directories always skipped.
137
+ * @param {Map<string, {raw: string}>} [opts.sources] - Exact captured source strings; omitted, walks and reads the tree.
136
138
  * @returns {{findings: Array<{file: string, line: number, column: number,
137
139
  * severity: "warning", message: string}>, files: number}} The findings, and
138
140
  * how many files were read.
139
141
  */
140
- export function lintContentHtml(contentBase, { skipDirectories = [] } = {}) {
142
+ export function lintContentHtml(contentBase, { skipDirectories = [], sources } = {}) {
141
143
  const skip = new Set(skipDirectories);
142
144
  /** @type {Array<{file: string, line: number, column: number, severity: "warning", message: string}>} */
143
145
  const findings = [];
144
146
  let files = 0;
147
+ if (sources) {
148
+ for (const [full, content] of capturedSources(
149
+ contentBase,
150
+ sources,
151
+ skipDirectories,
152
+ new Set([".md", ".markdown"]),
153
+ )) {
154
+ files++;
155
+ findings.push(...checkHtml(...bodyOf(content, path.relative(process.cwd(), full))));
156
+ }
157
+ return { findings, files };
158
+ }
145
159
 
146
160
  /** @param {string} dir - Directory to descend into. */
147
161
  const walk = (dir) => {
@@ -188,10 +202,10 @@ export function lintContentHtml(contentBase, { skipDirectories = [] } = {}) {
188
202
  * arguments {@link checkHtml} takes.
189
203
  */
190
204
  function bodyOf(content, file) {
191
- const match = content.match(/^---\n([\s\S]*?)\n---\n?([\s\S]*)$/);
205
+ const match = splitFrontmatter(content);
192
206
  if (!match) return [content, file, { bodyLine: 1, bodyColumn: 1 }];
193
207
 
194
- const raw = match[2];
208
+ const raw = match.body;
195
209
  const body = raw.trim();
196
210
  const bodyStart = content.length - raw.length + (raw.length - raw.trimStart().length);
197
211
  const before = content.slice(0, bodyStart);
@@ -27,6 +27,7 @@
27
27
 
28
28
  import fs from "node:fs";
29
29
  import path from "node:path";
30
+ import { capturedSources } from "./content-sources.mjs";
30
31
  import { parseExtensionAttributes } from "./extension-attributes.mjs";
31
32
 
32
33
  /**
@@ -524,13 +525,26 @@ export function checkIconRegistry(registry, where = "icons") {
524
525
  * @param {object} [opts]
525
526
  * @param {readonly string[]} [opts.skipDirectories] - Directory names to ignore.
526
527
  * @param {IconRegistry} [opts.registry] - The package's registry.
528
+ * @param {Map<string, {raw: string}>} [opts.sources] - Exact captured source strings; omitted, walks and reads the tree.
527
529
  * @returns {{findings: Array<{file: string, line: number, column: number,
528
530
  * severity: "warning", message: string}>, files: number}} What it found.
529
531
  */
530
- export function lintContentIcons(contentBase, { skipDirectories = [], registry } = {}) {
532
+ export function lintContentIcons(contentBase, { skipDirectories = [], registry, sources } = {}) {
531
533
  const skip = new Set(skipDirectories);
532
534
  const findings = [];
533
535
  let files = 0;
536
+ if (sources) {
537
+ for (const [full, text] of capturedSources(
538
+ contentBase,
539
+ sources,
540
+ skipDirectories,
541
+ new Set([".md", ".markdown"]),
542
+ )) {
543
+ files++;
544
+ findings.push(...lintIcons(text, path.relative(process.cwd(), full), registry));
545
+ }
546
+ return { findings, files };
547
+ }
534
548
 
535
549
  /** @param {string} dir - Directory to descend into. */
536
550
  const walk = (dir) => {
@@ -80,10 +80,11 @@
80
80
 
81
81
  import fs from "node:fs";
82
82
  import path from "node:path";
83
+ import { capturedSources } from "./content-sources.mjs";
83
84
 
84
85
  import { matchAllOutsideCode } from "./code-fences.mjs";
85
86
  import { parseExtensionAttributes } from "./extension-attributes.mjs";
86
- import { positionInBody } from "./diagnostics.mjs";
87
+ import { positionInBody, splitFrontmatter } from "./diagnostics.mjs";
87
88
  import { FOOTNOTE_REFERENCE } from "./content-footnotes.mjs";
88
89
  import { foundryAddressProblem, pathnameProblem, servesFoundry } from "./pathnames.mjs";
89
90
  import { ASSET_ROLES } from "./asset-index.mjs";
@@ -431,16 +432,20 @@ export function standsAlone(text, start, end) {
431
432
  const lineEnd = src.indexOf("\n", end);
432
433
  const afterMatch = src.slice(end, lineEnd === -1 ? src.length : lineEnd);
433
434
  const reference = TRAILING_FOOTNOTE_REFERENCE.exec(afterMatch);
434
- if (!/^[ \t]*$/.test(reference ? afterMatch.slice(reference[0].length) : afterMatch))
435
+ if (!/^[ \t]*\r?$/.test(reference ? afterMatch.slice(reference[0].length) : afterMatch))
435
436
  return false;
436
437
 
437
438
  const before = src.slice(0, lineStart);
438
- const previous = before.replace(/\n$/, "").split("\n").pop() ?? "";
439
- if (before.trim() && !/\n[ \t]*\n[ \t]*$/.test(before) && !DELIMITER.test(previous))
439
+ const previous =
440
+ before
441
+ .replace(/\r?\n$/, "")
442
+ .split(/\r?\n/)
443
+ .pop() ?? "";
444
+ if (before.trim() && !/\n[ \t]*\r?\n[ \t]*$/.test(before) && !DELIMITER.test(previous))
440
445
  return false;
441
446
  const after = lineEnd === -1 ? "" : src.slice(lineEnd);
442
- const next = after.replace(/^\n/, "").split("\n")[0] ?? "";
443
- if (after.trim() && !/^\n[ \t]*\n/.test(after) && !DELIMITER.test(next)) return false;
447
+ const next = after.replace(/^\n/, "").split(/\r?\n/)[0] ?? "";
448
+ if (after.trim() && !/^\n[ \t]*\r?\n/.test(after) && !DELIMITER.test(next)) return false;
444
449
  return true;
445
450
  }
446
451
 
@@ -586,10 +591,10 @@ export function checkImages(
586
591
  * arguments {@link checkImages} takes.
587
592
  */
588
593
  function bodyOf(content, file) {
589
- const match = content.match(/^---\n([\s\S]*?)\n---\n?([\s\S]*)$/);
594
+ const match = splitFrontmatter(content);
590
595
  if (!match) return [content, file, { bodyLine: 1, bodyColumn: 1 }];
591
596
 
592
- const raw = match[2];
597
+ const raw = match.body;
593
598
  const body = raw.trim();
594
599
  const bodyStart = content.length - raw.length + (raw.length - raw.trimStart().length);
595
600
  const before = content.slice(0, bodyStart);
@@ -619,15 +624,29 @@ function bodyOf(content, file) {
619
624
  * in addition to the dot-directories always skipped.
620
625
  * @param {object} [opts.config] - The resolved build configuration, passed to
621
626
  * {@link checkImages} so an address is held to the Foundry surface too.
627
+ * @param {Map<string, {raw: string}>} [opts.sources] - Exact captured source strings; omitted, walks and reads the tree.
622
628
  * @returns {{findings: Array<{file: string, line: number, column: number|undefined,
623
629
  * severity: "error", message: string}>, files: number}} The findings, and how
624
630
  * many files were read.
625
631
  */
626
- export function lintContentImages(contentBase, { skipDirectories = [], config } = {}) {
632
+ export function lintContentImages(contentBase, { skipDirectories = [], config, sources } = {}) {
627
633
  const skip = new Set(skipDirectories);
628
634
  /** @type {Array<{file: string, line: number, column: number|undefined, severity: "error", message: string}>} */
629
635
  const findings = [];
630
636
  let files = 0;
637
+ if (sources) {
638
+ for (const [full, content] of capturedSources(
639
+ contentBase,
640
+ sources,
641
+ skipDirectories,
642
+ new Set([".md", ".markdown"]),
643
+ )) {
644
+ files++;
645
+ const [body, rel, at] = bodyOf(content, path.relative(process.cwd(), full));
646
+ findings.push(...checkImages(body, rel, { ...at, config }));
647
+ }
648
+ return { findings, files };
649
+ }
631
650
 
632
651
  /** @param {string} dir - Directory to descend into. */
633
652
  const walk = (dir) => {
@@ -102,6 +102,9 @@ import {
102
102
  cloneAddressState,
103
103
  } from "./note-addresses.mjs";
104
104
  import { AddressLink } from "./address-values.mjs";
105
+ import { scanSpans } from "./content-spans.mjs";
106
+ import { rebaseNameSpans } from "./name-markup.mjs";
107
+ import { analyzeNameSpans } from "./names.mjs";
105
108
  import fs from "node:fs";
106
109
  import path from "node:path";
107
110
 
@@ -469,6 +472,8 @@ function assertNoDerivedKeys(frontmatter, relPath, absPath, contentPackage, raw)
469
472
  * and for the one question that decides whether the note publishes a page. A
470
473
  * caller that states no body is stating an empty one, and the note is a stub.
471
474
  * @param {number} [options.bodyLine] - The 1-based file line the body starts on.
475
+ * @param {number} [options.bodyColumn] - The 1-based UTF-16 column of its first character.
476
+ * @param {Iterable<string>} [options.nameTypes] - Additional bare name kinds from configured type declarations.
472
477
  * @param {object} [options.manifest] - The package manifest, which the Foundry
473
478
  * entries are derived against.
474
479
  * @param {object} [options.addressContext] - Address resolution context.
@@ -477,6 +482,7 @@ function assertNoDerivedKeys(frontmatter, relPath, absPath, contentPackage, raw)
477
482
  * a picture's address to the role its asset declares, so a figure anchor's
478
483
  * `name` reads `Map 1` rather than `Figure 1` where it is due — see
479
484
  * {@link module:engine/content-figures.scanFigures}.
485
+ * @param {import("./content-session.mjs").ContentSyntax} [options.syntax] - Session scanner projections.
480
486
  * @returns {Record<string, any>} The record, keys sorted at every depth. A
481
487
  * **stub** — a note with an empty body, on a type an empty body suppresses —
482
488
  * carries `address` and `anchors` as `null`: it publishes no page, so it
@@ -496,13 +502,37 @@ export function buildIndexRecord({
496
502
  contentPackage,
497
503
  body,
498
504
  bodyLine,
505
+ bodyColumn,
506
+ nameTypes,
499
507
  manifest,
500
508
  addressContext,
501
509
  dateContext,
502
510
  resolveRole,
511
+ syntax,
503
512
  }) {
504
513
  assertNoDerivedKeys(frontmatter, relPath, absPath, contentPackage, raw);
505
514
 
515
+ const source = body ?? "";
516
+ const spans = syntax ? syntax(source).spans.names : scanSpans(source).names;
517
+ const marked = analyzeNameSpans(
518
+ rebaseNameSpans(spans, { raw: raw ?? source, bodyLine, bodyColumn }),
519
+ { file: absPath ?? relPath, types: nameTypes },
520
+ );
521
+ if (marked.findings.length) {
522
+ const findings = marked.findings.map(({ line, column, ...finding }) => ({
523
+ ...finding,
524
+ position: {
525
+ ...(line === undefined ? {} : { line }),
526
+ ...(column === undefined ? {} : { column }),
527
+ },
528
+ }));
529
+ throw Object.assign(new Error(findings[0].message), {
530
+ file: findings[0].file,
531
+ position: findings[0].position,
532
+ findings,
533
+ });
534
+ }
535
+
506
536
  const posix = relPath.split(path.sep).join("/");
507
537
  const folder = posix.includes("/") ? posix.slice(0, posix.lastIndexOf("/")) : "";
508
538
  try {
@@ -545,6 +575,7 @@ export function buildIndexRecord({
545
575
  ...frontmatter,
546
576
  ...(Object.keys(resolvedDates).length ? { resolvedDates } : {}),
547
577
  package: contentPackage,
578
+ names: marked.names,
548
579
  address: stub ? null : address,
549
580
  nameAscii: asciiName(frontmatter?.name?.full),
550
581
  aliasesAscii: asciiAliases(frontmatter?.name?.aliases),
@@ -557,7 +588,7 @@ export function buildIndexRecord({
557
588
  anchors:
558
589
  stub ? null : (
559
590
  [
560
- ...collectAnchors(body, bodyLine, resolveRole),
591
+ ...collectAnchors(body, bodyLine, resolveRole, syntax),
561
592
  ...eventAnchors(frontmatter, raw ?? rawNote(absPath)),
562
593
  ]
563
594
  .sort((a, b) => (a.line ?? 0) - (b.line ?? 0))
@@ -680,11 +711,16 @@ function buildDocRecord({ frontmatter, address, entry, file, contentPackage, anc
680
711
  );
681
712
  }
682
713
 
683
- /** Derive every index record belonging to one parsed note. */
714
+ /**
715
+ * Derive every index record belonging to one parsed note.
716
+ * @param {Parameters<typeof buildIndexRecord>[0]} options - Authored note and its context.
717
+ */
684
718
  export function indexRecordsForNote({
685
719
  frontmatter,
686
720
  body,
687
721
  bodyLine,
722
+ bodyColumn,
723
+ nameTypes,
688
724
  relPath,
689
725
  absPath,
690
726
  raw,
@@ -693,12 +729,15 @@ export function indexRecordsForNote({
693
729
  addressContext,
694
730
  dateContext,
695
731
  resolveRole,
732
+ syntax,
696
733
  }) {
697
734
  resolveNoteId(frontmatter, { pkg: contentPackage });
698
735
  const record = buildIndexRecord({
699
736
  frontmatter,
700
737
  body,
701
738
  bodyLine,
739
+ bodyColumn,
740
+ nameTypes,
702
741
  relPath,
703
742
  absPath,
704
743
  raw,
@@ -707,6 +746,7 @@ export function indexRecordsForNote({
707
746
  addressContext,
708
747
  dateContext,
709
748
  resolveRole,
749
+ syntax,
710
750
  });
711
751
  const records = [decodeIndexAddresses(record, addressContext ?? { package: contentPackage })];
712
752
  const address = noteAddress(frontmatter, contentPackage);
@@ -1063,6 +1103,9 @@ export function resolveCultures(
1063
1103
  * @param {Map<string, {body: string, frontmatter: object|null, raw: string, bodyLine?: number, bodyColumn?: number}>} [options.parsed] -
1064
1104
  * Filled with each note's source text, body coordinates and frontmatter as
1065
1105
  * authored, keyed by file, for reuse throughout the command.
1106
+ * @param {Map<string, {raw: string}>} [options.sources] - Selected captured source strings.
1107
+ * @param {import("./content-session.mjs").ContentSyntax} [options.syntax] - Session scanner projections.
1108
+ * @param {Iterable<string>} [options.nameTypes] - Additional bare name kinds from configured type declarations.
1066
1109
  * @returns {Array<Record<string, any>>} The records, in a total order that does
1067
1110
  * not depend on directory-read order.
1068
1111
  */
@@ -1077,12 +1120,15 @@ export function collectContentIndex(
1077
1120
  addressContext,
1078
1121
  foreignRecord,
1079
1122
  parsed = new Map(),
1123
+ sources,
1124
+ syntax,
1125
+ nameTypes,
1080
1126
  },
1081
1127
  ) {
1082
1128
  const records = [];
1083
1129
  // Passed through rather than defaulted away: an absent scope is the
1084
1130
  // caller's omission, and `walkMarkdownTree` says so.
1085
- const walkOpts = { skipDirectories };
1131
+ const walkOpts = { skipDirectories, sources, ...(sources ? { renderDescription: false } : {}) };
1086
1132
 
1087
1133
  // A package may ship assets and no notes at all, in which case there is no
1088
1134
  // tree to walk and the index is its asset records. The caller decides
@@ -1171,10 +1217,13 @@ export function collectContentIndex(
1171
1217
  contentPackage,
1172
1218
  body,
1173
1219
  bodyLine,
1220
+ bodyColumn,
1221
+ nameTypes,
1174
1222
  manifest,
1175
1223
  addressContext: decodeContext,
1176
1224
  dateContext: dates,
1177
1225
  resolveRole,
1226
+ syntax,
1178
1227
  });
1179
1228
  records.push(...made);
1180
1229
  bodies.push({ record: made[0], body });
@@ -1260,11 +1309,12 @@ export function collectContentIndex(
1260
1309
  const key = canonicalKey(contentPackage, "note", record.type, record.shortcode);
1261
1310
  const sections = (isGmNote(record) ? allSections : publicSections).get(key);
1262
1311
  if (!sections) continue;
1263
- const expanded = insertLexicalSections(body, sections).markdown;
1312
+ const expanded = insertLexicalSections(body, sections, { syntax }).markdown;
1264
1313
  projectPublicationBody(records, record, expanded, {
1265
1314
  contentPackage,
1266
1315
  manifest,
1267
1316
  resolveRole,
1317
+ syntax,
1268
1318
  });
1269
1319
  }
1270
1320
 
@@ -1415,6 +1465,10 @@ function dependencyAddresses(config) {
1415
1465
  * Filled with each note's body and authored frontmatter, keyed by the note's
1416
1466
  * file. Handed to {@link module:engine/sql-tables.prepareTreeSqlTables}, it
1417
1467
  * spares that pass reading and parsing every note again.
1468
+ * @param {Map<string, {raw: string}>} [opts.sources] - Selected source strings, in index walk order.
1469
+ * @param {import("./content-session.mjs").ContentSyntax} [opts.syntax] - Session scanner projections.
1470
+ * @param {Iterable<string>} [opts.nameTypes] - Bare name kinds, defaulting to configured item types.
1471
+ * @param {Map<string, object>} [opts.foreignIndex] - Captured dependency address view.
1418
1472
  * @returns {object[]} One record per note, plus one per documentation entry.
1419
1473
  */
1420
1474
  export function indexRecordsFor({
@@ -1424,6 +1478,10 @@ export function indexRecordsFor({
1424
1478
  skipDirectories,
1425
1479
  problems,
1426
1480
  parsed,
1481
+ sources,
1482
+ syntax,
1483
+ nameTypes,
1484
+ foreignIndex,
1427
1485
  } = {}) {
1428
1486
  const resolved = config ?? loadPackConfig();
1429
1487
  const tree = contentBase ?? resolved.paths.content;
@@ -1434,7 +1492,7 @@ export function indexRecordsFor({
1434
1492
  // a package whose parents are all its own reads no dependency index.
1435
1493
  let foreign;
1436
1494
  const foreignRecord = (key) => {
1437
- foreign ??= loadForeignIndexes(resolved, [resolved.contentPackage]).index;
1495
+ foreign ??= foreignIndex ?? loadForeignIndexes(resolved, [resolved.contentPackage]).index;
1438
1496
  return foreign.get(key);
1439
1497
  };
1440
1498
  return collectContentIndex(tree, {
@@ -1446,10 +1504,13 @@ export function indexRecordsFor({
1446
1504
  skipDirectories: skipDirectories ?? resolved.skipDirectories,
1447
1505
  assetsBase: assetsBase ?? resolved.paths.assets,
1448
1506
  // Only the identities a UUID is a function of — see emitContentIndex.
1449
- manifest: foundryIdentities(resolved),
1507
+ manifest: { ...foundryIdentities(resolved), syntax },
1450
1508
  problems,
1451
1509
  foreignRecord,
1452
1510
  parsed,
1511
+ sources,
1512
+ syntax,
1513
+ nameTypes: nameTypes ?? resolved.itemTypes,
1453
1514
  });
1454
1515
  }
1455
1516
 
@@ -1516,17 +1577,20 @@ function publicationExpressions(expanded, context) {
1516
1577
  * corpus, with target maps limited to that source's declared prose slugs.
1517
1578
  * No output files are written, and the returned inputs are read-only to the emitter.
1518
1579
  *
1519
- * @param {{contentBase?: string, config?: object}} [options]
1580
+ * @param {{contentBase?: string, config?: object, session?: import("./content-session.mjs").ContentSession}} [options]
1520
1581
  * @returns {Promise<PreparedContentIndex>}
1521
1582
  */
1522
- export async function prepareContentIndex({ contentBase, config } = {}) {
1583
+ export async function prepareContentIndex({ contentBase, config, session } = {}) {
1523
1584
  const resolved = config ?? loadPackConfig();
1524
1585
  const tree = path.resolve(contentBase ?? resolved.paths.content);
1525
- const parsed = new Map();
1526
- const records = indexRecordsFor({ contentBase: tree, config: resolved, parsed });
1586
+ session?.assertContext({ config: resolved, contentBase: tree });
1587
+ const parsed = session?.parsed ?? new Map();
1588
+ const records =
1589
+ session?.records ?? indexRecordsFor({ contentBase: tree, config: resolved, parsed });
1590
+ if (session?.problems.length) publicationProblems(session.problems);
1527
1591
  const expandedBodies = new Map();
1528
1592
  const foundryBodies = new Map();
1529
- const foreign = loadForeignIndexes(resolved, [resolved.contentPackage]);
1593
+ const foreign = session?.foreign ?? loadForeignIndexes(resolved, [resolved.contentPackage]);
1530
1594
  publicationProblems(
1531
1595
  foreign.stale.map((entry) => ({
1532
1596
  file: tree,
@@ -1545,13 +1609,16 @@ export async function prepareContentIndex({ contentBase, config } = {}) {
1545
1609
  ])) {
1546
1610
  const visible = records.filter((record) => audience === "all" || !isGmNote(record));
1547
1611
  const sources = audience === "all" ? notes : notes.filter((record) => !isGmNote(record));
1548
- const prepared = await prepareTreeSqlTables(tree, {
1549
- config: resolved,
1550
- records,
1551
- parsed,
1552
- audience,
1553
- foreignIndex: foreign.index,
1554
- });
1612
+ const prepared =
1613
+ session ?
1614
+ await session.sqlTables(audience)
1615
+ : await prepareTreeSqlTables(tree, {
1616
+ config: resolved,
1617
+ records,
1618
+ parsed,
1619
+ audience,
1620
+ foreignIndex: foreign.index,
1621
+ });
1555
1622
  const bodies = new Map();
1556
1623
  const figuresByFile = new Map();
1557
1624
  const figuresByAddress = new Map();
@@ -1615,6 +1682,7 @@ export async function prepareContentIndex({ contentBase, config } = {}) {
1615
1682
  package: resolved.contentPackage,
1616
1683
  records: visible,
1617
1684
  foreignIndex: foreign.index,
1685
+ syntax: session?.syntax,
1618
1686
  });
1619
1687
  const dates = index.dateContext;
1620
1688
  const events = eventNoteIndex(visible, {
@@ -1651,7 +1719,15 @@ export async function prepareContentIndex({ contentBase, config } = {}) {
1651
1719
  expandedBodies.set(file, expanded);
1652
1720
  }
1653
1721
  }
1654
- return { config: resolved, contentBase: tree, records, parsed, expandedBodies, foundryBodies };
1722
+ return {
1723
+ config: resolved,
1724
+ contentBase: tree,
1725
+ records,
1726
+ parsed,
1727
+ expandedBodies,
1728
+ foundryBodies,
1729
+ syntax: session?.syntax,
1730
+ };
1655
1731
  }
1656
1732
 
1657
1733
  /**
@@ -1663,6 +1739,7 @@ export async function prepareContentIndex({ contentBase, config } = {}) {
1663
1739
  * @property {Map<string, {frontmatter: object|null, body: string, raw: string, bodyLine?: number, bodyColumn?: number}>} parsed - Authored sources by absolute file.
1664
1740
  * @property {Map<string, {markdown: string, lineMap: Array<{line: number, generated: boolean}>, generatedFrom?: number}>} expandedBodies - Complete rendered bodies in each note's audience.
1665
1741
  * @property {Map<string, {markdown: string, lineMap: Array<{line: number, generated: boolean}>, generatedFrom?: number}>} foundryBodies - Complete native bodies with explicit SQL using the ALL corpus.
1742
+ * @property {import("./content-session.mjs").ContentSyntax} [syntax] - Session-local exact-text scanner projections.
1666
1743
  */
1667
1744
 
1668
1745
  /** Update owned copies from an actual output body, retaining authored locations and events. */
@@ -1670,7 +1747,7 @@ function projectPublicationBody(
1670
1747
  records,
1671
1748
  record,
1672
1749
  body,
1673
- { contentPackage, manifest, resolveRole, foundryBody = body },
1750
+ { contentPackage, manifest, resolveRole, foundryBody = body, syntax },
1674
1751
  ) {
1675
1752
  const fm = authoredFrontmatter(record);
1676
1753
  const address = noteAddress(fm, contentPackage);
@@ -1685,7 +1762,7 @@ function projectPublicationBody(
1685
1762
  record.anchors =
1686
1763
  stub ? null : (
1687
1764
  [
1688
- ...collectAnchors(body, 1, resolveRole).map((anchor) => {
1765
+ ...collectAnchors(body, 1, resolveRole, syntax).map((anchor) => {
1689
1766
  const { line: _, ...generated } = anchor;
1690
1767
  return {
1691
1768
  ...(original.get(anchor.slug) ?? generated),
@@ -1839,12 +1916,14 @@ export function emitContentIndex({ contentBase, outDir, config, publication } =
1839
1916
  );
1840
1917
  const body =
1841
1918
  publication?.expandedBodies.get(file)?.markdown ??
1842
- insertLexicalSections(parsed.get(file).body, lexical).markdown;
1919
+ insertLexicalSections(parsed.get(file).body, lexical, { syntax: publication?.syntax })
1920
+ .markdown;
1843
1921
  projectPublicationBody(records, record, body, {
1844
1922
  contentPackage,
1845
- manifest: foundryIdentities(resolved),
1923
+ manifest: { ...foundryIdentities(resolved), syntax: publication?.syntax },
1846
1924
  resolveRole: (address) => embedRole(art, address),
1847
1925
  foundryBody: publication?.foundryBodies.get(file).markdown,
1926
+ syntax: publication?.syntax,
1848
1927
  });
1849
1928
  }
1850
1929
  const assetBindings = checkForeignAssetBindings(resolved);
@@ -59,6 +59,7 @@ import { cultureRecordProblems, cultureInheritedFields } from "./culture-parents
59
59
  import path from "node:path";
60
60
 
61
61
  import { matchAllOutsideCode } from "./code-fences.mjs";
62
+ import { splitFrontmatter } from "./diagnostics.mjs";
62
63
  import { expandContentTables } from "./content-tables.mjs";
63
64
  import { collectAnchors } from "./anchors.mjs";
64
65
  // The corpus, and everything derived from it, read from the one place that
@@ -129,13 +130,11 @@ export function anchorsOf(body) {
129
130
  * not hypothetical: this module carried its own anchor reader until the anchor
130
131
  * anchor half, and the two disagreed about which anchors existed.
131
132
  *
132
- * **The file is opened for its bytes and nothing else.** The index deliberately
133
- * carries no note *body*, and a link lives in the body — so each note is read
134
- * once, here, for the prose. Everything *about* the note — its frontmatter, its
135
- * addresses, its anchors — is already in the record, and none of it is derived
136
- * a second time. That is one read per note rather than the two this module did
137
- * before, since the walk read the file and it then read it again for the raw
138
- * text.
133
+ * **Raw sources can come from the caller's capture.** The published index
134
+ * carries no note body, while a link lives in the prose. A caller that already
135
+ * captured the sources supplies `parsed`, so this pass uses those exact raw
136
+ * strings. Without a captured string, it opens the file for its bytes.
137
+ * Frontmatter, addresses and anchors still come from the records.
139
138
  *
140
139
  * The index holds each compiled document under its complete Address. Readable
141
140
  * content uses `note`, and system documents use their declared system.
@@ -153,14 +152,27 @@ export function anchorsOf(body) {
153
152
  * @param {object[]} [opts.records] - Index records the caller already derived,
154
153
  * so a command that also needs them — every one of them does, to answer its
155
154
  * `sql` tables — enumerates the corpus once rather than twice.
155
+ * @param {Map<string, {raw: string}>} [opts.parsed] - Sources captured alongside
156
+ * `records` in this invocation, keyed by absolute note path. A raw string is
157
+ * used verbatim; a missing string falls back to reading the file. The caller
158
+ * owns the map, which this function only reads.
156
159
  * @param {object[]} [opts.problems] - Collects the notes the index cannot
157
160
  * record, as diagnostics, instead of letting one of them abort the check
158
161
  * before it has reported anything else.
162
+ * @param {ReturnType<import("./metadata-index.mjs").loadForeignIndexes>} [opts.foreign] - Captured dependency address and reference views.
159
163
  * @returns {object} The notes, the index, and the resolvers built over it.
160
164
  */
161
165
  export function buildLinkIndex(
162
166
  contentBase,
163
- { config, skipDirectories, sqlTables, records, problems } = {},
167
+ {
168
+ config,
169
+ skipDirectories,
170
+ sqlTables,
171
+ records,
172
+ parsed,
173
+ problems,
174
+ foreign: capturedForeign,
175
+ } = {},
164
176
  ) {
165
177
  const notes = [];
166
178
  const frontmatterLinks = [];
@@ -221,8 +233,10 @@ export function buildLinkIndex(
221
233
  const absPath = noteFile(contentBase, record);
222
234
  // The raw text is kept beside the parsed body: a consumer's own checks
223
235
  // may need what frontmatter carried, which the body has dropped.
224
- const raw = fs.readFileSync(absPath, "utf8");
225
- const body = raw.replace(/^---\n[\s\S]*?\n---\n?/, "");
236
+ const capturedRaw = parsed?.get(absPath)?.raw;
237
+ const raw =
238
+ typeof capturedRaw === "string" ? capturedRaw : fs.readFileSync(absPath, "utf8");
239
+ const body = splitFrontmatter(raw)?.body ?? raw;
226
240
  const type = record.type.toLowerCase();
227
241
  const note = { file: absPath, rel, fm, body, raw, type };
228
242
  const hierarchyProblems = cultureRecordProblems(record);
@@ -295,9 +309,10 @@ export function buildLinkIndex(
295
309
  // is never checked at all.
296
310
  const localPackages = new Set([pkg]);
297
311
  const foreign =
298
- config ?
312
+ capturedForeign ??
313
+ (config ?
299
314
  loadForeignIndexes(config, localPackages)
300
- : { index: new Map(), packages: new Set(), stale: [] };
315
+ : { index: new Map(), packages: new Set(), stale: [] });
301
316
  for (const v of foreign.index.values()) if (v.type) types.add(v.type);
302
317
 
303
318
  // A stub counts. It publishes no page, so nothing may *link* to one, but its