@heroiclands/package-build 23.5.1 → 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.
- package/CHANGELOG.md +25 -0
- package/bin/content-commands.mjs +23 -5
- package/docs/api.md +9 -0
- package/docs/authoring/links-and-markup.md +77 -12
- package/docs/content-format.md +16 -0
- package/docs/guides/reference-pages.md +89 -0
- package/docs/reference/format-details.md +533 -84
- package/docs/reference/note-types.md +191 -149
- package/engine/base-compiler.mjs +21 -0
- package/engine/content-format.yaml +9 -0
- package/engine/content-index.mjs +19 -5
- package/engine/culture-reach.mjs +330 -0
- package/engine/dictionary-layout.mjs +340 -0
- package/engine/dictionary-rows.mjs +608 -0
- package/engine/glossary-entries.mjs +182 -0
- package/engine/glossary-language.mjs +99 -0
- package/engine/index.mjs +1 -0
- package/engine/infobox.mjs +8 -0
- package/engine/language-words.mjs +360 -0
- package/engine/literature-notes.mjs +10 -8
- package/engine/note-addresses.mjs +25 -0
- package/engine/note-vocabulary.mjs +161 -1
- package/engine/path-charset.mjs +120 -0
- package/engine/sql-tables.mjs +311 -21
- package/engine/word-classes.mjs +167 -0
- package/engine/word-rows.mjs +233 -0
- package/engine/word-terms.mjs +306 -0
- package/package.json +1 -1
- package/types/engine/content-index.d.mts +15 -5
- package/types/engine/culture-reach.d.mts +66 -0
- package/types/engine/dictionary-layout.d.mts +69 -0
- package/types/engine/dictionary-rows.d.mts +39 -0
- package/types/engine/glossary-entries.d.mts +12 -0
- package/types/engine/glossary-language.d.mts +42 -0
- package/types/engine/index.d.mts +1 -0
- package/types/engine/language-words.d.mts +33 -0
- package/types/engine/literature-notes.d.mts +2 -2
- package/types/engine/note-addresses.d.mts +17 -0
- package/types/engine/path-charset.d.mts +38 -0
- package/types/engine/sql-tables.d.mts +7 -2
- package/types/engine/word-classes.d.mts +87 -0
- package/types/engine/word-rows.d.mts +64 -0
- package/types/engine/word-terms.d.mts +68 -0
package/engine/sql-tables.mjs
CHANGED
|
@@ -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:
|
|
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
|
-
|
|
218
|
-
|
|
219
|
-
|
|
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
|
-
|
|
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
|
|
418
|
-
|
|
419
|
-
|
|
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
|
|
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
|
-
|
|
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:
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
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
|
+
}
|