@heroiclands/package-build 23.5.0 → 23.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +33 -0
- package/bin/content-commands.mjs +23 -5
- package/bin/package-build.mjs +1 -74
- package/docs/api.md +9 -0
- package/docs/authoring/links-and-markup.md +77 -12
- package/docs/commands.md +4 -48
- package/docs/content-format.md +16 -0
- package/docs/diagnostics.md +1 -2
- package/docs/guides/module-project.md +1 -1
- package/docs/guides/reference-pages.md +89 -0
- package/docs/guides/release-and-deploy.md +3 -3
- package/docs/project-setup.md +16 -59
- package/docs/reference/format-details.md +533 -84
- package/docs/reference/note-types.md +191 -149
- package/engine/base-compiler.mjs +21 -0
- package/engine/cdn-publish.mjs +30 -5
- package/engine/content-format.yaml +9 -0
- package/engine/content-index.mjs +19 -5
- package/engine/culture-reach.mjs +330 -0
- package/engine/dictionary-layout.mjs +340 -0
- package/engine/dictionary-rows.mjs +608 -0
- package/engine/glossary-entries.mjs +182 -0
- package/engine/glossary-language.mjs +99 -0
- package/engine/index.mjs +1 -0
- package/engine/infobox.mjs +8 -0
- package/engine/language-words.mjs +360 -0
- package/engine/literature-notes.mjs +10 -8
- package/engine/note-addresses.mjs +25 -0
- package/engine/note-vocabulary.mjs +161 -1
- package/engine/path-charset.mjs +120 -0
- package/engine/project-init.mjs +4 -29
- package/engine/sql-tables.mjs +311 -21
- package/engine/word-classes.mjs +167 -0
- package/engine/word-rows.mjs +233 -0
- package/engine/word-terms.mjs +306 -0
- package/package.json +2 -4
- package/types/engine/cdn-publish.d.mts +26 -25
- package/types/engine/content-index.d.mts +15 -5
- package/types/engine/culture-reach.d.mts +66 -0
- package/types/engine/dictionary-layout.d.mts +69 -0
- package/types/engine/dictionary-rows.d.mts +39 -0
- package/types/engine/glossary-entries.d.mts +12 -0
- package/types/engine/glossary-language.d.mts +42 -0
- package/types/engine/index.d.mts +1 -0
- package/types/engine/language-words.d.mts +33 -0
- package/types/engine/literature-notes.d.mts +2 -2
- package/types/engine/note-addresses.d.mts +17 -0
- package/types/engine/path-charset.d.mts +38 -0
- package/types/engine/sql-tables.d.mts +7 -2
- package/types/engine/word-classes.d.mts +87 -0
- package/types/engine/word-rows.d.mts +64 -0
- package/types/engine/word-terms.d.mts +68 -0
- package/labels.mjs +0 -191
- package/types/labels.d.mts +0 -40
|
@@ -0,0 +1,233 @@
|
|
|
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 rows of the SQL `words` and `glossary` relations, read from index
|
|
16
|
+
* records whose Addresses are already strings.
|
|
17
|
+
*
|
|
18
|
+
* `words` is one row per `data.words` entry of every language note, keyed by
|
|
19
|
+
* that note, and `glossary` one row per `data.glossary` entry of every culture
|
|
20
|
+
* and affiliation, with the language the entry resolves to
|
|
21
|
+
* ({@link module:engine/glossary-language}). **Every row has every column**:
|
|
22
|
+
* the keys come from {@link module:engine/word-terms}, so a key no entry in a
|
|
23
|
+
* corpus writes is still a column, `NULL` in every row, and a fence naming it
|
|
24
|
+
* binds.
|
|
25
|
+
*
|
|
26
|
+
* A word row holds the words its own note states, and nothing is copied for a
|
|
27
|
+
* daughter language. `language_lineage` is one row per language and each of
|
|
28
|
+
* its ancestors through `data.family`, itself included at depth `0`, so a
|
|
29
|
+
* language's words with its inherited ones are a join of the two.
|
|
30
|
+
*
|
|
31
|
+
* @module
|
|
32
|
+
*/
|
|
33
|
+
|
|
34
|
+
import { renderAddress } from "./address.mjs";
|
|
35
|
+
import { glossaryLanguage } from "./glossary-language.mjs";
|
|
36
|
+
import { GLOSSARY_ENTRY_KEYS, LANGUAGE_SUBTYPE, WORD_KEYS } from "./word-terms.mjs";
|
|
37
|
+
|
|
38
|
+
/** The columns a word row carries before the word's own keys. */
|
|
39
|
+
const WORD_IDENTITY_COLUMNS = Object.freeze(["language", "position"]);
|
|
40
|
+
|
|
41
|
+
/** The columns a word row derives after the word's own keys. */
|
|
42
|
+
const WORD_DERIVED_COLUMNS = Object.freeze(["built_text", "built_parts"]);
|
|
43
|
+
|
|
44
|
+
/** Every column of the `words` relation, in order. */
|
|
45
|
+
export const WORD_COLUMNS = Object.freeze([
|
|
46
|
+
...WORD_IDENTITY_COLUMNS,
|
|
47
|
+
...WORD_KEYS,
|
|
48
|
+
...WORD_DERIVED_COLUMNS,
|
|
49
|
+
]);
|
|
50
|
+
|
|
51
|
+
/** Every column of the `glossary` relation, in order. */
|
|
52
|
+
export const GLOSSARY_COLUMNS = Object.freeze(["note", "position", ...GLOSSARY_ENTRY_KEYS]);
|
|
53
|
+
|
|
54
|
+
/** Every column of the `language_lineage` relation, in order. */
|
|
55
|
+
export const LINEAGE_COLUMNS = Object.freeze(["language", "ancestor", "depth"]);
|
|
56
|
+
|
|
57
|
+
/** The columns of any of these relations that hold a whole number. */
|
|
58
|
+
export const ROW_INTEGER_COLUMNS = Object.freeze(["position", "depth"]);
|
|
59
|
+
|
|
60
|
+
/** Whether a value is a plain map. */
|
|
61
|
+
const isMap = (value) => Boolean(value) && typeof value === "object" && !Array.isArray(value);
|
|
62
|
+
|
|
63
|
+
/** A piece's form with its joining hyphens taken off, for a second match. */
|
|
64
|
+
const bare = (form) => String(form).replace(/^-+|-+$/g, "");
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* A lookup from a built piece to its gloss, over one language's words (stems
|
|
68
|
+
* included) and affixes.
|
|
69
|
+
*
|
|
70
|
+
* A piece matches a word or an affix written exactly as it is, and failing
|
|
71
|
+
* that one written with the same letters and different joining hyphens, so
|
|
72
|
+
* `wulf-` finds the word `wulf`. A word's own gloss wins over an affix's.
|
|
73
|
+
*
|
|
74
|
+
* @param {object} data - The language note's `data`.
|
|
75
|
+
* @returns {(piece: string) => string|null} The gloss, or `null` where none resolves.
|
|
76
|
+
*/
|
|
77
|
+
export function pieceGlosses(data) {
|
|
78
|
+
const exact = new Map();
|
|
79
|
+
const loose = new Map();
|
|
80
|
+
const take = (entries) => {
|
|
81
|
+
for (const entry of Array.isArray(entries) ? entries : []) {
|
|
82
|
+
if (!isMap(entry) || typeof entry.form !== "string" || typeof entry.gloss !== "string")
|
|
83
|
+
continue;
|
|
84
|
+
if (!exact.has(entry.form)) exact.set(entry.form, entry.gloss);
|
|
85
|
+
if (!loose.has(bare(entry.form))) loose.set(bare(entry.form), entry.gloss);
|
|
86
|
+
}
|
|
87
|
+
};
|
|
88
|
+
take(data?.words);
|
|
89
|
+
take(data?.affixes);
|
|
90
|
+
return (piece) => exact.get(piece) ?? loose.get(bare(piece)) ?? null;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Whether a record is a language note's.
|
|
95
|
+
*
|
|
96
|
+
* @param {Record<string, any>} record - An index record.
|
|
97
|
+
* @returns {boolean}
|
|
98
|
+
*/
|
|
99
|
+
export function isLanguageRecord(record) {
|
|
100
|
+
return record?.type === "skill" && record?.subType === LANGUAGE_SUBTYPE;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* A record's Address as a reference to it is written: in the `note` system,
|
|
105
|
+
* so it compares equal to the Addresses other notes state.
|
|
106
|
+
*
|
|
107
|
+
* @param {Record<string, any>} record - An index record.
|
|
108
|
+
* @returns {string|null} The Address, or `null` for a record naming no note.
|
|
109
|
+
*/
|
|
110
|
+
function noteAddressOf(record) {
|
|
111
|
+
if (!record?.package || !record.type || !record.shortcode) return null;
|
|
112
|
+
return renderAddress({
|
|
113
|
+
package: String(record.package),
|
|
114
|
+
system: "note",
|
|
115
|
+
type: String(record.type),
|
|
116
|
+
shortcode: String(record.shortcode),
|
|
117
|
+
});
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* The `words` rows of a set of index records.
|
|
122
|
+
*
|
|
123
|
+
* A row's `language` is the note that states it.
|
|
124
|
+
*
|
|
125
|
+
* @param {Iterable<Record<string, any>>} records - Index records, every
|
|
126
|
+
* Address a string, already narrowed to the audience being published to.
|
|
127
|
+
* @returns {object[]} One row per word, in record then entry order.
|
|
128
|
+
*/
|
|
129
|
+
export function wordRows(records) {
|
|
130
|
+
const rows = [];
|
|
131
|
+
for (const record of records) {
|
|
132
|
+
if (!isLanguageRecord(record)) continue;
|
|
133
|
+
const words = record.data?.words;
|
|
134
|
+
if (!Array.isArray(words) || !words.length) continue;
|
|
135
|
+
const language = noteAddressOf(record);
|
|
136
|
+
if (!language) continue;
|
|
137
|
+
const glossOf = pieceGlosses(record.data);
|
|
138
|
+
words.forEach((entry, position) => {
|
|
139
|
+
if (!isMap(entry)) return;
|
|
140
|
+
const row = { language, position };
|
|
141
|
+
for (const key of WORD_KEYS) row[key] = entry[key] ?? null;
|
|
142
|
+
if (row.name_sort === null) row.name_sort = row.form;
|
|
143
|
+
const built = Array.isArray(entry.built) ? entry.built.map(String) : [];
|
|
144
|
+
row.built_text = built.length ? built.join(" + ") : null;
|
|
145
|
+
row.built_parts =
|
|
146
|
+
built.length ? built.map((form) => ({ form, gloss: glossOf(form) })) : null;
|
|
147
|
+
rows.push(row);
|
|
148
|
+
});
|
|
149
|
+
}
|
|
150
|
+
return rows;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* The `glossary` rows of a set of index records: one per entry of a culture's
|
|
155
|
+
* or an affiliation's `data.glossary`, with `name_sort` the name and `common`
|
|
156
|
+
* `false` where the entry leaves them unstated, and `language` the language
|
|
157
|
+
* the entry resolves to, or `NULL` where it resolves to none.
|
|
158
|
+
*
|
|
159
|
+
* @param {Iterable<Record<string, any>>} records - Index records, every
|
|
160
|
+
* Address a string, already narrowed to the audience being published to.
|
|
161
|
+
* @param {Iterable<Record<string, any>>} [all] - Every record of the package,
|
|
162
|
+
* for resolving a language through a note the audience does not see.
|
|
163
|
+
* @returns {object[]} One row per entry, in record then entry order.
|
|
164
|
+
*/
|
|
165
|
+
export function glossaryRows(records, all = records) {
|
|
166
|
+
const byAddress = new Map();
|
|
167
|
+
for (const record of all) {
|
|
168
|
+
const key = noteAddressOf(record);
|
|
169
|
+
if (key && !byAddress.has(key)) byAddress.set(key, record);
|
|
170
|
+
}
|
|
171
|
+
const reader = {
|
|
172
|
+
keyOf: (value) => (typeof value === "string" && value ? value : undefined),
|
|
173
|
+
lookup: (key) => byAddress.get(key),
|
|
174
|
+
};
|
|
175
|
+
const rows = [];
|
|
176
|
+
for (const record of records) {
|
|
177
|
+
const owner =
|
|
178
|
+
record?.type === "affiliation" ||
|
|
179
|
+
(record?.type === "lore" && record?.subType === "culture");
|
|
180
|
+
if (!owner) continue;
|
|
181
|
+
const entries = record.data?.glossary;
|
|
182
|
+
if (!Array.isArray(entries) || !entries.length) continue;
|
|
183
|
+
const note = noteAddressOf(record);
|
|
184
|
+
if (!note) continue;
|
|
185
|
+
entries.forEach((entry, position) => {
|
|
186
|
+
if (!isMap(entry) || typeof entry.name !== "string") return;
|
|
187
|
+
const row = { note, position };
|
|
188
|
+
for (const key of GLOSSARY_ENTRY_KEYS) row[key] = entry[key] ?? null;
|
|
189
|
+
if (row.name_sort === null) row.name_sort = row.name;
|
|
190
|
+
if (row.common === null) row.common = false;
|
|
191
|
+
row.language = glossaryLanguage(entry, record, { ...reader, self: note }) ?? null;
|
|
192
|
+
rows.push(row);
|
|
193
|
+
});
|
|
194
|
+
}
|
|
195
|
+
return rows;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/**
|
|
199
|
+
* The `language_lineage` rows of a set of index records: for each language
|
|
200
|
+
* note, itself at depth `0`, its `data.family` at depth `1`, and so on up the
|
|
201
|
+
* chain. The chain stops at a parent that is no language note of the package,
|
|
202
|
+
* and before a language it has already reached, so a cycle the lint refuses
|
|
203
|
+
* still yields finite rows.
|
|
204
|
+
*
|
|
205
|
+
* @param {Iterable<Record<string, any>>} records - Index records, every
|
|
206
|
+
* Address a string, already narrowed to the audience being published to.
|
|
207
|
+
* @param {Iterable<Record<string, any>>} [all] - Every record of the package,
|
|
208
|
+
* for following a chain through a note the audience does not see.
|
|
209
|
+
* @returns {object[]} One row per language and ancestor, in record then depth order.
|
|
210
|
+
*/
|
|
211
|
+
export function lineageRows(records, all = records) {
|
|
212
|
+
const languages = new Map();
|
|
213
|
+
for (const record of all)
|
|
214
|
+
if (isLanguageRecord(record)) {
|
|
215
|
+
const key = noteAddressOf(record);
|
|
216
|
+
if (key && !languages.has(key)) languages.set(key, record);
|
|
217
|
+
}
|
|
218
|
+
const rows = [];
|
|
219
|
+
for (const record of records) {
|
|
220
|
+
if (!isLanguageRecord(record)) continue;
|
|
221
|
+
const language = noteAddressOf(record);
|
|
222
|
+
if (!language) continue;
|
|
223
|
+
const seen = new Set();
|
|
224
|
+
let current = language;
|
|
225
|
+
for (let depth = 0; current && languages.has(current) && !seen.has(current); depth++) {
|
|
226
|
+
seen.add(current);
|
|
227
|
+
rows.push({ language, ancestor: current, depth });
|
|
228
|
+
const family = languages.get(current).data?.family;
|
|
229
|
+
current = typeof family === "string" && family ? family : undefined;
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
return rows;
|
|
233
|
+
}
|
|
@@ -0,0 +1,306 @@
|
|
|
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 inner keys of a language note's words, fields and affixes, and of a
|
|
16
|
+
* culture's or an affiliation's glossary entries, as inner-key declarations.
|
|
17
|
+
*
|
|
18
|
+
* A leaf module, importing nothing: the note vocabulary reads these
|
|
19
|
+
* declarations as it loads, and the checks that judge what the values mean —
|
|
20
|
+
* a class against the master word classes, a field against the note's own
|
|
21
|
+
* fields — live in `language-words.mjs` and `glossary-entries.mjs`.
|
|
22
|
+
*
|
|
23
|
+
* @module
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
/** The `skill` subType whose notes are languages, and the only one with words. */
|
|
27
|
+
export const LANGUAGE_SUBTYPE = "language";
|
|
28
|
+
|
|
29
|
+
/** The `data` keys a language note states its words in, beside `names`. */
|
|
30
|
+
export const WORD_DATA_KEYS = Object.freeze(["classModifiers", "fields", "words", "affixes"]);
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Where a word comes from. `borrowed` names the tongue it was taken from;
|
|
34
|
+
* `inherited`, `kept` and `worn` name the ancient form where the note gives
|
|
35
|
+
* one; `coined`, `substrate` and `island` name no form.
|
|
36
|
+
*/
|
|
37
|
+
export const WORD_SOURCE_KINDS = Object.freeze([
|
|
38
|
+
"inherited",
|
|
39
|
+
"borrowed",
|
|
40
|
+
"kept",
|
|
41
|
+
"worn",
|
|
42
|
+
"coined",
|
|
43
|
+
"substrate",
|
|
44
|
+
"island",
|
|
45
|
+
]);
|
|
46
|
+
|
|
47
|
+
/** The source kind that requires `language`. */
|
|
48
|
+
export const BORROWED_SOURCE_KIND = "borrowed";
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* The keys of a word's `source`.
|
|
52
|
+
*
|
|
53
|
+
* @type {readonly import("./data-keys.mjs").InnerKeySpec[]}
|
|
54
|
+
*/
|
|
55
|
+
export const WORD_SOURCE_FIELDS = Object.freeze([
|
|
56
|
+
Object.freeze({
|
|
57
|
+
name: "kind",
|
|
58
|
+
kind: "string",
|
|
59
|
+
required: true,
|
|
60
|
+
allowed: WORD_SOURCE_KINDS,
|
|
61
|
+
shape: `one of ${WORD_SOURCE_KINDS.join(", ")}`,
|
|
62
|
+
describe: "How the word came into the tongue.",
|
|
63
|
+
}),
|
|
64
|
+
Object.freeze({
|
|
65
|
+
name: "language",
|
|
66
|
+
kind: "address",
|
|
67
|
+
shape: "a language skill Address",
|
|
68
|
+
describe: "The tongue the word was borrowed from; required for `borrowed`.",
|
|
69
|
+
}),
|
|
70
|
+
Object.freeze({
|
|
71
|
+
name: "form",
|
|
72
|
+
kind: "string",
|
|
73
|
+
shape: "a string",
|
|
74
|
+
describe: "The form the word had in its source, bare.",
|
|
75
|
+
}),
|
|
76
|
+
]);
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* The keys one word of a language note's `data.words` carries.
|
|
80
|
+
*
|
|
81
|
+
* @type {readonly import("./data-keys.mjs").InnerKeySpec[]}
|
|
82
|
+
*/
|
|
83
|
+
export const WORD_FIELDS = Object.freeze([
|
|
84
|
+
Object.freeze({
|
|
85
|
+
name: "form",
|
|
86
|
+
kind: "string",
|
|
87
|
+
required: true,
|
|
88
|
+
shape: "the word as cited, bare",
|
|
89
|
+
describe: "The word as cited, with no backticks.",
|
|
90
|
+
}),
|
|
91
|
+
Object.freeze({
|
|
92
|
+
name: "name_sort",
|
|
93
|
+
kind: "string",
|
|
94
|
+
shape: "a string",
|
|
95
|
+
describe: "The sort key; the form when unstated.",
|
|
96
|
+
}),
|
|
97
|
+
Object.freeze({
|
|
98
|
+
name: "class",
|
|
99
|
+
kind: "string",
|
|
100
|
+
required: true,
|
|
101
|
+
shape: "a word class, base first, such as `n I f`",
|
|
102
|
+
describe:
|
|
103
|
+
"A class from the master word classes: a base, then the modifiers the language " +
|
|
104
|
+
"declares in `classModifiers`.",
|
|
105
|
+
}),
|
|
106
|
+
Object.freeze({
|
|
107
|
+
name: "field",
|
|
108
|
+
kind: "string",
|
|
109
|
+
required: true,
|
|
110
|
+
shape: "one of the language's `fields` names",
|
|
111
|
+
describe: "The field the word is grouped under, named in the language's `fields`.",
|
|
112
|
+
}),
|
|
113
|
+
Object.freeze({
|
|
114
|
+
name: "gloss",
|
|
115
|
+
kind: "string",
|
|
116
|
+
required: true,
|
|
117
|
+
shape: "a string",
|
|
118
|
+
describe: "The meaning, in brief.",
|
|
119
|
+
}),
|
|
120
|
+
Object.freeze({
|
|
121
|
+
name: "source",
|
|
122
|
+
kind: "map",
|
|
123
|
+
shape: "a map — `{ kind, language?, form? }`",
|
|
124
|
+
fields: WORD_SOURCE_FIELDS,
|
|
125
|
+
describe: "Where the word comes from.",
|
|
126
|
+
}),
|
|
127
|
+
Object.freeze({
|
|
128
|
+
name: "built",
|
|
129
|
+
kind: "list",
|
|
130
|
+
shape: "a list of pieces",
|
|
131
|
+
entries: Object.freeze({ kind: "string", shape: "a string" }),
|
|
132
|
+
describe:
|
|
133
|
+
"The pieces the word is built from, in order, as the language's words and affixes " +
|
|
134
|
+
"write them; absent for a root word.",
|
|
135
|
+
}),
|
|
136
|
+
Object.freeze({
|
|
137
|
+
name: "attested",
|
|
138
|
+
kind: "list",
|
|
139
|
+
shape: "a list of Addresses",
|
|
140
|
+
entries: Object.freeze({ kind: "address", shape: "an Address" }),
|
|
141
|
+
describe: "The notes the word is cited from.",
|
|
142
|
+
}),
|
|
143
|
+
Object.freeze({
|
|
144
|
+
name: "pronunciation",
|
|
145
|
+
kind: "string",
|
|
146
|
+
shape: "a respelling such as `sol-MEH-reh`",
|
|
147
|
+
describe:
|
|
148
|
+
"A respelling in hyphen-separated syllables, the stressed one in capitals: letters, " +
|
|
149
|
+
"apostrophes and hyphens only, and at most one capitalised syllable.",
|
|
150
|
+
}),
|
|
151
|
+
Object.freeze({
|
|
152
|
+
name: "tones",
|
|
153
|
+
kind: "string",
|
|
154
|
+
shape: "a string",
|
|
155
|
+
describe: "A tone pattern, such as `LHL`.",
|
|
156
|
+
}),
|
|
157
|
+
Object.freeze({
|
|
158
|
+
name: "root",
|
|
159
|
+
kind: "string",
|
|
160
|
+
shape: "a string",
|
|
161
|
+
describe: "A consonantal skeleton, such as `d-l-k`.",
|
|
162
|
+
}),
|
|
163
|
+
Object.freeze({
|
|
164
|
+
name: "frame",
|
|
165
|
+
kind: "string",
|
|
166
|
+
shape: "a string",
|
|
167
|
+
describe: "The frame a root is set in.",
|
|
168
|
+
}),
|
|
169
|
+
]);
|
|
170
|
+
|
|
171
|
+
/** The keys of {@link WORD_FIELDS}, in declared order. */
|
|
172
|
+
export const WORD_KEYS = Object.freeze(WORD_FIELDS.map((field) => field.name));
|
|
173
|
+
|
|
174
|
+
/**
|
|
175
|
+
* The keys one entry of a language note's `data.fields` carries.
|
|
176
|
+
*
|
|
177
|
+
* @type {readonly import("./data-keys.mjs").InnerKeySpec[]}
|
|
178
|
+
*/
|
|
179
|
+
export const WORD_FIELD_FIELDS = Object.freeze([
|
|
180
|
+
Object.freeze({
|
|
181
|
+
name: "name",
|
|
182
|
+
kind: "string",
|
|
183
|
+
required: true,
|
|
184
|
+
shape: "a string",
|
|
185
|
+
describe: "The field's name, which a word's `field` names.",
|
|
186
|
+
}),
|
|
187
|
+
Object.freeze({
|
|
188
|
+
name: "holds",
|
|
189
|
+
kind: "string",
|
|
190
|
+
required: true,
|
|
191
|
+
shape: "a string",
|
|
192
|
+
describe: "What the field holds.",
|
|
193
|
+
}),
|
|
194
|
+
]);
|
|
195
|
+
|
|
196
|
+
/**
|
|
197
|
+
* The keys one entry of a language note's `data.affixes` carries.
|
|
198
|
+
*
|
|
199
|
+
* @type {readonly import("./data-keys.mjs").InnerKeySpec[]}
|
|
200
|
+
*/
|
|
201
|
+
export const AFFIX_FIELDS = Object.freeze([
|
|
202
|
+
Object.freeze({
|
|
203
|
+
name: "form",
|
|
204
|
+
kind: "string",
|
|
205
|
+
required: true,
|
|
206
|
+
shape: "the affix as written, such as `-rith`",
|
|
207
|
+
describe: "The affix as a word's `built` names it.",
|
|
208
|
+
}),
|
|
209
|
+
Object.freeze({
|
|
210
|
+
name: "gloss",
|
|
211
|
+
kind: "string",
|
|
212
|
+
required: true,
|
|
213
|
+
shape: "a string",
|
|
214
|
+
describe: "What the affix means.",
|
|
215
|
+
}),
|
|
216
|
+
Object.freeze({
|
|
217
|
+
name: "attaches",
|
|
218
|
+
kind: "string",
|
|
219
|
+
shape: "a string",
|
|
220
|
+
describe: "How it attaches, such as `replaces a closing vowel`.",
|
|
221
|
+
}),
|
|
222
|
+
Object.freeze({
|
|
223
|
+
name: "examples",
|
|
224
|
+
kind: "list",
|
|
225
|
+
shape: "a list of strings",
|
|
226
|
+
entries: Object.freeze({ kind: "string", shape: "a string" }),
|
|
227
|
+
describe: "The names the affix is read from.",
|
|
228
|
+
}),
|
|
229
|
+
]);
|
|
230
|
+
|
|
231
|
+
/**
|
|
232
|
+
* The keys one entry of a culture's or an affiliation's `data.glossary`
|
|
233
|
+
* carries.
|
|
234
|
+
*
|
|
235
|
+
* @type {readonly import("./data-keys.mjs").InnerKeySpec[]}
|
|
236
|
+
*/
|
|
237
|
+
export const GLOSSARY_ENTRY_FIELDS = Object.freeze([
|
|
238
|
+
Object.freeze({
|
|
239
|
+
name: "name",
|
|
240
|
+
kind: "string",
|
|
241
|
+
required: true,
|
|
242
|
+
shape: "a string",
|
|
243
|
+
describe: "The headword.",
|
|
244
|
+
}),
|
|
245
|
+
Object.freeze({
|
|
246
|
+
name: "name_sort",
|
|
247
|
+
kind: "string",
|
|
248
|
+
shape: "a string",
|
|
249
|
+
describe: "Its sort key; the name when unstated.",
|
|
250
|
+
}),
|
|
251
|
+
Object.freeze({
|
|
252
|
+
name: "common",
|
|
253
|
+
kind: "boolean",
|
|
254
|
+
shape: "`true` or `false`",
|
|
255
|
+
describe: "A word a player is expected to know by heart; `false` when unstated.",
|
|
256
|
+
}),
|
|
257
|
+
Object.freeze({
|
|
258
|
+
name: "definition",
|
|
259
|
+
kind: "string",
|
|
260
|
+
shape: "a string",
|
|
261
|
+
describe:
|
|
262
|
+
"The sense text. Stated, it creates the entry or replaces every sense the " +
|
|
263
|
+
"entry's other sources give it.",
|
|
264
|
+
}),
|
|
265
|
+
Object.freeze({
|
|
266
|
+
name: "pronunciation",
|
|
267
|
+
kind: "string",
|
|
268
|
+
shape: "a respelling such as `sol-MEH-reh`",
|
|
269
|
+
describe: "A respelling, held to the same rule as a word's.",
|
|
270
|
+
}),
|
|
271
|
+
Object.freeze({
|
|
272
|
+
name: "language",
|
|
273
|
+
kind: "address",
|
|
274
|
+
shape: "a language skill Address",
|
|
275
|
+
describe:
|
|
276
|
+
"The language the term is in. Unstated, it is the affiliation's language, its " +
|
|
277
|
+
"parents', or its culture's, or the culture's own.",
|
|
278
|
+
}),
|
|
279
|
+
]);
|
|
280
|
+
|
|
281
|
+
/** The keys of {@link GLOSSARY_ENTRY_FIELDS}, in declared order. */
|
|
282
|
+
export const GLOSSARY_ENTRY_KEYS = Object.freeze(GLOSSARY_ENTRY_FIELDS.map((field) => field.name));
|
|
283
|
+
|
|
284
|
+
/**
|
|
285
|
+
* Why a pronunciation is not a respelling, or `undefined` when it is one.
|
|
286
|
+
*
|
|
287
|
+
* A respelling is letters and apostrophes in hyphen-separated syllables, with
|
|
288
|
+
* at most one syllable in capitals.
|
|
289
|
+
*
|
|
290
|
+
* @param {string} text
|
|
291
|
+
* @returns {string|undefined}
|
|
292
|
+
*/
|
|
293
|
+
export function pronunciationProblem(text) {
|
|
294
|
+
const value = String(text ?? "");
|
|
295
|
+
if (!/^[\p{L}\p{M}'’]+(?:-[\p{L}\p{M}'’]+)*$/u.test(value))
|
|
296
|
+
return "a respelling holds letters, apostrophes and hyphens only, as `sol-MEH-reh`";
|
|
297
|
+
const stressed = value
|
|
298
|
+
.split(/-/)
|
|
299
|
+
.filter(
|
|
300
|
+
(syllable) =>
|
|
301
|
+
syllable !== syllable.toLowerCase() && syllable === syllable.toUpperCase(),
|
|
302
|
+
);
|
|
303
|
+
if (stressed.length > 1)
|
|
304
|
+
return `a respelling stresses one syllable, and this capitalises ${stressed.length}`;
|
|
305
|
+
return undefined;
|
|
306
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@heroiclands/package-build",
|
|
3
|
-
"version": "23.
|
|
3
|
+
"version": "23.6.0",
|
|
4
4
|
"description": "Shared toolchain for building and shipping a HeroicLands Foundry VTT package — content compilation, manifest, localization, staging, bundle, release and deployment.",
|
|
5
5
|
"license": "GPL-3.0-or-later",
|
|
6
6
|
"type": "module",
|
|
@@ -120,7 +120,6 @@
|
|
|
120
120
|
"hugo-theme",
|
|
121
121
|
"index.mjs",
|
|
122
122
|
"lang.mjs",
|
|
123
|
-
"labels.mjs",
|
|
124
123
|
"manifest.mjs",
|
|
125
124
|
"markdownlint-config.mjs",
|
|
126
125
|
"prettier-config.mjs",
|
|
@@ -192,11 +191,10 @@
|
|
|
192
191
|
"build:asset-index": "node ci/emit-asset-index.mjs",
|
|
193
192
|
"format": "node ci/prettier.mjs --write",
|
|
194
193
|
"format:check": "node ci/prettier.mjs --check",
|
|
195
|
-
"lint": "run-p --aggregate-output -c --max-parallel 4 format:check lint:markdown lint:yaml lint:
|
|
194
|
+
"lint": "run-p --aggregate-output -c --max-parallel 4 format:check lint:markdown lint:yaml lint:image-roles lint:banners lint:content-format lint:docs-links",
|
|
196
195
|
"lint:markdown": "node bin/package-build.mjs markdown",
|
|
197
196
|
"lint:docs-links": "node bin/package-build.mjs docs links",
|
|
198
197
|
"lint:yaml": "node bin/package-build.mjs yaml",
|
|
199
|
-
"lint:labels": "node bin/package-build.mjs labels check",
|
|
200
198
|
"lint:image-roles": "vitest run tests/theme-image-roles.test.ts",
|
|
201
199
|
"lint:banners": "node ci/check-banners.mjs",
|
|
202
200
|
"lint:markdown:fix": "node bin/package-build.mjs markdown --fix",
|
|
@@ -220,31 +220,6 @@ export const CDN_ENV: Readonly<{
|
|
|
220
220
|
accessKeyId: string;
|
|
221
221
|
secretAccessKey: string;
|
|
222
222
|
}>;
|
|
223
|
-
/**
|
|
224
|
-
* A store holding the CDN's objects.
|
|
225
|
-
*/
|
|
226
|
-
export type CdnStore = {
|
|
227
|
-
/**
|
|
228
|
-
* - An object's
|
|
229
|
-
* size in bytes, `undefined` when there is none.
|
|
230
|
-
*/
|
|
231
|
-
size: (key: string) => Promise<number | undefined>;
|
|
232
|
-
/**
|
|
233
|
-
* - An object's
|
|
234
|
-
* bytes, `undefined` when there is none.
|
|
235
|
-
*/
|
|
236
|
-
read: (key: string) => Promise<Buffer | undefined>;
|
|
237
|
-
/**
|
|
238
|
-
* -
|
|
239
|
-
* Store an object; absent on a read-only store.
|
|
240
|
-
*/
|
|
241
|
-
write: ((key: string, body: Buffer, contentType: string) => Promise<void>) | undefined;
|
|
242
|
-
/**
|
|
243
|
-
* - What the store is, for the log. Never a
|
|
244
|
-
* secret.
|
|
245
|
-
*/
|
|
246
|
-
describe: string;
|
|
247
|
-
};
|
|
248
223
|
/**
|
|
249
224
|
* One picture to publish.
|
|
250
225
|
*/
|
|
@@ -309,3 +284,29 @@ export type CdnPlan = {
|
|
|
309
284
|
*/
|
|
310
285
|
cdnRoot: string;
|
|
311
286
|
};
|
|
287
|
+
/**
|
|
288
|
+
* A store holding the CDN's objects.
|
|
289
|
+
*/
|
|
290
|
+
export type CdnStore = {
|
|
291
|
+
/**
|
|
292
|
+
* - An object's
|
|
293
|
+
* size in bytes, `undefined` when there is none or the store does not state
|
|
294
|
+
* it.
|
|
295
|
+
*/
|
|
296
|
+
size: (key: string) => Promise<number | undefined>;
|
|
297
|
+
/**
|
|
298
|
+
* - An object's
|
|
299
|
+
* bytes, `undefined` when there is none.
|
|
300
|
+
*/
|
|
301
|
+
read: (key: string) => Promise<Buffer | undefined>;
|
|
302
|
+
/**
|
|
303
|
+
* -
|
|
304
|
+
* Store an object; absent on a read-only store.
|
|
305
|
+
*/
|
|
306
|
+
write: ((key: string, body: Buffer, contentType: string) => Promise<void>) | undefined;
|
|
307
|
+
/**
|
|
308
|
+
* - What the store is, for the log. Never a
|
|
309
|
+
* secret.
|
|
310
|
+
*/
|
|
311
|
+
describe: string;
|
|
312
|
+
};
|
|
@@ -316,11 +316,13 @@ export function indexRecordsFor({ contentBase, assetsBase, config, skipDirectori
|
|
|
316
316
|
* `paths.contentIndex`.
|
|
317
317
|
* @param {object} [options.config] - A resolved configuration; loaded when omitted.
|
|
318
318
|
* @returns {{file: string, notes: number, assets: number, records: number,
|
|
319
|
-
* bytes: number, full: number, draft: number, stub: number
|
|
320
|
-
*
|
|
321
|
-
*
|
|
322
|
-
*
|
|
323
|
-
*
|
|
319
|
+
* bytes: number, full: number, draft: number, stub: number,
|
|
320
|
+
* dictionary: {file: string, rows: number}, terms: {file: string, rows: number}}}
|
|
321
|
+
* Where it was written, how many notes and how many assets it holds, how many
|
|
322
|
+
* records that is in all, its size, and the ladder those notes sit on — so
|
|
323
|
+
* the ratio of written to unwritten is visible on every build rather than
|
|
324
|
+
* discovered in a year — and where the `dictionary` and `terms` datafiles
|
|
325
|
+
* were written beside it, with their row counts.
|
|
324
326
|
* @throws {Error} When the content tree is absent, or when it yields no note at
|
|
325
327
|
* all — an empty index is indistinguishable from a mis-pointed tree, and a
|
|
326
328
|
* reader would take it as the authoritative statement that this package has
|
|
@@ -339,6 +341,14 @@ export function emitContentIndex({ contentBase, outDir, config }?: {
|
|
|
339
341
|
full: number;
|
|
340
342
|
draft: number;
|
|
341
343
|
stub: number;
|
|
344
|
+
dictionary: {
|
|
345
|
+
file: string;
|
|
346
|
+
rows: number;
|
|
347
|
+
};
|
|
348
|
+
terms: {
|
|
349
|
+
file: string;
|
|
350
|
+
rows: number;
|
|
351
|
+
};
|
|
342
352
|
};
|
|
343
353
|
import { collectAnchors } from "./anchors.mjs";
|
|
344
354
|
import { authoredFrontmatter } from "./index-records.mjs";
|