@heroiclands/package-build 25.3.0 → 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 (51) hide show
  1. package/CHANGELOG.md +15 -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 +136 -53
  6. package/docs/commands.md +2 -2
  7. package/docs/content-format.md +14 -6
  8. package/docs/diagnostics.md +12 -11
  9. package/docs/guides/writing-in-invented-scripts.md +62 -35
  10. package/docs/reference/format-details.md +6 -6
  11. package/docs/reference/note-types.md +15 -15
  12. package/docs/reference/theme-pages.md +26 -18
  13. package/engine/anchors.mjs +14 -0
  14. package/engine/code-fences.mjs +15 -6
  15. package/engine/content-blocks.mjs +4 -2
  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 +59 -43
  20. package/engine/content-scripts.mjs +204 -42
  21. package/engine/content-spans.mjs +73 -5
  22. package/engine/content-verse.mjs +320 -192
  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 +34 -23
  31. package/engine/prose-lint.mjs +14 -2
  32. package/engine/run-rows.mjs +1 -1
  33. package/engine/sql-tables.mjs +1 -1
  34. package/engine/verse-format.mjs +77 -0
  35. package/engine/verse-metre.mjs +18 -9
  36. package/engine/verse-regions.mjs +181 -0
  37. package/engine/wikilink-pattern.mjs +31 -0
  38. package/engine/wikilink-syntax.mjs +2 -16
  39. package/package.json +1 -1
  40. package/site-theme/static/css/style.css +24 -0
  41. package/types/engine/code-fences.d.mts +1 -3
  42. package/types/engine/content-footnotes.d.mts +23 -0
  43. package/types/engine/content-scripts.d.mts +4 -3
  44. package/types/engine/content-spans.d.mts +3 -1
  45. package/types/engine/content-verse.d.mts +69 -74
  46. package/types/engine/name-markup.d.mts +5 -0
  47. package/types/engine/verse-format.d.mts +9 -0
  48. package/types/engine/verse-metre.d.mts +4 -4
  49. package/types/engine/verse-regions.d.mts +78 -0
  50. package/types/engine/wikilink-pattern.d.mts +17 -0
  51. package/types/engine/wikilink-syntax.d.mts +1 -41
@@ -47,7 +47,7 @@
47
47
  * 2. **Captioned items** — each figure's wrapper and label. Its number, kind
48
48
  * and role come from the figure scan expansion made, so a figure is
49
49
  * numbered once; see {@link module:engine/content-figures.renderFigureBlocks}.
50
- * 3. **Named blocks, verse and scrolls** — `:::` blocks, verse fences and
50
+ * 3. **Named blocks, verse and scrolls** — `:::` blocks, verse divs and
51
51
  * `scroll` fences.
52
52
  * 4. **Alerts.**
53
53
  * 5. **Spans and withheld sections** — written here on the `web` target; the
@@ -79,7 +79,8 @@ import { imagePlugin } from "./content-images.mjs";
79
79
  import { renderRuns } from "./content-scripts.mjs";
80
80
  import { renderSpans, spanMarkdownPlugin } from "./content-spans.mjs";
81
81
  import { cartoucheFigures, renderSymbolSpans } from "./content-symbols.mjs";
82
- import { inlineVerse, scanVerse } from "./content-verse.mjs";
82
+ import { inlineVerse, parseVerse, renderVerseQuotes } from "./content-verse.mjs";
83
+ import { verseDivPlugin } from "./verse-regions.mjs";
83
84
  import { replaceOutsideCode } from "./code-fences.mjs";
84
85
  import { dictionaryEntryPlugin } from "./dictionary-layout.mjs";
85
86
  import { headingAttributesPlugin, headingIdsPlugin } from "./heading-attributes.mjs";
@@ -115,48 +116,59 @@ export function highlightCode(code, language) {
115
116
  return hljs.highlight(code, { language: name, ignoreIllegals: true }).value;
116
117
  }
117
118
 
118
- /**
119
- * Parse the verse a `verse` fence holds as inline Markdown.
120
- *
121
- * A fence is literal to the block parser; a poem's lines are prose with
122
- * inline Markdown. The lines are parsed before footnote definitions are
123
- * collected, with the surrounding note's env, so a reference in a verse
124
- * resolves against definitions written outside the fence.
125
- *
126
- * @param {import("markdown-it")} md - Markdown parser to extend.
127
- */
119
+ /** Parse logical verse lines in the note's shared inline environment. */
120
+ function parseVerseContent(token, md, env, region) {
121
+ const body = token.type === "verse_inline" ? token.meta.text : token.content;
122
+ const inline = token.type === "verse_inline";
123
+ const paired = inline ? undefined : env.verses?.get(body);
124
+ const footnotes = new Set(Object.keys(env.footnotes?.refs ?? {}).map((key) => key.slice(1)));
125
+ const model = paired?.model ?? region.model ?? parseVerse(body, { inline, footnotes });
126
+ token.meta ??= {};
127
+ token.meta.verse = region;
128
+ token.meta.versePaired = paired;
129
+ const parse = (text) => {
130
+ const children = [];
131
+ md.inline.parse(inlineVerse(text), md, env, children);
132
+ return children;
133
+ };
134
+ token.meta.verseNotes = (model.notes ?? []).map((line) => ({
135
+ children: parse(line.romanText ?? line.text),
136
+ }));
137
+ token.meta.verseGroups = model.groups.map((group) =>
138
+ group.cantoBreak ?
139
+ {
140
+ ...group,
141
+ children: group.text ? parse(group.text) : [],
142
+ titleChildren: group.title ? parse(group.title) : [],
143
+ }
144
+ : {
145
+ ...group,
146
+ lines: group.lines.map((line) => ({
147
+ ...line,
148
+ children: parse(line.text),
149
+ ...(paired ? { romanChildren: parse(line.romanText ?? line.text) } : {}),
150
+ })),
151
+ },
152
+ );
153
+ }
154
+
155
+ /** An opaque quoted verse is parsed once, before footnote collection. */
156
+ function quotedVerse(token, state) {
157
+ const classes = (token.attrGet("class") ?? "")
158
+ .split(/\s+/)
159
+ .filter((name) => name && name !== "verse");
160
+ const attributes = Object.fromEntries(
161
+ (token.attrs ?? []).filter(([key]) => !["id", "class"].includes(key)),
162
+ );
163
+ parseVerseContent(token, state.md, state.env, { id: token.attrGet("id"), classes, attributes });
164
+ }
165
+
166
+ /** A verse div's raw body receives inline parsing without block interpretation. */
128
167
  function verseInlinePlugin(md) {
129
168
  md.core.ruler.after("inline", "verse_inline", (state) => {
130
169
  for (const token of state.tokens) {
131
- if (token.type !== "fence" || !/^verse(?:\s|$)/.test(token.info?.trim() || ""))
132
- continue;
133
- token.meta ??= {};
134
- const source = `${token.markup}${token.info}\n${token.content}${token.markup}`;
135
- const poem = scanVerse(source, { checkWorks: false }).blocks[0];
136
- if (!poem) continue;
137
- const paired = state.env.verses?.get(token.content.replace(/\n$/, ""));
138
- const model = paired?.model ?? poem.model;
139
- token.meta.verse = poem;
140
- token.meta.versePaired = paired;
141
- const parse = (text) => {
142
- const children = [];
143
- state.md.inline.parse(inlineVerse(text), state.md, state.env, children);
144
- return children;
145
- };
146
- token.meta.verseGroups = model.groups.map((group) =>
147
- group.cantoBreak ?
148
- { ...group, children: group.text ? parse(group.text) : [] }
149
- : {
150
- ...group,
151
- lines: group.lines.map((line) => ({
152
- ...line,
153
- children: parse(line.text),
154
- ...(paired ?
155
- { romanChildren: parse(line.romanText ?? line.text) }
156
- : {}),
157
- })),
158
- },
159
- );
170
+ if (token.type !== "verse_div") continue;
171
+ parseVerseContent(token, state.md, state.env, token.meta.verseRegion);
160
172
  }
161
173
  });
162
174
  }
@@ -384,7 +396,8 @@ export function createRenderer(target, options = {}) {
384
396
  use("footnotes", footnotePlugin);
385
397
  if (book) {
386
398
  use("alerts", alertMarkdownPlugin);
387
- use("spans", spanMarkdownPlugin);
399
+ use("spans", spanMarkdownPlugin, { verseInline: quotedVerse });
400
+ use("verse-div", verseDivPlugin);
388
401
  use("verse-inline", verseInlinePlugin);
389
402
  use("script-emphasis", scriptEmphasisPlugin);
390
403
  use("definition-lists", deflistPlugin);
@@ -456,6 +469,7 @@ export function createRenderer(target, options = {}) {
456
469
  renderInline,
457
470
  },
458
471
  ).markdown;
472
+ text = renderVerseQuotes(text);
459
473
  if (target === "web") text = renderSpans(text).markdown;
460
474
  return target === "web" ? renderWithheldSections(text) : text;
461
475
  };
@@ -476,6 +490,8 @@ export function createRenderer(target, options = {}) {
476
490
  )
477
491
  ),
478
492
  renderInline:
479
- book ? unsupported : (markdown) => restore(md.renderInline(String(markdown ?? ""))),
493
+ book ? unsupported : (
494
+ (markdown) => restore(md.renderInline(renderVerseQuotes(String(markdown ?? ""))))
495
+ ),
480
496
  };
481
497
  }
@@ -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,19 @@ 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: span.text,
1206
+ model: span.verseModel,
1207
+ })),
1208
+ ];
1209
+ for (const poem of verseContexts) {
1210
+ const bodyStart = poem.bodyStart;
1203
1211
  for (const [from, to] of poem.model.layout)
1204
1212
  verseMarkers.set(bodyStart + from, bodyStart + to);
1205
1213
  for (const match of poem.body.matchAll(WIKILINK)) {
@@ -1227,11 +1235,7 @@ function analyseRuns(source, ctx, syntax) {
1227
1235
  if (poem.body[at] !== "[") continue;
1228
1236
  const end = closing(poem.body, at);
1229
1237
  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] !== "!") {
1238
+ if (poem.body[end + 1] === "(" && poem.body[at - 1] !== "!") {
1235
1239
  const past = closing(poem.body, end + 1);
1236
1240
  if (past < 0) continue;
1237
1241
  verseMarkers.set(bodyStart + at, bodyStart + at + 1);
@@ -1240,10 +1244,49 @@ function analyseRuns(source, ctx, syntax) {
1240
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]);
@@ -1572,7 +1626,18 @@ function analyseRuns(source, ctx, syntax) {
1572
1626
  const owned = details.filter((detail) => ownerOf(detail) === owner);
1573
1627
  for (const detail of owned) detail.owner = owner;
1574
1628
  const excluded = [
1629
+ ...noteReferences
1630
+ .filter((reference) => reference.from >= bounds[0] && reference.from < bounds[1])
1631
+ .map((reference) => [reference.from, reference.to]),
1575
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])]),
1576
1641
  ...owned.flatMap((detail) =>
1577
1642
  detail.emphasis.map((delimiter) => [
1578
1643
  delimiter.offset,
@@ -1588,17 +1653,16 @@ function analyseRuns(source, ctx, syntax) {
1588
1653
  includeSpan: (span) =>
1589
1654
  spanDetails.has(span.start) && ownerOf(spanDetails.get(span.start)) === owner,
1590
1655
  };
1591
- const poem =
1592
- owner.run.kind === "verse" ?
1593
- poems.get(owner.run.start)
1594
- : verseContexts.get(owner.run.line - 1);
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];
1595
1659
  const verse = poem !== undefined;
1596
1660
  const projection =
1597
1661
  verse ?
1598
1662
  plainRomanVerse(text, {
1599
1663
  ...projectionOptions,
1600
1664
  model: poem.model,
1601
- modelOffset: starts[poem.start + 1],
1665
+ modelOffset: poem.bodyStart,
1602
1666
  })
1603
1667
  : plainRomanRun(text, projectionOptions);
1604
1668
  const before = errors.length;
@@ -1776,7 +1840,7 @@ function analyseRuns(source, ctx, syntax) {
1776
1840
  details.sort((a, b) => order(a.run, b.run));
1777
1841
  errors.sort(order);
1778
1842
  warnings.sort(order);
1779
- return { runs, details, errors, warnings, spans, divs, poems };
1843
+ return { runs, details, errors, warnings, spans, divs, poems, references: noteReferences };
1780
1844
  }
1781
1845
 
1782
1846
  /**
@@ -1802,7 +1866,7 @@ function diagnosticColumns(source, findings) {
1802
1866
  }
1803
1867
 
1804
1868
  /**
1805
- * 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
1806
1870
  * `enc=`, and every fault in their markup and their text.
1807
1871
  *
1808
1872
  * @param {string} source - Authored Markdown: a note body, or a whole file
@@ -2033,7 +2097,7 @@ function setRange(text, from, to, detail, lineStart, book) {
2033
2097
  * scanner projections.
2034
2098
  * @param {{target?: "site"|"foundry"|"book"}} [options] - The target the
2035
2099
  * 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>}>}}
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}>}}
2037
2101
  * The rewritten Markdown, the findings of {@link scanRuns}, and each
2038
2102
  * rewritten scroll fence's layout, by its rewritten body.
2039
2103
  */
@@ -2046,7 +2110,7 @@ export function renderRuns(source, ctx = {}, syntax, options = {}) {
2046
2110
  const verses = new Map();
2047
2111
  if (!/\b(?:lang|font|enc)=|\bnativefont\b|\.(?:emph|strong)\b|⟦/.test(text))
2048
2112
  return { markdown: text, errors: [], scrolls, verses };
2049
- const { details, errors } = analyseRuns(text, ctx, syntax);
2113
+ const { details, errors, references } = analyseRuns(text, ctx, syntax);
2050
2114
  if (!details.length)
2051
2115
  return { markdown: text, errors: diagnosticColumns(text, errors), scrolls, verses };
2052
2116
  const lines = text.split("\n");
@@ -2067,9 +2131,24 @@ export function renderRuns(source, ctx = {}, syntax, options = {}) {
2067
2131
 
2068
2132
  /** @type {Array<{from: number, to: number, text: string}>} */
2069
2133
  const edits = [];
2070
- const poems = scanVerse(text, { checkWorks: false }).blocks;
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);
2071
2150
  const logicalRanges = poems.flatMap((poem) => {
2072
- const base = starts[poem.start + 1];
2151
+ const base = poem.bodyStart;
2073
2152
  return poem.model.groups.flatMap((group) =>
2074
2153
  group.cantoBreak ? [] : group.lines.map((line) => [base + line.from, base + line.to]),
2075
2154
  );
@@ -2260,22 +2339,79 @@ export function renderRuns(source, ctx = {}, syntax, options = {}) {
2260
2339
  }
2261
2340
  }
2262
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
+
2263
2383
  let markdown = text;
2264
2384
  for (const edit of edits.sort((a, b) => b.from - a.from))
2265
2385
  markdown = markdown.slice(0, edit.from) + edit.text + markdown.slice(edit.to);
2266
2386
  // Models retain authored structure; a native script may draw the boundaries
2267
2387
  // as glyphs which must not be reparsed as Roman delimiters.
2268
- const rewritten = scanVerse(markdown, { checkWorks: false }).blocks;
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);
2269
2403
  const pairedEdits = [];
2270
2404
  for (const [index, poem] of poems.entries()) {
2271
2405
  const detail = details.find(
2272
- (detail) => detail.run.kind === "verse" && detail.run.start === poem.start,
2406
+ (detail) =>
2407
+ detail.run.kind === (poem.inline ? "span" : "verse") &&
2408
+ detail.run.start === poem.start,
2273
2409
  );
2274
2410
  if (!detail || detail.run.enc === "default" || !detail.selection || faulted(detail.run))
2275
2411
  continue;
2276
2412
  const block = rewritten[index];
2277
2413
  if (!block) continue;
2278
- const base = starts[poem.start + 1];
2414
+ const base = poem.bodyStart;
2279
2415
  const applyLine = (line) => {
2280
2416
  const patches = [];
2281
2417
  for (const edit of edits) {
@@ -2327,6 +2463,10 @@ export function renderRuns(source, ctx = {}, syntax, options = {}) {
2327
2463
  return true;
2328
2464
  });
2329
2465
  const model = structuredClone(poem.model);
2466
+ for (const note of model.notes ?? []) {
2467
+ note.romanText = romanLine(note.text);
2468
+ note.text = "";
2469
+ }
2330
2470
  for (const group of model.groups) {
2331
2471
  if (group.cantoBreak) {
2332
2472
  group.text = detail.selection.encoding.controls.verseCanto ?? "";
@@ -2346,21 +2486,43 @@ export function renderRuns(source, ctx = {}, syntax, options = {}) {
2346
2486
  });
2347
2487
  }
2348
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
+ }
2349
2514
  const classes = [SCRIPT_CLASS, ...(detail.face ? [detail.face.class] : [])];
2350
2515
  const attributes = {
2351
2516
  ...(detail.run.tag ? { lang: detail.run.tag } : {}),
2352
2517
  ...(detail.face?.direction === "rtl" ? { dir: "rtl" } : {}),
2353
2518
  ...(search ? { "data-pagefind-ignore": "true" } : {}),
2354
2519
  };
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
- });
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 });
2362
2524
  }
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);
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);
2365
2527
  return { markdown, errors: diagnosticColumns(text, errors), scrolls, verses };
2366
2528
  }