@heroiclands/package-build 23.6.0 → 23.7.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 +42 -0
- package/docs/authoring/links-and-markup.md +266 -20
- package/docs/content-format.md +1 -1
- package/docs/reference/format-details.md +526 -70
- package/docs/reference/note-types.md +152 -38
- package/engine/content-format.yaml +10 -0
- package/engine/content-index.mjs +21 -14
- package/engine/dictionary-layout.mjs +9 -3
- package/engine/dictionary-rows.mjs +15 -25
- package/engine/index-records.mjs +1 -0
- package/engine/language-data.mjs +378 -0
- package/engine/language-rows.mjs +761 -0
- package/engine/language-terms.mjs +1203 -0
- package/engine/language-words.mjs +57 -9
- package/engine/note-addresses.mjs +38 -4
- package/engine/note-vocabulary.mjs +9 -2
- package/engine/pack-config.mjs +6 -1
- package/engine/sql-tables.mjs +71 -7
- package/engine/word-citations.mjs +206 -0
- package/engine/word-classes.mjs +1 -1
- package/engine/word-rows.mjs +193 -34
- package/engine/word-terms.mjs +83 -11
- package/package.json +1 -1
- package/types/engine/dictionary-layout.d.mts +7 -0
- package/types/engine/language-data.d.mts +22 -0
- package/types/engine/language-rows.d.mts +227 -0
- package/types/engine/language-terms.d.mts +265 -0
- package/types/engine/note-addresses.d.mts +8 -10
- package/types/engine/sql-tables.d.mts +2 -1
- package/types/engine/word-citations.d.mts +43 -0
- package/types/engine/word-rows.d.mts +78 -7
- package/types/engine/word-terms.d.mts +20 -0
|
@@ -26,8 +26,11 @@
|
|
|
26
26
|
* - a word's `field` is one of `data.fields`;
|
|
27
27
|
* - a `borrowed` source names its `language`;
|
|
28
28
|
* - no form stands twice in one language;
|
|
29
|
-
* -
|
|
30
|
-
*
|
|
29
|
+
* - a word's `gloss` states at least one sense;
|
|
30
|
+
* - an affix that `replaces` the stem's close does not also `lengthen` it;
|
|
31
|
+
* - a word's `frame` names a frame of `data.sounds.frames`, its language's or
|
|
32
|
+
* an ancestor's;
|
|
33
|
+
* - every source language resolves, and names a language skill;
|
|
31
34
|
* - a pronunciation is a respelling;
|
|
32
35
|
* - a `built` piece no word or affix of the language glosses is a warning.
|
|
33
36
|
*
|
|
@@ -49,10 +52,11 @@ import {
|
|
|
49
52
|
BORROWED_SOURCE_KIND,
|
|
50
53
|
LANGUAGE_SUBTYPE,
|
|
51
54
|
WORD_DATA_KEYS,
|
|
55
|
+
glossSenses,
|
|
52
56
|
pronunciationProblem,
|
|
53
57
|
} from "./word-terms.mjs";
|
|
54
58
|
import { WORD_CLASS_MODIFIERS, parseWordClass } from "./word-classes.mjs";
|
|
55
|
-
import { pieceGlosses } from "./word-rows.mjs";
|
|
59
|
+
import { lineageChain, pieceGlosses } from "./word-rows.mjs";
|
|
56
60
|
|
|
57
61
|
const MODIFIER_TOKENS = new Set(WORD_CLASS_MODIFIERS.map((item) => item.token));
|
|
58
62
|
|
|
@@ -159,7 +163,28 @@ export function checkLanguageWords(note, { index } = {}) {
|
|
|
159
163
|
return out;
|
|
160
164
|
}
|
|
161
165
|
|
|
162
|
-
|
|
166
|
+
// The language's own entries, then each ancestor's through `data.family`.
|
|
167
|
+
const ownAddress = ownKey(note, index);
|
|
168
|
+
const dataAt = (key) => {
|
|
169
|
+
const hit = index?.addressHit?.(key);
|
|
170
|
+
const target = hit?.fm ?? hit;
|
|
171
|
+
return target?.subType === LANGUAGE_SUBTYPE && isMap(target.data) ? target.data : undefined;
|
|
172
|
+
};
|
|
173
|
+
const ancestors =
|
|
174
|
+
index?.addressHit && stated("family") ?
|
|
175
|
+
lineageChain(addressKeyOf(data.family, index, "skill"), {
|
|
176
|
+
has: (key) => key !== ownAddress && dataAt(key) !== undefined,
|
|
177
|
+
parentOf: (key) => addressKeyOf(dataAt(key).family, index, "skill"),
|
|
178
|
+
}).map((key) => dataAt(key))
|
|
179
|
+
: [];
|
|
180
|
+
const glossOf = pieceGlosses(data, ...ancestors);
|
|
181
|
+
// The frames a word may be set in: its language's, then each ancestor's.
|
|
182
|
+
const frames = new Set();
|
|
183
|
+
for (const one of [data, ...ancestors]) {
|
|
184
|
+
const sounds = isMap(one?.sounds) ? one.sounds : {};
|
|
185
|
+
for (const frame of listOf(sounds.frames))
|
|
186
|
+
if (isMap(frame) && typeof frame.frame === "string") frames.add(frame.frame);
|
|
187
|
+
}
|
|
163
188
|
const used = new Set();
|
|
164
189
|
const forms = new Map();
|
|
165
190
|
listOf(data.words).forEach((word, i) => {
|
|
@@ -222,10 +247,20 @@ export function checkLanguageWords(note, { index } = {}) {
|
|
|
222
247
|
findings.push(...languageFindings(word.source.language, [...at("source"), "language"]));
|
|
223
248
|
}
|
|
224
249
|
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
250
|
+
if (typeof word.frame === "string" && !frames.has(word.frame))
|
|
251
|
+
findings.push(
|
|
252
|
+
finding(
|
|
253
|
+
at("frame"),
|
|
254
|
+
"error",
|
|
255
|
+
`the frame \`${word.frame}\` of \`${word.form}\` is not one of this language's ` +
|
|
256
|
+
"`data.sounds.frames`",
|
|
257
|
+
),
|
|
258
|
+
);
|
|
259
|
+
|
|
260
|
+
if (Array.isArray(word.gloss) && !glossSenses(word.gloss).length)
|
|
261
|
+
findings.push(
|
|
262
|
+
finding(at("gloss"), "error", "a `gloss` list states at least one sense"),
|
|
263
|
+
);
|
|
229
264
|
|
|
230
265
|
if (typeof word.pronunciation === "string") {
|
|
231
266
|
const problem = pronunciationProblem(word.pronunciation);
|
|
@@ -246,12 +281,25 @@ export function checkLanguageWords(note, { index } = {}) {
|
|
|
246
281
|
[...at("built"), j],
|
|
247
282
|
"warning",
|
|
248
283
|
`the piece \`${piece}\` of \`${word.form}\` matches no word or affix of this ` +
|
|
249
|
-
"language, so it prints with no gloss",
|
|
284
|
+
"language or its ancestors, so it prints with no gloss",
|
|
250
285
|
),
|
|
251
286
|
);
|
|
252
287
|
});
|
|
253
288
|
});
|
|
254
289
|
|
|
290
|
+
listOf(data.affixes).forEach((affix, i) => {
|
|
291
|
+
if (!isMap(affix) || affix.lengthens !== true || affix.replaces == null) return;
|
|
292
|
+
findings.push(
|
|
293
|
+
finding(
|
|
294
|
+
["data", "affixes", i, "lengthens"],
|
|
295
|
+
"error",
|
|
296
|
+
`\`${affix.form}\`: an affix that replaces the stem's close does not also lengthen ` +
|
|
297
|
+
"its last vowel; state one",
|
|
298
|
+
true,
|
|
299
|
+
),
|
|
300
|
+
);
|
|
301
|
+
});
|
|
302
|
+
|
|
255
303
|
for (const [token, i] of declared)
|
|
256
304
|
if (!used.has(token))
|
|
257
305
|
findings.push(
|
|
@@ -69,15 +69,49 @@ export const STRUCTURED_ADDRESSES = Object.freeze([
|
|
|
69
69
|
type: "place",
|
|
70
70
|
accepts: ["place"],
|
|
71
71
|
},
|
|
72
|
-
// A language's
|
|
73
|
-
//
|
|
72
|
+
// A language's Addresses and a glossary entry's language, read leniently:
|
|
73
|
+
// a value that is no Address stays as written, for the language or
|
|
74
74
|
// glossary check to refuse at its own key.
|
|
75
75
|
{
|
|
76
76
|
noteType: "skill",
|
|
77
|
-
path: ["data", "
|
|
78
|
-
shape: "
|
|
77
|
+
path: ["data", "tongues", "*", "language"],
|
|
78
|
+
shape: "value",
|
|
79
|
+
type: "skill",
|
|
80
|
+
accepts: ["skill"],
|
|
81
|
+
lenient: true,
|
|
82
|
+
},
|
|
83
|
+
...["from", "to"].map((key) => ({
|
|
84
|
+
noteType: "skill",
|
|
85
|
+
path: ["data", "soundChanges", "*", key],
|
|
86
|
+
shape: "value",
|
|
87
|
+
type: "skill",
|
|
88
|
+
accepts: ["skill"],
|
|
89
|
+
lenient: true,
|
|
90
|
+
})),
|
|
91
|
+
{
|
|
92
|
+
noteType: "skill",
|
|
93
|
+
path: ["data", "cognates", "*", "forms"],
|
|
94
|
+
shape: "keys",
|
|
95
|
+
type: "skill",
|
|
96
|
+
accepts: ["skill"],
|
|
79
97
|
lenient: true,
|
|
80
98
|
},
|
|
99
|
+
...[
|
|
100
|
+
["folders", "folder", ["folder"], "none"],
|
|
101
|
+
["cultures", "lore", ["lore"]],
|
|
102
|
+
["lore", "lore", ["lore"]],
|
|
103
|
+
["parents", "place", undefined],
|
|
104
|
+
["addresses", undefined, undefined],
|
|
105
|
+
["skip", undefined, undefined],
|
|
106
|
+
].map(([kind, type, accepts, system]) => ({
|
|
107
|
+
noteType: "skill",
|
|
108
|
+
path: ["data", "scope", kind],
|
|
109
|
+
shape: "list",
|
|
110
|
+
...(type ? { type } : {}),
|
|
111
|
+
...(accepts ? { accepts } : {}),
|
|
112
|
+
...(system ? { system } : {}),
|
|
113
|
+
lenient: true,
|
|
114
|
+
})),
|
|
81
115
|
{
|
|
82
116
|
noteType: "skill",
|
|
83
117
|
path: ["data", "words", "*", "source", "language"],
|
|
@@ -132,6 +132,8 @@ import {
|
|
|
132
132
|
checkLanguageWords,
|
|
133
133
|
} from "./language-words.mjs";
|
|
134
134
|
import { checkGlossary } from "./glossary-entries.mjs";
|
|
135
|
+
import { LANGUAGE_DATA_FIELDS } from "./language-terms.mjs";
|
|
136
|
+
import { checkLanguageData } from "./language-data.mjs";
|
|
135
137
|
import { checkAffiliationReach } from "./culture-reach.mjs";
|
|
136
138
|
import { checkLanguageNames, requiredGivenGenders } from "./language-names.mjs";
|
|
137
139
|
import { SOCIAL_TIES, SOCIAL_TIE_TARGET_TYPES } from "./social-tie-terms.mjs";
|
|
@@ -1989,8 +1991,12 @@ export const NOTE_VOCABULARY = Object.freeze({
|
|
|
1989
1991
|
skill: Object.freeze({
|
|
1990
1992
|
stubbable: true,
|
|
1991
1993
|
// A language note's words, held to the master word classes and to what
|
|
1992
|
-
// the note declares
|
|
1993
|
-
|
|
1994
|
+
// the note declares, and its register, sounds and cognates held to
|
|
1995
|
+
// what they name; no other skill states them.
|
|
1996
|
+
check: (note, context) => [
|
|
1997
|
+
...checkLanguageWords(note, context),
|
|
1998
|
+
...checkLanguageData(note, { ...context, types: Object.keys(NOTE_VOCABULARY) }),
|
|
1999
|
+
],
|
|
1994
2000
|
subTypes: Object.freeze([
|
|
1995
2001
|
"social",
|
|
1996
2002
|
"nature",
|
|
@@ -2017,6 +2023,7 @@ export const NOTE_VOCABULARY = Object.freeze({
|
|
|
2017
2023
|
FAMILY_FIELD,
|
|
2018
2024
|
ABBREVIATION_FIELD,
|
|
2019
2025
|
...WORD_DATA_FIELDS,
|
|
2026
|
+
...LANGUAGE_DATA_FIELDS,
|
|
2020
2027
|
]),
|
|
2021
2028
|
}),
|
|
2022
2029
|
|
package/engine/pack-config.mjs
CHANGED
|
@@ -695,7 +695,7 @@ export function loadPackConfig() {
|
|
|
695
695
|
|
|
696
696
|
const explicit = process.env.PACKAGE_BUILD_CONFIG;
|
|
697
697
|
const found = explicit ? undefined : resolveConfigFile();
|
|
698
|
-
|
|
698
|
+
let configPath = explicit ? path.resolve(explicit) : found.path;
|
|
699
699
|
|
|
700
700
|
if (!configPath || !fs.existsSync(configPath)) {
|
|
701
701
|
throw new Error(
|
|
@@ -713,6 +713,11 @@ export function loadPackConfig() {
|
|
|
713
713
|
);
|
|
714
714
|
}
|
|
715
715
|
|
|
716
|
+
// An explicit name is followed through any symlink, so the configuration's
|
|
717
|
+
// root agrees with `process.cwd()`, which is always a real path, and
|
|
718
|
+
// diagnostic paths relative to one read relative to the other.
|
|
719
|
+
if (explicit) configPath = fs.realpathSync(configPath);
|
|
720
|
+
|
|
716
721
|
// Two different files, one of which is about to be ignored. Said out loud
|
|
717
722
|
// because the alternative is a build that reads the parent
|
|
718
723
|
// checkout's configuration, compiles the parent's tree, and reports it only
|
package/engine/sql-tables.mjs
CHANGED
|
@@ -32,14 +32,19 @@ import { encodeAddresses, flattenPublishedAddresses } from "./address-values.mjs
|
|
|
32
32
|
import { EVENT_COLUMNS, EVENT_SORT_COLUMNS, eventRows } from "./event-rows.mjs";
|
|
33
33
|
import {
|
|
34
34
|
GLOSSARY_COLUMNS,
|
|
35
|
+
AFFIX_COLUMNS,
|
|
36
|
+
FIELD_COLUMNS,
|
|
35
37
|
LINEAGE_COLUMNS,
|
|
36
38
|
ROW_INTEGER_COLUMNS,
|
|
37
39
|
WORD_COLUMNS,
|
|
38
40
|
glossaryRows,
|
|
39
41
|
lineageRows,
|
|
42
|
+
affixRows,
|
|
43
|
+
fieldRows,
|
|
40
44
|
wordRows,
|
|
41
45
|
} from "./word-rows.mjs";
|
|
42
46
|
import { DICTIONARY_COLUMNS, TERM_COLUMNS, dictionaryAndTermRows } from "./dictionary-rows.mjs";
|
|
47
|
+
import { LANGUAGE_COLUMN_TYPES, LANGUAGE_RELATIONS } from "./language-rows.mjs";
|
|
43
48
|
import { renderDictionary, renderGlossary } from "./dictionary-layout.mjs";
|
|
44
49
|
import { glossaryLanguage } from "./glossary-language.mjs";
|
|
45
50
|
import { eventViewSections } from "./event-views.mjs";
|
|
@@ -94,6 +99,15 @@ export const SQL_FENCE_ATTRIBUTES = Object.freeze({
|
|
|
94
99
|
"The heading level given to each group a `_section` column creates, and to each " +
|
|
95
100
|
"letter of a dictionary or a glossary.",
|
|
96
101
|
}),
|
|
102
|
+
key: Object.freeze({
|
|
103
|
+
value: "`true`, `false` or `only`",
|
|
104
|
+
default: "`true`",
|
|
105
|
+
summary:
|
|
106
|
+
"Whether a dictionary or a glossary prints its key, the line of abbreviations it " +
|
|
107
|
+
"uses. `false` leaves the key out, for a page that states it once above several " +
|
|
108
|
+
"fences; `only` prints the key for the fence's rows and nothing else, which is how " +
|
|
109
|
+
"that page states it. Refused on a fence set as a table.",
|
|
110
|
+
}),
|
|
97
111
|
layout: Object.freeze({
|
|
98
112
|
value: "`table`, `dictionary` or `glossary`",
|
|
99
113
|
default: "`table`",
|
|
@@ -123,7 +137,7 @@ const escapeCell = (text) =>
|
|
|
123
137
|
*
|
|
124
138
|
* @param {string} markdown - The note body, frontmatter already stripped.
|
|
125
139
|
* @returns {Array<{line: number, close: number, indent: string, query: string,
|
|
126
|
-
* allowEmpty: boolean, sectionLevel: number, layout: string, block: string}>} One entry per
|
|
140
|
+
* allowEmpty: boolean, sectionLevel: number, showKey: boolean|"only", layout: string, block: string}>} One entry per
|
|
127
141
|
* fence, in document order.
|
|
128
142
|
*/
|
|
129
143
|
export function findSqlBlocks(markdown) {
|
|
@@ -158,11 +172,22 @@ export function findSqlBlocks(markdown) {
|
|
|
158
172
|
errors.push(error.message);
|
|
159
173
|
}
|
|
160
174
|
}
|
|
175
|
+
/** @type {boolean|"only"} */
|
|
176
|
+
let showKey = true;
|
|
177
|
+
if (Object.hasOwn(args, "key")) {
|
|
178
|
+
if (args.key === "only") showKey = "only";
|
|
179
|
+
else if (args.key === "true" || args.key === "false") showKey = args.key === "true";
|
|
180
|
+
else errors.push("key needs the literal value true, false or only");
|
|
181
|
+
}
|
|
161
182
|
if (!Number.isInteger(level) || level < 1 || level > 6)
|
|
162
183
|
errors.push("section-level needs an integer from 1 through 6");
|
|
163
184
|
const layout = String(args.layout ?? "table");
|
|
164
185
|
if (!SQL_LAYOUTS.includes(layout))
|
|
165
186
|
errors.push(`layout is \`table\`, \`dictionary\` or \`glossary\`, not \`${layout}\``);
|
|
187
|
+
else if (Object.hasOwn(args, "key") && layout === "table")
|
|
188
|
+
errors.push(
|
|
189
|
+
"key sets a dictionary's or a glossary's key, and this fence's layout is `table`",
|
|
190
|
+
);
|
|
166
191
|
blocks.push({
|
|
167
192
|
line: i,
|
|
168
193
|
close,
|
|
@@ -170,6 +195,7 @@ export function findSqlBlocks(markdown) {
|
|
|
170
195
|
query: lines.slice(i + 1, close).join("\n"),
|
|
171
196
|
allowEmpty,
|
|
172
197
|
sectionLevel: level,
|
|
198
|
+
showKey,
|
|
173
199
|
layout,
|
|
174
200
|
problems: errors,
|
|
175
201
|
args,
|
|
@@ -310,10 +336,13 @@ export async function openNotesDatabase(
|
|
|
310
336
|
for (const relation of [
|
|
311
337
|
"events",
|
|
312
338
|
"words",
|
|
339
|
+
"affixes",
|
|
340
|
+
"fields",
|
|
313
341
|
"glossary",
|
|
314
342
|
"language_lineage",
|
|
315
343
|
"dictionary",
|
|
316
344
|
"terms",
|
|
345
|
+
...LANGUAGE_RELATIONS.map(([name]) => name),
|
|
317
346
|
])
|
|
318
347
|
await connection.run(
|
|
319
348
|
`CREATE VIEW ${WITH_STUBS_SCHEMA}.${relation} AS SELECT * FROM main.${relation}`,
|
|
@@ -522,15 +551,20 @@ async function createTableFromFile(connection, name, select) {
|
|
|
522
551
|
}
|
|
523
552
|
|
|
524
553
|
/**
|
|
525
|
-
* Create the `words`, `
|
|
526
|
-
* relations: one row per language note's word, one per
|
|
554
|
+
* Create the `words`, `affixes`, `fields`, `glossary`, `language_lineage`,
|
|
555
|
+
* `dictionary` and `terms` relations: one row per language note's word, one per
|
|
556
|
+
* its affix, one per its field, one per culture's or
|
|
527
557
|
* affiliation's glossary entry as authored, one per language and ancestor, one
|
|
528
558
|
* per word per language it is listed under, and one per term per owning note,
|
|
529
559
|
* with the columns {@link module:engine/word-rows.WORD_COLUMNS},
|
|
560
|
+
* {@link module:engine/word-rows.AFFIX_COLUMNS},
|
|
561
|
+
* {@link module:engine/word-rows.FIELD_COLUMNS},
|
|
530
562
|
* {@link module:engine/word-rows.GLOSSARY_COLUMNS},
|
|
531
563
|
* {@link module:engine/word-rows.LINEAGE_COLUMNS},
|
|
532
564
|
* {@link module:engine/dictionary-rows.DICTIONARY_COLUMNS} and
|
|
533
|
-
* {@link module:engine/dictionary-rows.TERM_COLUMNS} name
|
|
565
|
+
* {@link module:engine/dictionary-rows.TERM_COLUMNS} name; then each relation
|
|
566
|
+
* of a language's register, sounds, history and names, as
|
|
567
|
+
* {@link module:engine/language-rows} derives them.
|
|
534
568
|
*
|
|
535
569
|
* Derived from the same records `entries` reads, stubs included, and a GM
|
|
536
570
|
* note's rows are left out on a public surface exactly as the note is, while a
|
|
@@ -550,10 +584,24 @@ async function createWordRelations(connection, stem, records, prefix, audience)
|
|
|
550
584
|
await createRowRelation(
|
|
551
585
|
connection,
|
|
552
586
|
`${stem}-words.jsonl`,
|
|
553
|
-
wordRows(visible),
|
|
587
|
+
wordRows(visible, records),
|
|
554
588
|
`${prefix}words`,
|
|
555
589
|
WORD_COLUMNS,
|
|
556
590
|
);
|
|
591
|
+
await createRowRelation(
|
|
592
|
+
connection,
|
|
593
|
+
`${stem}-affixes.jsonl`,
|
|
594
|
+
affixRows(visible),
|
|
595
|
+
`${prefix}affixes`,
|
|
596
|
+
AFFIX_COLUMNS,
|
|
597
|
+
);
|
|
598
|
+
await createRowRelation(
|
|
599
|
+
connection,
|
|
600
|
+
`${stem}-fields.jsonl`,
|
|
601
|
+
fieldRows(visible),
|
|
602
|
+
`${prefix}fields`,
|
|
603
|
+
FIELD_COLUMNS,
|
|
604
|
+
);
|
|
557
605
|
await createRowRelation(
|
|
558
606
|
connection,
|
|
559
607
|
`${stem}-glossary.jsonl`,
|
|
@@ -583,6 +631,15 @@ async function createWordRelations(connection, stem, records, prefix, audience)
|
|
|
583
631
|
`${prefix}terms`,
|
|
584
632
|
TERM_COLUMNS,
|
|
585
633
|
);
|
|
634
|
+
for (const [name, rowsOf, columns] of LANGUAGE_RELATIONS)
|
|
635
|
+
await createRowRelation(
|
|
636
|
+
connection,
|
|
637
|
+
`${stem}-${name}.jsonl`,
|
|
638
|
+
rowsOf(visible, records),
|
|
639
|
+
`${prefix}${name}`,
|
|
640
|
+
columns,
|
|
641
|
+
LANGUAGE_COLUMN_TYPES[name],
|
|
642
|
+
);
|
|
586
643
|
}
|
|
587
644
|
|
|
588
645
|
/**
|
|
@@ -595,12 +652,18 @@ async function createWordRelations(connection, stem, records, prefix, audience)
|
|
|
595
652
|
* @param {object[]} rows - The rows, each carrying every column.
|
|
596
653
|
* @param {string} view - The qualified view name.
|
|
597
654
|
* @param {readonly string[]} columns - Its columns, for the empty case.
|
|
655
|
+
* @param {Readonly<Record<string, string>>} [types] - Each column's type, for
|
|
656
|
+
* the empty case; a column it leaves out is text. Without it, a whole-number
|
|
657
|
+
* column is a number and every other column text.
|
|
598
658
|
* @returns {Promise<void>}
|
|
599
659
|
*/
|
|
600
|
-
async function createRowRelation(connection, file, rows, view, columns) {
|
|
660
|
+
async function createRowRelation(connection, file, rows, view, columns, types) {
|
|
601
661
|
if (!rows.length) {
|
|
602
662
|
const typed = columns.map((column) => {
|
|
603
|
-
const type =
|
|
663
|
+
const type =
|
|
664
|
+
types ? (types[column] ?? "VARCHAR")
|
|
665
|
+
: ROW_INTEGER_COLUMNS.includes(column) ? "BIGINT"
|
|
666
|
+
: "VARCHAR";
|
|
604
667
|
return `NULL::${type} AS "${column}"`;
|
|
605
668
|
});
|
|
606
669
|
await connection.run(`CREATE VIEW ${view} AS SELECT ${typed.join(", ")} WHERE false`);
|
|
@@ -870,6 +933,7 @@ export async function prepareSqlTables(db, sources, { linkable, records, pageLan
|
|
|
870
933
|
setAs(result, {
|
|
871
934
|
...options,
|
|
872
935
|
labelOf,
|
|
936
|
+
showKey: block.showKey,
|
|
873
937
|
pageLanguage: languageOf(source, frontmatter),
|
|
874
938
|
})
|
|
875
939
|
: renderSqlTable(result, options),
|
|
@@ -0,0 +1,206 @@
|
|
|
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 notes that cite a language's words, read from the notes' prose and from
|
|
16
|
+
* the names their frontmatter carries.
|
|
17
|
+
*
|
|
18
|
+
* **A marked use is an italic span of a note's body**: `_vazhmadêr_` or
|
|
19
|
+
* `*vazhmadêr*`, holding text alone — a span holding a link, a code span or
|
|
20
|
+
* other markup is no use, and neither is anything in a code fence. A use cites
|
|
21
|
+
* every word, of every language note of the package, whose form or one of
|
|
22
|
+
* whose `variants` it spells, compared with diacritics taken off and case
|
|
23
|
+
* folded, so `_Vazhmadêr_` and `_vazhmader_` both cite `vazhmadêr`. An
|
|
24
|
+
* italic span that spells no word cites nothing.
|
|
25
|
+
*
|
|
26
|
+
* **A note also cites a word its frontmatter names whole**: a full name or an
|
|
27
|
+
* alias, or an affiliation's rank or office title, that spells the word's form
|
|
28
|
+
* or a variant under the same folding. A word found only inside a longer name
|
|
29
|
+
* is no citation.
|
|
30
|
+
*
|
|
31
|
+
* A language note never cites its own words, and its own body cites nothing:
|
|
32
|
+
* its italics are the words it describes. The index carries what this derives as each language record's
|
|
33
|
+
* `citations` — the citing notes' Addresses, never their text — and the
|
|
34
|
+
* `attested` column of the `words` relation and the `usage` of a `dictionary`
|
|
35
|
+
* entry read it.
|
|
36
|
+
*
|
|
37
|
+
* @module
|
|
38
|
+
*/
|
|
39
|
+
|
|
40
|
+
import MarkdownIt from "markdown-it";
|
|
41
|
+
|
|
42
|
+
import { isLanguageRecord, noteAddressOf } from "./word-rows.mjs";
|
|
43
|
+
|
|
44
|
+
/** The parser marked uses are read with: CommonMark's emphasis and nothing added. */
|
|
45
|
+
const PARSER = new MarkdownIt("commonmark");
|
|
46
|
+
|
|
47
|
+
/** Code-point order, so a list is the same on every machine. */
|
|
48
|
+
const compare = (a, b) =>
|
|
49
|
+
a < b ? -1
|
|
50
|
+
: a > b ? 1
|
|
51
|
+
: 0;
|
|
52
|
+
|
|
53
|
+
/** Whether a value is a plain map. */
|
|
54
|
+
const isMap = (value) => Boolean(value) && typeof value === "object" && !Array.isArray(value);
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* A form as uses are matched against it: diacritics taken off, case folded.
|
|
58
|
+
*
|
|
59
|
+
* @param {unknown} text - A form or a use.
|
|
60
|
+
* @returns {string} The folded form.
|
|
61
|
+
*/
|
|
62
|
+
export function foldForm(text) {
|
|
63
|
+
return String(text ?? "")
|
|
64
|
+
.normalize("NFD")
|
|
65
|
+
.replace(/\p{M}/gu, "")
|
|
66
|
+
.toLowerCase()
|
|
67
|
+
.normalize("NFC")
|
|
68
|
+
.trim();
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* The marked uses in a note's body: the text of every italic span that holds
|
|
73
|
+
* text alone, in document order.
|
|
74
|
+
*
|
|
75
|
+
* @param {string} body - The note's Markdown body.
|
|
76
|
+
* @returns {string[]} Each span's text.
|
|
77
|
+
*/
|
|
78
|
+
export function markedUses(body) {
|
|
79
|
+
const text = String(body ?? "");
|
|
80
|
+
if (!/[_*]/.test(text)) return [];
|
|
81
|
+
const uses = [];
|
|
82
|
+
for (const token of PARSER.parse(text, {})) {
|
|
83
|
+
if (token.type !== "inline" || !token.children) continue;
|
|
84
|
+
let span;
|
|
85
|
+
for (const child of token.children) {
|
|
86
|
+
if (child.type === "em_open") span = [];
|
|
87
|
+
else if (child.type === "em_close") {
|
|
88
|
+
if (span) {
|
|
89
|
+
const use = span.join("").trim();
|
|
90
|
+
if (use && !use.includes("[[")) uses.push(use);
|
|
91
|
+
}
|
|
92
|
+
span = undefined;
|
|
93
|
+
} else if (span) {
|
|
94
|
+
if (child.type === "text") span.push(child.content);
|
|
95
|
+
else span = undefined;
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
return uses;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* The names a note carries whole, which cite a word they spell exactly: its
|
|
104
|
+
* full name and aliases, and an affiliation's rank and office titles.
|
|
105
|
+
*
|
|
106
|
+
* @param {Record<string, any>} record - An index record.
|
|
107
|
+
* @returns {string[]}
|
|
108
|
+
*/
|
|
109
|
+
function namesCarried(record) {
|
|
110
|
+
const out = [];
|
|
111
|
+
const name = isMap(record.name) ? record.name : {};
|
|
112
|
+
if (typeof name.full === "string") out.push(name.full);
|
|
113
|
+
for (const alias of Array.isArray(name.aliases) ? name.aliases : [])
|
|
114
|
+
if (typeof alias === "string") out.push(alias);
|
|
115
|
+
const governance = isMap(record.data?.governance) ? record.data.governance : {};
|
|
116
|
+
for (const rank of Array.isArray(governance.ranks) ? governance.ranks : [])
|
|
117
|
+
if (isMap(rank) && typeof rank.title === "string") out.push(rank.title);
|
|
118
|
+
if (isMap(governance.offices)) out.push(...Object.keys(governance.offices));
|
|
119
|
+
return out;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* Each language note's cited words: for every word some note's marked use
|
|
124
|
+
* spells, the Addresses of the notes citing it.
|
|
125
|
+
*
|
|
126
|
+
* @param {Iterable<Record<string, any>>} records - Index records.
|
|
127
|
+
* @param {Iterable<{record: Record<string, any>, body: string}>} notes - The
|
|
128
|
+
* notes whose bodies are read, each with its record.
|
|
129
|
+
* @returns {Map<string, Map<string, string[]>>} By language Address, by the
|
|
130
|
+
* word's form, the citing notes in code-point order.
|
|
131
|
+
*/
|
|
132
|
+
export function wordCitations(records, notes) {
|
|
133
|
+
const byFold = new Map();
|
|
134
|
+
for (const record of records) {
|
|
135
|
+
if (!isLanguageRecord(record)) continue;
|
|
136
|
+
const language = noteAddressOf(record);
|
|
137
|
+
if (!language) continue;
|
|
138
|
+
for (const word of Array.isArray(record.data?.words) ? record.data.words : []) {
|
|
139
|
+
if (!isMap(word) || typeof word.form !== "string") continue;
|
|
140
|
+
const spellings = [
|
|
141
|
+
word.form,
|
|
142
|
+
...(Array.isArray(word.variants) ? word.variants : []).filter(
|
|
143
|
+
(variant) => typeof variant === "string",
|
|
144
|
+
),
|
|
145
|
+
];
|
|
146
|
+
for (const spelling of spellings) {
|
|
147
|
+
const fold = foldForm(spelling);
|
|
148
|
+
if (!fold) continue;
|
|
149
|
+
if (!byFold.has(fold)) byFold.set(fold, []);
|
|
150
|
+
byFold.get(fold).push({ language, form: word.form });
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
const cited = new Map();
|
|
155
|
+
if (!byFold.size) return cited;
|
|
156
|
+
/** Cite every word a folded spelling matches from `note`, never from its own language. */
|
|
157
|
+
const cite = (note, fold) => {
|
|
158
|
+
for (const { language, form } of byFold.get(fold) ?? []) {
|
|
159
|
+
if (language === note) continue;
|
|
160
|
+
if (!cited.has(language)) cited.set(language, new Map());
|
|
161
|
+
const words = cited.get(language);
|
|
162
|
+
if (!words.has(form)) words.set(form, new Set());
|
|
163
|
+
words.get(form).add(note);
|
|
164
|
+
}
|
|
165
|
+
};
|
|
166
|
+
for (const { record, body } of notes) {
|
|
167
|
+
if (isLanguageRecord(record)) continue;
|
|
168
|
+
const note = noteAddressOf(record);
|
|
169
|
+
if (!note) continue;
|
|
170
|
+
for (const fold of new Set(markedUses(body).map(foldForm))) cite(note, fold);
|
|
171
|
+
}
|
|
172
|
+
for (const record of records) {
|
|
173
|
+
if (record?.documents || record?.type === "folder") continue;
|
|
174
|
+
const note = noteAddressOf(record);
|
|
175
|
+
if (!note) continue;
|
|
176
|
+
for (const name of namesCarried(record)) cite(note, foldForm(name));
|
|
177
|
+
}
|
|
178
|
+
const out = new Map();
|
|
179
|
+
for (const [language, words] of cited)
|
|
180
|
+
out.set(language, new Map([...words].map(([form, set]) => [form, [...set].sort(compare)])));
|
|
181
|
+
return out;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/**
|
|
185
|
+
* Set each language record's `citations` from {@link wordCitations}: one entry
|
|
186
|
+
* per cited word, in the order the words stand, `{ form, notes }`. A language
|
|
187
|
+
* with no cited word carries no `citations`.
|
|
188
|
+
*
|
|
189
|
+
* @param {Record<string, any>[]} records - Index records, written in place.
|
|
190
|
+
* @param {Iterable<{record: Record<string, any>, body: string}>} notes - The
|
|
191
|
+
* notes whose bodies are read.
|
|
192
|
+
* @returns {void}
|
|
193
|
+
*/
|
|
194
|
+
export function attachCitations(records, notes) {
|
|
195
|
+
const cited = wordCitations(records, notes);
|
|
196
|
+
for (const record of records) {
|
|
197
|
+
if (!isLanguageRecord(record)) continue;
|
|
198
|
+
const words = cited.get(noteAddressOf(record) ?? "");
|
|
199
|
+
if (!words) continue;
|
|
200
|
+
const citations = [];
|
|
201
|
+
for (const word of record.data.words)
|
|
202
|
+
if (isMap(word) && words.has(word.form))
|
|
203
|
+
citations.push({ form: word.form, notes: words.get(word.form) });
|
|
204
|
+
record.citations = citations;
|
|
205
|
+
}
|
|
206
|
+
}
|
package/engine/word-classes.mjs
CHANGED
|
@@ -70,7 +70,7 @@ export const WORD_CLASS_BASES = Object.freeze([
|
|
|
70
70
|
base("adv", "adv.", "adverb", true),
|
|
71
71
|
base("pron", "pron.", "pronoun", true),
|
|
72
72
|
base("num", "num.", "numeral", true),
|
|
73
|
-
base("part", "
|
|
73
|
+
base("part", "ptcl.", "particle", true),
|
|
74
74
|
base("art", "art.", "article", true),
|
|
75
75
|
base("conj", "conj.", "conjunction", true),
|
|
76
76
|
base("dem", "dem.", "demonstrative", true),
|