@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
@@ -26,10 +26,13 @@
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";
35
+ import { NAME_COLUMNS, namesRows } from "./name-rows.mjs";
33
36
  import {
34
37
  GLOSSARY_COLUMNS,
35
38
  AFFIX_COLUMNS,
@@ -45,8 +48,10 @@ import {
45
48
  } from "./word-rows.mjs";
46
49
  import { DICTIONARY_COLUMNS, TERM_COLUMNS, dictionaryAndTermRows } from "./dictionary-rows.mjs";
47
50
  import { LANGUAGE_COLUMN_TYPES, LANGUAGE_RELATIONS } from "./language-rows.mjs";
51
+ import { VERB_PERSON_FIELDS } from "./language-terms.mjs";
48
52
  import { renderDictionary, renderGlossary } from "./dictionary-layout.mjs";
49
53
  import { glossaryLanguage } from "./glossary-language.mjs";
54
+ import { lexicalSectionsFor } from "./lexical-sections.mjs";
50
55
  import { eventViewSections } from "./event-views.mjs";
51
56
  import { appendGeneratedSections, joinSections } from "./generated-sections.mjs";
52
57
  import fs from "node:fs";
@@ -60,7 +65,7 @@ import { MARKET_CLASSES } from "./market-class.mjs";
60
65
  import { parseMarkdownFile } from "./helpers.mjs";
61
66
  import { sqlQueriesInMarkdown } from "./markdown-expressions.mjs";
62
67
  // The record accessors only — see `engine/index-records.mjs`.
63
- import { isNoteRecord, noteFile, sortKeysDeep } from "./index-records.mjs";
68
+ import { authoredFrontmatter, isNoteRecord, noteFile, sortKeysDeep } from "./index-records.mjs";
64
69
 
65
70
  /** Rendered in a cell whose value is absent. */
66
71
  const EMPTY_CELL = "—";
@@ -261,17 +266,14 @@ export async function openNotesDatabase(
261
266
  const base = dir ?? fs.mkdtempSync(path.join(os.tmpdir(), "content-sql-"));
262
267
  fs.mkdirSync(base, { recursive: true });
263
268
  const jsonl = path.join(base, "notes.jsonl");
264
- fs.writeFileSync(
265
- jsonl,
266
- records.map((record) => JSON.stringify(sortKeysDeep(encodeAddresses(record)))).join("\n"),
267
- );
269
+ const encoded = records.map((record) => encodeAddresses(record));
268
270
 
269
271
  const instance = await DuckDBInstance.create(":memory:");
270
272
  const connection = await instance.connect();
271
273
  await connection.run("SET threads=1");
272
- await createRelations(connection, jsonl, "", audience);
273
- const encoded = records.map((record) => encodeAddresses(record));
274
+ await createRelations(connection, jsonl, encoded, "", audience);
274
275
  await createEventRelation(connection, path.join(base, "events.jsonl"), encoded, "", audience);
276
+ await createNameRelation(connection, path.join(base, "names.jsonl"), encoded, "", audience);
275
277
  await createWordRelations(connection, path.join(base, "notes"), encoded, "", audience);
276
278
 
277
279
  // One schema per declared dependency, so `FROM sohl.notes` reads the notes
@@ -293,8 +295,7 @@ export async function openNotesDatabase(
293
295
  .split("\n")
294
296
  .filter((line) => line.trim())
295
297
  .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);
298
+ await createRelations(connection, flat, depRecords, `${schema}.`, audience);
298
299
  await createEventRelation(
299
300
  connection,
300
301
  flat.replace(/\.jsonl$/, "-events.jsonl"),
@@ -302,6 +303,13 @@ export async function openNotesDatabase(
302
303
  `${schema}.`,
303
304
  audience,
304
305
  );
306
+ await createNameRelation(
307
+ connection,
308
+ flat.replace(/\.jsonl$/, "-names.jsonl"),
309
+ depRecords,
310
+ `${schema}.`,
311
+ audience,
312
+ );
305
313
  await createWordRelations(
306
314
  connection,
307
315
  flat.replace(/\.jsonl$/, ""),
@@ -335,6 +343,7 @@ export async function openNotesDatabase(
335
343
  await connection.run(`CREATE VIEW ${WITH_STUBS_SCHEMA}.notes AS SELECT * FROM entries`);
336
344
  for (const relation of [
337
345
  "events",
346
+ "names",
338
347
  "words",
339
348
  "affixes",
340
349
  "fields",
@@ -462,12 +471,27 @@ const WITH_STUBS_SCHEMA = "__with_stubs";
462
471
  * of the ladder falls back to a constant where its column is absent.
463
472
  *
464
473
  * @param {object} connection - An open DuckDB connection.
465
- * @param {string} file - The JSONL to read.
474
+ * @param {string} file - Where to write the audience's JSONL.
475
+ * @param {object[]} records - Complete index records, every Address a string.
466
476
  * @param {string} [prefix] - A schema to qualify the view names with.
477
+ * @param {"all"|"public"} [audience] - Whether to leave out GM notes.
467
478
  * @returns {Promise<void>}
468
479
  */
469
- async function createRelations(connection, file, prefix = "", audience = "all") {
470
- const read = readJsonAuto(file);
480
+ async function createRelations(connection, file, records, prefix = "", audience = "all") {
481
+ // Every record contributes its schema; only visible records contribute rows.
482
+ fs.writeFileSync(
483
+ file,
484
+ records
485
+ .map((record) =>
486
+ JSON.stringify({
487
+ record: sortKeysDeep(record),
488
+ visible: audience !== "public" || !isGmNote(record),
489
+ }),
490
+ )
491
+ .join("\n"),
492
+ );
493
+ const source = readJsonAuto(file);
494
+ const read = records.length ? `SELECT record.* FROM (${source}) WHERE visible` : source;
471
495
  const described = await connection.runAndReadAll(`DESCRIBE ${read}`);
472
496
  const columns = new Set(described.getRowObjects().map((row) => String(row.column_name)));
473
497
  // A row with no address is a stub. With no address column anywhere, every
@@ -481,16 +505,11 @@ async function createRelations(connection, file, prefix = "", audience = "all")
481
505
  "COALESCE(list_contains(TRY_CAST(tags AS VARCHAR[]), 'draft'), false) " +
482
506
  "OR COALESCE(TRY_CAST(tags AS VARCHAR) = 'draft', false)"
483
507
  : "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
508
  await createTableFromFile(
490
509
  connection,
491
510
  `${prefix}entries`,
492
511
  `SELECT *, CASE WHEN ${stub} THEN 'stub' WHEN ${draft} THEN 'draft' ` +
493
- `ELSE 'full' END AS state FROM (${read}) WHERE ${visible}`,
512
+ `ELSE 'full' END AS state FROM (${read})`,
494
513
  );
495
514
  await connection.run(
496
515
  `CREATE VIEW ${prefix}notes AS SELECT * FROM ${prefix}entries WHERE state <> 'stub'`,
@@ -533,6 +552,41 @@ async function createEventRelation(connection, file, records, prefix, audience)
533
552
  await createTableFromFile(connection, `${prefix}events`, readJsonAuto(file));
534
553
  }
535
554
 
555
+ /**
556
+ * Create authored name rows from own-note records, with audience filtering and
557
+ * fixed scalar types even when every optional classification is null.
558
+ * @param {object} connection - An open DuckDB connection.
559
+ * @param {string} file - Where to write the rows.
560
+ * @param {object[]} records - Index records, every Address a string.
561
+ * @param {string} prefix - A schema to qualify the relation name with.
562
+ * @param {"all"|"public"} audience - Whether to leave out GM notes.
563
+ * @returns {Promise<void>}
564
+ */
565
+ async function createNameRelation(connection, file, records, prefix, audience) {
566
+ const rows = namesRows(
567
+ audience === "public" ? records.filter((record) => !isGmNote(record)) : records,
568
+ );
569
+ const columnTypes = NAME_COLUMNS.map((column) => [
570
+ column,
571
+ column === "line" || column === "column" ? "BIGINT" : "VARCHAR",
572
+ ]);
573
+ if (!rows.length) {
574
+ const columns = columnTypes.map(([column, type]) => `NULL::${type} AS "${column}"`);
575
+ await connection.run(
576
+ `CREATE VIEW ${prefix}names AS SELECT ${columns.join(", ")} WHERE false`,
577
+ );
578
+ return;
579
+ }
580
+ fs.writeFileSync(file, rows.map((row) => JSON.stringify(row)).join("\n"));
581
+ const schema = columnTypes.map(([column, type]) => `'${column}': '${type}'`).join(", ");
582
+ await createTableFromFile(
583
+ connection,
584
+ `${prefix}names`,
585
+ `SELECT * FROM read_json('${file.replace(/'/g, "''")}', columns={${schema}}, ` +
586
+ "format='newline_delimited', maximum_object_size=20000000)",
587
+ );
588
+ }
589
+
536
590
  /**
537
591
  * Create a relation as a table, so the JSONL behind it is parsed once when the
538
592
  * database opens rather than on every query. A view over `read_json_auto`
@@ -617,20 +671,68 @@ async function createWordRelations(connection, stem, records, prefix, audience)
617
671
  LINEAGE_COLUMNS,
618
672
  );
619
673
  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
- );
674
+ const dictionaryTypes = {
675
+ etymology:
676
+ "STRUCT(kind VARCHAR, language VARCHAR, language_tag VARCHAR, form VARCHAR, " +
677
+ "parts STRUCT(form VARCHAR, gloss VARCHAR)[], root VARCHAR, frame VARCHAR)",
678
+ senses: "STRUCT(text VARCHAR, bodies VARCHAR[])[]",
679
+ usage: "VARCHAR[]",
680
+ depth: "BIGINT",
681
+ verbs: `STRUCT(invariant BOOLEAN, persons STRUCT(${VERB_PERSON_FIELDS.map((person) => `"${person.name}" VARCHAR`).join(", ")}))`,
682
+ };
683
+ const dictionaryFile = `${stem}-dictionary.jsonl`;
684
+ if (dictionary.length) {
685
+ // Only the dictionary's declared schema: optional patterns must not
686
+ // change its nested/list types when a corpus has no stated grammar.
687
+ fs.writeFileSync(dictionaryFile, dictionary.map((row) => JSON.stringify(row)).join("\n"));
688
+ const columns = DICTIONARY_COLUMNS.map(
689
+ (column) =>
690
+ `CAST("${column}" AS ${dictionaryTypes[column] ?? "VARCHAR"}) AS "${column}"`,
691
+ );
692
+ await createTableFromFile(
693
+ connection,
694
+ `${prefix}dictionary`,
695
+ `SELECT ${columns.join(", ")} FROM (${readJsonAuto(dictionaryFile)})`,
696
+ );
697
+ } else {
698
+ await createRowRelation(
699
+ connection,
700
+ dictionaryFile,
701
+ dictionary,
702
+ `${prefix}dictionary`,
703
+ DICTIONARY_COLUMNS,
704
+ dictionaryTypes,
705
+ );
706
+ }
707
+ const termTypes = {
708
+ senses: "STRUCT(text VARCHAR, bodies VARCHAR[])[]",
709
+ usage: "VARCHAR[]",
710
+ common: "BOOLEAN",
711
+ origins: "VARCHAR[]",
712
+ cultures: "VARCHAR[]",
713
+ };
714
+ const termsFile = `${stem}-terms.jsonl`;
715
+ if (terms.length) {
716
+ // Terms retain declared types even when every value of a column is null.
717
+ fs.writeFileSync(termsFile, terms.map((row) => JSON.stringify(row)).join("\n"));
718
+ const columns = TERM_COLUMNS.map(
719
+ (column) => `CAST("${column}" AS ${termTypes[column] ?? "VARCHAR"}) AS "${column}"`,
720
+ );
721
+ await createTableFromFile(
722
+ connection,
723
+ `${prefix}terms`,
724
+ `SELECT ${columns.join(", ")} FROM (${readJsonAuto(termsFile)})`,
725
+ );
726
+ } else {
727
+ await createRowRelation(
728
+ connection,
729
+ termsFile,
730
+ terms,
731
+ `${prefix}terms`,
732
+ TERM_COLUMNS,
733
+ termTypes,
734
+ );
735
+ }
634
736
  for (const [name, rowsOf, columns] of LANGUAGE_RELATIONS)
635
737
  await createRowRelation(
636
738
  connection,
@@ -953,6 +1055,20 @@ export async function prepareSqlTables(db, sources, { linkable, records, pageLan
953
1055
  labelOf,
954
1056
  showKey: block.showKey,
955
1057
  pageLanguage: languageOf(source, frontmatter),
1058
+ pageOwner:
1059
+ (
1060
+ typeof frontmatter?.type === "string" &&
1061
+ typeof frontmatter.shortcode === "string" &&
1062
+ db.addressContext?.package
1063
+ ) ?
1064
+ renderAddress({
1065
+ package: db.addressContext.package,
1066
+ system: "note",
1067
+ type: frontmatter.type,
1068
+ shortcode: frontmatter.shortcode,
1069
+ })
1070
+ : undefined,
1071
+ entryAnchors: false,
956
1072
  })
957
1073
  : renderSqlTable(result, options),
958
1074
  rows: result.rows.length,
@@ -1108,8 +1224,11 @@ export async function prepareInlineSqlExpressions(db, sources) {
1108
1224
  * @param {Map<string, Map<string, string>>} [opts.derived] - The derived
1109
1225
  * lists, from an earlier call over the same records, which are not derived
1110
1226
  * again.
1227
+ * @param {object[]} [opts.all=records] - Complete records for lexical lineage
1228
+ * traversal through providers outside the surface's audience.
1111
1229
  * @returns {Promise<{sections: Map<string, string>, views: Set<string>,
1112
- * derived: Map<string, Map<string, string>>}>} Note file to its sections,
1230
+ * derived: Map<string, Map<string, string>>,
1231
+ * lexical: Map<string, import("./lexical-sections.mjs").LexicalSection[]>}>} Note file to its sections,
1113
1232
  * joined in order; the files whose sections hold an event view's fence; and
1114
1233
  * the derived lists alone.
1115
1234
  */
@@ -1117,7 +1236,7 @@ export async function generatedSectionsFor(
1117
1236
  db,
1118
1237
  records,
1119
1238
  contentBase,
1120
- { only, foreignIndex, config, derived: given } = {},
1239
+ { only, foreignIndex, config, derived: given, all = records } = {},
1121
1240
  ) {
1122
1241
  const notes = records.filter((record) => isNoteRecord(record));
1123
1242
  const fileOf = (record) => noteFile(contentBase, record);
@@ -1135,7 +1254,26 @@ export async function generatedSectionsFor(
1135
1254
  const sections = new Map();
1136
1255
  for (const file of new Set([...derived.keys(), ...views.keys()]))
1137
1256
  sections.set(file, joinSections([derived.get(file), views.get(file)]));
1138
- return { sections, views: new Set(views.keys()), derived };
1257
+ // Automatic sections follow their source's audience even in a Foundry
1258
+ // corpus containing both public and private packs. Explicit SQL keeps
1259
+ // the database audience the caller requested.
1260
+ const publicRecords = records.filter((record) => !isGmNote(record));
1261
+ const publicOwners = lexicalSectionsFor(publicRecords, all);
1262
+ const allOwners =
1263
+ publicRecords.length === records.length ? publicOwners : lexicalSectionsFor(records, all);
1264
+ const lexical = new Map();
1265
+ for (const note of notes) {
1266
+ const owners = isGmNote(note) ? allOwners : publicOwners;
1267
+ const key = renderAddress({
1268
+ package: note.package,
1269
+ system: "note",
1270
+ type: note.type,
1271
+ shortcode: note.shortcode,
1272
+ });
1273
+ if (owners.has(key) && (only === undefined || fileOf(note) === only))
1274
+ lexical.set(fileOf(note), owners.get(key));
1275
+ }
1276
+ return { sections, views: new Set(views.keys()), derived, lexical };
1139
1277
  }
1140
1278
 
1141
1279
  /**
@@ -1182,7 +1320,7 @@ export function joinGeneratedSections(generated, sources, parsed) {
1182
1320
  * empty set carrying them.
1183
1321
  *
1184
1322
  * @param {Map<string, object[]>} prepared - From {@link prepareSqlTables}.
1185
- * @param {{sections: Map<string, string>}} generated - From
1323
+ * @param {{sections: Map<string, string>, lexical?: Map<string, import("./lexical-sections.mjs").LexicalSection[]>}} generated - From
1186
1324
  * {@link generatedSectionsFor}.
1187
1325
  * @returns {void}
1188
1326
  */
@@ -1192,6 +1330,52 @@ export function attachGeneratedSections(prepared, generated) {
1192
1330
  if (!results) prepared.set(absPath, (results = []));
1193
1331
  results.generated = text;
1194
1332
  }
1333
+ for (const [absPath, sections] of generated.lexical ?? []) {
1334
+ let results = prepared.get(absPath);
1335
+ if (!results) prepared.set(absPath, (results = []));
1336
+ results.lexical = sections;
1337
+ }
1338
+ }
1339
+
1340
+ /**
1341
+ * Index the anchors the prepared bodies actually emit, including anchors in
1342
+ * SQL results. Foundry maps use the same expanded body the compiler splits.
1343
+ *
1344
+ * @param {Map<string, object[]>} prepared - The prepared directives.
1345
+ * @param {object[]} records - The visible content-index records.
1346
+ * @param {string} contentBase - Root of the content tree.
1347
+ * @param {Map<string, object>} parsed - Notes already parsed.
1348
+ * @param {object} [config] - The resolved build configuration.
1349
+ * @returns {Promise<void>}
1350
+ */
1351
+ async function attachPreparedAnchors(prepared, records, contentBase, parsed, config, syntax) {
1352
+ const { entriesForNote, foundryIdentities } = await import("./foundry-entries.mjs");
1353
+ const identities = config ? { ...foundryIdentities(config), syntax } : undefined;
1354
+ for (const record of records) {
1355
+ if (!isNoteRecord(record)) continue;
1356
+ const file = noteFile(contentBase, record);
1357
+ const results = prepared.get(file);
1358
+ if (!results) continue;
1359
+ if (syntax) results.syntax = syntax;
1360
+ const { body } = parsed.get(file) ?? parseMarkdownFile(file);
1361
+ const expanded = expandContentTables(body, {
1362
+ source: file,
1363
+ sqlTables: results,
1364
+ pageLists: prepared.pageLists?.get(file),
1365
+ }).markdown;
1366
+ results.anchors = collectAnchors(expanded, 1, undefined, syntax);
1367
+ if (!identities || !record.address?.slug) continue;
1368
+ const frontmatter = authoredFrontmatter(record);
1369
+ const entries = entriesForNote(
1370
+ frontmatter,
1371
+ frontmatter.name?.full ?? record.file.name,
1372
+ record.address.slug,
1373
+ expanded,
1374
+ identities,
1375
+ );
1376
+ results.anchorUuids = entries[0]?.anchors;
1377
+ results.docAnchorUuids = entries[1]?.anchors;
1378
+ }
1195
1379
  }
1196
1380
 
1197
1381
  /**
@@ -1291,13 +1475,23 @@ export async function cachedDependencies(config) {
1291
1475
  * @param {Map<string, object>} [opts.foreignIndex] - The fetched indexes the
1292
1476
  * generated sections take in, where the caller holds them already; absent,
1293
1477
  * they are read from the cache.
1478
+ * @param {import("./content-session.mjs").ContentSyntax} [opts.syntax] - Exact-text scanner projections.
1294
1479
  * @returns {Promise<Map<string, object[]>|undefined>} Results by note path,
1295
1480
  * carrying `inline` and `pageLists` beside them, or nothing when the tree
1296
1481
  * holds no such directive and no note is given a generated section.
1297
1482
  */
1298
1483
  export async function prepareTreeSqlTables(
1299
1484
  contentBase,
1300
- { config, skipDirectories, records, parsed, database, audience = "all", foreignIndex } = {},
1485
+ {
1486
+ config,
1487
+ skipDirectories,
1488
+ records,
1489
+ parsed,
1490
+ database,
1491
+ audience = "all",
1492
+ foreignIndex,
1493
+ syntax,
1494
+ } = {},
1301
1495
  ) {
1302
1496
  // Imported here rather than at module scope: the index reaches the pack
1303
1497
  // compilers through `manifest-emit` → `journals`, so a static import from a
@@ -1305,9 +1499,9 @@ export async function prepareTreeSqlTables(
1305
1499
  // uninitialised for whichever module the runtime happened to load first.
1306
1500
  const { indexRecordsFor } = await import("./content-index.mjs");
1307
1501
  const notes = parsed ?? new Map();
1308
- const indexRecords = (
1309
- records ?? indexRecordsFor({ contentBase, config, skipDirectories, parsed: notes })
1310
- ).filter((record) => audience !== "public" || !isGmNote(record));
1502
+ const allRecords =
1503
+ records ?? indexRecordsFor({ contentBase, config, skipDirectories, parsed: notes });
1504
+ const indexRecords = allRecords.filter((record) => audience !== "public" || !isGmNote(record));
1311
1505
 
1312
1506
  // Which notes carry a directive, discovered over the same corpus every
1313
1507
  // other pass reads rather than over a walk of this one's own. The
@@ -1354,8 +1548,10 @@ export async function prepareTreeSqlTables(
1354
1548
  const derivedOnly = await generatedSectionsFor(undefined, indexRecords, contentBase, {
1355
1549
  foreignIndex: foreign,
1356
1550
  config,
1551
+ all: allRecords,
1357
1552
  });
1358
- if (!sources.length && !hasEvents && !derivedOnly.sections.size) return undefined;
1553
+ if (!sources.length && !hasEvents && !derivedOnly.sections.size && !derivedOnly.lexical.size)
1554
+ return undefined;
1359
1555
 
1360
1556
  // A page list is a filter over the records, so it needs no database. A tree
1361
1557
  // whose only corpus-reading directive is one therefore opens none — which
@@ -1374,6 +1570,7 @@ export async function prepareTreeSqlTables(
1374
1570
  pageOnly.inline = new Map();
1375
1571
  pageOnly.pageLists = preparePageLists(indexRecords, sources);
1376
1572
  attachGeneratedSections(pageOnly, derivedOnly);
1573
+ await attachPreparedAnchors(pageOnly, indexRecords, contentBase, notes, config, syntax);
1377
1574
  return pageOnly;
1378
1575
  }
1379
1576
  // A cell links only where the address it would emit resolves, so a table
@@ -1388,7 +1585,11 @@ export async function prepareTreeSqlTables(
1388
1585
  .filter((record) => !record.documents)
1389
1586
  .map((record) => record.documentation ?? record.address?.canonical)
1390
1587
  .map((target) => target && encodeAddresses(target))
1391
- .filter(Boolean),
1588
+ .filter(Boolean)
1589
+ .flatMap((target) => [
1590
+ target,
1591
+ renderAddress({ ...readCanonicalKey(target), system: "note" }),
1592
+ ]),
1392
1593
  );
1393
1594
  // Each declared dependency's published index, attached as its own schema so
1394
1595
  // a table can read `FROM <package>.notes`. Imported here for the
@@ -1397,13 +1598,14 @@ export async function prepareTreeSqlTables(
1397
1598
  // been fetched already fails earlier with a message naming the fetch.
1398
1599
  const db =
1399
1600
  database ??
1400
- (await openNotesDatabase(indexRecords, {
1601
+ (await openNotesDatabase(allRecords, {
1401
1602
  dependencies: await cachedDependencies(config),
1402
1603
  audience,
1403
1604
  }));
1404
1605
  try {
1405
1606
  const generated = await generatedSectionsFor(db, indexRecords, contentBase, {
1406
1607
  derived: derivedOnly.derived,
1608
+ all: allRecords,
1407
1609
  });
1408
1610
  joinGeneratedSections(generated, sources, notes);
1409
1611
  const prepared = await prepareSqlTables(db, sources, {
@@ -1413,8 +1615,86 @@ export async function prepareTreeSqlTables(
1413
1615
  });
1414
1616
  prepared.inline = await prepareInlineSqlExpressions(db, sources);
1415
1617
  attachGeneratedSections(prepared, generated);
1618
+ await attachPreparedAnchors(prepared, indexRecords, contentBase, notes, config, syntax);
1416
1619
  return prepared;
1417
1620
  } finally {
1418
1621
  if (!database) await db.close();
1419
1622
  }
1420
1623
  }
1624
+
1625
+ /**
1626
+ * Prepare authored link-check tables in each source note's audience. Public
1627
+ * sources share one public database; GM sources share one complete database.
1628
+ * Complete records remain available for authored-link checks.
1629
+ *
1630
+ * @param {string} contentBase - Root of the content tree.
1631
+ * @param {object} [opts]
1632
+ * @param {object} [opts.config] - Resolved configuration.
1633
+ * @param {readonly string[]} [opts.skipDirectories] - The walk's scope.
1634
+ * @param {object[]} [opts.records] - Complete index records already derived.
1635
+ * @param {Map<string, {body: string, frontmatter: object|null}>} [opts.parsed] - Parsed notes.
1636
+ * @param {Map<string, object>} [opts.foreignIndex] - Published dependency indexes.
1637
+ * @param {(audience: "all"|"public") => Promise<Map<string, object[]>|undefined>} [opts.prepareAudience] -
1638
+ * Context-bound preparation shared with other operations in the same session.
1639
+ * @returns {Promise<Map<string, object[]>|undefined>} Prepared tables by source.
1640
+ */
1641
+ export async function prepareLinkSqlTables(
1642
+ contentBase,
1643
+ { config, skipDirectories, records, parsed, foreignIndex, prepareAudience } = {},
1644
+ ) {
1645
+ const { indexRecordsFor } = await import("./content-index.mjs");
1646
+ const notes = parsed ?? new Map();
1647
+ const allRecords =
1648
+ records ?? indexRecordsFor({ contentBase, config, skipDirectories, parsed: notes });
1649
+ const sources = new Map();
1650
+ const lexicalOwners = lexicalSectionsFor(allRecords);
1651
+ for (const record of allRecords) {
1652
+ if (!isNoteRecord(record)) continue;
1653
+ const file = noteFile(contentBase, record);
1654
+ let note = notes.get(file);
1655
+ if (!note) {
1656
+ note = parseMarkdownFile(file);
1657
+ notes.set(file, note);
1658
+ }
1659
+ const body = note.body ?? "";
1660
+ if (
1661
+ findSqlBlocks(body).length ||
1662
+ findPageListBlocks(body).length ||
1663
+ sqlQueriesInMarkdown(body, note.frontmatter).length ||
1664
+ lexicalOwners.has(
1665
+ renderAddress({
1666
+ package: record.package,
1667
+ system: "note",
1668
+ type: record.type,
1669
+ shortcode: record.shortcode,
1670
+ }),
1671
+ )
1672
+ )
1673
+ sources.set(file, isGmNote(record) ? "all" : "public");
1674
+ }
1675
+ if (!sources.size) return undefined;
1676
+ const selected = new Map();
1677
+ selected.pageLists = new Map();
1678
+ selected.inline = new Map();
1679
+ for (const audience of new Set(sources.values())) {
1680
+ const prepared =
1681
+ prepareAudience ?
1682
+ await prepareAudience(audience)
1683
+ : await prepareTreeSqlTables(contentBase, {
1684
+ config,
1685
+ skipDirectories,
1686
+ records: allRecords,
1687
+ parsed: notes,
1688
+ foreignIndex,
1689
+ audience,
1690
+ });
1691
+ for (const [file, sourceAudience] of sources) {
1692
+ if (sourceAudience !== audience) continue;
1693
+ if (prepared?.has(file)) selected.set(file, prepared.get(file));
1694
+ if (prepared?.pageLists?.has(file))
1695
+ selected.pageLists.set(file, prepared.pageLists.get(file));
1696
+ if (prepared?.inline?.has(file)) selected.inline.set(file, prepared.inline.get(file));
1697
+ }
1698
+ }
1699
+ return selected;
1700
+ }
@@ -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),
@@ -120,13 +124,14 @@ function checkNote({ fm, body, file, raw }) {
120
124
  * @param {object} opts
121
125
  * @param {readonly object[]} opts.records - Index records the caller derived.
122
126
  * @param {string} [opts.contentPackage] - Named in the counts line.
127
+ * @param {Map<string, import("./content-session.mjs").ContentNoteSource>} [opts.parsed] - Captured sources, with the file reader as fallback.
123
128
  * @returns {{findings: object[], counts: {full: number, draft: number,
124
129
  * stub: number}, byType: Array<{type: string, full: number, draft: number,
125
130
  * stub: number}>, oldestDrafts: Array<{file: string, modified: Date}>,
126
131
  * summary: string[]}} The findings, the ladder's counts, and the lines a
127
132
  * reader is shown as prose rather than as findings.
128
133
  */
129
- export function lintNoteStates(contentBase, { records, contentPackage } = {}) {
134
+ export function lintNoteStates(contentBase, { records, contentPackage, parsed } = {}) {
130
135
  const findings = [];
131
136
  const counts = { full: 0, draft: 0, stub: 0 };
132
137
  /** @type {Map<string, {full: number, draft: number, stub: number}>} */
@@ -145,8 +150,8 @@ export function lintNoteStates(contentBase, { records, contentPackage } = {}) {
145
150
  const absPath = noteFile(contentBase, record);
146
151
  const file = path.relative(process.cwd(), absPath);
147
152
  const fm = authoredFrontmatter(record);
148
- const { body } = parseMarkdownFile(absPath);
149
- const raw = fs.readFileSync(absPath, "utf8");
153
+ const source = parsed?.get(absPath) ?? parseMarkdownFile(absPath);
154
+ const { body, raw } = source;
150
155
 
151
156
  // The ladder, read exactly as the `entries` view reads it: the absent
152
157
  // address is the state, not a second body test beside it. A note with
@@ -73,12 +73,12 @@ import { resolveEmbeds } from "./content-embeds.mjs";
73
73
  /**
74
74
  * Whether a page's declared anchors include the slug a link names.
75
75
  *
76
- * A local page's entry carries a `Set` (built by {@link module:engine/site-index});
77
- * a foreign one carries the `{slug: uuid}` map a fetched manifest publishes.
78
- * `undefined` means neither build recorded an answer, which keeps a target
79
- * this check cannot speak for exactly as unchecked as it always was.
76
+ * A local page carries a `Set` built by {@link module:engine/site-index}.
77
+ * A foreign page declares prose anchors in its note metadata, independently
78
+ * of whether it has a Foundry target map. An empty collection declares none.
79
+ * An absent collection leaves the target unchecked.
80
80
  *
81
- * @param {Set<string>|Record<string, string>|undefined} anchors - The page's
81
+ * @param {Set<string>|Record<string, string>|Array<{slug: string, kind: string}>|undefined} anchors - The page's
82
82
  * declared anchors, or `undefined` where none were recorded.
83
83
  * @param {string} slug - The anchor a link names.
84
84
  * @returns {boolean} Whether the anchor is declared, or `true` when `anchors`
@@ -87,6 +87,8 @@ import { resolveEmbeds } from "./content-embeds.mjs";
87
87
  */
88
88
  function hasAnchor(anchors, slug) {
89
89
  if (!anchors) return true;
90
+ if (Array.isArray(anchors))
91
+ return anchors.some((anchor) => anchor.kind === "prose" && anchor.slug === slug);
90
92
  return anchors instanceof Set ? anchors.has(slug) : Object.hasOwn(anchors, slug);
91
93
  }
92
94
 
@@ -478,7 +480,7 @@ export function resolveWebWikilinks(body, ctx) {
478
480
  label ?? hit.name ?? target,
479
481
  );
480
482
  }
481
- if (anchor && hit.url && !hasAnchor(hit.anchors, anchor)) {
483
+ if (anchor && hit.url && !hasAnchor(hit.noteAnchors ?? hit.anchors, anchor)) {
482
484
  return report(
483
485
  all,
484
486
  { target, reason: "unknown-anchor", anchor, addressed: true },