@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
@@ -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
@@ -429,13 +444,17 @@ export function buildLinkIndex(
429
444
  // Code is verbatim, so a `[[…]]` inside a fence, an indented block or an
430
445
  // inline span is not a link — the compilers make none of it either.
431
446
  for (const [all, rawInner] of matchAllOutsideCode(body, new RegExp(WIKILINK.source, "g"))) {
432
- const parsed = readWikilink(rawInner, {
433
- package: pkg,
434
- system: "note",
435
- types,
436
- packages,
437
- noIndexPackages,
438
- });
447
+ const parsed = readWikilink(
448
+ rawInner,
449
+ {
450
+ package: pkg,
451
+ system: "note",
452
+ types,
453
+ packages,
454
+ noIndexPackages,
455
+ },
456
+ all,
457
+ );
439
458
  const target = parsed.target.kind ? parsed.rawTarget : parsed.target;
440
459
  const { anchor, rawTarget } = parsed;
441
460
  const occurrence = (seen.get(all) ?? 0) + 1;
@@ -444,6 +463,7 @@ export function buildLinkIndex(
444
463
  // in the file. A link a table generated is not in the file at all,
445
464
  // so the search simply fails and a finding names the file.
446
465
  out.push({
466
+ attributeProblems: parsed.attributes?.problems ?? [],
447
467
  target,
448
468
  rawTarget,
449
469
  anchor,
@@ -947,15 +967,23 @@ export function auditLinks(index) {
947
967
 
948
968
  const deadAnchors = [];
949
969
  for (const note of notes) {
950
- for (const { target, rawTarget, anchor, text, occurrence, labelled } of linksOf(note)) {
970
+ for (const {
971
+ target,
972
+ rawTarget,
973
+ anchor,
974
+ text,
975
+ occurrence,
976
+ labelled,
977
+ attributeProblems,
978
+ } of linksOf(note)) {
951
979
  if (!anchor || !labelled) continue;
952
980
  const local = target ? resolve(target) : note;
953
981
  const foreign = !local && target ? manifestHit(target) : null;
954
982
  // An unresolved address is reported below. A foreign sheet opens
955
983
  // its system document, so only the readable note view names prose
956
- // sections. A public note's GM target is an audience refusal below.
984
+ // sections, including private destinations.
957
985
  if (!local) {
958
- if (!foreign || (foreign.gm && !isGmNote(note.fm))) continue;
986
+ if (!foreign) continue;
959
987
  const qualified = readQualifier(
960
988
  target,
961
989
  index.types,
@@ -1001,7 +1029,24 @@ export function auditLinks(index) {
1001
1029
  const unlabelledLinks = [];
1002
1030
  const usedManifest = new Set();
1003
1031
  for (const note of notes) {
1004
- for (const { target, rawTarget, anchor, text, occurrence, labelled } of linksOf(note)) {
1032
+ for (const {
1033
+ target,
1034
+ rawTarget,
1035
+ anchor,
1036
+ text,
1037
+ occurrence,
1038
+ labelled,
1039
+ attributeProblems,
1040
+ } of linksOf(note)) {
1041
+ if (attributeProblems.length)
1042
+ deadAddresses.push({
1043
+ note,
1044
+ target: rawTarget,
1045
+ text,
1046
+ occurrence,
1047
+ reason: "invalid-attributes",
1048
+ problems: attributeProblems,
1049
+ });
1005
1050
  // The label is required whatever the link part is, an anchor
1006
1051
  // included — so this is tested before the same-page form.
1007
1052
  if (!labelled) {
@@ -1048,9 +1093,6 @@ export function auditLinks(index) {
1048
1093
  }
1049
1094
  const resolved = index.resolveAddress(target);
1050
1095
  if (resolved) {
1051
- if ((isGmNote(resolved.fm) || resolved.gm) && !isGmNote(note.fm)) {
1052
- deadAddresses.push({ ...at, reason: "gm" });
1053
- }
1054
1096
  continue;
1055
1097
  }
1056
1098
  // A stub is in the index, so the refusal can name it. Asked before
@@ -1069,10 +1111,6 @@ export function auditLinks(index) {
1069
1111
  // report.
1070
1112
  const manifest = manifestHit(target);
1071
1113
  if (manifest) {
1072
- if (manifest.gm && !isGmNote(note.fm)) {
1073
- deadAddresses.push({ ...at, reason: "gm" });
1074
- continue;
1075
- }
1076
1114
  usedManifest.add(encodeAddresses(target).toLowerCase());
1077
1115
  continue;
1078
1116
  }