@heroiclands/package-build 25.2.0 → 25.3.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.
@@ -116,9 +116,9 @@ function namedBlockLines(markdown, syntax) {
116
116
  * code blocks are respected so `# foo` inside ``` or ~~~ blocks doesn't
117
117
  * trigger a split. Content before the first H1 (if non-empty) becomes a
118
118
  * leading page. Each H1 yields a page whose name is the heading
119
- * text (with any `{#anchor-id}` suffix stripped out and surfaced as
120
- * `anchorSlug`). The classes the heading declares come along as `classes`, so
121
- * a surface that honours one has it without reading the suffix again.
119
+ * text without inline markup (with any `{#anchor-id}` suffix stripped out and
120
+ * surfaced as `anchorSlug`). The classes the heading declares come along as
121
+ * `classes`, so a surface that honours one has it without reading the suffix again.
122
122
  *
123
123
  * `leadName` names that leading page. A journal note's is "Introduction",
124
124
  * because it introduces the pages that follow. An item doc's is the item — a
@@ -220,7 +220,7 @@ export function splitPages(body, leadName = "Introduction", resolveRole, syntax)
220
220
  anchorSlug: null,
221
221
  level: section.level,
222
222
  classes: section.classes,
223
- continues: item.slug || item.label || captionPageName(item.caption),
223
+ continues: item.slug || item.label || pageNameText(item.caption),
224
224
  lines: [],
225
225
  };
226
226
  }
@@ -231,7 +231,7 @@ export function splitPages(body, leadName = "Introduction", resolveRole, syntax)
231
231
  if (figure) {
232
232
  closeCurrent();
233
233
  current = {
234
- name: figure.label || captionPageName(figure.caption),
234
+ name: figure.label || pageNameText(figure.caption),
235
235
  anchorSlug: figure.id || null,
236
236
  level: 1,
237
237
  classes: section.classes.includes(WITHHELD_CLASS) ? [WITHHELD_CLASS] : [],
@@ -243,7 +243,7 @@ export function splitPages(body, leadName = "Introduction", resolveRole, syntax)
243
243
  if (opening) {
244
244
  closeCurrent();
245
245
  current = {
246
- name: opening.text,
246
+ name: pageNameText(opening.text),
247
247
  anchorSlug: opening.id || null,
248
248
  level: opening.level,
249
249
  classes: opening.classes,
@@ -384,20 +384,20 @@ export function journalPageId(entryId, page) {
384
384
  }
385
385
 
386
386
  /**
387
- * The name of the page a caption begins: the caption's visible text.
387
+ * The name of a page a heading or caption begins: its visible text.
388
388
  *
389
- * Foundry shows a page name as plain text, so a caption's markup — emphasis, a
389
+ * Foundry shows a page name as plain text, so inline markup — emphasis, a
390
390
  * code span, a Markdown link, or the `@UUID[...]{label}` a resolved wikilink
391
391
  * has already become, inside its wrapping `<span>` — reduces to the text a
392
392
  * reader sees, the same text a reference to the caption displays. The markup
393
393
  * itself stays in the page's content, where it renders.
394
394
  *
395
- * @param {string} caption - The figure's caption, as the journal pass sees it.
395
+ * @param {string} markdown - A heading or caption, as the journal pass sees it.
396
396
  * @returns {string} Its visible text, whitespace collapsed.
397
397
  */
398
- function captionPageName(caption) {
399
- if (!caption) return "";
400
- const [inline] = md.parseInline(caption, {});
398
+ function pageNameText(markdown) {
399
+ if (!markdown) return "";
400
+ const [inline] = md.parseInline(markdown, {});
401
401
  const text = (inline?.children ?? [])
402
402
  .map((token) =>
403
403
  token.type === "text" || token.type === "code_inline" ? token.content
@@ -501,7 +501,7 @@ function buildFigurePageFields(figure, page, pageId, footnoteNumbers, resolveRol
501
501
  // image's own render takes when no configuration resolves it.
502
502
  }
503
503
  return {
504
- name: figure.label || captionPageName(figure.caption),
504
+ name: figure.label || pageNameText(figure.caption),
505
505
  type: "image",
506
506
  src,
507
507
  image: figure.hasCaption ? { caption: figure.caption } : {},
@@ -520,7 +520,7 @@ function buildFigurePageFields(figure, page, pageId, footnoteNumbers, resolveRol
520
520
  runs,
521
521
  );
522
522
  return {
523
- name: figure.label || captionPageName(figure.caption),
523
+ name: figure.label || pageNameText(figure.caption),
524
524
  type: "text",
525
525
  text: {
526
526
  format: 1,
@@ -86,13 +86,7 @@ import { scanSpans } from "./content-spans.mjs";
86
86
  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
- import {
90
- hasColumns,
91
- lineNumber,
92
- rightToLeft,
93
- scanVerse,
94
- verseColumnCount,
95
- } from "./content-verse.mjs";
89
+ import { rightToLeft, scanVerse } from "./content-verse.mjs";
96
90
  import { matchAllOutsideCode, replaceOutsideCode } from "./code-fences.mjs";
97
91
  import { panelRoman, scanScrolls, scrollPanels, scrollParagraphs } from "./content-scroll.mjs";
98
92
  import { BOOK_INK, BOOK_RUBRIC, fontOutlines, scrollPanelSvg } from "./font-outlines.mjs";
@@ -522,7 +516,10 @@ export function markdownToTypst(markdown, opts = {}) {
522
516
  const marked =
523
517
  opts.nested ? undefined : renderRuns(authored, opts.runs, undefined, { target: "book" });
524
518
  const source = marked ? marked.markdown : authored;
525
- if (marked) sharedOptions.scrolls = marked.scrolls;
519
+ if (marked) {
520
+ sharedOptions.scrolls = marked.scrolls;
521
+ sharedOptions.verses = marked.verses;
522
+ }
526
523
  // One map for the whole body, not one per block: a heading inside a
527
524
  // blockquote or a list item shares the entry's anchor namespace with every
528
525
  // other heading in the same body, because `sectionLabel` scopes by entry
@@ -543,6 +540,7 @@ export function markdownToTypst(markdown, opts = {}) {
543
540
  footnoteState,
544
541
  footnotePrefix: opts.footnotePrefix ?? `footnote-${slugify(anchorPrefix || "body")}`,
545
542
  scrolls: marked?.scrolls ?? opts.scrolls ?? new Map(),
543
+ verses: marked?.verses ?? opts.verses ?? new Map(),
546
544
  };
547
545
  // A withheld section is set in the box a `:::secret` is set in, and its
548
546
  // heading is set inside it: a printed page honours no reader permission, so
@@ -691,7 +689,7 @@ export function markdownToTypst(markdown, opts = {}) {
691
689
  /** Render one fragment with the note's footnote definitions available. */
692
690
  function renderMarkdownSegment(source, md, ctx, definitions) {
693
691
  const input = definitions ? `${source}\n\n${definitions}` : source;
694
- const tokens = md.parse(input, {});
692
+ const tokens = md.parse(input, { verses: ctx.verses });
695
693
  const footnoteStart = tokens.findIndex((token) => token.type === "footnote_block_open");
696
694
  const visible = footnoteStart < 0 ? tokens : tokens.slice(0, footnoteStart);
697
695
  const footnotes = new Map();
@@ -779,34 +777,19 @@ function renderBlock(tokens, i, out, ctx) {
779
777
  }
780
778
  if (token.type === "fence" && /^verse(?:\s|$)/.test(token.info?.trim() || "")) {
781
779
  const opening = `${token.markup}${token.info}`;
782
- const scanned = scanVerse(`${opening}\n${token.content}${token.markup}`);
780
+ const scanned = scanVerse(`${opening}\n${token.content}${token.markup}`, {
781
+ checkWorks: false,
782
+ });
783
783
  // Findings are collected before rendering. Keep malformed verse
784
784
  // literal rather than assigning invalid parts to display cells.
785
- if (scanned.errors.length) {
785
+ if (scanned.errors.length && !token.meta?.versePaired) {
786
786
  out.push(rawBlock(token.content, token.info));
787
787
  return 1;
788
788
  }
789
789
  const poem = scanned.blocks[0];
790
- const step = Number(poem?.attributes.numstep ?? 0) || 0;
791
- const content =
792
- hasColumns(poem) ? verseGrid(token, poem, step, ctx)
793
- : step ? numberedVerse(token, step, ctx)
794
- : token.meta.verseStanzas
795
- .map((stanza) =>
796
- stanza[0]?.stanzaBreak ?
797
- "#v(0.5em)"
798
- : stanza
799
- .map(({ children, level }) => {
800
- const verse = renderInline({ children }, ctx);
801
- return `${level ? `#h(${level * 1.25}em)` : ""}${verse}`;
802
- })
803
- .join(" \\\n"),
804
- )
805
- .join("\n\n");
790
+ const content = workVerse(token, poem, ctx);
806
791
  const anchor = poem?.id ? ` <${sectionLabel(ctx.anchorPrefix, poem.id)}>` : "";
807
- out.push(
808
- `\n#block(width: 100%)[${scriptWrapped(content, poem?.classes, poem?.attributes)}]${anchor}\n`,
809
- );
792
+ out.push(`\n#block(width: 100%)[${content}]${anchor}\n`);
810
793
  } else out.push(rawBlock(token.content, token.info?.trim() || ""));
811
794
  return 1;
812
795
  }
@@ -1300,90 +1283,51 @@ function typstLanguage(tag) {
1300
1283
  /** The classes that set a run in a font: a face's run carries `script`. */
1301
1284
  const FONT_CLASSES = Object.freeze(["script", ...Object.values(GENERIC_FONTS)]);
1302
1285
 
1303
- /** A line number in the book: muted, as on the page. */
1304
- const typstNumber = (number) => (number ? `[#text(fill: luma(45%))[${number}]]` : "[]");
1305
-
1306
- /**
1307
- * A numbered verse without columns: a grid of each line's number and the
1308
- * line, with a row of space between stanzas.
1309
- *
1310
- * @param {object} token - The verse's fence token.
1311
- * @param {number} step - Its `numstep`.
1312
- * @param {object} ctx - Render context.
1313
- * @returns {string} Typst markup.
1314
- */
1315
- function numberedVerse(token, step, ctx) {
1316
- let count = 0;
1317
- const stanzas = token.meta.verseStanzas.map((stanza) =>
1318
- stanza[0]?.stanzaBreak ?
1319
- " grid.cell(colspan: 2)[#v(0.5em)],"
1320
- : stanza
1321
- .map(({ children, level }) => {
1322
- const verse = renderInline({ children }, ctx);
1323
- return ` ${typstNumber(lineNumber(++count, step))}, [${level ? `#h(${level * 1.25}em)` : ""}${verse}],`;
1324
- })
1325
- .join("\n"),
1326
- );
1327
- return [
1328
- "#grid(columns: (2.5em, 1fr), column-gutter: 1em, row-gutter: 0.35em,",
1329
- stanzas.join("\n grid.cell(colspan: 2)[#v(0.5em)],\n"),
1330
- ")",
1331
- ].join("\n");
1332
- }
1333
-
1334
- /**
1335
- * A verse in columns: a grid with a row per verse line, its number first when
1336
- * the verse is numbered, then each column's entries stacked, the first in the
1337
- * normal tone and each later one grey. A part whose text runs right to left
1338
- * is set right to left inside its cell; the grid stays left to right so
1339
- * numbered columns retain their authored order. A `***` line is a row of space
1340
- * across the grid.
1341
- *
1342
- * @param {object} token - The verse's fence token with parsed logical rows.
1343
- * @param {object} poem - The verse, from `scanVerse`.
1344
- * @param {number} step - Its `numstep`.
1345
- * @param {object} ctx - Render context.
1346
- * @returns {string} Typst markup.
1347
- */
1348
- function verseGrid(token, poem, step, ctx) {
1349
- const count = verseColumnCount(poem);
1350
- const width = count + (step ? 1 : 0);
1351
- const columns = [...(step ? ["2.5em"] : []), ...Array(count).fill("1fr")];
1352
- const rows = [];
1353
- let row = 0;
1354
- for (const group of token.meta.verseGroups) {
1355
- if (group.stanzaBreak) {
1356
- rows.push(` grid.cell(colspan: ${width})[#v(0.5em)],`);
1357
- continue;
1358
- }
1359
- const cells = Array.from({ length: count }, () => []);
1360
- for (const part of group.parts) {
1361
- const rtl = rightToLeft(part.text);
1362
- const secondary = cells[part.column - 1].length > 0;
1363
- const text = renderInline(
1364
- { children: part.children },
1365
- secondary ? { ...ctx, textFill: "luma(45%)" } : ctx,
1286
+ /** The logical lines of one stanza, with a shared label and paired scripts. */
1287
+ function workVerse(token, poem, ctx) {
1288
+ const paired = token.meta.versePaired;
1289
+ const labelled = Boolean(poem?.attributes.work);
1290
+ const nativeClasses = paired?.classes ?? poem?.classes;
1291
+ const nativeAttributes = paired?.attributes ?? poem?.attributes;
1292
+ return (token.meta.verseGroups ?? [])
1293
+ .map((group) => {
1294
+ if (group.cantoBreak) {
1295
+ const mark =
1296
+ group.children?.length ? renderInline({ children: group.children }, ctx) : "";
1297
+ return `${mark ? scriptWrapped(mark, nativeClasses, nativeAttributes) + "\n" : ""}#v(0.5em)`;
1298
+ }
1299
+ const label = labelled ? `#text(size: 0.75em, fill: luma(45%))[${group.label}]` : "";
1300
+ const lineText = (line, roman = false) => {
1301
+ const text = renderInline(
1302
+ { children: roman ? line.romanChildren : line.children },
1303
+ ctx,
1304
+ );
1305
+ const padded = `${line.level ? `#h(${line.level * 1.25}em)` : ""}${text}`;
1306
+ if (roman || !paired) return padded;
1307
+ const wrapped = scriptWrapped(padded, nativeClasses, nativeAttributes);
1308
+ return rightToLeft(line.text) ? `#set text(dir: rtl)\n${wrapped}` : wrapped;
1309
+ };
1310
+ if (!paired) {
1311
+ const lines = scriptWrapped(
1312
+ group.lines.map((line) => lineText(line)).join(" \\\n"),
1313
+ nativeClasses,
1314
+ nativeAttributes,
1315
+ );
1316
+ return labelled ?
1317
+ `#grid(columns: (2.5em, 1fr), column-gutter: 0.5em, [${label}], [${lines}])`
1318
+ : lines;
1319
+ }
1320
+ const cells = group.lines.map((line, index) =>
1321
+ [
1322
+ ...(labelled ? [index === 0 ? `[${label}]` : "[]"] : []),
1323
+ `[${lineText(line)}]`,
1324
+ `[${lineText(line, true)}]`,
1325
+ ].join(", "),
1366
1326
  );
1367
- const padded =
1368
- part.indent ?
1369
- `#pad(${rtl ? "right" : "left"}: ${part.indent * 1.25}em)[${text}]`
1370
- : text;
1371
- cells[part.column - 1].push(rtl ? `#set text(dir: rtl)\n${padded}` : padded);
1372
- }
1373
- const rendered = cells.map((stacked) => {
1374
- if (!stacked.length) return "[]";
1375
- if (stacked.length === 1) return `[${stacked[0]}]`;
1376
- return `[#stack(dir: ttb, spacing: 0.3em, ${stacked.map((part, k) => (k ? `[#text(fill: luma(45%))[${part}]]` : `[${part}]`)).join(", ")})]`;
1377
- });
1378
- const number = step ? [typstNumber(lineNumber(++row, step))] : [];
1379
- rows.push(` ${[...number, ...rendered].join(", ")},`);
1380
- }
1381
- return [
1382
- "#set text(dir: ltr)",
1383
- `#grid(columns: (${columns.join(", ")}), column-gutter: 1.5em, row-gutter: 0.65em,`,
1384
- ...rows,
1385
- ")",
1386
- ].join("\n");
1327
+ const columns = labelled ? "2.5em, 1fr, 1fr" : "1fr, 1fr";
1328
+ return `#set text(dir: ltr)\n#grid(columns: (${columns}), column-gutter: 1.5em, row-gutter: 0.35em,\n ${cells.join(",\n ")}\n)`;
1329
+ })
1330
+ .join("\n\n");
1387
1331
  }
1388
1332
 
1389
1333
  /**
@@ -1719,8 +1663,7 @@ function pageOfLabel(label) {
1719
1663
  * @returns {string} Typst markup.
1720
1664
  */
1721
1665
  function renderLink(href, inner, ctx, { bracketed = false } = {}) {
1722
- // A secondary verse reading keeps its label gray inside the global blue
1723
- // link presentation, including any native spans or emphasis in that label.
1666
+ // A caller's explicit text fill applies inside the link's label.
1724
1667
  if (ctx.textFill) inner = `#text(fill: ${ctx.textFill})[${inner}]`;
1725
1668
  const url = String(href ?? "");
1726
1669
  const fragment = /#([^?]*)/.exec(url)?.[1] ?? "";
@@ -2012,9 +1955,14 @@ export function bookTypstPreamble(runs = {}, families = {}) {
2012
1955
  " }\n" +
2013
1956
  " if lang != none { text(lang: lang, styled) } else { styled }\n" +
2014
1957
  "}",
2015
- // Span classes reach the book intact.
1958
+ // Plain semantic hooks retain the span classes without choosing their appearance.
1959
+ "#let book-direction(body) = body",
1960
+ "#let book-speaker(body) = body",
2016
1961
  "#let book-span(body, classes: (), lang: none) = {\n" +
2017
- " box(book-script-text(body, classes: classes, lang: lang))\n" +
1962
+ " let styled = book-script-text(body, classes: classes, lang: lang)\n" +
1963
+ ' if "direction" in classes { styled = book-direction(styled) }\n' +
1964
+ ' if "speaker" in classes { styled = book-speaker(styled) }\n' +
1965
+ " box(styled)\n" +
2018
1966
  "}",
2019
1967
  // Use the file's natural measure for auto. Every requested width is
2020
1968
  // bounded by the available measure and by the height of a page.
@@ -67,7 +67,8 @@ export const PREVIEW_HELPERS = Object.freeze({
67
67
  */
68
68
  export const INERT_BRACKET = "\uE000";
69
69
 
70
- const escapeHtml = new MarkdownIt().utils.escapeHtml;
70
+ const syntax = new MarkdownIt();
71
+ const escapeHtml = syntax.utils.escapeHtml;
71
72
  // An authored `<` stands as a private-use character until the HTML is written;
72
73
  // source is shown as it was authored.
73
74
  const escape = (value) => escapeHtml(String(value ?? "").replaceAll(INERT_BRACKET, "<"));
@@ -185,16 +186,16 @@ function withTrailingAttributes(rest, raw) {
185
186
  }
186
187
 
187
188
  /**
188
- * The cells of one table row, keeping a pipe inside a wikilink, a code span or
189
- * an extension's braces in its cell.
189
+ * The cells of one table row, keeping pipes inside balanced brackets, code
190
+ * spans and extension braces in their cell.
190
191
  *
191
192
  * @param {string} line - One table line.
192
193
  * @returns {string[]} The trimmed cells, without the empty edges.
193
194
  */
194
195
  function tableCells(line) {
195
196
  const result = [];
197
+ const state = new syntax.inline.State(line, syntax, {}, []);
196
198
  let cell = "",
197
- wiki = false,
198
199
  code = "",
199
200
  quote = false,
200
201
  braces = 0;
@@ -204,7 +205,15 @@ function tableCells(line) {
204
205
  cell += char + line[++at];
205
206
  continue;
206
207
  }
207
- if (char === "`" && !wiki) {
208
+ if (char === "[" && !code && !braces) {
209
+ const end = syntax.helpers.parseLinkLabel(state, at, false);
210
+ if (end >= 0) {
211
+ cell += line.slice(at, end + 1);
212
+ at = end;
213
+ continue;
214
+ }
215
+ }
216
+ if (char === "`") {
208
217
  const marks = /^`+/.exec(line.slice(at))[0];
209
218
  if (!code) code = marks;
210
219
  else if (code === marks) code = "";
@@ -213,13 +222,11 @@ function tableCells(line) {
213
222
  continue;
214
223
  }
215
224
  if (!code) {
216
- if (line.slice(at, at + 2) === "[[") wiki = true;
217
- if (line.slice(at - 1, at + 1) === "]]") wiki = false;
218
225
  if (char === '"' && braces) quote = !quote;
219
226
  if (!quote && char === "{") braces++;
220
227
  if (!quote && char === "}" && braces) braces--;
221
228
  }
222
- if (char === "|" && !wiki && !code && !braces) {
229
+ if (char === "|" && !code && !braces) {
223
230
  result.push(cell.trim());
224
231
  cell = "";
225
232
  } else cell += char;
@@ -12,16 +12,15 @@
12
12
  */
13
13
 
14
14
  /** Logical verse text and separately located structural boundaries. @module */
15
- import { verseLine } from "./content-verse.mjs";
15
+ import { parseVerse } from "./content-verse.mjs";
16
16
  import { plainRomanRun } from "./roman-run.mjs";
17
17
 
18
18
  /**
19
- * Remove visual indentation before checking or encoding Roman verse. Each
20
- * content line keeps its own boundary, including the last line before a stanza
21
- * or canto; those extra structural controls do not consume its line boundary.
22
- * The caller writes the boundaries through its selected encoding.
19
+ * Project verse text with explicit authored line, stanza and canto boundaries.
20
+ * Source newlines within a logical line are whitespace. Delimiters and visual
21
+ * indentation do not reach the encoder as letters.
23
22
  * @param {string} source - Complete authored source.
24
- * @param {Parameters<typeof plainRomanRun>[1] & {isCantoLine?: (line: string) => boolean}} [options] - Run bounds, spans and layout projection.
23
+ * @param {Parameters<typeof plainRomanRun>[1] & {model?: ReturnType<typeof parseVerse>, modelOffset?: number}} [options] - Run bounds, spans and layout projection.
25
24
  * @returns {ReturnType<typeof plainRomanRun> & {boundaries: Array<{kind: "line"|"stanza"|"canto", at: number, sourceOffset: number}>, errors: Array<{offset: number, message: string}>}} Visible text, positions and structural events.
26
25
  */
27
26
  export function plainRomanVerse(source, options = {}) {
@@ -29,28 +28,60 @@ export function plainRomanVerse(source, options = {}) {
29
28
  from = 0,
30
29
  to = source.length,
31
30
  exclude = [],
32
- isCantoLine = (line) => /^\s*---\s+\{\.canto\}\s*$/.test(line),
31
+ model: sharedModel,
32
+ modelOffset = from,
33
33
  } = options;
34
- const lines = [];
35
- const layout = [...exclude];
36
- let at = from;
37
- for (const match of source.slice(from, to).matchAll(/([^\r\n]*)(\r\n|\r|\n|$)/g)) {
38
- if (!match[0]) continue;
39
- const line = match[1];
40
- const end = at + line.length;
41
- const kind =
42
- isCantoLine(line) ? "canto"
43
- : line.trim() ? "content"
44
- : "blank";
45
- lines.push({ from: at, to: end, kind });
46
- if (kind === "canto") layout.push([at, end]);
47
- else if (kind === "content") {
48
- const { markerStart, markerLength } = verseLine(line);
49
- if (markerLength) layout.push([at + markerStart, at + markerStart + markerLength]);
50
- }
51
- at += match[0].length;
34
+ const model = sharedModel ?? parseVerse(source.slice(from, to));
35
+ const base = sharedModel ? modelOffset : from;
36
+ const boundaries = model.boundaries
37
+ .map((boundary) => ({ ...boundary, sourceOffset: base + boundary.sourceOffset }))
38
+ .filter((boundary) => boundary.sourceOffset >= from && boundary.sourceOffset <= to);
39
+ const projected = plainRomanRun(source, {
40
+ ...options,
41
+ from,
42
+ to,
43
+ exclude: [
44
+ ...exclude,
45
+ ...model.layout
46
+ .map(([first, past]) => [Math.max(from, base + first), Math.min(to, base + past)])
47
+ .filter(([first, past]) => past > first),
48
+ ],
49
+ });
50
+ const text = [],
51
+ offsets = [],
52
+ positions = [],
53
+ ends = [];
54
+ const pending = [...boundaries];
55
+ let boundaryIndex = 0;
56
+ const appendBoundary = (boundary) => {
57
+ text.push("\n");
58
+ offsets.push(boundary.sourceOffset);
59
+ };
60
+ for (let index = 0; index < projected.text.length; index++) {
61
+ ends[index] = text.length;
62
+ while (
63
+ pending[boundaryIndex] &&
64
+ pending[boundaryIndex].sourceOffset <= projected.offsets[index]
65
+ )
66
+ appendBoundary(pending[boundaryIndex++]);
67
+ positions[index] = text.length;
68
+ const char = projected.text[index];
69
+ if (char === "\n" && projected.text[index - 1] === "\r") continue;
70
+ text.push(/[\r\n]/.test(char) ? " " : char);
71
+ offsets.push(projected.offsets[index]);
52
72
  }
53
- const projected = plainRomanRun(source, { ...options, from, to, exclude: layout });
73
+ ends[projected.text.length] = text.length;
74
+ while (pending[boundaryIndex]) appendBoundary(pending[boundaryIndex++]);
75
+ positions[projected.text.length] = text.length;
76
+ projected.text = text.join("");
77
+ projected.offsets = offsets;
78
+ projected.spans = projected.spans.map((span) => ({
79
+ ...span,
80
+ from: positions[span.from],
81
+ to: ends[span.to],
82
+ contentFrom: positions[span.contentFrom],
83
+ contentTo: ends[span.contentTo],
84
+ }));
54
85
  const logicalAt = (offset) => {
55
86
  let first = 0,
56
87
  past = projected.offsets.length;
@@ -61,35 +92,15 @@ export function plainRomanVerse(source, options = {}) {
61
92
  }
62
93
  return first;
63
94
  };
64
- /** @type {Array<{kind: "line"|"stanza"|"canto", at: number, sourceOffset: number}>} */
65
- const boundaries = [];
66
- for (let index = 0; index < lines.length; index++) {
67
- const line = lines[index];
68
- if (line.kind === "content")
69
- boundaries.push({ kind: "line", at: logicalAt(line.to), sourceOffset: line.to });
70
- else if (line.kind === "canto")
71
- boundaries.push({ kind: "canto", at: logicalAt(line.from), sourceOffset: line.from });
72
- else if (lines[index - 1]?.kind === "content") {
73
- const following = lines.slice(index + 1).find((next) => next.kind !== "blank");
74
- if (following?.kind === "content")
75
- boundaries.push({
76
- kind: "stanza",
77
- at: logicalAt(line.from),
78
- sourceOffset: line.from,
79
- });
80
- }
81
- }
82
- const errors = [];
83
- for (let index = 0; index < projected.text.length; index++) {
84
- if (projected.text[index] === "⟦") {
85
- const close = projected.text.indexOf("⟧", index + 1);
86
- index = close < 0 ? projected.text.length : close;
87
- } else if (projected.text[index] === "/")
88
- errors.push({
89
- offset: projected.offsets[index],
90
- message:
91
- "a verse fence uses authored line endings for verse boundaries; write no literal `/` inside it",
92
- });
93
- }
94
- return { ...projected, boundaries, errors };
95
+ return {
96
+ ...projected,
97
+ boundaries: boundaries.map(({ kind, sourceOffset }) => ({
98
+ kind,
99
+ sourceOffset,
100
+ at: logicalAt(sourceOffset),
101
+ })),
102
+ errors: model.errors
103
+ .map(({ offset, message }) => ({ offset: base + offset, message }))
104
+ .filter((error) => error.offset >= from && error.offset < to),
105
+ };
95
106
  }
@@ -60,7 +60,7 @@ import { WORD } from "./word-pattern.mjs";
60
60
 
61
61
  import MarkdownIt from "markdown-it";
62
62
  import { spanMarkdownPlugin } from "./content-spans.mjs";
63
- import { STANZA_BREAK, verseLine } from "./content-verse.mjs";
63
+ import { parseVerse } from "./content-verse.mjs";
64
64
  import { inlineText } from "./name-markup.mjs";
65
65
 
66
66
  const verseParser = new MarkdownIt().use(spanMarkdownPlugin);
@@ -311,13 +311,6 @@ export function metreFindings(blocks, lines, ctx) {
311
311
  column: ctx.attributeColumns(opening, key)[part],
312
312
  });
313
313
  const error = (position, message) => errors.push({ ...position, message });
314
- if (Number(block.attributes.cols ?? 1) > 1) {
315
- error(
316
- at("scan", "key"),
317
- "`scan=` counts the beats of one language's lines; a verse with cols>1 groups independent physical lines",
318
- );
319
- continue;
320
- }
321
314
  const metre = metres.get(name);
322
315
  if (!metre) {
323
316
  const near = nearest(name, [...metres.keys()]);
@@ -346,40 +339,40 @@ export function metreFindings(blocks, lines, ctx) {
346
339
  );
347
340
  continue;
348
341
  }
349
- for (let line = block.start + 1; line < block.end; line++) {
350
- const text = lines[line];
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",
342
+ const body = lines.slice(block.start + 1, block.end).join("\n");
343
+ const model = block.model ?? parseVerse(body);
344
+ const sourcePosition = (offset) => {
345
+ const preceding = body.slice(0, offset);
346
+ return {
347
+ line: block.start + 2 + preceding.split("\n").length - 1,
348
+ column: preceding.length - preceding.lastIndexOf("\n"),
349
+ };
350
+ };
351
+ for (const group of model.groups) {
352
+ if (group.cantoBreak) continue;
353
+ for (const reading of group.lines) {
354
+ const tokens = verseParser.parseInline(reading.text, {})[0]?.children ?? [];
355
+ const tag = (name) => (ctx.languages?.[name]?.tag ?? name).toLowerCase();
356
+ const foreign = tokens.find(
357
+ (token) =>
358
+ token.type === "span_open" &&
359
+ token.attrGet("lang") &&
360
+ tag(token.attrGet("lang")) !== tag(lang),
373
361
  );
374
- continue;
362
+ if (foreign) {
363
+ error(
364
+ sourcePosition(reading.offsets[foreign.meta.start] ?? reading.from),
365
+ "`scan=` counts one language's beats; this verse line contains another language",
366
+ );
367
+ continue;
368
+ }
369
+ const beats = lineBeats(inlineText(tokens), book);
370
+ if (metre.beats >= beats.min && metre.beats <= beats.max) continue;
371
+ warnings.push({
372
+ ...sourcePosition(reading.from),
373
+ message: metreMessage(beats, metre),
374
+ });
375
375
  }
376
- const beats = lineBeats(inlineText(tokens), book);
377
- if (metre.beats >= beats.min && metre.beats <= beats.max) continue;
378
- warnings.push({
379
- line: line + 1,
380
- column: /^\s*/.exec(text)[0].length + 1,
381
- message: metreMessage(beats, metre),
382
- });
383
376
  }
384
377
  }
385
378
  return { errors, warnings };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@heroiclands/package-build",
3
- "version": "25.2.0",
3
+ "version": "25.3.0",
4
4
  "description": "Shared toolchain for building and shipping a HeroicLands Foundry VTT package \u2014 content compilation, manifest, localization, staging, bundle, release and deployment.",
5
5
  "license": "GPL-3.0-or-later",
6
6
  "type": "module",