@heroiclands/package-build 23.5.0 → 23.6.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 (54) hide show
  1. package/CHANGELOG.md +33 -0
  2. package/bin/content-commands.mjs +23 -5
  3. package/bin/package-build.mjs +1 -74
  4. package/docs/api.md +9 -0
  5. package/docs/authoring/links-and-markup.md +77 -12
  6. package/docs/commands.md +4 -48
  7. package/docs/content-format.md +16 -0
  8. package/docs/diagnostics.md +1 -2
  9. package/docs/guides/module-project.md +1 -1
  10. package/docs/guides/reference-pages.md +89 -0
  11. package/docs/guides/release-and-deploy.md +3 -3
  12. package/docs/project-setup.md +16 -59
  13. package/docs/reference/format-details.md +533 -84
  14. package/docs/reference/note-types.md +191 -149
  15. package/engine/base-compiler.mjs +21 -0
  16. package/engine/cdn-publish.mjs +30 -5
  17. package/engine/content-format.yaml +9 -0
  18. package/engine/content-index.mjs +19 -5
  19. package/engine/culture-reach.mjs +330 -0
  20. package/engine/dictionary-layout.mjs +340 -0
  21. package/engine/dictionary-rows.mjs +608 -0
  22. package/engine/glossary-entries.mjs +182 -0
  23. package/engine/glossary-language.mjs +99 -0
  24. package/engine/index.mjs +1 -0
  25. package/engine/infobox.mjs +8 -0
  26. package/engine/language-words.mjs +360 -0
  27. package/engine/literature-notes.mjs +10 -8
  28. package/engine/note-addresses.mjs +25 -0
  29. package/engine/note-vocabulary.mjs +161 -1
  30. package/engine/path-charset.mjs +120 -0
  31. package/engine/project-init.mjs +4 -29
  32. package/engine/sql-tables.mjs +311 -21
  33. package/engine/word-classes.mjs +167 -0
  34. package/engine/word-rows.mjs +233 -0
  35. package/engine/word-terms.mjs +306 -0
  36. package/package.json +2 -4
  37. package/types/engine/cdn-publish.d.mts +26 -25
  38. package/types/engine/content-index.d.mts +15 -5
  39. package/types/engine/culture-reach.d.mts +66 -0
  40. package/types/engine/dictionary-layout.d.mts +69 -0
  41. package/types/engine/dictionary-rows.d.mts +39 -0
  42. package/types/engine/glossary-entries.d.mts +12 -0
  43. package/types/engine/glossary-language.d.mts +42 -0
  44. package/types/engine/index.d.mts +1 -0
  45. package/types/engine/language-words.d.mts +33 -0
  46. package/types/engine/literature-notes.d.mts +2 -2
  47. package/types/engine/note-addresses.d.mts +17 -0
  48. package/types/engine/path-charset.d.mts +38 -0
  49. package/types/engine/sql-tables.d.mts +7 -2
  50. package/types/engine/word-classes.d.mts +87 -0
  51. package/types/engine/word-rows.d.mts +64 -0
  52. package/types/engine/word-terms.d.mts +68 -0
  53. package/labels.mjs +0 -191
  54. package/types/labels.d.mts +0 -40
@@ -30,6 +30,18 @@ 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
+ LINEAGE_COLUMNS,
36
+ ROW_INTEGER_COLUMNS,
37
+ WORD_COLUMNS,
38
+ glossaryRows,
39
+ lineageRows,
40
+ wordRows,
41
+ } from "./word-rows.mjs";
42
+ import { DICTIONARY_COLUMNS, TERM_COLUMNS, dictionaryAndTermRows } from "./dictionary-rows.mjs";
43
+ import { renderDictionary, renderGlossary } from "./dictionary-layout.mjs";
44
+ import { glossaryLanguage } from "./glossary-language.mjs";
33
45
  import { eventViewSections } from "./event-views.mjs";
34
46
  import { appendGeneratedSections, joinSections } from "./generated-sections.mjs";
35
47
  import fs from "node:fs";
@@ -78,10 +90,24 @@ export const SQL_FENCE_ATTRIBUTES = Object.freeze({
78
90
  "section-level": Object.freeze({
79
91
  value: "an integer from 1 through 6",
80
92
  default: "`2`",
81
- summary: "The heading level given to each group a `_section` column creates.",
93
+ summary:
94
+ "The heading level given to each group a `_section` column creates, and to each " +
95
+ "letter of a dictionary or a glossary.",
96
+ }),
97
+ layout: Object.freeze({
98
+ value: "`table`, `dictionary` or `glossary`",
99
+ default: "`table`",
100
+ summary:
101
+ "How the result is set: as a table; as a dictionary of words, read from the " +
102
+ "`dictionary` relation; or as a glossary of terms, read from the `terms` relation. " +
103
+ "A dictionary and a glossary are one paragraph per entry under letter headings, and " +
104
+ "each needs the `name`, `senses` and `usage` columns.",
82
105
  }),
83
106
  });
84
107
 
108
+ /** The values `layout` takes. */
109
+ const SQL_LAYOUTS = Object.freeze(["table", "dictionary", "glossary"]);
110
+
85
111
  /** A table cell may not carry a raw `|` or a line break. */
86
112
  const escapeCell = (text) =>
87
113
  String(text)
@@ -97,7 +123,7 @@ const escapeCell = (text) =>
97
123
  *
98
124
  * @param {string} markdown - The note body, frontmatter already stripped.
99
125
  * @returns {Array<{line: number, close: number, indent: string, query: string,
100
- * allowEmpty: boolean, sectionLevel: number, block: string}>} One entry per
126
+ * allowEmpty: boolean, sectionLevel: number, layout: string, block: string}>} One entry per
101
127
  * fence, in document order.
102
128
  */
103
129
  export function findSqlBlocks(markdown) {
@@ -134,6 +160,9 @@ export function findSqlBlocks(markdown) {
134
160
  }
135
161
  if (!Number.isInteger(level) || level < 1 || level > 6)
136
162
  errors.push("section-level needs an integer from 1 through 6");
163
+ const layout = String(args.layout ?? "table");
164
+ if (!SQL_LAYOUTS.includes(layout))
165
+ errors.push(`layout is \`table\`, \`dictionary\` or \`glossary\`, not \`${layout}\``);
137
166
  blocks.push({
138
167
  line: i,
139
168
  close,
@@ -141,6 +170,7 @@ export function findSqlBlocks(markdown) {
141
170
  query: lines.slice(i + 1, close).join("\n"),
142
171
  allowEmpty,
143
172
  sectionLevel: level,
173
+ layout,
144
174
  problems: errors,
145
175
  args,
146
176
  block: lines.slice(i, close + 1).join("\n"),
@@ -214,13 +244,9 @@ export async function openNotesDatabase(
214
244
  const connection = await instance.connect();
215
245
  await connection.run("SET threads=1");
216
246
  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
- );
247
+ const encoded = records.map((record) => encodeAddresses(record));
248
+ await createEventRelation(connection, path.join(base, "events.jsonl"), encoded, "", audience);
249
+ await createWordRelations(connection, path.join(base, "notes"), encoded, "", audience);
224
250
 
225
251
  // One schema per declared dependency, so `FROM sohl.notes` reads the notes
226
252
  // that package published. Quoted, because a package id may carry a hyphen
@@ -250,6 +276,13 @@ export async function openNotesDatabase(
250
276
  `${schema}.`,
251
277
  audience,
252
278
  );
279
+ await createWordRelations(
280
+ connection,
281
+ flat.replace(/\.jsonl$/, ""),
282
+ depRecords,
283
+ `${schema}.`,
284
+ audience,
285
+ );
253
286
  }
254
287
 
255
288
  // The market scale as a relation, so a table prints `village` beside the
@@ -274,7 +307,17 @@ export async function openNotesDatabase(
274
307
  // {@link prepareSqlTables}.
275
308
  await connection.run(`CREATE SCHEMA IF NOT EXISTS ${WITH_STUBS_SCHEMA}`);
276
309
  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`);
310
+ for (const relation of [
311
+ "events",
312
+ "words",
313
+ "glossary",
314
+ "language_lineage",
315
+ "dictionary",
316
+ "terms",
317
+ ])
318
+ await connection.run(
319
+ `CREATE VIEW ${WITH_STUBS_SCHEMA}.${relation} AS SELECT * FROM main.${relation}`,
320
+ );
278
321
 
279
322
  // Whether this index holds a stub at all, asked once. A corpus with none
280
323
  // pays nothing for the comparison above, which is every package that has
@@ -414,9 +457,10 @@ async function createRelations(connection, file, prefix = "", audience = "all")
414
457
  "NOT (COALESCE(list_contains(TRY_CAST(tags AS VARCHAR[]), 'gm'), false) " +
415
458
  "OR COALESCE(TRY_CAST(tags AS VARCHAR) = 'gm', false))"
416
459
  : "true";
417
- await connection.run(
418
- `CREATE VIEW ${prefix}entries AS SELECT *, ` +
419
- `CASE WHEN ${stub} THEN 'stub' WHEN ${draft} THEN 'draft' ` +
460
+ await createTableFromFile(
461
+ connection,
462
+ `${prefix}entries`,
463
+ `SELECT *, CASE WHEN ${stub} THEN 'stub' WHEN ${draft} THEN 'draft' ` +
420
464
  `ELSE 'full' END AS state FROM (${read}) WHERE ${visible}`,
421
465
  );
422
466
  await connection.run(
@@ -457,7 +501,113 @@ async function createEventRelation(connection, file, records, prefix, audience)
457
501
  return;
458
502
  }
459
503
  fs.writeFileSync(file, rows.map((row) => JSON.stringify(row)).join("\n"));
460
- await connection.run(`CREATE VIEW ${prefix}events AS ${readJsonAuto(file)}`);
504
+ await createTableFromFile(connection, `${prefix}events`, readJsonAuto(file));
505
+ }
506
+
507
+ /**
508
+ * Create a relation as a table, so the JSONL behind it is parsed once when the
509
+ * database opens rather than on every query. A view over `read_json_auto`
510
+ * re-reads and re-parses the whole file for each query that touches it.
511
+ *
512
+ * Every relation built from a file goes through here; relations derived from
513
+ * other relations stay views over them.
514
+ *
515
+ * @param {object} connection - An open DuckDB connection.
516
+ * @param {string} name - The relation name, schema-qualified if needed.
517
+ * @param {string} select - The `SELECT` that reads the file.
518
+ * @returns {Promise<void>}
519
+ */
520
+ async function createTableFromFile(connection, name, select) {
521
+ await connection.run(`CREATE TABLE ${name} AS ${select}`);
522
+ }
523
+
524
+ /**
525
+ * Create the `words`, `glossary`, `language_lineage`, `dictionary` and `terms`
526
+ * relations: one row per language note's word, one per culture's or
527
+ * affiliation's glossary entry as authored, one per language and ancestor, one
528
+ * per word per language it is listed under, and one per term per owning note,
529
+ * with the columns {@link module:engine/word-rows.WORD_COLUMNS},
530
+ * {@link module:engine/word-rows.GLOSSARY_COLUMNS},
531
+ * {@link module:engine/word-rows.LINEAGE_COLUMNS},
532
+ * {@link module:engine/dictionary-rows.DICTIONARY_COLUMNS} and
533
+ * {@link module:engine/dictionary-rows.TERM_COLUMNS} name.
534
+ *
535
+ * Derived from the same records `entries` reads, stubs included, and a GM
536
+ * note's rows are left out on a public surface exactly as the note is, while a
537
+ * glossary entry's language still resolves through it. A corpus with no word
538
+ * or no glossary entry still has the relation, empty.
539
+ *
540
+ * @param {object} connection - An open DuckDB connection.
541
+ * @param {string} stem - Where to write the rows, before `-words.jsonl`,
542
+ * `-glossary.jsonl`, `-lineage.jsonl`, `-dictionary.jsonl` and `-terms.jsonl`.
543
+ * @param {object[]} records - Index records, every Address a string.
544
+ * @param {string} prefix - A schema to qualify the view names with.
545
+ * @param {"all"|"public"} audience - Whether to leave out GM notes.
546
+ * @returns {Promise<void>}
547
+ */
548
+ async function createWordRelations(connection, stem, records, prefix, audience) {
549
+ const visible = audience === "public" ? records.filter((record) => !isGmNote(record)) : records;
550
+ await createRowRelation(
551
+ connection,
552
+ `${stem}-words.jsonl`,
553
+ wordRows(visible),
554
+ `${prefix}words`,
555
+ WORD_COLUMNS,
556
+ );
557
+ await createRowRelation(
558
+ connection,
559
+ `${stem}-glossary.jsonl`,
560
+ glossaryRows(visible, records),
561
+ `${prefix}glossary`,
562
+ GLOSSARY_COLUMNS,
563
+ );
564
+ await createRowRelation(
565
+ connection,
566
+ `${stem}-lineage.jsonl`,
567
+ lineageRows(visible, records),
568
+ `${prefix}language_lineage`,
569
+ LINEAGE_COLUMNS,
570
+ );
571
+ const { dictionary, terms } = dictionaryAndTermRows(visible, records);
572
+ await createRowRelation(
573
+ connection,
574
+ `${stem}-dictionary.jsonl`,
575
+ dictionary,
576
+ `${prefix}dictionary`,
577
+ DICTIONARY_COLUMNS,
578
+ );
579
+ await createRowRelation(
580
+ connection,
581
+ `${stem}-terms.jsonl`,
582
+ terms,
583
+ `${prefix}terms`,
584
+ TERM_COLUMNS,
585
+ );
586
+ }
587
+
588
+ /**
589
+ * Create one derived relation from its rows, or an empty one of its columns
590
+ * when there are none, so a fence over it reports no rows rather than a
591
+ * missing table.
592
+ *
593
+ * @param {object} connection - An open DuckDB connection.
594
+ * @param {string} file - Where to write the rows.
595
+ * @param {object[]} rows - The rows, each carrying every column.
596
+ * @param {string} view - The qualified view name.
597
+ * @param {readonly string[]} columns - Its columns, for the empty case.
598
+ * @returns {Promise<void>}
599
+ */
600
+ async function createRowRelation(connection, file, rows, view, columns) {
601
+ if (!rows.length) {
602
+ const typed = columns.map((column) => {
603
+ const type = ROW_INTEGER_COLUMNS.includes(column) ? "BIGINT" : "VARCHAR";
604
+ return `NULL::${type} AS "${column}"`;
605
+ });
606
+ await connection.run(`CREATE VIEW ${view} AS SELECT ${typed.join(", ")} WHERE false`);
607
+ return;
608
+ }
609
+ fs.writeFileSync(file, rows.map((row) => JSON.stringify(row)).join("\n"));
610
+ await createTableFromFile(connection, view, readJsonAuto(file));
461
611
  }
462
612
 
463
613
  /**
@@ -661,6 +811,9 @@ export function renderSqlTable(
661
811
  * @param {object} [opts]
662
812
  * @param {(ref: string) => boolean} [opts.linkable] - Passed to
663
813
  * {@link renderSqlTable}.
814
+ * @param {(source: string, frontmatter?: object) => string|undefined} [opts.pageLanguage] -
815
+ * The language of the note a body belongs to, which a dictionary or a
816
+ * glossary does not tag; absent, it is read from the body's frontmatter.
664
817
  * @param {object[]} [opts.records] - Content-index records, narrowed to the
665
818
  * audience this surface publishes to. Given them, the result also carries
666
819
  * `pageLists`, so one prepared object answers every corpus-reading directive
@@ -674,7 +827,7 @@ export function renderSqlTable(
674
827
  * @returns {Promise<Map<string, object[]>>} Note to results, in document order,
675
828
  * each carrying either a rendered `markdown` and its `rows`, or a `reason`.
676
829
  */
677
- export async function prepareSqlTables(db, sources, { linkable, records } = {}) {
830
+ export async function prepareSqlTables(db, sources, { linkable, records, pageLanguage } = {}) {
678
831
  const prepared = new Map();
679
832
  if (records) prepared.pageLists = preparePageLists(records, sources);
680
833
  // The corpus does not change while a preparation runs, so a query written
@@ -688,7 +841,11 @@ export async function prepareSqlTables(db, sources, { linkable, records } = {})
688
841
  return structuredClone(answers.get(sql));
689
842
  },
690
843
  };
691
- for (const { source, markdown } of sources) {
844
+ let labels;
845
+ const labelOf = (address) => labels.get(address);
846
+ const languageOf =
847
+ pageLanguage ?? ((_source, frontmatter) => frontmatterLanguage(frontmatter, db));
848
+ for (const { source, markdown, frontmatter } of sources) {
692
849
  const blocks = findSqlBlocks(markdown);
693
850
  if (!blocks.length) continue;
694
851
  const forNote = [];
@@ -697,12 +854,25 @@ export async function prepareSqlTables(db, sources, { linkable, records } = {})
697
854
  try {
698
855
  if (block.problems.length) throw new Error(block.problems.join("; "));
699
856
  const result = await runSqlQuery(reader, block.query);
857
+ const options = {
858
+ linkable,
859
+ addressContext: db.addressContext,
860
+ sectionLevel: block.sectionLevel,
861
+ };
862
+ const setAs =
863
+ block.layout === "dictionary" ? renderDictionary
864
+ : block.layout === "glossary" ? renderGlossary
865
+ : undefined;
866
+ if (setAs) labels ??= await noteLabels(db);
700
867
  forNote.push({
701
- markdown: renderSqlTable(result, {
702
- linkable,
703
- addressContext: db.addressContext,
704
- sectionLevel: block.sectionLevel,
705
- }),
868
+ markdown:
869
+ setAs ?
870
+ setAs(result, {
871
+ ...options,
872
+ labelOf,
873
+ pageLanguage: languageOf(source, frontmatter),
874
+ })
875
+ : renderSqlTable(result, options),
706
876
  rows: result.rows.length,
707
877
  // How many rows this table would gain from the wider
708
878
  // relation, so a fence that has not adopted stubs says so
@@ -722,6 +892,81 @@ export async function prepareSqlTables(db, sources, { linkable, records } = {})
722
892
  return prepared;
723
893
  }
724
894
 
895
+ /**
896
+ * Every note's `name.full`, by its Address in the `note` system, across this
897
+ * package's relations and each dependency's: what a dictionary prints for a
898
+ * citation, a body and a language.
899
+ *
900
+ * @param {object} db - From {@link openNotesDatabase}.
901
+ * @returns {Promise<Map<string, string>>} Address to name.
902
+ */
903
+ async function noteLabels(db) {
904
+ const labels = new Map();
905
+ let schemas = [];
906
+ try {
907
+ schemas = (
908
+ await db.query(
909
+ "SELECT DISTINCT schema_name FROM duckdb_tables() WHERE table_name = 'entries' " +
910
+ "ORDER BY schema_name",
911
+ )
912
+ ).rows.map((row) => String(row.schema_name));
913
+ } catch {
914
+ return labels;
915
+ }
916
+ for (const schema of schemas) {
917
+ try {
918
+ const { rows } = await db.query(
919
+ `SELECT package, type, shortcode, name.full AS label ` +
920
+ `FROM "${schema.replace(/"/g, '""')}".entries WHERE name.full IS NOT NULL`,
921
+ );
922
+ for (const row of rows) {
923
+ if (!row.package || !row.type || !row.shortcode) continue;
924
+ const key = renderAddress({
925
+ package: String(row.package),
926
+ system: "note",
927
+ type: String(row.type),
928
+ shortcode: String(row.shortcode),
929
+ });
930
+ if (!labels.has(key)) labels.set(key, String(row.label));
931
+ }
932
+ } catch {
933
+ // A relation whose records carry no name has no label to give.
934
+ }
935
+ }
936
+ return labels;
937
+ }
938
+
939
+ /**
940
+ * The language a note's frontmatter says it is in: a language note's own
941
+ * Address, else its `data.language`.
942
+ *
943
+ * @param {object|undefined} frontmatter - The note's authored frontmatter.
944
+ * @param {object} db - From {@link openNotesDatabase}, for its Address context.
945
+ * @returns {string|undefined} The language's Address.
946
+ */
947
+ function frontmatterLanguage(frontmatter, db) {
948
+ if (!frontmatter || typeof frontmatter !== "object") return undefined;
949
+ const context = db.addressContext ?? {};
950
+ const read = (value, type) => {
951
+ if (typeof value !== "string" || !value) return undefined;
952
+ const tuple = parseAddress(value, { ...context, system: "note", type }, { declared: true });
953
+ return tuple.reason ? undefined : renderAddress({ ...tuple, system: "note" });
954
+ };
955
+ if (
956
+ String(frontmatter.type ?? "").toLowerCase() === "skill" &&
957
+ frontmatter.subType === "language" &&
958
+ context.package &&
959
+ typeof frontmatter.shortcode === "string"
960
+ )
961
+ return renderAddress({
962
+ package: context.package,
963
+ system: "note",
964
+ type: "skill",
965
+ shortcode: frontmatter.shortcode,
966
+ });
967
+ return read(frontmatter.data?.language, "skill");
968
+ }
969
+
725
970
  /** Evaluate scalar SQL calls before synchronous Markdown renderers run. */
726
971
  export async function prepareInlineSqlExpressions(db, sources) {
727
972
  const byNote = new Map();
@@ -860,6 +1105,50 @@ export function attachGeneratedSections(prepared, generated) {
860
1105
  }
861
1106
  }
862
1107
 
1108
+ /**
1109
+ * The language each note of a tree is in, by its file: a language note itself,
1110
+ * a culture its `data.language`, its own or one it inherits, and an
1111
+ * affiliation the language its glossary resolves to — see
1112
+ * {@link module:engine/glossary-language}.
1113
+ *
1114
+ * @param {object[]} records - Content-index records.
1115
+ * @param {string} contentBase - Root of the content tree.
1116
+ * @returns {(source: string) => string|undefined} The language of a note's file.
1117
+ */
1118
+ function pageLanguages(records, contentBase) {
1119
+ const notes = records
1120
+ .filter((record) => isNoteRecord(record))
1121
+ .map((record) => encodeAddresses(record));
1122
+ const byKey = new Map();
1123
+ const keyOf = (record) =>
1124
+ record.package && record.type && record.shortcode ?
1125
+ renderAddress({
1126
+ package: String(record.package),
1127
+ system: "note",
1128
+ type: String(record.type),
1129
+ shortcode: String(record.shortcode),
1130
+ })
1131
+ : undefined;
1132
+ for (const record of notes) {
1133
+ const key = keyOf(record);
1134
+ if (key && !byKey.has(key)) byKey.set(key, record);
1135
+ }
1136
+ const byFile = new Map(
1137
+ records
1138
+ .filter((record) => isNoteRecord(record))
1139
+ .map((record, i) => [noteFile(contentBase, record), notes[i]]),
1140
+ );
1141
+ return (source) => {
1142
+ const record = byFile.get(source);
1143
+ if (!record) return undefined;
1144
+ return glossaryLanguage(undefined, record, {
1145
+ keyOf: (value) => (typeof value === "string" && value ? value : undefined),
1146
+ lookup: (key) => byKey.get(key),
1147
+ self: keyOf(record),
1148
+ });
1149
+ };
1150
+ }
1151
+
863
1152
  /**
864
1153
  * Each declared dependency's cached index, as {@link openNotesDatabase} attaches
865
1154
  * them. Empty where none has been fetched.
@@ -1031,6 +1320,7 @@ export async function prepareTreeSqlTables(
1031
1320
  const prepared = await prepareSqlTables(db, sources, {
1032
1321
  linkable: (ref) => addresses.has(renderAddress(ref)),
1033
1322
  records: indexRecords,
1323
+ pageLanguage: pageLanguages(indexRecords, contentBase),
1034
1324
  });
1035
1325
  prepared.inline = await prepareInlineSqlExpressions(db, sources);
1036
1326
  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", "part.", "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
+ }