@heroiclands/package-build 25.2.1 → 25.4.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.
Files changed (54) hide show
  1. package/CHANGELOG.md +26 -0
  2. package/README.md +1 -1
  3. package/assets/styles/content-base.css +97 -116
  4. package/docs/api.md +30 -6
  5. package/docs/authoring/links-and-markup.md +214 -126
  6. package/docs/commands.md +2 -2
  7. package/docs/content-format.md +31 -6
  8. package/docs/diagnostics.md +27 -27
  9. package/docs/guides/writing-in-invented-scripts.md +89 -83
  10. package/docs/reference/format-details.md +6 -7
  11. package/docs/reference/note-types.md +15 -15
  12. package/docs/reference/theme-pages.md +38 -57
  13. package/engine/anchors.mjs +14 -0
  14. package/engine/code-fences.mjs +15 -6
  15. package/engine/content-blocks.mjs +7 -4
  16. package/engine/content-figures.mjs +19 -5
  17. package/engine/content-footnotes.mjs +60 -1
  18. package/engine/content-index.mjs +1 -1
  19. package/engine/content-render.mjs +66 -48
  20. package/engine/content-scripts.mjs +357 -101
  21. package/engine/content-spans.mjs +76 -10
  22. package/engine/content-verse.mjs +687 -358
  23. package/engine/first-use.mjs +43 -12
  24. package/engine/heading-attributes.mjs +2 -1
  25. package/engine/journals.mjs +8 -1
  26. package/engine/markdown-blocks.mjs +3 -2
  27. package/engine/markup-validation.mjs +17 -0
  28. package/engine/name-markup.mjs +1 -0
  29. package/engine/note-vocabulary.mjs +2 -2
  30. package/engine/pdf-render.mjs +89 -130
  31. package/engine/prose-lint.mjs +14 -2
  32. package/engine/roman-verse.mjs +68 -57
  33. package/engine/run-rows.mjs +1 -1
  34. package/engine/sql-tables.mjs +1 -1
  35. package/engine/verse-format.mjs +77 -0
  36. package/engine/verse-metre.mjs +49 -47
  37. package/engine/verse-regions.mjs +181 -0
  38. package/engine/wikilink-pattern.mjs +31 -0
  39. package/engine/wikilink-syntax.mjs +2 -16
  40. package/package.json +1 -1
  41. package/site-theme/static/css/style.css +68 -96
  42. package/types/engine/code-fences.d.mts +1 -3
  43. package/types/engine/content-blocks.d.mts +3 -1
  44. package/types/engine/content-footnotes.d.mts +23 -0
  45. package/types/engine/content-scripts.d.mts +12 -7
  46. package/types/engine/content-spans.d.mts +3 -1
  47. package/types/engine/content-verse.d.mts +263 -82
  48. package/types/engine/name-markup.d.mts +5 -0
  49. package/types/engine/roman-verse.d.mts +7 -6
  50. package/types/engine/verse-format.d.mts +9 -0
  51. package/types/engine/verse-metre.d.mts +4 -4
  52. package/types/engine/verse-regions.d.mts +78 -0
  53. package/types/engine/wikilink-pattern.d.mts +17 -0
  54. package/types/engine/wikilink-syntax.d.mts +1 -41
@@ -45,6 +45,7 @@ import remarkFrontmatter from "remark-frontmatter";
45
45
  import remarkGfm from "remark-gfm";
46
46
  import MarkdownIt from "markdown-it";
47
47
  import { readQualifier } from "./address.mjs";
48
+ import { verseRegions } from "./verse-regions.mjs";
48
49
  import { scanBlocks } from "./content-blocks.mjs";
49
50
  import { scanFigures } from "./content-figures.mjs";
50
51
  import { RUN_KEYS } from "./content-scripts.mjs";
@@ -169,14 +170,37 @@ const overlaps = (values, value) => {
169
170
  * Explicit inline occurrences, authored strong text and structural exclusions
170
171
  * at original source offsets.
171
172
  */
172
- function prose(source, capturedNames) {
173
+ function prose(source, capturedNames, capturedSpans) {
173
174
  const links = [],
174
175
  paragraphs = [],
175
176
  regions = [],
176
177
  excluded = [],
177
178
  strong = [],
178
179
  html = [];
179
- const masked = source
180
+ const spans = capturedSpans ?? scanSpans(source);
181
+ const verseMasks = verseRegions(source).map((region) => ({
182
+ start: region.from,
183
+ end: region.to,
184
+ block: true,
185
+ }));
186
+ for (const span of spans.spans)
187
+ if (
188
+ span.verseModel ||
189
+ span.attrs.some(
190
+ ([key, value]) => key === "class" && value.split(/\s+/).includes("verse"),
191
+ )
192
+ )
193
+ verseMasks.push({ start: span.start, end: span.end, block: false });
194
+ excluded.push(...verseMasks);
195
+ const verseRanges = verseMasks
196
+ .sort((a, b) => a.start - b.start || b.end - a.end)
197
+ .filter(
198
+ (range, index, all) =>
199
+ !all
200
+ .slice(0, index)
201
+ .some((parent) => parent.start <= range.start && parent.end >= range.end),
202
+ );
203
+ let masked = source
180
204
  .replace(/!\[\[[^\]\n]+\]\]|\{\{[^}\n]+\}\}/g, (text, start) => {
181
205
  excluded.push({ start, end: start + text.length });
182
206
  return blank(text);
@@ -193,6 +217,11 @@ function prose(source, capturedNames) {
193
217
  });
194
218
  return "x".repeat(text.length);
195
219
  });
220
+ for (const range of [...verseRanges].reverse())
221
+ masked =
222
+ masked.slice(0, range.start) +
223
+ masked.slice(range.start, range.end).replace(/[^\r\n]/g, range.block ? " " : "x") +
224
+ masked.slice(range.end);
196
225
  const tree = parser.parse(masked);
197
226
  const env = { references: Object.create(null) };
198
227
  const inline = (node) => {
@@ -285,7 +314,7 @@ function prose(source, capturedNames) {
285
314
  });
286
315
  const authoredLinks = [...new Map(actualLinks.map((link) => [link.start, link])).values()];
287
316
  const linked = ranges(authoredLinks);
288
- const marked = (capturedNames ?? scanSpans(source).names)
317
+ const marked = (capturedNames ?? spans.names)
289
318
  .filter((span) => eligible(span) && !contains(linked, span))
290
319
  .map((span) => ({
291
320
  ...span,
@@ -373,7 +402,7 @@ export function nameUses(markdown, vocabulary) {
373
402
  // Every use carries the class, so a body without it is read no further.
374
403
  if (!source.includes(".name")) return [];
375
404
  const spans = scanSpans(source);
376
- const { marked, links, strong, inParagraph } = prose(source, spans.names);
405
+ const { marked, links, strong, inParagraph } = prose(source, spans.names, spans);
377
406
  const starts = [0];
378
407
  for (let i = 0; i < source.length; i++) if (source[i] === "\n") starts.push(i + 1);
379
408
  // A caption and a named block's opening and closing lines are not prose.
@@ -527,14 +556,16 @@ export function createFirstUseChecker({
527
556
  const { recognize } = catalog;
528
557
  return ({ file, raw }) => {
529
558
  const authored = parsed?.get(file);
530
- const capturedNames =
531
- authored?.raw === raw && Number.isInteger(authored.bodyLine) ?
532
- rebaseNameSpans(
533
- (syntax ? syntax(authored.body).spans : scanSpans(authored.body)).names,
534
- { raw, bodyLine: authored.bodyLine, bodyColumn: authored.bodyColumn },
535
- )
536
- : undefined;
537
- const { marked } = prose(raw, capturedNames);
559
+ let captured;
560
+ if (authored?.raw === raw && Number.isInteger(authored.bodyLine)) {
561
+ const inventory = syntax ? syntax(authored.body).spans : scanSpans(authored.body);
562
+ const context = { raw, bodyLine: authored.bodyLine, bodyColumn: authored.bodyColumn };
563
+ captured = {
564
+ names: rebaseNameSpans(inventory.names, context),
565
+ spans: rebaseNameSpans(inventory.spans, context),
566
+ };
567
+ }
568
+ const { marked } = prose(raw, captured?.names, captured);
538
569
  const findings = new Map();
539
570
  const add = (offset, rule, message) => {
540
571
  if (!findings.has(offset)) findings.set(offset, { rules: [], messages: [] });
@@ -40,7 +40,8 @@ import { blockTokens } from "./markdown-blocks.mjs";
40
40
  function literalHeadingLines(source) {
41
41
  const lines = new Set();
42
42
  for (const token of blockTokens(source)) {
43
- if (!token.map || !["fence", "code_block", "html_block"].includes(token.type)) continue;
43
+ if (!token.map || !["fence", "code_block", "html_block", "verse_div"].includes(token.type))
44
+ continue;
44
45
  for (let line = token.map[0]; line < token.map[1]; line++) lines.add(line);
45
46
  }
46
47
  return lines;
@@ -76,6 +76,7 @@ import { journalHasContent } from "./note-state.mjs";
76
76
  import { hasLexicalEntries } from "./lexical-sections.mjs";
77
77
  import { draftNoticeFor } from "./draft-notice.mjs";
78
78
  import { scanFigures } from "./content-figures.mjs";
79
+ import { verseRegions } from "./verse-regions.mjs";
79
80
  import { separateFootnotes } from "./content-footnotes.mjs";
80
81
  import { WITHHELD_CLASS, pageOpenings } from "./heading-attributes.mjs";
81
82
  import { imagesIn } from "./content-images.mjs";
@@ -154,6 +155,12 @@ export function splitPages(body, leadName = "Introduction", resolveRole, syntax)
154
155
  scanFigures(markdown, { resolveRole }).figures.map((figure) => [figure.line - 1, figure]),
155
156
  );
156
157
  const blockLines = namedBlockLines(markdown, syntax);
158
+ const verseLines = new Set();
159
+ for (const region of verseRegions(markdown))
160
+ for (let line = region.start; line <= region.end; line++) {
161
+ blockLines.add(line);
162
+ verseLines.add(line);
163
+ }
157
164
  const openings = pageOpenings(markdown, blockLines);
158
165
  const pages = [];
159
166
  const beforeFirstH1 = [];
@@ -189,7 +196,7 @@ export function splitPages(body, leadName = "Introduction", resolveRole, syntax)
189
196
  // carry a shorter fence of its own as literal content, same as
190
197
  // {@link module:engine/content-blocks.scanBlocks} and
191
198
  // {@link module:engine/content-figures.scanFigures} track it.
192
- const fence = /^ {0,3}(`{3,}|~{3,})/.exec(line);
199
+ const fence = verseLines.has(lineIndex) ? null : /^ {0,3}(`{3,}|~{3,})/.exec(line);
193
200
  if (codeFence) {
194
201
  if (fence && fence[1][0] === codeFence[0] && fence[1].length >= codeFence.length) {
195
202
  codeFence = null;
@@ -25,9 +25,10 @@
25
25
  */
26
26
 
27
27
  import MarkdownIt from "markdown-it";
28
+ import { verseDivPlugin } from "./verse-regions.mjs";
28
29
 
29
30
  /** The renderer's block grammar, with inline parsing and its passes switched off. */
30
- const parser = new MarkdownIt({ html: true });
31
+ const parser = new MarkdownIt({ html: true }).use(verseDivPlugin);
31
32
  parser.core.ruler.disable(["inline", "linkify", "replacements", "smartquotes", "text_join"]);
32
33
 
33
34
  /** How many recent sources keep their tokens. */
@@ -51,7 +52,7 @@ export function blockTokens(source) {
51
52
  recent.set(text, hit);
52
53
  return hit;
53
54
  }
54
- const tokens = parser.parse(text, {});
55
+ const tokens = parser.parse(text, { verseSource: text });
55
56
  recent.set(text, tokens);
56
57
  if (recent.size > RECENT) recent.delete(recent.keys().next().value);
57
58
  return tokens;
@@ -14,6 +14,8 @@
14
14
  import { scanBlocks } from "./content-blocks.mjs";
15
15
  import { scanAlerts } from "./content-alerts.mjs";
16
16
  import { scanRuns } from "./content-scripts.mjs";
17
+ import { scanSpans } from "./content-spans.mjs";
18
+ import { verseRegions } from "./verse-regions.mjs";
17
19
  import { matchAllOutsideCode } from "./code-fences.mjs";
18
20
 
19
21
  /** The opening of a shortcode: `{{<` or `{{%`. */
@@ -55,9 +57,24 @@ export function markupFindings(source, file, syntax, runs) {
55
57
  );
56
58
  const scanned = syntax?.(body);
57
59
  const marked = runs ? scanRuns(body, runs, syntax) : undefined;
60
+ const spans = scanned?.spans ?? scanSpans(body);
61
+ const verseRanges = [
62
+ ...verseRegions(body).map((region) => [region.from, region.to]),
63
+ ...spans.spans.filter((span) => span.verseModel).map((span) => [span.start, span.end]),
64
+ ];
65
+ const starts = [0];
66
+ for (const match of body.matchAll(/\n/g)) starts.push(match.index + 1);
67
+ const verseErrors = spans.errors.filter((error) => {
68
+ const offset = (starts[error.line - 1] ?? 0) + error.column - 1;
69
+ return (
70
+ error.message.includes(".verse") ||
71
+ verseRanges.some(([from, to]) => offset >= from && offset < to)
72
+ );
73
+ });
58
74
  const errors = [
59
75
  ...(scanned?.blocks ?? scanBlocks(body)).errors,
60
76
  ...(scanned?.alerts ?? scanAlerts(body)).errors,
77
+ ...verseErrors,
61
78
  ...(marked?.errors ?? []),
62
79
  ...shortcodeErrors(body),
63
80
  ].map((error) => ({
@@ -42,6 +42,7 @@
42
42
  * @property {string} visible - Visible text from the actual parsed children.
43
43
  * @property {ReadonlyArray<readonly [string, string]>} attrs - Retained permitted HTML attributes.
44
44
  * @property {Record<string, SpanAttributePosition>} attributePositions - Authored key/value sites by retained attribute name.
45
+ * @property {ReturnType<typeof import("./content-verse.mjs").parseVerse>} [verseModel] - Logical model of an inline verse quotation.
45
46
  */
46
47
 
47
48
  /**
@@ -2338,7 +2338,7 @@ export const NOTE_VOCABULARY = Object.freeze({
2338
2338
  ...TEXT,
2339
2339
  required: true,
2340
2340
  describe:
2341
- "What a verse fence's `scan=` names the metre by: lowercase " +
2341
+ "What a verse div's `scan=` names the metre by: lowercase " +
2342
2342
  "letters, digits and `-`, unique in the package.",
2343
2343
  },
2344
2344
  {
@@ -2362,7 +2362,7 @@ export const NOTE_VOCABULARY = Object.freeze({
2362
2362
  ]),
2363
2363
  }),
2364
2364
  describe:
2365
- "The metres a verse fence's `scan=` names. A fence that states one is " +
2365
+ "The metres a verse div's `scan=` names. A div that states one is " +
2366
2366
  "warned at each line that cannot have its beats. See " +
2367
2367
  "[metres](../authoring/links-and-markup.md#metre).",
2368
2368
  },
@@ -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 } 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";
@@ -505,7 +499,7 @@ export function markdownToTypst(markdown, opts = {}) {
505
499
  const given = opts.nested ? String(markdown ?? "") : stripHtmlComments(markdown);
506
500
  const separated = opts.footnoteDefinitions === undefined ? separateFootnotes(given) : null;
507
501
  const authored = separated?.markdown ?? given;
508
- const definitions = opts.footnoteDefinitions ?? separated?.definitions ?? "";
502
+ let definitions = opts.footnoteDefinitions ?? separated?.definitions ?? "";
509
503
  const footnoteState = opts.footnoteState ?? { seen: new Set() };
510
504
  const sharedOptions = {
511
505
  ...opts,
@@ -519,10 +513,19 @@ export function markdownToTypst(markdown, opts = {}) {
519
513
  // Text marked with a language, a font or a map is rewritten once, ahead of
520
514
  // every other pass, line for line; the nested calls below read the result,
521
515
  // and the scroll fences' layouts the rewrite returns.
516
+ const markingSource = definitions ? `${authored}\n\n${definitions}` : authored;
522
517
  const marked =
523
- opts.nested ? undefined : renderRuns(authored, opts.runs, undefined, { target: "book" });
524
- const source = marked ? marked.markdown : authored;
525
- if (marked) sharedOptions.scrolls = marked.scrolls;
518
+ opts.nested ? undefined : (
519
+ renderRuns(markingSource, opts.runs, undefined, { target: "book" })
520
+ );
521
+ const markedFootnotes = marked && definitions ? separateFootnotes(marked.markdown) : undefined;
522
+ const source = markedFootnotes?.markdown ?? marked?.markdown ?? authored;
523
+ if (markedFootnotes) definitions = markedFootnotes.definitions;
524
+ if (marked) {
525
+ sharedOptions.footnoteDefinitions = definitions;
526
+ sharedOptions.scrolls = marked.scrolls;
527
+ sharedOptions.verses = marked.verses;
528
+ }
526
529
  // One map for the whole body, not one per block: a heading inside a
527
530
  // blockquote or a list item shares the entry's anchor namespace with every
528
531
  // other heading in the same body, because `sectionLabel` scopes by entry
@@ -543,6 +546,7 @@ export function markdownToTypst(markdown, opts = {}) {
543
546
  footnoteState,
544
547
  footnotePrefix: opts.footnotePrefix ?? `footnote-${slugify(anchorPrefix || "body")}`,
545
548
  scrolls: marked?.scrolls ?? opts.scrolls ?? new Map(),
549
+ verses: marked?.verses ?? opts.verses ?? new Map(),
546
550
  };
547
551
  // A withheld section is set in the box a `:::secret` is set in, and its
548
552
  // heading is set inside it: a printed page honours no reader permission, so
@@ -691,7 +695,7 @@ export function markdownToTypst(markdown, opts = {}) {
691
695
  /** Render one fragment with the note's footnote definitions available. */
692
696
  function renderMarkdownSegment(source, md, ctx, definitions) {
693
697
  const input = definitions ? `${source}\n\n${definitions}` : source;
694
- const tokens = md.parse(input, {});
698
+ const tokens = md.parse(input, { verses: ctx.verses });
695
699
  const footnoteStart = tokens.findIndex((token) => token.type === "footnote_block_open");
696
700
  const visible = footnoteStart < 0 ? tokens : tokens.slice(0, footnoteStart);
697
701
  const footnotes = new Map();
@@ -777,37 +781,14 @@ function renderBlock(tokens, i, out, ctx) {
777
781
  out.push(scrollBlock(token, ctx));
778
782
  return 1;
779
783
  }
780
- if (token.type === "fence" && /^verse(?:\s|$)/.test(token.info?.trim() || "")) {
781
- const opening = `${token.markup}${token.info}`;
782
- const scanned = scanVerse(`${opening}\n${token.content}${token.markup}`);
783
- // Findings are collected before rendering. Keep malformed verse
784
- // literal rather than assigning invalid parts to display cells.
785
- if (scanned.errors.length) {
786
- out.push(rawBlock(token.content, token.info));
787
- return 1;
788
- }
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");
806
- 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
- );
810
- } else out.push(rawBlock(token.content, token.info?.trim() || ""));
784
+ out.push(rawBlock(token.content, token.info?.trim() || ""));
785
+ return 1;
786
+ }
787
+ case "verse_div": {
788
+ const poem = token.meta.verse;
789
+ const content = workVerse(token, poem, ctx);
790
+ const anchor = poem?.id ? ` <${sectionLabel(ctx.anchorPrefix, poem.id)}>` : "";
791
+ out.push(`\n#block(width: 100%)[${content}]${anchor}\n`);
811
792
  return 1;
812
793
  }
813
794
  case "hr":
@@ -1300,90 +1281,58 @@ function typstLanguage(tag) {
1300
1281
  /** The classes that set a run in a font: a face's run carries `script`. */
1301
1282
  const FONT_CLASSES = Object.freeze(["script", ...Object.values(GENERIC_FONTS)]);
1302
1283
 
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,
1284
+ /** The logical lines of one stanza, with a shared label and paired scripts. */
1285
+ function workVerse(token, poem, ctx) {
1286
+ const paired = token.meta.versePaired;
1287
+ const labelled = token.type !== "verse_inline" && Boolean(poem?.attributes.work);
1288
+ const nativeClasses = paired?.classes ?? poem?.classes;
1289
+ const nativeAttributes = paired?.attributes ?? poem?.attributes;
1290
+ const body = (token.meta.verseGroups ?? [])
1291
+ .map((group) => {
1292
+ if (group.cantoBreak) {
1293
+ const mark =
1294
+ group.children?.length ? renderInline({ children: group.children }, ctx) : "";
1295
+ const heading =
1296
+ group.titleChildren?.length ?
1297
+ `#heading(level: 3, outlined: false, bookmarked: false)[${renderInline({ children: group.titleChildren }, ctx)}]`
1298
+ : "";
1299
+ const anchor = group.id ? ` <${sectionLabel(ctx.anchorPrefix, group.id)}>` : "";
1300
+ return `${mark ? scriptWrapped(mark, nativeClasses, nativeAttributes) + "\n" : ""}#v(0.5em)${heading ? "\n" + heading : ""}${anchor}`;
1301
+ }
1302
+ const label = labelled ? `#text(size: 0.75em, fill: luma(45%))[${group.label}]` : "";
1303
+ const lineText = (line, roman = false) => {
1304
+ const text = renderInline(
1305
+ { children: roman ? line.romanChildren : line.children },
1306
+ ctx,
1307
+ );
1308
+ const padded = `${line.level ? `#h(${line.level * 1.25}em)` : ""}${text}`;
1309
+ if (roman || !paired) return padded;
1310
+ const wrapped = scriptWrapped(padded, nativeClasses, nativeAttributes);
1311
+ return rightToLeft(line.text) ? `#set text(dir: rtl)\n${wrapped}` : wrapped;
1312
+ };
1313
+ if (!paired) {
1314
+ const lines = scriptWrapped(
1315
+ group.lines.map((line) => lineText(line)).join(" \\\n"),
1316
+ nativeClasses,
1317
+ nativeAttributes,
1318
+ );
1319
+ return labelled ?
1320
+ `#grid(columns: (2.5em, 1fr), column-gutter: 0.5em, [${label}], [${lines}])`
1321
+ : lines;
1322
+ }
1323
+ const cells = group.lines.map((line, index) =>
1324
+ [
1325
+ ...(labelled ? [index === 0 ? `[${label}]` : "[]"] : []),
1326
+ `[${lineText(line)}]`,
1327
+ `[${lineText(line, true)}]`,
1328
+ ].join(", "),
1366
1329
  );
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");
1330
+ const columns = labelled ? "2.5em, 1fr, 1fr" : "1fr, 1fr";
1331
+ return `#set text(dir: ltr)\n#grid(columns: (${columns}), column-gutter: 1.5em, row-gutter: 0.35em,\n ${cells.join(",\n ")}\n)`;
1332
+ })
1333
+ .join("\n\n");
1334
+ const notes = (token.meta.verseNotes ?? []).map((line) => renderInline(line, ctx)).join("");
1335
+ return body + notes;
1387
1336
  }
1388
1337
 
1389
1338
  /**
@@ -1430,6 +1379,12 @@ function renderInline(token, ctx) {
1430
1379
  case "code_inline":
1431
1380
  out.push(inlineRaw(child.content));
1432
1381
  break;
1382
+ case "verse_inline": {
1383
+ const content = workVerse(child, child.meta.verse, ctx);
1384
+ const id = child.attrGet?.("id");
1385
+ out.push(`#box[${content}]${id ? ` <${sectionLabel(ctx.anchorPrefix, id)}>` : ""}`);
1386
+ break;
1387
+ }
1433
1388
  case "span_open": {
1434
1389
  if (["em", "strong"].includes(child.tag)) {
1435
1390
  const classes = (child.attrGet?.("class") ?? "").split(/\s+/);
@@ -1719,8 +1674,7 @@ function pageOfLabel(label) {
1719
1674
  * @returns {string} Typst markup.
1720
1675
  */
1721
1676
  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.
1677
+ // A caller's explicit text fill applies inside the link's label.
1724
1678
  if (ctx.textFill) inner = `#text(fill: ${ctx.textFill})[${inner}]`;
1725
1679
  const url = String(href ?? "");
1726
1680
  const fragment = /#([^?]*)/.exec(url)?.[1] ?? "";
@@ -2012,9 +1966,14 @@ export function bookTypstPreamble(runs = {}, families = {}) {
2012
1966
  " }\n" +
2013
1967
  " if lang != none { text(lang: lang, styled) } else { styled }\n" +
2014
1968
  "}",
2015
- // Span classes reach the book intact.
1969
+ // Plain semantic hooks retain the span classes without choosing their appearance.
1970
+ "#let book-direction(body) = body",
1971
+ "#let book-speaker(body) = body",
2016
1972
  "#let book-span(body, classes: (), lang: none) = {\n" +
2017
- " box(book-script-text(body, classes: classes, lang: lang))\n" +
1973
+ " let styled = book-script-text(body, classes: classes, lang: lang)\n" +
1974
+ ' if "direction" in classes { styled = book-direction(styled) }\n' +
1975
+ ' if "speaker" in classes { styled = book-speaker(styled) }\n' +
1976
+ " box(styled)\n" +
2018
1977
  "}",
2019
1978
  // Use the file's natural measure for auto. Every requested width is
2020
1979
  // bounded by the available measure and by the height of a page.
@@ -35,6 +35,7 @@
35
35
  import fs from "node:fs";
36
36
  import path from "node:path";
37
37
  import { formatNoteFrontmatter } from "./note-format.mjs";
38
+ import { protectVerseSource } from "./verse-format.mjs";
38
39
 
39
40
  import {
40
41
  MARKDOWNLINT_CONFIG,
@@ -161,6 +162,17 @@ export async function checkFormatting(root, opts = {}) {
161
162
  }
162
163
  }
163
164
 
165
+ const format = async (input) => {
166
+ const protectedSource =
167
+ ["markdown", "mdx"].includes(info.inferredParser) ?
168
+ protectVerseSource(input)
169
+ : undefined;
170
+ const output = await prettier.format(protectedSource?.markdown ?? input, options);
171
+ return formatNoteFrontmatter(
172
+ protectedSource ? protectedSource.restore(output) : output,
173
+ );
174
+ };
175
+
164
176
  // A file Prettier cannot parse is a finding, not a crash. Its own CLI
165
177
  // reports the syntax error and carries on to the next file, and a
166
178
  // single unparseable file in a large tree must not cost the report on
@@ -172,7 +184,7 @@ export async function checkFormatting(root, opts = {}) {
172
184
  let formatted = source;
173
185
  let converged = false;
174
186
  for (let pass = 0; pass < MAX_FORMAT_PASSES; pass += 1) {
175
- const next = formatNoteFrontmatter(await prettier.format(formatted, options));
187
+ const next = await format(formatted);
176
188
  if (next === formatted) {
177
189
  converged = true;
178
190
  break;
@@ -206,7 +218,7 @@ export async function checkFormatting(root, opts = {}) {
206
218
  continue;
207
219
  }
208
220
 
209
- if (formatNoteFrontmatter(await prettier.format(source, options)) !== source) {
221
+ if ((await format(source)) !== source) {
210
222
  findings.push({
211
223
  file,
212
224
  severity: "error",