@heroiclands/package-build 23.12.8 → 23.13.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 +48 -0
- package/README.md +1 -1
- package/assets/preview/gitea.ini +2 -2
- package/assets/styles/content-base.css +143 -23
- package/bin/content-commands.mjs +15 -4
- package/bin/package-build-preview.mjs +2 -2
- package/docs/api.md +18 -18
- package/docs/authoring/links-and-markup.md +156 -22
- package/docs/commands.md +22 -7
- package/docs/configuration.md +1 -1
- package/docs/content-format.md +3 -3
- package/docs/diagnostics.md +42 -0
- package/docs/reference/format-details.md +99 -22
- package/docs/reference/note-types.md +8 -1
- package/docs/reference/theme-pages.md +51 -0
- package/docs/source-preview.md +1 -1
- package/engine/anchors.mjs +2 -2
- package/engine/code-fences.mjs +3 -3
- package/engine/content-blocks.mjs +3 -3
- package/engine/content-figures.mjs +5 -5
- package/engine/content-index.mjs +1 -1
- package/engine/content-scripts.mjs +55 -21
- package/engine/content-spans.mjs +2 -2
- package/engine/content-verse.mjs +750 -0
- package/engine/dictionary-rows.mjs +15 -4
- package/engine/field-reference.mjs +4 -4
- package/engine/first-use.mjs +17 -27
- package/engine/key-case-collisions.mjs +136 -0
- package/engine/language-data.mjs +203 -6
- package/engine/language-terms.mjs +74 -5
- package/engine/map-lexicon.mjs +42 -23
- package/engine/names.mjs +36 -9
- package/engine/note-matter.mjs +66 -0
- package/engine/pdf-build.mjs +1 -0
- package/engine/pdf-render.mjs +107 -18
- package/engine/read-stdin.mjs +29 -0
- package/engine/run-rows.mjs +2 -2
- package/engine/run-words.mjs +4 -111
- package/engine/site-build.mjs +4 -4
- package/engine/site-preview.mjs +14 -14
- package/engine/sql-tables.mjs +20 -3
- package/engine/web-wikilinks.mjs +1 -1
- package/engine/word-forms.mjs +349 -0
- package/engine/word-terms.mjs +9 -0
- package/hugo-theme/static/css/style.css +92 -14
- package/package.json +2 -2
- package/types/engine/code-fences.d.mts +3 -3
- package/types/engine/content-scripts.d.mts +8 -4
- package/types/engine/content-verse.d.mts +240 -0
- package/types/engine/dictionary-rows.d.mts +10 -0
- package/types/engine/field-reference.d.mts +1 -1
- package/types/engine/key-case-collisions.d.mts +28 -0
- package/types/engine/language-data.d.mts +0 -2
- package/types/engine/map-lexicon.d.mts +19 -6
- package/types/engine/names.d.mts +34 -3
- package/types/engine/note-matter.d.mts +48 -0
- package/types/engine/read-stdin.d.mts +11 -0
- package/types/engine/run-rows.d.mts +2 -2
- package/types/engine/run-words.d.mts +0 -15
- package/types/engine/sql-tables.d.mts +4 -1
- package/types/engine/web-wikilinks.d.mts +1 -1
- package/types/engine/word-forms.d.mts +82 -0
- package/engine/content-poetry.mjs +0 -240
- package/types/engine/content-poetry.d.mts +0 -58
|
@@ -143,12 +143,23 @@ const defines = (entry) => typeof entry.definition === "string" && entry.definit
|
|
|
143
143
|
export function pluralOf(word, grammar) {
|
|
144
144
|
if (parseWordClass(word.class).base !== "n") return null;
|
|
145
145
|
if (typeof word.plural === "string" && word.plural) return word.plural;
|
|
146
|
+
return pluralByRule(word.form, grammar);
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* The plural a language's `data.grammar.plural` rule gives a form: the first
|
|
151
|
+
* literal ending replacement that fits, whatever the form's class.
|
|
152
|
+
*
|
|
153
|
+
* @param {string} form - The form.
|
|
154
|
+
* @param {Record<string, any>|undefined} grammar - Its language's
|
|
155
|
+
* `data.grammar`.
|
|
156
|
+
* @returns {string|null} The plural, or `null` where no rule fits.
|
|
157
|
+
*/
|
|
158
|
+
export function pluralByRule(form, grammar) {
|
|
146
159
|
for (const rule of Array.isArray(grammar?.plural) ? grammar.plural : []) {
|
|
147
160
|
if (typeof rule?.ending !== "string" || typeof rule.becomes !== "string") continue;
|
|
148
|
-
if (
|
|
149
|
-
return (
|
|
150
|
-
(rule.ending ? word.form.slice(0, -rule.ending.length) : word.form) + rule.becomes
|
|
151
|
-
);
|
|
161
|
+
if (form.endsWith(rule.ending))
|
|
162
|
+
return (rule.ending ? form.slice(0, -rule.ending.length) : form) + rule.becomes;
|
|
152
163
|
}
|
|
153
164
|
return null;
|
|
154
165
|
}
|
|
@@ -35,7 +35,7 @@
|
|
|
35
35
|
*/
|
|
36
36
|
|
|
37
37
|
import path from "node:path";
|
|
38
|
-
import
|
|
38
|
+
import { writeMatter } from "./note-matter.mjs";
|
|
39
39
|
import { formatNoteFrontmatter } from "./note-format.mjs";
|
|
40
40
|
|
|
41
41
|
import { authoredFields, runtimeOnlyFields } from "./field-spec.mjs";
|
|
@@ -422,7 +422,7 @@ export function shortcodeFromBasename(destination) {
|
|
|
422
422
|
* {@link shortcodeFromBasename}.
|
|
423
423
|
* @param {Record<string, unknown>} [options.frontmatter] - The consumer's
|
|
424
424
|
* declared `docs.itemFields.frontmatter`.
|
|
425
|
-
* @returns {Record<string, unknown>} The envelope, ready for `
|
|
425
|
+
* @returns {Record<string, unknown>} The envelope, ready for `writeMatter`.
|
|
426
426
|
*/
|
|
427
427
|
export function itemFieldsEnvelope({ title, shortcode, frontmatter }) {
|
|
428
428
|
return deepMerge(
|
|
@@ -464,8 +464,8 @@ export function renderItemFieldsPage(body, { title, destination, contentRoot, fr
|
|
|
464
464
|
shortcode: shortcodeFromBasename(destination),
|
|
465
465
|
frontmatter,
|
|
466
466
|
});
|
|
467
|
-
const page =
|
|
468
|
-
// `
|
|
467
|
+
const page = writeMatter(body, envelope);
|
|
468
|
+
// `writeMatter` closes the frontmatter fence directly onto the
|
|
469
469
|
// body's first line; Prettier's markdown printer requires a blank line
|
|
470
470
|
// between them, so a page written without one fails a consumer's
|
|
471
471
|
// `lint:format` the moment it is committed. Insert it here rather than
|
package/engine/first-use.mjs
CHANGED
|
@@ -83,6 +83,14 @@ function readLabel(text, env = {}) {
|
|
|
83
83
|
}
|
|
84
84
|
const labelText = (text, env) => readLabel(text, env).text;
|
|
85
85
|
|
|
86
|
+
/** The kind a span's `data-type` supplies; an invalid value is refused by the span check. */
|
|
87
|
+
function spanKind(span) {
|
|
88
|
+
const value = span.attrs?.find(([key]) => key === "data-type")?.[1];
|
|
89
|
+
if (typeof value !== "string") return undefined;
|
|
90
|
+
const [type, subType] = value.split("/");
|
|
91
|
+
return { type, subType: subType ?? null };
|
|
92
|
+
}
|
|
93
|
+
|
|
86
94
|
/** Join overlapping source ranges so lookups stay logarithmic. */
|
|
87
95
|
function ranges(values) {
|
|
88
96
|
const out = [];
|
|
@@ -119,7 +127,6 @@ function prose(source, capturedNames) {
|
|
|
119
127
|
const links = [],
|
|
120
128
|
regions = [],
|
|
121
129
|
excluded = [],
|
|
122
|
-
bold = [],
|
|
123
130
|
html = [];
|
|
124
131
|
const masked = source
|
|
125
132
|
.replace(/!\[\[[^\]\n]+\]\]|\{\{[^}\n]+\}\}/g, (text, start) => {
|
|
@@ -142,7 +149,6 @@ function prose(source, capturedNames) {
|
|
|
142
149
|
excluded.push({ start, end });
|
|
143
150
|
else if (node.type === "html") html.push(node);
|
|
144
151
|
else if (["strong", "emphasis", "delete", "link", "linkReference"].includes(node.type)) {
|
|
145
|
-
if (node.type === "strong") bold.push({ start, end });
|
|
146
152
|
if (["link", "linkReference"].includes(node.type) && source[start] === "[")
|
|
147
153
|
links.push({ start, end, labelled: true });
|
|
148
154
|
for (const child of node.children ?? []) inline(child);
|
|
@@ -224,18 +230,8 @@ function prose(source, capturedNames) {
|
|
|
224
230
|
const linked = ranges(authoredLinks);
|
|
225
231
|
const marked = (capturedNames ?? scanSpans(source).names)
|
|
226
232
|
.filter((span) => eligible(span) && !contains(linked, span))
|
|
227
|
-
.map((span) => ({ ...span, label: span.visible }));
|
|
228
|
-
|
|
229
|
-
return {
|
|
230
|
-
marked,
|
|
231
|
-
links: authoredLinks,
|
|
232
|
-
bold: bold
|
|
233
|
-
.filter((span) => eligible(span) && !overlaps(explicit, span))
|
|
234
|
-
.map((span) => ({
|
|
235
|
-
...span,
|
|
236
|
-
label: labelText(source.slice(span.start, span.end), env),
|
|
237
|
-
})),
|
|
238
|
-
};
|
|
233
|
+
.map((span) => ({ ...span, label: span.visible, nameKind: spanKind(span) }));
|
|
234
|
+
return { marked, links: authoredLinks };
|
|
239
235
|
}
|
|
240
236
|
|
|
241
237
|
/**
|
|
@@ -264,7 +260,7 @@ export function createFirstUseChecker({
|
|
|
264
260
|
{ raw, bodyLine: authored.bodyLine, bodyColumn: authored.bodyColumn },
|
|
265
261
|
)
|
|
266
262
|
: undefined;
|
|
267
|
-
const { marked, links
|
|
263
|
+
const { marked, links } = prose(raw, capturedNames);
|
|
268
264
|
const events = [
|
|
269
265
|
...marked.map((span) => ({ ...span, kind: "marked" })),
|
|
270
266
|
...links.map((link) => {
|
|
@@ -286,7 +282,10 @@ export function createFirstUseChecker({
|
|
|
286
282
|
for (const event of [...events].sort(
|
|
287
283
|
(a, b) => a.label.length - b.label.length || a.label.localeCompare(b.label),
|
|
288
284
|
)) {
|
|
289
|
-
if (
|
|
285
|
+
if (
|
|
286
|
+
recognized(event.label, event.preferred, event.nameKind) ||
|
|
287
|
+
unknown.has(normalize(event.label))
|
|
288
|
+
)
|
|
290
289
|
continue;
|
|
291
290
|
const entry = {
|
|
292
291
|
key: "name:" + normalize(event.label),
|
|
@@ -309,7 +308,8 @@ export function createFirstUseChecker({
|
|
|
309
308
|
fullUsed = new Set();
|
|
310
309
|
for (const event of events.sort((a, b) => a.start - b.start)) {
|
|
311
310
|
const entry =
|
|
312
|
-
recognized(event.label, event.preferred
|
|
311
|
+
recognized(event.label, event.preferred, event.nameKind) ??
|
|
312
|
+
unknown.get(normalize(event.label));
|
|
313
313
|
if (event.kind === "marked" && entry.indexed) {
|
|
314
314
|
const destinations = (entry.candidates ?? [entry])
|
|
315
315
|
.map((target) => target.key)
|
|
@@ -338,16 +338,6 @@ export function createFirstUseChecker({
|
|
|
338
338
|
used.add(entry.key);
|
|
339
339
|
if (introduces) fullUsed.add(entry.key);
|
|
340
340
|
}
|
|
341
|
-
for (const span of bold) {
|
|
342
|
-
if (typeof span.label !== "string") continue;
|
|
343
|
-
const entry = recognized(span.label);
|
|
344
|
-
if (entry)
|
|
345
|
-
add(
|
|
346
|
-
span.start,
|
|
347
|
-
"bold-name",
|
|
348
|
-
`“${span.label}” matches an indexed name; mark or link this name`,
|
|
349
|
-
);
|
|
350
|
-
}
|
|
351
341
|
const lineStarts = [0];
|
|
352
342
|
for (let i = 0; i < raw.length; i++) if (raw[i] === "\n") lineStarts.push(i + 1);
|
|
353
343
|
return [...findings]
|
|
@@ -0,0 +1,136 @@
|
|
|
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
|
+
* Keys that differ only in case, found in the content index's records.
|
|
16
|
+
*
|
|
17
|
+
* The notes relation is read with `read_json_auto`, which infers one struct
|
|
18
|
+
* from every record and treats the names of a struct's fields without regard
|
|
19
|
+
* to case. A map whose keys an author chooses therefore cannot hold `carved`
|
|
20
|
+
* in one note and `Carved` in another: the relation refuses to load. This
|
|
21
|
+
* module names each such pair, with the note and the line of both keys, so the
|
|
22
|
+
* author renames one.
|
|
23
|
+
*
|
|
24
|
+
* @module
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
import fs from "node:fs";
|
|
28
|
+
import path from "node:path";
|
|
29
|
+
import { formatDiagnostic, positionInFrontmatter } from "./diagnostics.mjs";
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Walk a record, noting every key under its case-folded path.
|
|
33
|
+
*
|
|
34
|
+
* @param {unknown} value - The value at `trail`.
|
|
35
|
+
* @param {string} trail - The case-folded path to it.
|
|
36
|
+
* @param {object} record - The record being walked.
|
|
37
|
+
* @param {Map<string, Map<string, Set<object>>>} groups - Case-folded path to
|
|
38
|
+
* each spelling found there and the records that use it.
|
|
39
|
+
*/
|
|
40
|
+
function walk(value, trail, record, groups) {
|
|
41
|
+
if (Array.isArray(value)) {
|
|
42
|
+
for (const entry of value) walk(entry, trail, record, groups);
|
|
43
|
+
return;
|
|
44
|
+
}
|
|
45
|
+
if (value === null || typeof value !== "object") return;
|
|
46
|
+
for (const [key, child] of Object.entries(value)) {
|
|
47
|
+
const at = `${trail}.${key.toLowerCase()}`;
|
|
48
|
+
let spellings = groups.get(at);
|
|
49
|
+
if (!spellings) groups.set(at, (spellings = new Map()));
|
|
50
|
+
let users = spellings.get(key);
|
|
51
|
+
if (!users) spellings.set(key, (users = new Set()));
|
|
52
|
+
users.add(record);
|
|
53
|
+
walk(child, at, record, groups);
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Every key that two records, or one record, spell with different case at the
|
|
59
|
+
* same path.
|
|
60
|
+
*
|
|
61
|
+
* @param {object[]} records - Content-index records.
|
|
62
|
+
* @returns {Array<{key: string, other: string, record: object, otherRecord: object}>}
|
|
63
|
+
* One entry per spelling that collides with another, naming the record that
|
|
64
|
+
* uses it and a record that uses the other spelling.
|
|
65
|
+
*/
|
|
66
|
+
export function findKeyCaseCollisions(records) {
|
|
67
|
+
const groups = new Map();
|
|
68
|
+
for (const record of records) walk(record, "", record, groups);
|
|
69
|
+
const found = [];
|
|
70
|
+
for (const spellings of groups.values()) {
|
|
71
|
+
if (spellings.size < 2) continue;
|
|
72
|
+
const keys = [...spellings.keys()].sort();
|
|
73
|
+
const [first, ...rest] = keys;
|
|
74
|
+
for (const key of keys) {
|
|
75
|
+
const other = key === first ? rest[0] : first;
|
|
76
|
+
found.push({
|
|
77
|
+
key,
|
|
78
|
+
other,
|
|
79
|
+
record: [...spellings.get(key)][0],
|
|
80
|
+
otherRecord: [...spellings.get(other)][0],
|
|
81
|
+
});
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
return found;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* The note a record came from and where a key sits in it.
|
|
89
|
+
*
|
|
90
|
+
* @param {object} record - A content-index record.
|
|
91
|
+
* @param {string} key - The key to find.
|
|
92
|
+
* @param {string|undefined} contentBase - The directory `record.file.path` is
|
|
93
|
+
* relative to.
|
|
94
|
+
* @returns {{file: string, line?: number, column?: number}} The path relative
|
|
95
|
+
* to the working directory, with the position where the key is found.
|
|
96
|
+
*/
|
|
97
|
+
function noteLocation(record, key, contentBase) {
|
|
98
|
+
const relative = record.file?.path ?? "";
|
|
99
|
+
const absolute = contentBase ? path.join(contentBase, relative) : undefined;
|
|
100
|
+
const file = absolute ? path.relative(process.cwd(), absolute) : relative;
|
|
101
|
+
let position = {};
|
|
102
|
+
if (absolute) {
|
|
103
|
+
try {
|
|
104
|
+
position = positionInFrontmatter(fs.readFileSync(absolute, "utf8"), key, undefined);
|
|
105
|
+
} catch {
|
|
106
|
+
position = {};
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
return { file, ...position };
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* The diagnostics for every case collision in the records, one per spelling,
|
|
114
|
+
* each naming the note and line of the spelling it collides with.
|
|
115
|
+
*
|
|
116
|
+
* @param {object[]} records - Content-index records.
|
|
117
|
+
* @param {object} [options]
|
|
118
|
+
* @param {string} [options.contentBase] - The directory the records' paths are
|
|
119
|
+
* relative to.
|
|
120
|
+
* @returns {string[]} Formatted `file:line:column: error: message` lines.
|
|
121
|
+
*/
|
|
122
|
+
export function keyCaseCollisionDiagnostics(records, { contentBase } = {}) {
|
|
123
|
+
return findKeyCaseCollisions(records).map(({ key, other, record, otherRecord }) => {
|
|
124
|
+
const here = noteLocation(record, key, contentBase);
|
|
125
|
+
const there = noteLocation(otherRecord, other, contentBase);
|
|
126
|
+
const where = [there.file, there.line, there.column].filter((part) => part !== undefined);
|
|
127
|
+
return formatDiagnostic({
|
|
128
|
+
...here,
|
|
129
|
+
severity: "error",
|
|
130
|
+
message:
|
|
131
|
+
`\`${key}\` and \`${other}\` name one key: the content index reads keys ` +
|
|
132
|
+
`without regard to case, so \`${other}\` in ${where.join(":")} must be spelled ` +
|
|
133
|
+
`the same way`,
|
|
134
|
+
});
|
|
135
|
+
});
|
|
136
|
+
}
|
package/engine/language-data.mjs
CHANGED
|
@@ -39,7 +39,8 @@
|
|
|
39
39
|
* and NFC (see {@link foreignRowFindings});
|
|
40
40
|
* - a sound-change set's name stands once in the note, a step's label once
|
|
41
41
|
* in its set, and its `from` and `to` name language notes;
|
|
42
|
-
* - a frame's classes
|
|
42
|
+
* - a frame's classes, and the classes an affix's `attachesTo` names, are
|
|
43
|
+
* bases of the master word classes, each named once;
|
|
43
44
|
* - a calling-name closing states a `form` or `doubled: true`, not both;
|
|
44
45
|
* - a form stands once in `data.older`;
|
|
45
46
|
* - the mutation `data.negation` names is one of `data.sounds.mutations`;
|
|
@@ -69,6 +70,7 @@ import {
|
|
|
69
70
|
} from "./language-terms.mjs";
|
|
70
71
|
import { LANGUAGE_SUBTYPE } from "./word-terms.mjs";
|
|
71
72
|
import { WORD_CLASS_BASES } from "./word-classes.mjs";
|
|
73
|
+
import { nearest } from "./near-miss.mjs";
|
|
72
74
|
|
|
73
75
|
/** The bases a frame's `class` names. */
|
|
74
76
|
const CLASS_BASES = new Set(WORD_CLASS_BASES.map((item) => item.token));
|
|
@@ -117,9 +119,6 @@ export function tongueDeclared(tag, declared) {
|
|
|
117
119
|
/** The tag families whose rows name a name of another tongue's register. */
|
|
118
120
|
const FOREIGN_FAMILIES = new Set(["other", "exonym"]);
|
|
119
121
|
|
|
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
122
|
/** A name as a foreign row is compared: NFC and lowercase. */
|
|
124
123
|
const foldName = (name) => name.normalize("NFC").toLowerCase();
|
|
125
124
|
|
|
@@ -190,7 +189,7 @@ function foreignRowFindings(rows, index, finding) {
|
|
|
190
189
|
findings.push(
|
|
191
190
|
finding(
|
|
192
191
|
["data", "register", i, "tongue"],
|
|
193
|
-
|
|
192
|
+
"error",
|
|
194
193
|
languages.length ?
|
|
195
194
|
`the tongue \`${tongue}\` in \`${row.tongue}\` names ${languages.length} ` +
|
|
196
195
|
`language notes, ${languages.join(" and ")}; the \`data.tongues\` entries ` +
|
|
@@ -217,7 +216,7 @@ function foreignRowFindings(rows, index, finding) {
|
|
|
217
216
|
findings.push(
|
|
218
217
|
finding(
|
|
219
218
|
["data", "register", i, "name"],
|
|
220
|
-
|
|
219
|
+
"error",
|
|
221
220
|
`\`${row.name}\` is registered as \`${row.tongue}\`, but the register of ` +
|
|
222
221
|
`${title} (${fm.type}-${fm.shortcode}) lists no \`${tongue}\` name ` +
|
|
223
222
|
`\`${row.name}\`; correct the spelling, or register the name there first`,
|
|
@@ -241,6 +240,198 @@ function formEntries(forms) {
|
|
|
241
240
|
return [];
|
|
242
241
|
}
|
|
243
242
|
|
|
243
|
+
/** A suggestion clause for a near miss, or nothing. */
|
|
244
|
+
const suggestion = (value, candidates) => {
|
|
245
|
+
const near = nearest(value, candidates);
|
|
246
|
+
return near ? `; did you mean \`${near}\`?` : "";
|
|
247
|
+
};
|
|
248
|
+
|
|
249
|
+
/** A suggestion clause for a word class written out or mistyped, or nothing. */
|
|
250
|
+
const classSuggestion = (value, bases) => {
|
|
251
|
+
const spelled = WORD_CLASS_BASES.find((base) => base.expansion === value.trim().toLowerCase());
|
|
252
|
+
return spelled ? `; did you mean \`${spelled.token}\`?` : suggestion(value, bases);
|
|
253
|
+
};
|
|
254
|
+
|
|
255
|
+
/**
|
|
256
|
+
* Each entry of a list of strings that repeats an earlier one, as a finding.
|
|
257
|
+
*
|
|
258
|
+
* @param {unknown[]} list - The list.
|
|
259
|
+
* @param {Array<string|number>} path - Where it sits.
|
|
260
|
+
* @param {string} verb - What the entry does: `names` or `states`.
|
|
261
|
+
* @param {Function} finding - The note's finding builder.
|
|
262
|
+
* @returns {object[]} The findings.
|
|
263
|
+
*/
|
|
264
|
+
function repeats(list, path, verb, finding) {
|
|
265
|
+
const seen = new Set();
|
|
266
|
+
const out = [];
|
|
267
|
+
list.forEach((item, i) => {
|
|
268
|
+
if (typeof item !== "string") return;
|
|
269
|
+
const key = item.trim();
|
|
270
|
+
if (seen.has(key))
|
|
271
|
+
out.push(
|
|
272
|
+
finding(
|
|
273
|
+
[...path, i],
|
|
274
|
+
"error",
|
|
275
|
+
`\`${label([...path, i])}\` ${verb} \`${key}\` a second time`,
|
|
276
|
+
),
|
|
277
|
+
);
|
|
278
|
+
seen.add(key);
|
|
279
|
+
});
|
|
280
|
+
return out;
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
/**
|
|
284
|
+
* Check `data.grammar.agreement` against what it means: classes of the master
|
|
285
|
+
* list, each once, and at least two distinct endings to agree among.
|
|
286
|
+
*
|
|
287
|
+
* @param {unknown} agreement - The authored value.
|
|
288
|
+
* @param {Function} finding - The note's finding builder.
|
|
289
|
+
* @returns {object[]} The findings.
|
|
290
|
+
*/
|
|
291
|
+
function checkAgreement(agreement, finding) {
|
|
292
|
+
if (!isMap(agreement)) return [];
|
|
293
|
+
const out = [];
|
|
294
|
+
const at = ["data", "grammar", "agreement"];
|
|
295
|
+
const bases = WORD_CLASS_BASES.map((base) => base.token);
|
|
296
|
+
if (Array.isArray(agreement.classes)) {
|
|
297
|
+
if (!agreement.classes.length)
|
|
298
|
+
out.push(
|
|
299
|
+
finding(
|
|
300
|
+
[...at, "classes"],
|
|
301
|
+
"error",
|
|
302
|
+
"`data.grammar.agreement.classes` must name at least one word class",
|
|
303
|
+
),
|
|
304
|
+
);
|
|
305
|
+
agreement.classes.forEach((item, i) => {
|
|
306
|
+
if (typeof item !== "string" || bases.includes(item.trim())) return;
|
|
307
|
+
out.push(
|
|
308
|
+
finding(
|
|
309
|
+
[...at, "classes", i],
|
|
310
|
+
"error",
|
|
311
|
+
`\`${label([...at, "classes", i])}\` is \`${item}\`, which is not a word class` +
|
|
312
|
+
classSuggestion(item, bases),
|
|
313
|
+
),
|
|
314
|
+
);
|
|
315
|
+
});
|
|
316
|
+
out.push(...repeats(agreement.classes, [...at, "classes"], "names", finding));
|
|
317
|
+
}
|
|
318
|
+
if (Array.isArray(agreement.endings)) {
|
|
319
|
+
const distinct = new Set(
|
|
320
|
+
agreement.endings.filter((item) => typeof item === "string").map((item) => item.trim()),
|
|
321
|
+
);
|
|
322
|
+
if (distinct.size < 2)
|
|
323
|
+
out.push(
|
|
324
|
+
finding(
|
|
325
|
+
[...at, "endings"],
|
|
326
|
+
"error",
|
|
327
|
+
"`data.grammar.agreement.endings` must state at least two endings for a word to agree among",
|
|
328
|
+
),
|
|
329
|
+
);
|
|
330
|
+
out.push(...repeats(agreement.endings, [...at, "endings"], "states", finding));
|
|
331
|
+
}
|
|
332
|
+
return out;
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
/**
|
|
336
|
+
* Check each `data.affixes[].attachesTo` against what it means: bases of the
|
|
337
|
+
* master word classes, each once, and at least one.
|
|
338
|
+
*
|
|
339
|
+
* @param {unknown} affixes - The note's `data.affixes`.
|
|
340
|
+
* @param {Function} finding - The note's finding builder.
|
|
341
|
+
* @returns {object[]} The findings.
|
|
342
|
+
*/
|
|
343
|
+
function checkAffixClasses(affixes, finding) {
|
|
344
|
+
const out = [];
|
|
345
|
+
const bases = WORD_CLASS_BASES.map((base) => base.token);
|
|
346
|
+
listOf(affixes).forEach((affix, i) => {
|
|
347
|
+
if (!isMap(affix) || !Array.isArray(affix.attachesTo)) return;
|
|
348
|
+
const at = ["data", "affixes", i, "attachesTo"];
|
|
349
|
+
if (!affix.attachesTo.length)
|
|
350
|
+
out.push(finding(at, "error", `\`${label(at)}\` must name at least one word class`));
|
|
351
|
+
affix.attachesTo.forEach((item, j) => {
|
|
352
|
+
if (typeof item !== "string" || bases.includes(item.trim())) return;
|
|
353
|
+
out.push(
|
|
354
|
+
finding(
|
|
355
|
+
[...at, j],
|
|
356
|
+
"error",
|
|
357
|
+
`\`${label([...at, j])}\` is \`${item}\`, which is not a word class` +
|
|
358
|
+
classSuggestion(item, bases),
|
|
359
|
+
),
|
|
360
|
+
);
|
|
361
|
+
});
|
|
362
|
+
out.push(...repeats(affix.attachesTo, at, "names", finding));
|
|
363
|
+
});
|
|
364
|
+
return out;
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
/**
|
|
368
|
+
* Check `data.grammar.participles` against what it means: root endings with
|
|
369
|
+
* letters, and affixes that are suffixes the language's `data.affixes` states.
|
|
370
|
+
*
|
|
371
|
+
* @param {unknown} participles - The authored value.
|
|
372
|
+
* @param {unknown} affixes - The note's `data.affixes`.
|
|
373
|
+
* @param {Function} finding - The note's finding builder.
|
|
374
|
+
* @returns {object[]} The findings.
|
|
375
|
+
*/
|
|
376
|
+
function checkParticiples(participles, affixes, finding) {
|
|
377
|
+
if (!isMap(participles)) return [];
|
|
378
|
+
const out = [];
|
|
379
|
+
const at = ["data", "grammar", "participles"];
|
|
380
|
+
if (Array.isArray(participles.root)) {
|
|
381
|
+
if (!participles.root.length)
|
|
382
|
+
out.push(
|
|
383
|
+
finding(
|
|
384
|
+
[...at, "root"],
|
|
385
|
+
"error",
|
|
386
|
+
"`data.grammar.participles.root` must name at least one ending a verb drops to give its root",
|
|
387
|
+
),
|
|
388
|
+
);
|
|
389
|
+
participles.root.forEach((item, i) => {
|
|
390
|
+
if (typeof item === "string" && !item.trim())
|
|
391
|
+
out.push(
|
|
392
|
+
finding(
|
|
393
|
+
[...at, "root", i],
|
|
394
|
+
"error",
|
|
395
|
+
`\`${label([...at, "root", i])}\` must name the letters a verb drops to give its root`,
|
|
396
|
+
),
|
|
397
|
+
);
|
|
398
|
+
});
|
|
399
|
+
out.push(...repeats(participles.root, [...at, "root"], "states", finding));
|
|
400
|
+
}
|
|
401
|
+
if (Array.isArray(participles.affixes)) {
|
|
402
|
+
if (!participles.affixes.length)
|
|
403
|
+
out.push(
|
|
404
|
+
finding(
|
|
405
|
+
[...at, "affixes"],
|
|
406
|
+
"error",
|
|
407
|
+
"`data.grammar.participles.affixes` must name at least one suffix of `data.affixes`",
|
|
408
|
+
),
|
|
409
|
+
);
|
|
410
|
+
const stated = listOf(affixes)
|
|
411
|
+
.filter((affix) => isMap(affix) && typeof affix.form === "string")
|
|
412
|
+
.map((affix) => affix.form.trim());
|
|
413
|
+
const suffixes = stated.filter((form) => /^[-–]/u.test(form) && !/[-–]$/u.test(form));
|
|
414
|
+
participles.affixes.forEach((item, i) => {
|
|
415
|
+
if (typeof item !== "string") return;
|
|
416
|
+
const form = item.trim();
|
|
417
|
+
const where = `\`${label([...at, "affixes", i])}\` is \`${item}\``;
|
|
418
|
+
if (suffixes.includes(form)) return;
|
|
419
|
+
out.push(
|
|
420
|
+
finding(
|
|
421
|
+
[...at, "affixes", i],
|
|
422
|
+
"error",
|
|
423
|
+
stated.includes(form) ?
|
|
424
|
+
`${where}, a ${/^[-–]/u.test(form) ? "infix" : "prefix"}; a participle is a suffix on the root`
|
|
425
|
+
: `${where}, which is not a suffix of \`data.affixes\`` +
|
|
426
|
+
suggestion(form, suffixes),
|
|
427
|
+
),
|
|
428
|
+
);
|
|
429
|
+
});
|
|
430
|
+
out.push(...repeats(participles.affixes, [...at, "affixes"], "names", finding));
|
|
431
|
+
}
|
|
432
|
+
return out;
|
|
433
|
+
}
|
|
434
|
+
|
|
244
435
|
/**
|
|
245
436
|
* Check a `skill` note's language data against what it means.
|
|
246
437
|
*
|
|
@@ -345,6 +536,12 @@ export function checkLanguageData(note, { index, types } = {}) {
|
|
|
345
536
|
);
|
|
346
537
|
}
|
|
347
538
|
|
|
539
|
+
findings.push(
|
|
540
|
+
...checkAgreement(grammar.agreement, finding),
|
|
541
|
+
...checkParticiples(grammar.participles, data.affixes, finding),
|
|
542
|
+
...checkAffixClasses(data.affixes, finding),
|
|
543
|
+
);
|
|
544
|
+
|
|
348
545
|
/**
|
|
349
546
|
* The note an Address names, or a finding where it names none.
|
|
350
547
|
*
|
|
@@ -1651,7 +1651,56 @@ const VERB_PATTERN_FIELDS = Object.freeze([
|
|
|
1651
1651
|
}),
|
|
1652
1652
|
]);
|
|
1653
1653
|
|
|
1654
|
-
/** The
|
|
1654
|
+
/** The two keys of a language's optional agreement rule. */
|
|
1655
|
+
const AGREEMENT_FIELDS = Object.freeze([
|
|
1656
|
+
Object.freeze({
|
|
1657
|
+
name: "classes",
|
|
1658
|
+
kind: "list",
|
|
1659
|
+
required: true,
|
|
1660
|
+
shape: "a list of word class bases, such as `[adj, pron]`",
|
|
1661
|
+
entries: Object.freeze({ kind: "string", shape: "a word class base" }),
|
|
1662
|
+
describe: "The word classes that agree, each a base of the master word classes.",
|
|
1663
|
+
}),
|
|
1664
|
+
Object.freeze({
|
|
1665
|
+
name: "endings",
|
|
1666
|
+
kind: "list",
|
|
1667
|
+
required: true,
|
|
1668
|
+
shape: "a list of at least two literal endings, such as `[os, a, on]`",
|
|
1669
|
+
entries: Object.freeze({ kind: "string", shape: "a literal ending string" }),
|
|
1670
|
+
describe:
|
|
1671
|
+
"The singular endings an agreeing word swaps among, one for each gender; the " +
|
|
1672
|
+
"`plural` rule gives each one's plural. A word closing on none does not agree.",
|
|
1673
|
+
}),
|
|
1674
|
+
]);
|
|
1675
|
+
|
|
1676
|
+
/** The two keys of a language's optional participle rule. */
|
|
1677
|
+
const PARTICIPLE_FIELDS = Object.freeze([
|
|
1678
|
+
Object.freeze({
|
|
1679
|
+
name: "root",
|
|
1680
|
+
kind: "list",
|
|
1681
|
+
required: true,
|
|
1682
|
+
shape: "a list of closing letters, such as `[athen, ethen]`",
|
|
1683
|
+
entries: Object.freeze({ kind: "string", shape: "the closing letters of a citation form" }),
|
|
1684
|
+
describe:
|
|
1685
|
+
"The infinitive endings a verb's citation form drops to give its root, the longest " +
|
|
1686
|
+
"that matches first. A verb closing on none has no participle.",
|
|
1687
|
+
}),
|
|
1688
|
+
Object.freeze({
|
|
1689
|
+
name: "affixes",
|
|
1690
|
+
kind: "list",
|
|
1691
|
+
required: true,
|
|
1692
|
+
shape: "a list of suffixes of `data.affixes`, such as `[-andar, -thos]`",
|
|
1693
|
+
entries: Object.freeze({
|
|
1694
|
+
kind: "string",
|
|
1695
|
+
shape: "the `form` of a suffix of `data.affixes`",
|
|
1696
|
+
}),
|
|
1697
|
+
describe:
|
|
1698
|
+
"The participle suffixes, each attaching to the root as its own `replaces` says: " +
|
|
1699
|
+
"`vowel` or letters take the place of the root's close, and none adds it whole.",
|
|
1700
|
+
}),
|
|
1701
|
+
]);
|
|
1702
|
+
|
|
1703
|
+
/** The optional noun, verb, agreement and participle patterns of one language. */
|
|
1655
1704
|
const GRAMMAR_FIELDS = Object.freeze([
|
|
1656
1705
|
innerList(
|
|
1657
1706
|
"plural",
|
|
@@ -1680,6 +1729,26 @@ const GRAMMAR_FIELDS = Object.freeze([
|
|
|
1680
1729
|
fields: VERB_PATTERN_FIELDS,
|
|
1681
1730
|
describe: "One optional regular present pattern, printed once at the dictionary head.",
|
|
1682
1731
|
}),
|
|
1732
|
+
Object.freeze({
|
|
1733
|
+
name: "agreement",
|
|
1734
|
+
kind: "map",
|
|
1735
|
+
shape: "a map — `{ classes, endings }`",
|
|
1736
|
+
fields: AGREEMENT_FIELDS,
|
|
1737
|
+
describe:
|
|
1738
|
+
"How words of the named classes agree with a noun: a word closing on one of the " +
|
|
1739
|
+
"`endings` also takes each other one, and each form takes the `plural` rule. Read " +
|
|
1740
|
+
"by the mapped-run word check.",
|
|
1741
|
+
}),
|
|
1742
|
+
Object.freeze({
|
|
1743
|
+
name: "participles",
|
|
1744
|
+
kind: "map",
|
|
1745
|
+
shape: "a map — `{ root, affixes }`",
|
|
1746
|
+
fields: PARTICIPLE_FIELDS,
|
|
1747
|
+
describe:
|
|
1748
|
+
"Participles on the verb root: a verb closing on a `root` ending drops it, and each " +
|
|
1749
|
+
"named suffix attaches to what is left; a participle closing on an `agreement` " +
|
|
1750
|
+
"ending agrees. Read by the mapped-run word check.",
|
|
1751
|
+
}),
|
|
1683
1752
|
]);
|
|
1684
1753
|
|
|
1685
1754
|
/**
|
|
@@ -1693,12 +1762,12 @@ export const LANGUAGE_DATA_FIELDS = Object.freeze([
|
|
|
1693
1762
|
Object.freeze({
|
|
1694
1763
|
name: "grammar",
|
|
1695
1764
|
kind: "map",
|
|
1696
|
-
shape: "a map — `{ plural?, verbs? }`",
|
|
1765
|
+
shape: "a map — `{ plural?, verbs?, agreement?, participles? }`",
|
|
1697
1766
|
fields: GRAMMAR_FIELDS,
|
|
1698
1767
|
describe:
|
|
1699
|
-
"Optional noun plurals
|
|
1700
|
-
"
|
|
1701
|
-
"skill note only.",
|
|
1768
|
+
"Optional noun plurals, one verb pattern, agreement and participles, for dictionary " +
|
|
1769
|
+
"presentation and the mapped-run word check. Only the shape of supplied keys is " +
|
|
1770
|
+
"checked; no word must follow or state a rule. A language skill note only.",
|
|
1702
1771
|
}),
|
|
1703
1772
|
listOfMaps(
|
|
1704
1773
|
"tongues",
|