@heroiclands/package-build 25.5.4 → 25.5.5
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 +22 -0
- package/README.md +7 -0
- package/bin/content-commands.mjs +32 -8
- package/docs/api.md +57 -16
- package/docs/authoring/links-and-markup.md +51 -3
- package/docs/commands.md +60 -17
- package/docs/configuration.md +3 -2
- package/docs/content-format.md +10 -0
- package/docs/diagnostics.md +17 -0
- package/docs/guides/module-project.md +11 -3
- package/docs/guides/site.md +12 -2
- package/docs/guides/writing-in-invented-scripts.md +5 -2
- package/docs/reference/format-details.md +61 -11
- package/docs/reference/note-types.md +32 -32
- package/e2e.mjs +1 -1
- package/engine/base-compiler.mjs +2 -0
- package/engine/compile-corpus.mjs +6 -2
- package/engine/content-charset.mjs +1 -1
- package/engine/content-render.mjs +14 -2
- package/engine/content-scripts.mjs +44 -6
- package/engine/content-session.mjs +11 -7
- package/engine/content-spans.mjs +3 -4
- package/engine/content-verse.mjs +52 -10
- package/engine/core-report.mjs +27 -18
- package/engine/language-names.mjs +53 -23
- package/engine/markdownlint-run.mjs +1 -1
- package/engine/markup-validation.mjs +2 -0
- package/engine/note-vocabulary.mjs +10 -5
- package/engine/npc-generate.mjs +52 -7
- package/engine/page-breaks.mjs +115 -0
- package/engine/pdf-build.mjs +12 -4
- package/engine/pdf-render.mjs +10 -1
- package/engine/preview-index.mjs +4 -2
- package/engine/site-build.mjs +12 -3
- package/engine/site-preview.mjs +3 -0
- package/engine/site-render.mjs +18 -4
- package/engine/site-root.mjs +10 -1
- package/engine/site-serve-http.mjs +151 -0
- package/engine/site-serve.mjs +196 -126
- package/engine/verse-metre.mjs +50 -5
- package/engine/verse-regions.mjs +16 -0
- package/package.json +3 -3
- package/types/e2e.d.mts +1 -1
- package/types/engine/content-scripts.d.mts +9 -1
- package/types/engine/content-verse.d.mts +11 -2
- package/types/engine/core-report.d.mts +6 -2
- package/types/engine/language-names.d.mts +18 -6
- package/types/engine/npc-generate.d.mts +3 -0
- package/types/engine/page-breaks.d.mts +30 -0
- package/types/engine/pdf-render.d.mts +2 -0
- package/types/engine/site-build.d.mts +3 -1
- package/types/engine/site-render.d.mts +6 -2
- package/types/engine/site-root.d.mts +3 -1
- package/types/engine/site-serve-http.d.mts +1 -0
- package/types/engine/site-serve.d.mts +12 -6
- package/types/engine/verse-metre.d.mts +2 -1
- package/types/engine/verse-regions.d.mts +8 -0
package/engine/content-verse.mjs
CHANGED
|
@@ -140,7 +140,7 @@ export function scanVerse(source, { checkWorks = true } = {}) {
|
|
|
140
140
|
for (const message of [...region.problems, ...refusedAttributes(region.attributes)])
|
|
141
141
|
errors.push({ ...at, message });
|
|
142
142
|
if (!region.closed) errors.push({ ...at, message: "verse div needs a closing ::: line" });
|
|
143
|
-
if (!region.
|
|
143
|
+
if (!region.logicalBody.trim()) errors.push({ ...at, message: "verse div is empty" });
|
|
144
144
|
for (const key of Object.keys(region.attributes))
|
|
145
145
|
if (!VERSE_KEYS.includes(key))
|
|
146
146
|
errors.push({
|
|
@@ -150,7 +150,10 @@ export function scanVerse(source, { checkWorks = true } = {}) {
|
|
|
150
150
|
});
|
|
151
151
|
if (region.attributes.work !== undefined && !region.attributes.work.trim())
|
|
152
152
|
errors.push({ ...at, message: "work= needs a nonempty page-local key" });
|
|
153
|
-
const model = parseVerse(region.
|
|
153
|
+
const model = parseVerse(region.logicalBody, {
|
|
154
|
+
footnotes: definedFootnotes,
|
|
155
|
+
sourceOffsets: region.bodyOffsets,
|
|
156
|
+
});
|
|
154
157
|
for (const finding of model.errors) {
|
|
155
158
|
const preceding = text.slice(0, region.bodyStart + finding.offset);
|
|
156
159
|
errors.push({
|
|
@@ -254,6 +257,8 @@ function keyColumn(opening, key) {
|
|
|
254
257
|
* @property {string[]} classes - Authored presentation classes.
|
|
255
258
|
* @property {string} indent - Opening div's leading spaces.
|
|
256
259
|
* @property {string} body - Authored verse body.
|
|
260
|
+
* @property {string} logicalBody - Body without Markdown container prefixes.
|
|
261
|
+
* @property {number[]} bodyOffsets - Logical UTF-16 offsets mapped to authored body offsets, including the end.
|
|
257
262
|
* @property {VerseModel} model - Logical structure relative to the body.
|
|
258
263
|
*/
|
|
259
264
|
|
|
@@ -291,10 +296,10 @@ function cantoLine(line) {
|
|
|
291
296
|
* Offsets are UTF-16 positions relative to body. Delimiters and indentation are
|
|
292
297
|
* layout ranges; source newlines inside a logical line contribute one space.
|
|
293
298
|
* @param {string} body - Authored verse body.
|
|
294
|
-
* @param {{inline?: boolean, footnotes?: ReadonlySet<string
|
|
299
|
+
* @param {{inline?: boolean, footnotes?: ReadonlySet<string>, sourceOffsets?: number[]}} [options] - Inline quotations have no cantos; footnotes are same-note definition labels; sourceOffsets maps projected body positions to authored offsets, including the end.
|
|
295
300
|
* @returns {VerseModel} The shared structure with offsets relative to the body.
|
|
296
301
|
*/
|
|
297
|
-
export function parseVerse(body, { inline = false, footnotes = new Set() } = {}) {
|
|
302
|
+
export function parseVerse(body, { inline = false, footnotes = new Set(), sourceOffsets } = {}) {
|
|
298
303
|
const source = String(body ?? "");
|
|
299
304
|
const groups = [],
|
|
300
305
|
boundaries = [],
|
|
@@ -594,6 +599,35 @@ export function parseVerse(body, { inline = false, footnotes = new Set() } = {})
|
|
|
594
599
|
for (const group of groups)
|
|
595
600
|
if (!group.cantoBreak)
|
|
596
601
|
group.label = multi ? `${group.canto}:${group.stanza}` : String(group.stanza);
|
|
602
|
+
if (sourceOffsets) {
|
|
603
|
+
const position = (offset) => sourceOffsets[offset];
|
|
604
|
+
const bounds = (item) => {
|
|
605
|
+
item.from = position(item.from);
|
|
606
|
+
item.to = position(item.to);
|
|
607
|
+
};
|
|
608
|
+
for (const line of new Set([
|
|
609
|
+
...cantos.flatMap((canto) => canto.stanzas.flatMap((stanza) => stanza.lines)),
|
|
610
|
+
...pendingNotes,
|
|
611
|
+
])) {
|
|
612
|
+
bounds(line);
|
|
613
|
+
line.offsets = line.offsets.map(position);
|
|
614
|
+
}
|
|
615
|
+
for (const canto of cantos) for (const stanza of canto.stanzas) bounds(stanza);
|
|
616
|
+
for (const group of groups) bounds(group);
|
|
617
|
+
for (const boundary of boundaries) boundary.sourceOffset = position(boundary.sourceOffset);
|
|
618
|
+
for (const range of layout) {
|
|
619
|
+
range[0] = position(range[0]);
|
|
620
|
+
range[1] = position(range[1]);
|
|
621
|
+
}
|
|
622
|
+
for (const finding of errors) finding.offset = position(finding.offset);
|
|
623
|
+
// Container bytes remain layout in projections that read the authored body.
|
|
624
|
+
let previous = 0;
|
|
625
|
+
for (const offset of sourceOffsets) {
|
|
626
|
+
if (offset > previous) layout.push([previous, offset]);
|
|
627
|
+
previous = offset + 1;
|
|
628
|
+
}
|
|
629
|
+
layout.sort((a, b) => a[0] - b[0] || b[1] - a[1]);
|
|
630
|
+
}
|
|
597
631
|
return { cantos, groups, boundaries, layout, errors, notes: pendingNotes };
|
|
598
632
|
}
|
|
599
633
|
|
|
@@ -816,16 +850,24 @@ export function renderVerse(source, { verses = new Map() } = {}) {
|
|
|
816
850
|
!lines.slice(0, block.start).some((line) => /^ *(?:[-+*]|\d+[.)]) +/.test(line))
|
|
817
851
|
)
|
|
818
852
|
indent = "";
|
|
853
|
+
const openingPrefix = lines[block.start].slice(0, block.column - 1);
|
|
854
|
+
// A list marker belongs only to the opening line; its continuation is spaces.
|
|
855
|
+
const prefix = openingPrefix.replace(/(?:[-+*]|\d+[.)])[ \t]+/g, (mark) =>
|
|
856
|
+
" ".repeat(mark.length),
|
|
857
|
+
);
|
|
858
|
+
const container = prefix.includes(">") || openingPrefix !== prefix;
|
|
859
|
+
const continuation = container ? prefix : indent;
|
|
860
|
+
const blank = continuation.trimEnd();
|
|
819
861
|
output.push(
|
|
820
862
|
"",
|
|
821
|
-
`${indent}<div ${attrs.join(" ")}>`,
|
|
822
|
-
|
|
863
|
+
`${container ? openingPrefix : indent}<div ${attrs.join(" ")}>`,
|
|
864
|
+
blank,
|
|
823
865
|
...verseMarkdown(block.body, { labelled, model, presentation: transported })
|
|
824
866
|
.split("\n")
|
|
825
|
-
.map((line) => (line ?
|
|
826
|
-
|
|
827
|
-
`${
|
|
828
|
-
|
|
867
|
+
.map((line) => (line ? continuation + line : blank)),
|
|
868
|
+
blank,
|
|
869
|
+
`${continuation}</div>`,
|
|
870
|
+
blank,
|
|
829
871
|
);
|
|
830
872
|
cursor = block.end + 1;
|
|
831
873
|
}
|
package/engine/core-report.mjs
CHANGED
|
@@ -79,7 +79,7 @@ function idsOf(word) {
|
|
|
79
79
|
* @param {Iterable<Record<string, any>>} records - Index records.
|
|
80
80
|
* @param {{language?: string}} [options] - A language to report alone, by its
|
|
81
81
|
* name, shortcode or reference slug whether or not it states `data.core`.
|
|
82
|
-
* @returns {{concepts: number, languages: Array<{name: string, language: string, core: boolean, total: number, covered: number, missing: Array<{group: string, count: number, total: number, concepts: Array<{id: string, gloss: string}>}>}>}|{problem: string, about: string}}
|
|
82
|
+
* @returns {{concepts: number, notCovered: string[], languages: Array<{name: string, language: string, core: boolean, total: number, covered: number, missing: Array<{group: string, count: number, total: number, concepts: Array<{id: string, gloss: string}>}>}>}|{problem: string, about: string}}
|
|
83
83
|
* The report, or the problem when there is no concept list or the language
|
|
84
84
|
* asked for is none.
|
|
85
85
|
*/
|
|
@@ -113,6 +113,13 @@ export function coreReport(records, { language } = {}) {
|
|
|
113
113
|
const groups = [...new Set(concepts.map((concept) => concept.group))];
|
|
114
114
|
return {
|
|
115
115
|
concepts: concepts.length,
|
|
116
|
+
notCovered:
|
|
117
|
+
language === undefined ?
|
|
118
|
+
languages
|
|
119
|
+
.filter((record) => record.data?.core !== true)
|
|
120
|
+
.map(nameOf)
|
|
121
|
+
.sort((a, b) => a.localeCompare(b))
|
|
122
|
+
: [],
|
|
116
123
|
languages: chosen.map((record) => {
|
|
117
124
|
const have = new Set();
|
|
118
125
|
const forms = new Set();
|
|
@@ -154,24 +161,26 @@ export function coreReport(records, { language } = {}) {
|
|
|
154
161
|
* A core report as text: one block per language, its missing concepts under
|
|
155
162
|
* their groups with counts, each concept's id and gloss tab-separated.
|
|
156
163
|
*
|
|
157
|
-
*
|
|
164
|
+
* The default report ends with the names of languages it does not measure.
|
|
165
|
+
*
|
|
166
|
+
* @param {{concepts: number, notCovered: string[], languages: Array<object>}} report - A report from {@link coreReport}.
|
|
158
167
|
* @returns {string} The text, ending in a newline.
|
|
159
168
|
*/
|
|
160
169
|
export function coreReportText(report) {
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
for (const
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
})
|
|
176
|
-
|
|
170
|
+
const blocks = report.languages.map((one) => {
|
|
171
|
+
if (!one.missing.length)
|
|
172
|
+
return `${one.name} (${one.language}): all ${one.total} core concepts have a word\n`;
|
|
173
|
+
const lines = [
|
|
174
|
+
`${one.name} (${one.language}): ${one.total - one.covered} of ${one.total} core concepts have no word`,
|
|
175
|
+
];
|
|
176
|
+
for (const group of one.missing) {
|
|
177
|
+
lines.push(` ${group.group}: ${group.count} of ${group.total}`);
|
|
178
|
+
for (const concept of group.concepts) lines.push(` ${concept.id}\t${concept.gloss}`);
|
|
179
|
+
}
|
|
180
|
+
return `${lines.join("\n")}\n`;
|
|
181
|
+
});
|
|
182
|
+
if (!blocks.length) blocks.push("No language states `data.core: true`.\n");
|
|
183
|
+
if (report.notCovered.length)
|
|
184
|
+
blocks.push(`Not covered (no data.core): ${report.notCovered.join(", ")}\n`);
|
|
185
|
+
return blocks.join("\n");
|
|
177
186
|
}
|
|
@@ -29,11 +29,11 @@
|
|
|
29
29
|
* tongue whose forms differ by gender states a map of gender words to a
|
|
30
30
|
* pattern or a list of them. A gender with no pattern of its own takes every
|
|
31
31
|
* pattern stated.
|
|
32
|
-
* - `given` —
|
|
33
|
-
*
|
|
34
|
-
* person's gender's list, or
|
|
35
|
-
* has none
|
|
36
|
-
* person's gender, as a patronymic draws a father's name.
|
|
32
|
+
* - `given` — one list of names for everyone, or a map of gender words to
|
|
33
|
+
* lists. The map requires `female` and `male`. `{given}` draws from the shared
|
|
34
|
+
* list, the person's gender's list, or every given list together where that
|
|
35
|
+
* gender has none. `{given.<gender>}` draws from the shared list or that
|
|
36
|
+
* gender's list whatever the person's gender, as a patronymic draws a father's name.
|
|
37
37
|
* - `en` — English names, in groups of lists: `en.epithet` is a list a pattern
|
|
38
38
|
* draws from as `{en.epithet}`, as `"{given} the {en.epithet}"` does. English
|
|
39
39
|
* names are never encoded in the tongue's script or checked against its
|
|
@@ -73,7 +73,7 @@ const LIST_NAME = /^[A-Za-z][A-Za-z0-9_]*$/;
|
|
|
73
73
|
const PLACEHOLDER = /^([A-Za-z][A-Za-z0-9_]*)(?:\.([A-Za-z][A-Za-z0-9_]*))?$/;
|
|
74
74
|
|
|
75
75
|
/**
|
|
76
|
-
* The genders
|
|
76
|
+
* The genders a mapped `data.names.given` states a list for.
|
|
77
77
|
*
|
|
78
78
|
* A function rather than a `const`: `note-vocabulary.mjs` reads it while the
|
|
79
79
|
* module graph is still evaluating.
|
|
@@ -317,8 +317,12 @@ export function namesProblems(names, lookVocabulary) {
|
|
|
317
317
|
|
|
318
318
|
const given = names[GIVEN_LIST];
|
|
319
319
|
if (given === undefined || given === null) shape([], "`data.names` must state `given`", true);
|
|
320
|
+
else if (Array.isArray(given)) checkList([GIVEN_LIST]);
|
|
320
321
|
else if (!isPlainMap(given))
|
|
321
|
-
shape(
|
|
322
|
+
shape(
|
|
323
|
+
[GIVEN_LIST],
|
|
324
|
+
`\`${label([GIVEN_LIST])}\` should be a list of names or a map of genders to lists`,
|
|
325
|
+
);
|
|
322
326
|
else {
|
|
323
327
|
const genders = genderWords(lookVocabulary);
|
|
324
328
|
for (const gender of requiredGivenGenders()) {
|
|
@@ -428,7 +432,18 @@ export function namesProblems(names, lookVocabulary) {
|
|
|
428
432
|
}
|
|
429
433
|
named.add(part.list);
|
|
430
434
|
if (part.gender !== undefined) {
|
|
431
|
-
if (
|
|
435
|
+
if (Array.isArray(given)) {
|
|
436
|
+
if (!genders.includes(part.gender)) {
|
|
437
|
+
const guess = nearest(part.gender, genders);
|
|
438
|
+
problem(
|
|
439
|
+
path,
|
|
440
|
+
`${JSON.stringify(part.gender)} is not a gender; ` +
|
|
441
|
+
`\`{${placeholderText(part)}}\` in \`${label(path)}\` takes ` +
|
|
442
|
+
genders.join(", ") +
|
|
443
|
+
(guess ? `. Did you mean "${guess}"?` : ""),
|
|
444
|
+
);
|
|
445
|
+
}
|
|
446
|
+
} else if (givenMap[part.gender] === undefined || givenMap[part.gender] === null)
|
|
432
447
|
problem(
|
|
433
448
|
path,
|
|
434
449
|
`\`{${placeholderText(part)}}\` in \`${label(path)}\` names no list: ` +
|
|
@@ -476,7 +491,7 @@ export function namesProblems(names, lookVocabulary) {
|
|
|
476
491
|
* keys cannot: a pattern that does not parse, a placeholder naming no list, a
|
|
477
492
|
* list no pattern names, a list name a placeholder cannot spell, an empty list
|
|
478
493
|
* of patterns or of names, an empty name, a pattern or given list keyed by no
|
|
479
|
-
* gender, and a missing `female` or `male` list. Each is located at the pattern, list, entry or key at fault.
|
|
494
|
+
* gender, and a missing `female` or `male` list in a gender map. Each is located at the pattern, list, entry or key at fault.
|
|
480
495
|
*
|
|
481
496
|
* @param {object} note - The note: `file`, `raw` and parsed `fm`.
|
|
482
497
|
* @param {Readonly<Record<string, readonly {value: string}[]>>} lookVocabulary -
|
|
@@ -534,8 +549,8 @@ export function namesFindings(note, lookVocabulary) {
|
|
|
534
549
|
}
|
|
535
550
|
|
|
536
551
|
/**
|
|
537
|
-
* The given names a gender draws from: its own list, or
|
|
538
|
-
* together where it has none.
|
|
552
|
+
* The given names a gender draws from: the shared list, its own list, or
|
|
553
|
+
* every given list together where it has none.
|
|
539
554
|
*
|
|
540
555
|
* @param {object} names - Checked `data.names`.
|
|
541
556
|
* @param {string} gender - A gender word.
|
|
@@ -543,6 +558,7 @@ export function namesFindings(note, lookVocabulary) {
|
|
|
543
558
|
*/
|
|
544
559
|
export function givenNames(names, gender) {
|
|
545
560
|
const given = names[GIVEN_LIST];
|
|
561
|
+
if (Array.isArray(given)) return given;
|
|
546
562
|
if (Array.isArray(given[gender])) return given[gender];
|
|
547
563
|
return [...new Set(Object.values(given).filter(Array.isArray).flat())];
|
|
548
564
|
}
|
|
@@ -551,29 +567,42 @@ export function givenNames(names, gender) {
|
|
|
551
567
|
function listFor(names, part, gender) {
|
|
552
568
|
if (part.sub !== undefined) return names[part.list][part.sub];
|
|
553
569
|
if (part.list !== GIVEN_LIST) return names[part.list];
|
|
554
|
-
if (part.gender !== undefined) return names
|
|
570
|
+
if (part.gender !== undefined) return givenNames(names, part.gender);
|
|
555
571
|
return givenNames(names, gender);
|
|
556
572
|
}
|
|
557
573
|
|
|
558
574
|
/**
|
|
559
|
-
* A name
|
|
560
|
-
*
|
|
575
|
+
* A name's selected literal and placeholder parts. One of the gender's
|
|
576
|
+
* patterns is selected first, then each placeholder is an even draw from its
|
|
577
|
+
* source list.
|
|
561
578
|
*
|
|
562
579
|
* @param {object} names - Checked `data.names`.
|
|
563
580
|
* @param {string} gender - A gender word.
|
|
564
581
|
* @param {() => number} next - A stream of numbers in `[0, 1)`.
|
|
565
|
-
* @returns {string} The
|
|
582
|
+
* @returns {(PatternPart & {text: string})[]} The selected parts in pattern order.
|
|
566
583
|
*/
|
|
567
|
-
export function
|
|
584
|
+
export function drawNameParts(names, gender, next) {
|
|
568
585
|
const patterns = patternsFor(names, gender);
|
|
569
586
|
const pattern =
|
|
570
587
|
patterns.length > 1 ? patterns[Math.floor(next() * patterns.length)] : patterns[0];
|
|
571
|
-
return parsePattern(pattern)
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
588
|
+
return parsePattern(pattern).parts.map((part) => {
|
|
589
|
+
if ("literal" in part) return { ...part, text: part.literal };
|
|
590
|
+
const list = listFor(names, part, gender);
|
|
591
|
+
return { ...part, text: list[Math.floor(next() * list.length)] };
|
|
592
|
+
});
|
|
593
|
+
}
|
|
594
|
+
|
|
595
|
+
/**
|
|
596
|
+
* A name drawn from a language's lists.
|
|
597
|
+
*
|
|
598
|
+
* @param {object} names - Checked `data.names`.
|
|
599
|
+
* @param {string} gender - A gender word.
|
|
600
|
+
* @param {() => number} next - A stream of numbers in `[0, 1)`.
|
|
601
|
+
* @returns {string} The name.
|
|
602
|
+
*/
|
|
603
|
+
export function drawName(names, gender, next) {
|
|
604
|
+
return drawNameParts(names, gender, next)
|
|
605
|
+
.map((part) => part.text)
|
|
577
606
|
.join("");
|
|
578
607
|
}
|
|
579
608
|
|
|
@@ -640,7 +669,8 @@ export function nameMisfit(full, names, gender) {
|
|
|
640
669
|
const text = nfc(full);
|
|
641
670
|
const given = names[GIVEN_LIST];
|
|
642
671
|
const own = givenNames(names, gender).map(nfc);
|
|
643
|
-
const every =
|
|
672
|
+
const every = (
|
|
673
|
+
Array.isArray(given) ? given : Object.values(given).filter(Array.isArray).flat()).map(nfc);
|
|
644
674
|
const lookup = (forGiven) => (part) =>
|
|
645
675
|
part.list === GIVEN_LIST && part.gender === undefined ?
|
|
646
676
|
forGiven
|
|
@@ -669,7 +669,7 @@ function protectLintVerse(source) {
|
|
|
669
669
|
};
|
|
670
670
|
for (const region of verseRegions(syntax)) {
|
|
671
671
|
if (!region.closed) continue;
|
|
672
|
-
referencesInVerse(region.
|
|
672
|
+
referencesInVerse(region.logicalBody, parseVerse(region.logicalBody, { footnotes }));
|
|
673
673
|
ranges.push([region.bodyStart, starts[region.end]]);
|
|
674
674
|
for (let line = region.start + 1; line < region.end; line++) {
|
|
675
675
|
bodyLines.add(line + 1);
|
|
@@ -11,6 +11,7 @@
|
|
|
11
11
|
* SPDX-License-Identifier: GPL-3.0-or-later
|
|
12
12
|
*/
|
|
13
13
|
|
|
14
|
+
import { scanPageBreaks } from "./page-breaks.mjs";
|
|
14
15
|
import { scanBlocks } from "./content-blocks.mjs";
|
|
15
16
|
import { scanAlerts } from "./content-alerts.mjs";
|
|
16
17
|
import { scanRuns } from "./content-scripts.mjs";
|
|
@@ -74,6 +75,7 @@ export function markupFindings(source, file, syntax, runs) {
|
|
|
74
75
|
const errors = [
|
|
75
76
|
...(scanned?.blocks ?? scanBlocks(body)).errors,
|
|
76
77
|
...(scanned?.alerts ?? scanAlerts(body)).errors,
|
|
78
|
+
...scanPageBreaks(body).errors,
|
|
77
79
|
...verseErrors,
|
|
78
80
|
...(marked?.errors ?? []),
|
|
79
81
|
...shortcodeErrors(body),
|
|
@@ -771,7 +771,7 @@ const NAMES_FIELD = Object.freeze({
|
|
|
771
771
|
describe:
|
|
772
772
|
"How this language's speakers are named, for `package-build npc new`: `pattern` " +
|
|
773
773
|
'builds a name from placeholders such as "{given} {clan}" and literal text, `given` ' +
|
|
774
|
-
"
|
|
774
|
+
"is one list of given names for everyone or a map by gender with " +
|
|
775
775
|
`${wordList([...requiredGivenGenders()]).replace(" or ", " and ")} required, and ` +
|
|
776
776
|
"`en` holds groups of English names, and every other key is a list of names a " +
|
|
777
777
|
"placeholder of the same name draws from. A " +
|
|
@@ -807,12 +807,17 @@ const NAMES_FIELD = Object.freeze({
|
|
|
807
807
|
}),
|
|
808
808
|
Object.freeze({
|
|
809
809
|
name: "given",
|
|
810
|
-
kind: "map",
|
|
810
|
+
kind: "list-or-map",
|
|
811
811
|
required: true,
|
|
812
|
-
shape: "a map of genders to lists of names",
|
|
812
|
+
shape: "a list of names or a map of genders to lists of names",
|
|
813
813
|
describe:
|
|
814
|
-
"
|
|
815
|
-
"list together.",
|
|
814
|
+
"One shared list supplies every gender. A gender map requires female and male; " +
|
|
815
|
+
"a gender with no list draws from every given list together.",
|
|
816
|
+
entries: Object.freeze({
|
|
817
|
+
kind: "string",
|
|
818
|
+
shape: "a name",
|
|
819
|
+
describe: "One name, as written.",
|
|
820
|
+
}),
|
|
816
821
|
values: Object.freeze({
|
|
817
822
|
kind: "list",
|
|
818
823
|
shape: "a list of names",
|
package/engine/npc-generate.mjs
CHANGED
|
@@ -32,9 +32,11 @@
|
|
|
32
32
|
*
|
|
33
33
|
* - `name.full` — drawn from the culture's language's name lists by their
|
|
34
34
|
* pattern, the given name from the list for `data.gender` (see
|
|
35
|
-
* `language-names.mjs`).
|
|
36
|
-
*
|
|
37
|
-
* the
|
|
35
|
+
* `language-names.mjs`). Each drawn name word starts with a capital in the
|
|
36
|
+
* English display form; the pattern's literal connectors keep their case.
|
|
37
|
+
* A different source form with no English list part is retained under the
|
|
38
|
+
* language code. A name the spec states is kept, and must fit the source or
|
|
39
|
+
* display-form lists.
|
|
38
40
|
* - `shortcode` — the name transliterated to lowercase ASCII letters and
|
|
39
41
|
* digits, cut to twelve characters, with a counter replacing its tail where a
|
|
40
42
|
* being in the package already holds it.
|
|
@@ -70,7 +72,7 @@ import { parseNoteDate } from "./note-dates.mjs";
|
|
|
70
72
|
import { eraYear } from "./calendars.mjs";
|
|
71
73
|
import { parseAddress, renderAddress } from "./address.mjs";
|
|
72
74
|
import { isPlainMap } from "./data-keys.mjs";
|
|
73
|
-
import {
|
|
75
|
+
import { drawNameParts, nameMisfit } from "./language-names.mjs";
|
|
74
76
|
|
|
75
77
|
/** The youngest and oldest age, in whole years, a generated person is born to. */
|
|
76
78
|
export const ADULT_AGE = Object.freeze({ min: 18, max: 60 });
|
|
@@ -152,6 +154,27 @@ export function canonicalSpec(value) {
|
|
|
152
154
|
/** Whether a spec value counts as given — any value but `undefined`. */
|
|
153
155
|
const given = (value) => value !== undefined;
|
|
154
156
|
|
|
157
|
+
/** Each name word starts with a capital and keeps its remaining letters. */
|
|
158
|
+
const displayNameWords = (name) =>
|
|
159
|
+
name.replace(/\p{L}[\p{L}\p{M}'’]*/gu, (word) =>
|
|
160
|
+
word.replace(/^\p{L}/u, (letter) => letter.toUpperCase()),
|
|
161
|
+
);
|
|
162
|
+
|
|
163
|
+
/** Name lists in English display case; literal pattern text keeps its case. */
|
|
164
|
+
function displayNameLists(names) {
|
|
165
|
+
const display = (value) =>
|
|
166
|
+
Array.isArray(value) ? value.map(displayNameWords)
|
|
167
|
+
: isPlainMap(value) ?
|
|
168
|
+
Object.fromEntries(Object.entries(value).map(([key, list]) => [key, display(list)]))
|
|
169
|
+
: value;
|
|
170
|
+
return Object.fromEntries(
|
|
171
|
+
Object.entries(names).map(([key, value]) => [
|
|
172
|
+
key,
|
|
173
|
+
key === "pattern" ? value : display(value),
|
|
174
|
+
]),
|
|
175
|
+
);
|
|
176
|
+
}
|
|
177
|
+
|
|
155
178
|
/** The value at a dotted path, or `undefined`. */
|
|
156
179
|
function at(object, path) {
|
|
157
180
|
let value = object;
|
|
@@ -433,6 +456,8 @@ function frontmatterYaml(frontmatter) {
|
|
|
433
456
|
* by `namesProblems`.
|
|
434
457
|
* @param {string} [context.language] - The language's Address, for an error
|
|
435
458
|
* about a name that does not fit.
|
|
459
|
+
* @param {string} [context.languageCode] - The language code under which a
|
|
460
|
+
* generated name's distinct source form is retained.
|
|
436
461
|
* @param {string} [context.seed] - Text joined to the spec's own seed, to draw a
|
|
437
462
|
* different person from the same spec.
|
|
438
463
|
* @param {unknown} [context.currentDate] - The setting's neutral current date, defaulting to "720.1".
|
|
@@ -460,7 +485,9 @@ export function generateNpc(spec, context) {
|
|
|
460
485
|
const gender = data.gender;
|
|
461
486
|
const statedName = spec.name?.full;
|
|
462
487
|
if (given(statedName)) {
|
|
463
|
-
const
|
|
488
|
+
const displayNames = displayNameLists(names);
|
|
489
|
+
const misfit =
|
|
490
|
+
nameMisfit(statedName, names, gender) && nameMisfit(statedName, displayNames, gender);
|
|
464
491
|
if (misfit)
|
|
465
492
|
throw Object.assign(
|
|
466
493
|
new Error(
|
|
@@ -470,7 +497,25 @@ export function generateNpc(spec, context) {
|
|
|
470
497
|
{ keyPath: ["name", "full"] },
|
|
471
498
|
);
|
|
472
499
|
}
|
|
473
|
-
const
|
|
500
|
+
const nameParts = given(statedName) ? [] : drawNameParts(names, gender, draw("name.full"));
|
|
501
|
+
const sourceName = given(statedName) ? statedName : nameParts.map((part) => part.text).join("");
|
|
502
|
+
const name =
|
|
503
|
+
given(statedName) ? statedName : (
|
|
504
|
+
nameParts
|
|
505
|
+
.map((part) => ("literal" in part ? part.text : displayNameWords(part.text)))
|
|
506
|
+
.join("")
|
|
507
|
+
);
|
|
508
|
+
const languageCode = context.languageCode;
|
|
509
|
+
const sourceAlias =
|
|
510
|
+
(
|
|
511
|
+
!given(statedName) &&
|
|
512
|
+
languageCode &&
|
|
513
|
+
languageCode !== "en" &&
|
|
514
|
+
sourceName !== name &&
|
|
515
|
+
!nameParts.some((part) => "list" in part && part.list === "en")
|
|
516
|
+
) ?
|
|
517
|
+
{ [languageCode]: [sourceName] }
|
|
518
|
+
: {};
|
|
474
519
|
|
|
475
520
|
const height =
|
|
476
521
|
given(data.height) ?
|
|
@@ -537,7 +582,7 @@ export function generateNpc(spec, context) {
|
|
|
537
582
|
subType: "npc",
|
|
538
583
|
tags: ["generated", "draft"],
|
|
539
584
|
...frontmatterSpec,
|
|
540
|
-
name: { full: name, ...(isPlainMap(spec.name) ? spec.name : {}) },
|
|
585
|
+
name: { full: name, ...sourceAlias, ...(isPlainMap(spec.name) ? spec.name : {}) },
|
|
541
586
|
data: fullData,
|
|
542
587
|
},
|
|
543
588
|
TOP_ORDER,
|
|
@@ -0,0 +1,115 @@
|
|
|
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
|
+
/** Authored book page breaks and their positioned syntax findings. @module */
|
|
15
|
+
|
|
16
|
+
import { blockTokens } from "./markdown-blocks.mjs";
|
|
17
|
+
import { scanBlocks } from "./content-blocks.mjs";
|
|
18
|
+
import { parseExtensionAttributes, refusedAttributes } from "./extension-attributes.mjs";
|
|
19
|
+
|
|
20
|
+
/** A standalone attributed break, including Markdown container prefixes. */
|
|
21
|
+
const BREAK_LINE =
|
|
22
|
+
/^(?:[ \t]*(?:>[ \t]?|(?:[-+*]|\d{1,9}[.)])[ \t]+))*[ \t]*(---|\*\*\*|___)[ \t]+(\{[^\r\n]*\})[ \t]*$/;
|
|
23
|
+
const PLACEMENT = "a page break stands between blocks at the top level of a note";
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Read page-break markers before any surface rewrites their source.
|
|
27
|
+
* Code, HTML examples and entire verse divs remain literal. Parser ranges
|
|
28
|
+
* retain list and quote membership, including lazy continuation lines.
|
|
29
|
+
* @param {string} source - Authored Markdown body.
|
|
30
|
+
* @returns {{breaks: Array<{line: number, column: number}>, errors: Array<{line: number, column: number, message: string}>}}
|
|
31
|
+
*/
|
|
32
|
+
export function scanPageBreaks(source) {
|
|
33
|
+
const text = String(source ?? "");
|
|
34
|
+
const lines = text.split(/\r?\n/);
|
|
35
|
+
if (!lines.some((line) => BREAK_LINE.test(line))) return { breaks: [], errors: [] };
|
|
36
|
+
const literal = new Set();
|
|
37
|
+
const nested = new Set();
|
|
38
|
+
for (const token of blockTokens(text)) {
|
|
39
|
+
if (!token.map) continue;
|
|
40
|
+
const target =
|
|
41
|
+
["fence", "code_block", "html_block", "verse_div"].includes(token.type) ? literal
|
|
42
|
+
: ["blockquote_open", "list_item_open"].includes(token.type) ? nested
|
|
43
|
+
: undefined;
|
|
44
|
+
if (target) for (let line = token.map[0]; line < token.map[1]; line++) target.add(line);
|
|
45
|
+
}
|
|
46
|
+
for (const block of scanBlocks(text).blocks)
|
|
47
|
+
for (let line = block.start + 1; line < block.end; line++) nested.add(line);
|
|
48
|
+
const breaks = [],
|
|
49
|
+
errors = [];
|
|
50
|
+
for (let i = 0; i < lines.length; i++) {
|
|
51
|
+
if (literal.has(i)) continue;
|
|
52
|
+
const match = BREAK_LINE.exec(lines[i]);
|
|
53
|
+
if (!match) continue;
|
|
54
|
+
const parsed = parseExtensionAttributes(match[2].slice(1, -1));
|
|
55
|
+
if (match[1] !== "---" && !parsed.classes.includes("page-break")) continue;
|
|
56
|
+
const column = lines[i].indexOf(match[1]) + 1;
|
|
57
|
+
const attributeColumn = lines[i].indexOf("{") + 1;
|
|
58
|
+
const at = (message) => ({ line: i + 1, column: attributeColumn, message });
|
|
59
|
+
const problems = [...parsed.problems, ...refusedAttributes(parsed.values)];
|
|
60
|
+
if (
|
|
61
|
+
parsed.classes.some((name) => name !== "page-break") ||
|
|
62
|
+
(!parsed.classes.length &&
|
|
63
|
+
!parsed.id &&
|
|
64
|
+
!Object.keys(parsed.values).length &&
|
|
65
|
+
!problems.length)
|
|
66
|
+
)
|
|
67
|
+
problems.push("a page break accepts only .page-break");
|
|
68
|
+
if (parsed.id) problems.push("a page break does not accept an id");
|
|
69
|
+
if (Object.keys(parsed.values).length)
|
|
70
|
+
problems.push("a page break does not accept attributes");
|
|
71
|
+
if (match[1] !== "---")
|
|
72
|
+
errors.push({ line: i + 1, column, message: "only --- accepts {.page-break}" });
|
|
73
|
+
if (
|
|
74
|
+
nested.has(i) ||
|
|
75
|
+
(i > 0 && lines[i - 1].trim()) ||
|
|
76
|
+
(i + 1 < lines.length && lines[i + 1].trim())
|
|
77
|
+
)
|
|
78
|
+
errors.push({ line: i + 1, column, message: PLACEMENT });
|
|
79
|
+
errors.push(...problems.map(at));
|
|
80
|
+
if (match[1] === "---" && !problems.length && !errors.some((error) => error.line === i + 1))
|
|
81
|
+
breaks.push({ line: i + 1, column });
|
|
82
|
+
}
|
|
83
|
+
return { breaks, errors };
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Omit valid page breaks on a screen, preserving the surrounding block boundaries.
|
|
88
|
+
* @param {string} source - Markdown body.
|
|
89
|
+
* @returns {string} Markdown without valid page-break lines.
|
|
90
|
+
*/
|
|
91
|
+
export function omitPageBreaks(source) {
|
|
92
|
+
const lines = String(source ?? "").split("\n");
|
|
93
|
+
for (const marker of scanPageBreaks(source).breaks) lines[marker.line - 1] = "";
|
|
94
|
+
return lines.join("\n");
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Produce the book's page-break token, leaving ordinary thematic breaks alone.
|
|
99
|
+
* @param {import("markdown-it")} md - The book parser.
|
|
100
|
+
* @returns {void}
|
|
101
|
+
*/
|
|
102
|
+
export function pageBreakPlugin(md) {
|
|
103
|
+
md.block.ruler.before("hr", "page_break", (state, startLine, _endLine, silent) => {
|
|
104
|
+
if (state.env.pageBreaks === false) return false;
|
|
105
|
+
const line = state.src.slice(state.bMarks[startLine], state.eMarks[startLine]);
|
|
106
|
+
if (!BREAK_LINE.test(line)) return false;
|
|
107
|
+
if (!scanPageBreaks(state.src).breaks.some((marker) => marker.line === startLine + 1))
|
|
108
|
+
return false;
|
|
109
|
+
if (silent) return true;
|
|
110
|
+
const token = state.push("page_break", "", 0);
|
|
111
|
+
token.map = [startLine, startLine + 1];
|
|
112
|
+
state.line = startLine + 1;
|
|
113
|
+
return true;
|
|
114
|
+
});
|
|
115
|
+
}
|
package/engine/pdf-build.mjs
CHANGED
|
@@ -1385,7 +1385,9 @@ async function buildBook(book, shared) {
|
|
|
1385
1385
|
url: site,
|
|
1386
1386
|
elsewhere,
|
|
1387
1387
|
findings,
|
|
1388
|
-
runs: runContext(resolved, records
|
|
1388
|
+
runs: runContext(resolved, records, {
|
|
1389
|
+
referenceName: (target) => resolveInfoboxRef(gates.index, target)?.name,
|
|
1390
|
+
}),
|
|
1389
1391
|
file: page.file,
|
|
1390
1392
|
bodyLine: page.bodyLine,
|
|
1391
1393
|
prepared: true,
|
|
@@ -1476,7 +1478,9 @@ async function buildBook(book, shared) {
|
|
|
1476
1478
|
elsewhere,
|
|
1477
1479
|
indexMentions: true,
|
|
1478
1480
|
findings,
|
|
1479
|
-
runs: runContext(resolved, records
|
|
1481
|
+
runs: runContext(resolved, records, {
|
|
1482
|
+
referenceName: (target) => resolveInfoboxRef(gates.index, target)?.name,
|
|
1483
|
+
}),
|
|
1480
1484
|
file,
|
|
1481
1485
|
}),
|
|
1482
1486
|
);
|
|
@@ -1508,7 +1512,9 @@ async function buildBook(book, shared) {
|
|
|
1508
1512
|
elsewhere,
|
|
1509
1513
|
indexMentions: true,
|
|
1510
1514
|
findings,
|
|
1511
|
-
runs: runContext(resolved, records
|
|
1515
|
+
runs: runContext(resolved, records, {
|
|
1516
|
+
referenceName: (target) => resolveInfoboxRef(gates.index, target)?.name,
|
|
1517
|
+
}),
|
|
1512
1518
|
file,
|
|
1513
1519
|
});
|
|
1514
1520
|
} catch {
|
|
@@ -1682,7 +1688,9 @@ async function buildBook(book, shared) {
|
|
|
1682
1688
|
copyright: shared.copyright,
|
|
1683
1689
|
license: shared.license,
|
|
1684
1690
|
index,
|
|
1685
|
-
runs: runContext(resolved, records
|
|
1691
|
+
runs: runContext(resolved, records, {
|
|
1692
|
+
referenceName: (target) => resolveInfoboxRef(gates.index, target)?.name,
|
|
1693
|
+
}),
|
|
1686
1694
|
});
|
|
1687
1695
|
// Last, over the whole document: a reference can only be checked once every
|
|
1688
1696
|
// declaration is in one string, and Typst treats a dangling one as fatal.
|