@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
@@ -26,7 +26,9 @@
26
26
  * @module
27
27
  */
28
28
 
29
- import { parseAddress, renderAddress, isAddressTuple } from "./address.mjs";
29
+ import { parseAddress, readCanonicalKey, renderAddress, isAddressTuple } from "./address.mjs";
30
+ import { collectAnchors } from "./anchors.mjs";
31
+ import { expandContentTables } from "./content-tables.mjs";
30
32
  import { NOTE_VOCABULARY, isGmNote } from "./note-vocabulary.mjs";
31
33
  import { encodeAddresses, flattenPublishedAddresses } from "./address-values.mjs";
32
34
  import { EVENT_COLUMNS, EVENT_SORT_COLUMNS, eventRows } from "./event-rows.mjs";
@@ -45,8 +47,10 @@ import {
45
47
  } from "./word-rows.mjs";
46
48
  import { DICTIONARY_COLUMNS, TERM_COLUMNS, dictionaryAndTermRows } from "./dictionary-rows.mjs";
47
49
  import { LANGUAGE_COLUMN_TYPES, LANGUAGE_RELATIONS } from "./language-rows.mjs";
50
+ import { VERB_PERSON_FIELDS } from "./language-terms.mjs";
48
51
  import { renderDictionary, renderGlossary } from "./dictionary-layout.mjs";
49
52
  import { glossaryLanguage } from "./glossary-language.mjs";
53
+ import { lexicalSectionsFor } from "./lexical-sections.mjs";
50
54
  import { eventViewSections } from "./event-views.mjs";
51
55
  import { appendGeneratedSections, joinSections } from "./generated-sections.mjs";
52
56
  import fs from "node:fs";
@@ -60,7 +64,7 @@ import { MARKET_CLASSES } from "./market-class.mjs";
60
64
  import { parseMarkdownFile } from "./helpers.mjs";
61
65
  import { sqlQueriesInMarkdown } from "./markdown-expressions.mjs";
62
66
  // The record accessors only — see `engine/index-records.mjs`.
63
- import { isNoteRecord, noteFile, sortKeysDeep } from "./index-records.mjs";
67
+ import { authoredFrontmatter, isNoteRecord, noteFile, sortKeysDeep } from "./index-records.mjs";
64
68
 
65
69
  /** Rendered in a cell whose value is absent. */
66
70
  const EMPTY_CELL = "—";
@@ -261,16 +265,12 @@ export async function openNotesDatabase(
261
265
  const base = dir ?? fs.mkdtempSync(path.join(os.tmpdir(), "content-sql-"));
262
266
  fs.mkdirSync(base, { recursive: true });
263
267
  const jsonl = path.join(base, "notes.jsonl");
264
- fs.writeFileSync(
265
- jsonl,
266
- records.map((record) => JSON.stringify(sortKeysDeep(encodeAddresses(record)))).join("\n"),
267
- );
268
+ const encoded = records.map((record) => encodeAddresses(record));
268
269
 
269
270
  const instance = await DuckDBInstance.create(":memory:");
270
271
  const connection = await instance.connect();
271
272
  await connection.run("SET threads=1");
272
- await createRelations(connection, jsonl, "", audience);
273
- const encoded = records.map((record) => encodeAddresses(record));
273
+ await createRelations(connection, jsonl, encoded, "", audience);
274
274
  await createEventRelation(connection, path.join(base, "events.jsonl"), encoded, "", audience);
275
275
  await createWordRelations(connection, path.join(base, "notes"), encoded, "", audience);
276
276
 
@@ -293,8 +293,7 @@ export async function openNotesDatabase(
293
293
  .split("\n")
294
294
  .filter((line) => line.trim())
295
295
  .map((line) => flattenPublishedAddresses(JSON.parse(line)));
296
- fs.writeFileSync(flat, depRecords.map((record) => JSON.stringify(record)).join("\n"));
297
- await createRelations(connection, flat, `${schema}.`, audience);
296
+ await createRelations(connection, flat, depRecords, `${schema}.`, audience);
298
297
  await createEventRelation(
299
298
  connection,
300
299
  flat.replace(/\.jsonl$/, "-events.jsonl"),
@@ -462,12 +461,27 @@ const WITH_STUBS_SCHEMA = "__with_stubs";
462
461
  * of the ladder falls back to a constant where its column is absent.
463
462
  *
464
463
  * @param {object} connection - An open DuckDB connection.
465
- * @param {string} file - The JSONL to read.
464
+ * @param {string} file - Where to write the audience's JSONL.
465
+ * @param {object[]} records - Complete index records, every Address a string.
466
466
  * @param {string} [prefix] - A schema to qualify the view names with.
467
+ * @param {"all"|"public"} [audience] - Whether to leave out GM notes.
467
468
  * @returns {Promise<void>}
468
469
  */
469
- async function createRelations(connection, file, prefix = "", audience = "all") {
470
- const read = readJsonAuto(file);
470
+ async function createRelations(connection, file, records, prefix = "", audience = "all") {
471
+ // Every record contributes its schema; only visible records contribute rows.
472
+ fs.writeFileSync(
473
+ file,
474
+ records
475
+ .map((record) =>
476
+ JSON.stringify({
477
+ record: sortKeysDeep(record),
478
+ visible: audience !== "public" || !isGmNote(record),
479
+ }),
480
+ )
481
+ .join("\n"),
482
+ );
483
+ const source = readJsonAuto(file);
484
+ const read = records.length ? `SELECT record.* FROM (${source}) WHERE visible` : source;
471
485
  const described = await connection.runAndReadAll(`DESCRIBE ${read}`);
472
486
  const columns = new Set(described.getRowObjects().map((row) => String(row.column_name)));
473
487
  // A row with no address is a stub. With no address column anywhere, every
@@ -481,16 +495,11 @@ async function createRelations(connection, file, prefix = "", audience = "all")
481
495
  "COALESCE(list_contains(TRY_CAST(tags AS VARCHAR[]), 'draft'), false) " +
482
496
  "OR COALESCE(TRY_CAST(tags AS VARCHAR) = 'draft', false)"
483
497
  : "false";
484
- const visible =
485
- audience === "public" && columns.has("tags") ?
486
- "NOT (COALESCE(list_contains(TRY_CAST(tags AS VARCHAR[]), 'gm'), false) " +
487
- "OR COALESCE(TRY_CAST(tags AS VARCHAR) = 'gm', false))"
488
- : "true";
489
498
  await createTableFromFile(
490
499
  connection,
491
500
  `${prefix}entries`,
492
501
  `SELECT *, CASE WHEN ${stub} THEN 'stub' WHEN ${draft} THEN 'draft' ` +
493
- `ELSE 'full' END AS state FROM (${read}) WHERE ${visible}`,
502
+ `ELSE 'full' END AS state FROM (${read})`,
494
503
  );
495
504
  await connection.run(
496
505
  `CREATE VIEW ${prefix}notes AS SELECT * FROM ${prefix}entries WHERE state <> 'stub'`,
@@ -617,20 +626,68 @@ async function createWordRelations(connection, stem, records, prefix, audience)
617
626
  LINEAGE_COLUMNS,
618
627
  );
619
628
  const { dictionary, terms } = dictionaryAndTermRows(visible, records);
620
- await createRowRelation(
621
- connection,
622
- `${stem}-dictionary.jsonl`,
623
- dictionary,
624
- `${prefix}dictionary`,
625
- DICTIONARY_COLUMNS,
626
- );
627
- await createRowRelation(
628
- connection,
629
- `${stem}-terms.jsonl`,
630
- terms,
631
- `${prefix}terms`,
632
- TERM_COLUMNS,
633
- );
629
+ const dictionaryTypes = {
630
+ etymology:
631
+ "STRUCT(kind VARCHAR, language VARCHAR, language_tag VARCHAR, form VARCHAR, " +
632
+ "parts STRUCT(form VARCHAR, gloss VARCHAR)[], root VARCHAR, frame VARCHAR)",
633
+ senses: "STRUCT(text VARCHAR, bodies VARCHAR[])[]",
634
+ usage: "VARCHAR[]",
635
+ depth: "BIGINT",
636
+ verbs: `STRUCT(invariant BOOLEAN, persons STRUCT(${VERB_PERSON_FIELDS.map((person) => `"${person.name}" VARCHAR`).join(", ")}))`,
637
+ };
638
+ const dictionaryFile = `${stem}-dictionary.jsonl`;
639
+ if (dictionary.length) {
640
+ // Only the dictionary's declared schema: optional patterns must not
641
+ // change its nested/list types when a corpus has no stated grammar.
642
+ fs.writeFileSync(dictionaryFile, dictionary.map((row) => JSON.stringify(row)).join("\n"));
643
+ const columns = DICTIONARY_COLUMNS.map(
644
+ (column) =>
645
+ `CAST("${column}" AS ${dictionaryTypes[column] ?? "VARCHAR"}) AS "${column}"`,
646
+ );
647
+ await createTableFromFile(
648
+ connection,
649
+ `${prefix}dictionary`,
650
+ `SELECT ${columns.join(", ")} FROM (${readJsonAuto(dictionaryFile)})`,
651
+ );
652
+ } else {
653
+ await createRowRelation(
654
+ connection,
655
+ dictionaryFile,
656
+ dictionary,
657
+ `${prefix}dictionary`,
658
+ DICTIONARY_COLUMNS,
659
+ dictionaryTypes,
660
+ );
661
+ }
662
+ const termTypes = {
663
+ senses: "STRUCT(text VARCHAR, bodies VARCHAR[])[]",
664
+ usage: "VARCHAR[]",
665
+ common: "BOOLEAN",
666
+ origins: "VARCHAR[]",
667
+ cultures: "VARCHAR[]",
668
+ };
669
+ const termsFile = `${stem}-terms.jsonl`;
670
+ if (terms.length) {
671
+ // Terms retain declared types even when every value of a column is null.
672
+ fs.writeFileSync(termsFile, terms.map((row) => JSON.stringify(row)).join("\n"));
673
+ const columns = TERM_COLUMNS.map(
674
+ (column) => `CAST("${column}" AS ${termTypes[column] ?? "VARCHAR"}) AS "${column}"`,
675
+ );
676
+ await createTableFromFile(
677
+ connection,
678
+ `${prefix}terms`,
679
+ `SELECT ${columns.join(", ")} FROM (${readJsonAuto(termsFile)})`,
680
+ );
681
+ } else {
682
+ await createRowRelation(
683
+ connection,
684
+ termsFile,
685
+ terms,
686
+ `${prefix}terms`,
687
+ TERM_COLUMNS,
688
+ termTypes,
689
+ );
690
+ }
634
691
  for (const [name, rowsOf, columns] of LANGUAGE_RELATIONS)
635
692
  await createRowRelation(
636
693
  connection,
@@ -879,8 +936,9 @@ export function renderSqlTable(
879
936
  * its own table and not the whole build's report.
880
937
  *
881
938
  * @param {object} db - From {@link openNotesDatabase}.
882
- * @param {Array<{source: string, markdown: string}>} sources - The bodies to
883
- * scan.
939
+ * @param {Array<{source: string, markdown: string, frontmatter?: object, authoredSqlBlocks?: number}>} sources -
940
+ * The bodies to scan. `joinGeneratedSections` records the number of authored
941
+ * fences; following generated event fences need no stub comparison.
884
942
  * @param {object} [opts]
885
943
  * @param {(ref: string) => boolean} [opts.linkable] - Passed to
886
944
  * {@link renderSqlTable}.
@@ -904,26 +962,33 @@ export async function prepareSqlTables(db, sources, { linkable, records, pageLan
904
962
  const prepared = new Map();
905
963
  if (records) prepared.pageLists = preparePageLists(records, sources);
906
964
  // The corpus does not change while a preparation runs, so a query written
907
- // twice has one answer. Each use gets its own copy, because a caller
908
- // rewrites the rows it is handed.
965
+ // twice has one answer in each stub context. The normal result and the
966
+ // inclusive comparison occupy separate fields of the same cache entry.
967
+ // Each result use gets a copy because a caller rewrites the rows.
909
968
  const answers = new Map();
910
969
  const reader = {
911
970
  ...db,
912
971
  async query(sql) {
913
- if (!answers.has(sql)) answers.set(sql, await db.query(sql));
914
- return structuredClone(answers.get(sql));
972
+ if (!answers.has(sql)) answers.set(sql, { result: await db.query(sql) });
973
+ return structuredClone(answers.get(sql).result);
974
+ },
975
+ async stubsExcluded(sql, rows) {
976
+ const answer = answers.get(sql);
977
+ if (!Object.hasOwn(answer, "excluded"))
978
+ answer.excluded = await db.stubsExcluded?.(sql, rows);
979
+ return answer.excluded;
915
980
  },
916
981
  };
917
982
  let labels;
918
983
  const labelOf = (address) => labels.get(address);
919
984
  const languageOf =
920
985
  pageLanguage ?? ((_source, frontmatter) => frontmatterLanguage(frontmatter, db));
921
- for (const { source, markdown, frontmatter } of sources) {
986
+ for (const { source, markdown, frontmatter, authoredSqlBlocks = Infinity } of sources) {
922
987
  const blocks = findSqlBlocks(markdown);
923
988
  if (!blocks.length) continue;
924
989
  const forNote = [];
925
990
  prepared.set(source, forNote);
926
- for (const block of blocks) {
991
+ for (const [ordinal, block] of blocks.entries()) {
927
992
  try {
928
993
  if (block.problems.length) throw new Error(block.problems.join("; "));
929
994
  const result = await runSqlQuery(reader, block.query);
@@ -945,6 +1010,20 @@ export async function prepareSqlTables(db, sources, { linkable, records, pageLan
945
1010
  labelOf,
946
1011
  showKey: block.showKey,
947
1012
  pageLanguage: languageOf(source, frontmatter),
1013
+ pageOwner:
1014
+ (
1015
+ typeof frontmatter?.type === "string" &&
1016
+ typeof frontmatter.shortcode === "string" &&
1017
+ db.addressContext?.package
1018
+ ) ?
1019
+ renderAddress({
1020
+ package: db.addressContext.package,
1021
+ system: "note",
1022
+ type: frontmatter.type,
1023
+ shortcode: frontmatter.shortcode,
1024
+ })
1025
+ : undefined,
1026
+ entryAnchors: false,
948
1027
  })
949
1028
  : renderSqlTable(result, options),
950
1029
  rows: result.rows.length,
@@ -952,7 +1031,12 @@ export async function prepareSqlTables(db, sources, { linkable, records, pageLan
952
1031
  // relation, so a fence that has not adopted stubs says so
953
1032
  // rather than quietly listing fewer settlements than the
954
1033
  // region has.
955
- stubsExcluded: await db.stubsExcluded?.(block.query, result.rows.length),
1034
+ // Generated event views read entries and events, whose
1035
+ // meaning is identical in both stub contexts.
1036
+ stubsExcluded:
1037
+ ordinal >= authoredSqlBlocks ? 0 : (
1038
+ await reader.stubsExcluded(block.query, result.rows.length)
1039
+ ),
956
1040
  allowEmpty: block.allowEmpty,
957
1041
  });
958
1042
  } catch (err) {
@@ -1095,8 +1179,11 @@ export async function prepareInlineSqlExpressions(db, sources) {
1095
1179
  * @param {Map<string, Map<string, string>>} [opts.derived] - The derived
1096
1180
  * lists, from an earlier call over the same records, which are not derived
1097
1181
  * again.
1182
+ * @param {object[]} [opts.all=records] - Complete records for lexical lineage
1183
+ * traversal through providers outside the surface's audience.
1098
1184
  * @returns {Promise<{sections: Map<string, string>, views: Set<string>,
1099
- * derived: Map<string, Map<string, string>>}>} Note file to its sections,
1185
+ * derived: Map<string, Map<string, string>>,
1186
+ * lexical: Map<string, import("./lexical-sections.mjs").LexicalSection[]>}>} Note file to its sections,
1100
1187
  * joined in order; the files whose sections hold an event view's fence; and
1101
1188
  * the derived lists alone.
1102
1189
  */
@@ -1104,7 +1191,7 @@ export async function generatedSectionsFor(
1104
1191
  db,
1105
1192
  records,
1106
1193
  contentBase,
1107
- { only, foreignIndex, config, derived: given } = {},
1194
+ { only, foreignIndex, config, derived: given, all = records } = {},
1108
1195
  ) {
1109
1196
  const notes = records.filter((record) => isNoteRecord(record));
1110
1197
  const fileOf = (record) => noteFile(contentBase, record);
@@ -1122,7 +1209,26 @@ export async function generatedSectionsFor(
1122
1209
  const sections = new Map();
1123
1210
  for (const file of new Set([...derived.keys(), ...views.keys()]))
1124
1211
  sections.set(file, joinSections([derived.get(file), views.get(file)]));
1125
- return { sections, views: new Set(views.keys()), derived };
1212
+ // Automatic sections follow their source's audience even in a Foundry
1213
+ // corpus containing both public and private packs. Explicit SQL keeps
1214
+ // the database audience the caller requested.
1215
+ const publicRecords = records.filter((record) => !isGmNote(record));
1216
+ const publicOwners = lexicalSectionsFor(publicRecords, all);
1217
+ const allOwners =
1218
+ publicRecords.length === records.length ? publicOwners : lexicalSectionsFor(records, all);
1219
+ const lexical = new Map();
1220
+ for (const note of notes) {
1221
+ const owners = isGmNote(note) ? allOwners : publicOwners;
1222
+ const key = renderAddress({
1223
+ package: note.package,
1224
+ system: "note",
1225
+ type: note.type,
1226
+ shortcode: note.shortcode,
1227
+ });
1228
+ if (owners.has(key) && (only === undefined || fileOf(note) === only))
1229
+ lexical.set(fileOf(note), owners.get(key));
1230
+ }
1231
+ return { sections, views: new Set(views.keys()), derived, lexical };
1126
1232
  }
1127
1233
 
1128
1234
  /**
@@ -1138,7 +1244,7 @@ export async function generatedSectionsFor(
1138
1244
  *
1139
1245
  * @param {{sections: Map<string, string>, views: Set<string>}} generated -
1140
1246
  * From {@link generatedSectionsFor}.
1141
- * @param {Array<{source: string, markdown: string, frontmatter?: object}>} sources -
1247
+ * @param {Array<{source: string, markdown: string, frontmatter?: object, authoredSqlBlocks?: number}>} sources -
1142
1248
  * The bodies to answer, extended in place.
1143
1249
  * @param {Map<string, {body: string, frontmatter: object|null}>} [parsed] -
1144
1250
  * Notes already parsed, keyed by file; a note absent from it is read.
@@ -1148,6 +1254,7 @@ export function joinGeneratedSections(generated, sources, parsed) {
1148
1254
  for (const [absPath, text] of generated.sections) {
1149
1255
  const source = sources.find((one) => one.source === absPath);
1150
1256
  if (source) {
1257
+ source.authoredSqlBlocks ??= findSqlBlocks(source.markdown).length;
1151
1258
  source.markdown = appendGeneratedSections(source.markdown, text);
1152
1259
  continue;
1153
1260
  }
@@ -1157,6 +1264,7 @@ export function joinGeneratedSections(generated, sources, parsed) {
1157
1264
  source: absPath,
1158
1265
  markdown: appendGeneratedSections(body, text),
1159
1266
  frontmatter,
1267
+ authoredSqlBlocks: findSqlBlocks(body).length,
1160
1268
  });
1161
1269
  }
1162
1270
  }
@@ -1167,7 +1275,7 @@ export function joinGeneratedSections(generated, sources, parsed) {
1167
1275
  * empty set carrying them.
1168
1276
  *
1169
1277
  * @param {Map<string, object[]>} prepared - From {@link prepareSqlTables}.
1170
- * @param {{sections: Map<string, string>}} generated - From
1278
+ * @param {{sections: Map<string, string>, lexical?: Map<string, import("./lexical-sections.mjs").LexicalSection[]>}} generated - From
1171
1279
  * {@link generatedSectionsFor}.
1172
1280
  * @returns {void}
1173
1281
  */
@@ -1177,6 +1285,51 @@ export function attachGeneratedSections(prepared, generated) {
1177
1285
  if (!results) prepared.set(absPath, (results = []));
1178
1286
  results.generated = text;
1179
1287
  }
1288
+ for (const [absPath, sections] of generated.lexical ?? []) {
1289
+ let results = prepared.get(absPath);
1290
+ if (!results) prepared.set(absPath, (results = []));
1291
+ results.lexical = sections;
1292
+ }
1293
+ }
1294
+
1295
+ /**
1296
+ * Index the anchors the prepared bodies actually emit, including anchors in
1297
+ * SQL results. Foundry maps use the same expanded body the compiler splits.
1298
+ *
1299
+ * @param {Map<string, object[]>} prepared - The prepared directives.
1300
+ * @param {object[]} records - The visible content-index records.
1301
+ * @param {string} contentBase - Root of the content tree.
1302
+ * @param {Map<string, object>} parsed - Notes already parsed.
1303
+ * @param {object} [config] - The resolved build configuration.
1304
+ * @returns {Promise<void>}
1305
+ */
1306
+ async function attachPreparedAnchors(prepared, records, contentBase, parsed, config) {
1307
+ const { entriesForNote, foundryIdentities } = await import("./foundry-entries.mjs");
1308
+ const identities = config ? foundryIdentities(config) : undefined;
1309
+ for (const record of records) {
1310
+ if (!isNoteRecord(record)) continue;
1311
+ const file = noteFile(contentBase, record);
1312
+ const results = prepared.get(file);
1313
+ if (!results) continue;
1314
+ const { body } = parsed.get(file) ?? parseMarkdownFile(file);
1315
+ const expanded = expandContentTables(body, {
1316
+ source: file,
1317
+ sqlTables: results,
1318
+ pageLists: prepared.pageLists?.get(file),
1319
+ }).markdown;
1320
+ results.anchors = collectAnchors(expanded);
1321
+ if (!identities || !record.address?.slug) continue;
1322
+ const frontmatter = authoredFrontmatter(record);
1323
+ const entries = entriesForNote(
1324
+ frontmatter,
1325
+ frontmatter.name?.full ?? record.file.name,
1326
+ record.address.slug,
1327
+ expanded,
1328
+ identities,
1329
+ );
1330
+ results.anchorUuids = entries[0]?.anchors;
1331
+ results.docAnchorUuids = entries[1]?.anchors;
1332
+ }
1180
1333
  }
1181
1334
 
1182
1335
  /**
@@ -1290,9 +1443,9 @@ export async function prepareTreeSqlTables(
1290
1443
  // uninitialised for whichever module the runtime happened to load first.
1291
1444
  const { indexRecordsFor } = await import("./content-index.mjs");
1292
1445
  const notes = parsed ?? new Map();
1293
- const indexRecords = (
1294
- records ?? indexRecordsFor({ contentBase, config, skipDirectories, parsed: notes })
1295
- ).filter((record) => audience !== "public" || !isGmNote(record));
1446
+ const allRecords =
1447
+ records ?? indexRecordsFor({ contentBase, config, skipDirectories, parsed: notes });
1448
+ const indexRecords = allRecords.filter((record) => audience !== "public" || !isGmNote(record));
1296
1449
 
1297
1450
  // Which notes carry a directive, discovered over the same corpus every
1298
1451
  // other pass reads rather than over a walk of this one's own. The
@@ -1339,8 +1492,10 @@ export async function prepareTreeSqlTables(
1339
1492
  const derivedOnly = await generatedSectionsFor(undefined, indexRecords, contentBase, {
1340
1493
  foreignIndex: foreign,
1341
1494
  config,
1495
+ all: allRecords,
1342
1496
  });
1343
- if (!sources.length && !hasEvents && !derivedOnly.sections.size) return undefined;
1497
+ if (!sources.length && !hasEvents && !derivedOnly.sections.size && !derivedOnly.lexical.size)
1498
+ return undefined;
1344
1499
 
1345
1500
  // A page list is a filter over the records, so it needs no database. A tree
1346
1501
  // whose only corpus-reading directive is one therefore opens none — which
@@ -1359,6 +1514,7 @@ export async function prepareTreeSqlTables(
1359
1514
  pageOnly.inline = new Map();
1360
1515
  pageOnly.pageLists = preparePageLists(indexRecords, sources);
1361
1516
  attachGeneratedSections(pageOnly, derivedOnly);
1517
+ await attachPreparedAnchors(pageOnly, indexRecords, contentBase, notes, config);
1362
1518
  return pageOnly;
1363
1519
  }
1364
1520
  // A cell links only where the address it would emit resolves, so a table
@@ -1373,7 +1529,11 @@ export async function prepareTreeSqlTables(
1373
1529
  .filter((record) => !record.documents)
1374
1530
  .map((record) => record.documentation ?? record.address?.canonical)
1375
1531
  .map((target) => target && encodeAddresses(target))
1376
- .filter(Boolean),
1532
+ .filter(Boolean)
1533
+ .flatMap((target) => [
1534
+ target,
1535
+ renderAddress({ ...readCanonicalKey(target), system: "note" }),
1536
+ ]),
1377
1537
  );
1378
1538
  // Each declared dependency's published index, attached as its own schema so
1379
1539
  // a table can read `FROM <package>.notes`. Imported here for the
@@ -1382,13 +1542,14 @@ export async function prepareTreeSqlTables(
1382
1542
  // been fetched already fails earlier with a message naming the fetch.
1383
1543
  const db =
1384
1544
  database ??
1385
- (await openNotesDatabase(indexRecords, {
1545
+ (await openNotesDatabase(allRecords, {
1386
1546
  dependencies: await cachedDependencies(config),
1387
1547
  audience,
1388
1548
  }));
1389
1549
  try {
1390
1550
  const generated = await generatedSectionsFor(db, indexRecords, contentBase, {
1391
1551
  derived: derivedOnly.derived,
1552
+ all: allRecords,
1392
1553
  });
1393
1554
  joinGeneratedSections(generated, sources, notes);
1394
1555
  const prepared = await prepareSqlTables(db, sources, {
@@ -1398,8 +1559,81 @@ export async function prepareTreeSqlTables(
1398
1559
  });
1399
1560
  prepared.inline = await prepareInlineSqlExpressions(db, sources);
1400
1561
  attachGeneratedSections(prepared, generated);
1562
+ await attachPreparedAnchors(prepared, indexRecords, contentBase, notes, config);
1401
1563
  return prepared;
1402
1564
  } finally {
1403
1565
  if (!database) await db.close();
1404
1566
  }
1405
1567
  }
1568
+
1569
+ /**
1570
+ * Prepare authored link-check tables in each source note's audience. Public
1571
+ * sources share one public database; GM sources share one complete database.
1572
+ * Complete records remain available for authored-link checks.
1573
+ *
1574
+ * @param {string} contentBase - Root of the content tree.
1575
+ * @param {object} [opts]
1576
+ * @param {object} [opts.config] - Resolved configuration.
1577
+ * @param {readonly string[]} [opts.skipDirectories] - The walk's scope.
1578
+ * @param {object[]} [opts.records] - Complete index records already derived.
1579
+ * @param {Map<string, {body: string, frontmatter: object|null}>} [opts.parsed] - Parsed notes.
1580
+ * @param {Map<string, object>} [opts.foreignIndex] - Published dependency indexes.
1581
+ * @returns {Promise<Map<string, object[]>|undefined>} Prepared tables by source.
1582
+ */
1583
+ export async function prepareLinkSqlTables(
1584
+ contentBase,
1585
+ { config, skipDirectories, records, parsed, foreignIndex } = {},
1586
+ ) {
1587
+ const { indexRecordsFor } = await import("./content-index.mjs");
1588
+ const notes = parsed ?? new Map();
1589
+ const allRecords =
1590
+ records ?? indexRecordsFor({ contentBase, config, skipDirectories, parsed: notes });
1591
+ const sources = new Map();
1592
+ const lexicalOwners = lexicalSectionsFor(allRecords);
1593
+ for (const record of allRecords) {
1594
+ if (!isNoteRecord(record)) continue;
1595
+ const file = noteFile(contentBase, record);
1596
+ let note = notes.get(file);
1597
+ if (!note) {
1598
+ note = parseMarkdownFile(file);
1599
+ notes.set(file, note);
1600
+ }
1601
+ const body = note.body ?? "";
1602
+ if (
1603
+ findSqlBlocks(body).length ||
1604
+ findPageListBlocks(body).length ||
1605
+ sqlQueriesInMarkdown(body, note.frontmatter).length ||
1606
+ lexicalOwners.has(
1607
+ renderAddress({
1608
+ package: record.package,
1609
+ system: "note",
1610
+ type: record.type,
1611
+ shortcode: record.shortcode,
1612
+ }),
1613
+ )
1614
+ )
1615
+ sources.set(file, isGmNote(record) ? "all" : "public");
1616
+ }
1617
+ if (!sources.size) return undefined;
1618
+ const selected = new Map();
1619
+ selected.pageLists = new Map();
1620
+ selected.inline = new Map();
1621
+ for (const audience of new Set(sources.values())) {
1622
+ const prepared = await prepareTreeSqlTables(contentBase, {
1623
+ config,
1624
+ skipDirectories,
1625
+ records: allRecords,
1626
+ parsed: notes,
1627
+ foreignIndex,
1628
+ audience,
1629
+ });
1630
+ for (const [file, sourceAudience] of sources) {
1631
+ if (sourceAudience !== audience) continue;
1632
+ if (prepared?.has(file)) selected.set(file, prepared.get(file));
1633
+ if (prepared?.pageLists?.has(file))
1634
+ selected.pageLists.set(file, prepared.pageLists.get(file));
1635
+ if (prepared?.inline?.has(file)) selected.inline.set(file, prepared.inline.get(file));
1636
+ }
1637
+ }
1638
+ return selected;
1639
+ }
@@ -378,17 +378,19 @@ function resolveBody(note, index, body) {
378
378
 
379
379
  /** Findings about the body an entry names. */
380
380
  function bodyFindings(note, body, path, isKey, found) {
381
- const at = position(note, path, isKey);
381
+ const at = () => position(note, path, isKey);
382
382
  const written = isAddressTuple(body) ? renderAddress(body) : String(body);
383
383
  const label = `\`data.affiliations\`${isKey ? " key" : ""} ${JSON.stringify(written)}`;
384
384
  if (found.reason)
385
- return [{ ...at, message: `${label} is not a complete Address (${found.reason})` }];
385
+ return [{ ...at(), message: `${label} is not a complete Address (${found.reason})` }];
386
386
  if (found.type)
387
- return [{ ...at, message: `\`data.affiliations\` accepts affiliation, not ${found.type}` }];
387
+ return [
388
+ { ...at(), message: `\`data.affiliations\` accepts affiliation, not ${found.type}` },
389
+ ];
388
390
  if (found.missing)
389
391
  return [
390
392
  {
391
- ...at,
393
+ ...at(),
392
394
  message:
393
395
  `\`data.affiliations\` names ${found.address}, which does not resolve ` +
394
396
  "in this package or its declared dependencies",
@@ -438,12 +440,12 @@ function absentRankFindings(note, body, path) {
438
440
  function rankFindings(note, standing, body, path, resolved) {
439
441
  // Absent, or not a whole number: the inner-key check's finding.
440
442
  if (!fitsKind(standing.rank ?? null, "integer")) return [];
441
- const at = position(note, [...path, "rank"]);
443
+ const at = () => position(note, [...path, "rank"]);
442
444
  const level = typeof standing.rank === "number" ? standing.rank : Number(standing.rank);
443
445
  if (typeof standing.rank !== "number" || !Number.isInteger(level))
444
446
  return [
445
447
  {
446
- ...at,
448
+ ...at(),
447
449
  message:
448
450
  `\`data.affiliations\` entry for ${body}: \`rank\` must be the level on that body's own ` +
449
451
  `ladder, as a whole number, but reads ${JSON.stringify(standing.rank)}`,
@@ -455,7 +457,7 @@ function rankFindings(note, standing, body, path, resolved) {
455
457
  if (!ladder.size)
456
458
  return [
457
459
  {
458
- ...at,
460
+ ...at(),
459
461
  message:
460
462
  `\`data.affiliations\` entry for ${body} states a rung, and that body ` +
461
463
  "declares no `data.governance.ranks` ladder to hold one",
@@ -464,7 +466,7 @@ function rankFindings(note, standing, body, path, resolved) {
464
466
  const levels = [...ladder.keys()].sort((a, b) => a - b);
465
467
  return [
466
468
  {
467
- ...at,
469
+ ...at(),
468
470
  message:
469
471
  `\`data.affiliations\` entry for ${body}: rank ${level} is not a level ` +
470
472
  `${resolved.address} confers; its ladder runs ${levels.join(", ")}`,
@@ -477,11 +479,11 @@ function officeFindings(note, standing, body, path, resolved) {
477
479
  // Absent is a membership with no post; a value that is not text is the
478
480
  // inner-key check's finding.
479
481
  if (typeof standing.office !== "string") return [];
480
- const at = position(note, [...path, "office"]);
482
+ const at = () => position(note, [...path, "office"]);
481
483
  if (!standing.office.trim())
482
484
  return [
483
485
  {
484
- ...at,
486
+ ...at(),
485
487
  message:
486
488
  `\`data.affiliations\` entry for ${body}: \`office\` must name a post from its ` +
487
489
  `\`governance.offices\`, but reads ${JSON.stringify(standing.office)}`,
@@ -493,7 +495,7 @@ function officeFindings(note, standing, body, path, resolved) {
493
495
  if (!roster.size)
494
496
  return [
495
497
  {
496
- ...at,
498
+ ...at(),
497
499
  message:
498
500
  `\`data.affiliations\` entry for ${body} names an office, and that body ` +
499
501
  `${resolved.address} declares no \`data.governance.offices\` to hold one`,
@@ -501,7 +503,7 @@ function officeFindings(note, standing, body, path, resolved) {
501
503
  ];
502
504
  return [
503
505
  {
504
- ...at,
506
+ ...at(),
505
507
  message:
506
508
  `\`data.affiliations\` entry for ${body}: office ${JSON.stringify(standing.office)} is not ` +
507
509
  `an office ${resolved.address} names; it holds ${[...roster.keys()].join(", ")}`,
@@ -12,14 +12,16 @@
12
12
  */
13
13
 
14
14
  /**
15
- * Every typed note needs a nonempty body.
15
+ * Every typed note needs content: an authored body or automatic lexical data.
16
16
  *
17
- * A missing or whitespace-only body is an error, whether or not the note has
17
+ * Without automatic lexical data, a missing or whitespace-only body is an error, whether or not the note has
18
18
  * a `draft` tag or a frontmatter description. Placeholder-only bodies remain
19
19
  * errors. A nonempty body below twenty-five words receives a warning when it
20
20
  * is not tagged `draft`; the author decides whether that short note is done.
21
21
  * A `folder` note is held only to the first rule: any nonempty body passes,
22
22
  * with or without `draft`.
23
+ * A language's nonempty `words`, or a culture's or affiliation's nonempty
24
+ * `glossary`, supplies an automatic section and needs no authored prose.
23
25
  *
24
26
  * Counts and the oldest drafts remain reports. Classification and indexing
25
27
  * still derive full, draft and stub states from the existing note records;
@@ -36,6 +38,7 @@ import { positionInFrontmatter, positionOfLiteral } from "./diagnostics.mjs";
36
38
  import { parseMarkdownFile } from "./helpers.mjs";
37
39
  import { bodyWordCount, placeholderBody } from "./note-state.mjs";
38
40
  import { isDraftNote } from "./note-vocabulary.mjs";
41
+ import { hasLexicalEntries } from "./lexical-sections.mjs";
39
42
 
40
43
  /**
41
44
  * The word count below which an unmarked body is asked whether it is a draft.
@@ -62,6 +65,7 @@ function checkNote({ fm, body, file, raw }) {
62
65
  const at = (key, value) => positionInFrontmatter(raw, key, value, { topLevel: true });
63
66
 
64
67
  if (String(body ?? "").trim() === "") {
68
+ if (hasLexicalEntries(fm)) return findings;
65
69
  findings.push({
66
70
  file,
67
71
  ...at("type", fm.type),