@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.
@@ -124,16 +124,19 @@ export function scanVerse(source, { checkWorks = true } = {}) {
124
124
  blocks = [],
125
125
  errors = [];
126
126
  for (const token of blockTokens(text)) {
127
- if (token.type === "fence" && /^verse(?:\s|$)/.test(token.info.trim()))
127
+ if (token.type === "fence" && /^verse(?:\s|$)/.test(token.info.trim())) {
128
+ const opening = lines[token.map[0]];
129
+ const marker = opening?.indexOf(token.markup);
128
130
  errors.push({
129
131
  line: token.map[0] + 1,
130
- column: 1,
132
+ ...(marker !== undefined && marker >= 0 ? { column: marker + 1 } : {}),
131
133
  message: "verse uses a .verse div: write ::: {.verse …} and a closing :::",
132
134
  });
135
+ }
133
136
  }
134
137
  const definedFootnotes = footnoteLabels(text);
135
138
  for (const region of verseRegions(text)) {
136
- const at = { line: region.start + 1, column: region.indent.length + 1 };
139
+ const at = { line: region.start + 1, column: region.contentStarts[0] - region.from + 1 };
137
140
  for (const message of [...region.problems, ...refusedAttributes(region.attributes)])
138
141
  errors.push({ ...at, message });
139
142
  if (!region.closed) errors.push({ ...at, message: "verse div needs a closing ::: line" });
@@ -164,7 +167,7 @@ export function scanVerse(source, { checkWorks = true } = {}) {
164
167
  message: "tabs are not allowed in verse",
165
168
  });
166
169
  }
167
- blocks.push({ ...region, model });
170
+ blocks.push({ ...region, column: at.column, model });
168
171
  }
169
172
  if (checkWorks) {
170
173
  const openings = [...pageOpenings(source).keys()];
@@ -176,7 +179,7 @@ export function scanVerse(source, { checkWorks = true } = {}) {
176
179
  }
177
180
  for (const page of pages.values()) errors.push(...workShapeFindings(page));
178
181
  }
179
- errors.sort((a, b) => a.line - b.line || a.column - b.column);
182
+ errors.sort((a, b) => a.line - b.line || (a.column ?? 0) - (b.column ?? 0));
180
183
  return { blocks, errors };
181
184
  }
182
185
 
@@ -237,6 +240,7 @@ function keyColumn(opening, key) {
237
240
  * An authored verse div with its parsed logical structure.
238
241
  * @typedef {object} VerseBlock
239
242
  * @property {number} start - Opening div's zero-based source line.
243
+ * @property {number} [column] - Opening delimiter's one-based source column, when known.
240
244
  * @property {number} end - Closing div's zero-based source line.
241
245
  * @property {number} from - Complete div first offset.
242
246
  * @property {number} to - Offset past complete div.
@@ -253,7 +257,7 @@ function keyColumn(opening, key) {
253
257
  * @property {VerseModel} model - Logical structure relative to the body.
254
258
  */
255
259
 
256
- /** @typedef {{line: number, column: number, message: string}} VerseDiagnostic */
260
+ /** @typedef {{line: number, column?: number, message: string}} VerseDiagnostic */
257
261
 
258
262
  /**
259
263
  * An encoded verse's shared display model and native-cell presentation.
@@ -594,15 +598,20 @@ export function parseVerse(body, { inline = false, footnotes = new Set() } = {})
594
598
 
595
599
  /**
596
600
  * Validate a quoted verse span using its original source coordinates.
597
- * @param {{start: number, text: string, attrs: Array<[string,string]>}} span - Authored span.
601
+ * @param {{start: number, text: string, attrs: Array<[string,string]>, verseModel?: VerseModel}} span - Authored span.
598
602
  * @param {string} source - Complete Markdown source.
599
603
  * @returns {VerseDiagnostic[]} Located inline-only findings.
600
604
  */
601
605
  export function inlineVerseFindings(span, source) {
602
- const findings = parseVerse(span.text, {
603
- inline: true,
604
- footnotes: footnoteLabels(source),
605
- }).errors;
606
+ const findings = [
607
+ ...(
608
+ span.verseModel ??
609
+ parseVerse(span.text, {
610
+ inline: true,
611
+ footnotes: footnoteLabels(source),
612
+ })
613
+ ).errors,
614
+ ];
606
615
  for (const [key] of span.attrs)
607
616
  if (["form", "work", "scan"].includes(key))
608
617
  findings.push({ offset: 0, message: `${key}= is not an inline .verse attribute` });
@@ -641,6 +650,10 @@ export const stanzaSeparator = (group) => group.length === 1 && STANZA_BREAK.tes
641
650
  * @returns {VerseDiagnostic[]} Findings at affected opening divs.
642
651
  */
643
652
  export function workShapeFindings(blocks) {
653
+ const location = (block) => ({
654
+ line: block.start + 1,
655
+ ...(block.column === undefined ? {} : { column: block.column }),
656
+ });
644
657
  const works = new Map(),
645
658
  errors = [];
646
659
  for (const block of blocks) {
@@ -652,8 +665,7 @@ export function workShapeFindings(blocks) {
652
665
  for (const [key, versions] of works) {
653
666
  if (versions.length === 1) {
654
667
  errors.push({
655
- line: versions[0].start + 1,
656
- column: 1,
668
+ ...location(versions[0]),
657
669
  message: `work=${key} has only one version`,
658
670
  });
659
671
  continue;
@@ -669,7 +681,7 @@ export function workShapeFindings(blocks) {
669
681
  for (const block of [first, version]) {
670
682
  const identity = `${block.start}:${message}`;
671
683
  if (!seen.has(identity)) {
672
- errors.push({ line: block.start + 1, column: 1, message });
684
+ errors.push({ ...location(block), message });
673
685
  seen.add(identity);
674
686
  }
675
687
  }
@@ -680,7 +692,7 @@ export function workShapeFindings(blocks) {
680
692
  for (const block of [first, version]) {
681
693
  const identity = `${block.start}:${message}`;
682
694
  if (!seen.has(identity)) {
683
- errors.push({ line: block.start + 1, column: 1, message });
695
+ errors.push({ ...location(block), message });
684
696
  seen.add(identity);
685
697
  }
686
698
  }
@@ -27,6 +27,7 @@ export const STANDARD_ROWS = Object.freeze(
27
27
  ["—", "\u{E010A}"],
28
28
  ["…", "\u{E010B}"],
29
29
  ["/", "\u{E010C}"],
30
+ ["\\/", "\u{E010F}"],
30
31
  ["{", "\u{E0110}"],
31
32
  ["}", "\u{E0111}"],
32
33
  ].map(Object.freeze),
@@ -207,7 +207,7 @@ export function templateProgram(data, options, fault) {
207
207
  fault(key, "Grave accents are forbidden");
208
208
  };
209
209
  const validateOutput = (from, to, key) => {
210
- if (/[\u{E010D}\u{E010E}\u{E0112}\u{E0113}\u{E0115}]/u.test(to))
210
+ if (/[\u{E010D}-\u{E010F}\u{E0112}\u{E0113}\u{E0115}]/u.test(to))
211
211
  fault(key, "Structural controls cannot be mapping outputs");
212
212
  if (from.normalize("NFD").includes("\u0301") && stress) {
213
213
  const plain = from.normalize("NFD").replaceAll("\u0301", "").normalize("NFC");
@@ -343,7 +343,10 @@ export function templateProgram(data, options, fault) {
343
343
  definition: {
344
344
  name: "encoding-note",
345
345
  fonts: [data.font],
346
- accepts: [...accepts, ...STANDARD_ROWS.map((r) => (Array.isArray(r) ? r[0] : r.from))],
346
+ accepts: [
347
+ ...accepts,
348
+ ...STANDARD_ROWS.flatMap((r) => [...(Array.isArray(r) ? r[0] : r.from)]),
349
+ ],
347
350
  separator: " ",
348
351
  roundTrip: canonical ? "canonical" : "exact",
349
352
  vowels,
@@ -256,7 +256,7 @@ export function compileRows(definition, metadata = {}) {
256
256
  const prefix = d.separator ? [separatorRow] : [];
257
257
  const controlRows = [];
258
258
  if (controls.verseStanza) controlRows.push({ from: "\n\n", to: controls.verseStanza });
259
- if (controls.verseCanto) controlRows.push({ from: "--- {.canto}", to: controls.verseCanto });
259
+ if (controls.verseCanto) controlRows.push({ from: "***", to: controls.verseCanto });
260
260
  const shared = tier(sharedRows, [], [...prefix, ...controlRows]);
261
261
  const faces = new Map();
262
262
  if (d.faces !== undefined && !object(d.faces))
@@ -270,7 +270,7 @@ export function compileRows(definition, metadata = {}) {
270
270
  tier(
271
271
  rows(face.rows ?? [], `faces.${font}.rows`, vowels, d.roundTrip === "canonical"),
272
272
  sharedRows,
273
- prefix,
273
+ [...prefix, ...controlRows],
274
274
  ),
275
275
  );
276
276
  }
@@ -330,10 +330,10 @@ function beforeHolds(condition, char, vowels) {
330
330
  function holds(row, text, start, end, vowels) {
331
331
  if (row.before && !beforeHolds(row.before, firstAt(text, end), vowels)) return false;
332
332
  if (row.at === "line-start") {
333
- const prefix = text.slice(
334
- Math.max(text.lastIndexOf("\n", start - 1), text.lastIndexOf("/", start - 1)) + 1,
335
- start,
336
- );
333
+ let slash = text.lastIndexOf("/", start - 1);
334
+ while (slash >= 0 && (/\\+$/.exec(text.slice(0, slash))?.[0].length ?? 0) % 2)
335
+ slash = text.lastIndexOf("/", slash - 1);
336
+ const prefix = text.slice(Math.max(text.lastIndexOf("\n", start - 1), slash) + 1, start);
337
337
  if (prefix.replaceAll("^", "")) return false;
338
338
  }
339
339
  if (row.at === "start" && start > 0 && !boundary(previousAt(text, start))) return false;
@@ -514,8 +514,7 @@ function encodeRawPieces(enc, text, { font, verse = false, start = 0, end = text
514
514
  out.push(piece(text.slice(at, end), enc.controls.verseCanto, at, end, null));
515
515
  at = end;
516
516
  if (verse) {
517
- if (text.startsWith("\r\n", at)) at += 2;
518
- else if (/[\r\n]/.test(text[at] ?? "")) at++;
517
+ while (at < bound && /[ \t\r\n]/.test(text[at])) at++;
519
518
  }
520
519
  continue;
521
520
  }
@@ -523,10 +522,6 @@ function encodeRawPieces(enc, text, { font, verse = false, start = 0, end = text
523
522
  let end = at;
524
523
  while (end < bound && /[ \t\r\n]/.test(text[end])) end++;
525
524
  const run = text.slice(at, end).replaceAll("\r\n", "\n").replaceAll("\r", "\n");
526
- if (text.startsWith("--- {.canto}", end)) {
527
- at = end;
528
- continue;
529
- }
530
525
  const row =
531
526
  run.split("\n").length === 2 ?
532
527
  rowAt(enc, groups, "/", 0)
@@ -614,12 +609,22 @@ function sourceProjection(enc, text, options) {
614
609
  return { normalized, sourceAt, begin, bound };
615
610
  }
616
611
  function cantoEnd(enc, text, at, bound) {
617
- const source = "--- {.canto}";
612
+ const source = "***";
618
613
  return (
619
614
  enc.controls.verseCanto &&
620
615
  at + source.length <= bound &&
621
616
  text.startsWith(source, at) &&
622
- !text.slice(text.lastIndexOf("\n", at - 1) + 1, at).trim()
617
+ !text
618
+ .slice(
619
+ Math.max(text.lastIndexOf("\n", at - 1), text.lastIndexOf("\r", at - 1)) +
620
+ 1,
621
+ at,
622
+ )
623
+ .trim() &&
624
+ !text
625
+ .slice(at + source.length, bound)
626
+ .split(/[\r\n]/, 1)[0]
627
+ .trim()
623
628
  ) ?
624
629
  at + source.length
625
630
  : undefined;
@@ -51,6 +51,7 @@ import {
51
51
  printParseErrorCode,
52
52
  } from "jsonc-parser";
53
53
  import markdownIt from "markdown-it";
54
+ import footnotePlugin from "markdown-it-footnote";
54
55
  import { minimatch } from "minimatch";
55
56
  import { LineCounter, parse as parseYaml, parseDocument } from "yaml";
56
57
 
@@ -62,6 +63,13 @@ import {
62
63
  resolveModule,
63
64
  } from "../vendor/markdownlint.mjs";
64
65
  import { walkFiles } from "./walk-files.mjs";
66
+ import { verseRegions } from "./verse-regions.mjs";
67
+ import { scanSpans, spanMarkdownPlugin } from "./content-spans.mjs";
68
+ import { parseVerse } from "./content-verse.mjs";
69
+ import { footnoteLabels, footnoteReferences } from "./content-footnotes.mjs";
70
+
71
+ /** References inside verse obey inline Markdown, without block interpretation. */
72
+ const referenceParser = markdownIt({ html: true }).use(footnotePlugin).use(spanMarkdownPlugin);
65
73
 
66
74
  /** Option-file names, in the order the first existing one is taken. */
67
75
  const OPTIONS_FILES = Object.freeze([
@@ -443,6 +451,9 @@ function gitignoreHooks(root) {
443
451
  };
444
452
  }
445
453
 
454
+ /** Sources per markdownlint call: it lints `strings` by recursing once per entry, so a large set overflows the stack. */
455
+ const LINT_BATCH_SIZE = 200;
456
+
446
457
  /**
447
458
  * Lint markdown under a root with the `markdownlint` library.
448
459
  *
@@ -542,12 +553,42 @@ export async function runMarkdownlint(params) {
542
553
  for (const [dir, files] of groups) {
543
554
  const options = await lintOptions(dir, files);
544
555
  const malformed = new Set();
556
+ const sources = new Map();
545
557
  for (const file of files) {
546
- const findings = markupFindings(fs.readFileSync(file, "utf8"), file);
558
+ const source = fs.readFileSync(file, "utf8");
559
+ sources.set(file, protectLintVerse(source));
560
+ const findings = markupFindings(source, file);
547
561
  if (findings.length) malformed.add(file);
548
562
  markup.push(...findings);
549
563
  }
550
- let results = await lint(options);
564
+ const lintSources = async (selected) => {
565
+ const { files: _files, ...shared } = options;
566
+ const results = {};
567
+ for (let start = 0; start < selected.length; start += LINT_BATCH_SIZE) {
568
+ const batch = selected.slice(start, start + LINT_BATCH_SIZE);
569
+ Object.assign(
570
+ results,
571
+ await lint({
572
+ ...shared,
573
+ strings: Object.fromEntries(
574
+ batch.map((file) => [file, sources.get(file).text]),
575
+ ),
576
+ }),
577
+ );
578
+ }
579
+ for (const file of selected) {
580
+ const protectedSource = sources.get(file);
581
+ results[file] = (results[file] ?? [])
582
+ .filter((error) => !protectedSource.containsFinding(error))
583
+ .map((error) => {
584
+ if (!error.fixInfo || !protectedSource.containsFix(error)) return error;
585
+ const { fixInfo: _fixInfo, ...unfixable } = error;
586
+ return unfixable;
587
+ });
588
+ }
589
+ return results;
590
+ };
591
+ let results = await lintSources(files);
551
592
  if (fix) {
552
593
  const toFix = [];
553
594
  for (const file of files) {
@@ -555,11 +596,15 @@ export async function runMarkdownlint(params) {
555
596
  const fixable = (results[file] ?? []).filter((error) => error.fixInfo);
556
597
  if (!fixable.length) continue;
557
598
  const original = fs.readFileSync(file, "utf8");
558
- fs.writeFileSync(file, applyFixes(original, fixable), "utf8");
599
+ const fixed =
600
+ sources.get(file).protected ?
601
+ applyVerseSafeFixes(original, fixable)
602
+ : applyFixes(original, fixable);
603
+ fs.writeFileSync(file, fixed, "utf8");
604
+ sources.set(file, protectLintVerse(fixed));
559
605
  toFix.push(file);
560
606
  }
561
- if (toFix.length)
562
- results = { ...results, ...(await lint({ ...options, files: toFix })) };
607
+ if (toFix.length) results = { ...results, ...(await lintSources(toFix)) };
563
608
  }
564
609
  for (const [file, errors] of Object.entries(results)) resultsByFile.set(file, errors);
565
610
  }
@@ -581,3 +626,155 @@ export async function runMarkdownlint(params) {
581
626
  );
582
627
  return { results: flat, markup };
583
628
  }
629
+
630
+ /**
631
+ * Hide verse content from Markdown rules and inline configuration without moving
632
+ * any authored coordinate. Container prefixes and div delimiters remain visible.
633
+ * @param {string} source - Original file contents.
634
+ * @returns {object} Masked text and predicates guarding findings and fixes.
635
+ */
636
+ function protectLintVerse(source) {
637
+ const syntax = source.replace(/^\uFEFF?---\r?\n[^]*?\r?\n---(?:\r?\n|$)/, (frontmatter) =>
638
+ frontmatter.replace(/[^\r\n]/g, " "),
639
+ );
640
+ const starts = [0];
641
+ for (const match of source.matchAll(/\r\n|\r|\n/g)) starts.push(match.index + match[0].length);
642
+ const endOfLine = (line) => {
643
+ const past = starts[line + 1] ?? source.length;
644
+ return past - (/\r\n$|[\r\n]$/.exec(source.slice(starts[line], past))?.[0].length ?? 0);
645
+ };
646
+ const ranges = [],
647
+ masks = [],
648
+ inlineMasks = [],
649
+ spanBrackets = [],
650
+ bodyLines = new Set(),
651
+ verseLines = [],
652
+ usedReferences = new Set(),
653
+ footnotes = footnoteLabels(syntax);
654
+ const referencesInVerse = (text, model) => {
655
+ const titles = model.groups
656
+ .filter((group) => group.cantoBreak && group.title)
657
+ .map((group) => group.title);
658
+ for (const fragment of [text, ...titles])
659
+ for (const reference of footnoteReferences(fragment, {
660
+ definitions: footnotes,
661
+ inline: true,
662
+ }))
663
+ usedReferences.add(referenceParser.utils.normalizeReference(`^${reference.key}`));
664
+ for (const group of model.groups) {
665
+ if (group.cantoBreak) {
666
+ if (group.title) verseLines.push(group.title);
667
+ } else for (const line of group.lines) verseLines.push(line.text);
668
+ }
669
+ };
670
+ for (const region of verseRegions(syntax)) {
671
+ if (!region.closed) continue;
672
+ referencesInVerse(region.body, parseVerse(region.body, { footnotes }));
673
+ ranges.push([region.bodyStart, starts[region.end]]);
674
+ for (let line = region.start + 1; line < region.end; line++) {
675
+ bodyLines.add(line + 1);
676
+ masks.push([
677
+ Math.min(region.contentStarts[line - region.start], endOfLine(line)),
678
+ endOfLine(line),
679
+ ]);
680
+ }
681
+ }
682
+ for (const span of scanSpans(syntax).spans) {
683
+ if (!span.verseModel || source[span.end - 1] !== "}") continue;
684
+ referencesInVerse(span.text, span.verseModel);
685
+ ranges.push([span.start + 1, span.contentEnd]);
686
+ spanBrackets.push(span.start, span.contentEnd);
687
+ // Inline tokens omit quote/list prefixes. Continuation content ends
688
+ // at its physical line ending (or the located closing bracket), so
689
+ // its parsed length gives the content start without parsing prefixes.
690
+ const contentLines = span.text.replace(/\r\n?/g, "\n").split("\n");
691
+ for (const [index, content] of contentLines.entries()) {
692
+ const line = span.line - 1 + index;
693
+ const to = Math.min(endOfLine(line), span.contentEnd);
694
+ inlineMasks.push([index === 0 ? span.start + 1 : to - content.length, to]);
695
+ }
696
+ }
697
+ const raw = source.split("");
698
+ for (const [from, to] of masks)
699
+ for (let at = from; at < to; at++) if (!/[\r\n]/.test(raw[at])) raw[at] = " ";
700
+ // A blanked physical span line would split its surrounding paragraph.
701
+ // Neutralize its bracket delimiters too, so the mask cannot manufacture
702
+ // a reference shortcut to an unrelated definition with the same label.
703
+ for (const [from, to] of inlineMasks)
704
+ for (let at = from; at < to; at++) if (/\S/.test(raw[at])) raw[at] = "x";
705
+ for (const at of spanBrackets) raw[at] = "x";
706
+ const masked = raw.join("");
707
+ if (ranges.length) {
708
+ const env = {};
709
+ referenceParser.parse(masked, env);
710
+ // Reference links and images carry their normalized definition label
711
+ // on the actual emitted token. Speculative parser lookups do not count.
712
+ const collect = (tokens) => {
713
+ for (const token of tokens) {
714
+ const label = token.meta?.label;
715
+ if (["link_open", "image"].includes(token.type) && label) usedReferences.add(label);
716
+ if (token.children) collect(token.children);
717
+ }
718
+ };
719
+ for (const line of verseLines) {
720
+ const tokens = [];
721
+ referenceParser.inline.parse(line, referenceParser, env, tokens);
722
+ collect(tokens);
723
+ }
724
+ }
725
+ const intersects = (from, to) => ranges.some(([start, end]) => from < end && to > start);
726
+ return {
727
+ text: masked,
728
+ protected: ranges.length > 0,
729
+ containsFinding(error) {
730
+ if (error.ruleNames.includes("MD053")) {
731
+ const unused = /^Unused link or image reference definition: "([^]*)"$/.exec(
732
+ error.errorDetail ?? "",
733
+ );
734
+ if (
735
+ unused &&
736
+ usedReferences.has(referenceParser.utils.normalizeReference(unused[1]))
737
+ )
738
+ return true;
739
+ }
740
+ if (bodyLines.has(error.lineNumber)) return true;
741
+ if (!error.errorRange) return false;
742
+ const from = starts[error.lineNumber - 1] + error.errorRange[0] - 1;
743
+ return intersects(from, from + error.errorRange[1]);
744
+ },
745
+ containsFix(error) {
746
+ const fix = error.fixInfo;
747
+ const line = (fix.lineNumber || error.lineNumber) - 1;
748
+ const from = starts[line] + (fix.editColumn || 1) - 1;
749
+ if (fix.deleteCount === -1)
750
+ return intersects(starts[line], starts[line + 1] ?? source.length);
751
+ if (intersects(from, from + (fix.deleteCount ?? 0))) return true;
752
+ return Boolean(
753
+ fix.insertText && ranges.some(([start, end]) => from >= start && from <= end),
754
+ );
755
+ },
756
+ };
757
+ }
758
+
759
+ /**
760
+ * Apply fixes per original line so fixing prose cannot normalize the untouched
761
+ * line endings of a verse region elsewhere in a mixed-ending file.
762
+ * @param {string} source - Original file contents.
763
+ * @param {object[]} errors - Findings whose fixes do not intersect verse.
764
+ * @returns {string} Source with only the permitted fixes applied.
765
+ */
766
+ function applyVerseSafeFixes(source, errors) {
767
+ const byLine = new Map();
768
+ for (const error of errors) {
769
+ const line = error.fixInfo.lineNumber || error.lineNumber;
770
+ if (!byLine.has(line)) byLine.set(line, []);
771
+ byLine
772
+ .get(line)
773
+ .push({ ...error, lineNumber: 1, fixInfo: { ...error.fixInfo, lineNumber: 1 } });
774
+ }
775
+ let line = 0;
776
+ return source.replace(/[^\r\n]*(?:\r\n|\r|\n|$)/g, (original) => {
777
+ const fixes = byLine.get(++line);
778
+ return fixes ? applyFixes(original, fixes) : original;
779
+ });
780
+ }
@@ -121,15 +121,25 @@ export function romanTextProblems(text, inventory) {
121
121
  );
122
122
  const quotes = [];
123
123
  let sentenceStart = true;
124
+ const sentenceText = () => {
125
+ sentenceStart = false;
126
+ if (quotes.length) quotes.at(-1).hasText = true;
127
+ };
124
128
  const report = (offset, message) => out.push({ offset, message });
125
129
  for (let offset = 0; offset < text.length;) {
126
130
  const char = String.fromCodePoint(text.codePointAt(offset));
131
+ // Verse retains this escape so the encoder can distinguish a literal
132
+ // slash from its line control. The slash itself is standard Roman.
133
+ if (text.startsWith("\\/", offset)) {
134
+ offset++;
135
+ continue;
136
+ }
127
137
  if (char === "⟦") {
128
138
  // The encoding validates codage and its boundary grammar. Roman
129
139
  // punctuation and language letters never reinterpret its contents.
130
140
  const close = text.indexOf("⟧", offset + 1);
131
141
  offset = close < 0 ? text.length : close + 1;
132
- sentenceStart = false;
142
+ sentenceText();
133
143
  continue;
134
144
  }
135
145
  const cluster = decomposed.get(offset);
@@ -139,16 +149,25 @@ export function romanTextProblems(text, inventory) {
139
149
  `\`${cluster}\` is not NFC spelling in \`${inventory.language}\`; write \`${cluster.normalize("NFC")}\``,
140
150
  );
141
151
  offset += cluster.length;
142
- sentenceStart = false;
152
+ sentenceText();
153
+ continue;
154
+ }
155
+ // An independent language child projects to an opaque empty name.
156
+ if (text.startsWith("{}", offset)) {
157
+ offset += 2;
158
+ sentenceText();
143
159
  continue;
144
160
  }
145
- if (isRomanWhitespace(char)) {
161
+ // Layout, pauses and name boundaries supply no sentence text.
162
+ if (isRomanWhitespace(char) || "/,{}".includes(char)) {
146
163
  offset += char.length;
147
164
  continue;
148
165
  }
149
166
  if (char === "^") {
150
- const start =
151
- Math.max(text.lastIndexOf("\n", offset - 1), text.lastIndexOf("/", offset - 1)) + 1;
167
+ let slash = text.lastIndexOf("/", offset - 1);
168
+ while (slash >= 0 && (/\\+$/.exec(text.slice(0, slash))?.[0].length ?? 0) % 2)
169
+ slash = text.lastIndexOf("/", slash - 1);
170
+ const start = Math.max(text.lastIndexOf("\n", offset - 1), slash) + 1;
152
171
  if (/^\^*$/.test(text.slice(start, offset))) {
153
172
  // Indentation is layout and leaves sentence-start ellipsis
154
173
  // semantics intact. The reference encoder uses these bounds.
@@ -199,7 +218,7 @@ export function romanTextProblems(text, inventory) {
199
218
  const expected = quotes.length % 2 === 0 ? "«" : "‹";
200
219
  if (char !== expected)
201
220
  report(offset, `a quotation at this depth opens with \`${expected}\``);
202
- quotes.push({ char, offset });
221
+ quotes.push({ char, offset, sentenceStart, hasText: false });
203
222
  sentenceStart = true;
204
223
  } else if (char === "»" || char === "›") {
205
224
  const opening = quotes.at(-1);
@@ -209,7 +228,8 @@ export function romanTextProblems(text, inventory) {
209
228
  if (char !== expected)
210
229
  report(offset, `this quotation closes with \`${expected}\`, not \`${char}\``);
211
230
  quotes.pop();
212
- sentenceStart = false;
231
+ sentenceStart = opening.sentenceStart && !opening.hasText;
232
+ if (opening.hasText) sentenceText();
213
233
  }
214
234
  } else if (char === "—" || (char === "…" && !sentenceStart)) {
215
235
  const rest = text.slice(offset + char.length);
@@ -218,9 +238,11 @@ export function romanTextProblems(text, inventory) {
218
238
  offset,
219
239
  `\`${char}\` is immediately followed by a terminal (\`.\`, \`?\`, \`‼\` or \`!\`) except at the end of a run`,
220
240
  );
221
- sentenceStart = false;
222
- } else if (TERMINALS.has(char)) sentenceStart = true;
223
- else sentenceStart = false;
241
+ } else if (TERMINALS.has(char)) {
242
+ if (sentenceStart)
243
+ report(offset, `terminal \`${char}\` has no sentence text before it`);
244
+ sentenceStart = true;
245
+ } else sentenceText();
224
246
  offset += char.length;
225
247
  }
226
248
  for (const opening of quotes)
@@ -52,6 +52,7 @@
52
52
  * @param {(span: RomanSpan) => string} [options.opaqueSpan] - Placeholder for an independent child. The default empty name keeps sentence state without introducing that child's letters.
53
53
  * @param {ReadonlyArray<readonly [number, number]>} [options.exclude] - Absolute source ranges that carry layout or presentation syntax.
54
54
  * @param {boolean} [options.markNames] - Whether name children contribute braces.
55
+ * @param {boolean} [options.preserveSlashEscape] - Keep a literal verse slash distinct from a line control.
55
56
  * @returns {{text: string, offsets: number[], spans: ProjectedSpan[]}} Logical text and source locations, with child ranges for presentation.
56
57
  */
57
58
  export function plainRomanRun(
@@ -64,6 +65,7 @@ export function plainRomanRun(
64
65
  opaqueSpan = () => "{}",
65
66
  exclude = [],
66
67
  markNames = true,
68
+ preserveSlashEscape = false,
67
69
  } = {},
68
70
  ) {
69
71
  let text = "";
@@ -134,6 +136,7 @@ export function plainRomanRun(
134
136
  const escaped =
135
137
  source[at] === "\\" && at + 1 < past && /[!-/:-@[-`{-~]/.test(source[at + 1]);
136
138
  const offset = at + (escaped ? 1 : 0);
139
+ if (escaped && preserveSlashEscape && source[offset] === "/") append("\\", at);
137
140
  // A terminal Markdown line-break slash is layout, not Roman.
138
141
  if (source[at] === "\\" && !escaped && /[\r\n]/.test(source[at + 1] ?? "")) {
139
142
  at++;
@@ -38,6 +38,7 @@ export function plainRomanVerse(source, options = {}) {
38
38
  .filter((boundary) => boundary.sourceOffset >= from && boundary.sourceOffset <= to);
39
39
  const projected = plainRomanRun(source, {
40
40
  ...options,
41
+ preserveSlashEscape: true,
41
42
  from,
42
43
  to,
43
44
  exclude: [