@heroiclands/package-build 23.8.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 (114) hide show
  1. package/CHANGELOG.md +53 -0
  2. package/README.md +2 -0
  3. package/bin/content-commands.mjs +207 -70
  4. package/docs/api.md +646 -78
  5. package/docs/authoring/first-note.md +1 -1
  6. package/docs/authoring/links-and-markup.md +253 -102
  7. package/docs/commands.md +111 -13
  8. package/docs/content-format.md +7 -2
  9. package/docs/diagnostics.md +1 -1
  10. package/docs/guides/packs.md +2 -1
  11. package/docs/guides/reference-pages.md +86 -63
  12. package/docs/reference/format-details.md +398 -137
  13. package/docs/reference/note-types.md +188 -174
  14. package/engine/anchors.mjs +15 -26
  15. package/engine/base-compiler.mjs +6 -4
  16. package/engine/changelog-group.mjs +58 -34
  17. package/engine/changelog-lint.mjs +6 -2
  18. package/engine/compendiums.mjs +15 -1
  19. package/engine/compile-corpus.mjs +17 -3
  20. package/engine/content-charset.mjs +13 -1
  21. package/engine/content-format.yaml +1 -0
  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 +496 -16
  26. package/engine/content-links.mjs +70 -27
  27. package/engine/content-session.mjs +247 -0
  28. package/engine/content-sources.mjs +110 -0
  29. package/engine/content-spans.mjs +87 -8
  30. package/engine/content-tables.mjs +9 -4
  31. package/engine/content-tasklists.mjs +20 -4
  32. package/engine/culture-reach.mjs +61 -34
  33. package/engine/data-keys.mjs +3 -1
  34. package/engine/diagnostics.mjs +17 -6
  35. package/engine/dictionary-layout.mjs +151 -19
  36. package/engine/dictionary-rows.mjs +129 -79
  37. package/engine/extension-attributes.mjs +28 -5
  38. package/engine/first-use.mjs +371 -0
  39. package/engine/foundry-entries.mjs +29 -8
  40. package/engine/generate.mjs +13 -4
  41. package/engine/glossary-entries.mjs +11 -11
  42. package/engine/heading-attributes.mjs +21 -28
  43. package/engine/helpers.mjs +54 -19
  44. package/engine/index-records.mjs +1 -0
  45. package/engine/index.mjs +3 -0
  46. package/engine/journals.mjs +13 -6
  47. package/engine/language-data.mjs +55 -2
  48. package/engine/language-rows.mjs +4 -4
  49. package/engine/language-terms.mjs +90 -4
  50. package/engine/language-words.mjs +2 -2
  51. package/engine/lexical-sections.mjs +247 -0
  52. package/engine/markup-validation.mjs +7 -2
  53. package/engine/metadata-index.mjs +1 -0
  54. package/engine/name-markup.mjs +114 -0
  55. package/engine/name-rows.mjs +70 -0
  56. package/engine/names.mjs +460 -0
  57. package/engine/note-addresses.mjs +25 -4
  58. package/engine/note-claims.mjs +2 -0
  59. package/engine/note-state.mjs +9 -3
  60. package/engine/note-vocabulary.mjs +7 -6
  61. package/engine/path-charset.mjs +24 -1
  62. package/engine/pdf-build.mjs +9 -9
  63. package/engine/pdf-render.mjs +15 -1
  64. package/engine/site-build.mjs +16 -2
  65. package/engine/site-index.mjs +5 -1
  66. package/engine/sql-tables.mjs +324 -44
  67. package/engine/stub-lint.mjs +10 -5
  68. package/engine/web-wikilinks.mjs +8 -6
  69. package/engine/word-rows.mjs +20 -12
  70. package/engine/word-terms.mjs +15 -6
  71. package/engine/yaml-lint.mjs +3 -5
  72. package/hugo-theme/static/css/style.css +4 -0
  73. package/package.json +1 -1
  74. package/types/engine/anchors.d.mts +4 -2
  75. package/types/engine/compendiums.d.mts +5 -1
  76. package/types/engine/compile-corpus.d.mts +5 -2
  77. package/types/engine/content-charset.d.mts +5 -1
  78. package/types/engine/content-html.d.mts +5 -1
  79. package/types/engine/content-icons.d.mts +5 -1
  80. package/types/engine/content-images.d.mts +5 -1
  81. package/types/engine/content-index.d.mts +102 -18
  82. package/types/engine/content-links.d.mts +22 -8
  83. package/types/engine/content-session.d.mts +108 -0
  84. package/types/engine/content-sources.d.mts +32 -0
  85. package/types/engine/content-spans.d.mts +3 -5
  86. package/types/engine/content-tables.d.mts +2 -1
  87. package/types/engine/content-tasklists.d.mts +5 -1
  88. package/types/engine/culture-reach.d.mts +22 -2
  89. package/types/engine/data-keys.d.mts +7 -0
  90. package/types/engine/diagnostics.d.mts +10 -0
  91. package/types/engine/dictionary-layout.d.mts +35 -4
  92. package/types/engine/dictionary-rows.d.mts +9 -0
  93. package/types/engine/extension-attributes.d.mts +16 -2
  94. package/types/engine/first-use.d.mts +31 -0
  95. package/types/engine/foundry-entries.d.mts +14 -5
  96. package/types/engine/generate.d.mts +5 -1
  97. package/types/engine/heading-attributes.d.mts +2 -2
  98. package/types/engine/helpers.d.mts +33 -7
  99. package/types/engine/index.d.mts +1 -0
  100. package/types/engine/journals.d.mts +5 -2
  101. package/types/engine/language-terms.d.mts +7 -0
  102. package/types/engine/lexical-sections.d.mts +96 -0
  103. package/types/engine/markup-validation.d.mts +2 -1
  104. package/types/engine/name-markup.d.mts +176 -0
  105. package/types/engine/name-rows.d.mts +35 -0
  106. package/types/engine/names.d.mts +356 -0
  107. package/types/engine/note-addresses.d.mts +3 -3
  108. package/types/engine/note-state.d.mts +2 -1
  109. package/types/engine/path-charset.d.mts +16 -1
  110. package/types/engine/site-build.d.mts +6 -3
  111. package/types/engine/site-index.d.mts +3 -1
  112. package/types/engine/sql-tables.d.mts +39 -4
  113. package/types/engine/stub-lint.d.mts +3 -1
  114. package/types/engine/word-rows.d.mts +9 -7
@@ -99,8 +99,12 @@ import {
99
99
  noteAddressContext,
100
100
  encodeAddresses,
101
101
  publishAddresses,
102
+ cloneAddressState,
102
103
  } from "./note-addresses.mjs";
103
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";
104
108
  import fs from "node:fs";
105
109
  import path from "node:path";
106
110
 
@@ -113,9 +117,24 @@ import { addressSlug, canonicalKey } from "./content-address.mjs";
113
117
  import { isAddressTuple, ownDocumentSystem, readCanonicalKey, renderAddress } from "./address.mjs";
114
118
  import { NOTE_SYSTEM } from "./systems.mjs";
115
119
  import { dictionaryAndTermRows, serializeRows } from "./dictionary-rows.mjs";
120
+ import { insertLexicalSections, lexicalSectionsFor } from "./lexical-sections.mjs";
121
+ import { isGmNote } from "./note-vocabulary.mjs";
116
122
  import { attachCitations } from "./word-citations.mjs";
117
123
  import { assetAddressIndex } from "./art-fields.mjs";
118
124
  import { embedRole } from "./content-embeds.mjs";
125
+ import { expandContentTables } from "./content-tables.mjs";
126
+ import {
127
+ codeRegions,
128
+ FENCE_LINE,
129
+ matchAllOutsideCode,
130
+ parseHeaderArgs,
131
+ replaceOutsideCode,
132
+ } from "./code-fences.mjs";
133
+ import { PAGE_LIST_LANGUAGE } from "./page-lists.mjs";
134
+ import { EXPRESSION, renderMarkdownExpressions } from "./markdown-expressions.mjs";
135
+ import { scanFigures } from "./content-figures.mjs";
136
+ import { eventNoteIndex } from "./event-fields.mjs";
137
+ import { derivedSections } from "./derived-sections.mjs";
119
138
  // One reader for a note's anchors, shared with the link checker and with the
120
139
  // builds that emit a link. Re-exported because this is where callers
121
140
  // have always addressed it.
@@ -133,7 +152,7 @@ import { NO_SYSTEM } from "./document-subtypes.mjs";
133
152
  */
134
153
 
135
154
  export { collectAnchors };
136
- import { entriesForNote, foundryIdentities } from "./foundry-entries.mjs";
155
+ import { entriesForNote, foundryIdentities, LEAD_ANCHOR } from "./foundry-entries.mjs";
137
156
  import { walkMarkdownTree } from "./helpers.mjs";
138
157
  // The one statement of what an empty body means, shared with the lint and the
139
158
  // SQL view that derives the ladder from the absence this writes.
@@ -453,6 +472,8 @@ function assertNoDerivedKeys(frontmatter, relPath, absPath, contentPackage, raw)
453
472
  * and for the one question that decides whether the note publishes a page. A
454
473
  * caller that states no body is stating an empty one, and the note is a stub.
455
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.
456
477
  * @param {object} [options.manifest] - The package manifest, which the Foundry
457
478
  * entries are derived against.
458
479
  * @param {object} [options.addressContext] - Address resolution context.
@@ -461,6 +482,7 @@ function assertNoDerivedKeys(frontmatter, relPath, absPath, contentPackage, raw)
461
482
  * a picture's address to the role its asset declares, so a figure anchor's
462
483
  * `name` reads `Map 1` rather than `Figure 1` where it is due — see
463
484
  * {@link module:engine/content-figures.scanFigures}.
485
+ * @param {import("./content-session.mjs").ContentSyntax} [options.syntax] - Session scanner projections.
464
486
  * @returns {Record<string, any>} The record, keys sorted at every depth. A
465
487
  * **stub** — a note with an empty body, on a type an empty body suppresses —
466
488
  * carries `address` and `anchors` as `null`: it publishes no page, so it
@@ -480,13 +502,37 @@ export function buildIndexRecord({
480
502
  contentPackage,
481
503
  body,
482
504
  bodyLine,
505
+ bodyColumn,
506
+ nameTypes,
483
507
  manifest,
484
508
  addressContext,
485
509
  dateContext,
486
510
  resolveRole,
511
+ syntax,
487
512
  }) {
488
513
  assertNoDerivedKeys(frontmatter, relPath, absPath, contentPackage, raw);
489
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
+
490
536
  const posix = relPath.split(path.sep).join("/");
491
537
  const folder = posix.includes("/") ? posix.slice(0, posix.lastIndexOf("/")) : "";
492
538
  try {
@@ -529,6 +575,7 @@ export function buildIndexRecord({
529
575
  ...frontmatter,
530
576
  ...(Object.keys(resolvedDates).length ? { resolvedDates } : {}),
531
577
  package: contentPackage,
578
+ names: marked.names,
532
579
  address: stub ? null : address,
533
580
  nameAscii: asciiName(frontmatter?.name?.full),
534
581
  aliasesAscii: asciiAliases(frontmatter?.name?.aliases),
@@ -541,7 +588,7 @@ export function buildIndexRecord({
541
588
  anchors:
542
589
  stub ? null : (
543
590
  [
544
- ...collectAnchors(body, bodyLine, resolveRole),
591
+ ...collectAnchors(body, bodyLine, resolveRole, syntax),
545
592
  ...eventAnchors(frontmatter, raw ?? rawNote(absPath)),
546
593
  ]
547
594
  .sort((a, b) => (a.line ?? 0) - (b.line ?? 0))
@@ -643,22 +690,37 @@ function buildDocRecord({ frontmatter, address, entry, file, contentPackage, anc
643
690
  // entry's derivation.
644
691
  id: made ? entry.id : null,
645
692
  nameAscii: asciiName(frontmatter?.name?.full),
646
- address: made ? { slug: address.slug, canonical: entry.key } : null,
693
+ address: made ? { slug: address.slug, canonical: readCanonicalKey(entry.key) } : null,
647
694
  // The record this is the documentation *for*. `documentation` is
648
695
  // the forward link on that record, so either end reaches the other.
649
- documents: address.canonical,
650
- anchors: made ? anchors : null,
696
+ documents: readCanonicalKey(address.canonical),
697
+ anchors:
698
+ made ?
699
+ anchors.map((anchor) => ({
700
+ ...anchor,
701
+ link: new AddressLink(
702
+ readCanonicalKey(entry.key),
703
+ anchor.slug,
704
+ anchor.kind,
705
+ ),
706
+ }))
707
+ : null,
651
708
  foundry: foundryBlock(entry, NOTE_SYSTEM),
652
709
  file,
653
710
  })
654
711
  );
655
712
  }
656
713
 
657
- /** 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
+ */
658
718
  export function indexRecordsForNote({
659
719
  frontmatter,
660
720
  body,
661
721
  bodyLine,
722
+ bodyColumn,
723
+ nameTypes,
662
724
  relPath,
663
725
  absPath,
664
726
  raw,
@@ -667,12 +729,15 @@ export function indexRecordsForNote({
667
729
  addressContext,
668
730
  dateContext,
669
731
  resolveRole,
732
+ syntax,
670
733
  }) {
671
734
  resolveNoteId(frontmatter, { pkg: contentPackage });
672
735
  const record = buildIndexRecord({
673
736
  frontmatter,
674
737
  body,
675
738
  bodyLine,
739
+ bodyColumn,
740
+ nameTypes,
676
741
  relPath,
677
742
  absPath,
678
743
  raw,
@@ -681,6 +746,7 @@ export function indexRecordsForNote({
681
746
  addressContext,
682
747
  dateContext,
683
748
  resolveRole,
749
+ syntax,
684
750
  });
685
751
  const records = [decodeIndexAddresses(record, addressContext ?? { package: contentPackage })];
686
752
  const address = noteAddress(frontmatter, contentPackage);
@@ -1037,6 +1103,9 @@ export function resolveCultures(
1037
1103
  * @param {Map<string, {body: string, frontmatter: object|null, raw: string, bodyLine?: number, bodyColumn?: number}>} [options.parsed] -
1038
1104
  * Filled with each note's source text, body coordinates and frontmatter as
1039
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.
1040
1109
  * @returns {Array<Record<string, any>>} The records, in a total order that does
1041
1110
  * not depend on directory-read order.
1042
1111
  */
@@ -1051,12 +1120,15 @@ export function collectContentIndex(
1051
1120
  addressContext,
1052
1121
  foreignRecord,
1053
1122
  parsed = new Map(),
1123
+ sources,
1124
+ syntax,
1125
+ nameTypes,
1054
1126
  },
1055
1127
  ) {
1056
1128
  const records = [];
1057
1129
  // Passed through rather than defaulted away: an absent scope is the
1058
1130
  // caller's omission, and `walkMarkdownTree` says so.
1059
- const walkOpts = { skipDirectories };
1131
+ const walkOpts = { skipDirectories, sources, ...(sources ? { renderDescription: false } : {}) };
1060
1132
 
1061
1133
  // A package may ship assets and no notes at all, in which case there is no
1062
1134
  // tree to walk and the index is its asset records. The caller decides
@@ -1145,10 +1217,13 @@ export function collectContentIndex(
1145
1217
  contentPackage,
1146
1218
  body,
1147
1219
  bodyLine,
1220
+ bodyColumn,
1221
+ nameTypes,
1148
1222
  manifest,
1149
1223
  addressContext: decodeContext,
1150
1224
  dateContext: dates,
1151
1225
  resolveRole,
1226
+ syntax,
1152
1227
  });
1153
1228
  records.push(...made);
1154
1229
  bodies.push({ record: made[0], body });
@@ -1222,6 +1297,27 @@ export function collectContentIndex(
1222
1297
  for (const [note, inherited] of inheritance.values)
1223
1298
  if (Object.keys(inherited).length) note.fm.data = { ...note.fm.data, ...inherited };
1224
1299
 
1300
+ // Automatic lexical sections require only the recorded frontmatter. Their
1301
+ // published anchors and journal targets therefore use the same pure body
1302
+ // projection as the surfaces, without making the index emitter async.
1303
+ // Public notes resolve providers in their audience, including lineage
1304
+ // through hidden languages, just as their build does.
1305
+ const visible = records.filter((record) => !isGmNote(record));
1306
+ const publicSections = lexicalSectionsFor(visible, records);
1307
+ const allSections = lexicalSectionsFor(records);
1308
+ for (const { record, body } of bodies) {
1309
+ const key = canonicalKey(contentPackage, "note", record.type, record.shortcode);
1310
+ const sections = (isGmNote(record) ? allSections : publicSections).get(key);
1311
+ if (!sections) continue;
1312
+ const expanded = insertLexicalSections(body, sections, { syntax }).markdown;
1313
+ projectPublicationBody(records, record, expanded, {
1314
+ contentPackage,
1315
+ manifest,
1316
+ resolveRole,
1317
+ syntax,
1318
+ });
1319
+ }
1320
+
1225
1321
  // Sorted at every depth like a note's record, and for the same reason: the
1226
1322
  // declaration order of the `asset` fields is a fact about the emitter, not
1227
1323
  // about the content, and the artifact is meant to be byte-identical across
@@ -1369,6 +1465,10 @@ function dependencyAddresses(config) {
1369
1465
  * Filled with each note's body and authored frontmatter, keyed by the note's
1370
1466
  * file. Handed to {@link module:engine/sql-tables.prepareTreeSqlTables}, it
1371
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.
1372
1472
  * @returns {object[]} One record per note, plus one per documentation entry.
1373
1473
  */
1374
1474
  export function indexRecordsFor({
@@ -1378,6 +1478,10 @@ export function indexRecordsFor({
1378
1478
  skipDirectories,
1379
1479
  problems,
1380
1480
  parsed,
1481
+ sources,
1482
+ syntax,
1483
+ nameTypes,
1484
+ foreignIndex,
1381
1485
  } = {}) {
1382
1486
  const resolved = config ?? loadPackConfig();
1383
1487
  const tree = contentBase ?? resolved.paths.content;
@@ -1388,7 +1492,7 @@ export function indexRecordsFor({
1388
1492
  // a package whose parents are all its own reads no dependency index.
1389
1493
  let foreign;
1390
1494
  const foreignRecord = (key) => {
1391
- foreign ??= loadForeignIndexes(resolved, [resolved.contentPackage]).index;
1495
+ foreign ??= foreignIndex ?? loadForeignIndexes(resolved, [resolved.contentPackage]).index;
1392
1496
  return foreign.get(key);
1393
1497
  };
1394
1498
  return collectContentIndex(tree, {
@@ -1400,11 +1504,307 @@ export function indexRecordsFor({
1400
1504
  skipDirectories: skipDirectories ?? resolved.skipDirectories,
1401
1505
  assetsBase: assetsBase ?? resolved.paths.assets,
1402
1506
  // Only the identities a UUID is a function of — see emitContentIndex.
1403
- manifest: foundryIdentities(resolved),
1507
+ manifest: { ...foundryIdentities(resolved), syntax },
1404
1508
  problems,
1405
1509
  foreignRecord,
1406
1510
  parsed,
1511
+ sources,
1512
+ syntax,
1513
+ nameTypes: nameTypes ?? resolved.itemTypes,
1514
+ });
1515
+ }
1516
+
1517
+ /** Active body directives whose output cannot be inferred synchronously. */
1518
+ function dynamicPublicationBody(body) {
1519
+ const source = String(body ?? "");
1520
+ return (
1521
+ [...matchAllOutsideCode(source, EXPRESSION)].length > 0 ||
1522
+ codeRegions(source, { spans: false, poetryAsProse: false }).some(({ start, end }) => {
1523
+ const opener = FENCE_LINE.exec(source.slice(start, end).split("\n", 1)[0]);
1524
+ const language = opener && parseHeaderArgs(opener[3]).language;
1525
+ return language === "sql" || language === PAGE_LIST_LANGUAGE;
1526
+ })
1527
+ );
1528
+ }
1529
+
1530
+ /** Publication errors are located and always precede output creation. */
1531
+ function publicationProblems(problems) {
1532
+ if (!problems.length) return;
1533
+ throw Object.assign(new Error(problems.map(formatDiagnostic).join("\n")), { located: true });
1534
+ }
1535
+
1536
+ /** Render the shared expression language and carry inserted scalar lines back to their source. */
1537
+ function publicationExpressions(expanded, context) {
1538
+ const source = expanded.markdown;
1539
+ const additions = [];
1540
+ const findings = [];
1541
+ const markdown = replaceOutsideCode(source, EXPRESSION, (expression, _inside, offset) => {
1542
+ const line = source.slice(0, offset).split("\n").length - 1;
1543
+ const location = expanded.lineMap[line] ?? { line, generated: false };
1544
+ const result = renderMarkdownExpressions(expression, {
1545
+ ...context,
1546
+ bodyLine: (context.bodyLine ?? 1) + location.line,
1547
+ });
1548
+ const column = offset - source.lastIndexOf("\n", offset - 1) - 1;
1549
+ findings.push(
1550
+ ...result.findings.map((finding) => ({ ...finding, column: finding.column + column })),
1551
+ );
1552
+ const count = result.markdown.split("\n").length - 1;
1553
+ if (count) additions.push({ line, count, location });
1554
+ return result.markdown;
1407
1555
  });
1556
+ publicationProblems(findings);
1557
+ const lineMap = expanded.lineMap.map((location) => ({ ...location }));
1558
+ let generatedFrom = expanded.generatedFrom;
1559
+ for (const addition of additions.sort((a, b) => b.line - a.line)) {
1560
+ lineMap.splice(
1561
+ addition.line + 1,
1562
+ 0,
1563
+ ...Array.from({ length: addition.count }, () => ({
1564
+ ...addition.location,
1565
+ generated: true,
1566
+ })),
1567
+ );
1568
+ if (generatedFrom !== undefined && addition.line < expanded.generatedFrom)
1569
+ generatedFrom += addition.count;
1570
+ }
1571
+ return { markdown, lineMap, ...(generatedFrom === undefined ? {} : { generatedFrom }) };
1572
+ }
1573
+
1574
+ /**
1575
+ * Prepare the complete bodies a content-index publication describes.
1576
+ * Prose declarations use each source's audience. Native SQL uses the complete
1577
+ * corpus, with target maps limited to that source's declared prose slugs.
1578
+ * No output files are written, and the returned inputs are read-only to the emitter.
1579
+ *
1580
+ * @param {{contentBase?: string, config?: object, session?: import("./content-session.mjs").ContentSession}} [options]
1581
+ * @returns {Promise<PreparedContentIndex>}
1582
+ */
1583
+ export async function prepareContentIndex({ contentBase, config, session } = {}) {
1584
+ const resolved = config ?? loadPackConfig();
1585
+ const tree = path.resolve(contentBase ?? resolved.paths.content);
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);
1591
+ const expandedBodies = new Map();
1592
+ const foundryBodies = new Map();
1593
+ const foreign = session?.foreign ?? loadForeignIndexes(resolved, [resolved.contentPackage]);
1594
+ publicationProblems(
1595
+ foreign.stale.map((entry) => ({
1596
+ file: tree,
1597
+ severity: "error",
1598
+ message: `unusable ${entry.package} content index: ${entry.reason}`,
1599
+ })),
1600
+ );
1601
+ // Dynamic import keeps SQL preparation out of the synchronous reader's import cycle.
1602
+ const { prepareTreeSqlTables } = await import("./sql-tables.mjs");
1603
+ const { buildSiteIndex, figureIndexKeys, resolveCrossNoteFigures } =
1604
+ await import("./site-index.mjs");
1605
+ const notes = records.filter(isNoteRecord);
1606
+ for (const audience of new Set([
1607
+ ...notes.map((record) => (isGmNote(record) ? "all" : "public")),
1608
+ "all",
1609
+ ])) {
1610
+ const visible = records.filter((record) => audience === "all" || !isGmNote(record));
1611
+ const sources = audience === "all" ? notes : notes.filter((record) => !isGmNote(record));
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
+ });
1622
+ const bodies = new Map();
1623
+ const figuresByFile = new Map();
1624
+ const figuresByAddress = new Map();
1625
+ const art = assetAddressIndex(visible, { config: resolved, foreign });
1626
+ const resolveRole = (address) => embedRole(art, address);
1627
+ const pages = visible.filter(isNoteRecord).flatMap((record) => {
1628
+ const file = noteFile(tree, record);
1629
+ const authored = parsed.get(file);
1630
+ const expanded = expandContentTables(authored.body, {
1631
+ source: file,
1632
+ sqlTables: prepared?.get(file),
1633
+ pageLists: prepared?.pageLists?.get(file),
1634
+ });
1635
+ publicationProblems(
1636
+ expanded.errors.map((problem) => ({
1637
+ file,
1638
+ line: (authored.bodyLine ?? 1) + problem.line,
1639
+ column: problem.column,
1640
+ severity: "error",
1641
+ message: problem.reason,
1642
+ })),
1643
+ );
1644
+ bodies.set(file, expanded);
1645
+ const byId = new Map(
1646
+ scanFigures(expanded.markdown, { resolveRole })
1647
+ .figures.filter((figure) => figure.id)
1648
+ .map((figure) => [
1649
+ figure.id,
1650
+ {
1651
+ label: figure.label,
1652
+ caption: figure.caption,
1653
+ hasCaption: figure.hasCaption,
1654
+ },
1655
+ ]),
1656
+ );
1657
+ figuresByFile.set(file, byId);
1658
+ const address = noteAddress(record, resolved.contentPackage);
1659
+ if (!address) return [];
1660
+ for (const key of figureIndexKeys(
1661
+ resolved.contentPackage,
1662
+ record.type,
1663
+ record.shortcode,
1664
+ ))
1665
+ figuresByAddress.set(key, byId);
1666
+ const slug = address.slug;
1667
+ return [
1668
+ {
1669
+ kind: "content",
1670
+ file,
1671
+ fm: authoredFrontmatter(record),
1672
+ body: authored.body,
1673
+ pkg: resolved.contentPackage,
1674
+ slug,
1675
+ url: `${resolved.site?.base || `/${resolved.contentPackage}/`}${slug}/`,
1676
+ name: record.name?.full,
1677
+ relPath: record.file.path,
1678
+ },
1679
+ ];
1680
+ });
1681
+ const index = buildSiteIndex(pages, {
1682
+ package: resolved.contentPackage,
1683
+ records: visible,
1684
+ foreignIndex: foreign.index,
1685
+ syntax: session?.syntax,
1686
+ });
1687
+ const dates = index.dateContext;
1688
+ const events = eventNoteIndex(visible, {
1689
+ contentPackage: resolved.contentPackage,
1690
+ foreignIndex: foreign.index,
1691
+ dates,
1692
+ });
1693
+ for (const record of sources) {
1694
+ const file = noteFile(tree, record);
1695
+ const authored = parsed.get(file);
1696
+ const figures = figuresByFile.get(file);
1697
+ const expanded = publicationExpressions(bodies.get(file), {
1698
+ fm: authoredFrontmatter(record),
1699
+ dates,
1700
+ events,
1701
+ file,
1702
+ bodyLine: authored.bodyLine,
1703
+ sqlResults: prepared?.inline?.get(file),
1704
+ figures: {
1705
+ get: (id) => figures?.get(id),
1706
+ note: (target) =>
1707
+ resolveCrossNoteFigures(target, {
1708
+ contentTypes: index.contentTypes,
1709
+ packages: index.packages,
1710
+ noIndexPackages: index.noIndexPackages,
1711
+ contentPackage: index.contentPackage,
1712
+ siteIndexMap: index.index,
1713
+ figuresByAddress,
1714
+ }),
1715
+ },
1716
+ });
1717
+ if (audience === "all") foundryBodies.set(file, expanded);
1718
+ if ((isGmNote(record) ? "all" : "public") === audience)
1719
+ expandedBodies.set(file, expanded);
1720
+ }
1721
+ }
1722
+ return {
1723
+ config: resolved,
1724
+ contentBase: tree,
1725
+ records,
1726
+ parsed,
1727
+ expandedBodies,
1728
+ foundryBodies,
1729
+ syntax: session?.syntax,
1730
+ };
1731
+ }
1732
+
1733
+ /**
1734
+ * A captured source context, consumed without mutation by the synchronous emitter.
1735
+ * @typedef {object} PreparedContentIndex
1736
+ * @property {object} config - The exact resolved configuration for preparation.
1737
+ * @property {string} contentBase - The absolute captured source root.
1738
+ * @property {object[]} records - Complete index records, including hidden providers.
1739
+ * @property {Map<string, {frontmatter: object|null, body: string, raw: string, bodyLine?: number, bodyColumn?: number}>} parsed - Authored sources by absolute file.
1740
+ * @property {Map<string, {markdown: string, lineMap: Array<{line: number, generated: boolean}>, generatedFrom?: number}>} expandedBodies - Complete rendered bodies in each note's audience.
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.
1743
+ */
1744
+
1745
+ /** Update owned copies from an actual output body, retaining authored locations and events. */
1746
+ function projectPublicationBody(
1747
+ records,
1748
+ record,
1749
+ body,
1750
+ { contentPackage, manifest, resolveRole, foundryBody = body, syntax },
1751
+ ) {
1752
+ const fm = authoredFrontmatter(record);
1753
+ const address = noteAddress(fm, contentPackage);
1754
+ if (!address) return;
1755
+ const original = new Map(
1756
+ (record.anchors ?? [])
1757
+ .filter((anchor) => anchor.kind === "prose")
1758
+ .map((anchor) => [anchor.slug, anchor]),
1759
+ );
1760
+ const stub = isStubNote(fm, body);
1761
+ record.address = stub ? null : { ...address, canonical: readCanonicalKey(address.canonical) };
1762
+ record.anchors =
1763
+ stub ? null : (
1764
+ [
1765
+ ...collectAnchors(body, 1, resolveRole, syntax).map((anchor) => {
1766
+ const { line: _, ...generated } = anchor;
1767
+ return {
1768
+ ...(original.get(anchor.slug) ?? generated),
1769
+ link: new AddressLink(
1770
+ readCanonicalKey(address.canonical),
1771
+ anchor.slug,
1772
+ anchor.kind,
1773
+ ),
1774
+ };
1775
+ }),
1776
+ ...(record.anchors ?? []).filter((anchor) => anchor.kind === "event"),
1777
+ ]
1778
+ );
1779
+ const entries = foundryEntries({ frontmatter: fm, address, body: foundryBody, manifest });
1780
+ const declared = new Set(
1781
+ (record.anchors ?? [])
1782
+ .filter((anchor) => anchor.kind === "prose")
1783
+ .map((anchor) => anchor.slug),
1784
+ );
1785
+ // Native SQL keeps its complete corpus; only a declared prose target is public.
1786
+ for (const entry of [entries?.own, entries?.doc])
1787
+ if (entry?.anchors)
1788
+ entry.anchors = Object.fromEntries(
1789
+ Object.entries(entry.anchors).filter(
1790
+ ([slug]) => slug === LEAD_ANCHOR || declared.has(slug),
1791
+ ),
1792
+ );
1793
+ record.foundry = foundryBlock(entries?.own, ownDocumentSystem(fm.type));
1794
+ record.documentation = entries?.doc?.key ? readCanonicalKey(entries.doc.key) : null;
1795
+ if (entries?.doc?.key) {
1796
+ const doc = buildDocRecord({
1797
+ frontmatter: fm,
1798
+ address,
1799
+ entry: entries.doc,
1800
+ file: record.file,
1801
+ contentPackage,
1802
+ anchors: record.anchors ?? [],
1803
+ });
1804
+ const existing = records.find((one) => one.documents && one.file.path === record.file.path);
1805
+ if (existing) Object.assign(existing, doc);
1806
+ else records.push(doc);
1807
+ }
1408
1808
  }
1409
1809
 
1410
1810
  /**
@@ -1416,6 +1816,7 @@ export function indexRecordsFor({
1416
1816
  * @param {string} [options.outDir] - Where to write; defaults to the configured
1417
1817
  * `paths.contentIndex`.
1418
1818
  * @param {object} [options.config] - A resolved configuration; loaded when omitted.
1819
+ * @param {PreparedContentIndex} [options.publication] - Complete preparation for this tree and configuration.
1419
1820
  * @returns {{file: string, notes: number, assets: number, records: number,
1420
1821
  * bytes: number, full: number, draft: number, stub: number,
1421
1822
  * dictionary: {file: string, rows: number}, terms: {file: string, rows: number}}}
@@ -1429,9 +1830,9 @@ export function indexRecordsFor({
1429
1830
  * reader would take it as the authoritative statement that this package has
1430
1831
  * no content.
1431
1832
  */
1432
- export function emitContentIndex({ contentBase, outDir, config } = {}) {
1433
- const resolved = config ?? loadPackConfig();
1434
- const tree = contentBase ?? resolved.paths.content;
1833
+ export function emitContentIndex({ contentBase, outDir, config, publication } = {}) {
1834
+ const resolved = config ?? publication?.config ?? loadPackConfig();
1835
+ const tree = path.resolve(contentBase ?? publication?.contentBase ?? resolved.paths.content);
1435
1836
  const dir = outDir ?? resolved.paths.contentIndex;
1436
1837
  const contentPackage = resolved.contentPackage;
1437
1838
 
@@ -1448,7 +1849,83 @@ export function emitContentIndex({ contentBase, outDir, config } = {}) {
1448
1849
  // publishes pages is no part of an address, and depending on it would make
1449
1850
  // the index refuse to build for a configuration that is perfectly able to
1450
1851
  // state one.
1451
- const records = indexRecordsFor({ contentBase: tree, config: resolved });
1852
+ const parsed = publication?.parsed ?? new Map();
1853
+ if (
1854
+ publication &&
1855
+ (publication.config !== resolved ||
1856
+ publication.contentBase !== tree ||
1857
+ !Array.isArray(publication.records) ||
1858
+ !(publication.parsed instanceof Map) ||
1859
+ !(publication.expandedBodies instanceof Map) ||
1860
+ !(publication.foundryBodies instanceof Map))
1861
+ )
1862
+ throw new Error(
1863
+ "Use await prepareContentIndex({ config, contentBase }) for this publication's source tree and configuration",
1864
+ );
1865
+ const records = cloneAddressState(
1866
+ publication?.records ?? indexRecordsFor({ contentBase: tree, config: resolved, parsed }),
1867
+ );
1868
+ const notes = records.filter(isNoteRecord);
1869
+ const needsPreparation =
1870
+ !publication &&
1871
+ (notes.some(
1872
+ (record) =>
1873
+ dynamicPublicationBody(parsed.get(noteFile(tree, record))?.body) ||
1874
+ record.data?.events?.length,
1875
+ ) ||
1876
+ derivedSections(notes, {
1877
+ fileOf: (record) => noteFile(tree, record),
1878
+ config: resolved,
1879
+ foreignIndex: loadForeignIndexes(resolved, [contentPackage]).index,
1880
+ }).size > 0);
1881
+ if (needsPreparation)
1882
+ throw new Error(
1883
+ "Dynamic content-index publication requires await prepareContentIndex({ config, contentBase }); pass its result as emitContentIndex({ config, contentBase, publication })",
1884
+ );
1885
+ if (
1886
+ publication &&
1887
+ notes.some((record) => {
1888
+ const file = noteFile(tree, record);
1889
+ const body = publication.expandedBodies.get(file);
1890
+ const native = publication.foundryBodies.get(file);
1891
+ return (
1892
+ !parsed.has(file) ||
1893
+ typeof body?.markdown !== "string" ||
1894
+ !Array.isArray(body.lineMap) ||
1895
+ typeof native?.markdown !== "string" ||
1896
+ !Array.isArray(native.lineMap)
1897
+ );
1898
+ })
1899
+ )
1900
+ throw new Error(
1901
+ "Incomplete content-index bodies: run await prepareContentIndex({ config, contentBase }) before publication",
1902
+ );
1903
+ const art = assetAddressIndex(records, { config: resolved });
1904
+ const publicLexical =
1905
+ publication ? undefined : (
1906
+ lexicalSectionsFor(
1907
+ records.filter((record) => !isGmNote(record)),
1908
+ records,
1909
+ )
1910
+ );
1911
+ const allLexical = publication ? undefined : lexicalSectionsFor(records);
1912
+ for (const record of notes) {
1913
+ const file = noteFile(tree, record);
1914
+ const lexical = (isGmNote(record) ? allLexical : publicLexical)?.get(
1915
+ canonicalKey(contentPackage, "note", record.type, record.shortcode),
1916
+ );
1917
+ const body =
1918
+ publication?.expandedBodies.get(file)?.markdown ??
1919
+ insertLexicalSections(parsed.get(file).body, lexical, { syntax: publication?.syntax })
1920
+ .markdown;
1921
+ projectPublicationBody(records, record, body, {
1922
+ contentPackage,
1923
+ manifest: { ...foundryIdentities(resolved), syntax: publication?.syntax },
1924
+ resolveRole: (address) => embedRole(art, address),
1925
+ foundryBody: publication?.foundryBodies.get(file).markdown,
1926
+ syntax: publication?.syntax,
1927
+ });
1928
+ }
1452
1929
  const assetBindings = checkForeignAssetBindings(resolved);
1453
1930
  if (assetBindings.length)
1454
1931
  throw Object.assign(new Error(assetBindings.map(formatDiagnostic).join("\n")), {
@@ -1463,16 +1940,19 @@ export function emitContentIndex({ contentBase, outDir, config } = {}) {
1463
1940
 
1464
1941
  const text = serializeContentIndex(records);
1465
1942
  const file = path.join(dir, metadataFileName(contentPackage));
1466
- fs.mkdirSync(dir, { recursive: true });
1467
- fs.writeFileSync(file, text);
1468
1943
 
1469
1944
  // The `dictionary` and `terms` relations as datafiles beside the index, for
1470
1945
  // `jq`, an editor or another package. Nothing in the build reads them back.
1471
1946
  const derived = dictionaryAndTermRows(records.map((record) => encodeAddresses(record)));
1947
+ const serialized = Object.fromEntries(
1948
+ ["dictionary", "terms"].map((relation) => [relation, serializeRows(derived[relation])]),
1949
+ );
1950
+ fs.mkdirSync(dir, { recursive: true });
1951
+ fs.writeFileSync(file, text);
1472
1952
  const datafiles = {};
1473
1953
  for (const relation of ["dictionary", "terms"]) {
1474
1954
  const rowsFile = path.join(dir, `${contentPackage}-${relation}.jsonl`);
1475
- fs.writeFileSync(rowsFile, serializeRows(derived[relation]));
1955
+ fs.writeFileSync(rowsFile, serialized[relation]);
1476
1956
  datafiles[relation] = { file: rowsFile, rows: derived[relation].length };
1477
1957
  }
1478
1958