@heroiclands/package-build 25.1.3 → 25.2.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 +29 -0
- package/assets/styles/content-base.css +0 -9
- package/bin/content-commands.mjs +22 -16
- package/bin/package-build.mjs +24 -25
- package/content-config.mjs +7 -34
- package/docs/api.md +38 -21
- package/docs/authoring/first-note.md +3 -3
- package/docs/authoring/frontmatter.md +30 -2
- package/docs/authoring/links-and-markup.md +92 -31
- package/docs/commands.md +103 -59
- package/docs/configuration.md +10 -45
- package/docs/content-format.md +7 -5
- package/docs/diagnostics.md +75 -0
- package/docs/guides/book.md +3 -3
- package/docs/guides/module-project.md +1 -1
- package/docs/guides/reference-pages.md +7 -3
- package/docs/project-setup.md +3 -0
- package/docs/reference/format-details.md +299 -185
- package/docs/reference/note-types.md +16 -17
- package/engine/changelog-group.mjs +84 -173
- package/engine/changelog-labels.mjs +73 -0
- package/engine/changelog-lint.mjs +168 -44
- package/engine/content-expand.mjs +58 -38
- package/engine/content-format.yaml +0 -2
- package/engine/content-index.mjs +49 -23
- package/engine/content-links.mjs +4 -0
- package/engine/content-lint.mjs +2 -3
- package/engine/content-render.mjs +6 -2
- package/engine/culture-parents.mjs +1 -1
- package/engine/culture-reach.mjs +10 -1
- package/engine/diagnostics.mjs +3 -2
- package/engine/dictionary-rows.mjs +3 -1
- package/engine/first-use.mjs +301 -87
- package/engine/frontmatter-lint.mjs +40 -14
- package/engine/glossary-entries.mjs +5 -5
- package/engine/glossary-language.mjs +21 -44
- package/engine/homepage.mjs +0 -4
- package/engine/index-records.mjs +3 -1
- package/engine/infobox.mjs +6 -3
- package/engine/language-names.mjs +3 -2
- package/engine/language-rows.mjs +6 -7
- package/engine/lexical-sections.mjs +3 -1
- package/engine/literature-notes.mjs +4 -76
- package/engine/metadata-index.mjs +15 -8
- package/engine/name-forms.mjs +99 -0
- package/engine/name-refs.mjs +143 -0
- package/engine/name-rows.mjs +11 -1
- package/engine/names.mjs +27 -22
- package/engine/note-addresses.mjs +0 -3
- package/engine/note-frontmatter.mjs +3 -5
- package/engine/note-language.mjs +348 -0
- package/engine/note-vocabulary.mjs +13 -11
- package/engine/pdf-index.mjs +3 -2
- package/engine/pdf-render.mjs +2 -4
- package/engine/settlement-tables.mjs +4 -3
- package/engine/site-build.mjs +2 -7
- package/engine/site-search.mjs +3 -5
- package/engine/site-theme.mjs +1 -1
- package/engine/sql-tables.mjs +29 -14
- package/engine/wikilink-syntax.mjs +2 -0
- package/engine/word-citations.mjs +3 -3
- package/engine/word-rows.mjs +3 -1
- package/engine/word-terms.mjs +2 -2
- package/package.json +1 -1
- package/site-theme/static/css/style.css +0 -4
- package/types/engine/changelog-group.d.mts +6 -10
- package/types/engine/changelog-labels.d.mts +10 -0
- package/types/engine/changelog-lint.d.mts +11 -6
- package/types/engine/content-index.d.mts +16 -16
- package/types/engine/diagnostics.d.mts +1 -1
- package/types/engine/first-use.d.mts +97 -1
- package/types/engine/frontmatter-lint.d.mts +9 -2
- package/types/engine/glossary-language.d.mts +13 -6
- package/types/engine/homepage.d.mts +0 -3
- package/types/engine/literature-notes.d.mts +2 -27
- package/types/engine/name-forms.d.mts +61 -0
- package/types/engine/name-refs.d.mts +51 -0
- package/types/engine/name-rows.d.mts +4 -0
- package/types/engine/names.d.mts +12 -92
- package/types/engine/note-frontmatter.d.mts +1 -4
- package/types/engine/note-language.d.mts +47 -0
- package/types/engine/settlement-tables.d.mts +1 -1
- package/types/engine/site-build.d.mts +2 -6
package/engine/name-rows.mjs
CHANGED
|
@@ -22,7 +22,15 @@ import { encodeAddresses } from "./address-values.mjs";
|
|
|
22
22
|
import { isNoteRecord } from "./index-records.mjs";
|
|
23
23
|
|
|
24
24
|
/** Every column of the `names` relation, in order. */
|
|
25
|
-
export const NAME_COLUMNS = Object.freeze([
|
|
25
|
+
export const NAME_COLUMNS = Object.freeze([
|
|
26
|
+
"owner",
|
|
27
|
+
"name",
|
|
28
|
+
"type",
|
|
29
|
+
"subType",
|
|
30
|
+
"ref",
|
|
31
|
+
"line",
|
|
32
|
+
"column",
|
|
33
|
+
]);
|
|
26
34
|
|
|
27
35
|
/**
|
|
28
36
|
* @typedef {object} NameRow
|
|
@@ -30,6 +38,7 @@ export const NAME_COLUMNS = Object.freeze(["owner", "name", "type", "subType", "
|
|
|
30
38
|
* @property {string} name - Rendered visible text of the occurrence.
|
|
31
39
|
* @property {string|null} type - Supplied note type, or null.
|
|
32
40
|
* @property {string|null} subType - Supplied subtype, or null.
|
|
41
|
+
* @property {string|null} ref - The `ref=` as written, a note address or a page-local `#key`, or null.
|
|
33
42
|
* @property {number} line - Original 1-based source line at the opening bracket.
|
|
34
43
|
* @property {number} column - Original 1-based UTF-16 source column at that bracket.
|
|
35
44
|
*/
|
|
@@ -61,6 +70,7 @@ export function namesRows(records) {
|
|
|
61
70
|
name: entry.name,
|
|
62
71
|
type: entry.type ?? null,
|
|
63
72
|
subType: entry.subType ?? null,
|
|
73
|
+
ref: entry.ref ?? null,
|
|
64
74
|
line: entry.line,
|
|
65
75
|
column: entry.column,
|
|
66
76
|
});
|
package/engine/names.mjs
CHANGED
|
@@ -19,7 +19,9 @@ import { formatLocator } from "./diagnostics.mjs";
|
|
|
19
19
|
import { FOLDER_TYPE } from "./folder-notes.mjs";
|
|
20
20
|
import { isNoteRecord } from "./index-records.mjs";
|
|
21
21
|
import { NOTE_VOCABULARY } from "./note-vocabulary.mjs";
|
|
22
|
+
import { readNameRef } from "./name-refs.mjs";
|
|
22
23
|
import { WORD } from "./word-pattern.mjs";
|
|
24
|
+
import { displayName, nameFormsOf, normalizeName } from "./name-forms.mjs";
|
|
23
25
|
|
|
24
26
|
const compare = (a, b) =>
|
|
25
27
|
a < b ? -1
|
|
@@ -32,7 +34,7 @@ const compare = (a, b) =>
|
|
|
32
34
|
* @property {string} key - Canonical page identity, or the ambiguous label identity.
|
|
33
35
|
* @property {string} full - Declared full name.
|
|
34
36
|
* @property {true} indexed - The identity belongs to the indexed catalog.
|
|
35
|
-
* @property {boolean} [fullUse] - The label is a full
|
|
37
|
+
* @property {boolean} [fullUse] - The label is a form of the full name rather than a listed form.
|
|
36
38
|
* @property {string} [type] - The note's type, absent on an ambiguous label identity.
|
|
37
39
|
* @property {string|null} [subType] - The note's subtype, or null.
|
|
38
40
|
* @property {NameMatch[]} [candidates] - Indexed identities sharing the label.
|
|
@@ -40,7 +42,7 @@ const compare = (a, b) =>
|
|
|
40
42
|
*/
|
|
41
43
|
|
|
42
44
|
/**
|
|
43
|
-
* The prepared
|
|
45
|
+
* The prepared lookup of full names and listed forms.
|
|
44
46
|
* @typedef {object} NameCatalog
|
|
45
47
|
* @property {(label: string, preferred?: {key: string}, kind?: NameKind) => NameMatch|undefined} recognize - Match a whole visible label, among notes of `kind` when it supplies a type.
|
|
46
48
|
* @property {(target: string) => NameMatch|undefined} resolve - Resolve an Address target without its anchor to its indexed identity.
|
|
@@ -60,6 +62,7 @@ const compare = (a, b) =>
|
|
|
60
62
|
* @property {string} name - Rendered visible text.
|
|
61
63
|
* @property {string|null} type - Supplied valid type, or null.
|
|
62
64
|
* @property {string|null} subType - Supplied valid subtype, or null.
|
|
65
|
+
* @property {string|null} ref - The `ref=` as written, a note address or a `#key`, or null.
|
|
63
66
|
* @property {number} line - Original 1-based file line.
|
|
64
67
|
* @property {number} column - Original 1-based UTF-16 file column.
|
|
65
68
|
*/
|
|
@@ -116,19 +119,14 @@ const compare = (a, b) =>
|
|
|
116
119
|
* @property {NameConflict[]} conflicts - Labels with incompatible supplied kinds.
|
|
117
120
|
*/
|
|
118
121
|
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
* @param {string} text - Visible name or declared alias.
|
|
122
|
-
* @returns {string} NFC text with straight apostrophes and single spaces.
|
|
123
|
-
*/
|
|
124
|
-
export function normalizeName(text) {
|
|
125
|
-
return text.normalize("NFC").replaceAll("’", "'").replace(/\s+/g, " ").trim();
|
|
126
|
-
}
|
|
122
|
+
// A label's matching key folds case; its display form keeps it.
|
|
123
|
+
export { displayName, normalizeName } from "./name-forms.mjs";
|
|
127
124
|
|
|
128
125
|
/**
|
|
129
|
-
* Whole-label possessives and regular plurals
|
|
130
|
-
*
|
|
131
|
-
* @
|
|
126
|
+
* Whole-label possessives and regular plurals, as matching keys; any other
|
|
127
|
+
* form is one a note's name lists.
|
|
128
|
+
* @param {string} text - Full name or a listed form.
|
|
129
|
+
* @returns {string[]} Supported whole-label forms, case folded.
|
|
132
130
|
*/
|
|
133
131
|
export function nameForms(text) {
|
|
134
132
|
const value = normalizeName(text);
|
|
@@ -150,7 +148,7 @@ function identity(record) {
|
|
|
150
148
|
}
|
|
151
149
|
|
|
152
150
|
/**
|
|
153
|
-
* Prepare full names,
|
|
151
|
+
* Prepare full names, every listed form and link identities from captured records.
|
|
154
152
|
* @param {object} options - The invocation's existing corpus and link index.
|
|
155
153
|
* @param {readonly object[]} options.records - Captured local records.
|
|
156
154
|
* @param {ReturnType<typeof import("./content-links.mjs").buildLinkIndex>} options.index - Captured address space.
|
|
@@ -160,7 +158,7 @@ export function createNameCatalog({ records, index }) {
|
|
|
160
158
|
const names = new Map(),
|
|
161
159
|
byFile = new Map(),
|
|
162
160
|
entries = new Map();
|
|
163
|
-
const addName = (key, full,
|
|
161
|
+
const addName = (key, full, forms, type, subType) => {
|
|
164
162
|
const entry = entries.get(key) ?? {
|
|
165
163
|
key,
|
|
166
164
|
full,
|
|
@@ -169,7 +167,7 @@ export function createNameCatalog({ records, index }) {
|
|
|
169
167
|
subType: typeof subType === "string" && subType ? subType : null,
|
|
170
168
|
};
|
|
171
169
|
entries.set(key, entry);
|
|
172
|
-
for (const text of [full, ...(
|
|
170
|
+
for (const text of [full, ...(forms ?? [])]) {
|
|
173
171
|
if (typeof text !== "string" || !text.trim()) continue;
|
|
174
172
|
for (const spelling of nameForms(text)) {
|
|
175
173
|
if (!names.has(spelling)) names.set(spelling, new Map());
|
|
@@ -192,7 +190,7 @@ export function createNameCatalog({ records, index }) {
|
|
|
192
190
|
const entry = addName(
|
|
193
191
|
index.resolve(page) ? page : canonical,
|
|
194
192
|
full,
|
|
195
|
-
record.name
|
|
193
|
+
nameFormsOf(record.name),
|
|
196
194
|
parts.type,
|
|
197
195
|
record.subType,
|
|
198
196
|
);
|
|
@@ -211,7 +209,10 @@ export function createNameCatalog({ records, index }) {
|
|
|
211
209
|
continue;
|
|
212
210
|
const page = canonicalKey(parts.package, "note", parts.type, parts.shortcode);
|
|
213
211
|
const key = index.foreign.index.has(page) ? page : canonical;
|
|
214
|
-
entries.set(
|
|
212
|
+
entries.set(
|
|
213
|
+
canonical,
|
|
214
|
+
addName(key, hit.name, nameFormsOf(hit.forms), parts.type, hit.subType),
|
|
215
|
+
);
|
|
215
216
|
}
|
|
216
217
|
return {
|
|
217
218
|
recognize(label, preferred, kind) {
|
|
@@ -302,8 +303,9 @@ export function collectUnwrittenNames({ records, catalog }) {
|
|
|
302
303
|
if (!kinds.has(kind)) kinds.set(kind, { type, subType, names: new Map() });
|
|
303
304
|
const names = kinds.get(kind).names,
|
|
304
305
|
name = normalizeName(occurrence.name);
|
|
305
|
-
if (!names.has(name))
|
|
306
|
-
|
|
306
|
+
if (!names.has(name))
|
|
307
|
+
names.set(name, { display: displayName(occurrence.name), uses: [] });
|
|
308
|
+
names.get(name).uses.push(nameUse(record, occurrence));
|
|
307
309
|
}
|
|
308
310
|
}
|
|
309
311
|
const groups = [...kinds.values()]
|
|
@@ -316,7 +318,7 @@ export function collectUnwrittenNames({ records, catalog }) {
|
|
|
316
318
|
subType,
|
|
317
319
|
names: [...names]
|
|
318
320
|
.sort(([a], [b]) => compare(a, b))
|
|
319
|
-
.map(([
|
|
321
|
+
.map(([, { display, uses }]) => ({ name: display, uses: uses.sort(compareUses) })),
|
|
320
322
|
}));
|
|
321
323
|
const firstUse = firstNameUse(
|
|
322
324
|
groups.flatMap((group) => group.names.flatMap((name) => name.uses)),
|
|
@@ -383,6 +385,7 @@ export function analyzeNameSpans(spans, { file, vocabulary = NOTE_VOCABULARY, ty
|
|
|
383
385
|
name: span.visible,
|
|
384
386
|
type: kind.type,
|
|
385
387
|
subType: kind.subType,
|
|
388
|
+
ref: readNameRef(span),
|
|
386
389
|
line: span.line,
|
|
387
390
|
column: span.column,
|
|
388
391
|
});
|
|
@@ -401,11 +404,13 @@ export function analyzeNameSpans(spans, { file, vocabulary = NOTE_VOCABULARY, ty
|
|
|
401
404
|
*/
|
|
402
405
|
export function createNamesReport({ package: contentPackage, records, catalog }) {
|
|
403
406
|
const names = new Map();
|
|
407
|
+
const displays = new Map();
|
|
404
408
|
for (const record of records.filter(isNoteRecord))
|
|
405
409
|
for (const occurrence of record.names ?? []) {
|
|
406
410
|
if (occurrence.type === null) continue;
|
|
407
411
|
const name = normalizeName(occurrence.name);
|
|
408
412
|
if (!names.has(name)) names.set(name, new Map());
|
|
413
|
+
if (!displays.has(name)) displays.set(name, displayName(occurrence.name));
|
|
409
414
|
const kinds = names.get(name),
|
|
410
415
|
key = JSON.stringify([occurrence.type, occurrence.subType]);
|
|
411
416
|
if (!kinds.has(key))
|
|
@@ -421,7 +426,7 @@ export function createNamesReport({ package: contentPackage, records, catalog })
|
|
|
421
426
|
}
|
|
422
427
|
if (parents.size < 2 && ![...parents.values()].some((values) => values.size > 1)) continue;
|
|
423
428
|
conflicts.push({
|
|
424
|
-
name,
|
|
429
|
+
name: displays.get(name),
|
|
425
430
|
kinds: [...kinds.values()]
|
|
426
431
|
.sort(
|
|
427
432
|
(a, b) => compare(a.type, b.type) || compare(a.subType ?? "", b.subType ?? ""),
|
|
@@ -159,9 +159,6 @@ export function addressPositions(fm, context = {}) {
|
|
|
159
159
|
...(ASSET_TYPE_NAMES.has(field.ref) || field.ref === "folder" ?
|
|
160
160
|
{ system: "none" }
|
|
161
161
|
: {}),
|
|
162
|
-
...(field.name === "language" && field.ref === "skill" ?
|
|
163
|
-
{ system: NOTE_SYSTEM, noteOnly: true }
|
|
164
|
-
: {}),
|
|
165
162
|
});
|
|
166
163
|
}
|
|
167
164
|
out.push(...STRUCTURED_ADDRESSES.filter((p) => !p.noteType || p.noteType === fm.type));
|
|
@@ -21,11 +21,9 @@ export {
|
|
|
21
21
|
NOTE_TOP_LEVEL_KEY_SET,
|
|
22
22
|
} from "./note-vocabulary.mjs";
|
|
23
23
|
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
/** Additional name components accepted by an individual character. */
|
|
28
|
-
export const CHARACTER_NAME_KEYS = Object.freeze(["given", "clan"]);
|
|
24
|
+
// The single-string name keys, beside the language form lists they are told
|
|
25
|
+
// apart from.
|
|
26
|
+
export { CHARACTER_NAME_KEYS, NOTE_NAME_KEYS } from "./name-forms.mjs";
|
|
29
27
|
|
|
30
28
|
/** Return authored keys from an opening YAML frontmatter map. */
|
|
31
29
|
export function authoredNoteKeys(markdown) {
|
|
@@ -0,0 +1,348 @@
|
|
|
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 language a note is in**, a code `languages:` declares or `en`. A
|
|
16
|
+
* note's language is the first of:
|
|
17
|
+
*
|
|
18
|
+
* 1. its own `data.lang`;
|
|
19
|
+
* 2. its `data.culture`'s language: the culture note it names, resolved by
|
|
20
|
+
* these same rules;
|
|
21
|
+
* 3. its `data.parents`, nearest first, each resolved by these same rules;
|
|
22
|
+
* 4. `en`.
|
|
23
|
+
*
|
|
24
|
+
* Steps 2 and 3 take only a language some note states: a culture or parent
|
|
25
|
+
* that itself falls through to `en` contributes nothing, and the chain goes on.
|
|
26
|
+
* A parent in another package contributes the language its published entry
|
|
27
|
+
* carries.
|
|
28
|
+
*
|
|
29
|
+
* The content index writes the result onto every note record as `lang`, and
|
|
30
|
+
* every reader of a note's language reads it from there. The configured
|
|
31
|
+
* language note of each code carries that code as `languageCode`, so a reader
|
|
32
|
+
* holding only records maps a code to its note.
|
|
33
|
+
*
|
|
34
|
+
* Findings, each an error:
|
|
35
|
+
*
|
|
36
|
+
* - a `data.lang` naming no declared code, at its value;
|
|
37
|
+
* - a `data.culture` whose culture resolves to no language, at its value;
|
|
38
|
+
* - every `data.parents` entry on a cycle, at the entry, whether or not a walk
|
|
39
|
+
* reaches it. A cycle among culture notes is reported by the culture
|
|
40
|
+
* hierarchy check instead.
|
|
41
|
+
*
|
|
42
|
+
* @module
|
|
43
|
+
*/
|
|
44
|
+
|
|
45
|
+
import fs from "node:fs";
|
|
46
|
+
|
|
47
|
+
import { canonicalKey, isAddressTuple, parseAddress, renderAddress } from "./address.mjs";
|
|
48
|
+
import { isAddressSegment } from "./address-charset.mjs";
|
|
49
|
+
import { AddressLink } from "./address-values.mjs";
|
|
50
|
+
import { formatDiagnostic, positionOfFrontmatterPath } from "./diagnostics.mjs";
|
|
51
|
+
import { isNoteRecord, noteFile } from "./index-records.mjs";
|
|
52
|
+
import { FALLBACK_LANGUAGE } from "./name-forms.mjs";
|
|
53
|
+
import { NOTE_SYSTEM } from "./systems.mjs";
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* The canonical key an Address names, without any anchor.
|
|
57
|
+
*
|
|
58
|
+
* @param {unknown} value - A tuple, an {@link AddressLink}, or anything else.
|
|
59
|
+
* @returns {string|undefined} The key, or `undefined` for anything else.
|
|
60
|
+
*/
|
|
61
|
+
function addressKey(value) {
|
|
62
|
+
const tuple = value instanceof AddressLink ? value.target : value;
|
|
63
|
+
return isAddressTuple(tuple) ? renderAddress(tuple) : undefined;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Whether a record is a culture note.
|
|
68
|
+
*
|
|
69
|
+
* @param {Record<string, any>|undefined} record - An index record.
|
|
70
|
+
* @returns {boolean} Whether it is a `lore` note with `subType: culture`.
|
|
71
|
+
*/
|
|
72
|
+
function isCultureNote(record) {
|
|
73
|
+
return record?.type === "lore" && String(record.subType ?? "").toLowerCase() === "culture";
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Write each note's language onto its record as `lang`, and each configured
|
|
78
|
+
* language note's code onto its record as `languageCode`.
|
|
79
|
+
*
|
|
80
|
+
* Reads each note's authored `data.lang`, `data.culture` and `data.parents`,
|
|
81
|
+
* so it runs before the index writes a resolved culture over `data.culture`.
|
|
82
|
+
*
|
|
83
|
+
* @param {Array<Record<string, any>>} records - Index records, every Address a
|
|
84
|
+
* tuple. Records that are not notes are passed over.
|
|
85
|
+
* @param {object} options - Options.
|
|
86
|
+
* @param {string} options.contentPackage - The package the records belong to.
|
|
87
|
+
* @param {Readonly<Record<string, {note?: string}>>} [options.languages] - The
|
|
88
|
+
* configured languages, by code.
|
|
89
|
+
* @param {string} [options.contentBase] - The content tree, for opening a note
|
|
90
|
+
* to locate a finding.
|
|
91
|
+
* @param {(key: string) => Record<string, any>|undefined} [options.foreignRecord] -
|
|
92
|
+
* Another package's entry for a canonical address.
|
|
93
|
+
* @param {object[]} [options.problems] - Receives each finding. Omitted, the
|
|
94
|
+
* findings are collected and one Error lists them all.
|
|
95
|
+
* @param {Map<string, {raw: string}>} [options.parsed] - Notes already read, keyed by file.
|
|
96
|
+
* @returns {Array<Record<string, any>>} The same records.
|
|
97
|
+
* @throws {Error} When `problems` is omitted and anything is reported.
|
|
98
|
+
*/
|
|
99
|
+
export function resolveNoteLanguages(
|
|
100
|
+
records,
|
|
101
|
+
{ contentPackage, languages, contentBase, foreignRecord, problems, parsed },
|
|
102
|
+
) {
|
|
103
|
+
const codes = new Set([FALLBACK_LANGUAGE, ...Object.keys(languages ?? {})]);
|
|
104
|
+
const notes = new Map();
|
|
105
|
+
for (const record of records) {
|
|
106
|
+
if (!isNoteRecord(record) || !isAddressSegment(String(record.shortcode ?? ""))) continue;
|
|
107
|
+
const key = canonicalKey(contentPackage, NOTE_SYSTEM, record.type, record.shortcode);
|
|
108
|
+
if (!notes.has(key)) notes.set(key, record);
|
|
109
|
+
}
|
|
110
|
+
const localOf = (value) => {
|
|
111
|
+
const tuple = value instanceof AddressLink ? value.target : value;
|
|
112
|
+
if (!isAddressTuple(tuple) || tuple.package !== contentPackage) return undefined;
|
|
113
|
+
return notes.get(canonicalKey(contentPackage, NOTE_SYSTEM, tuple.type, tuple.shortcode));
|
|
114
|
+
};
|
|
115
|
+
|
|
116
|
+
const findings = [];
|
|
117
|
+
/**
|
|
118
|
+
* Report one finding on a note, at a frontmatter value.
|
|
119
|
+
*
|
|
120
|
+
* @param {Record<string, any>} record - The note.
|
|
121
|
+
* @param {Array<string|number>} keyPath - Where the value is written.
|
|
122
|
+
* @param {string} message - What is wrong.
|
|
123
|
+
*/
|
|
124
|
+
const report = (record, keyPath, message) => {
|
|
125
|
+
const file = contentBase ? noteFile(contentBase, record) : record.file?.path;
|
|
126
|
+
const raw =
|
|
127
|
+
parsed?.get(file)?.raw ??
|
|
128
|
+
(contentBase && fs.existsSync(file) ? fs.readFileSync(file, "utf8") : undefined);
|
|
129
|
+
findings.push({
|
|
130
|
+
file,
|
|
131
|
+
...positionOfFrontmatterPath(raw, keyPath),
|
|
132
|
+
severity: "error",
|
|
133
|
+
message,
|
|
134
|
+
});
|
|
135
|
+
};
|
|
136
|
+
|
|
137
|
+
const memo = new Map();
|
|
138
|
+
const inProgress = new Set();
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* The language a source Address states: a local note's, resolved, or the
|
|
142
|
+
* one a foreign entry publishes.
|
|
143
|
+
*
|
|
144
|
+
* @param {unknown} value - A `data.culture` or `data.parents` Address.
|
|
145
|
+
* @returns {string|undefined} The code.
|
|
146
|
+
*/
|
|
147
|
+
const statedThrough = (value) => {
|
|
148
|
+
const key = addressKey(value);
|
|
149
|
+
if (!key) return undefined;
|
|
150
|
+
const local = localOf(value);
|
|
151
|
+
if (local) return resolve(local);
|
|
152
|
+
const tuple = value instanceof AddressLink ? value.target : value;
|
|
153
|
+
if (tuple.package === contentPackage) return undefined;
|
|
154
|
+
const lang = foreignRecord?.(key)?.lang;
|
|
155
|
+
return typeof lang === "string" && lang ? lang : undefined;
|
|
156
|
+
};
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* The language a note states through its own field, its culture or its
|
|
160
|
+
* parents, ignoring the fallback.
|
|
161
|
+
*
|
|
162
|
+
* @param {Record<string, any>} record - The note.
|
|
163
|
+
* @returns {string|undefined} The code.
|
|
164
|
+
*/
|
|
165
|
+
const resolveRecord = (record) => {
|
|
166
|
+
const own = record.data?.lang;
|
|
167
|
+
if (own !== undefined && own !== null) {
|
|
168
|
+
if (typeof own === "string" && codes.has(own)) return own;
|
|
169
|
+
report(
|
|
170
|
+
record,
|
|
171
|
+
["data", "lang"],
|
|
172
|
+
`data.lang ${typeof own === "string" ? own : JSON.stringify(own)} names no ` +
|
|
173
|
+
`language: state a code \`languages:\` declares, or ${FALLBACK_LANGUAGE} ` +
|
|
174
|
+
`(${[...codes].sort().join(", ")})`,
|
|
175
|
+
);
|
|
176
|
+
}
|
|
177
|
+
const culture = record.data?.culture;
|
|
178
|
+
const cultureRecord = localOf(culture);
|
|
179
|
+
if (addressKey(culture) && cultureRecord !== record) {
|
|
180
|
+
const lang = statedThrough(culture);
|
|
181
|
+
if (lang) return lang;
|
|
182
|
+
if (isCultureNote(cultureRecord) && !inProgress.has(cultureRecord))
|
|
183
|
+
report(
|
|
184
|
+
record,
|
|
185
|
+
["data", "culture"],
|
|
186
|
+
`data.culture ${cultureRecord.type}-${cultureRecord.shortcode} resolves to ` +
|
|
187
|
+
"no language: state data.lang on the culture or on a parent culture",
|
|
188
|
+
);
|
|
189
|
+
}
|
|
190
|
+
const parents = Array.isArray(record.data?.parents) ? record.data.parents : [];
|
|
191
|
+
for (const [position, parent] of parents.entries()) {
|
|
192
|
+
const target = localOf(parent);
|
|
193
|
+
if (target && inProgress.has(target)) continue;
|
|
194
|
+
const lang = statedThrough(parent);
|
|
195
|
+
if (lang) return lang;
|
|
196
|
+
}
|
|
197
|
+
return undefined;
|
|
198
|
+
};
|
|
199
|
+
|
|
200
|
+
/**
|
|
201
|
+
* A local note's stated language, memoised; a note already being resolved
|
|
202
|
+
* is a cycle and states nothing.
|
|
203
|
+
*
|
|
204
|
+
* @param {Record<string, any>} record - The note.
|
|
205
|
+
* @returns {string|undefined} The code.
|
|
206
|
+
*/
|
|
207
|
+
const resolve = (record) => {
|
|
208
|
+
if (memo.has(record)) return memo.get(record);
|
|
209
|
+
if (inProgress.has(record)) return undefined;
|
|
210
|
+
inProgress.add(record);
|
|
211
|
+
const result = resolveRecord(record);
|
|
212
|
+
inProgress.delete(record);
|
|
213
|
+
memo.set(record, result);
|
|
214
|
+
return result;
|
|
215
|
+
};
|
|
216
|
+
|
|
217
|
+
// Walked in address order, so which note of a cycle is met first is a fact
|
|
218
|
+
// about the content rather than about directory-read order.
|
|
219
|
+
for (const key of [...notes.keys()].sort()) resolve(notes.get(key));
|
|
220
|
+
|
|
221
|
+
// Every `data.parents` entry on a cycle, whether or not a walk reached it.
|
|
222
|
+
for (const { record, position, target } of cyclicParents(notes, localOf))
|
|
223
|
+
if (!(isCultureNote(record) && isCultureNote(target)))
|
|
224
|
+
report(
|
|
225
|
+
record,
|
|
226
|
+
["data", "parents", position],
|
|
227
|
+
`data.parents.${position} names ${target.type}-${target.shortcode}, ` +
|
|
228
|
+
"which closes a parents cycle; a note is never its own ancestor",
|
|
229
|
+
);
|
|
230
|
+
for (const record of records) {
|
|
231
|
+
if (!isNoteRecord(record) || !isAddressSegment(String(record.shortcode ?? ""))) continue;
|
|
232
|
+
const own = notes.get(
|
|
233
|
+
canonicalKey(contentPackage, NOTE_SYSTEM, record.type, record.shortcode),
|
|
234
|
+
);
|
|
235
|
+
record.lang = memo.get(own) ?? FALLBACK_LANGUAGE;
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
// Each configured language note carries its code.
|
|
239
|
+
const types = new Set(["skill", ...records.map((record) => String(record.type ?? ""))]);
|
|
240
|
+
for (const [code, spec] of Object.entries(languages ?? {})) {
|
|
241
|
+
if (typeof spec?.note !== "string") continue;
|
|
242
|
+
const tuple = parseAddress(spec.note, {
|
|
243
|
+
package: contentPackage,
|
|
244
|
+
system: NOTE_SYSTEM,
|
|
245
|
+
type: "skill",
|
|
246
|
+
types,
|
|
247
|
+
});
|
|
248
|
+
const record = tuple.reason ? undefined : localOf(tuple);
|
|
249
|
+
if (record) record.languageCode = code;
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
findings.sort(
|
|
253
|
+
(a, b) =>
|
|
254
|
+
String(a.file).localeCompare(String(b.file), "en") ||
|
|
255
|
+
(a.line ?? 0) - (b.line ?? 0) ||
|
|
256
|
+
(a.column ?? 0) - (b.column ?? 0),
|
|
257
|
+
);
|
|
258
|
+
if (problems) problems.push(...findings);
|
|
259
|
+
else if (findings.length)
|
|
260
|
+
throw Object.assign(new Error(findings.map(formatDiagnostic).join("\n")), {
|
|
261
|
+
located: true,
|
|
262
|
+
});
|
|
263
|
+
return records;
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
/**
|
|
267
|
+
* Every local `data.parents` entry that lies on a cycle: its target reaches
|
|
268
|
+
* its note again through `data.parents`.
|
|
269
|
+
*
|
|
270
|
+
* @param {Map<string, Record<string, any>>} notes - Local notes by key.
|
|
271
|
+
* @param {(value: unknown) => Record<string, any>|undefined} localOf - The
|
|
272
|
+
* local note an Address names.
|
|
273
|
+
* @returns {Array<{record: Record<string, any>, position: number, target: Record<string, any>}>}
|
|
274
|
+
* The entries, in note-key then list order.
|
|
275
|
+
*/
|
|
276
|
+
function cyclicParents(notes, localOf) {
|
|
277
|
+
const edges = new Map();
|
|
278
|
+
for (const record of notes.values()) {
|
|
279
|
+
const parents = Array.isArray(record.data?.parents) ? record.data.parents : [];
|
|
280
|
+
edges.set(
|
|
281
|
+
record,
|
|
282
|
+
parents
|
|
283
|
+
.map((parent, position) => ({ position, target: localOf(parent) }))
|
|
284
|
+
.filter((edge) => edge.target),
|
|
285
|
+
);
|
|
286
|
+
}
|
|
287
|
+
// Tarjan's strongly connected components: an edge inside one component,
|
|
288
|
+
// or from a note to itself, lies on a cycle.
|
|
289
|
+
const index = new Map();
|
|
290
|
+
const low = new Map();
|
|
291
|
+
const component = new Map();
|
|
292
|
+
const stack = [];
|
|
293
|
+
const onStack = new Set();
|
|
294
|
+
let counter = 0;
|
|
295
|
+
const visit = (record) => {
|
|
296
|
+
index.set(record, counter);
|
|
297
|
+
low.set(record, counter);
|
|
298
|
+
counter++;
|
|
299
|
+
stack.push(record);
|
|
300
|
+
onStack.add(record);
|
|
301
|
+
for (const { target } of edges.get(record) ?? []) {
|
|
302
|
+
if (!index.has(target)) {
|
|
303
|
+
visit(target);
|
|
304
|
+
low.set(record, Math.min(low.get(record), low.get(target)));
|
|
305
|
+
} else if (onStack.has(target))
|
|
306
|
+
low.set(record, Math.min(low.get(record), index.get(target)));
|
|
307
|
+
}
|
|
308
|
+
if (low.get(record) !== index.get(record)) return;
|
|
309
|
+
let member;
|
|
310
|
+
do {
|
|
311
|
+
member = stack.pop();
|
|
312
|
+
onStack.delete(member);
|
|
313
|
+
component.set(member, record);
|
|
314
|
+
} while (member !== record);
|
|
315
|
+
};
|
|
316
|
+
const keys = [...notes.keys()].sort();
|
|
317
|
+
for (const key of keys) if (!index.has(notes.get(key))) visit(notes.get(key));
|
|
318
|
+
const out = [];
|
|
319
|
+
for (const key of keys) {
|
|
320
|
+
const record = notes.get(key);
|
|
321
|
+
for (const { position, target } of edges.get(record) ?? [])
|
|
322
|
+
if (target === record || component.get(target) === component.get(record))
|
|
323
|
+
out.push({ record, position, target });
|
|
324
|
+
}
|
|
325
|
+
return out;
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
/**
|
|
329
|
+
* Each language code's note, read from records alone: the Address of every
|
|
330
|
+
* record carrying a `languageCode`.
|
|
331
|
+
*
|
|
332
|
+
* @param {Iterable<Record<string, any>>} records - Index records, local and
|
|
333
|
+
* any dependency's, or link-index notes carrying them.
|
|
334
|
+
* @param {(record: Record<string, any>) => string|undefined} addressOf - A
|
|
335
|
+
* record's Address, as the caller keys notes.
|
|
336
|
+
* @returns {Map<string, string>} Code to the language note's Address; the first
|
|
337
|
+
* record naming a code wins.
|
|
338
|
+
*/
|
|
339
|
+
export function languageNotesByCode(records, addressOf) {
|
|
340
|
+
const out = new Map();
|
|
341
|
+
for (const record of records) {
|
|
342
|
+
const code = record?.languageCode;
|
|
343
|
+
if (typeof code !== "string" || out.has(code)) continue;
|
|
344
|
+
const address = addressOf(record);
|
|
345
|
+
if (address) out.set(code, address);
|
|
346
|
+
}
|
|
347
|
+
return out;
|
|
348
|
+
}
|
|
@@ -125,7 +125,7 @@ import {
|
|
|
125
125
|
requiredHeightGenders,
|
|
126
126
|
} from "./appearance-profile.mjs";
|
|
127
127
|
import { checkExtraFeatures } from "./appearance-features.mjs";
|
|
128
|
-
import {
|
|
128
|
+
import { LITERATURE_FIELDS, checkLiteratureNote } from "./literature-notes.mjs";
|
|
129
129
|
import {
|
|
130
130
|
AFFIX_FIELDS,
|
|
131
131
|
GLOSSARY_ENTRY_FIELDS,
|
|
@@ -1035,7 +1035,9 @@ export const NOTE_TOP_LEVEL_FIELDS = Object.freeze([
|
|
|
1035
1035
|
}),
|
|
1036
1036
|
Object.freeze({
|
|
1037
1037
|
name: "name",
|
|
1038
|
-
describe:
|
|
1038
|
+
describe:
|
|
1039
|
+
"The display names — a required `full`, the English name; the other names, " +
|
|
1040
|
+
"listed under the code of their language; and any `sort`.",
|
|
1039
1041
|
}),
|
|
1040
1042
|
Object.freeze({
|
|
1041
1043
|
name: "type",
|
|
@@ -1099,6 +1101,14 @@ export const NOTE_TOP_LEVEL_KEY_SET = Object.freeze(new Set(NOTE_TOP_LEVEL_KEYS)
|
|
|
1099
1101
|
* @type {readonly DataFieldSpec[]}
|
|
1100
1102
|
*/
|
|
1101
1103
|
export const SHARED_DATA_FIELDS = Object.freeze([
|
|
1104
|
+
Object.freeze({
|
|
1105
|
+
name: "lang",
|
|
1106
|
+
kind: "string",
|
|
1107
|
+
shape: "a language code `languages:` declares, or `en`",
|
|
1108
|
+
describe:
|
|
1109
|
+
"The language the note is in. Absent, the content index derives it: the " +
|
|
1110
|
+
"note's culture's language, then its `data.parents`' nearest first, then `en`.",
|
|
1111
|
+
}),
|
|
1102
1112
|
Object.freeze({
|
|
1103
1113
|
name: "seeAlso",
|
|
1104
1114
|
kind: "list",
|
|
@@ -1916,13 +1926,6 @@ export const NOTE_VOCABULARY = Object.freeze({
|
|
|
1916
1926
|
...CULTURE_FIELD,
|
|
1917
1927
|
describe: "The people this affiliation belongs to, as a culture lore note.",
|
|
1918
1928
|
},
|
|
1919
|
-
{
|
|
1920
|
-
...LANGUAGE_FIELD,
|
|
1921
|
-
describe:
|
|
1922
|
-
"The trade, temple or order tongue the affiliation keeps, as a language " +
|
|
1923
|
-
"skill note. A glossary entry that states no `language` is in it. The " +
|
|
1924
|
-
"affiliation chooses its own common vocabulary and context meanings.",
|
|
1925
|
-
},
|
|
1926
1929
|
GLOSSARY_FIELD,
|
|
1927
1930
|
{
|
|
1928
1931
|
name: "relations",
|
|
@@ -2465,7 +2468,7 @@ export const NOTE_VOCABULARY = Object.freeze({
|
|
|
2465
2468
|
accepts: ["lore"],
|
|
2466
2469
|
check: checkCultureParents,
|
|
2467
2470
|
describe:
|
|
2468
|
-
"Culture only: parent culture lore notes, searched in listed depth-first order for omitted
|
|
2471
|
+
"Culture only: parent culture lore notes, searched in listed depth-first order for an omitted appearance profile, and nearest first for an omitted `data.lang`.",
|
|
2469
2472
|
},
|
|
2470
2473
|
{
|
|
2471
2474
|
...CULTURE_FIELD,
|
|
@@ -2475,7 +2478,6 @@ export const NOTE_VOCABULARY = Object.freeze({
|
|
|
2475
2478
|
"culture, and states the field only as itself.",
|
|
2476
2479
|
},
|
|
2477
2480
|
...LITERATURE_FIELDS,
|
|
2478
|
-
LANGUAGE_FIELD,
|
|
2479
2481
|
EVENTS_FIELD,
|
|
2480
2482
|
APPEARANCE_PROFILE_FIELD,
|
|
2481
2483
|
{
|
package/engine/pdf-index.mjs
CHANGED
|
@@ -28,7 +28,7 @@
|
|
|
28
28
|
* - A note whose name opens with one of the book's `pdf.indexHeads` is also
|
|
29
29
|
* filed under the rest of its name, with the head after a comma
|
|
30
30
|
* (`Targud, Kingdom of`), and that entry carries the note's pages.
|
|
31
|
-
* -
|
|
31
|
+
* - A listed name form (`name.<code>`) and an event's in-world name
|
|
32
32
|
* (`data.events[].names`) list a cross-reference to the note's name.
|
|
33
33
|
*
|
|
34
34
|
* Terms sort on the ASCII fold the content index derives for names
|
|
@@ -42,6 +42,7 @@
|
|
|
42
42
|
*/
|
|
43
43
|
|
|
44
44
|
import { asciiName, nameSortOf } from "./name-sort.mjs";
|
|
45
|
+
import { nameFormsOf } from "./name-forms.mjs";
|
|
45
46
|
|
|
46
47
|
/**
|
|
47
48
|
* The marker an entry heading or an authored mention carries.
|
|
@@ -153,7 +154,7 @@ export function indexTerms(entries, { owns = () => true, foreign = [], heads = [
|
|
|
153
154
|
terms.push({ name: inverted, slug, main: false, inverted: true });
|
|
154
155
|
}
|
|
155
156
|
const names = [
|
|
156
|
-
...(
|
|
157
|
+
...nameFormsOf(entry.record?.name),
|
|
157
158
|
...(Array.isArray(entry.record?.data?.events) ? entry.record.data.events : []).flatMap(
|
|
158
159
|
(event) => (Array.isArray(event?.names) ? event.names.map((n) => n?.name) : []),
|
|
159
160
|
),
|
package/engine/pdf-render.mjs
CHANGED
|
@@ -2012,11 +2012,9 @@ export function bookTypstPreamble(runs = {}, families = {}) {
|
|
|
2012
2012
|
" }\n" +
|
|
2013
2013
|
" if lang != none { text(lang: lang, styled) } else { styled }\n" +
|
|
2014
2014
|
"}",
|
|
2015
|
-
// Span classes reach the book intact
|
|
2016
|
-
// weight so it remains bold inside an ordinary strong phrase.
|
|
2015
|
+
// Span classes reach the book intact.
|
|
2017
2016
|
"#let book-span(body, classes: (), lang: none) = {\n" +
|
|
2018
|
-
|
|
2019
|
-
" box(book-script-text(styled, classes: classes, lang: lang))\n" +
|
|
2017
|
+
" box(book-script-text(body, classes: classes, lang: lang))\n" +
|
|
2020
2018
|
"}",
|
|
2021
2019
|
// Use the file's natural measure for auto. Every requested width is
|
|
2022
2020
|
// bounded by the available measure and by the height of a page.
|