@heroiclands/package-build 25.3.0 → 25.5.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 (62) hide show
  1. package/CHANGELOG.md +39 -0
  2. package/README.md +1 -1
  3. package/assets/styles/content-base.css +32 -0
  4. package/docs/api.md +30 -6
  5. package/docs/authoring/links-and-markup.md +143 -53
  6. package/docs/commands.md +8 -2
  7. package/docs/content-format.md +14 -6
  8. package/docs/diagnostics.md +12 -11
  9. package/docs/encoding-core.md +18 -4
  10. package/docs/guides/writing-in-invented-scripts.md +83 -37
  11. package/docs/reference/format-details.md +10 -11
  12. package/docs/reference/note-types.md +15 -15
  13. package/docs/reference/theme-pages.md +26 -18
  14. package/engine/anchors.mjs +14 -0
  15. package/engine/code-fences.mjs +15 -6
  16. package/engine/content-blocks.mjs +4 -2
  17. package/engine/content-figures.mjs +19 -5
  18. package/engine/content-footnotes.mjs +69 -1
  19. package/engine/content-index.mjs +1 -1
  20. package/engine/content-render.mjs +59 -43
  21. package/engine/content-scripts.mjs +301 -58
  22. package/engine/content-scroll.mjs +8 -3
  23. package/engine/content-spans.mjs +107 -6
  24. package/engine/content-verse.mjs +338 -198
  25. package/engine/encoding-standard.mjs +1 -0
  26. package/engine/encoding-template.mjs +5 -2
  27. package/engine/encoding.mjs +19 -14
  28. package/engine/first-use.mjs +43 -12
  29. package/engine/heading-attributes.mjs +2 -1
  30. package/engine/journals.mjs +8 -1
  31. package/engine/markdown-blocks.mjs +3 -2
  32. package/engine/markdownlint-run.mjs +190 -5
  33. package/engine/markup-validation.mjs +17 -0
  34. package/engine/name-markup.mjs +1 -0
  35. package/engine/note-vocabulary.mjs +2 -2
  36. package/engine/pdf-render.mjs +34 -23
  37. package/engine/prose-lint.mjs +14 -2
  38. package/engine/roman-characters.mjs +32 -10
  39. package/engine/roman-run.mjs +3 -0
  40. package/engine/roman-verse.mjs +1 -0
  41. package/engine/run-rows.mjs +1 -1
  42. package/engine/sql-tables.mjs +1 -1
  43. package/engine/verse-format.mjs +89 -0
  44. package/engine/verse-metre.mjs +18 -9
  45. package/engine/verse-regions.mjs +190 -0
  46. package/engine/wikilink-pattern.mjs +31 -0
  47. package/engine/wikilink-syntax.mjs +2 -16
  48. package/package.json +1 -1
  49. package/site-theme/static/css/style.css +24 -0
  50. package/types/engine/code-fences.d.mts +1 -3
  51. package/types/engine/content-footnotes.d.mts +23 -0
  52. package/types/engine/content-scripts.d.mts +4 -3
  53. package/types/engine/content-scroll.d.mts +1 -1
  54. package/types/engine/content-spans.d.mts +3 -1
  55. package/types/engine/content-verse.d.mts +75 -75
  56. package/types/engine/name-markup.d.mts +5 -0
  57. package/types/engine/roman-run.d.mts +3 -1
  58. package/types/engine/verse-format.d.mts +10 -0
  59. package/types/engine/verse-metre.d.mts +4 -4
  60. package/types/engine/verse-regions.d.mts +82 -0
  61. package/types/engine/wikilink-pattern.d.mts +17 -0
  62. package/types/engine/wikilink-syntax.d.mts +1 -41
@@ -45,6 +45,7 @@ import { blockTokens } from "./markdown-blocks.mjs";
45
45
  import { scanBlocks, BLOCK_CONTAINERS } from "./content-blocks.mjs";
46
46
  import { scanVerse } from "./content-verse.mjs";
47
47
  import { scanSpans } from "./content-spans.mjs";
48
+ import { footnoteLabels, footnoteReferences } from "./content-footnotes.mjs";
48
49
  import { scanScrolls, scrollParagraphs } from "./content-scroll.mjs";
49
50
  import { directionsFromRecords } from "./script-direction.mjs";
50
51
  import { isLanguageTag } from "./language-tag.mjs";
@@ -126,7 +127,7 @@ const CONFIG = "package-build.config.yaml";
126
127
  */
127
128
 
128
129
  /**
129
- * One span, div, verse fence or scroll fence carrying run attributes.
130
+ * One span, div, verse div or scroll fence carrying run attributes.
130
131
  * Values combine explicit choices, class-only inheritance, encoding-document
131
132
  * language/font defaults and the Roman default encoding.
132
133
  *
@@ -232,7 +233,7 @@ function withinOneEdit(a, b) {
232
233
  }
233
234
 
234
235
  /** The word each construct is called in a finding. */
235
- const CONSTRUCT = Object.freeze({ span: "span", block: "div", verse: "verse fence" });
236
+ const CONSTRUCT = Object.freeze({ span: "span", block: "div", verse: "verse div" });
236
237
 
237
238
  /** Line starts of a source, for offsets to positions. */
238
239
  function lineStarts(text) {
@@ -834,9 +835,7 @@ function analyseRuns(source, ctx, syntax) {
834
835
  );
835
836
  const errors = [];
836
837
  const warnings = [];
837
- const footnotes = new Set(
838
- [...text.matchAll(/^ {0,3}\[\^([^\]\s]+)\]:/gm)].map((match) => match[1]),
839
- );
838
+ const footnotes = footnoteLabels(text);
840
839
  /** @param {{line: number, column: number}} at @param {string} message */
841
840
  const report = (at, message) => errors.push({ line: at.line, column: at.column, message });
842
841
  /** @param {Record<string, string>} values */
@@ -890,6 +889,7 @@ function analyseRuns(source, ctx, syntax) {
890
889
  const collect = (offset, body) => {
891
890
  const scanned = offset === 0 && syntax ? syntax(body).blocks : scanBlocks(body);
892
891
  for (const block of scanned.blocks) {
892
+ if (block.name === "verse" || block.classes?.includes("verse")) continue;
893
893
  const first = offset + block.start;
894
894
  const last = offset + block.end;
895
895
  const opening = lines[first];
@@ -911,7 +911,7 @@ function analyseRuns(source, ctx, syntax) {
911
911
  for (const key of Object.keys(keys))
912
912
  report(
913
913
  at(key, "key"),
914
- `\`${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`,
915
915
  );
916
916
  } else {
917
917
  divs.set(first, block);
@@ -948,7 +948,6 @@ function analyseRuns(source, ctx, syntax) {
948
948
  for (const poem of scanVerse(text).blocks) {
949
949
  poems.set(poem.start, poem);
950
950
  const keys = runKeys(poem.attributes);
951
- if (!Object.keys(keys).length && !poem.classes.includes(NATIVEFONT_CLASS)) continue;
952
951
  const opening = lines[poem.start];
953
952
  constructs.push({
954
953
  kind: "verse",
@@ -1196,10 +1195,35 @@ function analyseRuns(source, ctx, syntax) {
1196
1195
  }
1197
1196
 
1198
1197
  const verseMarkers = new Map();
1199
- const verseContexts = new Map();
1200
- for (const poem of scanVerse(text, { checkWorks: false }).blocks) {
1201
- for (let line = poem.start + 1; line < poem.end; line++) verseContexts.set(line, poem);
1202
- const bodyStart = starts[poem.start + 1];
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: text.slice(span.start + 1, span.contentEnd),
1206
+ model: span.verseModel,
1207
+ })),
1208
+ ];
1209
+ // A reference with its own run keys is an independent child, just as
1210
+ // the presentation span produced by the link resolver is. Keep its label
1211
+ // out of the enclosing language and encoder without rewriting its target.
1212
+ const independentReferences = rawReferences.filter(
1213
+ (reference) =>
1214
+ Object.keys(reference.construct.keys).length &&
1215
+ verseContexts.some(
1216
+ (poem) =>
1217
+ reference.where.start >= poem.bodyStart && reference.where.end <= poem.bodyEnd,
1218
+ ),
1219
+ );
1220
+ const referenceSpans = independentReferences.map((reference) => ({
1221
+ start: reference.where.start,
1222
+ end: reference.where.end,
1223
+ contentEnd: reference.to,
1224
+ }));
1225
+ for (const poem of verseContexts) {
1226
+ const bodyStart = poem.bodyStart;
1203
1227
  for (const [from, to] of poem.model.layout)
1204
1228
  verseMarkers.set(bodyStart + from, bodyStart + to);
1205
1229
  for (const match of poem.body.matchAll(WIKILINK)) {
@@ -1227,11 +1251,7 @@ function analyseRuns(source, ctx, syntax) {
1227
1251
  if (poem.body[at] !== "[") continue;
1228
1252
  const end = closing(poem.body, at);
1229
1253
  if (end < 0) continue;
1230
- const label = poem.body.slice(at + 1, end);
1231
- if (/^\^[^\s\]]+$/.test(label) && footnotes.has(label.slice(1))) {
1232
- verseMarkers.set(bodyStart + at, bodyStart + end + 1);
1233
- at = end;
1234
- } else if (poem.body[end + 1] === "(" && poem.body[at - 1] !== "!") {
1254
+ if (poem.body[end + 1] === "(" && poem.body[at - 1] !== "!") {
1235
1255
  const past = closing(poem.body, end + 1);
1236
1256
  if (past < 0) continue;
1237
1257
  verseMarkers.set(bodyStart + at, bodyStart + at + 1);
@@ -1240,10 +1260,52 @@ function analyseRuns(source, ctx, syntax) {
1240
1260
  }
1241
1261
  }
1242
1262
 
1263
+ const noteReferences = [
1264
+ ...new Map(
1265
+ [
1266
+ ...footnoteReferences(text, { definitions: footnotes }),
1267
+ ...spans.flatMap((span) => {
1268
+ const label = referenceLabel(span.text);
1269
+ if (!label) return [];
1270
+ const base = span.start + 1 + label[0];
1271
+ return footnoteReferences(span.text.slice(label[0], label[1]), {
1272
+ definitions: footnotes,
1273
+ inline: true,
1274
+ }).map((reference) => ({
1275
+ ...reference,
1276
+ from: base + reference.from,
1277
+ to: base + reference.to,
1278
+ }));
1279
+ }),
1280
+ ...verseContexts.flatMap((region) =>
1281
+ footnoteReferences(region.body, { definitions: footnotes, inline: true }).map(
1282
+ (reference) => ({
1283
+ ...reference,
1284
+ from: reference.from + region.bodyStart,
1285
+ to: reference.to + region.bodyStart,
1286
+ }),
1287
+ ),
1288
+ ),
1289
+ ].map((reference) => [reference.from, reference]),
1290
+ ).values(),
1291
+ ];
1292
+
1243
1293
  /** Nested spans and verse markers excluded from a run, by absolute offset. */
1244
1294
  const holesIn = (from, to) =>
1245
1295
  new Map([
1296
+ ...noteReferences
1297
+ .filter((reference) => reference.from >= from && reference.from < to)
1298
+ .map((reference) => [reference.from, reference.to]),
1246
1299
  ...[...verseMarkers].filter(([start]) => start >= from && start < to),
1300
+ ...independentReferences
1301
+ .filter((reference) => reference.where.start >= from && reference.where.start < to)
1302
+ .map((reference) => [reference.where.start, reference.where.end]),
1303
+ ...verseContexts
1304
+ .filter(
1305
+ (region) =>
1306
+ Number.isFinite(region.from) && region.from >= from && region.from < to,
1307
+ )
1308
+ .map((region) => [region.from, Math.min(region.to, to)]),
1247
1309
  ...active
1248
1310
  .filter(
1249
1311
  (construct) =>
@@ -1406,6 +1468,7 @@ function analyseRuns(source, ctx, syntax) {
1406
1468
  selection: enc === undefined ? undefined : encodingFor(ctx, { enc, font, lang }),
1407
1469
  holes: new Map(),
1408
1470
  body: undefined,
1471
+ verseModel: construct.kind === "span" ? construct.node.verseModel : undefined,
1409
1472
  contentEnd: construct.kind === "span" ? construct.node.contentEnd : undefined,
1410
1473
  reference: undefined,
1411
1474
  languageEmphasis: lang === undefined ? undefined : ctx.languageEmphases?.get(lang),
@@ -1416,7 +1479,10 @@ function analyseRuns(source, ctx, syntax) {
1416
1479
  detail.conventions = conventionsOf(detail, ctx);
1417
1480
  details.push(detail);
1418
1481
  const nonEnglishSpan =
1419
- construct.kind === "span" && run.tag !== undefined && !/^en(?:-|$)/i.test(run.tag);
1482
+ construct.kind === "span" &&
1483
+ !construct.node.verseModel &&
1484
+ run.tag !== undefined &&
1485
+ !/^en(?:-|$)/i.test(run.tag);
1420
1486
  const emphasis = construct.own.classes.filter(
1421
1487
  (name) => name === "emph" || name === "strong",
1422
1488
  );
@@ -1447,7 +1513,14 @@ function analyseRuns(source, ctx, syntax) {
1447
1513
  [from, to] = detail.reference;
1448
1514
  }
1449
1515
  const inline = text.slice(from, to);
1450
- detail.holes = label ? new Map() : holesIn(from, to);
1516
+ detail.holes =
1517
+ label ?
1518
+ new Map(
1519
+ noteReferences
1520
+ .filter((reference) => reference.from >= from && reference.to <= to)
1521
+ .map((reference) => [reference.from, reference.to]),
1522
+ )
1523
+ : holesIn(from, to);
1451
1524
  if (nonEnglishSpan) {
1452
1525
  const relative = new Map(
1453
1526
  [...detail.holes].map(([from, to]) => [
@@ -1471,8 +1544,15 @@ function analyseRuns(source, ctx, syntax) {
1471
1544
 
1472
1545
  const first = construct.line0 + 1;
1473
1546
  const last = construct.last;
1474
- const from = starts[first] ?? text.length;
1475
- const to = last > first ? starts[last] - 1 : from;
1547
+ // Verse regions retain authored UTF-16 bounds, including the full
1548
+ // width of a CRLF terminator. A one-byte closing-line adjustment
1549
+ // would leave CR outside the model and lose verse projection.
1550
+ const from =
1551
+ construct.kind === "verse" ? construct.node.bodyStart : (starts[first] ?? text.length);
1552
+ const to =
1553
+ construct.kind === "verse" ? construct.node.bodyEnd
1554
+ : last > first ? starts[last] - 1
1555
+ : from;
1476
1556
  detail.body = [from, to];
1477
1557
  detail.holes = holesIn(from, to);
1478
1558
 
@@ -1507,7 +1587,7 @@ function analyseRuns(source, ctx, syntax) {
1507
1587
  said(
1508
1588
  top,
1509
1589
  token.info.trim().split(/\s/)[0] === "verse" ?
1510
- "a verse fence"
1590
+ "a code fence named `verse`; write a `::: {.verse}` div"
1511
1591
  : "a code fence",
1512
1592
  );
1513
1593
  else if (Object.hasOwn(BLOCK_WHAT, token.type)) said(top, BLOCK_WHAT[token.type]);
@@ -1572,7 +1652,23 @@ function analyseRuns(source, ctx, syntax) {
1572
1652
  const owned = details.filter((detail) => ownerOf(detail) === owner);
1573
1653
  for (const detail of owned) detail.owner = owner;
1574
1654
  const excluded = [
1575
- ...[...verseMarkers].filter(([start]) => start >= bounds[0] && start < bounds[1]),
1655
+ ...noteReferences
1656
+ .filter((reference) => reference.from >= bounds[0] && reference.from < bounds[1])
1657
+ .map((reference) => [reference.from, reference.to]),
1658
+ ...[...verseMarkers].filter(
1659
+ ([start]) =>
1660
+ start >= bounds[0] &&
1661
+ start < bounds[1] &&
1662
+ !referenceSpans.some((span) => start === span.start),
1663
+ ),
1664
+ ...verseContexts
1665
+ .filter(
1666
+ (region) =>
1667
+ Number.isFinite(region.from) &&
1668
+ region.from >= bounds[0] &&
1669
+ region.from < bounds[1],
1670
+ )
1671
+ .map((region) => [region.from, Math.min(region.to, bounds[1])]),
1576
1672
  ...owned.flatMap((detail) =>
1577
1673
  detail.emphasis.map((delimiter) => [
1578
1674
  delimiter.offset,
@@ -1583,22 +1679,21 @@ function analyseRuns(source, ctx, syntax) {
1583
1679
  const projectionOptions = {
1584
1680
  from: bounds[0],
1585
1681
  to: bounds[1],
1586
- spans,
1682
+ spans: [...spans, ...referenceSpans],
1587
1683
  exclude: excluded,
1588
1684
  includeSpan: (span) =>
1589
1685
  spanDetails.has(span.start) && ownerOf(spanDetails.get(span.start)) === owner,
1590
1686
  };
1591
- const poem =
1592
- owner.run.kind === "verse" ?
1593
- poems.get(owner.run.start)
1594
- : verseContexts.get(owner.run.line - 1);
1687
+ const poem = verseContexts
1688
+ .filter((region) => region.bodyStart <= bounds[0] && region.bodyEnd >= bounds[1])
1689
+ .sort((a, b) => a.bodyEnd - a.bodyStart - (b.bodyEnd - b.bodyStart))[0];
1595
1690
  const verse = poem !== undefined;
1596
1691
  const projection =
1597
1692
  verse ?
1598
1693
  plainRomanVerse(text, {
1599
1694
  ...projectionOptions,
1600
1695
  model: poem.model,
1601
- modelOffset: starts[poem.start + 1],
1696
+ modelOffset: poem.bodyStart,
1602
1697
  })
1603
1698
  : plainRomanRun(text, projectionOptions);
1604
1699
  const before = errors.length;
@@ -1680,12 +1775,25 @@ function analyseRuns(source, ctx, syntax) {
1680
1775
  }
1681
1776
  if (verse) {
1682
1777
  for (const boundary of projection.boundaries) {
1683
- const target =
1684
- boundary.kind === "line" ?
1685
- encodeText(owner.selection.encoding, "/", { font: owner.selection.font })
1686
- : owner.selection.encoding.controls[
1687
- boundary.kind === "stanza" ? "verseStanza" : "verseCanto"
1688
- ];
1778
+ let target;
1779
+ if (boundary.kind === "canto") {
1780
+ const encoded = encodeWithLanguage(owner.selection.encoding, "***", {
1781
+ font: owner.selection.font,
1782
+ lexicon: owner.selection.lexicon,
1783
+ verse: true,
1784
+ });
1785
+ for (const message of new Set(
1786
+ encoded.findings.map((finding) => finding.message),
1787
+ ))
1788
+ report(positionOf(starts, boundary.sourceOffset), message);
1789
+ target = encoded.text;
1790
+ } else
1791
+ target =
1792
+ boundary.kind === "line" ?
1793
+ encodeText(owner.selection.encoding, "/", {
1794
+ font: owner.selection.font,
1795
+ })
1796
+ : owner.selection.encoding.controls.verseStanza;
1689
1797
  if (target === undefined) continue;
1690
1798
  pieces.push({
1691
1799
  from: "",
@@ -1734,6 +1842,17 @@ function analyseRuns(source, ctx, syntax) {
1734
1842
  });
1735
1843
  }
1736
1844
 
1845
+ for (const reference of independentReferences) {
1846
+ const book = ctx.wordBooks?.get(reference.lang);
1847
+ if (!book) continue;
1848
+ const projection = plainRomanRun(text, { from: reference.from, to: reference.to });
1849
+ for (const problem of unknownWords(projection.text, book))
1850
+ report(
1851
+ positionOf(starts, projection.offsets[problem.offset] ?? reference.from),
1852
+ unknownWordMessage(problem.word, reference.lang),
1853
+ );
1854
+ }
1855
+
1737
1856
  const firstEncodingRun =
1738
1857
  active.find(native) ??
1739
1858
  rawReferences.find((reference) => native(reference.construct))?.construct;
@@ -1776,7 +1895,7 @@ function analyseRuns(source, ctx, syntax) {
1776
1895
  details.sort((a, b) => order(a.run, b.run));
1777
1896
  errors.sort(order);
1778
1897
  warnings.sort(order);
1779
- return { runs, details, errors, warnings, spans, divs, poems };
1898
+ return { runs, details, errors, warnings, spans, divs, poems, references: noteReferences };
1780
1899
  }
1781
1900
 
1782
1901
  /**
@@ -1802,7 +1921,7 @@ function diagnosticColumns(source, findings) {
1802
1921
  }
1803
1922
 
1804
1923
  /**
1805
- * Find the spans, divs and verse fences marked with `lang=`, `font=` or
1924
+ * Find the spans, divs and verse divs marked with `lang=`, `font=` or
1806
1925
  * `enc=`, and every fault in their markup and their text.
1807
1926
  *
1808
1927
  * @param {string} source - Authored Markdown: a note body, or a whole file
@@ -2033,7 +2152,7 @@ function setRange(text, from, to, detail, lineStart, book) {
2033
2152
  * scanner projections.
2034
2153
  * @param {{target?: "site"|"foundry"|"book"}} [options] - The target the
2035
2154
  * Markdown is rendered for; only `site` writes the search marker.
2036
- * @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>}>}}
2155
+ * @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}>}}
2037
2156
  * The rewritten Markdown, the findings of {@link scanRuns}, and each
2038
2157
  * rewritten scroll fence's layout, by its rewritten body.
2039
2158
  */
@@ -2046,7 +2165,7 @@ export function renderRuns(source, ctx = {}, syntax, options = {}) {
2046
2165
  const verses = new Map();
2047
2166
  if (!/\b(?:lang|font|enc)=|\bnativefont\b|\.(?:emph|strong)\b|⟦/.test(text))
2048
2167
  return { markdown: text, errors: [], scrolls, verses };
2049
- const { details, errors } = analyseRuns(text, ctx, syntax);
2168
+ const { details, errors, references } = analyseRuns(text, ctx, syntax);
2050
2169
  if (!details.length)
2051
2170
  return { markdown: text, errors: diagnosticColumns(text, errors), scrolls, verses };
2052
2171
  const lines = text.split("\n");
@@ -2067,9 +2186,24 @@ export function renderRuns(source, ctx = {}, syntax, options = {}) {
2067
2186
 
2068
2187
  /** @type {Array<{from: number, to: number, text: string}>} */
2069
2188
  const edits = [];
2070
- const poems = scanVerse(text, { checkWorks: false }).blocks;
2189
+ const poems = [
2190
+ ...scanVerse(text, { checkWorks: false }).blocks.map((block) => ({
2191
+ ...block,
2192
+ inline: false,
2193
+ })),
2194
+ ...scanSpans(text)
2195
+ .spans.filter((span) => span.verseModel)
2196
+ .map((span) => ({
2197
+ ...span,
2198
+ inline: true,
2199
+ model: span.verseModel,
2200
+ body: text.slice(span.start + 1, span.contentEnd),
2201
+ bodyStart: span.start + 1,
2202
+ bodyEnd: span.contentEnd,
2203
+ })),
2204
+ ].sort((a, b) => a.bodyStart - b.bodyStart);
2071
2205
  const logicalRanges = poems.flatMap((poem) => {
2072
- const base = starts[poem.start + 1];
2206
+ const base = poem.bodyStart;
2073
2207
  return poem.model.groups.flatMap((group) =>
2074
2208
  group.cantoBreak ? [] : group.lines.map((line) => [base + line.from, base + line.to]),
2075
2209
  );
@@ -2223,11 +2357,19 @@ export function renderRuns(source, ctx = {}, syntax, options = {}) {
2223
2357
  if (attributes !== undefined) {
2224
2358
  const open = lines[run.start].indexOf("{");
2225
2359
  const close = lines[run.start].lastIndexOf("}");
2226
- edits.push({
2227
- from: starts[run.start] + open + 1,
2228
- to: starts[run.start] + close,
2229
- text: attributes,
2230
- });
2360
+ if (open < 0 && run.kind === "verse") {
2361
+ const opening = lines[run.start];
2362
+ edits.push({
2363
+ from: starts[run.start] + opening.indexOf(":::"),
2364
+ to: starts[run.start] + opening.replace(/\r$/, "").length,
2365
+ text: `::: {${attributes}}`,
2366
+ });
2367
+ } else
2368
+ edits.push({
2369
+ from: starts[run.start] + open + 1,
2370
+ to: starts[run.start] + close,
2371
+ text: attributes,
2372
+ });
2231
2373
  }
2232
2374
  if (detail.plain && detail.body)
2233
2375
  for (const [from, to] of outsideHoles(detail.body[0], detail.body[1], detail.holes)) {
@@ -2260,22 +2402,79 @@ export function renderRuns(source, ctx = {}, syntax, options = {}) {
2260
2402
  }
2261
2403
  }
2262
2404
 
2405
+ const restoredReferences = new Map();
2406
+ for (const reference of references) {
2407
+ const nativeDiv = details.find(
2408
+ (detail) =>
2409
+ detail.run.kind === "verse" &&
2410
+ detail.selection &&
2411
+ detail.run.enc !== "default" &&
2412
+ detail.body &&
2413
+ reference.from >= detail.body[0] &&
2414
+ reference.to <= detail.body[1] &&
2415
+ !faulted(detail.run),
2416
+ );
2417
+ const nativeSpans = details
2418
+ .filter(
2419
+ (detail) =>
2420
+ detail.run.kind === "span" &&
2421
+ detail.selection &&
2422
+ detail.run.enc !== "default" &&
2423
+ reference.from > detail.run.start &&
2424
+ reference.to <= detail.contentEnd &&
2425
+ !faulted(detail.run),
2426
+ )
2427
+ .sort((a, b) => a.run.start - b.run.start);
2428
+ const outer = nativeSpans[0];
2429
+ if (!nativeDiv && !outer) continue;
2430
+ if (!edits.some((edit) => edit.from <= reference.from && edit.to >= reference.to))
2431
+ edits.push({ from: reference.from, to: reference.to, text: "" });
2432
+ if (nativeDiv) continue;
2433
+ if (!restoredReferences.has(outer.run.end)) restoredReferences.set(outer.run.end, []);
2434
+ restoredReferences.get(outer.run.end).push(reference);
2435
+ }
2436
+ for (const [at, notes] of restoredReferences)
2437
+ edits.push({
2438
+ from: at,
2439
+ to: at,
2440
+ text: notes
2441
+ .sort((a, b) => a.from - b.from)
2442
+ .map((reference) => reference.text)
2443
+ .join(""),
2444
+ });
2445
+
2263
2446
  let markdown = text;
2264
2447
  for (const edit of edits.sort((a, b) => b.from - a.from))
2265
2448
  markdown = markdown.slice(0, edit.from) + edit.text + markdown.slice(edit.to);
2266
2449
  // Models retain authored structure; a native script may draw the boundaries
2267
2450
  // as glyphs which must not be reparsed as Roman delimiters.
2268
- const rewritten = scanVerse(markdown, { checkWorks: false }).blocks;
2451
+ const rewritten = [
2452
+ ...scanVerse(markdown, { checkWorks: false }).blocks.map((block) => ({
2453
+ ...block,
2454
+ inline: false,
2455
+ })),
2456
+ ...scanSpans(markdown)
2457
+ .spans.filter((span) => span.verseModel)
2458
+ .map((span) => ({
2459
+ ...span,
2460
+ inline: true,
2461
+ body: markdown.slice(span.start + 1, span.contentEnd),
2462
+ bodyStart: span.start + 1,
2463
+ bodyEnd: span.contentEnd,
2464
+ })),
2465
+ ].sort((a, b) => a.bodyStart - b.bodyStart);
2269
2466
  const pairedEdits = [];
2270
2467
  for (const [index, poem] of poems.entries()) {
2271
2468
  const detail = details.find(
2272
- (detail) => detail.run.kind === "verse" && detail.run.start === poem.start,
2469
+ (detail) =>
2470
+ detail.run.kind === (poem.inline ? "span" : "verse") &&
2471
+ detail.run.start === poem.start,
2273
2472
  );
2274
2473
  if (!detail || detail.run.enc === "default" || !detail.selection || faulted(detail.run))
2275
2474
  continue;
2276
2475
  const block = rewritten[index];
2277
2476
  if (!block) continue;
2278
- const base = starts[poem.start + 1];
2477
+ const base = poem.bodyStart;
2279
2478
  const applyLine = (line) => {
2280
2479
  const patches = [];
2281
2480
  for (const edit of edits) {
@@ -2327,9 +2526,16 @@ export function renderRuns(source, ctx = {}, syntax, options = {}) {
2327
2526
  return true;
2328
2527
  });
2329
2528
  const model = structuredClone(poem.model);
2529
+ for (const note of model.notes ?? []) {
2530
+ note.romanText = romanLine(note.text);
2531
+ note.text = "";
2532
+ }
2330
2533
  for (const group of model.groups) {
2331
2534
  if (group.cantoBreak) {
2332
- group.text = detail.selection.encoding.controls.verseCanto ?? "";
2535
+ group.text =
2536
+ detail.plan?.pieces.find(
2537
+ (piece) => piece.boundary === "canto" && piece.offset === base + group.from,
2538
+ )?.to ?? "";
2333
2539
  continue;
2334
2540
  }
2335
2541
  for (const line of group.lines) {
@@ -2346,21 +2552,58 @@ export function renderRuns(source, ctx = {}, syntax, options = {}) {
2346
2552
  });
2347
2553
  }
2348
2554
  }
2555
+ if (poem.inline) {
2556
+ const values = Object.fromEntries(
2557
+ block.attrs.filter(([key]) => !["id", "class"].includes(key)),
2558
+ );
2559
+ const classes = (block.attrs.find(([key]) => key === "class")?.[1] ?? "")
2560
+ .split(/\s+/)
2561
+ .filter((name) => name && name !== "verse");
2562
+ const attributes = attributeText({
2563
+ lang: values.lang,
2564
+ id: block.id,
2565
+ classes,
2566
+ values: Object.entries(values).filter(([key]) => key !== "lang"),
2567
+ });
2568
+ const fragments = model.groups.flatMap((group) =>
2569
+ group.cantoBreak ? [{ from: group.from, text: group.text ?? "" }] : group.lines,
2570
+ );
2571
+ // Consecutive boundaries can stand between logical lines. They
2572
+ // still contribute controls even when no line owns their offsets.
2573
+ for (const piece of detail.plan?.pieces ?? []) {
2574
+ if (!piece.boundary || piece.boundary === "canto") continue;
2575
+ const offset = piece.anchor - base;
2576
+ if (
2577
+ model.groups.some((group) =>
2578
+ group.lines.some((line) => offset >= line.from && offset <= line.to),
2579
+ )
2580
+ )
2581
+ continue;
2582
+ fragments.push({ from: offset, text: escapeRoman(piece.to, false) });
2583
+ }
2584
+ const native = fragments
2585
+ .sort((a, b) => a.from - b.from)
2586
+ .map((fragment) => fragment.text)
2587
+ .join("");
2588
+ pairedEdits.push({
2589
+ from: block.start,
2590
+ to: block.end,
2591
+ text: `[${native}]{${attributes}}`,
2592
+ });
2593
+ continue;
2594
+ }
2349
2595
  const classes = [SCRIPT_CLASS, ...(detail.face ? [detail.face.class] : [])];
2350
2596
  const attributes = {
2351
2597
  ...(detail.run.tag ? { lang: detail.run.tag } : {}),
2352
2598
  ...(detail.face?.direction === "rtl" ? { dir: "rtl" } : {}),
2353
2599
  ...(search ? { "data-pagefind-ignore": "true" } : {}),
2354
2600
  };
2355
- const body = `<!--verse-pair:${verses.size + 1}-->\n${block.body}`;
2356
- verses.set(body, { model, classes, attributes });
2357
- const rewrittenStarts = lineStarts(markdown);
2358
- pairedEdits.push({
2359
- at: rewrittenStarts[block.start + 1],
2360
- text: `<!--verse-pair:${verses.size}-->\n`,
2361
- });
2601
+ const marker = `<!--verse-pair:${verses.size + 1}-->${poem.inline ? "" : "\n"}`;
2602
+ const body = marker + block.body;
2603
+ verses.set(body, { model, classes, attributes, inline: poem.inline });
2604
+ pairedEdits.push({ from: block.bodyStart, to: block.bodyStart, text: marker });
2362
2605
  }
2363
- for (const edit of pairedEdits.sort((a, b) => b.at - a.at))
2364
- markdown = markdown.slice(0, edit.at) + edit.text + markdown.slice(edit.at);
2606
+ for (const edit of pairedEdits.sort((a, b) => b.from - a.from))
2607
+ markdown = markdown.slice(0, edit.from) + edit.text + markdown.slice(edit.to);
2365
2608
  return { markdown, errors: diagnosticColumns(text, errors), scrolls, verses };
2366
2609
  }
@@ -43,6 +43,7 @@
43
43
  */
44
44
 
45
45
  import { blockTokens } from "./markdown-blocks.mjs";
46
+ import { verseRegions } from "./verse-regions.mjs";
46
47
 
47
48
  import { parseExtensionAttributes, refusedAttributes } from "./extension-attributes.mjs";
48
49
 
@@ -114,7 +115,7 @@ function keyColumns(line, key) {
114
115
  * Read every `scroll` fence in a source, and every fault in its opening.
115
116
  *
116
117
  * A fence inside a block quote or an HTML block is not read, and neither is a
117
- * fence quoted inside another fence.
118
+ * fence quoted inside another fence. Verse lines are inline content.
118
119
  *
119
120
  * @param {string} source - Markdown.
120
121
  * @returns {{blocks: Scroll[], errors: Array<{line: number, column: number, message: string}>}}
@@ -125,8 +126,12 @@ export function scanScrolls(source) {
125
126
  const blocks = [];
126
127
  const errors = [];
127
128
  if (!/^ *(?:`{3,}|~{3,}) *scroll(?:\s|$)/m.test(text)) return { blocks, errors };
128
- const lines = text.split("\n");
129
- const literalLines = new Set();
129
+ const lines = text.split(/\r?\n/);
130
+ const literalLines = new Set(
131
+ verseRegions(text).flatMap((region) =>
132
+ Array.from({ length: region.end - region.start + 1 }, (_, i) => region.start + i),
133
+ ),
134
+ );
130
135
  for (const token of blockTokens(text)) {
131
136
  if (!token.map || !["blockquote_open", "html_block"].includes(token.type)) continue;
132
137
  for (let line = token.map[0]; line < token.map[1]; line++) literalLines.add(line);