@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.
- package/CHANGELOG.md +41 -0
- package/bin/content-commands.mjs +23 -5
- package/docs/api.md +9 -0
- package/docs/authoring/links-and-markup.md +108 -12
- package/docs/content-format.md +16 -0
- package/docs/guides/reference-pages.md +89 -0
- package/docs/reference/format-details.md +549 -88
- package/docs/reference/note-types.md +192 -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 +342 -0
- package/engine/dictionary-rows.mjs +605 -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 +374 -0
- package/engine/literature-notes.mjs +10 -8
- package/engine/note-addresses.mjs +25 -0
- package/engine/note-vocabulary.mjs +161 -1
- package/engine/pack-config.mjs +6 -1
- package/engine/path-charset.mjs +120 -0
- package/engine/sql-tables.mjs +352 -21
- package/engine/word-classes.mjs +167 -0
- package/engine/word-rows.mjs +343 -0
- package/engine/word-terms.mjs +318 -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 +74 -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 +8 -2
- package/types/engine/word-classes.d.mts +87 -0
- package/types/engine/word-rows.d.mts +119 -0
- package/types/engine/word-terms.d.mts +72 -0
package/engine/sql-tables.mjs
CHANGED
|
@@ -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:
|
|
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
|
-
|
|
218
|
-
|
|
219
|
-
|
|
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
|
-
|
|
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
|
|
418
|
-
|
|
419
|
-
|
|
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
|
|
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
|
-
|
|
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:
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
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
|
+
}
|