@heroiclands/package-build 23.7.1 → 23.9.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 +41 -0
  2. package/README.md +32 -0
  3. package/bin/content-commands.mjs +32 -10
  4. package/ci/check-banners.mjs +37 -19
  5. package/container.mjs +48 -10
  6. package/docs/api.md +402 -58
  7. package/docs/authoring/first-note.md +1 -1
  8. package/docs/authoring/frontmatter.md +30 -1
  9. package/docs/authoring/links-and-markup.md +154 -56
  10. package/docs/commands.md +31 -12
  11. package/docs/content-format.md +25 -2
  12. package/docs/design.md +31 -0
  13. package/docs/diagnostics.md +1 -1
  14. package/docs/guides/e2e.md +37 -1
  15. package/docs/guides/packs.md +2 -1
  16. package/docs/guides/reference-pages.md +86 -63
  17. package/docs/project-setup.md +30 -15
  18. package/docs/reference/format-details.md +313 -132
  19. package/docs/reference/note-types.md +191 -174
  20. package/e2e.mjs +89 -2
  21. package/engine/anchors.mjs +7 -20
  22. package/engine/base-compiler.mjs +11 -18
  23. package/engine/compile-corpus.mjs +5 -3
  24. package/engine/content-alerts.mjs +4 -2
  25. package/engine/content-blocks.mjs +18 -36
  26. package/engine/content-format.yaml +1 -0
  27. package/engine/content-index.mjs +447 -33
  28. package/engine/content-links.mjs +43 -15
  29. package/engine/content-poetry.mjs +9 -0
  30. package/engine/content-tables.mjs +8 -4
  31. package/engine/culture-reach.mjs +61 -34
  32. package/engine/data-keys.mjs +3 -1
  33. package/engine/diagnostics.mjs +24 -11
  34. package/engine/dictionary-layout.mjs +151 -19
  35. package/engine/dictionary-rows.mjs +129 -79
  36. package/engine/foundry-entries.mjs +21 -3
  37. package/engine/frontmatter-lint.mjs +1 -1
  38. package/engine/generate.mjs +12 -22
  39. package/engine/glossary-entries.mjs +11 -11
  40. package/engine/heading-attributes.mjs +21 -28
  41. package/engine/helpers.mjs +20 -12
  42. package/engine/infobox.mjs +3 -0
  43. package/engine/journals.mjs +15 -2
  44. package/engine/language-data.mjs +53 -0
  45. package/engine/language-terms.mjs +86 -0
  46. package/engine/language-words.mjs +2 -2
  47. package/engine/lexical-sections.mjs +247 -0
  48. package/engine/markdownlint-run.mjs +12 -2
  49. package/engine/markup-validation.mjs +34 -0
  50. package/engine/note-addresses.mjs +1 -0
  51. package/engine/note-claims.mjs +21 -10
  52. package/engine/note-package.mjs +3 -5
  53. package/engine/note-state.mjs +9 -3
  54. package/engine/note-vocabulary.mjs +36 -7
  55. package/engine/pdf-build.mjs +17 -10
  56. package/engine/prose-lint.mjs +12 -6
  57. package/engine/related-pages.mjs +22 -3
  58. package/engine/see-also.mjs +150 -0
  59. package/engine/site-build.mjs +21 -3
  60. package/engine/site-preview.mjs +4 -2
  61. package/engine/sql-tables.mjs +287 -53
  62. package/engine/standings.mjs +14 -12
  63. package/engine/stub-lint.mjs +6 -2
  64. package/engine/web-wikilinks.mjs +8 -6
  65. package/engine/word-rows.mjs +20 -12
  66. package/engine/word-terms.mjs +15 -6
  67. package/hugo-theme/layouts/partials/related.html +25 -8
  68. package/package.json +2 -1
  69. package/types/container.d.mts +4 -3
  70. package/types/e2e.d.mts +28 -0
  71. package/types/engine/base-compiler.d.mts +2 -2
  72. package/types/engine/compile-corpus.d.mts +2 -1
  73. package/types/engine/content-index.d.mts +85 -8
  74. package/types/engine/content-tables.d.mts +2 -1
  75. package/types/engine/culture-reach.d.mts +22 -2
  76. package/types/engine/data-keys.d.mts +7 -0
  77. package/types/engine/diagnostics.d.mts +7 -6
  78. package/types/engine/dictionary-layout.d.mts +35 -4
  79. package/types/engine/dictionary-rows.d.mts +9 -0
  80. package/types/engine/foundry-entries.d.mts +3 -1
  81. package/types/engine/heading-attributes.d.mts +2 -2
  82. package/types/engine/helpers.d.mts +11 -5
  83. package/types/engine/language-terms.d.mts +7 -0
  84. package/types/engine/lexical-sections.d.mts +95 -0
  85. package/types/engine/markdownlint-run.d.mts +3 -1
  86. package/types/engine/markup-validation.d.mts +14 -0
  87. package/types/engine/note-addresses.d.mts +10 -0
  88. package/types/engine/note-claims.d.mts +7 -1
  89. package/types/engine/note-package.d.mts +3 -1
  90. package/types/engine/note-state.d.mts +2 -1
  91. package/types/engine/note-vocabulary.d.mts +1 -0
  92. package/types/engine/related-pages.d.mts +10 -1
  93. package/types/engine/see-also.d.mts +55 -0
  94. package/types/engine/site-build.d.mts +4 -1
  95. package/types/engine/sql-tables.d.mts +40 -6
  96. package/types/engine/word-rows.d.mts +9 -7
@@ -99,6 +99,7 @@ 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";
104
105
  import fs from "node:fs";
@@ -113,9 +114,24 @@ import { addressSlug, canonicalKey } from "./content-address.mjs";
113
114
  import { isAddressTuple, ownDocumentSystem, readCanonicalKey, renderAddress } from "./address.mjs";
114
115
  import { NOTE_SYSTEM } from "./systems.mjs";
115
116
  import { dictionaryAndTermRows, serializeRows } from "./dictionary-rows.mjs";
117
+ import { insertLexicalSections, lexicalSectionsFor } from "./lexical-sections.mjs";
118
+ import { isGmNote } from "./note-vocabulary.mjs";
116
119
  import { attachCitations } from "./word-citations.mjs";
117
120
  import { assetAddressIndex } from "./art-fields.mjs";
118
121
  import { embedRole } from "./content-embeds.mjs";
122
+ import { expandContentTables } from "./content-tables.mjs";
123
+ import {
124
+ codeRegions,
125
+ FENCE_LINE,
126
+ matchAllOutsideCode,
127
+ parseHeaderArgs,
128
+ replaceOutsideCode,
129
+ } from "./code-fences.mjs";
130
+ import { PAGE_LIST_LANGUAGE } from "./page-lists.mjs";
131
+ import { EXPRESSION, renderMarkdownExpressions } from "./markdown-expressions.mjs";
132
+ import { scanFigures } from "./content-figures.mjs";
133
+ import { eventNoteIndex } from "./event-fields.mjs";
134
+ import { derivedSections } from "./derived-sections.mjs";
119
135
  // One reader for a note's anchors, shared with the link checker and with the
120
136
  // builds that emit a link. Re-exported because this is where callers
121
137
  // have always addressed it.
@@ -133,7 +149,7 @@ import { NO_SYSTEM } from "./document-subtypes.mjs";
133
149
  */
134
150
 
135
151
  export { collectAnchors };
136
- import { entriesForNote, foundryIdentities } from "./foundry-entries.mjs";
152
+ import { entriesForNote, foundryIdentities, LEAD_ANCHOR } from "./foundry-entries.mjs";
137
153
  import { walkMarkdownTree } from "./helpers.mjs";
138
154
  // The one statement of what an empty body means, shared with the lint and the
139
155
  // SQL view that derives the ladder from the absence this writes.
@@ -410,23 +426,25 @@ function foundryBlock(entry, system) {
410
426
  * @param {string} [absPath] - The file, read only on the failing path to locate
411
427
  * the offending key.
412
428
  * @param {string} [contentPackage] - The package this tree compiles as.
429
+ * @param {string} [raw] - Source text already read by the caller.
413
430
  * @returns {void}
414
431
  * @throws {Error} When the note authors a derived key. `file` and `position`
415
432
  * ride on the error.
416
433
  */
417
- function assertNoDerivedKeys(frontmatter, relPath, absPath, contentPackage) {
434
+ function assertNoDerivedKeys(frontmatter, relPath, absPath, contentPackage, raw) {
418
435
  for (const key of DERIVED_KEYS) {
419
436
  if (!Object.hasOwn(frontmatter ?? {}, key)) continue;
420
437
  if (key === "package") {
421
438
  // Throws with its own wording, and its own position.
422
- assertNoDeclaredPackage(frontmatter, { absPath, configured: contentPackage });
439
+ assertNoDeclaredPackage(frontmatter, { absPath, configured: contentPackage, raw });
423
440
  }
424
441
  const err = new Error(
425
442
  `\`${key}:\` is derived by the content index and cannot be ` +
426
443
  `authored — rename the frontmatter field`,
427
444
  );
428
445
  err.file = relPath;
429
- const position = absPath ? locateFrontmatterKey(absPath, key) : undefined;
446
+ const position =
447
+ absPath ? locateFrontmatterKey(absPath, key, undefined, { raw }) : undefined;
430
448
  if (position) err.position = position;
431
449
  throw err;
432
450
  }
@@ -445,6 +463,7 @@ function assertNoDerivedKeys(frontmatter, relPath, absPath, contentPackage) {
445
463
  * @param {string} options.relPath - Its path below the content root, POSIX-separated.
446
464
  * @param {string} [options.absPath] - The file, read only on the failing path to
447
465
  * locate the offending key.
466
+ * @param {string} [options.raw] - Source text already read by the caller.
448
467
  * @param {string} options.contentPackage - The package the tree compiles as.
449
468
  * @param {string} [options.body] - The note's markdown body, for its anchors
450
469
  * and for the one question that decides whether the note publishes a page. A
@@ -473,6 +492,7 @@ export function buildIndexRecord({
473
492
  frontmatter,
474
493
  relPath,
475
494
  absPath,
495
+ raw,
476
496
  contentPackage,
477
497
  body,
478
498
  bodyLine,
@@ -481,7 +501,7 @@ export function buildIndexRecord({
481
501
  dateContext,
482
502
  resolveRole,
483
503
  }) {
484
- assertNoDerivedKeys(frontmatter, relPath, absPath, contentPackage);
504
+ assertNoDerivedKeys(frontmatter, relPath, absPath, contentPackage, raw);
485
505
 
486
506
  const posix = relPath.split(path.sep).join("/");
487
507
  const folder = posix.includes("/") ? posix.slice(0, posix.lastIndexOf("/")) : "";
@@ -490,9 +510,7 @@ export function buildIndexRecord({
490
510
  } catch (error) {
491
511
  error.file = absPath ?? relPath;
492
512
  if (absPath && error.keyPath) {
493
- const yamlText = fs
494
- .readFileSync(absPath, "utf8")
495
- .match(/^---\r?\n([\s\S]*?)\r?\n---/)?.[1];
513
+ const yamlText = (raw ?? rawNote(absPath)).match(/^---\r?\n([\s\S]*?)\r?\n---/)?.[1];
496
514
  const locate = (/** @type {any} */ found) => {
497
515
  const position = positionOfYamlPath(yamlText, found.keyPath, {
498
516
  key: found.addressKey,
@@ -540,7 +558,7 @@ export function buildIndexRecord({
540
558
  stub ? null : (
541
559
  [
542
560
  ...collectAnchors(body, bodyLine, resolveRole),
543
- ...eventAnchors(frontmatter, rawNote(absPath)),
561
+ ...eventAnchors(frontmatter, raw ?? rawNote(absPath)),
544
562
  ]
545
563
  .sort((a, b) => (a.line ?? 0) - (b.line ?? 0))
546
564
  .map((a) => ({
@@ -641,11 +659,21 @@ function buildDocRecord({ frontmatter, address, entry, file, contentPackage, anc
641
659
  // entry's derivation.
642
660
  id: made ? entry.id : null,
643
661
  nameAscii: asciiName(frontmatter?.name?.full),
644
- address: made ? { slug: address.slug, canonical: entry.key } : null,
662
+ address: made ? { slug: address.slug, canonical: readCanonicalKey(entry.key) } : null,
645
663
  // The record this is the documentation *for*. `documentation` is
646
664
  // the forward link on that record, so either end reaches the other.
647
- documents: address.canonical,
648
- anchors: made ? anchors : null,
665
+ documents: readCanonicalKey(address.canonical),
666
+ anchors:
667
+ made ?
668
+ anchors.map((anchor) => ({
669
+ ...anchor,
670
+ link: new AddressLink(
671
+ readCanonicalKey(entry.key),
672
+ anchor.slug,
673
+ anchor.kind,
674
+ ),
675
+ }))
676
+ : null,
649
677
  foundry: foundryBlock(entry, NOTE_SYSTEM),
650
678
  file,
651
679
  })
@@ -659,6 +687,7 @@ export function indexRecordsForNote({
659
687
  bodyLine,
660
688
  relPath,
661
689
  absPath,
690
+ raw,
662
691
  contentPackage,
663
692
  manifest,
664
693
  addressContext,
@@ -672,6 +701,7 @@ export function indexRecordsForNote({
672
701
  bodyLine,
673
702
  relPath,
674
703
  absPath,
704
+ raw,
675
705
  contentPackage,
676
706
  manifest,
677
707
  addressContext,
@@ -783,10 +813,14 @@ function cultureTuple(value) {
783
813
  * immediate parent whose package is not this one.
784
814
  * @param {object[]} [options.problems] - Receives each conflict as a finding.
785
815
  * Omitted, every conflict is collected and one Error lists them all.
816
+ * @param {Map<string, {raw: string}>} [options.parsed] - Notes already read, keyed by file.
786
817
  * @returns {Array<Record<string, any>>} The same records.
787
818
  * @throws {Error} When `problems` is omitted and any note's sources disagree.
788
819
  */
789
- export function resolveCultures(records, { contentPackage, contentBase, foreignRecord, problems }) {
820
+ export function resolveCultures(
821
+ records,
822
+ { contentPackage, contentBase, foreignRecord, problems, parsed },
823
+ ) {
790
824
  // Each note under every key a link may name it by: its record's canonical
791
825
  // address, and its page address in the `note` system, which is what a
792
826
  // `data.parents`, `data.seat` or `data.government` link names.
@@ -879,7 +913,7 @@ export function resolveCultures(records, { contentPackage, contentBase, foreignR
879
913
  */
880
914
  const conflict = (record, keyPath, subject, distinct) => {
881
915
  const file = contentBase ? noteFile(contentBase, record) : record.file?.path;
882
- const raw = contentBase && fs.existsSync(file) ? fs.readFileSync(file, "utf8") : undefined;
916
+ const raw = parsed?.get(file)?.raw ?? (contentBase ? rawNote(file) : undefined);
883
917
  let position = positionOfFrontmatterPath(raw, keyPath, { key: true });
884
918
  if (position.line === undefined && keyPath.length > 1)
885
919
  position = positionOfFrontmatterPath(raw, ["data"], { key: true });
@@ -1026,9 +1060,9 @@ export function resolveCultures(records, { contentPackage, contentBase, foreignR
1026
1060
  * @param {(key: string) => ({culture?: unknown}|undefined)} [options.foreignRecord] -
1027
1061
  * Another package's entry for a canonical address, read for a parent that
1028
1062
  * package publishes; see {@link resolveCultures}.
1029
- * @param {Map<string, {body: string, frontmatter: object|null}>} [options.parsed] -
1030
- * Filled with each note's body and its frontmatter as authored, keyed by the
1031
- * note's file, so a later pass reads the notes this walk already parsed.
1063
+ * @param {Map<string, {body: string, frontmatter: object|null, raw: string, bodyLine?: number, bodyColumn?: number}>} [options.parsed] -
1064
+ * Filled with each note's source text, body coordinates and frontmatter as
1065
+ * authored, keyed by file, for reuse throughout the command.
1032
1066
  * @returns {Array<Record<string, any>>} The records, in a total order that does
1033
1067
  * not depend on directory-read order.
1034
1068
  */
@@ -1042,7 +1076,7 @@ export function collectContentIndex(
1042
1076
  problems,
1043
1077
  addressContext,
1044
1078
  foreignRecord,
1045
- parsed,
1079
+ parsed = new Map(),
1046
1080
  },
1047
1081
  ) {
1048
1082
  const records = [];
@@ -1115,10 +1149,16 @@ export function collectContentIndex(
1115
1149
 
1116
1150
  // Each recorded note with its body, for the words its prose cites.
1117
1151
  const bodies = [];
1118
- for (const { frontmatter, body, bodyLine, absPath } of parsedNotes) {
1152
+ for (const { frontmatter, body, bodyLine, bodyColumn, raw, absPath } of parsedNotes) {
1119
1153
  // Before the index derives anything onto the frontmatter, so a later
1120
1154
  // pass reads the note as authored.
1121
- parsed?.set(absPath, { body, frontmatter: structuredClone(frontmatter) });
1155
+ parsed.set(absPath, {
1156
+ body,
1157
+ bodyLine,
1158
+ bodyColumn,
1159
+ raw,
1160
+ frontmatter: structuredClone(frontmatter),
1161
+ });
1122
1162
  const fm = frontmatter ?? {};
1123
1163
  applyComputedBeingAge(fm, present, dates);
1124
1164
  const relPath = path.relative(contentBase, absPath);
@@ -1127,6 +1167,7 @@ export function collectContentIndex(
1127
1167
  frontmatter: fm,
1128
1168
  relPath,
1129
1169
  absPath,
1170
+ raw,
1130
1171
  contentPackage,
1131
1172
  body,
1132
1173
  bodyLine,
@@ -1142,9 +1183,7 @@ export function collectContentIndex(
1142
1183
  err.identity = { type: fm.type, shortcode: fm.shortcode };
1143
1184
  err.keyPath = ["shortcode"];
1144
1185
  err.message = `shortcode "${fm.shortcode}" is not strictly alphanumeric — lowercase letters and digits only`;
1145
- const yamlText = fs
1146
- .readFileSync(absPath, "utf8")
1147
- .match(/^---\r?\n([\s\S]*?)\r?\n---/)?.[1];
1186
+ const yamlText = raw.match(/^---\r?\n([\s\S]*?)\r?\n---/)?.[1];
1148
1187
  err.position = positionOfYamlPath(yamlText, err.keyPath);
1149
1188
  if (err.position.line) err.position.line++;
1150
1189
  }
@@ -1186,13 +1225,13 @@ export function collectContentIndex(
1186
1225
  // Every note record exists and every Address in it is a tuple, so a parent
1187
1226
  // is found by its canonical address. Before the sort, which reads nothing
1188
1227
  // the pass writes.
1189
- resolveCultures(records, { contentPackage, contentBase, foreignRecord, problems });
1228
+ resolveCultures(records, { contentPackage, contentBase, foreignRecord, problems, parsed });
1190
1229
  const cultureNotes = records
1191
1230
  .filter((record) => isNoteRecord(record) && record.type === "lore")
1192
1231
  .map((record) => ({
1193
1232
  fm: record,
1194
1233
  file: noteFile(contentBase, record),
1195
- raw: fs.readFileSync(noteFile(contentBase, record), "utf8"),
1234
+ raw: parsed.get(noteFile(contentBase, record)).raw,
1196
1235
  }));
1197
1236
  const cultureByKey = new Map(
1198
1237
  cultureNotes.map((note) => [
@@ -1209,6 +1248,26 @@ export function collectContentIndex(
1209
1248
  for (const [note, inherited] of inheritance.values)
1210
1249
  if (Object.keys(inherited).length) note.fm.data = { ...note.fm.data, ...inherited };
1211
1250
 
1251
+ // Automatic lexical sections require only the recorded frontmatter. Their
1252
+ // published anchors and journal targets therefore use the same pure body
1253
+ // projection as the surfaces, without making the index emitter async.
1254
+ // Public notes resolve providers in their audience, including lineage
1255
+ // through hidden languages, just as their build does.
1256
+ const visible = records.filter((record) => !isGmNote(record));
1257
+ const publicSections = lexicalSectionsFor(visible, records);
1258
+ const allSections = lexicalSectionsFor(records);
1259
+ for (const { record, body } of bodies) {
1260
+ const key = canonicalKey(contentPackage, "note", record.type, record.shortcode);
1261
+ const sections = (isGmNote(record) ? allSections : publicSections).get(key);
1262
+ if (!sections) continue;
1263
+ const expanded = insertLexicalSections(body, sections).markdown;
1264
+ projectPublicationBody(records, record, expanded, {
1265
+ contentPackage,
1266
+ manifest,
1267
+ resolveRole,
1268
+ });
1269
+ }
1270
+
1212
1271
  // Sorted at every depth like a note's record, and for the same reason: the
1213
1272
  // declaration order of the `asset` fields is a fact about the emitter, not
1214
1273
  // about the content, and the artifact is meant to be byte-identical across
@@ -1352,7 +1411,7 @@ function dependencyAddresses(config) {
1352
1411
  * aborting the derivation. Omitted, the note throws — which is the contract
1353
1412
  * the emitter needs, since an index missing a note asserts that it does not
1354
1413
  * exist.
1355
- * @param {Map<string, {body: string, frontmatter: object|null}>} [opts.parsed] -
1414
+ * @param {Map<string, {body: string, frontmatter: object|null, raw: string, bodyLine?: number, bodyColumn?: number}>} [opts.parsed] -
1356
1415
  * Filled with each note's body and authored frontmatter, keyed by the note's
1357
1416
  * file. Handed to {@link module:engine/sql-tables.prepareTreeSqlTables}, it
1358
1417
  * spares that pass reading and parsing every note again.
@@ -1394,6 +1453,283 @@ export function indexRecordsFor({
1394
1453
  });
1395
1454
  }
1396
1455
 
1456
+ /** Active body directives whose output cannot be inferred synchronously. */
1457
+ function dynamicPublicationBody(body) {
1458
+ const source = String(body ?? "");
1459
+ return (
1460
+ [...matchAllOutsideCode(source, EXPRESSION)].length > 0 ||
1461
+ codeRegions(source, { spans: false, poetryAsProse: false }).some(({ start, end }) => {
1462
+ const opener = FENCE_LINE.exec(source.slice(start, end).split("\n", 1)[0]);
1463
+ const language = opener && parseHeaderArgs(opener[3]).language;
1464
+ return language === "sql" || language === PAGE_LIST_LANGUAGE;
1465
+ })
1466
+ );
1467
+ }
1468
+
1469
+ /** Publication errors are located and always precede output creation. */
1470
+ function publicationProblems(problems) {
1471
+ if (!problems.length) return;
1472
+ throw Object.assign(new Error(problems.map(formatDiagnostic).join("\n")), { located: true });
1473
+ }
1474
+
1475
+ /** Render the shared expression language and carry inserted scalar lines back to their source. */
1476
+ function publicationExpressions(expanded, context) {
1477
+ const source = expanded.markdown;
1478
+ const additions = [];
1479
+ const findings = [];
1480
+ const markdown = replaceOutsideCode(source, EXPRESSION, (expression, _inside, offset) => {
1481
+ const line = source.slice(0, offset).split("\n").length - 1;
1482
+ const location = expanded.lineMap[line] ?? { line, generated: false };
1483
+ const result = renderMarkdownExpressions(expression, {
1484
+ ...context,
1485
+ bodyLine: (context.bodyLine ?? 1) + location.line,
1486
+ });
1487
+ const column = offset - source.lastIndexOf("\n", offset - 1) - 1;
1488
+ findings.push(
1489
+ ...result.findings.map((finding) => ({ ...finding, column: finding.column + column })),
1490
+ );
1491
+ const count = result.markdown.split("\n").length - 1;
1492
+ if (count) additions.push({ line, count, location });
1493
+ return result.markdown;
1494
+ });
1495
+ publicationProblems(findings);
1496
+ const lineMap = expanded.lineMap.map((location) => ({ ...location }));
1497
+ let generatedFrom = expanded.generatedFrom;
1498
+ for (const addition of additions.sort((a, b) => b.line - a.line)) {
1499
+ lineMap.splice(
1500
+ addition.line + 1,
1501
+ 0,
1502
+ ...Array.from({ length: addition.count }, () => ({
1503
+ ...addition.location,
1504
+ generated: true,
1505
+ })),
1506
+ );
1507
+ if (generatedFrom !== undefined && addition.line < expanded.generatedFrom)
1508
+ generatedFrom += addition.count;
1509
+ }
1510
+ return { markdown, lineMap, ...(generatedFrom === undefined ? {} : { generatedFrom }) };
1511
+ }
1512
+
1513
+ /**
1514
+ * Prepare the complete bodies a content-index publication describes.
1515
+ * Prose declarations use each source's audience. Native SQL uses the complete
1516
+ * corpus, with target maps limited to that source's declared prose slugs.
1517
+ * No output files are written, and the returned inputs are read-only to the emitter.
1518
+ *
1519
+ * @param {{contentBase?: string, config?: object}} [options]
1520
+ * @returns {Promise<PreparedContentIndex>}
1521
+ */
1522
+ export async function prepareContentIndex({ contentBase, config } = {}) {
1523
+ const resolved = config ?? loadPackConfig();
1524
+ const tree = path.resolve(contentBase ?? resolved.paths.content);
1525
+ const parsed = new Map();
1526
+ const records = indexRecordsFor({ contentBase: tree, config: resolved, parsed });
1527
+ const expandedBodies = new Map();
1528
+ const foundryBodies = new Map();
1529
+ const foreign = loadForeignIndexes(resolved, [resolved.contentPackage]);
1530
+ publicationProblems(
1531
+ foreign.stale.map((entry) => ({
1532
+ file: tree,
1533
+ severity: "error",
1534
+ message: `unusable ${entry.package} content index: ${entry.reason}`,
1535
+ })),
1536
+ );
1537
+ // Dynamic import keeps SQL preparation out of the synchronous reader's import cycle.
1538
+ const { prepareTreeSqlTables } = await import("./sql-tables.mjs");
1539
+ const { buildSiteIndex, figureIndexKeys, resolveCrossNoteFigures } =
1540
+ await import("./site-index.mjs");
1541
+ const notes = records.filter(isNoteRecord);
1542
+ for (const audience of new Set([
1543
+ ...notes.map((record) => (isGmNote(record) ? "all" : "public")),
1544
+ "all",
1545
+ ])) {
1546
+ const visible = records.filter((record) => audience === "all" || !isGmNote(record));
1547
+ 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
+ });
1555
+ const bodies = new Map();
1556
+ const figuresByFile = new Map();
1557
+ const figuresByAddress = new Map();
1558
+ const art = assetAddressIndex(visible, { config: resolved, foreign });
1559
+ const resolveRole = (address) => embedRole(art, address);
1560
+ const pages = visible.filter(isNoteRecord).flatMap((record) => {
1561
+ const file = noteFile(tree, record);
1562
+ const authored = parsed.get(file);
1563
+ const expanded = expandContentTables(authored.body, {
1564
+ source: file,
1565
+ sqlTables: prepared?.get(file),
1566
+ pageLists: prepared?.pageLists?.get(file),
1567
+ });
1568
+ publicationProblems(
1569
+ expanded.errors.map((problem) => ({
1570
+ file,
1571
+ line: (authored.bodyLine ?? 1) + problem.line,
1572
+ column: problem.column,
1573
+ severity: "error",
1574
+ message: problem.reason,
1575
+ })),
1576
+ );
1577
+ bodies.set(file, expanded);
1578
+ const byId = new Map(
1579
+ scanFigures(expanded.markdown, { resolveRole })
1580
+ .figures.filter((figure) => figure.id)
1581
+ .map((figure) => [
1582
+ figure.id,
1583
+ {
1584
+ label: figure.label,
1585
+ caption: figure.caption,
1586
+ hasCaption: figure.hasCaption,
1587
+ },
1588
+ ]),
1589
+ );
1590
+ figuresByFile.set(file, byId);
1591
+ const address = noteAddress(record, resolved.contentPackage);
1592
+ if (!address) return [];
1593
+ for (const key of figureIndexKeys(
1594
+ resolved.contentPackage,
1595
+ record.type,
1596
+ record.shortcode,
1597
+ ))
1598
+ figuresByAddress.set(key, byId);
1599
+ const slug = address.slug;
1600
+ return [
1601
+ {
1602
+ kind: "content",
1603
+ file,
1604
+ fm: authoredFrontmatter(record),
1605
+ body: authored.body,
1606
+ pkg: resolved.contentPackage,
1607
+ slug,
1608
+ url: `${resolved.site?.base || `/${resolved.contentPackage}/`}${slug}/`,
1609
+ name: record.name?.full,
1610
+ relPath: record.file.path,
1611
+ },
1612
+ ];
1613
+ });
1614
+ const index = buildSiteIndex(pages, {
1615
+ package: resolved.contentPackage,
1616
+ records: visible,
1617
+ foreignIndex: foreign.index,
1618
+ });
1619
+ const dates = index.dateContext;
1620
+ const events = eventNoteIndex(visible, {
1621
+ contentPackage: resolved.contentPackage,
1622
+ foreignIndex: foreign.index,
1623
+ dates,
1624
+ });
1625
+ for (const record of sources) {
1626
+ const file = noteFile(tree, record);
1627
+ const authored = parsed.get(file);
1628
+ const figures = figuresByFile.get(file);
1629
+ const expanded = publicationExpressions(bodies.get(file), {
1630
+ fm: authoredFrontmatter(record),
1631
+ dates,
1632
+ events,
1633
+ file,
1634
+ bodyLine: authored.bodyLine,
1635
+ sqlResults: prepared?.inline?.get(file),
1636
+ figures: {
1637
+ get: (id) => figures?.get(id),
1638
+ note: (target) =>
1639
+ resolveCrossNoteFigures(target, {
1640
+ contentTypes: index.contentTypes,
1641
+ packages: index.packages,
1642
+ noIndexPackages: index.noIndexPackages,
1643
+ contentPackage: index.contentPackage,
1644
+ siteIndexMap: index.index,
1645
+ figuresByAddress,
1646
+ }),
1647
+ },
1648
+ });
1649
+ if (audience === "all") foundryBodies.set(file, expanded);
1650
+ if ((isGmNote(record) ? "all" : "public") === audience)
1651
+ expandedBodies.set(file, expanded);
1652
+ }
1653
+ }
1654
+ return { config: resolved, contentBase: tree, records, parsed, expandedBodies, foundryBodies };
1655
+ }
1656
+
1657
+ /**
1658
+ * A captured source context, consumed without mutation by the synchronous emitter.
1659
+ * @typedef {object} PreparedContentIndex
1660
+ * @property {object} config - The exact resolved configuration for preparation.
1661
+ * @property {string} contentBase - The absolute captured source root.
1662
+ * @property {object[]} records - Complete index records, including hidden providers.
1663
+ * @property {Map<string, {frontmatter: object|null, body: string, raw: string, bodyLine?: number, bodyColumn?: number}>} parsed - Authored sources by absolute file.
1664
+ * @property {Map<string, {markdown: string, lineMap: Array<{line: number, generated: boolean}>, generatedFrom?: number}>} expandedBodies - Complete rendered bodies in each note's audience.
1665
+ * @property {Map<string, {markdown: string, lineMap: Array<{line: number, generated: boolean}>, generatedFrom?: number}>} foundryBodies - Complete native bodies with explicit SQL using the ALL corpus.
1666
+ */
1667
+
1668
+ /** Update owned copies from an actual output body, retaining authored locations and events. */
1669
+ function projectPublicationBody(
1670
+ records,
1671
+ record,
1672
+ body,
1673
+ { contentPackage, manifest, resolveRole, foundryBody = body },
1674
+ ) {
1675
+ const fm = authoredFrontmatter(record);
1676
+ const address = noteAddress(fm, contentPackage);
1677
+ if (!address) return;
1678
+ const original = new Map(
1679
+ (record.anchors ?? [])
1680
+ .filter((anchor) => anchor.kind === "prose")
1681
+ .map((anchor) => [anchor.slug, anchor]),
1682
+ );
1683
+ const stub = isStubNote(fm, body);
1684
+ record.address = stub ? null : { ...address, canonical: readCanonicalKey(address.canonical) };
1685
+ record.anchors =
1686
+ stub ? null : (
1687
+ [
1688
+ ...collectAnchors(body, 1, resolveRole).map((anchor) => {
1689
+ const { line: _, ...generated } = anchor;
1690
+ return {
1691
+ ...(original.get(anchor.slug) ?? generated),
1692
+ link: new AddressLink(
1693
+ readCanonicalKey(address.canonical),
1694
+ anchor.slug,
1695
+ anchor.kind,
1696
+ ),
1697
+ };
1698
+ }),
1699
+ ...(record.anchors ?? []).filter((anchor) => anchor.kind === "event"),
1700
+ ]
1701
+ );
1702
+ const entries = foundryEntries({ frontmatter: fm, address, body: foundryBody, manifest });
1703
+ const declared = new Set(
1704
+ (record.anchors ?? [])
1705
+ .filter((anchor) => anchor.kind === "prose")
1706
+ .map((anchor) => anchor.slug),
1707
+ );
1708
+ // Native SQL keeps its complete corpus; only a declared prose target is public.
1709
+ for (const entry of [entries?.own, entries?.doc])
1710
+ if (entry?.anchors)
1711
+ entry.anchors = Object.fromEntries(
1712
+ Object.entries(entry.anchors).filter(
1713
+ ([slug]) => slug === LEAD_ANCHOR || declared.has(slug),
1714
+ ),
1715
+ );
1716
+ record.foundry = foundryBlock(entries?.own, ownDocumentSystem(fm.type));
1717
+ record.documentation = entries?.doc?.key ? readCanonicalKey(entries.doc.key) : null;
1718
+ if (entries?.doc?.key) {
1719
+ const doc = buildDocRecord({
1720
+ frontmatter: fm,
1721
+ address,
1722
+ entry: entries.doc,
1723
+ file: record.file,
1724
+ contentPackage,
1725
+ anchors: record.anchors ?? [],
1726
+ });
1727
+ const existing = records.find((one) => one.documents && one.file.path === record.file.path);
1728
+ if (existing) Object.assign(existing, doc);
1729
+ else records.push(doc);
1730
+ }
1731
+ }
1732
+
1397
1733
  /**
1398
1734
  * Emit this package's content index.
1399
1735
  *
@@ -1403,6 +1739,7 @@ export function indexRecordsFor({
1403
1739
  * @param {string} [options.outDir] - Where to write; defaults to the configured
1404
1740
  * `paths.contentIndex`.
1405
1741
  * @param {object} [options.config] - A resolved configuration; loaded when omitted.
1742
+ * @param {PreparedContentIndex} [options.publication] - Complete preparation for this tree and configuration.
1406
1743
  * @returns {{file: string, notes: number, assets: number, records: number,
1407
1744
  * bytes: number, full: number, draft: number, stub: number,
1408
1745
  * dictionary: {file: string, rows: number}, terms: {file: string, rows: number}}}
@@ -1416,9 +1753,9 @@ export function indexRecordsFor({
1416
1753
  * reader would take it as the authoritative statement that this package has
1417
1754
  * no content.
1418
1755
  */
1419
- export function emitContentIndex({ contentBase, outDir, config } = {}) {
1420
- const resolved = config ?? loadPackConfig();
1421
- const tree = contentBase ?? resolved.paths.content;
1756
+ export function emitContentIndex({ contentBase, outDir, config, publication } = {}) {
1757
+ const resolved = config ?? publication?.config ?? loadPackConfig();
1758
+ const tree = path.resolve(contentBase ?? publication?.contentBase ?? resolved.paths.content);
1422
1759
  const dir = outDir ?? resolved.paths.contentIndex;
1423
1760
  const contentPackage = resolved.contentPackage;
1424
1761
 
@@ -1435,7 +1772,81 @@ export function emitContentIndex({ contentBase, outDir, config } = {}) {
1435
1772
  // publishes pages is no part of an address, and depending on it would make
1436
1773
  // the index refuse to build for a configuration that is perfectly able to
1437
1774
  // state one.
1438
- const records = indexRecordsFor({ contentBase: tree, config: resolved });
1775
+ const parsed = publication?.parsed ?? new Map();
1776
+ if (
1777
+ publication &&
1778
+ (publication.config !== resolved ||
1779
+ publication.contentBase !== tree ||
1780
+ !Array.isArray(publication.records) ||
1781
+ !(publication.parsed instanceof Map) ||
1782
+ !(publication.expandedBodies instanceof Map) ||
1783
+ !(publication.foundryBodies instanceof Map))
1784
+ )
1785
+ throw new Error(
1786
+ "Use await prepareContentIndex({ config, contentBase }) for this publication's source tree and configuration",
1787
+ );
1788
+ const records = cloneAddressState(
1789
+ publication?.records ?? indexRecordsFor({ contentBase: tree, config: resolved, parsed }),
1790
+ );
1791
+ const notes = records.filter(isNoteRecord);
1792
+ const needsPreparation =
1793
+ !publication &&
1794
+ (notes.some(
1795
+ (record) =>
1796
+ dynamicPublicationBody(parsed.get(noteFile(tree, record))?.body) ||
1797
+ record.data?.events?.length,
1798
+ ) ||
1799
+ derivedSections(notes, {
1800
+ fileOf: (record) => noteFile(tree, record),
1801
+ config: resolved,
1802
+ foreignIndex: loadForeignIndexes(resolved, [contentPackage]).index,
1803
+ }).size > 0);
1804
+ if (needsPreparation)
1805
+ throw new Error(
1806
+ "Dynamic content-index publication requires await prepareContentIndex({ config, contentBase }); pass its result as emitContentIndex({ config, contentBase, publication })",
1807
+ );
1808
+ if (
1809
+ publication &&
1810
+ notes.some((record) => {
1811
+ const file = noteFile(tree, record);
1812
+ const body = publication.expandedBodies.get(file);
1813
+ const native = publication.foundryBodies.get(file);
1814
+ return (
1815
+ !parsed.has(file) ||
1816
+ typeof body?.markdown !== "string" ||
1817
+ !Array.isArray(body.lineMap) ||
1818
+ typeof native?.markdown !== "string" ||
1819
+ !Array.isArray(native.lineMap)
1820
+ );
1821
+ })
1822
+ )
1823
+ throw new Error(
1824
+ "Incomplete content-index bodies: run await prepareContentIndex({ config, contentBase }) before publication",
1825
+ );
1826
+ const art = assetAddressIndex(records, { config: resolved });
1827
+ const publicLexical =
1828
+ publication ? undefined : (
1829
+ lexicalSectionsFor(
1830
+ records.filter((record) => !isGmNote(record)),
1831
+ records,
1832
+ )
1833
+ );
1834
+ const allLexical = publication ? undefined : lexicalSectionsFor(records);
1835
+ for (const record of notes) {
1836
+ const file = noteFile(tree, record);
1837
+ const lexical = (isGmNote(record) ? allLexical : publicLexical)?.get(
1838
+ canonicalKey(contentPackage, "note", record.type, record.shortcode),
1839
+ );
1840
+ const body =
1841
+ publication?.expandedBodies.get(file)?.markdown ??
1842
+ insertLexicalSections(parsed.get(file).body, lexical).markdown;
1843
+ projectPublicationBody(records, record, body, {
1844
+ contentPackage,
1845
+ manifest: foundryIdentities(resolved),
1846
+ resolveRole: (address) => embedRole(art, address),
1847
+ foundryBody: publication?.foundryBodies.get(file).markdown,
1848
+ });
1849
+ }
1439
1850
  const assetBindings = checkForeignAssetBindings(resolved);
1440
1851
  if (assetBindings.length)
1441
1852
  throw Object.assign(new Error(assetBindings.map(formatDiagnostic).join("\n")), {
@@ -1450,16 +1861,19 @@ export function emitContentIndex({ contentBase, outDir, config } = {}) {
1450
1861
 
1451
1862
  const text = serializeContentIndex(records);
1452
1863
  const file = path.join(dir, metadataFileName(contentPackage));
1453
- fs.mkdirSync(dir, { recursive: true });
1454
- fs.writeFileSync(file, text);
1455
1864
 
1456
1865
  // The `dictionary` and `terms` relations as datafiles beside the index, for
1457
1866
  // `jq`, an editor or another package. Nothing in the build reads them back.
1458
1867
  const derived = dictionaryAndTermRows(records.map((record) => encodeAddresses(record)));
1868
+ const serialized = Object.fromEntries(
1869
+ ["dictionary", "terms"].map((relation) => [relation, serializeRows(derived[relation])]),
1870
+ );
1871
+ fs.mkdirSync(dir, { recursive: true });
1872
+ fs.writeFileSync(file, text);
1459
1873
  const datafiles = {};
1460
1874
  for (const relation of ["dictionary", "terms"]) {
1461
1875
  const rowsFile = path.join(dir, `${contentPackage}-${relation}.jsonl`);
1462
- fs.writeFileSync(rowsFile, serializeRows(derived[relation]));
1876
+ fs.writeFileSync(rowsFile, serialized[relation]);
1463
1877
  datafiles[relation] = { file: rowsFile, rows: derived[relation].length };
1464
1878
  }
1465
1879