@heroiclands/package-build 23.8.0 → 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 (57) hide show
  1. package/CHANGELOG.md +27 -0
  2. package/bin/content-commands.mjs +12 -4
  3. package/docs/api.md +328 -28
  4. package/docs/authoring/first-note.md +1 -1
  5. package/docs/authoring/links-and-markup.md +140 -56
  6. package/docs/commands.md +19 -10
  7. package/docs/content-format.md +2 -2
  8. package/docs/diagnostics.md +1 -1
  9. package/docs/guides/packs.md +2 -1
  10. package/docs/guides/reference-pages.md +86 -63
  11. package/docs/reference/format-details.md +269 -123
  12. package/docs/reference/note-types.md +184 -170
  13. package/engine/anchors.mjs +7 -20
  14. package/engine/compile-corpus.mjs +3 -2
  15. package/engine/content-format.yaml +1 -0
  16. package/engine/content-index.mjs +412 -11
  17. package/engine/content-links.mjs +43 -15
  18. package/engine/content-tables.mjs +8 -4
  19. package/engine/culture-reach.mjs +61 -34
  20. package/engine/data-keys.mjs +3 -1
  21. package/engine/dictionary-layout.mjs +151 -19
  22. package/engine/dictionary-rows.mjs +129 -79
  23. package/engine/foundry-entries.mjs +21 -3
  24. package/engine/glossary-entries.mjs +11 -11
  25. package/engine/heading-attributes.mjs +21 -28
  26. package/engine/helpers.mjs +12 -6
  27. package/engine/journals.mjs +4 -1
  28. package/engine/language-data.mjs +53 -0
  29. package/engine/language-terms.mjs +86 -0
  30. package/engine/language-words.mjs +2 -2
  31. package/engine/lexical-sections.mjs +247 -0
  32. package/engine/note-claims.mjs +2 -0
  33. package/engine/note-state.mjs +9 -3
  34. package/engine/note-vocabulary.mjs +7 -6
  35. package/engine/pdf-build.mjs +9 -9
  36. package/engine/site-build.mjs +16 -2
  37. package/engine/sql-tables.mjs +262 -43
  38. package/engine/stub-lint.mjs +6 -2
  39. package/engine/web-wikilinks.mjs +8 -6
  40. package/engine/word-rows.mjs +20 -12
  41. package/engine/word-terms.mjs +15 -6
  42. package/package.json +1 -1
  43. package/types/engine/content-index.d.mts +65 -1
  44. package/types/engine/content-tables.d.mts +2 -1
  45. package/types/engine/culture-reach.d.mts +22 -2
  46. package/types/engine/data-keys.d.mts +7 -0
  47. package/types/engine/dictionary-layout.d.mts +35 -4
  48. package/types/engine/dictionary-rows.d.mts +9 -0
  49. package/types/engine/foundry-entries.d.mts +3 -1
  50. package/types/engine/heading-attributes.d.mts +2 -2
  51. package/types/engine/helpers.d.mts +4 -1
  52. package/types/engine/language-terms.d.mts +7 -0
  53. package/types/engine/lexical-sections.d.mts +95 -0
  54. package/types/engine/note-state.d.mts +2 -1
  55. package/types/engine/site-build.d.mts +4 -1
  56. package/types/engine/sql-tables.d.mts +33 -3
  57. 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,
@@ -953,6 +1010,20 @@ export async function prepareSqlTables(db, sources, { linkable, records, pageLan
953
1010
  labelOf,
954
1011
  showKey: block.showKey,
955
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,
956
1027
  })
957
1028
  : renderSqlTable(result, options),
958
1029
  rows: result.rows.length,
@@ -1108,8 +1179,11 @@ export async function prepareInlineSqlExpressions(db, sources) {
1108
1179
  * @param {Map<string, Map<string, string>>} [opts.derived] - The derived
1109
1180
  * lists, from an earlier call over the same records, which are not derived
1110
1181
  * again.
1182
+ * @param {object[]} [opts.all=records] - Complete records for lexical lineage
1183
+ * traversal through providers outside the surface's audience.
1111
1184
  * @returns {Promise<{sections: Map<string, string>, views: Set<string>,
1112
- * 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,
1113
1187
  * joined in order; the files whose sections hold an event view's fence; and
1114
1188
  * the derived lists alone.
1115
1189
  */
@@ -1117,7 +1191,7 @@ export async function generatedSectionsFor(
1117
1191
  db,
1118
1192
  records,
1119
1193
  contentBase,
1120
- { only, foreignIndex, config, derived: given } = {},
1194
+ { only, foreignIndex, config, derived: given, all = records } = {},
1121
1195
  ) {
1122
1196
  const notes = records.filter((record) => isNoteRecord(record));
1123
1197
  const fileOf = (record) => noteFile(contentBase, record);
@@ -1135,7 +1209,26 @@ export async function generatedSectionsFor(
1135
1209
  const sections = new Map();
1136
1210
  for (const file of new Set([...derived.keys(), ...views.keys()]))
1137
1211
  sections.set(file, joinSections([derived.get(file), views.get(file)]));
1138
- 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 };
1139
1232
  }
1140
1233
 
1141
1234
  /**
@@ -1182,7 +1275,7 @@ export function joinGeneratedSections(generated, sources, parsed) {
1182
1275
  * empty set carrying them.
1183
1276
  *
1184
1277
  * @param {Map<string, object[]>} prepared - From {@link prepareSqlTables}.
1185
- * @param {{sections: Map<string, string>}} generated - From
1278
+ * @param {{sections: Map<string, string>, lexical?: Map<string, import("./lexical-sections.mjs").LexicalSection[]>}} generated - From
1186
1279
  * {@link generatedSectionsFor}.
1187
1280
  * @returns {void}
1188
1281
  */
@@ -1192,6 +1285,51 @@ export function attachGeneratedSections(prepared, generated) {
1192
1285
  if (!results) prepared.set(absPath, (results = []));
1193
1286
  results.generated = text;
1194
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
+ }
1195
1333
  }
1196
1334
 
1197
1335
  /**
@@ -1305,9 +1443,9 @@ export async function prepareTreeSqlTables(
1305
1443
  // uninitialised for whichever module the runtime happened to load first.
1306
1444
  const { indexRecordsFor } = await import("./content-index.mjs");
1307
1445
  const notes = parsed ?? new Map();
1308
- const indexRecords = (
1309
- records ?? indexRecordsFor({ contentBase, config, skipDirectories, parsed: notes })
1310
- ).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));
1311
1449
 
1312
1450
  // Which notes carry a directive, discovered over the same corpus every
1313
1451
  // other pass reads rather than over a walk of this one's own. The
@@ -1354,8 +1492,10 @@ export async function prepareTreeSqlTables(
1354
1492
  const derivedOnly = await generatedSectionsFor(undefined, indexRecords, contentBase, {
1355
1493
  foreignIndex: foreign,
1356
1494
  config,
1495
+ all: allRecords,
1357
1496
  });
1358
- if (!sources.length && !hasEvents && !derivedOnly.sections.size) return undefined;
1497
+ if (!sources.length && !hasEvents && !derivedOnly.sections.size && !derivedOnly.lexical.size)
1498
+ return undefined;
1359
1499
 
1360
1500
  // A page list is a filter over the records, so it needs no database. A tree
1361
1501
  // whose only corpus-reading directive is one therefore opens none — which
@@ -1374,6 +1514,7 @@ export async function prepareTreeSqlTables(
1374
1514
  pageOnly.inline = new Map();
1375
1515
  pageOnly.pageLists = preparePageLists(indexRecords, sources);
1376
1516
  attachGeneratedSections(pageOnly, derivedOnly);
1517
+ await attachPreparedAnchors(pageOnly, indexRecords, contentBase, notes, config);
1377
1518
  return pageOnly;
1378
1519
  }
1379
1520
  // A cell links only where the address it would emit resolves, so a table
@@ -1388,7 +1529,11 @@ export async function prepareTreeSqlTables(
1388
1529
  .filter((record) => !record.documents)
1389
1530
  .map((record) => record.documentation ?? record.address?.canonical)
1390
1531
  .map((target) => target && encodeAddresses(target))
1391
- .filter(Boolean),
1532
+ .filter(Boolean)
1533
+ .flatMap((target) => [
1534
+ target,
1535
+ renderAddress({ ...readCanonicalKey(target), system: "note" }),
1536
+ ]),
1392
1537
  );
1393
1538
  // Each declared dependency's published index, attached as its own schema so
1394
1539
  // a table can read `FROM <package>.notes`. Imported here for the
@@ -1397,13 +1542,14 @@ export async function prepareTreeSqlTables(
1397
1542
  // been fetched already fails earlier with a message naming the fetch.
1398
1543
  const db =
1399
1544
  database ??
1400
- (await openNotesDatabase(indexRecords, {
1545
+ (await openNotesDatabase(allRecords, {
1401
1546
  dependencies: await cachedDependencies(config),
1402
1547
  audience,
1403
1548
  }));
1404
1549
  try {
1405
1550
  const generated = await generatedSectionsFor(db, indexRecords, contentBase, {
1406
1551
  derived: derivedOnly.derived,
1552
+ all: allRecords,
1407
1553
  });
1408
1554
  joinGeneratedSections(generated, sources, notes);
1409
1555
  const prepared = await prepareSqlTables(db, sources, {
@@ -1413,8 +1559,81 @@ export async function prepareTreeSqlTables(
1413
1559
  });
1414
1560
  prepared.inline = await prepareInlineSqlExpressions(db, sources);
1415
1561
  attachGeneratedSections(prepared, generated);
1562
+ await attachPreparedAnchors(prepared, indexRecords, contentBase, notes, config);
1416
1563
  return prepared;
1417
1564
  } finally {
1418
1565
  if (!database) await db.close();
1419
1566
  }
1420
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
+ }
@@ -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),
@@ -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 },
@@ -82,15 +82,17 @@ const isMap = (value) => Boolean(value) && typeof value === "object" && !Array.i
82
82
  const bare = (form) => String(form).replace(/^-+|-+$/g, "");
83
83
 
84
84
  /**
85
- * A lookup from a built piece to its gloss, over the words (stems included)
86
- * and affixes of one or more languages.
85
+ * A lookup from a built piece to its gloss, over the words (stems included),
86
+ * affixes and elements of one or more languages.
87
87
  *
88
- * Languages are given nearest first: a language's own words, then its own
89
- * affixes, then the next language's, so a daughter's entry wins over its
90
- * parent's. A piece matches an entry written exactly as it is, and failing
91
- * that one written with the same letters and different joining hyphens, so
88
+ * For each spelling, languages are given nearest first: a language's own
89
+ * words, then its affixes and elements, then the next language's. A piece
90
+ * matches an exact spelling across the lineage first, and failing that one
91
+ * written with the same letters and different joining hyphens, so
92
92
  * `wulf-` finds the word `wulf`. Within a language a word's own gloss wins over
93
- * an affix's.
93
+ * an affix's, and both precede an element's sense. Elements also match their
94
+ * declared variants; affixes also match their front-vowel forms. Spelling,
95
+ * including case and accents, is preserved.
94
96
  *
95
97
  * @param {...object} datas - Each language note's `data`, nearest first.
96
98
  * @returns {(piece: string) => string|null} The gloss, or `null` where none resolves.
@@ -98,18 +100,24 @@ const bare = (form) => String(form).replace(/^-+|-+$/g, "");
98
100
  export function pieceGlosses(...datas) {
99
101
  const exact = new Map();
100
102
  const loose = new Map();
101
- const take = (entries) => {
103
+ const take = (entries, key = "gloss", alternateKey = "") => {
102
104
  for (const entry of Array.isArray(entries) ? entries : []) {
103
105
  if (!isMap(entry) || typeof entry.form !== "string") continue;
104
- const gloss = glossText(entry.gloss);
106
+ const gloss = glossText(entry[key]);
105
107
  if (gloss === null) continue;
106
- if (!exact.has(entry.form)) exact.set(entry.form, gloss);
107
- if (!loose.has(bare(entry.form))) loose.set(bare(entry.form), gloss);
108
+ const alternate = entry[alternateKey];
109
+ const forms = [entry.form, ...(Array.isArray(alternate) ? alternate : [alternate])];
110
+ for (const form of forms) {
111
+ if (typeof form !== "string") continue;
112
+ if (!exact.has(form)) exact.set(form, gloss);
113
+ if (!loose.has(bare(form))) loose.set(bare(form), gloss);
114
+ }
108
115
  }
109
116
  };
110
117
  for (const data of datas) {
111
118
  take(data?.words);
112
- take(data?.affixes);
119
+ take(data?.affixes, "gloss", "front");
120
+ take(data?.elements, "sense", "variants");
113
121
  }
114
122
  return (piece) => exact.get(piece) ?? loose.get(bare(piece)) ?? null;
115
123
  }
@@ -136,12 +136,20 @@ export const WORD_FIELDS = Object.freeze([
136
136
  "A class from the master word classes: a base, then the modifiers the language " +
137
137
  "declares in `classModifiers`.",
138
138
  }),
139
+ Object.freeze({
140
+ name: "plural",
141
+ kind: "string",
142
+ shape: "a string",
143
+ describe:
144
+ "The noun's explicit plural, where it differs from the language's optional plural " +
145
+ "rule. A dictionary uses this form before the rule; no rule agreement is checked.",
146
+ }),
139
147
  Object.freeze({
140
148
  name: "field",
141
149
  kind: "string",
142
150
  required: true,
143
151
  shape: "one of the language's `fields` names",
144
- describe: "The field the word is grouped under, named in the language's `fields`.",
152
+ describe: "The field tag beside the dictionary entry, named in the language's `fields`.",
145
153
  }),
146
154
  Object.freeze({
147
155
  name: "gloss",
@@ -376,27 +384,28 @@ export const GLOSSARY_ENTRY_FIELDS = Object.freeze([
376
384
  name: "name_sort",
377
385
  kind: "string",
378
386
  shape: "a string",
379
- describe: "Its sort key; the name when unstated.",
387
+ describe: "Its sort key; the dictionary word's sort key, or the name, when unstated.",
380
388
  }),
381
389
  Object.freeze({
382
390
  name: "common",
383
391
  kind: "boolean",
384
392
  shape: "`true` or `false`",
385
- describe: "A word a player is expected to know by heart; `false` when unstated.",
393
+ describe: "Core vocabulary for this glossary's owner; `false` when unstated.",
386
394
  }),
387
395
  Object.freeze({
388
396
  name: "definition",
389
397
  kind: "string",
390
398
  shape: "a string",
391
399
  describe:
392
- "The sense text. Stated, it creates the entry or replaces every sense the " +
393
- "entry's other sources give it.",
400
+ "The sense text, overriding the word's or governance sources' senses. Unstated, " +
401
+ "a named dictionary word supplies all its senses and class; a named rank or office supplies its descriptions.",
394
402
  }),
395
403
  Object.freeze({
396
404
  name: "pronunciation",
397
405
  kind: "string",
398
406
  shape: "a respelling such as `sol-MEH-reh`",
399
- describe: "A respelling, held to the same rule as a word's.",
407
+ describe:
408
+ "A respelling, held to the same rule as a word's; the dictionary word's when unstated.",
400
409
  }),
401
410
  Object.freeze({
402
411
  name: "language",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@heroiclands/package-build",
3
- "version": "23.8.0",
3
+ "version": "23.9.0",
4
4
  "description": "Shared toolchain for building and shipping a HeroicLands Foundry VTT package — content compilation, manifest, localization, staging, bundle, release and deployment.",
5
5
  "license": "GPL-3.0-or-later",
6
6
  "type": "module",