@heroiclands/package-build 23.12.6 → 23.12.7
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 +12 -0
- package/assets/styles/content-base.css +25 -0
- package/bin/content-commands.mjs +264 -3
- package/bin/package-build.mjs +25 -0
- package/content-config.mjs +72 -0
- package/docs/api.md +10 -9
- package/docs/authoring/links-and-markup.md +439 -47
- package/docs/commands.md +127 -0
- package/docs/configuration.md +66 -3
- package/docs/content-format.md +3 -1
- package/docs/guides/book.md +11 -0
- package/docs/guides/module-project.md +16 -0
- package/docs/guides/site.md +19 -0
- package/docs/reference/format-details.md +372 -6
- package/docs/reference/note-types.md +36 -11
- package/engine/base-compiler.mjs +2 -0
- package/engine/bundles.mjs +7 -2
- package/engine/compile-corpus.mjs +3 -1
- package/engine/content-charset.mjs +21 -2
- package/engine/content-format.yaml +1 -0
- package/engine/content-index.mjs +12 -0
- package/engine/content-poetry.mjs +22 -4
- package/engine/content-scripts.mjs +1219 -0
- package/engine/content-session.mjs +2 -0
- package/engine/data-keys.mjs +5 -1
- package/engine/dictionary-layout.mjs +18 -12
- package/engine/dictionary-rows.mjs +11 -2
- package/engine/helpers.mjs +8 -2
- package/engine/index-records.mjs +1 -0
- package/engine/journals.mjs +14 -3
- package/engine/language-data.mjs +137 -1
- package/engine/language-tag.mjs +103 -0
- package/engine/language-terms.mjs +16 -4
- package/engine/map-lexicon.mjs +762 -0
- package/engine/markup-validation.mjs +17 -3
- package/engine/names.mjs +1 -1
- package/engine/note-vocabulary.mjs +113 -0
- package/engine/pdf-build.mjs +28 -10
- package/engine/pdf-render.mjs +116 -11
- package/engine/run-rows.mjs +119 -0
- package/engine/run-words.mjs +266 -0
- package/engine/scenes.mjs +1 -0
- package/engine/script-font-delivery.mjs +321 -0
- package/engine/script-fonts.mjs +172 -0
- package/engine/site-build.mjs +10 -3
- package/engine/site-config.mjs +5 -0
- package/engine/site-root.mjs +19 -5
- package/engine/sql-tables.mjs +111 -1
- package/engine/text-maps.mjs +607 -0
- package/engine/verb-pattern.mjs +99 -0
- package/engine/word-pattern.mjs +27 -0
- package/engine/word-rows.mjs +8 -2
- package/engine/word-terms.mjs +44 -0
- package/hugo-theme/layouts/_default/baseof.html +3 -0
- package/hugo-theme/static/css/style.css +10 -0
- package/manifest.mjs +9 -1
- package/package.json +1 -1
- package/types/content-config.d.mts +26 -0
- package/types/engine/compile-corpus.d.mts +2 -1
- package/types/engine/content-index.d.mts +11 -3
- package/types/engine/content-poetry.d.mts +7 -0
- package/types/engine/content-scripts.d.mts +226 -0
- package/types/engine/data-keys.d.mts +2 -2
- package/types/engine/dictionary-rows.d.mts +15 -0
- package/types/engine/helpers.d.mts +5 -1
- package/types/engine/journals.d.mts +7 -2
- package/types/engine/language-data.d.mts +2 -0
- package/types/engine/language-tag.d.mts +8 -0
- package/types/engine/language-terms.d.mts +2 -1
- package/types/engine/map-lexicon.d.mts +274 -0
- package/types/engine/markup-validation.d.mts +7 -2
- package/types/engine/pdf-build.d.mts +26 -3
- package/types/engine/pdf-render.d.mts +17 -2
- package/types/engine/run-rows.d.mts +89 -0
- package/types/engine/run-words.d.mts +86 -0
- package/types/engine/script-font-delivery.d.mts +134 -0
- package/types/engine/script-fonts.d.mts +168 -0
- package/types/engine/site-build.d.mts +2 -1
- package/types/engine/site-root.d.mts +11 -3
- package/types/engine/text-maps.d.mts +175 -0
- package/types/engine/verb-pattern.d.mts +65 -0
- package/types/engine/word-pattern.d.mts +13 -0
- package/types/engine/word-terms.d.mts +6 -0
|
@@ -21,6 +21,7 @@ import * as sql from "./sql-tables.mjs";
|
|
|
21
21
|
import { captureContentSources } from "./content-sources.mjs";
|
|
22
22
|
import { buildLinkIndex } from "./content-links.mjs";
|
|
23
23
|
import { buildContentLinkIndex } from "./helpers.mjs";
|
|
24
|
+
import { runContext } from "./content-scripts.mjs";
|
|
24
25
|
import { loadForeignIndexes } from "./metadata-index.mjs";
|
|
25
26
|
import { loadPackConfig } from "./pack-config.mjs";
|
|
26
27
|
import { routerFor } from "./pack-router.mjs";
|
|
@@ -199,6 +200,7 @@ export async function createContentSession({
|
|
|
199
200
|
return memo("compile", async () => {
|
|
200
201
|
const sqlTables = await session.sqlTables("all");
|
|
201
202
|
return {
|
|
203
|
+
runs: runContext(config, records),
|
|
202
204
|
records,
|
|
203
205
|
parsed,
|
|
204
206
|
problems,
|
package/engine/data-keys.mjs
CHANGED
|
@@ -62,7 +62,7 @@ import { nearest } from "./near-miss.mjs";
|
|
|
62
62
|
* @property {string} name - The key as an author writes it.
|
|
63
63
|
* @property {string} [kind] - Its value's shape: `string`, `date`, `number`,
|
|
64
64
|
* `integer`, `boolean`, `address`, `list`, `map`, `scalar-or-map`,
|
|
65
|
-
* `string-or-list`,
|
|
65
|
+
* `string-or-list`, `string-list-or-map` or `list-or-map` — see {@link fitsKind}. Absent, the check
|
|
66
66
|
* makes no claim about the value.
|
|
67
67
|
* @property {readonly string[]} [allowed] - The closed set a present value is
|
|
68
68
|
* one of, where the key takes a fixed word.
|
|
@@ -156,6 +156,10 @@ export function fitsKind(value, kind) {
|
|
|
156
156
|
// described by `values`.
|
|
157
157
|
case "string-list-or-map":
|
|
158
158
|
return fitsKind(value, "string-or-list") || fitsKind(value, "map");
|
|
159
|
+
// A list described by `entries`, or a map with the keys `fields` names:
|
|
160
|
+
// a mapping row is `[from, to]` or `{from, to, before?, at?}`.
|
|
161
|
+
case "list-or-map":
|
|
162
|
+
return Array.isArray(value) || fitsKind(value, "map");
|
|
159
163
|
default:
|
|
160
164
|
return true;
|
|
161
165
|
}
|
|
@@ -48,7 +48,7 @@
|
|
|
48
48
|
|
|
49
49
|
import { parseAddress, readCanonicalKey, renderAddress, splitAnchor } from "./address.mjs";
|
|
50
50
|
import { dictionaryEntryAnchor, dictionaryLetter } from "./dictionary-rows.mjs";
|
|
51
|
-
import {
|
|
51
|
+
import { verbPattern } from "./verb-pattern.mjs";
|
|
52
52
|
import {
|
|
53
53
|
WORD_CLASS_BASES,
|
|
54
54
|
WORD_CLASS_MODIFIERS,
|
|
@@ -191,16 +191,22 @@ function verbTables(rows, opts) {
|
|
|
191
191
|
patterns.set(opts.pageLanguage ?? "", { verbs: opts.verbs });
|
|
192
192
|
const tables = [];
|
|
193
193
|
for (const [language, { verbs, label }] of [...patterns].sort(([a], [b]) => compare(a, b))) {
|
|
194
|
-
|
|
195
|
-
if (
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
194
|
+
const pattern = verbPattern(verbs);
|
|
195
|
+
if (!pattern) continue;
|
|
196
|
+
const replaced = pattern.replaces === undefined ? undefined : `-${pattern.replaces}`;
|
|
197
|
+
const endings =
|
|
198
|
+
pattern.invariant ?
|
|
199
|
+
[["All persons", "unchanged"]]
|
|
200
|
+
: pattern.endings.map(({ label, ending }) => [
|
|
201
|
+
label,
|
|
202
|
+
ending ? `-${ending}`
|
|
203
|
+
: replaced ? `${replaced} dropped`
|
|
204
|
+
: "unchanged",
|
|
205
|
+
]);
|
|
206
|
+
const heading =
|
|
207
|
+
replaced && !pattern.invariant ?
|
|
208
|
+
`Ending in place of a closing ${replaced}`
|
|
209
|
+
: "Ending added to citation form";
|
|
204
210
|
const title =
|
|
205
211
|
patterns.size > 1 ?
|
|
206
212
|
`**Verbs (${String(opts.labelOf?.(language) ?? label ?? language).replace(/[\r\n]+/g, " ")})**`
|
|
@@ -209,7 +215,7 @@ function verbTables(rows, opts) {
|
|
|
209
215
|
[
|
|
210
216
|
title,
|
|
211
217
|
"",
|
|
212
|
-
|
|
218
|
+
`| Person | ${verbCell(heading)} |`,
|
|
213
219
|
"| --- | --- |",
|
|
214
220
|
...endings.map((ending) => `| ${ending.map(verbCell).join(" | ")} |`),
|
|
215
221
|
].join("\n"),
|
|
@@ -130,8 +130,17 @@ const isSingleWord = (title) => !/[\s\-‐‑]/u.test(title);
|
|
|
130
130
|
/** Whether a glossary entry states a definition. */
|
|
131
131
|
const defines = (entry) => typeof entry.definition === "string" && entry.definition.trim() !== "";
|
|
132
132
|
|
|
133
|
-
/**
|
|
134
|
-
|
|
133
|
+
/**
|
|
134
|
+
* A noun's stated plural, or the first literal ending replacement that fits.
|
|
135
|
+
*
|
|
136
|
+
* @param {{form: string, class?: unknown, plural?: unknown}} word - A word of
|
|
137
|
+
* `data.words`.
|
|
138
|
+
* @param {Record<string, any>|undefined} grammar - Its language's
|
|
139
|
+
* `data.grammar`.
|
|
140
|
+
* @returns {string|null} The plural, or `null` for a word that is no noun or
|
|
141
|
+
* that no rule fits.
|
|
142
|
+
*/
|
|
143
|
+
export function pluralOf(word, grammar) {
|
|
135
144
|
if (parseWordClass(word.class).base !== "n") return null;
|
|
136
145
|
if (typeof word.plural === "string" && word.plural) return word.plural;
|
|
137
146
|
for (const rule of Array.isArray(grammar?.plural) ? grammar.plural : []) {
|
package/engine/helpers.mjs
CHANGED
|
@@ -26,6 +26,7 @@
|
|
|
26
26
|
*/
|
|
27
27
|
|
|
28
28
|
import { renderAlerts } from "./content-alerts.mjs";
|
|
29
|
+
import { renderRuns } from "./content-scripts.mjs";
|
|
29
30
|
import { buildReferenceTargets } from "./reference-targets.mjs";
|
|
30
31
|
import { standingsDigest } from "./standings.mjs";
|
|
31
32
|
|
|
@@ -224,10 +225,15 @@ md.renderer.rules.footnote_open = (tokens, idx, options, env, renderer) => {
|
|
|
224
225
|
* @param {(address: string) => string|undefined} [resolveRole] - From a
|
|
225
226
|
* picture's address to the role its asset declares — see
|
|
226
227
|
* {@link module:engine/content-figures.scanFigures}.
|
|
228
|
+
* @param {import("./content-scripts.mjs").RunContext} [runs] - The package's
|
|
229
|
+
* languages, faces and maps. Given, text marked with `lang=`, `font=` and
|
|
230
|
+
* `map=` is rewritten first, so it is tagged for its language, mapped and
|
|
231
|
+
* set in its font.
|
|
227
232
|
* @returns {string} The rendered HTML.
|
|
228
233
|
*/
|
|
229
|
-
export function renderFoundryMarkdown(body, figures, footnoteNumbers, docId, resolveRole) {
|
|
230
|
-
const
|
|
234
|
+
export function renderFoundryMarkdown(body, figures, footnoteNumbers, docId, resolveRole, runs) {
|
|
235
|
+
const marked = runs ? renderRuns(body, runs, undefined, { target: "foundry" }).markdown : body;
|
|
236
|
+
const figured = renderFigureBlocks(marked, (block) => md.render(block), figures, {
|
|
231
237
|
resolveRole,
|
|
232
238
|
});
|
|
233
239
|
const blocks = renderBlocks(figured.markdown, "foundry");
|
package/engine/index-records.mjs
CHANGED
package/engine/journals.mjs
CHANGED
|
@@ -485,10 +485,12 @@ function soleFigurePicture(figure, page) {
|
|
|
485
485
|
* @param {Map<string, number>} footnoteNumbers - Shared across the note.
|
|
486
486
|
* @param {(address: string) => string|undefined} [resolveRole] - See
|
|
487
487
|
* {@link splitPages}.
|
|
488
|
+
* @param {import("./content-scripts.mjs").RunContext} [runs] - The package's
|
|
489
|
+
* languages, faces and maps — see {@link module:engine/helpers.renderFoundryMarkdown}.
|
|
488
490
|
* @returns {{name: string, type: "image", src: string, image: {caption?: string}}
|
|
489
491
|
* |{name: string, type: "text", text: {format: number, content: string}}}
|
|
490
492
|
*/
|
|
491
|
-
function buildFigurePageFields(figure, page, pageId, footnoteNumbers, resolveRole) {
|
|
493
|
+
function buildFigurePageFields(figure, page, pageId, footnoteNumbers, resolveRole, runs) {
|
|
492
494
|
const picture = soleFigurePicture(figure, page);
|
|
493
495
|
if (picture && !captionCarriesMarkup(figure.caption)) {
|
|
494
496
|
let src = picture;
|
|
@@ -515,6 +517,7 @@ function buildFigurePageFields(figure, page, pageId, footnoteNumbers, resolveRol
|
|
|
515
517
|
footnoteNumbers,
|
|
516
518
|
pageId,
|
|
517
519
|
resolveRole,
|
|
520
|
+
runs,
|
|
518
521
|
);
|
|
519
522
|
return {
|
|
520
523
|
name: figure.label || captionPageName(figure.caption),
|
|
@@ -547,13 +550,15 @@ function buildFigurePageFields(figure, page, pageId, footnoteNumbers, resolveRol
|
|
|
547
550
|
* carries its own figure already, from {@link splitPages}.
|
|
548
551
|
* @param {(address: string) => string|undefined} [resolveRole] - See
|
|
549
552
|
* {@link splitPages}.
|
|
553
|
+
* @param {import("./content-scripts.mjs").RunContext} [runs] - The package's
|
|
554
|
+
* languages, faces and maps — see {@link module:engine/helpers.renderFoundryMarkdown}.
|
|
550
555
|
* @returns {Array<{_id: string, name: string, type: string,
|
|
551
556
|
* title: {show: boolean, level: number},
|
|
552
557
|
* text: {format: number, content: string}, _key: string,
|
|
553
558
|
* ownership?: {default: number}}>} The page documents, in order.
|
|
554
559
|
* @throws {Error} When the note has no content at all, or repeats an anchor.
|
|
555
560
|
*/
|
|
556
|
-
export function buildPages(rawPages, entryId, noteName, figures, resolveRole) {
|
|
561
|
+
export function buildPages(rawPages, entryId, noteName, figures, resolveRole, runs) {
|
|
557
562
|
if (rawPages.length === 0) {
|
|
558
563
|
throw new Error(
|
|
559
564
|
`note "${noteName}" has no Introduction content and no H1 headings — nothing to compile`,
|
|
@@ -573,7 +578,7 @@ export function buildPages(rawPages, entryId, noteName, figures, resolveRole) {
|
|
|
573
578
|
const withheld = (page.classes ?? []).includes(WITHHELD_CLASS);
|
|
574
579
|
const fields =
|
|
575
580
|
page.figure ?
|
|
576
|
-
buildFigurePageFields(page.figure, page, pageId, footnoteNumbers, resolveRole)
|
|
581
|
+
buildFigurePageFields(page.figure, page, pageId, footnoteNumbers, resolveRole, runs)
|
|
577
582
|
: {
|
|
578
583
|
name: page.name,
|
|
579
584
|
type: "text",
|
|
@@ -587,6 +592,7 @@ export function buildPages(rawPages, entryId, noteName, figures, resolveRole) {
|
|
|
587
592
|
footnoteNumbers,
|
|
588
593
|
pageId,
|
|
589
594
|
resolveRole,
|
|
595
|
+
runs,
|
|
590
596
|
)
|
|
591
597
|
: "",
|
|
592
598
|
},
|
|
@@ -638,6 +644,8 @@ export function buildPages(rawPages, entryId, noteName, figures, resolveRole) {
|
|
|
638
644
|
* name reads `Map 1` rather than `Figure 1` where it is due — see
|
|
639
645
|
* {@link module:engine/content-figures.scanFigures}.
|
|
640
646
|
* @param {import("./content-session.mjs").ContentSyntax} [params.syntax] - Session scanner projections.
|
|
647
|
+
* @param {import("./content-scripts.mjs").RunContext} [params.runs] - The package's
|
|
648
|
+
* languages, faces and maps — see {@link module:engine/helpers.renderFoundryMarkdown}.
|
|
641
649
|
* @returns {object} The JournalEntry document, keyed for the pack.
|
|
642
650
|
*/
|
|
643
651
|
export function buildJournalEntry({
|
|
@@ -651,6 +659,7 @@ export function buildJournalEntry({
|
|
|
651
659
|
notice = "",
|
|
652
660
|
resolveRole,
|
|
653
661
|
syntax,
|
|
662
|
+
runs,
|
|
654
663
|
}) {
|
|
655
664
|
const rawPages = splitPages(markdown, leadName, resolveRole, syntax);
|
|
656
665
|
const pages = buildPages(
|
|
@@ -659,6 +668,7 @@ export function buildJournalEntry({
|
|
|
659
668
|
name,
|
|
660
669
|
scanFigures(markdown, { resolveRole }).figures,
|
|
661
670
|
resolveRole,
|
|
671
|
+
runs,
|
|
662
672
|
);
|
|
663
673
|
for (const box of infoboxes) {
|
|
664
674
|
const pageId = makeId("journal-infobox-page", `${id}:${box.id}`);
|
|
@@ -844,6 +854,7 @@ export class Journals extends BasePackCompiler {
|
|
|
844
854
|
.filter(Boolean)
|
|
845
855
|
.join("\n\n"),
|
|
846
856
|
resolveRole: (pathname) => roles.get(pathname),
|
|
857
|
+
runs: this.corpus?.runs,
|
|
847
858
|
infoboxes: boxes.map((box) => ({
|
|
848
859
|
id: box.id,
|
|
849
860
|
name: `${
|
package/engine/language-data.mjs
CHANGED
|
@@ -34,6 +34,9 @@
|
|
|
34
34
|
* type;
|
|
35
35
|
* - every language a cognate's `forms` names is a language note;
|
|
36
36
|
* - a register row states a `tongue` or a `kind`;
|
|
37
|
+
* - a register row tagged `other:<tongue>` or `exonym:<tongue>` names a name
|
|
38
|
+
* the tongue's own language note registers under that tongue, case folded
|
|
39
|
+
* and NFC (see {@link foreignRowFindings});
|
|
37
40
|
* - a sound-change set's name stands once in the note, a step's label once
|
|
38
41
|
* in its set, and its `from` and `to` name language notes;
|
|
39
42
|
* - a frame's classes are bases of the master word classes;
|
|
@@ -52,7 +55,7 @@
|
|
|
52
55
|
|
|
53
56
|
import { pathLabel as label } from "./data-keys.mjs";
|
|
54
57
|
import { positionOfFrontmatterPath } from "./diagnostics.mjs";
|
|
55
|
-
import { addressKeyOf } from "./culture-reach.mjs";
|
|
58
|
+
import { addressKeyOf, isLanguageNote } from "./culture-reach.mjs";
|
|
56
59
|
import { AddressEntries } from "./address-values.mjs";
|
|
57
60
|
import { isAddressTuple, renderAddress } from "./address.mjs";
|
|
58
61
|
import {
|
|
@@ -111,6 +114,119 @@ export function tongueDeclared(tag, declared) {
|
|
|
111
114
|
: declared.has(`${match[1]}:${TONGUE_PLACEHOLDER}`);
|
|
112
115
|
}
|
|
113
116
|
|
|
117
|
+
/** The tag families whose rows name a name of another tongue's register. */
|
|
118
|
+
const FOREIGN_FAMILIES = new Set(["other", "exonym"]);
|
|
119
|
+
|
|
120
|
+
/** How a foreign row that its tongue's register does not bear out is reported. */
|
|
121
|
+
export const FOREIGN_ROW_SEVERITY = "warning";
|
|
122
|
+
|
|
123
|
+
/** A name as a foreign row is compared: NFC and lowercase. */
|
|
124
|
+
const foldName = (name) => name.normalize("NFC").toLowerCase();
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* Each tag a language note of the index declares with a `language`, to the
|
|
128
|
+
* canonical Addresses of the language notes it names, keyed once per index.
|
|
129
|
+
*
|
|
130
|
+
* @type {WeakMap<object, Map<string, Set<string>>>}
|
|
131
|
+
*/
|
|
132
|
+
const tongueLanguagesByIndex = new WeakMap();
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* The language notes each tag names: every `data.tongues` entry of the index's
|
|
136
|
+
* own language notes whose `tag` is a plain tag and that states a `language`.
|
|
137
|
+
*
|
|
138
|
+
* @param {object} index - The link index.
|
|
139
|
+
* @returns {Map<string, Set<string>>} Canonical Addresses by tag.
|
|
140
|
+
*/
|
|
141
|
+
function tongueLanguages(index) {
|
|
142
|
+
const known = tongueLanguagesByIndex.get(index);
|
|
143
|
+
if (known) return known;
|
|
144
|
+
const out = new Map();
|
|
145
|
+
for (const note of index.notes ?? []) {
|
|
146
|
+
if (!isLanguageNote(note.fm)) continue;
|
|
147
|
+
for (const tongue of listOf(note.fm?.data?.tongues)) {
|
|
148
|
+
if (!isMap(tongue) || typeof tongue.tag !== "string" || tongue.tag.includes(":"))
|
|
149
|
+
continue;
|
|
150
|
+
const key = addressKeyOf(tongue.language, index, "skill");
|
|
151
|
+
if (!key) continue;
|
|
152
|
+
if (!out.has(tongue.tag)) out.set(tongue.tag, new Set());
|
|
153
|
+
out.get(tongue.tag).add(key);
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
tongueLanguagesByIndex.set(index, out);
|
|
157
|
+
return out;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* The findings on a register's rows tagged `other:<tongue>` or
|
|
162
|
+
* `exonym:<tongue>`, each checked against the register of the tongue's own
|
|
163
|
+
* language note.
|
|
164
|
+
*
|
|
165
|
+
* A tongue names the language note a `data.tongues` entry of one of the
|
|
166
|
+
* index's language notes tags it with, through its `language`: `other:helonic`
|
|
167
|
+
* reads the note that `{tag: helonic, language: skill-helonclng}` names. The
|
|
168
|
+
* row passes when that note's `data.register` holds a row of the same name
|
|
169
|
+
* whose `tongue` is `helonic`. A tongue no entry links to a language note, or
|
|
170
|
+
* one linked to two, is a finding at the row's `tongue`; a language note of
|
|
171
|
+
* another package is not read, since its register does not reach this index.
|
|
172
|
+
* Bare `other`, and every other tag, are not checked.
|
|
173
|
+
*
|
|
174
|
+
* @param {Array<{row: object, i: number}>} rows - The register rows whose tag
|
|
175
|
+
* `data.tongues` declares.
|
|
176
|
+
* @param {object|undefined} index - The link index.
|
|
177
|
+
* @param {(path: Array<string|number>, severity: string, message: string) => object} finding -
|
|
178
|
+
* Builds a finding at a frontmatter path.
|
|
179
|
+
* @returns {object[]} The findings.
|
|
180
|
+
*/
|
|
181
|
+
function foreignRowFindings(rows, index, finding) {
|
|
182
|
+
if (!index?.addressHit) return [];
|
|
183
|
+
const findings = [];
|
|
184
|
+
for (const { row, i } of rows) {
|
|
185
|
+
const match = USED_TAG_PATTERN.exec(row.tongue);
|
|
186
|
+
if (!match || match[2] === undefined || !FOREIGN_FAMILIES.has(match[1])) continue;
|
|
187
|
+
const tongue = match[2];
|
|
188
|
+
const languages = [...(tongueLanguages(index).get(tongue) ?? [])];
|
|
189
|
+
if (languages.length !== 1) {
|
|
190
|
+
findings.push(
|
|
191
|
+
finding(
|
|
192
|
+
["data", "register", i, "tongue"],
|
|
193
|
+
FOREIGN_ROW_SEVERITY,
|
|
194
|
+
languages.length ?
|
|
195
|
+
`the tongue \`${tongue}\` in \`${row.tongue}\` names ${languages.length} ` +
|
|
196
|
+
`language notes, ${languages.join(" and ")}; the \`data.tongues\` entries ` +
|
|
197
|
+
`tagged \`${tongue}\` name one`
|
|
198
|
+
: `the tongue \`${tongue}\` in \`${row.tongue}\` names no language note: no ` +
|
|
199
|
+
`language note's \`data.tongues\` tags \`${tongue}\` with a \`language\``,
|
|
200
|
+
),
|
|
201
|
+
);
|
|
202
|
+
continue;
|
|
203
|
+
}
|
|
204
|
+
const target = index.addressHit(languages[0]);
|
|
205
|
+
const fm = target?.fm;
|
|
206
|
+
if (!fm || !isLanguageNote(fm)) continue;
|
|
207
|
+
const name = foldName(row.name);
|
|
208
|
+
const listed = listOf(fm.data?.register).some(
|
|
209
|
+
(other) =>
|
|
210
|
+
isMap(other) &&
|
|
211
|
+
other.tongue === tongue &&
|
|
212
|
+
typeof other.name === "string" &&
|
|
213
|
+
foldName(other.name) === name,
|
|
214
|
+
);
|
|
215
|
+
if (listed) continue;
|
|
216
|
+
const title = typeof fm.name?.full === "string" ? fm.name.full : fm.name;
|
|
217
|
+
findings.push(
|
|
218
|
+
finding(
|
|
219
|
+
["data", "register", i, "name"],
|
|
220
|
+
FOREIGN_ROW_SEVERITY,
|
|
221
|
+
`\`${row.name}\` is registered as \`${row.tongue}\`, but the register of ` +
|
|
222
|
+
`${title} (${fm.type}-${fm.shortcode}) lists no \`${tongue}\` name ` +
|
|
223
|
+
`\`${row.name}\`; correct the spelling, or register the name there first`,
|
|
224
|
+
),
|
|
225
|
+
);
|
|
226
|
+
}
|
|
227
|
+
return findings;
|
|
228
|
+
}
|
|
229
|
+
|
|
114
230
|
/**
|
|
115
231
|
* The entries of a cognate's `forms`, whatever form it reached the check in:
|
|
116
232
|
* `[written key, value, Address]`.
|
|
@@ -192,6 +308,22 @@ export function checkLanguageData(note, { index, types } = {}) {
|
|
|
192
308
|
"`data.grammar.verbs` must state exactly one of `invariant: true` or `persons`",
|
|
193
309
|
),
|
|
194
310
|
);
|
|
311
|
+
else if (invariant && Object.hasOwn(verbs, "replaces"))
|
|
312
|
+
findings.push(
|
|
313
|
+
finding(
|
|
314
|
+
["data", "grammar", "verbs", "replaces"],
|
|
315
|
+
"error",
|
|
316
|
+
"`data.grammar.verbs.replaces` states what a `persons` ending replaces, and an invariant pattern has none",
|
|
317
|
+
),
|
|
318
|
+
);
|
|
319
|
+
else if (typeof verbs.replaces === "string" && !verbs.replaces.trim())
|
|
320
|
+
findings.push(
|
|
321
|
+
finding(
|
|
322
|
+
["data", "grammar", "verbs", "replaces"],
|
|
323
|
+
"error",
|
|
324
|
+
"`data.grammar.verbs.replaces` must name the letters an ending replaces; leave it out where endings are added",
|
|
325
|
+
),
|
|
326
|
+
);
|
|
195
327
|
else if (invariant && (verbs.invariant == null || verbs.invariant === false))
|
|
196
328
|
findings.push(
|
|
197
329
|
finding(
|
|
@@ -319,6 +451,7 @@ export function checkLanguageData(note, { index, types } = {}) {
|
|
|
319
451
|
);
|
|
320
452
|
});
|
|
321
453
|
};
|
|
454
|
+
const foreignRows = [];
|
|
322
455
|
listOf(data.register).forEach((row, i) => {
|
|
323
456
|
if (!isMap(row)) return;
|
|
324
457
|
if (row.tongue == null && row.kind == null)
|
|
@@ -331,7 +464,10 @@ export function checkLanguageData(note, { index, types } = {}) {
|
|
|
331
464
|
);
|
|
332
465
|
if (typeof row.tongue !== "string") return;
|
|
333
466
|
undeclared([row.tongue], () => ["data", "register", i, "tongue"]);
|
|
467
|
+
if (typeof row.name === "string" && tongueDeclared(row.tongue, declared))
|
|
468
|
+
foreignRows.push({ row, i });
|
|
334
469
|
});
|
|
470
|
+
findings.push(...foreignRowFindings(foreignRows, index, finding));
|
|
335
471
|
|
|
336
472
|
// ── Sounds ───────────────────────────────────────────────────────────────
|
|
337
473
|
const sounds = isMap(data.sounds) ? data.sounds : {};
|
|
@@ -0,0 +1,103 @@
|
|
|
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
|
+
* **BCP 47 language tags** (RFC 5646), checked without the IANA registry.
|
|
16
|
+
*
|
|
17
|
+
* A tag is accepted when it is well-formed by the RFC's grammar and meets the
|
|
18
|
+
* two validity rules that need no registry: no variant subtag appears twice,
|
|
19
|
+
* and no extension singleton appears twice. Matching is case-insensitive, as
|
|
20
|
+
* the RFC requires. Whether each subtag is registered is not checked.
|
|
21
|
+
*
|
|
22
|
+
* A constructed language takes `art-x-<name>`: `art` is the registered
|
|
23
|
+
* collective code for artificial languages, and the private-use part names
|
|
24
|
+
* which one.
|
|
25
|
+
*
|
|
26
|
+
* @module
|
|
27
|
+
*/
|
|
28
|
+
|
|
29
|
+
const ALPHANUM = "[a-z0-9]";
|
|
30
|
+
|
|
31
|
+
/** What separates a tag's subtags: RFC 5646's own separator, not an Address's. */
|
|
32
|
+
const SUBTAG_SEPARATOR = "-";
|
|
33
|
+
|
|
34
|
+
/** `langtag`, with each part captured so the validity rules can read them. */
|
|
35
|
+
const LANGTAG = new RegExp(
|
|
36
|
+
"^" +
|
|
37
|
+
// language: 2–3 letters with up to three extlangs, 4 letters, or 5–8 letters
|
|
38
|
+
"(?:[a-z]{2,3}(?:-[a-z]{3}){0,3}|[a-z]{4}|[a-z]{5,8})" +
|
|
39
|
+
// script
|
|
40
|
+
"(?:-[a-z]{4})?" +
|
|
41
|
+
// region
|
|
42
|
+
"(?:-(?:[a-z]{2}|[0-9]{3}))?" +
|
|
43
|
+
// variants
|
|
44
|
+
`((?:-(?:${ALPHANUM}{5,8}|[0-9]${ALPHANUM}{3}))*)` +
|
|
45
|
+
// extensions: a singleton other than x, then subtags of 2–8
|
|
46
|
+
`((?:-[0-9a-wy-z](?:-${ALPHANUM}{2,8})+)*)` +
|
|
47
|
+
// private use
|
|
48
|
+
`(?:-x(?:-${ALPHANUM}{1,8})+)?` +
|
|
49
|
+
"$",
|
|
50
|
+
"i",
|
|
51
|
+
);
|
|
52
|
+
|
|
53
|
+
/** A tag that is private use alone. */
|
|
54
|
+
const PRIVATE_USE = new RegExp(`^x(?:-${ALPHANUM}{1,8})+$`, "i");
|
|
55
|
+
|
|
56
|
+
/** RFC 5646's grandfathered tags, which the grammar does not otherwise admit. */
|
|
57
|
+
const GRANDFATHERED = new Set([
|
|
58
|
+
"en-gb-oed",
|
|
59
|
+
"i-ami",
|
|
60
|
+
"i-bnn",
|
|
61
|
+
"i-default",
|
|
62
|
+
"i-enochian",
|
|
63
|
+
"i-hak",
|
|
64
|
+
"i-klingon",
|
|
65
|
+
"i-lux",
|
|
66
|
+
"i-mingo",
|
|
67
|
+
"i-navajo",
|
|
68
|
+
"i-pwn",
|
|
69
|
+
"i-tao",
|
|
70
|
+
"i-tay",
|
|
71
|
+
"i-tsu",
|
|
72
|
+
"sgn-be-fr",
|
|
73
|
+
"sgn-be-nl",
|
|
74
|
+
"sgn-ch-de",
|
|
75
|
+
"art-lojban",
|
|
76
|
+
"cel-gaulish",
|
|
77
|
+
"no-bok",
|
|
78
|
+
"no-nyn",
|
|
79
|
+
"zh-guoyu",
|
|
80
|
+
"zh-hakka",
|
|
81
|
+
"zh-min",
|
|
82
|
+
"zh-min-nan",
|
|
83
|
+
"zh-xiang",
|
|
84
|
+
]);
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Whether a value is a BCP 47 language tag.
|
|
88
|
+
*
|
|
89
|
+
* @param {unknown} value - The value to test.
|
|
90
|
+
* @returns {boolean} Whether it is a well-formed tag with no repeated variant
|
|
91
|
+
* or extension singleton.
|
|
92
|
+
*/
|
|
93
|
+
export function isLanguageTag(value) {
|
|
94
|
+
if (typeof value !== "string" || value === "") return false;
|
|
95
|
+
const tag = value.toLowerCase();
|
|
96
|
+
if (GRANDFATHERED.has(tag) || PRIVATE_USE.test(tag)) return true;
|
|
97
|
+
const match = LANGTAG.exec(tag);
|
|
98
|
+
if (!match) return false;
|
|
99
|
+
const variants = match[1].split(SUBTAG_SEPARATOR).filter(Boolean);
|
|
100
|
+
if (new Set(variants).size !== variants.length) return false;
|
|
101
|
+
const singletons = match[2].split(SUBTAG_SEPARATOR).filter((part) => part.length === 1);
|
|
102
|
+
return new Set(singletons).size === singletons.length;
|
|
103
|
+
}
|
|
@@ -1597,7 +1597,8 @@ function listOfMaps(name, fields, describe) {
|
|
|
1597
1597
|
|
|
1598
1598
|
/**
|
|
1599
1599
|
* The six present persons of the one optional regular verb pattern, in
|
|
1600
|
-
* presentation order.
|
|
1600
|
+
* presentation order. Each ending is added to the citation form, or takes the
|
|
1601
|
+
* place of its closing `replaces`; an empty ending adds nothing.
|
|
1601
1602
|
*
|
|
1602
1603
|
* @type {readonly import("./data-keys.mjs").InnerKeySpec[]}
|
|
1603
1604
|
*/
|
|
@@ -1617,7 +1618,7 @@ export const VERB_PERSON_FIELDS = Object.freeze(
|
|
|
1617
1618
|
required: true,
|
|
1618
1619
|
allowEmpty: true,
|
|
1619
1620
|
shape: "an ending string, empty for unchanged",
|
|
1620
|
-
describe: `The ending added to the citation form
|
|
1621
|
+
describe: `The ending for ${label}, added to the citation form or in place of its closing \`replaces\`.`,
|
|
1621
1622
|
}),
|
|
1622
1623
|
),
|
|
1623
1624
|
);
|
|
@@ -1635,7 +1636,18 @@ const VERB_PATTERN_FIELDS = Object.freeze([
|
|
|
1635
1636
|
kind: "map",
|
|
1636
1637
|
shape: "a map — `{ I, you, heShe, we, youAll, they }` of ending strings",
|
|
1637
1638
|
fields: VERB_PERSON_FIELDS,
|
|
1638
|
-
describe:
|
|
1639
|
+
describe:
|
|
1640
|
+
"All six present endings, each added to the citation form or in place of its " +
|
|
1641
|
+
"closing `replaces`; excludes `invariant`.",
|
|
1642
|
+
}),
|
|
1643
|
+
Object.freeze({
|
|
1644
|
+
name: "replaces",
|
|
1645
|
+
kind: "string",
|
|
1646
|
+
shape: "the closing letters every ending takes the place of",
|
|
1647
|
+
describe:
|
|
1648
|
+
"Letters a citation form closes on, swapped for each `persons` ending: with `then`, " +
|
|
1649
|
+
"`lokathen` and `zor` give `lokazor`. A citation form closing otherwise takes the " +
|
|
1650
|
+
"ending whole. With `persons` only.",
|
|
1639
1651
|
}),
|
|
1640
1652
|
]);
|
|
1641
1653
|
|
|
@@ -1664,7 +1676,7 @@ const GRAMMAR_FIELDS = Object.freeze([
|
|
|
1664
1676
|
Object.freeze({
|
|
1665
1677
|
name: "verbs",
|
|
1666
1678
|
kind: "map",
|
|
1667
|
-
shape: "a map — `{ invariant: true }` or `{ persons: { I, you, heShe, we, youAll, they } }`",
|
|
1679
|
+
shape: "a map — `{ invariant: true }` or `{ persons: { I, you, heShe, we, youAll, they }, replaces? }`",
|
|
1668
1680
|
fields: VERB_PATTERN_FIELDS,
|
|
1669
1681
|
describe: "One optional regular present pattern, printed once at the dictionary head.",
|
|
1670
1682
|
}),
|