@heroiclands/package-build 24.0.3 → 24.1.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.
@@ -18,7 +18,8 @@
18
18
  * The list is the `data.concepts` of the package's `doc` notes. A language
19
19
  * must cover it where it states `data.core: true`. A word covers every
20
20
  * concept its `concept` names, and a language reads the words of its
21
- * `data.family` chain as its own. A missing concept is a fact to work through,
21
+ * `data.family` chain as its own, with the nearest definition of each form
22
+ * winning. A missing concept is a fact to work through,
22
23
  * never a finding.
23
24
  *
24
25
  * @module
@@ -114,9 +115,16 @@ export function coreReport(records, { language } = {}) {
114
115
  concepts: concepts.length,
115
116
  languages: chosen.map((record) => {
116
117
  const have = new Set();
118
+ const forms = new Set();
117
119
  for (const key of chain(noteAddressOf(record) ?? ""))
118
- for (const word of byKey.get(key)?.data?.words ?? [])
120
+ for (const word of byKey.get(key)?.data?.words ?? []) {
121
+ if (!isMap(word) || typeof word.form !== "string") continue;
122
+ // Match dictionary headwords: NFC, retaining case and word separators.
123
+ const form = word.form.normalize("NFC");
124
+ if (forms.has(form)) continue;
125
+ forms.add(form);
119
126
  for (const id of idsOf(word)) have.add(id);
127
+ }
120
128
  const missing = [];
121
129
  for (const group of groups) {
122
130
  const members = concepts.filter((concept) => concept.group === group);
@@ -59,7 +59,7 @@ import { fromWrittenIn } from "./written-in.mjs";
59
59
  import { writingsOf } from "./logographic.mjs";
60
60
  import { FONTS_PACKAGE } from "./script-fonts.mjs";
61
61
  import { GENERIC_FAMILIES, applyMap } from "./text-maps.mjs";
62
- import { isLanguageRecord, noteAddressOf } from "./word-rows.mjs";
62
+ import { isLanguageRecord, lineageChain, noteAddressOf } from "./word-rows.mjs";
63
63
  import { derivedForms } from "./word-forms.mjs";
64
64
  import { LANGUAGE_SUBTYPE, glossText } from "./word-terms.mjs";
65
65
 
@@ -183,7 +183,7 @@ function lexiconOf(language, data) {
183
183
  * @returns {string|undefined} The Address.
184
184
  */
185
185
  function noteKey(note, contentPackage, types) {
186
- if (typeof note !== "string" || !contentPackage) return undefined;
186
+ if (!contentPackage) return undefined;
187
187
  const tuple = parseAddress(
188
188
  note,
189
189
  { package: contentPackage, system: "note", type: "skill", types: new Set(types) },
@@ -248,7 +248,9 @@ function languageNotes(languages, contentPackage, records) {
248
248
 
249
249
  /**
250
250
  * Each configured language's lexicon, read from the language note its `note`
251
- * names. A language with no `note`, or one naming no language note, has none.
251
+ * names and its family chain. The nearest exact NFC form supplies the whole
252
+ * word; grammar, registers and other settings remain the daughter's own.
253
+ * A language with no `note`, or one naming no language note, has none.
252
254
  *
253
255
  * @param {Iterable<Record<string, any>>|undefined} records - Index records.
254
256
  * @param {{languages?: Readonly<Record<string, {tag: string, note?: string}>>, contentPackage?: string}} config -
@@ -256,9 +258,39 @@ function languageNotes(languages, contentPackage, records) {
256
258
  * @returns {Map<string, Lexicon>} The lexicons by language name.
257
259
  */
258
260
  export function lexiconsFromRecords(records, config = {}) {
261
+ const list = [...(records ?? [])];
262
+ const byKey = recordsByKey(list, config.contentPackage);
263
+ const types = new Set(["skill", ...list.map((record) => record?.type).filter(Boolean)]);
264
+ const lineage = {
265
+ has: (key) => isLanguage(byKey.get(key)),
266
+ parentOf: (key) => {
267
+ const record = byKey.get(key);
268
+ return noteKey(record.data?.family, record.package ?? config.contentPackage, types);
269
+ },
270
+ };
259
271
  const lexicons = new Map();
260
- for (const [name, record] of languageRecordsByName(records, config))
261
- lexicons.set(name, lexiconOf(name, record.data));
272
+ for (const [name, { key, record }] of languageNotes(
273
+ config.languages,
274
+ config.contentPackage,
275
+ list,
276
+ )) {
277
+ if (!record) continue;
278
+ const chain = lineageChain(key, lineage);
279
+ let data = record.data;
280
+ if (chain.length > 1) {
281
+ const words = new Map();
282
+ for (const ancestor of chain)
283
+ for (const word of listOf(byKey.get(ancestor).data?.words)) {
284
+ if (!isMap(word) || typeof word.form !== "string") continue;
285
+ // The nearest complete definition wins before word matching
286
+ // folds case or splits compounds. Only words are inherited.
287
+ const form = word.form.normalize("NFC");
288
+ if (!words.has(form)) words.set(form, word);
289
+ }
290
+ data = { ...data, words: [...words.values()] };
291
+ }
292
+ lexicons.set(name, lexiconOf(name, data));
293
+ }
262
294
  return lexicons;
263
295
  }
264
296
 
@@ -2209,9 +2209,9 @@ export const NOTE_VOCABULARY = Object.freeze({
2209
2209
  },
2210
2210
  ]),
2211
2211
  describe:
2212
- "The script the language is written in, as the map and the face a run marked " +
2213
- "with its name alone takes; a run's own `map=` or `font=` overrides it. Only a " +
2214
- "skill of `subType: language` states it.",
2212
+ "The script the language is written in, as the default map and face selected " +
2213
+ "by a run with explicit `lang=` and `.nativefont`; a run's own `map=` or " +
2214
+ "`font=` overrides its half. Only a skill of `subType: language` states it.",
2215
2215
  },
2216
2216
  {
2217
2217
  name: "script",
@@ -2451,8 +2451,8 @@ export const NOTE_VOCABULARY = Object.freeze({
2451
2451
  },
2452
2452
  ]),
2453
2453
  describe:
2454
- "A mapping, which text marked `map=` with its `name` is rewritten by " +
2455
- "before it is set. See " +
2454
+ "A mapping, which native text marked with `lang=`, `.nativefont` and its " +
2455
+ "`map=` name is rewritten by before it is set. See " +
2456
2456
  "[mapping notes](format-details.md#mapping-notes).",
2457
2457
  },
2458
2458
  {
@@ -87,13 +87,11 @@ import { GENERIC_FONTS, renderRuns, scanRuns } from "./content-scripts.mjs";
87
87
  import { scanFigures } from "./content-figures.mjs";
88
88
  import { BLOCK_CONTAINERS, scanBlocks, BLOCK_NAMES } from "./content-blocks.mjs";
89
89
  import {
90
- COLUMNS_CLASS,
91
- STANZA_BREAK,
90
+ hasColumns,
92
91
  lineNumber,
93
92
  rightToLeft,
94
93
  scanVerse,
95
- verseColumns,
96
- verseStanzas,
94
+ verseColumnCount,
97
95
  } from "./content-verse.mjs";
98
96
  import { matchAllOutsideCode, replaceOutsideCode } from "./code-fences.mjs";
99
97
  import { panelRoman, scanScrolls, scrollPanels, scrollParagraphs } from "./content-scroll.mjs";
@@ -763,20 +761,28 @@ function renderBlock(tokens, i, out, ctx) {
763
761
  }
764
762
  if (token.type === "fence" && /^verse(?:\s|$)/.test(token.info?.trim() || "")) {
765
763
  const opening = `${token.markup}${token.info}`;
766
- const poem = scanVerse(`${opening}\n${token.content}${token.markup}`).blocks[0];
764
+ const scanned = scanVerse(`${opening}\n${token.content}${token.markup}`);
765
+ // Findings are collected before rendering. Keep malformed verse
766
+ // literal rather than assigning invalid parts to display cells.
767
+ if (scanned.errors.length) {
768
+ out.push(rawBlock(token.content, token.info));
769
+ return 1;
770
+ }
771
+ const poem = scanned.blocks[0];
767
772
  const step = Number(poem?.attributes.numstep ?? 0) || 0;
768
773
  const content =
769
- poem?.classes.includes(COLUMNS_CLASS) ?
770
- verseGrid(token, poem, opening, step, ctx)
774
+ hasColumns(poem) ? verseGrid(token, poem, step, ctx)
771
775
  : step ? numberedVerse(token, step, ctx)
772
776
  : token.meta.verseStanzas
773
777
  .map((stanza) =>
774
- stanza
775
- .map(({ children, level }) => {
776
- const verse = renderInline({ children }, ctx);
777
- return `${level ? `#h(${level * 1.25}em)` : ""}${verse}`;
778
- })
779
- .join(" \\\n"),
778
+ stanza[0]?.stanzaBreak ?
779
+ "#v(0.5em)"
780
+ : stanza
781
+ .map(({ children, level }) => {
782
+ const verse = renderInline({ children }, ctx);
783
+ return `${level ? `#h(${level * 1.25}em)` : ""}${verse}`;
784
+ })
785
+ .join(" \\\n"),
780
786
  )
781
787
  .join("\n\n");
782
788
  const anchor = poem?.id ? ` <${sectionLabel(ctx.anchorPrefix, poem.id)}>` : "";
@@ -1291,12 +1297,14 @@ const typstNumber = (number) => (number ? `[#text(fill: luma(45%))[${number}]]`
1291
1297
  function numberedVerse(token, step, ctx) {
1292
1298
  let count = 0;
1293
1299
  const stanzas = token.meta.verseStanzas.map((stanza) =>
1294
- stanza
1295
- .map(({ children, level }) => {
1296
- const verse = renderInline({ children }, ctx);
1297
- return ` ${typstNumber(lineNumber(++count, step))}, [${level ? `#h(${level * 1.25}em)` : ""}${verse}],`;
1298
- })
1299
- .join("\n"),
1300
+ stanza[0]?.stanzaBreak ?
1301
+ " grid.cell(colspan: 2)[#v(0.5em)],"
1302
+ : stanza
1303
+ .map(({ children, level }) => {
1304
+ const verse = renderInline({ children }, ctx);
1305
+ return ` ${typstNumber(lineNumber(++count, step))}, [${level ? `#h(${level * 1.25}em)` : ""}${verse}],`;
1306
+ })
1307
+ .join("\n"),
1300
1308
  );
1301
1309
  return [
1302
1310
  "#grid(columns: (2.5em, 1fr), column-gutter: 1em, row-gutter: 0.35em,",
@@ -1309,44 +1317,52 @@ function numberedVerse(token, step, ctx) {
1309
1317
  * A verse in columns: a grid with a row per verse line, its number first when
1310
1318
  * the verse is numbered, then each column's entries stacked, the first in the
1311
1319
  * normal tone and each later one grey. A part whose text runs right to left
1312
- * is set right to left inside its cell, and the grid keeps the page's
1313
- * direction, so the columns stay in order. A `***` line is a row of space
1320
+ * is set right to left inside its cell; the grid stays left to right so
1321
+ * numbered columns retain their authored order. A `***` line is a row of space
1314
1322
  * across the grid.
1315
1323
  *
1316
- * @param {object} token - The expanded verse's fence token.
1324
+ * @param {object} token - The verse's fence token with parsed logical rows.
1317
1325
  * @param {object} poem - The verse, from `scanVerse`.
1318
- * @param {string} opening - Its opening line.
1319
1326
  * @param {number} step - Its `numstep`.
1320
1327
  * @param {object} ctx - Render context.
1321
1328
  * @returns {string} Typst markup.
1322
1329
  */
1323
- function verseGrid(token, poem, opening, step, ctx) {
1324
- const counts = verseColumns(poem, opening).map((column) => column.entries.length);
1325
- const width = counts.length + (step ? 1 : 0);
1326
- const columns = [...(step ? ["2.5em"] : []), ...counts.map(() => "1fr")];
1327
- const raw = verseStanzas(token.content);
1330
+ function verseGrid(token, poem, step, ctx) {
1331
+ const count = verseColumnCount(poem);
1332
+ const width = count + (step ? 1 : 0);
1333
+ const columns = [...(step ? ["2.5em"] : []), ...Array(count).fill("1fr")];
1328
1334
  const rows = [];
1329
- let count = 0;
1330
- token.meta.verseStanzas.forEach((group, at) => {
1331
- if (raw[at].length === 1 && STANZA_BREAK.test(raw[at][0].text)) {
1335
+ let row = 0;
1336
+ for (const group of token.meta.verseGroups) {
1337
+ if (group.stanzaBreak) {
1332
1338
  rows.push(` grid.cell(colspan: ${width})[#v(0.5em)],`);
1333
- return;
1339
+ continue;
1334
1340
  }
1335
- const parts = group.map(({ children }, k) => {
1336
- const part = renderInline({ children }, ctx);
1337
- return rightToLeft(raw[at][k].text) ? `#set text(dir: rtl)\n${part}` : part;
1338
- });
1339
- let next = 0;
1340
- const cells = counts.map((n) => {
1341
- const stacked = parts.slice(next, (next += n));
1341
+ const cells = Array.from({ length: count }, () => []);
1342
+ for (const part of group.parts) {
1343
+ const rtl = rightToLeft(part.text);
1344
+ const secondary = cells[part.column - 1].length > 0;
1345
+ const text = renderInline(
1346
+ { children: part.children },
1347
+ secondary ? { ...ctx, textFill: "luma(45%)" } : ctx,
1348
+ );
1349
+ const padded =
1350
+ part.indent ?
1351
+ `#pad(${rtl ? "right" : "left"}: ${part.indent * 1.25}em)[${text}]`
1352
+ : text;
1353
+ cells[part.column - 1].push(rtl ? `#set text(dir: rtl)\n${padded}` : padded);
1354
+ }
1355
+ const rendered = cells.map((stacked) => {
1356
+ if (!stacked.length) return "[]";
1342
1357
  if (stacked.length === 1) return `[${stacked[0]}]`;
1343
1358
  return `[#stack(dir: ttb, spacing: 0.3em, ${stacked.map((part, k) => (k ? `[#text(fill: luma(45%))[${part}]]` : `[${part}]`)).join(", ")})]`;
1344
1359
  });
1345
- const number = step ? [typstNumber(lineNumber(++count, step))] : [];
1346
- rows.push(` ${[...number, ...cells].join(", ")},`);
1347
- });
1360
+ const number = step ? [typstNumber(lineNumber(++row, step))] : [];
1361
+ rows.push(` ${[...number, ...rendered].join(", ")},`);
1362
+ }
1348
1363
  return [
1349
- `#grid(columns: (${columns.join(", ")}${columns.length === 1 ? "," : ""}), column-gutter: 1.5em, row-gutter: 0.65em,`,
1364
+ "#set text(dir: ltr)",
1365
+ `#grid(columns: (${columns.join(", ")}), column-gutter: 1.5em, row-gutter: 0.65em,`,
1350
1366
  ...rows,
1351
1367
  ")",
1352
1368
  ].join("\n");
@@ -1440,7 +1456,7 @@ function renderInline(token, ctx) {
1440
1456
  case "script_emphasis_open":
1441
1457
  out.push(
1442
1458
  child.meta?.convention === "rubric" ?
1443
- "#text(fill: book-rubric)["
1459
+ `#text(fill: ${ctx.textFill ?? "book-rubric"})[`
1444
1460
  : "#overline[",
1445
1461
  );
1446
1462
  break;
@@ -1675,6 +1691,9 @@ function pageOfLabel(label) {
1675
1691
  * @returns {string} Typst markup.
1676
1692
  */
1677
1693
  function renderLink(href, inner, ctx, { bracketed = false } = {}) {
1694
+ // A secondary verse reading keeps its label gray inside the global blue
1695
+ // link presentation, including any native spans or emphasis in that label.
1696
+ if (ctx.textFill) inner = `#text(fill: ${ctx.textFill})[${inner}]`;
1678
1697
  const url = String(href ?? "");
1679
1698
  const fragment = /#([^?]*)/.exec(url)?.[1] ?? "";
1680
1699
  if (url.startsWith("#") && fragment)
@@ -17,9 +17,10 @@
17
17
  * column value and none is a key, so two notes writing one tag in different
18
18
  * case are two rows. Documentation records and assets contribute no rows.
19
19
  *
20
- * A run's `font` and `map` are the ones it is set with: its own, else those of
21
- * the div or fence around it, else those its language's note names in
22
- * `data.writtenIn`, filled by {@link fillWrittenIn} once every record exists.
20
+ * Native runs retain their explicit opt-in so language defaults can be filled
21
+ * by {@link fillWrittenIn} once every record exists. Roman language-only runs
22
+ * retain null map/font fields; ordinary generic-family inheritance is already
23
+ * projected by the shared scanner.
23
24
  *
24
25
  * @module
25
26
  */
@@ -30,7 +31,7 @@ import { isNoteRecord } from "./index-records.mjs";
30
31
  import { scanRuns } from "./content-scripts.mjs";
31
32
  import { languageRecordsByName } from "./map-lexicon.mjs";
32
33
  import { mapsFromRecords } from "./text-maps.mjs";
33
- import { fromWrittenIn, writtenInOf } from "./written-in.mjs";
34
+ import { writtenInOf } from "./written-in.mjs";
34
35
 
35
36
  /** Every column of the `runs` relation, in order. */
36
37
  export const RUN_COLUMNS = Object.freeze([
@@ -52,10 +53,12 @@ export const RUN_COLUMNS = Object.freeze([
52
53
  * @property {string|null} lang - The language it is set in, as written: its
53
54
  * own `lang=`, else the one the div or fence around it states.
54
55
  * @property {string|null} tag - The BCP 47 tag it resolves to, or null.
55
- * @property {string|null} font - Its font, as written and inherited the same
56
- * way, else its language's script's.
57
- * @property {string|null} map - Its map, as written and inherited the same way,
58
- * else its language's script's.
56
+ * @property {string|null} font - Its explicit font, an inherited ordinary
57
+ * prose family, or the configured default for an opted-in native run.
58
+ * @property {string|null} map - Its explicit map or the configured map of an
59
+ * opted-in native run; null for Roman language-only text.
60
+ * @property {boolean} [nativefont] - Explicit native opt-in on this construct;
61
+ * absent on Roman runs. This internal flag is not a SQL column.
59
62
  * @property {number} line - 1-based file line of its opening.
60
63
  * @property {number} column - 1-based UTF-16 file column of its opening.
61
64
  */
@@ -84,13 +87,14 @@ export function recordRuns(body, { context, bodyLine = 1, bodyColumn = 1, syntax
84
87
  tag: run.tag ?? null,
85
88
  font: run.font ?? null,
86
89
  map: run.map ?? null,
90
+ ...(run.nativefont ? { nativefont: true } : {}),
87
91
  line: bodyLine + run.line - 1,
88
92
  column: run.column + (run.line === 1 ? bodyColumn - 1 : 0),
89
93
  }));
90
94
  }
91
95
 
92
96
  /**
93
- * Give every recorded run the map and face its language's note names in
97
+ * Give explicitly native recorded runs the map and face their language names in
94
98
  * `data.writtenIn`, where the run leaves them unstated, as a run is set. Read
95
99
  * once every record exists, since a run's language note may be recorded after
96
100
  * the note that marks it.
@@ -107,17 +111,11 @@ export function fillWrittenIn(records, config) {
107
111
  for (const record of records) {
108
112
  if (!record || !isNoteRecord(record) || !Array.isArray(record.runs)) continue;
109
113
  for (const run of record.runs) {
110
- const filled = fromWrittenIn(
111
- {
112
- lang: run.lang ?? undefined,
113
- map: run.map ?? undefined,
114
- font: run.font ?? undefined,
115
- },
116
- scripts,
117
- maps,
118
- );
119
- if (filled.map !== undefined) run.map = filled.map;
120
- if (filled.font !== undefined) run.font = filled.font;
114
+ if (!run.nativefont) continue;
115
+ const script = scripts.get(run.lang);
116
+ if (!script) continue;
117
+ if (run.map == null) run.map = script.map;
118
+ if (run.font == null) run.font = script.font;
121
119
  }
122
120
  }
123
121
  }
@@ -36,7 +36,7 @@
36
36
  * @module
37
37
  */
38
38
 
39
- import { languageRecordsByName } from "./map-lexicon.mjs";
39
+ import { lexiconsFromRecords } from "./map-lexicon.mjs";
40
40
  import { RESERVED_TONGUE } from "./language-terms.mjs";
41
41
  import { addWords, foldWord, lexiconWords } from "./word-forms.mjs";
42
42
  import { WORD } from "./word-pattern.mjs";
@@ -82,7 +82,8 @@ export function registeredNameWords(data) {
82
82
 
83
83
  /**
84
84
  * Each configured language's word book: one per language whose `note` names a
85
- * language note.
85
+ * language note. Its effective lexicon shares the renderer's nearest inherited
86
+ * words, and derives forms and registered names from its own settings.
86
87
  *
87
88
  * @param {Iterable<Record<string, any>>|undefined} records - Index records.
88
89
  * @param {{languages?: Readonly<Record<string, {tag: string, note?: string}>>, contentPackage?: string}} config -
@@ -92,11 +93,10 @@ export function registeredNameWords(data) {
92
93
  export function wordBooksFromRecords(records, config = {}) {
93
94
  const list = [...(records ?? [])];
94
95
  const books = new Map();
95
- const languages = languageRecordsByName(list, config);
96
- if (!languages.size) return books;
97
- for (const [name, record] of languages) {
98
- const own = lexiconWords(record.data);
99
- const names = registeredNameWords(record.data);
96
+ const lexicons = lexiconsFromRecords(list, config);
97
+ for (const [name, lexicon] of lexicons) {
98
+ const own = lexiconWords(lexicon.data);
99
+ const names = registeredNameWords(lexicon.data);
100
100
  books.set(
101
101
  name,
102
102
  Object.freeze({
@@ -58,6 +58,13 @@ import { parseWordClass } from "./word-classes.mjs";
58
58
  import { derivedForms, foldWord } from "./word-forms.mjs";
59
59
  import { WORD } from "./word-pattern.mjs";
60
60
 
61
+ import MarkdownIt from "markdown-it";
62
+ import { spanMarkdownPlugin } from "./content-spans.mjs";
63
+ import { STANZA_BREAK, verseLine } from "./content-verse.mjs";
64
+ import { inlineText } from "./name-markup.mjs";
65
+
66
+ const verseParser = new MarkdownIt().use(spanMarkdownPlugin);
67
+
61
68
  export { METRE_KINDS, METRE_NAME, checkMetresField } from "./metre-declaration.mjs";
62
69
 
63
70
  /** The bases whose one-syllable words carry a beat. */
@@ -285,7 +292,7 @@ export function metreMessage({ min, max }, metre) {
285
292
  * @param {ReadonlyArray<{start: number, end: number, indent: string, attributes: Record<string, string>, classes: string[]}>} blocks -
286
293
  * The fences, from {@link module:engine/content-verse.scanVerse}.
287
294
  * @param {string[]} lines - The source's lines.
288
- * @param {{metres?: ReadonlyMap<string, import("./metre-declaration.mjs").Metre>, metreBooks?: ReadonlyMap<string, MetreBook>, lexicons?: ReadonlyMap<string, unknown>, columnsClass: string, columnKeys: readonly string[], attributeColumns: (line: string, key: string) => {key: number, value: number}}} ctx -
295
+ * @param {{languages?: Readonly<Record<string, {tag: string}>>, metres?: ReadonlyMap<string, import("./metre-declaration.mjs").Metre>, metreBooks?: ReadonlyMap<string, MetreBook>, lexicons?: ReadonlyMap<string, unknown>, attributeColumns: (line: string, key: string) => {key: number, value: number}}} ctx -
289
296
  * The metres and books, and how the fence says its columns and where its
290
297
  * keys sit.
291
298
  * @returns {{errors: object[], warnings: object[]}} The findings at 1-based
@@ -304,13 +311,10 @@ export function metreFindings(blocks, lines, ctx) {
304
311
  column: ctx.attributeColumns(opening, key)[part],
305
312
  });
306
313
  const error = (position, message) => errors.push({ ...position, message });
307
- if (
308
- block.classes.includes(ctx.columnsClass) ||
309
- ctx.columnKeys.some((key) => block.attributes[key] !== undefined)
310
- ) {
314
+ if (Number(block.attributes.cols ?? 1) > 1) {
311
315
  error(
312
316
  at("scan", "key"),
313
- "`scan=` counts the beats of one language's lines; a verse in columns sets a line in each of two",
317
+ "`scan=` counts the beats of one language's lines; a verse with cols>1 groups independent physical lines",
314
318
  );
315
319
  continue;
316
320
  }
@@ -344,8 +348,32 @@ export function metreFindings(blocks, lines, ctx) {
344
348
  }
345
349
  for (let line = block.start + 1; line < block.end; line++) {
346
350
  const text = lines[line];
347
- if (!text.trim()) continue;
348
- const beats = lineBeats(text, book);
351
+ if (!text.trim() || STANZA_BREAK.test(text.trim())) continue;
352
+ const reading = verseLine(text);
353
+ const tokens = verseParser.parseInline(reading.text, {})[0]?.children ?? [];
354
+ const tag = (name) => (ctx.languages?.[name]?.tag ?? name).toLowerCase();
355
+ const foreign = tokens.find(
356
+ (token) =>
357
+ token.type === "span_open" &&
358
+ token.attrGet("lang") &&
359
+ tag(token.attrGet("lang")) !== tag(lang),
360
+ );
361
+ if (foreign) {
362
+ error(
363
+ {
364
+ line: line + 1,
365
+ column:
366
+ reading.textColumn +
367
+ foreign.meta.start +
368
+ (reading.markerStart <= reading.textColumn - 1 + foreign.meta.start ?
369
+ reading.markerLength
370
+ : 0),
371
+ },
372
+ "`scan=` counts one language's beats; this verse line contains another language",
373
+ );
374
+ continue;
375
+ }
376
+ const beats = lineBeats(inlineText(tokens), book);
349
377
  if (metre.beats >= beats.min && metre.beats <= beats.max) continue;
350
378
  warnings.push({
351
379
  line: line + 1,
@@ -20,20 +20,16 @@
20
20
  * writtenIn: { map: semaron, font: semaron-kuriale }
21
21
  * ```
22
22
  *
23
- * A run marked with the language's name takes them where it states neither:
24
- * `[Zaron tholmeza]{lang=vyl}` is mapped by `semaron` and set in
25
- * `semaron-kuriale`. A run that states one of the two takes the other from the
26
- * default only where they agree, so the run is never set in a face its map is
27
- * not drawn in:
23
+ * These are native-presentation defaults, not an effect of identifying a
24
+ * language. A rendering run requests them with its own `.nativefont` class
25
+ * and explicit `lang=`; plain `[Zaron tholmeza]{lang=vyl}` stays Roman.
26
+ * The renderer fills the map and font independently, honoring explicit
27
+ * overrides and reporting an incompatible pair rather than silently omitting
28
+ * a default. A nested marked run makes its own presentation choice.
28
29
  *
29
- * - **`map=` alone** takes the default `font` where that map is drawn in it.
30
- * - **`font=` alone** takes the default `map` where the map is drawn in that
31
- * face.
32
- * - **Both** take nothing.
33
- *
34
- * The default belongs to the run's own language, after a div or fence around
35
- * it has given it its keys: a span marked `lang=en` inside a div of a language
36
- * with a default takes no face from it.
30
+ * The low-level `fromWrittenIn` helper also serves explicit conversion
31
+ * commands. It returns compatible default suggestions to those callers;
32
+ * rendering opt-in and its required-font diagnostics belong to the run reader.
37
33
  *
38
34
  * @module
39
35
  */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@heroiclands/package-build",
3
- "version": "24.0.3",
3
+ "version": "24.1.0",
4
4
  "description": "Shared toolchain for building and shipping a HeroicLands Foundry VTT package — content compilation, manifest, localization, staging, bundle, release and deployment.",
5
5
  "license": "GPL-3.0-or-later",
6
6
  "type": "module",
@@ -1384,7 +1384,7 @@ a.tag:hover {
1384
1384
  background: var(--bg-card);
1385
1385
  }
1386
1386
 
1387
- /* Verse lines are hard breaks in the markup; paragraphs mark stanzas. */
1387
+ /* Verse groups are paragraphs; explicit stanza separators add their own gap. */
1388
1388
  .single-content .verse {
1389
1389
  margin: 1.5rem 0;
1390
1390
  line-height: 1.7;
@@ -1404,14 +1404,16 @@ a.tag:hover {
1404
1404
  white-space: pre-wrap;
1405
1405
  }
1406
1406
 
1407
- .single-content .verse .verse-line.i1 { --verse-indent: 1.25em; }
1408
- .single-content .verse .verse-line.i2 { --verse-indent: 2.5em; }
1409
- .single-content .verse .verse-line.i3 { --verse-indent: 3.75em; }
1410
- .single-content .verse .verse-line.i4 { --verse-indent: 5em; }
1411
- .single-content .verse .verse-line.i5 { --verse-indent: 6.25em; }
1412
- .single-content .verse .verse-line.i6 { --verse-indent: 7.5em; }
1413
- .single-content .verse .verse-line.i7 { --verse-indent: 8.75em; }
1414
- .single-content .verse .verse-line.i8 { --verse-indent: 10em; }
1407
+ .single-content .verse :is(.verse-line, .verse-part).i1 { --verse-indent: 1.25em; }
1408
+ .single-content .verse :is(.verse-line, .verse-part).i2 { --verse-indent: 2.5em; }
1409
+ .single-content .verse :is(.verse-line, .verse-part).i3 { --verse-indent: 3.75em; }
1410
+ .single-content .verse :is(.verse-line, .verse-part).i4 { --verse-indent: 5em; }
1411
+ .single-content .verse :is(.verse-line, .verse-part).i5 { --verse-indent: 6.25em; }
1412
+ .single-content .verse :is(.verse-line, .verse-part).i6 { --verse-indent: 7.5em; }
1413
+ .single-content .verse :is(.verse-line, .verse-part).i7 { --verse-indent: 8.75em; }
1414
+ .single-content .verse :is(.verse-line, .verse-part).i8 { --verse-indent: 10em; }
1415
+
1416
+ .single-content .verse .verse-stanza-break { display: block; height: 0.5em; }
1415
1417
 
1416
1418
  /* Numbered verse carries its numbers in a margin. A verse in columns is one
1417
1419
  grid per verse line: its number, then each column's parts stacked, the
@@ -1446,12 +1448,14 @@ a.tag:hover {
1446
1448
  display: grid;
1447
1449
  grid-auto-flow: column;
1448
1450
  grid-auto-columns: 1fr;
1451
+ direction: ltr;
1452
+ grid-template-columns: repeat(var(--verse-columns, 2), minmax(0, 1fr));
1449
1453
  column-gap: 1.5rem;
1450
1454
  margin-bottom: 0.5em;
1451
1455
  }
1452
1456
 
1453
1457
  .single-content .verse-numbered.verse-columns .verse-row {
1454
- grid-template-columns: 2.5em;
1458
+ grid-template-columns: 2.5em repeat(var(--verse-columns, 2), minmax(0, 1fr));
1455
1459
  }
1456
1460
 
1457
1461
  .single-content .verse-columns .verse-number {
@@ -1466,12 +1470,17 @@ a.tag:hover {
1466
1470
 
1467
1471
  .single-content .verse-columns .verse-part {
1468
1472
  font-family: var(--font-body);
1473
+ padding-inline-start: var(--verse-indent, 0em);
1469
1474
  }
1470
1475
 
1471
1476
  .single-content .verse-columns .verse-secondary {
1472
1477
  color: var(--text-muted);
1473
1478
  }
1474
1479
 
1480
+ .single-content .verse-columns .verse-secondary * {
1481
+ color: inherit;
1482
+ }
1483
+
1475
1484
  .single-content .verse-columns .verse-part[dir="rtl"] {
1476
1485
  text-align: start;
1477
1486
  }