@heroiclands/package-build 23.5.1 → 23.6.1

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 (44) hide show
  1. package/CHANGELOG.md +41 -0
  2. package/bin/content-commands.mjs +23 -5
  3. package/docs/api.md +9 -0
  4. package/docs/authoring/links-and-markup.md +108 -12
  5. package/docs/content-format.md +16 -0
  6. package/docs/guides/reference-pages.md +89 -0
  7. package/docs/reference/format-details.md +549 -88
  8. package/docs/reference/note-types.md +192 -149
  9. package/engine/base-compiler.mjs +21 -0
  10. package/engine/content-format.yaml +9 -0
  11. package/engine/content-index.mjs +19 -5
  12. package/engine/culture-reach.mjs +330 -0
  13. package/engine/dictionary-layout.mjs +342 -0
  14. package/engine/dictionary-rows.mjs +605 -0
  15. package/engine/glossary-entries.mjs +182 -0
  16. package/engine/glossary-language.mjs +99 -0
  17. package/engine/index.mjs +1 -0
  18. package/engine/infobox.mjs +8 -0
  19. package/engine/language-words.mjs +374 -0
  20. package/engine/literature-notes.mjs +10 -8
  21. package/engine/note-addresses.mjs +25 -0
  22. package/engine/note-vocabulary.mjs +161 -1
  23. package/engine/pack-config.mjs +6 -1
  24. package/engine/path-charset.mjs +120 -0
  25. package/engine/sql-tables.mjs +352 -21
  26. package/engine/word-classes.mjs +167 -0
  27. package/engine/word-rows.mjs +343 -0
  28. package/engine/word-terms.mjs +318 -0
  29. package/package.json +1 -1
  30. package/types/engine/content-index.d.mts +15 -5
  31. package/types/engine/culture-reach.d.mts +66 -0
  32. package/types/engine/dictionary-layout.d.mts +74 -0
  33. package/types/engine/dictionary-rows.d.mts +39 -0
  34. package/types/engine/glossary-entries.d.mts +12 -0
  35. package/types/engine/glossary-language.d.mts +42 -0
  36. package/types/engine/index.d.mts +1 -0
  37. package/types/engine/language-words.d.mts +33 -0
  38. package/types/engine/literature-notes.d.mts +2 -2
  39. package/types/engine/note-addresses.d.mts +17 -0
  40. package/types/engine/path-charset.d.mts +38 -0
  41. package/types/engine/sql-tables.d.mts +8 -2
  42. package/types/engine/word-classes.d.mts +87 -0
  43. package/types/engine/word-rows.d.mts +119 -0
  44. package/types/engine/word-terms.d.mts +72 -0
@@ -30,6 +30,22 @@ import { parseAddress, renderAddress, isAddressTuple } from "./address.mjs";
30
30
  import { NOTE_VOCABULARY, isGmNote } from "./note-vocabulary.mjs";
31
31
  import { encodeAddresses, flattenPublishedAddresses } from "./address-values.mjs";
32
32
  import { EVENT_COLUMNS, EVENT_SORT_COLUMNS, eventRows } from "./event-rows.mjs";
33
+ import {
34
+ GLOSSARY_COLUMNS,
35
+ AFFIX_COLUMNS,
36
+ FIELD_COLUMNS,
37
+ LINEAGE_COLUMNS,
38
+ ROW_INTEGER_COLUMNS,
39
+ WORD_COLUMNS,
40
+ glossaryRows,
41
+ lineageRows,
42
+ affixRows,
43
+ fieldRows,
44
+ wordRows,
45
+ } from "./word-rows.mjs";
46
+ import { DICTIONARY_COLUMNS, TERM_COLUMNS, dictionaryAndTermRows } from "./dictionary-rows.mjs";
47
+ import { renderDictionary, renderGlossary } from "./dictionary-layout.mjs";
48
+ import { glossaryLanguage } from "./glossary-language.mjs";
33
49
  import { eventViewSections } from "./event-views.mjs";
34
50
  import { appendGeneratedSections, joinSections } from "./generated-sections.mjs";
35
51
  import fs from "node:fs";
@@ -78,10 +94,32 @@ export const SQL_FENCE_ATTRIBUTES = Object.freeze({
78
94
  "section-level": Object.freeze({
79
95
  value: "an integer from 1 through 6",
80
96
  default: "`2`",
81
- summary: "The heading level given to each group a `_section` column creates.",
97
+ summary:
98
+ "The heading level given to each group a `_section` column creates, and to each " +
99
+ "letter of a dictionary or a glossary.",
100
+ }),
101
+ key: Object.freeze({
102
+ value: "`true` or `false`",
103
+ default: "`true`",
104
+ summary:
105
+ "Whether a dictionary or a glossary prints its key, the line of abbreviations it " +
106
+ "uses. `false` leaves the key out, for a page that states it once above several " +
107
+ "fences.",
108
+ }),
109
+ layout: Object.freeze({
110
+ value: "`table`, `dictionary` or `glossary`",
111
+ default: "`table`",
112
+ summary:
113
+ "How the result is set: as a table; as a dictionary of words, read from the " +
114
+ "`dictionary` relation; or as a glossary of terms, read from the `terms` relation. " +
115
+ "A dictionary and a glossary are one paragraph per entry under letter headings, and " +
116
+ "each needs the `name`, `senses` and `usage` columns.",
82
117
  }),
83
118
  });
84
119
 
120
+ /** The values `layout` takes. */
121
+ const SQL_LAYOUTS = Object.freeze(["table", "dictionary", "glossary"]);
122
+
85
123
  /** A table cell may not carry a raw `|` or a line break. */
86
124
  const escapeCell = (text) =>
87
125
  String(text)
@@ -97,7 +135,7 @@ const escapeCell = (text) =>
97
135
  *
98
136
  * @param {string} markdown - The note body, frontmatter already stripped.
99
137
  * @returns {Array<{line: number, close: number, indent: string, query: string,
100
- * allowEmpty: boolean, sectionLevel: number, block: string}>} One entry per
138
+ * allowEmpty: boolean, sectionLevel: number, showKey: boolean, layout: string, block: string}>} One entry per
101
139
  * fence, in document order.
102
140
  */
103
141
  export function findSqlBlocks(markdown) {
@@ -132,8 +170,19 @@ export function findSqlBlocks(markdown) {
132
170
  errors.push(error.message);
133
171
  }
134
172
  }
173
+ let showKey = true;
174
+ if (Object.hasOwn(args, "key")) {
175
+ try {
176
+ showKey = booleanAttribute(args.key, "key");
177
+ } catch (error) {
178
+ errors.push(error.message);
179
+ }
180
+ }
135
181
  if (!Number.isInteger(level) || level < 1 || level > 6)
136
182
  errors.push("section-level needs an integer from 1 through 6");
183
+ const layout = String(args.layout ?? "table");
184
+ if (!SQL_LAYOUTS.includes(layout))
185
+ errors.push(`layout is \`table\`, \`dictionary\` or \`glossary\`, not \`${layout}\``);
137
186
  blocks.push({
138
187
  line: i,
139
188
  close,
@@ -141,6 +190,8 @@ export function findSqlBlocks(markdown) {
141
190
  query: lines.slice(i + 1, close).join("\n"),
142
191
  allowEmpty,
143
192
  sectionLevel: level,
193
+ showKey,
194
+ layout,
144
195
  problems: errors,
145
196
  args,
146
197
  block: lines.slice(i, close + 1).join("\n"),
@@ -214,13 +265,9 @@ export async function openNotesDatabase(
214
265
  const connection = await instance.connect();
215
266
  await connection.run("SET threads=1");
216
267
  await createRelations(connection, jsonl, "", audience);
217
- await createEventRelation(
218
- connection,
219
- path.join(base, "events.jsonl"),
220
- records.map((record) => encodeAddresses(record)),
221
- "",
222
- audience,
223
- );
268
+ const encoded = records.map((record) => encodeAddresses(record));
269
+ await createEventRelation(connection, path.join(base, "events.jsonl"), encoded, "", audience);
270
+ await createWordRelations(connection, path.join(base, "notes"), encoded, "", audience);
224
271
 
225
272
  // One schema per declared dependency, so `FROM sohl.notes` reads the notes
226
273
  // that package published. Quoted, because a package id may carry a hyphen
@@ -250,6 +297,13 @@ export async function openNotesDatabase(
250
297
  `${schema}.`,
251
298
  audience,
252
299
  );
300
+ await createWordRelations(
301
+ connection,
302
+ flat.replace(/\.jsonl$/, ""),
303
+ depRecords,
304
+ `${schema}.`,
305
+ audience,
306
+ );
253
307
  }
254
308
 
255
309
  // The market scale as a relation, so a table prints `village` beside the
@@ -274,7 +328,19 @@ export async function openNotesDatabase(
274
328
  // {@link prepareSqlTables}.
275
329
  await connection.run(`CREATE SCHEMA IF NOT EXISTS ${WITH_STUBS_SCHEMA}`);
276
330
  await connection.run(`CREATE VIEW ${WITH_STUBS_SCHEMA}.notes AS SELECT * FROM entries`);
277
- await connection.run(`CREATE VIEW ${WITH_STUBS_SCHEMA}.events AS SELECT * FROM main.events`);
331
+ for (const relation of [
332
+ "events",
333
+ "words",
334
+ "affixes",
335
+ "fields",
336
+ "glossary",
337
+ "language_lineage",
338
+ "dictionary",
339
+ "terms",
340
+ ])
341
+ await connection.run(
342
+ `CREATE VIEW ${WITH_STUBS_SCHEMA}.${relation} AS SELECT * FROM main.${relation}`,
343
+ );
278
344
 
279
345
  // Whether this index holds a stub at all, asked once. A corpus with none
280
346
  // pays nothing for the comparison above, which is every package that has
@@ -414,9 +480,10 @@ async function createRelations(connection, file, prefix = "", audience = "all")
414
480
  "NOT (COALESCE(list_contains(TRY_CAST(tags AS VARCHAR[]), 'gm'), false) " +
415
481
  "OR COALESCE(TRY_CAST(tags AS VARCHAR) = 'gm', false))"
416
482
  : "true";
417
- await connection.run(
418
- `CREATE VIEW ${prefix}entries AS SELECT *, ` +
419
- `CASE WHEN ${stub} THEN 'stub' WHEN ${draft} THEN 'draft' ` +
483
+ await createTableFromFile(
484
+ connection,
485
+ `${prefix}entries`,
486
+ `SELECT *, CASE WHEN ${stub} THEN 'stub' WHEN ${draft} THEN 'draft' ` +
420
487
  `ELSE 'full' END AS state FROM (${read}) WHERE ${visible}`,
421
488
  );
422
489
  await connection.run(
@@ -457,7 +524,130 @@ async function createEventRelation(connection, file, records, prefix, audience)
457
524
  return;
458
525
  }
459
526
  fs.writeFileSync(file, rows.map((row) => JSON.stringify(row)).join("\n"));
460
- await connection.run(`CREATE VIEW ${prefix}events AS ${readJsonAuto(file)}`);
527
+ await createTableFromFile(connection, `${prefix}events`, readJsonAuto(file));
528
+ }
529
+
530
+ /**
531
+ * Create a relation as a table, so the JSONL behind it is parsed once when the
532
+ * database opens rather than on every query. A view over `read_json_auto`
533
+ * re-reads and re-parses the whole file for each query that touches it.
534
+ *
535
+ * Every relation built from a file goes through here; relations derived from
536
+ * other relations stay views over them.
537
+ *
538
+ * @param {object} connection - An open DuckDB connection.
539
+ * @param {string} name - The relation name, schema-qualified if needed.
540
+ * @param {string} select - The `SELECT` that reads the file.
541
+ * @returns {Promise<void>}
542
+ */
543
+ async function createTableFromFile(connection, name, select) {
544
+ await connection.run(`CREATE TABLE ${name} AS ${select}`);
545
+ }
546
+
547
+ /**
548
+ * Create the `words`, `affixes`, `fields`, `glossary`, `language_lineage`,
549
+ * `dictionary` and `terms` relations: one row per language note's word, one per
550
+ * its affix, one per its field, one per culture's or
551
+ * affiliation's glossary entry as authored, one per language and ancestor, one
552
+ * per word per language it is listed under, and one per term per owning note,
553
+ * with the columns {@link module:engine/word-rows.WORD_COLUMNS},
554
+ * {@link module:engine/word-rows.AFFIX_COLUMNS},
555
+ * {@link module:engine/word-rows.FIELD_COLUMNS},
556
+ * {@link module:engine/word-rows.GLOSSARY_COLUMNS},
557
+ * {@link module:engine/word-rows.LINEAGE_COLUMNS},
558
+ * {@link module:engine/dictionary-rows.DICTIONARY_COLUMNS} and
559
+ * {@link module:engine/dictionary-rows.TERM_COLUMNS} name.
560
+ *
561
+ * Derived from the same records `entries` reads, stubs included, and a GM
562
+ * note's rows are left out on a public surface exactly as the note is, while a
563
+ * glossary entry's language still resolves through it. A corpus with no word
564
+ * or no glossary entry still has the relation, empty.
565
+ *
566
+ * @param {object} connection - An open DuckDB connection.
567
+ * @param {string} stem - Where to write the rows, before `-words.jsonl`,
568
+ * `-glossary.jsonl`, `-lineage.jsonl`, `-dictionary.jsonl` and `-terms.jsonl`.
569
+ * @param {object[]} records - Index records, every Address a string.
570
+ * @param {string} prefix - A schema to qualify the view names with.
571
+ * @param {"all"|"public"} audience - Whether to leave out GM notes.
572
+ * @returns {Promise<void>}
573
+ */
574
+ async function createWordRelations(connection, stem, records, prefix, audience) {
575
+ const visible = audience === "public" ? records.filter((record) => !isGmNote(record)) : records;
576
+ await createRowRelation(
577
+ connection,
578
+ `${stem}-words.jsonl`,
579
+ wordRows(visible, records),
580
+ `${prefix}words`,
581
+ WORD_COLUMNS,
582
+ );
583
+ await createRowRelation(
584
+ connection,
585
+ `${stem}-affixes.jsonl`,
586
+ affixRows(visible),
587
+ `${prefix}affixes`,
588
+ AFFIX_COLUMNS,
589
+ );
590
+ await createRowRelation(
591
+ connection,
592
+ `${stem}-fields.jsonl`,
593
+ fieldRows(visible),
594
+ `${prefix}fields`,
595
+ FIELD_COLUMNS,
596
+ );
597
+ await createRowRelation(
598
+ connection,
599
+ `${stem}-glossary.jsonl`,
600
+ glossaryRows(visible, records),
601
+ `${prefix}glossary`,
602
+ GLOSSARY_COLUMNS,
603
+ );
604
+ await createRowRelation(
605
+ connection,
606
+ `${stem}-lineage.jsonl`,
607
+ lineageRows(visible, records),
608
+ `${prefix}language_lineage`,
609
+ LINEAGE_COLUMNS,
610
+ );
611
+ const { dictionary, terms } = dictionaryAndTermRows(visible, records);
612
+ await createRowRelation(
613
+ connection,
614
+ `${stem}-dictionary.jsonl`,
615
+ dictionary,
616
+ `${prefix}dictionary`,
617
+ DICTIONARY_COLUMNS,
618
+ );
619
+ await createRowRelation(
620
+ connection,
621
+ `${stem}-terms.jsonl`,
622
+ terms,
623
+ `${prefix}terms`,
624
+ TERM_COLUMNS,
625
+ );
626
+ }
627
+
628
+ /**
629
+ * Create one derived relation from its rows, or an empty one of its columns
630
+ * when there are none, so a fence over it reports no rows rather than a
631
+ * missing table.
632
+ *
633
+ * @param {object} connection - An open DuckDB connection.
634
+ * @param {string} file - Where to write the rows.
635
+ * @param {object[]} rows - The rows, each carrying every column.
636
+ * @param {string} view - The qualified view name.
637
+ * @param {readonly string[]} columns - Its columns, for the empty case.
638
+ * @returns {Promise<void>}
639
+ */
640
+ async function createRowRelation(connection, file, rows, view, columns) {
641
+ if (!rows.length) {
642
+ const typed = columns.map((column) => {
643
+ const type = ROW_INTEGER_COLUMNS.includes(column) ? "BIGINT" : "VARCHAR";
644
+ return `NULL::${type} AS "${column}"`;
645
+ });
646
+ await connection.run(`CREATE VIEW ${view} AS SELECT ${typed.join(", ")} WHERE false`);
647
+ return;
648
+ }
649
+ fs.writeFileSync(file, rows.map((row) => JSON.stringify(row)).join("\n"));
650
+ await createTableFromFile(connection, view, readJsonAuto(file));
461
651
  }
462
652
 
463
653
  /**
@@ -661,6 +851,9 @@ export function renderSqlTable(
661
851
  * @param {object} [opts]
662
852
  * @param {(ref: string) => boolean} [opts.linkable] - Passed to
663
853
  * {@link renderSqlTable}.
854
+ * @param {(source: string, frontmatter?: object) => string|undefined} [opts.pageLanguage] -
855
+ * The language of the note a body belongs to, which a dictionary or a
856
+ * glossary does not tag; absent, it is read from the body's frontmatter.
664
857
  * @param {object[]} [opts.records] - Content-index records, narrowed to the
665
858
  * audience this surface publishes to. Given them, the result also carries
666
859
  * `pageLists`, so one prepared object answers every corpus-reading directive
@@ -674,7 +867,7 @@ export function renderSqlTable(
674
867
  * @returns {Promise<Map<string, object[]>>} Note to results, in document order,
675
868
  * each carrying either a rendered `markdown` and its `rows`, or a `reason`.
676
869
  */
677
- export async function prepareSqlTables(db, sources, { linkable, records } = {}) {
870
+ export async function prepareSqlTables(db, sources, { linkable, records, pageLanguage } = {}) {
678
871
  const prepared = new Map();
679
872
  if (records) prepared.pageLists = preparePageLists(records, sources);
680
873
  // The corpus does not change while a preparation runs, so a query written
@@ -688,7 +881,11 @@ export async function prepareSqlTables(db, sources, { linkable, records } = {})
688
881
  return structuredClone(answers.get(sql));
689
882
  },
690
883
  };
691
- for (const { source, markdown } of sources) {
884
+ let labels;
885
+ const labelOf = (address) => labels.get(address);
886
+ const languageOf =
887
+ pageLanguage ?? ((_source, frontmatter) => frontmatterLanguage(frontmatter, db));
888
+ for (const { source, markdown, frontmatter } of sources) {
692
889
  const blocks = findSqlBlocks(markdown);
693
890
  if (!blocks.length) continue;
694
891
  const forNote = [];
@@ -697,12 +894,26 @@ export async function prepareSqlTables(db, sources, { linkable, records } = {})
697
894
  try {
698
895
  if (block.problems.length) throw new Error(block.problems.join("; "));
699
896
  const result = await runSqlQuery(reader, block.query);
897
+ const options = {
898
+ linkable,
899
+ addressContext: db.addressContext,
900
+ sectionLevel: block.sectionLevel,
901
+ };
902
+ const setAs =
903
+ block.layout === "dictionary" ? renderDictionary
904
+ : block.layout === "glossary" ? renderGlossary
905
+ : undefined;
906
+ if (setAs) labels ??= await noteLabels(db);
700
907
  forNote.push({
701
- markdown: renderSqlTable(result, {
702
- linkable,
703
- addressContext: db.addressContext,
704
- sectionLevel: block.sectionLevel,
705
- }),
908
+ markdown:
909
+ setAs ?
910
+ setAs(result, {
911
+ ...options,
912
+ labelOf,
913
+ showKey: block.showKey,
914
+ pageLanguage: languageOf(source, frontmatter),
915
+ })
916
+ : renderSqlTable(result, options),
706
917
  rows: result.rows.length,
707
918
  // How many rows this table would gain from the wider
708
919
  // relation, so a fence that has not adopted stubs says so
@@ -722,6 +933,81 @@ export async function prepareSqlTables(db, sources, { linkable, records } = {})
722
933
  return prepared;
723
934
  }
724
935
 
936
+ /**
937
+ * Every note's `name.full`, by its Address in the `note` system, across this
938
+ * package's relations and each dependency's: what a dictionary prints for a
939
+ * citation, a body and a language.
940
+ *
941
+ * @param {object} db - From {@link openNotesDatabase}.
942
+ * @returns {Promise<Map<string, string>>} Address to name.
943
+ */
944
+ async function noteLabels(db) {
945
+ const labels = new Map();
946
+ let schemas = [];
947
+ try {
948
+ schemas = (
949
+ await db.query(
950
+ "SELECT DISTINCT schema_name FROM duckdb_tables() WHERE table_name = 'entries' " +
951
+ "ORDER BY schema_name",
952
+ )
953
+ ).rows.map((row) => String(row.schema_name));
954
+ } catch {
955
+ return labels;
956
+ }
957
+ for (const schema of schemas) {
958
+ try {
959
+ const { rows } = await db.query(
960
+ `SELECT package, type, shortcode, name.full AS label ` +
961
+ `FROM "${schema.replace(/"/g, '""')}".entries WHERE name.full IS NOT NULL`,
962
+ );
963
+ for (const row of rows) {
964
+ if (!row.package || !row.type || !row.shortcode) continue;
965
+ const key = renderAddress({
966
+ package: String(row.package),
967
+ system: "note",
968
+ type: String(row.type),
969
+ shortcode: String(row.shortcode),
970
+ });
971
+ if (!labels.has(key)) labels.set(key, String(row.label));
972
+ }
973
+ } catch {
974
+ // A relation whose records carry no name has no label to give.
975
+ }
976
+ }
977
+ return labels;
978
+ }
979
+
980
+ /**
981
+ * The language a note's frontmatter says it is in: a language note's own
982
+ * Address, else its `data.language`.
983
+ *
984
+ * @param {object|undefined} frontmatter - The note's authored frontmatter.
985
+ * @param {object} db - From {@link openNotesDatabase}, for its Address context.
986
+ * @returns {string|undefined} The language's Address.
987
+ */
988
+ function frontmatterLanguage(frontmatter, db) {
989
+ if (!frontmatter || typeof frontmatter !== "object") return undefined;
990
+ const context = db.addressContext ?? {};
991
+ const read = (value, type) => {
992
+ if (typeof value !== "string" || !value) return undefined;
993
+ const tuple = parseAddress(value, { ...context, system: "note", type }, { declared: true });
994
+ return tuple.reason ? undefined : renderAddress({ ...tuple, system: "note" });
995
+ };
996
+ if (
997
+ String(frontmatter.type ?? "").toLowerCase() === "skill" &&
998
+ frontmatter.subType === "language" &&
999
+ context.package &&
1000
+ typeof frontmatter.shortcode === "string"
1001
+ )
1002
+ return renderAddress({
1003
+ package: context.package,
1004
+ system: "note",
1005
+ type: "skill",
1006
+ shortcode: frontmatter.shortcode,
1007
+ });
1008
+ return read(frontmatter.data?.language, "skill");
1009
+ }
1010
+
725
1011
  /** Evaluate scalar SQL calls before synchronous Markdown renderers run. */
726
1012
  export async function prepareInlineSqlExpressions(db, sources) {
727
1013
  const byNote = new Map();
@@ -860,6 +1146,50 @@ export function attachGeneratedSections(prepared, generated) {
860
1146
  }
861
1147
  }
862
1148
 
1149
+ /**
1150
+ * The language each note of a tree is in, by its file: a language note itself,
1151
+ * a culture its `data.language`, its own or one it inherits, and an
1152
+ * affiliation the language its glossary resolves to — see
1153
+ * {@link module:engine/glossary-language}.
1154
+ *
1155
+ * @param {object[]} records - Content-index records.
1156
+ * @param {string} contentBase - Root of the content tree.
1157
+ * @returns {(source: string) => string|undefined} The language of a note's file.
1158
+ */
1159
+ function pageLanguages(records, contentBase) {
1160
+ const notes = records
1161
+ .filter((record) => isNoteRecord(record))
1162
+ .map((record) => encodeAddresses(record));
1163
+ const byKey = new Map();
1164
+ const keyOf = (record) =>
1165
+ record.package && record.type && record.shortcode ?
1166
+ renderAddress({
1167
+ package: String(record.package),
1168
+ system: "note",
1169
+ type: String(record.type),
1170
+ shortcode: String(record.shortcode),
1171
+ })
1172
+ : undefined;
1173
+ for (const record of notes) {
1174
+ const key = keyOf(record);
1175
+ if (key && !byKey.has(key)) byKey.set(key, record);
1176
+ }
1177
+ const byFile = new Map(
1178
+ records
1179
+ .filter((record) => isNoteRecord(record))
1180
+ .map((record, i) => [noteFile(contentBase, record), notes[i]]),
1181
+ );
1182
+ return (source) => {
1183
+ const record = byFile.get(source);
1184
+ if (!record) return undefined;
1185
+ return glossaryLanguage(undefined, record, {
1186
+ keyOf: (value) => (typeof value === "string" && value ? value : undefined),
1187
+ lookup: (key) => byKey.get(key),
1188
+ self: keyOf(record),
1189
+ });
1190
+ };
1191
+ }
1192
+
863
1193
  /**
864
1194
  * Each declared dependency's cached index, as {@link openNotesDatabase} attaches
865
1195
  * them. Empty where none has been fetched.
@@ -1031,6 +1361,7 @@ export async function prepareTreeSqlTables(
1031
1361
  const prepared = await prepareSqlTables(db, sources, {
1032
1362
  linkable: (ref) => addresses.has(renderAddress(ref)),
1033
1363
  records: indexRecords,
1364
+ pageLanguage: pageLanguages(indexRecords, contentBase),
1034
1365
  });
1035
1366
  prepared.inline = await prepareInlineSqlExpressions(db, sources);
1036
1367
  attachGeneratedSections(prepared, generated);
@@ -0,0 +1,167 @@
1
+ /*
2
+ * This file is part of the Song of Heroic Lands (SoHL) system for Foundry VTT.
3
+ * Copyright (c) 2024-2026 Tom Rodriguez ("Toasty") — <toasty@heroiclands.org>
4
+ *
5
+ * This work is licensed under the GNU General Public License v3.0 (GPLv3).
6
+ * You may copy, modify, and distribute it under the terms of that license.
7
+ *
8
+ * For full terms, see the LICENSE.md file in the project root or visit:
9
+ * https://www.gnu.org/licenses/gpl-3.0.html
10
+ *
11
+ * SPDX-License-Identifier: GPL-3.0-or-later
12
+ */
13
+
14
+ /**
15
+ * The master set of word classes: the parts of speech a word may
16
+ * name, and the modifiers that refine one.
17
+ *
18
+ * A leaf module, importing nothing. A class is written as a base followed by
19
+ * zero or more modifiers, separated by spaces: `n I f` is a noun of class I,
20
+ * feminine. The first token is always the base, so `n n` is a neuter noun.
21
+ *
22
+ * @module
23
+ */
24
+
25
+ /**
26
+ * @typedef {object} WordClassBase
27
+ * @property {string} token The base as a word writes it.
28
+ * @property {string | null} abbreviation The dictionary abbreviation, or
29
+ * `null` for a base that never prints in the glossary.
30
+ * @property {string} expansion The base in plain words.
31
+ * @property {boolean} glossary Whether entries of this base appear in the
32
+ * glossary.
33
+ */
34
+
35
+ /**
36
+ * @typedef {object} WordClassModifier
37
+ * @property {string} token The modifier as a word writes it.
38
+ * @property {string} group What the modifier says: `gender`, `animacy`,
39
+ * `number`, `declension`, `conjugation` or `voice`.
40
+ * @property {string} abbreviation The dictionary abbreviation.
41
+ * @property {string} expansion The modifier in plain words; a phrase starting
42
+ * `of` follows its base without a comma.
43
+ */
44
+
45
+ /**
46
+ * @param {string} token
47
+ * @param {string | null} abbreviation
48
+ * @param {string} expansion
49
+ * @param {boolean} glossary
50
+ * @returns {Readonly<WordClassBase>}
51
+ */
52
+ const base = (token, abbreviation, expansion, glossary) =>
53
+ Object.freeze({ token, abbreviation, expansion, glossary });
54
+
55
+ /**
56
+ * @param {string} token
57
+ * @param {string} group
58
+ * @param {string} abbreviation
59
+ * @param {string} expansion
60
+ * @returns {Readonly<WordClassModifier>}
61
+ */
62
+ const modifier = (token, group, abbreviation, expansion) =>
63
+ Object.freeze({ token, group, abbreviation, expansion });
64
+
65
+ /** The bases, in glossary order. */
66
+ export const WORD_CLASS_BASES = Object.freeze([
67
+ base("n", "n.", "noun", true),
68
+ base("v", "v.", "verb", true),
69
+ base("adj", "adj.", "adjective", true),
70
+ base("adv", "adv.", "adverb", true),
71
+ base("pron", "pron.", "pronoun", true),
72
+ base("num", "num.", "numeral", true),
73
+ base("part", "ptcl.", "particle", true),
74
+ base("art", "art.", "article", true),
75
+ base("conj", "conj.", "conjunction", true),
76
+ base("dem", "dem.", "demonstrative", true),
77
+ base("rel", "rel.", "relative marker", true),
78
+ base("call", "call", "call", true),
79
+ base("name", null, "proper name", false),
80
+ base("stem", null, "bound stem", false),
81
+ base("pref", null, "prefix", false),
82
+ ]);
83
+
84
+ /**
85
+ * The modifiers. The neuter prints `nt.` because `n. n.` would show the base
86
+ * and the gender identically.
87
+ */
88
+ export const WORD_CLASS_MODIFIERS = Object.freeze([
89
+ modifier("m", "gender", "m.", "masculine"),
90
+ modifier("f", "gender", "f.", "feminine"),
91
+ modifier("n", "gender", "nt.", "neuter"),
92
+ modifier("an", "animacy", "anim.", "animate"),
93
+ modifier("in", "animacy", "inan.", "inanimate"),
94
+ modifier("coll", "number", "coll.", "collective"),
95
+ modifier("I", "declension", "I", "of class I"),
96
+ modifier("II", "declension", "II", "of class II"),
97
+ modifier("III", "declension", "III", "of class III"),
98
+ modifier("IV", "declension", "IV", "of class IV"),
99
+ modifier("V", "declension", "V", "of class V"),
100
+ modifier("hal", "declension", "hal.", "of the hallowed class"),
101
+ modifier("â", "conjugation", "â", "of the first conjugation"),
102
+ modifier("ê", "conjugation", "ê", "of the second conjugation"),
103
+ modifier("i", "conjugation", "i", "of the third conjugation"),
104
+ modifier("c", "conjugation", "c", "of the fourth conjugation"),
105
+ modifier("dep", "voice", "dep.", "deponent"),
106
+ ]);
107
+
108
+ const BASES_BY_TOKEN = new Map(WORD_CLASS_BASES.map((item) => [item.token, item]));
109
+ const MODIFIERS_BY_TOKEN = new Map(WORD_CLASS_MODIFIERS.map((item) => [item.token, item]));
110
+
111
+ /**
112
+ * Reads a class string as a base and its modifiers.
113
+ *
114
+ * The result is `{ base, modifiers }` when the string is a class, and
115
+ * `{ reason }` when it is not. A hyphen is not a separator: `n-coll` is
116
+ * refused, and `n coll` is the collective noun.
117
+ *
118
+ * @param {string} text
119
+ * @returns {{ base: string, modifiers: string[], reason?: undefined } | { reason: string, base?: undefined, modifiers?: undefined }}
120
+ */
121
+ export function parseWordClass(text) {
122
+ const tokens = String(text ?? "")
123
+ .trim()
124
+ .split(/\s+/)
125
+ .filter(Boolean);
126
+ if (tokens.length === 0) return { reason: "the class is empty" };
127
+ const [head, ...rest] = tokens;
128
+ if (!BASES_BY_TOKEN.has(head)) {
129
+ return { reason: `\`${head}\` is not a word class base` };
130
+ }
131
+ for (const token of rest) {
132
+ if (!MODIFIERS_BY_TOKEN.has(token)) {
133
+ return { reason: `\`${token}\` is not a word class modifier` };
134
+ }
135
+ }
136
+ return { base: head, modifiers: rest };
137
+ }
138
+
139
+ /**
140
+ * The dictionary abbreviation of a parsed class, such as `n. I f.`. A base
141
+ * with no abbreviation prints as its token.
142
+ *
143
+ * @param {{ base: string, modifiers: readonly string[] }} parsed
144
+ * @returns {string}
145
+ */
146
+ export function abbreviateWordClass(parsed) {
147
+ const head = BASES_BY_TOKEN.get(parsed.base);
148
+ return [
149
+ head?.abbreviation ?? parsed.base,
150
+ ...parsed.modifiers.map((token) => MODIFIERS_BY_TOKEN.get(token)?.abbreviation ?? token),
151
+ ].join(" ");
152
+ }
153
+
154
+ /**
155
+ * A parsed class in plain words, such as "noun of class I, feminine".
156
+ *
157
+ * @param {{ base: string, modifiers: readonly string[] }} parsed
158
+ * @returns {string}
159
+ */
160
+ export function expandWordClass(parsed) {
161
+ let out = BASES_BY_TOKEN.get(parsed.base)?.expansion ?? parsed.base;
162
+ for (const token of parsed.modifiers) {
163
+ const words = MODIFIERS_BY_TOKEN.get(token)?.expansion ?? token;
164
+ out += words.startsWith("of ") ? ` ${words}` : `, ${words}`;
165
+ }
166
+ return out;
167
+ }