@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.
Files changed (57) hide show
  1. package/CHANGELOG.md +22 -0
  2. package/README.md +7 -0
  3. package/bin/content-commands.mjs +32 -8
  4. package/docs/api.md +57 -16
  5. package/docs/authoring/links-and-markup.md +51 -3
  6. package/docs/commands.md +60 -17
  7. package/docs/configuration.md +3 -2
  8. package/docs/content-format.md +10 -0
  9. package/docs/diagnostics.md +17 -0
  10. package/docs/guides/module-project.md +11 -3
  11. package/docs/guides/site.md +12 -2
  12. package/docs/guides/writing-in-invented-scripts.md +5 -2
  13. package/docs/reference/format-details.md +61 -11
  14. package/docs/reference/note-types.md +32 -32
  15. package/e2e.mjs +1 -1
  16. package/engine/base-compiler.mjs +2 -0
  17. package/engine/compile-corpus.mjs +6 -2
  18. package/engine/content-charset.mjs +1 -1
  19. package/engine/content-render.mjs +14 -2
  20. package/engine/content-scripts.mjs +44 -6
  21. package/engine/content-session.mjs +11 -7
  22. package/engine/content-spans.mjs +3 -4
  23. package/engine/content-verse.mjs +52 -10
  24. package/engine/core-report.mjs +27 -18
  25. package/engine/language-names.mjs +53 -23
  26. package/engine/markdownlint-run.mjs +1 -1
  27. package/engine/markup-validation.mjs +2 -0
  28. package/engine/note-vocabulary.mjs +10 -5
  29. package/engine/npc-generate.mjs +52 -7
  30. package/engine/page-breaks.mjs +115 -0
  31. package/engine/pdf-build.mjs +12 -4
  32. package/engine/pdf-render.mjs +10 -1
  33. package/engine/preview-index.mjs +4 -2
  34. package/engine/site-build.mjs +12 -3
  35. package/engine/site-preview.mjs +3 -0
  36. package/engine/site-render.mjs +18 -4
  37. package/engine/site-root.mjs +10 -1
  38. package/engine/site-serve-http.mjs +151 -0
  39. package/engine/site-serve.mjs +196 -126
  40. package/engine/verse-metre.mjs +50 -5
  41. package/engine/verse-regions.mjs +16 -0
  42. package/package.json +3 -3
  43. package/types/e2e.d.mts +1 -1
  44. package/types/engine/content-scripts.d.mts +9 -1
  45. package/types/engine/content-verse.d.mts +11 -2
  46. package/types/engine/core-report.d.mts +6 -2
  47. package/types/engine/language-names.d.mts +18 -6
  48. package/types/engine/npc-generate.d.mts +3 -0
  49. package/types/engine/page-breaks.d.mts +30 -0
  50. package/types/engine/pdf-render.d.mts +2 -0
  51. package/types/engine/site-build.d.mts +3 -1
  52. package/types/engine/site-render.d.mts +6 -2
  53. package/types/engine/site-root.d.mts +3 -1
  54. package/types/engine/site-serve-http.d.mts +1 -0
  55. package/types/engine/site-serve.d.mts +12 -6
  56. package/types/engine/verse-metre.d.mts +2 -1
  57. package/types/engine/verse-regions.d.mts +8 -0
@@ -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.body.trim()) errors.push({ ...at, message: "verse div is empty" });
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.body, { footnotes: definedFootnotes });
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>}} [options] - Inline quotations have no cantos; footnotes are same-note definition labels.
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 ? indent + line : line)),
826
- "",
827
- `${indent}</div>`,
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
  }
@@ -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
- * @param {{concepts: number, languages: Array<object>}} report - A report from {@link coreReport}.
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
- if (!report.languages.length) return "No language states `data.core: true`.\n";
162
- return report.languages
163
- .map((one) => {
164
- if (!one.missing.length)
165
- return `${one.name} (${one.language}): all ${one.total} core concepts have a word\n`;
166
- const lines = [
167
- `${one.name} (${one.language}): ${one.total - one.covered} of ${one.total} core concepts have no word`,
168
- ];
169
- for (const group of one.missing) {
170
- lines.push(` ${group.group}: ${group.count} of ${group.total}`);
171
- for (const concept of group.concepts)
172
- lines.push(` ${concept.id}\t${concept.gloss}`);
173
- }
174
- return `${lines.join("\n")}\n`;
175
- })
176
- .join("\n");
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` — given names, one list per gender word of the closed gender
33
- * vocabulary. `female` and `male` are required. `{given}` draws from the
34
- * person's gender's list, or from every given list together where that gender
35
- * has none; `{given.<gender>}` draws from that gender's list whatever the
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 every `data.names.given` states a list for.
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([GIVEN_LIST], `\`${label([GIVEN_LIST])}\` should be a map of genders to lists`);
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 (givenMap[part.gender] === undefined || givenMap[part.gender] === null)
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 every given list
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[GIVEN_LIST][part.gender];
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 drawn from a language's lists: one of the gender's patterns, where it
560
- * has more than one, then each placeholder an even draw from its list.
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 name.
582
+ * @returns {(PatternPart & {text: string})[]} The selected parts in pattern order.
566
583
  */
567
- export function drawName(names, gender, next) {
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
- .parts.map((part) => {
573
- if ("literal" in part) return part.literal;
574
- const list = listFor(names, part, gender);
575
- return list[Math.floor(next() * list.length)];
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 = Object.values(given).filter(Array.isArray).flat().map(nfc);
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.body, parseVerse(region.body, { footnotes }));
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
- "maps a gender to a list of given names, " +
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
- "Given names by gender word. A gender with no list draws from every given " +
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",
@@ -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`). A name the spec states is kept, and must fit those
36
- * lists: every placeholder filled by a name of its list, the given name from
37
- * the gender's.
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 { drawName, nameMisfit } from "./language-names.mjs";
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 misfit = nameMisfit(statedName, names, gender);
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 name = given(statedName) ? statedName : drawName(names, gender, draw("name.full"));
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
+ }
@@ -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.