@heroiclands/package-build 25.4.0 → 25.5.1

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.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,35 @@
1
1
  # @heroiclands/package-build
2
2
 
3
+ ## 25.5.1
4
+
5
+ **Builds and commands**
6
+
7
+ - `package-build markdown` checks a repository of any size, rather than crashing on large ones.
8
+
9
+ ## 25.5.0
10
+
11
+ **Languages and scripts**
12
+
13
+ - A `.`, `?`, `‼` or `!` placed at the start of a sentence in invented-language roman is reported at that mark.
14
+
15
+ **Verse**
16
+
17
+ - A `--- {.canto}` break keeps the stanza break before it and decodes back without adding an extra line ending.
18
+ - Inline `[…]{.verse}` keeps repeated and trailing `//` stanza marks alongside its `/` line marks and footnotes, including in files with CRLF line endings.
19
+ - A literal `\/` in verse stays inside its line and decodes with its backslash intact.
20
+ - A link inside verse is written in the link label's own language, not the verse's.
21
+ - A verse line that looks like a footnote definition or a code fence stays ordinary verse text instead of raising a footnote error or swallowing what follows.
22
+ - A scroll placed after verse renders and reports its own findings correctly, whichever line endings the file uses.
23
+ - A nested shorthand verse div takes the language of the verse around it and shows no stray attribute text on the page.
24
+ - Verse findings inside lists and block quotes point to the verse's opening mark, and leave out the column when the position is unknown.
25
+ - `package-build format` keeps block quotes that contain verse, aligns tables that quote verse, and gives each document one consistent line ending.
26
+ - `package-build markdown` leaves verse untouched while still checking and fixing the prose around it.
27
+ - The authoring guide describes verse scansion and the warnings for names as the build applies them.
28
+
29
+ **Upgrading**
30
+
31
+ - Move any `.`, `?`, `‼` or `!` that opens a sentence in invented-language roman to the end of that sentence.
32
+
3
33
  ## 25.4.0
4
34
 
5
35
  **Verse**
@@ -598,7 +598,8 @@ indentation and source-line joining as a div. It takes ordinary span attributes,
598
598
  including `lang=` and `enc=`. An unencoded quotation draws Roman text with its
599
599
  line and stanza layout. A concrete encoding draws only its script, continuously
600
600
  inside the surrounding prose, like any encoded span. Its `/` and `//` reach the
601
- encoder as verse controls, so a script with verse signs draws them. Only divs
601
+ encoder as verse controls, so a script with verse signs draws them. Consecutive
602
+ stanza endings and a trailing stanza ending each retain their control. Only divs
602
603
  have paired script and Roman columns.
603
604
  Inline verse has no `work=`, `form=`, `scan=`, stanza labels or canto breaks.
604
605
  Directions, speaker labels, links, names and shared note footnotes work inside it.
@@ -663,8 +664,14 @@ The captain raises the lamp.
663
664
  The heading appears once across an encoded div's presentations. Its title is
664
665
  heading text, separate from the letters passed to the encoder. A valid break
665
666
  supplies the encoder's standard Roman `***` canto control. `package-build format`
666
- leaves each complete verse div and quotation byte for byte, including its
667
- delimiters and attributes, while formatting surrounding prose.
667
+ preserves each verse div and quotation's text, spacing, delimiters and
668
+ attributes while formatting surrounding prose. Quote and list
669
+ containers remain intact, table columns use inline verse's authored width, and
670
+ verse follows the same configured line endings as surrounding prose.
671
+
672
+ The stanza mark before a canto is retained; whitespace after its encoded
673
+ control adds no extra line mark. An encoded literal `\/` uses a separate
674
+ invisible mark and decodes to `\/`, keeping it distinct from a line ending.
668
675
 
669
676
  #### Directions and speakers
670
677
 
package/docs/commands.md CHANGED
@@ -2574,6 +2574,12 @@ overrides the rules and the ignore list; an option the file cannot mean is
2574
2574
  refused as `file:line:column: error:` and nothing is linted. See
2575
2575
  [Project setup](project-setup.md) for the accepted keys.
2576
2576
 
2577
+ Closed `.verse` div bodies and inline `.verse` quotation contents are excluded
2578
+ from Markdown rules and inline lint directives. Their authored bytes stay
2579
+ unchanged under `--fix`, including their line endings. The div's opening and
2580
+ closing lines and surrounding prose are still checked, with diagnostics at
2581
+ their original source locations. Verse markup validation remains active.
2582
+
2577
2583
  **OPTIONS**
2578
2584
 
2579
2585
  | Positional | Type | Default | Description |
@@ -136,9 +136,15 @@ encoding has its Roman forms checked against the lettered one only.
136
136
  English names under a language's `data.names.en` are not encoded and are not
137
137
  checked against any encoding.
138
138
 
139
- Every encoding inherits the shared standard's fifteen Roman punctuation
139
+ Every encoding inherits the shared standard's Roman punctuation
140
140
  mappings, verse and name controls, and literal boundaries. `‼` marks an
141
- imperative and `!` an exclamation. An explicit mapping can give a standard
141
+ imperative and `!` an exclamation. Configured language runs require sentence
142
+ text before each terminal (`.`, `?`, `‼`, `!`). Validation reports an opening
143
+ terminal at its original source position, including escaped marks. Whitespace,
144
+ indentation and verse boundaries preserve sentence state; opening quotations
145
+ start their own sentence, and closing quotations contribute their enclosed
146
+ text to the surrounding sentence. Names, literal codage and independent
147
+ language children count as text. An explicit mapping can give a standard
142
148
  source its own font glyph. Other punctuation needs its own mapping.
143
149
 
144
150
  ## Conversion API
@@ -195,8 +201,16 @@ of the opening, closing or escape control are quoted with U+E0115; decoding
195
201
  unquotes them before reading their codes. Unknown codes, malformed escapes,
196
202
  nested markers and unmatched markers are errors.
197
203
 
198
- `{verse: true}` handles authored stanza and canto structure. The Markdown
199
- renderer additionally writes a boundary after every content line, including
204
+ The shared `\/` row writes the nonprinting U+E010F literal-slash mark and
205
+ decodes it to `\/`; `/` writes the distinct U+E010C line mark. Verse projection
206
+ retains the escape until encoding, including inside inline quotations. A row
207
+ program without a literal-slash row reports a source finding instead of
208
+ substituting a line control. Ordinary mappings cannot emit U+E010F.
209
+
210
+ `{verse: true}` handles line, stanza and canto structure. The encoder receives
211
+ `***` for a canto and decodes U+E010E back to `***`. Whitespace before the canto
212
+ still writes its stanza mark; following source whitespace adds no line mark.
213
+ The Markdown renderer additionally writes a boundary after every content line, including
200
214
  the final line before a stanza or canto. Roman line layout is retained.
201
215
 
202
216
  The internal row conformance fixtures cover their captured source programs;
@@ -99,8 +99,11 @@ are supplied automatically. A language that lists uppercase letters accepts
99
99
  them; the build changes no authored case.
100
100
 
101
101
  The terminals are `.` for a statement, `?` for a question, `‼` for an imperative
102
- and `!` for an exclamation. None is required. A comma pauses within a sentence;
103
- use it instead of `;` or `:`, and use a period between complete sentences.
102
+ and `!` for an exclamation. None is required. A terminal needs preceding text
103
+ in its sentence; `! Vesun` is a finding at `!`, while `Vesun!` is valid.
104
+ Whitespace, indentation, verse boundaries and a preceding terminal do not
105
+ supply sentence text. A comma pauses within a sentence; use it instead of `;`
106
+ or `:`, and use a period between complete sentences.
104
107
  Quotation pairs alternate `«…»`, `‹…›`, then `«…»` at successive depths.
105
108
  English double quotes, `<<` and `>>` are findings. A closing quote is not a
106
109
  terminal for the enclosing sentence.
@@ -117,6 +120,9 @@ blank lines or `//` end stanzas, and `--- {.canto}` after a blank line ends a ca
117
120
  slash is written `\/`. Indentation at a logical line boundary is layout,
118
121
  not a punctuation character added to the encoded words.
119
122
 
123
+ An encoded literal slash has its own invisible mark, distinct from the verse
124
+ line mark, and decodes to `\/`. It never ends the logical line.
125
+
120
126
  A `.name` child in a language run reaches the encoding with standard `{…}`
121
127
  name boundaries. An `.emph` child contributes the same letters with emphasis
122
128
  presentation. Both inherit the surrounding native writing when they state no
@@ -495,8 +501,10 @@ The keys are checked where they sit:
495
501
 
496
502
  A `.verse` div holds inline Markdown and explicit verse structure. It is opaque
497
503
  to lists, headings, quotes and code-block syntax. Its source newlines join unless
498
- `/` ends a logical line. The authoring formatter preserves the complete div and
499
- its attributes byte for byte, and also preserves inline `.verse` quotations.
504
+ `/` ends a logical line. The authoring formatter preserves the div's text,
505
+ spacing and attributes, and also preserves inline `.verse` quotations. Quote
506
+ and list containers remain intact, table columns use the quotation's authored
507
+ width, and verse follows the same configured line endings as surrounding prose.
500
508
 
501
509
  ```markdown
502
510
  ::: {.verse lang=vyl font=semaron-kuriale enc=vylsemaronenc}
@@ -505,11 +513,19 @@ Lokazor lonak./
505
513
  :::
506
514
  ```
507
515
 
516
+ Both `:::verse` and `::: {.verse}` inherit the enclosing div's language when
517
+ no `lang=` is stated. A link can select its label's language with trailing
518
+ attributes: `[[lore-longtunnel|Zebra Quartz]]{lang=en}`. This overrides the verse
519
+ language for the label while preserving the destination, including in an encoded
520
+ original's script and Roman columns.
521
+
508
522
  Quoted verse is a span within prose, such as
509
523
  `[Lokazor lonak zaron./..Lokamen lonak zaron.//]{.verse lang=vyl}`.
510
524
  A concrete `enc=` sets a quotation in script only, continuously in the prose.
511
525
  Its `/` and `//` become the encoding's verse marks; only a div has paired script
512
- and Roman columns. An unencoded quotation keeps Roman line and stanza layout.
526
+ and Roman columns. Each stanza ending contributes its control, including
527
+ consecutive `//` marks and a final `//`. An unencoded quotation keeps Roman
528
+ line and stanza layout.
513
529
  Quotations take no `work=`, `form=`, `scan=`, labels or canto breaks.
514
530
  A div's canto separator can carry a heading and an anchor:
515
531
 
@@ -526,6 +542,9 @@ The title is shown once as heading text; the encoder receives its Roman `***`
526
542
  canto control. Authored `***` inside verse is a finding. The canto stays within
527
543
  the work and does not open a separate journal page.
528
544
 
545
+ The blank line before a canto retains its stanza mark in the encoded text.
546
+ Whitespace after the canto control adds no extra line mark.
547
+
529
548
  ### Versions of a work
530
549
 
531
550
  `form=epic` records the author's literary form without inferring layout or metre.
@@ -615,7 +634,7 @@ the second beats and, in brackets, with all of them:
615
634
  > ``this line has 4 beats (5 with secondary beats); the metre `hexameter` has 6``
616
635
 
617
636
  **The check is author-side.** Readers see nothing of it: the rendered div
618
- carries `data-metre` with the metre's name and nothing a reader sees. A div
637
+ carries `data-scan` with the metre's name and nothing a reader sees. A div
619
638
  without `scan=` is not counted. `scan=` is refused where it cannot mean
620
639
  anything, each an error:
621
640
 
@@ -1994,9 +1994,9 @@ not.
1994
1994
 
1995
1995
  ##### Name-marking findings
1996
1996
 
1997
- `package-build lint` reports two name-marking warnings in authored body prose.
1998
- They leave a valid package passing. Headings, frontmatter, tables, infoboxes,
1999
- code, comments and embeds are outside both.
1997
+ `package-build lint` reports one name-marking warning in authored body prose.
1998
+ It leaves a valid package passing. Headings, frontmatter, tables, infoboxes,
1999
+ code, comments and embeds are outside this check.
2000
2000
 
2001
2001
  1. A `.name` span without a `ref` whose whole text matches an indexed
2002
2002
  `name.full` or a form its `name` lists, ignoring case, should be a link or
@@ -2025,8 +2025,7 @@ valid inside emphasis:
2025
2025
  The name is `Joe Blow`; the surrounding phrase is emphasis.
2026
2026
 
2027
2027
  Each finding is one warning at its authored `file:line:column`, naming the
2028
- full name or explicit label. A warning can describe both findings at one
2029
- occurrence.
2028
+ full name or explicit label.
2030
2029
 
2031
2030
  An optional `data-type` on a `.name` span states what the named thing is:
2032
2031
 
@@ -114,12 +114,21 @@ const CONTAINER_PREFIX = /^(?:[ \t]{0,3}>[ \t]?|[ \t]{0,3}(?:[-*+]|\d{1,9}[.)])[
114
114
  */
115
115
  export function misplacedFootnoteDefinitions(source) {
116
116
  const lines = String(source ?? "").split("\n");
117
+ const verseLines = new Set(
118
+ verseRegions(String(source ?? "")).flatMap((region) =>
119
+ Array.from({ length: region.end - region.start + 1 }, (_, i) => region.start + i),
120
+ ),
121
+ );
117
122
  const errors = [];
118
123
  let fence = null;
119
124
  // Inside the continuation of a genuine top-level definition: a blank
120
125
  // line or a four-space indent belongs to it, not to a new mistake.
121
126
  let continuation = false;
122
127
  for (let i = 0; i < lines.length; i++) {
128
+ if (verseLines.has(i)) {
129
+ continuation = false;
130
+ continue;
131
+ }
123
132
  const line = lines[i];
124
133
  const marker = /^ {0,3}(`{3,}|~{3,})/.exec(line);
125
134
  if (fence) {
@@ -1202,10 +1202,26 @@ function analyseRuns(source, ctx, syntax) {
1202
1202
  .map((span) => ({
1203
1203
  bodyStart: span.start + 1,
1204
1204
  bodyEnd: span.contentEnd,
1205
- body: span.text,
1205
+ body: text.slice(span.start + 1, span.contentEnd),
1206
1206
  model: span.verseModel,
1207
1207
  })),
1208
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
+ }));
1209
1225
  for (const poem of verseContexts) {
1210
1226
  const bodyStart = poem.bodyStart;
1211
1227
  for (const [from, to] of poem.model.layout)
@@ -1281,6 +1297,9 @@ function analyseRuns(source, ctx, syntax) {
1281
1297
  .filter((reference) => reference.from >= from && reference.from < to)
1282
1298
  .map((reference) => [reference.from, reference.to]),
1283
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]),
1284
1303
  ...verseContexts
1285
1304
  .filter(
1286
1305
  (region) =>
@@ -1525,8 +1544,15 @@ function analyseRuns(source, ctx, syntax) {
1525
1544
 
1526
1545
  const first = construct.line0 + 1;
1527
1546
  const last = construct.last;
1528
- const from = starts[first] ?? text.length;
1529
- 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;
1530
1556
  detail.body = [from, to];
1531
1557
  detail.holes = holesIn(from, to);
1532
1558
 
@@ -1561,7 +1587,7 @@ function analyseRuns(source, ctx, syntax) {
1561
1587
  said(
1562
1588
  top,
1563
1589
  token.info.trim().split(/\s/)[0] === "verse" ?
1564
- "a verse div"
1590
+ "a code fence named `verse`; write a `::: {.verse}` div"
1565
1591
  : "a code fence",
1566
1592
  );
1567
1593
  else if (Object.hasOwn(BLOCK_WHAT, token.type)) said(top, BLOCK_WHAT[token.type]);
@@ -1629,7 +1655,12 @@ function analyseRuns(source, ctx, syntax) {
1629
1655
  ...noteReferences
1630
1656
  .filter((reference) => reference.from >= bounds[0] && reference.from < bounds[1])
1631
1657
  .map((reference) => [reference.from, reference.to]),
1632
- ...[...verseMarkers].filter(([start]) => start >= bounds[0] && start < bounds[1]),
1658
+ ...[...verseMarkers].filter(
1659
+ ([start]) =>
1660
+ start >= bounds[0] &&
1661
+ start < bounds[1] &&
1662
+ !referenceSpans.some((span) => start === span.start),
1663
+ ),
1633
1664
  ...verseContexts
1634
1665
  .filter(
1635
1666
  (region) =>
@@ -1648,7 +1679,7 @@ function analyseRuns(source, ctx, syntax) {
1648
1679
  const projectionOptions = {
1649
1680
  from: bounds[0],
1650
1681
  to: bounds[1],
1651
- spans,
1682
+ spans: [...spans, ...referenceSpans],
1652
1683
  exclude: excluded,
1653
1684
  includeSpan: (span) =>
1654
1685
  spanDetails.has(span.start) && ownerOf(spanDetails.get(span.start)) === owner,
@@ -1744,12 +1775,25 @@ function analyseRuns(source, ctx, syntax) {
1744
1775
  }
1745
1776
  if (verse) {
1746
1777
  for (const boundary of projection.boundaries) {
1747
- const target =
1748
- boundary.kind === "line" ?
1749
- encodeText(owner.selection.encoding, "/", { font: owner.selection.font })
1750
- : owner.selection.encoding.controls[
1751
- boundary.kind === "stanza" ? "verseStanza" : "verseCanto"
1752
- ];
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;
1753
1797
  if (target === undefined) continue;
1754
1798
  pieces.push({
1755
1799
  from: "",
@@ -1798,6 +1842,17 @@ function analyseRuns(source, ctx, syntax) {
1798
1842
  });
1799
1843
  }
1800
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
+
1801
1856
  const firstEncodingRun =
1802
1857
  active.find(native) ??
1803
1858
  rawReferences.find((reference) => native(reference.construct))?.construct;
@@ -2142,7 +2197,7 @@ export function renderRuns(source, ctx = {}, syntax, options = {}) {
2142
2197
  ...span,
2143
2198
  inline: true,
2144
2199
  model: span.verseModel,
2145
- body: span.text,
2200
+ body: text.slice(span.start + 1, span.contentEnd),
2146
2201
  bodyStart: span.start + 1,
2147
2202
  bodyEnd: span.contentEnd,
2148
2203
  })),
@@ -2302,11 +2357,19 @@ export function renderRuns(source, ctx = {}, syntax, options = {}) {
2302
2357
  if (attributes !== undefined) {
2303
2358
  const open = lines[run.start].indexOf("{");
2304
2359
  const close = lines[run.start].lastIndexOf("}");
2305
- edits.push({
2306
- from: starts[run.start] + open + 1,
2307
- to: starts[run.start] + close,
2308
- text: attributes,
2309
- });
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
+ });
2310
2373
  }
2311
2374
  if (detail.plain && detail.body)
2312
2375
  for (const [from, to] of outsideHoles(detail.body[0], detail.body[1], detail.holes)) {
@@ -2395,7 +2458,7 @@ export function renderRuns(source, ctx = {}, syntax, options = {}) {
2395
2458
  .map((span) => ({
2396
2459
  ...span,
2397
2460
  inline: true,
2398
- body: span.text,
2461
+ body: markdown.slice(span.start + 1, span.contentEnd),
2399
2462
  bodyStart: span.start + 1,
2400
2463
  bodyEnd: span.contentEnd,
2401
2464
  })),
@@ -2469,7 +2532,10 @@ export function renderRuns(source, ctx = {}, syntax, options = {}) {
2469
2532
  }
2470
2533
  for (const group of model.groups) {
2471
2534
  if (group.cantoBreak) {
2472
- 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 ?? "";
2473
2539
  continue;
2474
2540
  }
2475
2541
  for (const line of group.lines) {
@@ -2499,10 +2565,25 @@ export function renderRuns(source, ctx = {}, syntax, options = {}) {
2499
2565
  classes,
2500
2566
  values: Object.entries(values).filter(([key]) => key !== "lang"),
2501
2567
  });
2502
- const native = model.groups
2503
- .flatMap((group) =>
2504
- group.cantoBreak ? [group.text ?? ""] : group.lines.map((line) => line.text),
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
+ )
2505
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)
2506
2587
  .join("");
2507
2588
  pairedEdits.push({
2508
2589
  from: block.start,
@@ -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);
@@ -67,7 +67,6 @@ export function spanMarkdownPlugin(md, { spoilers = false, source = false, verse
67
67
  state.src[state.src.indexOf("]", start + 2) + 1] === "]") ||
68
68
  /@[A-Za-z]+$/.test(state.src.slice(0, start)) ||
69
69
  state.src[start + 1] === "^" ||
70
- state.src[start + 1] === "!" ||
71
70
  ["!", "]"].includes(state.src[start - 1]) ||
72
71
  (state.src[start - 1] === "[" && state.linkLevel === 0)
73
72
  )
@@ -75,6 +74,7 @@ export function spanMarkdownPlugin(md, { spoilers = false, source = false, verse
75
74
  const end = state.md.helpers.parseLinkLabel(state, start, false);
76
75
  if (
77
76
  end < 0 ||
77
+ (state.src[start + 1] === "!" && state.src[end + 1] !== "{") ||
78
78
  ["(", "["].includes(state.src[end + 1]) ||
79
79
  (state.src[start + 1] === "[" && state.src[end + 1] !== "{")
80
80
  )
@@ -215,6 +215,7 @@ export function scanSpans(source) {
215
215
  tokens = parser.parse(text, env),
216
216
  spans = [],
217
217
  errors = [];
218
+ const sourcePositions = new Map();
218
219
  const lines = text.split("\n"),
219
220
  starts = [];
220
221
  let offset = 0;
@@ -244,6 +245,14 @@ export function scanSpans(source) {
244
245
  if (children[close].type === "span_open") depth++;
245
246
  if (children[close].type === "span_close" && --depth === 0) break;
246
247
  }
248
+ if ((child.attrGet("class") ?? "").split(/\s+/).includes("verse"))
249
+ sourcePositions.set(
250
+ start,
251
+ Array.from(
252
+ { length: child.meta.text.length + 1 },
253
+ (_, index) => position(child.meta.start + 1 + index) - start - 1,
254
+ ),
255
+ );
247
256
  spans.push({
248
257
  id: child.attrGet("id") ?? "",
249
258
  line,
@@ -360,10 +369,34 @@ export function scanSpans(source) {
360
369
  ([key, value]) => key === "class" && value.split(/\s+/).includes("verse"),
361
370
  )
362
371
  ) {
363
- span.verseModel = parseVerse(span.text, {
372
+ const model = parseVerse(span.text, {
364
373
  inline: true,
365
374
  footnotes: footnoteLabels(text),
366
375
  });
376
+ // The parser removes container prefixes and normalizes CRLF. Map
377
+ // every logical range back through its original source positions.
378
+ const positions = sourcePositions.get(span.start);
379
+ const at = (offset) => positions[offset];
380
+ const ranges = new Set([
381
+ ...model.groups,
382
+ ...model.cantos.flatMap((canto) => canto.stanzas),
383
+ ...model.groups.flatMap((group) => (group.cantoBreak ? [] : group.lines)),
384
+ ...model.notes,
385
+ ]);
386
+ for (const range of ranges) {
387
+ range.from = at(range.from);
388
+ range.to = at(range.to);
389
+ if ("offsets" in range) range.offsets = range.offsets.map(at);
390
+ }
391
+ for (const boundary of model.boundaries)
392
+ boundary.sourceOffset = at(boundary.sourceOffset);
393
+ for (const finding of model.errors) finding.offset = at(finding.offset);
394
+ model.layout = model.layout.map(([from, to]) => [at(from), at(to)]);
395
+ for (let index = 1; index < positions.length; index++)
396
+ if (positions[index] > positions[index - 1] + 1)
397
+ model.layout.push([positions[index - 1] + 1, positions[index]]);
398
+ model.layout.sort((a, b) => a[0] - b[0]);
399
+ span.verseModel = model;
367
400
  errors.push(...inlineVerseFindings(span, text));
368
401
  }
369
402
  spans.sort((a, b) => a.start - b.start);