@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
@@ -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;
@@ -45,6 +45,7 @@ import remarkFrontmatter from "remark-frontmatter";
45
45
  import remarkGfm from "remark-gfm";
46
46
  import MarkdownIt from "markdown-it";
47
47
  import { readQualifier } from "./address.mjs";
48
+ import { verseRegions } from "./verse-regions.mjs";
48
49
  import { scanBlocks } from "./content-blocks.mjs";
49
50
  import { scanFigures } from "./content-figures.mjs";
50
51
  import { RUN_KEYS } from "./content-scripts.mjs";
@@ -169,14 +170,37 @@ const overlaps = (values, value) => {
169
170
  * Explicit inline occurrences, authored strong text and structural exclusions
170
171
  * at original source offsets.
171
172
  */
172
- function prose(source, capturedNames) {
173
+ function prose(source, capturedNames, capturedSpans) {
173
174
  const links = [],
174
175
  paragraphs = [],
175
176
  regions = [],
176
177
  excluded = [],
177
178
  strong = [],
178
179
  html = [];
179
- const masked = source
180
+ const spans = capturedSpans ?? scanSpans(source);
181
+ const verseMasks = verseRegions(source).map((region) => ({
182
+ start: region.from,
183
+ end: region.to,
184
+ block: true,
185
+ }));
186
+ for (const span of spans.spans)
187
+ if (
188
+ span.verseModel ||
189
+ span.attrs.some(
190
+ ([key, value]) => key === "class" && value.split(/\s+/).includes("verse"),
191
+ )
192
+ )
193
+ verseMasks.push({ start: span.start, end: span.end, block: false });
194
+ excluded.push(...verseMasks);
195
+ const verseRanges = verseMasks
196
+ .sort((a, b) => a.start - b.start || b.end - a.end)
197
+ .filter(
198
+ (range, index, all) =>
199
+ !all
200
+ .slice(0, index)
201
+ .some((parent) => parent.start <= range.start && parent.end >= range.end),
202
+ );
203
+ let masked = source
180
204
  .replace(/!\[\[[^\]\n]+\]\]|\{\{[^}\n]+\}\}/g, (text, start) => {
181
205
  excluded.push({ start, end: start + text.length });
182
206
  return blank(text);
@@ -193,6 +217,11 @@ function prose(source, capturedNames) {
193
217
  });
194
218
  return "x".repeat(text.length);
195
219
  });
220
+ for (const range of [...verseRanges].reverse())
221
+ masked =
222
+ masked.slice(0, range.start) +
223
+ masked.slice(range.start, range.end).replace(/[^\r\n]/g, range.block ? " " : "x") +
224
+ masked.slice(range.end);
196
225
  const tree = parser.parse(masked);
197
226
  const env = { references: Object.create(null) };
198
227
  const inline = (node) => {
@@ -285,7 +314,7 @@ function prose(source, capturedNames) {
285
314
  });
286
315
  const authoredLinks = [...new Map(actualLinks.map((link) => [link.start, link])).values()];
287
316
  const linked = ranges(authoredLinks);
288
- const marked = (capturedNames ?? scanSpans(source).names)
317
+ const marked = (capturedNames ?? spans.names)
289
318
  .filter((span) => eligible(span) && !contains(linked, span))
290
319
  .map((span) => ({
291
320
  ...span,
@@ -373,7 +402,7 @@ export function nameUses(markdown, vocabulary) {
373
402
  // Every use carries the class, so a body without it is read no further.
374
403
  if (!source.includes(".name")) return [];
375
404
  const spans = scanSpans(source);
376
- const { marked, links, strong, inParagraph } = prose(source, spans.names);
405
+ const { marked, links, strong, inParagraph } = prose(source, spans.names, spans);
377
406
  const starts = [0];
378
407
  for (let i = 0; i < source.length; i++) if (source[i] === "\n") starts.push(i + 1);
379
408
  // A caption and a named block's opening and closing lines are not prose.
@@ -527,14 +556,16 @@ export function createFirstUseChecker({
527
556
  const { recognize } = catalog;
528
557
  return ({ file, raw }) => {
529
558
  const authored = parsed?.get(file);
530
- const capturedNames =
531
- authored?.raw === raw && Number.isInteger(authored.bodyLine) ?
532
- rebaseNameSpans(
533
- (syntax ? syntax(authored.body).spans : scanSpans(authored.body)).names,
534
- { raw, bodyLine: authored.bodyLine, bodyColumn: authored.bodyColumn },
535
- )
536
- : undefined;
537
- const { marked } = prose(raw, capturedNames);
559
+ let captured;
560
+ if (authored?.raw === raw && Number.isInteger(authored.bodyLine)) {
561
+ const inventory = syntax ? syntax(authored.body).spans : scanSpans(authored.body);
562
+ const context = { raw, bodyLine: authored.bodyLine, bodyColumn: authored.bodyColumn };
563
+ captured = {
564
+ names: rebaseNameSpans(inventory.names, context),
565
+ spans: rebaseNameSpans(inventory.spans, context),
566
+ };
567
+ }
568
+ const { marked } = prose(raw, captured?.names, captured);
538
569
  const findings = new Map();
539
570
  const add = (offset, rule, message) => {
540
571
  if (!findings.has(offset)) findings.set(offset, { rules: [], messages: [] });
@@ -40,7 +40,8 @@ import { blockTokens } from "./markdown-blocks.mjs";
40
40
  function literalHeadingLines(source) {
41
41
  const lines = new Set();
42
42
  for (const token of blockTokens(source)) {
43
- if (!token.map || !["fence", "code_block", "html_block"].includes(token.type)) continue;
43
+ if (!token.map || !["fence", "code_block", "html_block", "verse_div"].includes(token.type))
44
+ continue;
44
45
  for (let line = token.map[0]; line < token.map[1]; line++) lines.add(line);
45
46
  }
46
47
  return lines;
@@ -76,6 +76,7 @@ import { journalHasContent } from "./note-state.mjs";
76
76
  import { hasLexicalEntries } from "./lexical-sections.mjs";
77
77
  import { draftNoticeFor } from "./draft-notice.mjs";
78
78
  import { scanFigures } from "./content-figures.mjs";
79
+ import { verseRegions } from "./verse-regions.mjs";
79
80
  import { separateFootnotes } from "./content-footnotes.mjs";
80
81
  import { WITHHELD_CLASS, pageOpenings } from "./heading-attributes.mjs";
81
82
  import { imagesIn } from "./content-images.mjs";
@@ -154,6 +155,12 @@ export function splitPages(body, leadName = "Introduction", resolveRole, syntax)
154
155
  scanFigures(markdown, { resolveRole }).figures.map((figure) => [figure.line - 1, figure]),
155
156
  );
156
157
  const blockLines = namedBlockLines(markdown, syntax);
158
+ const verseLines = new Set();
159
+ for (const region of verseRegions(markdown))
160
+ for (let line = region.start; line <= region.end; line++) {
161
+ blockLines.add(line);
162
+ verseLines.add(line);
163
+ }
157
164
  const openings = pageOpenings(markdown, blockLines);
158
165
  const pages = [];
159
166
  const beforeFirstH1 = [];
@@ -189,7 +196,7 @@ export function splitPages(body, leadName = "Introduction", resolveRole, syntax)
189
196
  // carry a shorter fence of its own as literal content, same as
190
197
  // {@link module:engine/content-blocks.scanBlocks} and
191
198
  // {@link module:engine/content-figures.scanFigures} track it.
192
- const fence = /^ {0,3}(`{3,}|~{3,})/.exec(line);
199
+ const fence = verseLines.has(lineIndex) ? null : /^ {0,3}(`{3,}|~{3,})/.exec(line);
193
200
  if (codeFence) {
194
201
  if (fence && fence[1][0] === codeFence[0] && fence[1].length >= codeFence.length) {
195
202
  codeFence = null;
@@ -25,9 +25,10 @@
25
25
  */
26
26
 
27
27
  import MarkdownIt from "markdown-it";
28
+ import { verseDivPlugin } from "./verse-regions.mjs";
28
29
 
29
30
  /** The renderer's block grammar, with inline parsing and its passes switched off. */
30
- const parser = new MarkdownIt({ html: true });
31
+ const parser = new MarkdownIt({ html: true }).use(verseDivPlugin);
31
32
  parser.core.ruler.disable(["inline", "linkify", "replacements", "smartquotes", "text_join"]);
32
33
 
33
34
  /** How many recent sources keep their tokens. */
@@ -51,7 +52,7 @@ export function blockTokens(source) {
51
52
  recent.set(text, hit);
52
53
  return hit;
53
54
  }
54
- const tokens = parser.parse(text, {});
55
+ const tokens = parser.parse(text, { verseSource: text });
55
56
  recent.set(text, tokens);
56
57
  if (recent.size > RECENT) recent.delete(recent.keys().next().value);
57
58
  return tokens;
@@ -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([
@@ -542,12 +550,33 @@ export async function runMarkdownlint(params) {
542
550
  for (const [dir, files] of groups) {
543
551
  const options = await lintOptions(dir, files);
544
552
  const malformed = new Set();
553
+ const sources = new Map();
545
554
  for (const file of files) {
546
- const findings = markupFindings(fs.readFileSync(file, "utf8"), file);
555
+ const source = fs.readFileSync(file, "utf8");
556
+ sources.set(file, protectLintVerse(source));
557
+ const findings = markupFindings(source, file);
547
558
  if (findings.length) malformed.add(file);
548
559
  markup.push(...findings);
549
560
  }
550
- let results = await lint(options);
561
+ const lintSources = async (selected) => {
562
+ const { files: _files, ...shared } = options;
563
+ const results = await lint({
564
+ ...shared,
565
+ strings: Object.fromEntries(selected.map((file) => [file, sources.get(file).text])),
566
+ });
567
+ for (const file of selected) {
568
+ const protectedSource = sources.get(file);
569
+ results[file] = (results[file] ?? [])
570
+ .filter((error) => !protectedSource.containsFinding(error))
571
+ .map((error) => {
572
+ if (!error.fixInfo || !protectedSource.containsFix(error)) return error;
573
+ const { fixInfo: _fixInfo, ...unfixable } = error;
574
+ return unfixable;
575
+ });
576
+ }
577
+ return results;
578
+ };
579
+ let results = await lintSources(files);
551
580
  if (fix) {
552
581
  const toFix = [];
553
582
  for (const file of files) {
@@ -555,11 +584,15 @@ export async function runMarkdownlint(params) {
555
584
  const fixable = (results[file] ?? []).filter((error) => error.fixInfo);
556
585
  if (!fixable.length) continue;
557
586
  const original = fs.readFileSync(file, "utf8");
558
- fs.writeFileSync(file, applyFixes(original, fixable), "utf8");
587
+ const fixed =
588
+ sources.get(file).protected ?
589
+ applyVerseSafeFixes(original, fixable)
590
+ : applyFixes(original, fixable);
591
+ fs.writeFileSync(file, fixed, "utf8");
592
+ sources.set(file, protectLintVerse(fixed));
559
593
  toFix.push(file);
560
594
  }
561
- if (toFix.length)
562
- results = { ...results, ...(await lint({ ...options, files: toFix })) };
595
+ if (toFix.length) results = { ...results, ...(await lintSources(toFix)) };
563
596
  }
564
597
  for (const [file, errors] of Object.entries(results)) resultsByFile.set(file, errors);
565
598
  }
@@ -581,3 +614,155 @@ export async function runMarkdownlint(params) {
581
614
  );
582
615
  return { results: flat, markup };
583
616
  }
617
+
618
+ /**
619
+ * Hide verse content from Markdown rules and inline configuration without moving
620
+ * any authored coordinate. Container prefixes and div delimiters remain visible.
621
+ * @param {string} source - Original file contents.
622
+ * @returns {object} Masked text and predicates guarding findings and fixes.
623
+ */
624
+ function protectLintVerse(source) {
625
+ const syntax = source.replace(/^\uFEFF?---\r?\n[^]*?\r?\n---(?:\r?\n|$)/, (frontmatter) =>
626
+ frontmatter.replace(/[^\r\n]/g, " "),
627
+ );
628
+ const starts = [0];
629
+ for (const match of source.matchAll(/\r\n|\r|\n/g)) starts.push(match.index + match[0].length);
630
+ const endOfLine = (line) => {
631
+ const past = starts[line + 1] ?? source.length;
632
+ return past - (/\r\n$|[\r\n]$/.exec(source.slice(starts[line], past))?.[0].length ?? 0);
633
+ };
634
+ const ranges = [],
635
+ masks = [],
636
+ inlineMasks = [],
637
+ spanBrackets = [],
638
+ bodyLines = new Set(),
639
+ verseLines = [],
640
+ usedReferences = new Set(),
641
+ footnotes = footnoteLabels(syntax);
642
+ const referencesInVerse = (text, model) => {
643
+ const titles = model.groups
644
+ .filter((group) => group.cantoBreak && group.title)
645
+ .map((group) => group.title);
646
+ for (const fragment of [text, ...titles])
647
+ for (const reference of footnoteReferences(fragment, {
648
+ definitions: footnotes,
649
+ inline: true,
650
+ }))
651
+ usedReferences.add(referenceParser.utils.normalizeReference(`^${reference.key}`));
652
+ for (const group of model.groups) {
653
+ if (group.cantoBreak) {
654
+ if (group.title) verseLines.push(group.title);
655
+ } else for (const line of group.lines) verseLines.push(line.text);
656
+ }
657
+ };
658
+ for (const region of verseRegions(syntax)) {
659
+ if (!region.closed) continue;
660
+ referencesInVerse(region.body, parseVerse(region.body, { footnotes }));
661
+ ranges.push([region.bodyStart, starts[region.end]]);
662
+ for (let line = region.start + 1; line < region.end; line++) {
663
+ bodyLines.add(line + 1);
664
+ masks.push([
665
+ Math.min(region.contentStarts[line - region.start], endOfLine(line)),
666
+ endOfLine(line),
667
+ ]);
668
+ }
669
+ }
670
+ for (const span of scanSpans(syntax).spans) {
671
+ if (!span.verseModel || source[span.end - 1] !== "}") continue;
672
+ referencesInVerse(span.text, span.verseModel);
673
+ ranges.push([span.start + 1, span.contentEnd]);
674
+ spanBrackets.push(span.start, span.contentEnd);
675
+ // Inline tokens omit quote/list prefixes. Continuation content ends
676
+ // at its physical line ending (or the located closing bracket), so
677
+ // its parsed length gives the content start without parsing prefixes.
678
+ const contentLines = span.text.replace(/\r\n?/g, "\n").split("\n");
679
+ for (const [index, content] of contentLines.entries()) {
680
+ const line = span.line - 1 + index;
681
+ const to = Math.min(endOfLine(line), span.contentEnd);
682
+ inlineMasks.push([index === 0 ? span.start + 1 : to - content.length, to]);
683
+ }
684
+ }
685
+ const raw = source.split("");
686
+ for (const [from, to] of masks)
687
+ for (let at = from; at < to; at++) if (!/[\r\n]/.test(raw[at])) raw[at] = " ";
688
+ // A blanked physical span line would split its surrounding paragraph.
689
+ // Neutralize its bracket delimiters too, so the mask cannot manufacture
690
+ // a reference shortcut to an unrelated definition with the same label.
691
+ for (const [from, to] of inlineMasks)
692
+ for (let at = from; at < to; at++) if (/\S/.test(raw[at])) raw[at] = "x";
693
+ for (const at of spanBrackets) raw[at] = "x";
694
+ const masked = raw.join("");
695
+ if (ranges.length) {
696
+ const env = {};
697
+ referenceParser.parse(masked, env);
698
+ // Reference links and images carry their normalized definition label
699
+ // on the actual emitted token. Speculative parser lookups do not count.
700
+ const collect = (tokens) => {
701
+ for (const token of tokens) {
702
+ const label = token.meta?.label;
703
+ if (["link_open", "image"].includes(token.type) && label) usedReferences.add(label);
704
+ if (token.children) collect(token.children);
705
+ }
706
+ };
707
+ for (const line of verseLines) {
708
+ const tokens = [];
709
+ referenceParser.inline.parse(line, referenceParser, env, tokens);
710
+ collect(tokens);
711
+ }
712
+ }
713
+ const intersects = (from, to) => ranges.some(([start, end]) => from < end && to > start);
714
+ return {
715
+ text: masked,
716
+ protected: ranges.length > 0,
717
+ containsFinding(error) {
718
+ if (error.ruleNames.includes("MD053")) {
719
+ const unused = /^Unused link or image reference definition: "([^]*)"$/.exec(
720
+ error.errorDetail ?? "",
721
+ );
722
+ if (
723
+ unused &&
724
+ usedReferences.has(referenceParser.utils.normalizeReference(unused[1]))
725
+ )
726
+ return true;
727
+ }
728
+ if (bodyLines.has(error.lineNumber)) return true;
729
+ if (!error.errorRange) return false;
730
+ const from = starts[error.lineNumber - 1] + error.errorRange[0] - 1;
731
+ return intersects(from, from + error.errorRange[1]);
732
+ },
733
+ containsFix(error) {
734
+ const fix = error.fixInfo;
735
+ const line = (fix.lineNumber || error.lineNumber) - 1;
736
+ const from = starts[line] + (fix.editColumn || 1) - 1;
737
+ if (fix.deleteCount === -1)
738
+ return intersects(starts[line], starts[line + 1] ?? source.length);
739
+ if (intersects(from, from + (fix.deleteCount ?? 0))) return true;
740
+ return Boolean(
741
+ fix.insertText && ranges.some(([start, end]) => from >= start && from <= end),
742
+ );
743
+ },
744
+ };
745
+ }
746
+
747
+ /**
748
+ * Apply fixes per original line so fixing prose cannot normalize the untouched
749
+ * line endings of a verse region elsewhere in a mixed-ending file.
750
+ * @param {string} source - Original file contents.
751
+ * @param {object[]} errors - Findings whose fixes do not intersect verse.
752
+ * @returns {string} Source with only the permitted fixes applied.
753
+ */
754
+ function applyVerseSafeFixes(source, errors) {
755
+ const byLine = new Map();
756
+ for (const error of errors) {
757
+ const line = error.fixInfo.lineNumber || error.lineNumber;
758
+ if (!byLine.has(line)) byLine.set(line, []);
759
+ byLine
760
+ .get(line)
761
+ .push({ ...error, lineNumber: 1, fixInfo: { ...error.fixInfo, lineNumber: 1 } });
762
+ }
763
+ let line = 0;
764
+ return source.replace(/[^\r\n]*(?:\r\n|\r|\n|$)/g, (original) => {
765
+ const fixes = byLine.get(++line);
766
+ return fixes ? applyFixes(original, fixes) : original;
767
+ });
768
+ }
@@ -14,6 +14,8 @@
14
14
  import { scanBlocks } from "./content-blocks.mjs";
15
15
  import { scanAlerts } from "./content-alerts.mjs";
16
16
  import { scanRuns } from "./content-scripts.mjs";
17
+ import { scanSpans } from "./content-spans.mjs";
18
+ import { verseRegions } from "./verse-regions.mjs";
17
19
  import { matchAllOutsideCode } from "./code-fences.mjs";
18
20
 
19
21
  /** The opening of a shortcode: `{{<` or `{{%`. */
@@ -55,9 +57,24 @@ export function markupFindings(source, file, syntax, runs) {
55
57
  );
56
58
  const scanned = syntax?.(body);
57
59
  const marked = runs ? scanRuns(body, runs, syntax) : undefined;
60
+ const spans = scanned?.spans ?? scanSpans(body);
61
+ const verseRanges = [
62
+ ...verseRegions(body).map((region) => [region.from, region.to]),
63
+ ...spans.spans.filter((span) => span.verseModel).map((span) => [span.start, span.end]),
64
+ ];
65
+ const starts = [0];
66
+ for (const match of body.matchAll(/\n/g)) starts.push(match.index + 1);
67
+ const verseErrors = spans.errors.filter((error) => {
68
+ const offset = (starts[error.line - 1] ?? 0) + error.column - 1;
69
+ return (
70
+ error.message.includes(".verse") ||
71
+ verseRanges.some(([from, to]) => offset >= from && offset < to)
72
+ );
73
+ });
58
74
  const errors = [
59
75
  ...(scanned?.blocks ?? scanBlocks(body)).errors,
60
76
  ...(scanned?.alerts ?? scanAlerts(body)).errors,
77
+ ...verseErrors,
61
78
  ...(marked?.errors ?? []),
62
79
  ...shortcodeErrors(body),
63
80
  ].map((error) => ({
@@ -42,6 +42,7 @@
42
42
  * @property {string} visible - Visible text from the actual parsed children.
43
43
  * @property {ReadonlyArray<readonly [string, string]>} attrs - Retained permitted HTML attributes.
44
44
  * @property {Record<string, SpanAttributePosition>} attributePositions - Authored key/value sites by retained attribute name.
45
+ * @property {ReturnType<typeof import("./content-verse.mjs").parseVerse>} [verseModel] - Logical model of an inline verse quotation.
45
46
  */
46
47
 
47
48
  /**
@@ -2338,7 +2338,7 @@ export const NOTE_VOCABULARY = Object.freeze({
2338
2338
  ...TEXT,
2339
2339
  required: true,
2340
2340
  describe:
2341
- "What a verse fence's `scan=` names the metre by: lowercase " +
2341
+ "What a verse div's `scan=` names the metre by: lowercase " +
2342
2342
  "letters, digits and `-`, unique in the package.",
2343
2343
  },
2344
2344
  {
@@ -2362,7 +2362,7 @@ export const NOTE_VOCABULARY = Object.freeze({
2362
2362
  ]),
2363
2363
  }),
2364
2364
  describe:
2365
- "The metres a verse fence's `scan=` names. A fence that states one is " +
2365
+ "The metres a verse div's `scan=` names. A div that states one is " +
2366
2366
  "warned at each line that cannot have its beats. See " +
2367
2367
  "[metres](../authoring/links-and-markup.md#metre).",
2368
2368
  },