@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
@@ -34,9 +34,8 @@
34
34
  *
35
35
  * Span, div, verse and scroll scanners retain original UTF-16 source offsets.
36
36
  * Public diagnostics count Unicode code-point columns. A scroll's face must
37
- * belong to a script written vertically, and parallel verse selects encoding
38
- * on each reading rather than the fence. Every native content line retains
39
- * its line boundary before a stanza or canto.
37
+ * belong to a script written vertically. An encoded verse derives native and
38
+ * Roman presentations from one authored text and retains explicit boundaries.
40
39
  * @module
41
40
  */
42
41
 
@@ -44,8 +43,9 @@ import MarkdownIt from "markdown-it";
44
43
  import { blockTokens } from "./markdown-blocks.mjs";
45
44
 
46
45
  import { scanBlocks, BLOCK_CONTAINERS } from "./content-blocks.mjs";
47
- import { scanVerse, hasColumns, verseLine, verseGroups, STANZA_BREAK } from "./content-verse.mjs";
46
+ import { scanVerse } from "./content-verse.mjs";
48
47
  import { scanSpans } from "./content-spans.mjs";
48
+ import { footnoteLabels, footnoteReferences } from "./content-footnotes.mjs";
49
49
  import { scanScrolls, scrollParagraphs } from "./content-scroll.mjs";
50
50
  import { directionsFromRecords } from "./script-direction.mjs";
51
51
  import { isLanguageTag } from "./language-tag.mjs";
@@ -127,7 +127,7 @@ const CONFIG = "package-build.config.yaml";
127
127
  */
128
128
 
129
129
  /**
130
- * One span, div, verse fence or scroll fence carrying run attributes.
130
+ * One span, div, verse div or scroll fence carrying run attributes.
131
131
  * Values combine explicit choices, class-only inheritance, encoding-document
132
132
  * language/font defaults and the Roman default encoding.
133
133
  *
@@ -233,7 +233,7 @@ function withinOneEdit(a, b) {
233
233
  }
234
234
 
235
235
  /** The word each construct is called in a finding. */
236
- const CONSTRUCT = Object.freeze({ span: "span", block: "div", verse: "verse fence" });
236
+ const CONSTRUCT = Object.freeze({ span: "span", block: "div", verse: "verse div" });
237
237
 
238
238
  /** Line starts of a source, for offsets to positions. */
239
239
  function lineStarts(text) {
@@ -835,9 +835,7 @@ function analyseRuns(source, ctx, syntax) {
835
835
  );
836
836
  const errors = [];
837
837
  const warnings = [];
838
- const footnotes = new Set(
839
- [...text.matchAll(/^ {0,3}\[\^([^\]\s]+)\]:/gm)].map((match) => match[1]),
840
- );
838
+ const footnotes = footnoteLabels(text);
841
839
  /** @param {{line: number, column: number}} at @param {string} message */
842
840
  const report = (at, message) => errors.push({ line: at.line, column: at.column, message });
843
841
  /** @param {Record<string, string>} values */
@@ -891,6 +889,7 @@ function analyseRuns(source, ctx, syntax) {
891
889
  const collect = (offset, body) => {
892
890
  const scanned = offset === 0 && syntax ? syntax(body).blocks : scanBlocks(body);
893
891
  for (const block of scanned.blocks) {
892
+ if (block.name === "verse" || block.classes?.includes("verse")) continue;
894
893
  const first = offset + block.start;
895
894
  const last = offset + block.end;
896
895
  const opening = lines[first];
@@ -912,7 +911,7 @@ function analyseRuns(source, ctx, syntax) {
912
911
  for (const key of Object.keys(keys))
913
912
  report(
914
913
  at(key, "key"),
915
- `\`${key}=\` is read on a span, a div or a verse fence, not on a ${block.name} block`,
914
+ `\`${key}=\` is read on a span, a div or a verse div, not on a ${block.name} block`,
916
915
  );
917
916
  } else {
918
917
  divs.set(first, block);
@@ -949,7 +948,6 @@ function analyseRuns(source, ctx, syntax) {
949
948
  for (const poem of scanVerse(text).blocks) {
950
949
  poems.set(poem.start, poem);
951
950
  const keys = runKeys(poem.attributes);
952
- if (!Object.keys(keys).length && !poem.classes.includes(NATIVEFONT_CLASS)) continue;
953
951
  const opening = lines[poem.start];
954
952
  constructs.push({
955
953
  kind: "verse",
@@ -1055,11 +1053,6 @@ function analyseRuns(source, ctx, syntax) {
1055
1053
  nativeAt(construct),
1056
1054
  "`.nativefont` is not an accepted class; select an encoding with `enc=`",
1057
1055
  );
1058
- if (native(construct) && construct.kind === "verse" && hasColumns(construct.node))
1059
- report(
1060
- construct.at("enc", "value"),
1061
- "a verse with cols>1 selects encoding on individual runs, not the fence",
1062
- );
1063
1056
 
1064
1057
  if (lang !== undefined && !Object.hasOwn(languages, lang)) {
1065
1058
  if (isLanguageTag(lang)) {
@@ -1202,48 +1195,98 @@ function analyseRuns(source, ctx, syntax) {
1202
1195
  }
1203
1196
 
1204
1197
  const verseMarkers = new Map();
1205
- const verseContexts = new Map();
1206
- const verseReadings = new Map();
1207
- for (const poem of scanVerse(text).blocks) {
1208
- for (let line = poem.start + 1; line < poem.end; line++) verseContexts.set(line, poem);
1209
- for (let line = poem.start + 1; line < poem.end; line++)
1210
- if (
1211
- STANZA_BREAK.test(lines[line].trim()) ||
1212
- /^---\s+\{\.canto\}$/.test(lines[line].trim())
1213
- )
1214
- verseMarkers.set(starts[line], starts[line] + lines[line].length);
1215
- if (hasColumns(poem)) {
1216
- for (const group of verseGroups(poem, lines)) {
1217
- if (group.stanzaBreak) continue;
1218
- for (const part of group.parts) {
1219
- verseReadings.set(part.line, part);
1220
- const start = starts[part.line];
1221
- if (part.prefixLength)
1222
- verseMarkers.set(
1223
- start + part.sourceColumn - 1,
1224
- start + part.textColumn - 1,
1225
- );
1226
- if (part.markerLength)
1227
- verseMarkers.set(
1228
- start + part.markerStart,
1229
- start + part.markerStart + part.markerLength,
1230
- );
1231
- }
1198
+ const verseContexts = [
1199
+ ...scanVerse(text, { checkWorks: false }).blocks,
1200
+ ...spans
1201
+ .filter((span) => span.verseModel)
1202
+ .map((span) => ({
1203
+ bodyStart: span.start + 1,
1204
+ bodyEnd: span.contentEnd,
1205
+ body: span.text,
1206
+ model: span.verseModel,
1207
+ })),
1208
+ ];
1209
+ for (const poem of verseContexts) {
1210
+ const bodyStart = poem.bodyStart;
1211
+ for (const [from, to] of poem.model.layout)
1212
+ verseMarkers.set(bodyStart + from, bodyStart + to);
1213
+ for (const match of poem.body.matchAll(WIKILINK)) {
1214
+ const bar = match[1].indexOf("|");
1215
+ if (bar < 0) {
1216
+ verseMarkers.set(
1217
+ bodyStart + match.index,
1218
+ bodyStart + match.index + match[0].length,
1219
+ );
1220
+ continue;
1232
1221
  }
1233
- } else
1234
- for (let line = poem.start + 1; line < poem.end; line++) {
1235
- const { markerStart, markerLength } = verseLine(lines[line]);
1236
- if (markerLength) {
1237
- const from = starts[line] + markerStart;
1238
- verseMarkers.set(from, from + markerLength);
1239
- }
1222
+ verseMarkers.set(bodyStart + match.index, bodyStart + match.index + 3 + bar);
1223
+ verseMarkers.set(
1224
+ bodyStart + match.index + match[0].indexOf("]]"),
1225
+ bodyStart + match.index + match[0].length,
1226
+ );
1227
+ }
1228
+ // Link destinations and footnote references remain Markdown; only a
1229
+ // link's visible label contributes text to the encoding projection.
1230
+ for (let at = 0; at < poem.body.length; at++) {
1231
+ if (poem.body[at] === "\\") {
1232
+ at++;
1233
+ continue;
1234
+ }
1235
+ if (poem.body[at] !== "[") continue;
1236
+ const end = closing(poem.body, at);
1237
+ if (end < 0) continue;
1238
+ if (poem.body[end + 1] === "(" && poem.body[at - 1] !== "!") {
1239
+ const past = closing(poem.body, end + 1);
1240
+ if (past < 0) continue;
1241
+ verseMarkers.set(bodyStart + at, bodyStart + at + 1);
1242
+ verseMarkers.set(bodyStart + end, bodyStart + past + 1);
1240
1243
  }
1244
+ }
1241
1245
  }
1242
1246
 
1247
+ const noteReferences = [
1248
+ ...new Map(
1249
+ [
1250
+ ...footnoteReferences(text, { definitions: footnotes }),
1251
+ ...spans.flatMap((span) => {
1252
+ const label = referenceLabel(span.text);
1253
+ if (!label) return [];
1254
+ const base = span.start + 1 + label[0];
1255
+ return footnoteReferences(span.text.slice(label[0], label[1]), {
1256
+ definitions: footnotes,
1257
+ inline: true,
1258
+ }).map((reference) => ({
1259
+ ...reference,
1260
+ from: base + reference.from,
1261
+ to: base + reference.to,
1262
+ }));
1263
+ }),
1264
+ ...verseContexts.flatMap((region) =>
1265
+ footnoteReferences(region.body, { definitions: footnotes, inline: true }).map(
1266
+ (reference) => ({
1267
+ ...reference,
1268
+ from: reference.from + region.bodyStart,
1269
+ to: reference.to + region.bodyStart,
1270
+ }),
1271
+ ),
1272
+ ),
1273
+ ].map((reference) => [reference.from, reference]),
1274
+ ).values(),
1275
+ ];
1276
+
1243
1277
  /** Nested spans and verse markers excluded from a run, by absolute offset. */
1244
1278
  const holesIn = (from, to) =>
1245
1279
  new Map([
1280
+ ...noteReferences
1281
+ .filter((reference) => reference.from >= from && reference.from < to)
1282
+ .map((reference) => [reference.from, reference.to]),
1246
1283
  ...[...verseMarkers].filter(([start]) => start >= from && start < to),
1284
+ ...verseContexts
1285
+ .filter(
1286
+ (region) =>
1287
+ Number.isFinite(region.from) && region.from >= from && region.from < to,
1288
+ )
1289
+ .map((region) => [region.from, Math.min(region.to, to)]),
1247
1290
  ...active
1248
1291
  .filter(
1249
1292
  (construct) =>
@@ -1406,6 +1449,7 @@ function analyseRuns(source, ctx, syntax) {
1406
1449
  selection: enc === undefined ? undefined : encodingFor(ctx, { enc, font, lang }),
1407
1450
  holes: new Map(),
1408
1451
  body: undefined,
1452
+ verseModel: construct.kind === "span" ? construct.node.verseModel : undefined,
1409
1453
  contentEnd: construct.kind === "span" ? construct.node.contentEnd : undefined,
1410
1454
  reference: undefined,
1411
1455
  languageEmphasis: lang === undefined ? undefined : ctx.languageEmphases?.get(lang),
@@ -1416,7 +1460,10 @@ function analyseRuns(source, ctx, syntax) {
1416
1460
  detail.conventions = conventionsOf(detail, ctx);
1417
1461
  details.push(detail);
1418
1462
  const nonEnglishSpan =
1419
- construct.kind === "span" && run.tag !== undefined && !/^en(?:-|$)/i.test(run.tag);
1463
+ construct.kind === "span" &&
1464
+ !construct.node.verseModel &&
1465
+ run.tag !== undefined &&
1466
+ !/^en(?:-|$)/i.test(run.tag);
1420
1467
  const emphasis = construct.own.classes.filter(
1421
1468
  (name) => name === "emph" || name === "strong",
1422
1469
  );
@@ -1447,7 +1494,14 @@ function analyseRuns(source, ctx, syntax) {
1447
1494
  [from, to] = detail.reference;
1448
1495
  }
1449
1496
  const inline = text.slice(from, to);
1450
- detail.holes = label ? new Map() : holesIn(from, to);
1497
+ detail.holes =
1498
+ label ?
1499
+ new Map(
1500
+ noteReferences
1501
+ .filter((reference) => reference.from >= from && reference.to <= to)
1502
+ .map((reference) => [reference.from, reference.to]),
1503
+ )
1504
+ : holesIn(from, to);
1451
1505
  if (nonEnglishSpan) {
1452
1506
  const relative = new Map(
1453
1507
  [...detail.holes].map(([from, to]) => [
@@ -1507,7 +1561,7 @@ function analyseRuns(source, ctx, syntax) {
1507
1561
  said(
1508
1562
  top,
1509
1563
  token.info.trim().split(/\s/)[0] === "verse" ?
1510
- "a verse fence"
1564
+ "a verse div"
1511
1565
  : "a code fence",
1512
1566
  );
1513
1567
  else if (Object.hasOwn(BLOCK_WHAT, token.type)) said(top, BLOCK_WHAT[token.type]);
@@ -1560,23 +1614,7 @@ function analyseRuns(source, ctx, syntax) {
1560
1614
  }
1561
1615
  return owners.get(detail);
1562
1616
  };
1563
- const wholeVerseReading = (detail) => {
1564
- if (detail.run.kind === "verse") return true;
1565
- if (detail.run.kind !== "span") return false;
1566
- const line = detail.run.line - 1;
1567
- const poem = verseContexts.get(line);
1568
- if (!poem) return false;
1569
- const end = starts[line] + lines[line].length;
1570
- if (detail.run.end > end) return false;
1571
- const reading = verseReadings.get(line);
1572
- const from =
1573
- hasColumns(poem) ? starts[line] + (reading?.textColumn ?? 1) - 1 : starts[line];
1574
- return (
1575
- from <= detail.run.start &&
1576
- /^[ \t.]*$/.test(text.slice(from, detail.run.start)) &&
1577
- /^[ \t\r]*$/.test(text.slice(detail.run.end, end))
1578
- );
1579
- };
1617
+
1580
1618
  const spanDetails = new Map(
1581
1619
  details
1582
1620
  .filter((detail) => detail.run.kind === "span")
@@ -1588,7 +1626,18 @@ function analyseRuns(source, ctx, syntax) {
1588
1626
  const owned = details.filter((detail) => ownerOf(detail) === owner);
1589
1627
  for (const detail of owned) detail.owner = owner;
1590
1628
  const excluded = [
1629
+ ...noteReferences
1630
+ .filter((reference) => reference.from >= bounds[0] && reference.from < bounds[1])
1631
+ .map((reference) => [reference.from, reference.to]),
1591
1632
  ...[...verseMarkers].filter(([start]) => start >= bounds[0] && start < bounds[1]),
1633
+ ...verseContexts
1634
+ .filter(
1635
+ (region) =>
1636
+ Number.isFinite(region.from) &&
1637
+ region.from >= bounds[0] &&
1638
+ region.from < bounds[1],
1639
+ )
1640
+ .map((region) => [region.from, Math.min(region.to, bounds[1])]),
1592
1641
  ...owned.flatMap((detail) =>
1593
1642
  detail.emphasis.map((delimiter) => [
1594
1643
  delimiter.offset,
@@ -1604,13 +1653,16 @@ function analyseRuns(source, ctx, syntax) {
1604
1653
  includeSpan: (span) =>
1605
1654
  spanDetails.has(span.start) && ownerOf(spanDetails.get(span.start)) === owner,
1606
1655
  };
1607
- const verse = wholeVerseReading(owner);
1656
+ const poem = verseContexts
1657
+ .filter((region) => region.bodyStart <= bounds[0] && region.bodyEnd >= bounds[1])
1658
+ .sort((a, b) => a.bodyEnd - a.bodyStart - (b.bodyEnd - b.bodyStart))[0];
1659
+ const verse = poem !== undefined;
1608
1660
  const projection =
1609
1661
  verse ?
1610
1662
  plainRomanVerse(text, {
1611
1663
  ...projectionOptions,
1612
- isCantoLine: (line) =>
1613
- STANZA_BREAK.test(line.trim()) || /^---\s+\{\.canto\}$/.test(line.trim()),
1664
+ model: poem.model,
1665
+ modelOffset: poem.bodyStart,
1614
1666
  })
1615
1667
  : plainRomanRun(text, projectionOptions);
1616
1668
  const before = errors.length;
@@ -1628,18 +1680,7 @@ function analyseRuns(source, ctx, syntax) {
1628
1680
  );
1629
1681
  for (const problem of projection.errors ?? [])
1630
1682
  report(positionOf(starts, problem.offset), problem.message);
1631
- if (!verse && verseContexts.has(owner.run.line - 1))
1632
- for (const match of projection.text.matchAll(/\//g)) {
1633
- if (
1634
- projection.text.lastIndexOf("⟦", match.index) >
1635
- projection.text.lastIndexOf("⟧", match.index)
1636
- )
1637
- continue;
1638
- report(
1639
- positionOf(starts, projection.offsets[match.index]),
1640
- "a verse fence uses authored line endings for verse boundaries; write no literal `/` inside it",
1641
- );
1642
- }
1683
+
1643
1684
  const book = ctx.wordBooks?.get(owner.run.lang);
1644
1685
  if (book) {
1645
1686
  const mask = projection.text.split("");
@@ -1799,7 +1840,7 @@ function analyseRuns(source, ctx, syntax) {
1799
1840
  details.sort((a, b) => order(a.run, b.run));
1800
1841
  errors.sort(order);
1801
1842
  warnings.sort(order);
1802
- return { runs, details, errors, warnings, spans, divs, poems };
1843
+ return { runs, details, errors, warnings, spans, divs, poems, references: noteReferences };
1803
1844
  }
1804
1845
 
1805
1846
  /**
@@ -1825,7 +1866,7 @@ function diagnosticColumns(source, findings) {
1825
1866
  }
1826
1867
 
1827
1868
  /**
1828
- * Find the spans, divs and verse fences marked with `lang=`, `font=` or
1869
+ * Find the spans, divs and verse divs marked with `lang=`, `font=` or
1829
1870
  * `enc=`, and every fault in their markup and their text.
1830
1871
  *
1831
1872
  * @param {string} source - Authored Markdown: a note body, or a whole file
@@ -2026,11 +2067,10 @@ function setRange(text, from, to, detail, lineStart, book) {
2026
2067
 
2027
2068
  /**
2028
2069
  * Rewrite text marked with `lang=`, `font=` and `enc=` into the plain span,
2029
- * div and verse grammar the rest of each chain renders, one line for one line.
2070
+ * div and verse grammar the rest of each chain renders.
2030
2071
  *
2031
- * - **Numbered verse prefixes and indentation markers** stay outside run text.
2032
- * Each authored run chooses its own language and presentation; physical
2033
- * lines and source positions remain unchanged.
2072
+ * - **Verse layout markers** stay outside encoded text. A shared logical
2073
+ * structure retains authored source positions and both presentations.
2034
2074
  * - **The enc, if any, is applied first.** Characters Markdown would read are
2035
2075
  * then backslash-escaped, so they reach every later pass as characters.
2036
2076
  * - **Emphasis in a face** becomes an `<em>` or a `<strong>` carrying its
@@ -2057,7 +2097,7 @@ function setRange(text, from, to, detail, lineStart, book) {
2057
2097
  * scanner projections.
2058
2098
  * @param {{target?: "site"|"foundry"|"book"}} [options] - The target the
2059
2099
  * Markdown is rendered for; only `site` writes the search marker.
2060
- * @returns {{markdown: string, errors: Array<{file?: string, line: number, column: number, message: string}>, scrolls: Map<string, import("./content-scroll.mjs").ScrollLayout>}}
2100
+ * @returns {{markdown: string, errors: Array<{file?: string, line: number, column: number, message: string}>, scrolls: Map<string, import("./content-scroll.mjs").ScrollLayout>, verses: Map<string, {model: ReturnType<typeof import("./content-verse.mjs").parseVerse>, classes: string[], attributes: Record<string, string>, inline: boolean}>}}
2061
2101
  * The rewritten Markdown, the findings of {@link scanRuns}, and each
2062
2102
  * rewritten scroll fence's layout, by its rewritten body.
2063
2103
  */
@@ -2067,11 +2107,12 @@ export function renderRuns(source, ctx = {}, syntax, options = {}) {
2067
2107
  const text = String(source ?? "");
2068
2108
  /** @type {Map<string, import("./content-scroll.mjs").ScrollLayout>} */
2069
2109
  const scrolls = new Map();
2110
+ const verses = new Map();
2070
2111
  if (!/\b(?:lang|font|enc)=|\bnativefont\b|\.(?:emph|strong)\b|⟦/.test(text))
2071
- return { markdown: text, errors: [], scrolls };
2072
- const { details, errors } = analyseRuns(text, ctx, syntax);
2112
+ return { markdown: text, errors: [], scrolls, verses };
2113
+ const { details, errors, references } = analyseRuns(text, ctx, syntax);
2073
2114
  if (!details.length)
2074
- return { markdown: text, errors: diagnosticColumns(text, errors), scrolls };
2115
+ return { markdown: text, errors: diagnosticColumns(text, errors), scrolls, verses };
2075
2116
  const lines = text.split("\n");
2076
2117
  const starts = lineStarts(text);
2077
2118
 
@@ -2090,6 +2131,33 @@ export function renderRuns(source, ctx = {}, syntax, options = {}) {
2090
2131
 
2091
2132
  /** @type {Array<{from: number, to: number, text: string}>} */
2092
2133
  const edits = [];
2134
+ const poems = [
2135
+ ...scanVerse(text, { checkWorks: false }).blocks.map((block) => ({
2136
+ ...block,
2137
+ inline: false,
2138
+ })),
2139
+ ...scanSpans(text)
2140
+ .spans.filter((span) => span.verseModel)
2141
+ .map((span) => ({
2142
+ ...span,
2143
+ inline: true,
2144
+ model: span.verseModel,
2145
+ body: span.text,
2146
+ bodyStart: span.start + 1,
2147
+ bodyEnd: span.contentEnd,
2148
+ })),
2149
+ ].sort((a, b) => a.bodyStart - b.bodyStart);
2150
+ const logicalRanges = poems.flatMap((poem) => {
2151
+ const base = poem.bodyStart;
2152
+ return poem.model.groups.flatMap((group) =>
2153
+ group.cantoBreak ? [] : group.lines.map((line) => [base + line.from, base + line.to]),
2154
+ );
2155
+ });
2156
+ const rangesWithin = (from, to) => {
2157
+ const cuts = logicalRanges.flat().filter((at) => at > from && at < to);
2158
+ const points = [from, ...new Set(cuts.sort((a, b) => a - b)), to];
2159
+ return points.slice(1).map((past, index) => [points[index], past]);
2160
+ };
2093
2161
 
2094
2162
  /**
2095
2163
  * A scroll fence's body set as one line per paragraph, and its layout.
@@ -2207,8 +2275,11 @@ export function renderRuns(source, ctx = {}, syntax, options = {}) {
2207
2275
  for (const [from, to] of detail.reference ?
2208
2276
  [detail.reference]
2209
2277
  : outsideHoles(run.start + 1, contentEnd, detail.holes)) {
2210
- const set = setRange(text, from, to, detail, false, book);
2211
- if (set !== text.slice(from, to)) edits.push({ from, to, text: set });
2278
+ for (const [first, past] of rangesWithin(from, to)) {
2279
+ const set = setRange(text, first, past, detail, false, book);
2280
+ if (set !== text.slice(first, past))
2281
+ edits.push({ from: first, to: past, text: set });
2282
+ }
2212
2283
  }
2213
2284
  continue;
2214
2285
  }
@@ -2239,9 +2310,12 @@ export function renderRuns(source, ctx = {}, syntax, options = {}) {
2239
2310
  }
2240
2311
  if (detail.plain && detail.body)
2241
2312
  for (const [from, to] of outsideHoles(detail.body[0], detail.body[1], detail.holes)) {
2242
- const lineStart = from === 0 || text[from - 1] === "\n";
2243
- const set = setRange(text, from, to, detail, lineStart, book);
2244
- if (set !== text.slice(from, to)) edits.push({ from, to, text: set });
2313
+ for (const [first, past] of rangesWithin(from, to)) {
2314
+ const lineStart = first === 0 || text[first - 1] === "\n";
2315
+ const set = setRange(text, first, past, detail, lineStart, book);
2316
+ if (set !== text.slice(first, past))
2317
+ edits.push({ from: first, to: past, text: set });
2318
+ }
2245
2319
  }
2246
2320
  }
2247
2321
 
@@ -2265,8 +2339,190 @@ export function renderRuns(source, ctx = {}, syntax, options = {}) {
2265
2339
  }
2266
2340
  }
2267
2341
 
2342
+ const restoredReferences = new Map();
2343
+ for (const reference of references) {
2344
+ const nativeDiv = details.find(
2345
+ (detail) =>
2346
+ detail.run.kind === "verse" &&
2347
+ detail.selection &&
2348
+ detail.run.enc !== "default" &&
2349
+ detail.body &&
2350
+ reference.from >= detail.body[0] &&
2351
+ reference.to <= detail.body[1] &&
2352
+ !faulted(detail.run),
2353
+ );
2354
+ const nativeSpans = details
2355
+ .filter(
2356
+ (detail) =>
2357
+ detail.run.kind === "span" &&
2358
+ detail.selection &&
2359
+ detail.run.enc !== "default" &&
2360
+ reference.from > detail.run.start &&
2361
+ reference.to <= detail.contentEnd &&
2362
+ !faulted(detail.run),
2363
+ )
2364
+ .sort((a, b) => a.run.start - b.run.start);
2365
+ const outer = nativeSpans[0];
2366
+ if (!nativeDiv && !outer) continue;
2367
+ if (!edits.some((edit) => edit.from <= reference.from && edit.to >= reference.to))
2368
+ edits.push({ from: reference.from, to: reference.to, text: "" });
2369
+ if (nativeDiv) continue;
2370
+ if (!restoredReferences.has(outer.run.end)) restoredReferences.set(outer.run.end, []);
2371
+ restoredReferences.get(outer.run.end).push(reference);
2372
+ }
2373
+ for (const [at, notes] of restoredReferences)
2374
+ edits.push({
2375
+ from: at,
2376
+ to: at,
2377
+ text: notes
2378
+ .sort((a, b) => a.from - b.from)
2379
+ .map((reference) => reference.text)
2380
+ .join(""),
2381
+ });
2382
+
2268
2383
  let markdown = text;
2269
2384
  for (const edit of edits.sort((a, b) => b.from - a.from))
2270
2385
  markdown = markdown.slice(0, edit.from) + edit.text + markdown.slice(edit.to);
2271
- return { markdown, errors: diagnosticColumns(text, errors), scrolls };
2386
+ // Models retain authored structure; a native script may draw the boundaries
2387
+ // as glyphs which must not be reparsed as Roman delimiters.
2388
+ const rewritten = [
2389
+ ...scanVerse(markdown, { checkWorks: false }).blocks.map((block) => ({
2390
+ ...block,
2391
+ inline: false,
2392
+ })),
2393
+ ...scanSpans(markdown)
2394
+ .spans.filter((span) => span.verseModel)
2395
+ .map((span) => ({
2396
+ ...span,
2397
+ inline: true,
2398
+ body: span.text,
2399
+ bodyStart: span.start + 1,
2400
+ bodyEnd: span.contentEnd,
2401
+ })),
2402
+ ].sort((a, b) => a.bodyStart - b.bodyStart);
2403
+ const pairedEdits = [];
2404
+ for (const [index, poem] of poems.entries()) {
2405
+ const detail = details.find(
2406
+ (detail) =>
2407
+ detail.run.kind === (poem.inline ? "span" : "verse") &&
2408
+ detail.run.start === poem.start,
2409
+ );
2410
+ if (!detail || detail.run.enc === "default" || !detail.selection || faulted(detail.run))
2411
+ continue;
2412
+ const block = rewritten[index];
2413
+ if (!block) continue;
2414
+ const base = poem.bodyStart;
2415
+ const applyLine = (line) => {
2416
+ const patches = [];
2417
+ for (const edit of edits) {
2418
+ const positions = line.offsets.flatMap((offset, index) => {
2419
+ const sourceOffset = base + offset;
2420
+ return sourceOffset >= edit.from && sourceOffset < edit.to ? [index] : [];
2421
+ });
2422
+ if (positions.length)
2423
+ patches.push({ from: positions[0], to: positions.at(-1) + 1, text: edit.text });
2424
+ else if (edit.from === edit.to && edit.from === base + line.to)
2425
+ patches.push({ from: line.text.length, to: line.text.length, text: edit.text });
2426
+ }
2427
+ let result = line.text;
2428
+ for (const edit of patches.sort((a, b) => b.from - a.from))
2429
+ result = result.slice(0, edit.from) + edit.text + result.slice(edit.to);
2430
+ return result.replace(/\r?\n[ ]*/g, "").trim();
2431
+ };
2432
+ const rewriteSpanAttributes = (source, rewrite) => {
2433
+ const changes = scanSpans(source).spans.flatMap((span) => {
2434
+ const attrs = parseExtensionAttributes(
2435
+ source.slice(span.contentEnd + 2, span.end - 1),
2436
+ );
2437
+ if (!rewrite(attrs, span)) return [];
2438
+ return [
2439
+ {
2440
+ from: span.contentEnd + 2,
2441
+ to: span.end - 1,
2442
+ text: attributeText({
2443
+ lang: attrs.values.lang,
2444
+ id: attrs.id,
2445
+ classes: attrs.classes,
2446
+ values: Object.entries(attrs.values).filter(([key]) => key !== "lang"),
2447
+ }),
2448
+ },
2449
+ ];
2450
+ });
2451
+ let result = source;
2452
+ for (const change of changes.sort((a, b) => b.from - a.from))
2453
+ result = result.slice(0, change.from) + change.text + result.slice(change.to);
2454
+ return result;
2455
+ };
2456
+ const romanLine = (source) =>
2457
+ rewriteSpanAttributes(source, (attrs) => {
2458
+ const lang = attrs.values.lang;
2459
+ if (lang !== undefined) attrs.values.lang = ctx.languages?.[lang]?.tag ?? lang;
2460
+ delete attrs.values.enc;
2461
+ delete attrs.values.font;
2462
+ attrs.id = "";
2463
+ return true;
2464
+ });
2465
+ const model = structuredClone(poem.model);
2466
+ for (const note of model.notes ?? []) {
2467
+ note.romanText = romanLine(note.text);
2468
+ note.text = "";
2469
+ }
2470
+ for (const group of model.groups) {
2471
+ if (group.cantoBreak) {
2472
+ group.text = detail.selection.encoding.controls.verseCanto ?? "";
2473
+ continue;
2474
+ }
2475
+ for (const line of group.lines) {
2476
+ line.romanText = romanLine(line.text);
2477
+ const ids = new Set(
2478
+ scanSpans(line.text)
2479
+ .spans.map((span) => span.id)
2480
+ .filter(Boolean),
2481
+ );
2482
+ line.text = rewriteSpanAttributes(applyLine(line), (attrs, span) => {
2483
+ if (!span.id || ids.has(span.id)) return false;
2484
+ attrs.id = "";
2485
+ return true;
2486
+ });
2487
+ }
2488
+ }
2489
+ if (poem.inline) {
2490
+ const values = Object.fromEntries(
2491
+ block.attrs.filter(([key]) => !["id", "class"].includes(key)),
2492
+ );
2493
+ const classes = (block.attrs.find(([key]) => key === "class")?.[1] ?? "")
2494
+ .split(/\s+/)
2495
+ .filter((name) => name && name !== "verse");
2496
+ const attributes = attributeText({
2497
+ lang: values.lang,
2498
+ id: block.id,
2499
+ classes,
2500
+ values: Object.entries(values).filter(([key]) => key !== "lang"),
2501
+ });
2502
+ const native = model.groups
2503
+ .flatMap((group) =>
2504
+ group.cantoBreak ? [group.text ?? ""] : group.lines.map((line) => line.text),
2505
+ )
2506
+ .join("");
2507
+ pairedEdits.push({
2508
+ from: block.start,
2509
+ to: block.end,
2510
+ text: `[${native}]{${attributes}}`,
2511
+ });
2512
+ continue;
2513
+ }
2514
+ const classes = [SCRIPT_CLASS, ...(detail.face ? [detail.face.class] : [])];
2515
+ const attributes = {
2516
+ ...(detail.run.tag ? { lang: detail.run.tag } : {}),
2517
+ ...(detail.face?.direction === "rtl" ? { dir: "rtl" } : {}),
2518
+ ...(search ? { "data-pagefind-ignore": "true" } : {}),
2519
+ };
2520
+ const marker = `<!--verse-pair:${verses.size + 1}-->${poem.inline ? "" : "\n"}`;
2521
+ const body = marker + block.body;
2522
+ verses.set(body, { model, classes, attributes, inline: poem.inline });
2523
+ pairedEdits.push({ from: block.bodyStart, to: block.bodyStart, text: marker });
2524
+ }
2525
+ for (const edit of pairedEdits.sort((a, b) => b.from - a.from))
2526
+ markdown = markdown.slice(0, edit.from) + edit.text + markdown.slice(edit.to);
2527
+ return { markdown, errors: diagnosticColumns(text, errors), scrolls, verses };
2272
2528
  }