@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
@@ -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;
@@ -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
  },
@@ -86,7 +86,7 @@ import { scanSpans } from "./content-spans.mjs";
86
86
  import { GENERIC_FONTS, renderRuns, scanRuns } from "./content-scripts.mjs";
87
87
  import { scanFigures } from "./content-figures.mjs";
88
88
  import { BLOCK_CONTAINERS, scanBlocks, BLOCK_NAMES } from "./content-blocks.mjs";
89
- import { rightToLeft, scanVerse } from "./content-verse.mjs";
89
+ import { rightToLeft } from "./content-verse.mjs";
90
90
  import { matchAllOutsideCode, replaceOutsideCode } from "./code-fences.mjs";
91
91
  import { panelRoman, scanScrolls, scrollPanels, scrollParagraphs } from "./content-scroll.mjs";
92
92
  import { BOOK_INK, BOOK_RUBRIC, fontOutlines, scrollPanelSvg } from "./font-outlines.mjs";
@@ -499,7 +499,7 @@ export function markdownToTypst(markdown, opts = {}) {
499
499
  const given = opts.nested ? String(markdown ?? "") : stripHtmlComments(markdown);
500
500
  const separated = opts.footnoteDefinitions === undefined ? separateFootnotes(given) : null;
501
501
  const authored = separated?.markdown ?? given;
502
- const definitions = opts.footnoteDefinitions ?? separated?.definitions ?? "";
502
+ let definitions = opts.footnoteDefinitions ?? separated?.definitions ?? "";
503
503
  const footnoteState = opts.footnoteState ?? { seen: new Set() };
504
504
  const sharedOptions = {
505
505
  ...opts,
@@ -513,10 +513,16 @@ export function markdownToTypst(markdown, opts = {}) {
513
513
  // Text marked with a language, a font or a map is rewritten once, ahead of
514
514
  // every other pass, line for line; the nested calls below read the result,
515
515
  // and the scroll fences' layouts the rewrite returns.
516
+ const markingSource = definitions ? `${authored}\n\n${definitions}` : authored;
516
517
  const marked =
517
- opts.nested ? undefined : renderRuns(authored, opts.runs, undefined, { target: "book" });
518
- const source = marked ? marked.markdown : authored;
518
+ opts.nested ? undefined : (
519
+ renderRuns(markingSource, opts.runs, undefined, { target: "book" })
520
+ );
521
+ const markedFootnotes = marked && definitions ? separateFootnotes(marked.markdown) : undefined;
522
+ const source = markedFootnotes?.markdown ?? marked?.markdown ?? authored;
523
+ if (markedFootnotes) definitions = markedFootnotes.definitions;
519
524
  if (marked) {
525
+ sharedOptions.footnoteDefinitions = definitions;
520
526
  sharedOptions.scrolls = marked.scrolls;
521
527
  sharedOptions.verses = marked.verses;
522
528
  }
@@ -775,22 +781,14 @@ function renderBlock(tokens, i, out, ctx) {
775
781
  out.push(scrollBlock(token, ctx));
776
782
  return 1;
777
783
  }
778
- if (token.type === "fence" && /^verse(?:\s|$)/.test(token.info?.trim() || "")) {
779
- const opening = `${token.markup}${token.info}`;
780
- const scanned = scanVerse(`${opening}\n${token.content}${token.markup}`, {
781
- checkWorks: false,
782
- });
783
- // Findings are collected before rendering. Keep malformed verse
784
- // literal rather than assigning invalid parts to display cells.
785
- if (scanned.errors.length && !token.meta?.versePaired) {
786
- out.push(rawBlock(token.content, token.info));
787
- return 1;
788
- }
789
- const poem = scanned.blocks[0];
790
- const content = workVerse(token, poem, ctx);
791
- const anchor = poem?.id ? ` <${sectionLabel(ctx.anchorPrefix, poem.id)}>` : "";
792
- out.push(`\n#block(width: 100%)[${content}]${anchor}\n`);
793
- } else out.push(rawBlock(token.content, token.info?.trim() || ""));
784
+ out.push(rawBlock(token.content, token.info?.trim() || ""));
785
+ return 1;
786
+ }
787
+ case "verse_div": {
788
+ const poem = token.meta.verse;
789
+ const content = workVerse(token, poem, ctx);
790
+ const anchor = poem?.id ? ` <${sectionLabel(ctx.anchorPrefix, poem.id)}>` : "";
791
+ out.push(`\n#block(width: 100%)[${content}]${anchor}\n`);
794
792
  return 1;
795
793
  }
796
794
  case "hr":
@@ -1286,15 +1284,20 @@ const FONT_CLASSES = Object.freeze(["script", ...Object.values(GENERIC_FONTS)]);
1286
1284
  /** The logical lines of one stanza, with a shared label and paired scripts. */
1287
1285
  function workVerse(token, poem, ctx) {
1288
1286
  const paired = token.meta.versePaired;
1289
- const labelled = Boolean(poem?.attributes.work);
1287
+ const labelled = token.type !== "verse_inline" && Boolean(poem?.attributes.work);
1290
1288
  const nativeClasses = paired?.classes ?? poem?.classes;
1291
1289
  const nativeAttributes = paired?.attributes ?? poem?.attributes;
1292
- return (token.meta.verseGroups ?? [])
1290
+ const body = (token.meta.verseGroups ?? [])
1293
1291
  .map((group) => {
1294
1292
  if (group.cantoBreak) {
1295
1293
  const mark =
1296
1294
  group.children?.length ? renderInline({ children: group.children }, ctx) : "";
1297
- return `${mark ? scriptWrapped(mark, nativeClasses, nativeAttributes) + "\n" : ""}#v(0.5em)`;
1295
+ const heading =
1296
+ group.titleChildren?.length ?
1297
+ `#heading(level: 3, outlined: false, bookmarked: false)[${renderInline({ children: group.titleChildren }, ctx)}]`
1298
+ : "";
1299
+ const anchor = group.id ? ` <${sectionLabel(ctx.anchorPrefix, group.id)}>` : "";
1300
+ return `${mark ? scriptWrapped(mark, nativeClasses, nativeAttributes) + "\n" : ""}#v(0.5em)${heading ? "\n" + heading : ""}${anchor}`;
1298
1301
  }
1299
1302
  const label = labelled ? `#text(size: 0.75em, fill: luma(45%))[${group.label}]` : "";
1300
1303
  const lineText = (line, roman = false) => {
@@ -1328,6 +1331,8 @@ function workVerse(token, poem, ctx) {
1328
1331
  return `#set text(dir: ltr)\n#grid(columns: (${columns}), column-gutter: 1.5em, row-gutter: 0.35em,\n ${cells.join(",\n ")}\n)`;
1329
1332
  })
1330
1333
  .join("\n\n");
1334
+ const notes = (token.meta.verseNotes ?? []).map((line) => renderInline(line, ctx)).join("");
1335
+ return body + notes;
1331
1336
  }
1332
1337
 
1333
1338
  /**
@@ -1374,6 +1379,12 @@ function renderInline(token, ctx) {
1374
1379
  case "code_inline":
1375
1380
  out.push(inlineRaw(child.content));
1376
1381
  break;
1382
+ case "verse_inline": {
1383
+ const content = workVerse(child, child.meta.verse, ctx);
1384
+ const id = child.attrGet?.("id");
1385
+ out.push(`#box[${content}]${id ? ` <${sectionLabel(ctx.anchorPrefix, id)}>` : ""}`);
1386
+ break;
1387
+ }
1377
1388
  case "span_open": {
1378
1389
  if (["em", "strong"].includes(child.tag)) {
1379
1390
  const classes = (child.attrGet?.("class") ?? "").split(/\s+/);
@@ -35,6 +35,7 @@
35
35
  import fs from "node:fs";
36
36
  import path from "node:path";
37
37
  import { formatNoteFrontmatter } from "./note-format.mjs";
38
+ import { protectVerseSource } from "./verse-format.mjs";
38
39
 
39
40
  import {
40
41
  MARKDOWNLINT_CONFIG,
@@ -161,6 +162,17 @@ export async function checkFormatting(root, opts = {}) {
161
162
  }
162
163
  }
163
164
 
165
+ const format = async (input) => {
166
+ const protectedSource =
167
+ ["markdown", "mdx"].includes(info.inferredParser) ?
168
+ protectVerseSource(input)
169
+ : undefined;
170
+ const output = await prettier.format(protectedSource?.markdown ?? input, options);
171
+ return formatNoteFrontmatter(
172
+ protectedSource ? protectedSource.restore(output) : output,
173
+ );
174
+ };
175
+
164
176
  // A file Prettier cannot parse is a finding, not a crash. Its own CLI
165
177
  // reports the syntax error and carries on to the next file, and a
166
178
  // single unparseable file in a large tree must not cost the report on
@@ -172,7 +184,7 @@ export async function checkFormatting(root, opts = {}) {
172
184
  let formatted = source;
173
185
  let converged = false;
174
186
  for (let pass = 0; pass < MAX_FORMAT_PASSES; pass += 1) {
175
- const next = formatNoteFrontmatter(await prettier.format(formatted, options));
187
+ const next = await format(formatted);
176
188
  if (next === formatted) {
177
189
  converged = true;
178
190
  break;
@@ -206,7 +218,7 @@ export async function checkFormatting(root, opts = {}) {
206
218
  continue;
207
219
  }
208
220
 
209
- if (formatNoteFrontmatter(await prettier.format(source, options)) !== source) {
221
+ if ((await format(source)) !== source) {
210
222
  findings.push({
211
223
  file,
212
224
  severity: "error",
@@ -12,7 +12,7 @@
12
12
  */
13
13
 
14
14
  /**
15
- * Spans, divs and verse fences marked with `lang=`, `font=` or `enc=`, projected
15
+ * Spans, divs and verse divs and quoted verse spans marked with `lang=`, `font=` or `enc=`, projected
16
16
  * from own-note index records into the SQL `runs` relation. Every value is a
17
17
  * column value and none is a key, so two notes writing one tag in different
18
18
  * case are two rows. Documentation records and assets contribute no rows.
@@ -682,7 +682,7 @@ async function createNameRelation(connection, file, records, prefix, audience) {
682
682
  }
683
683
 
684
684
  /**
685
- * Create the `runs` relation: one row per span, div and verse fence an own
685
+ * Create the `runs` relation: one row per span, div and verse div an own
686
686
  * note marks with `lang=`, `font=` or `enc=`, with the columns
687
687
  * {@link module:engine/run-rows.RUN_COLUMNS} names. The columns are declared
688
688
  * rather than inferred, so every value stays a string and none becomes a
@@ -0,0 +1,77 @@
1
+ /*
2
+ * This file is part of the Song of Heroic Lands (SoHL) system for Foundry VTT.
3
+ * Copyright (c) 2024-2026 Tom Rodriguez ("Toasty") — <toasty@heroiclands.org>
4
+ *
5
+ * This work is licensed under the GNU General Public License v3.0 (GPLv3).
6
+ * You may copy, modify, and distribute it under the terms of that license.
7
+ *
8
+ * For full terms, see the LICENSE.md file in the project root or visit:
9
+ * https://www.gnu.org/licenses/gpl-3.0.html
10
+ *
11
+ * SPDX-License-Identifier: GPL-3.0-or-later
12
+ */
13
+
14
+ /** Preserve complete authored verse regions through surrounding prose formatting. @module */
15
+
16
+ import { verseRegions } from "./verse-regions.mjs";
17
+ import { scanSpans } from "./content-spans.mjs";
18
+
19
+ /**
20
+ * Replace verse markup with opaque placeholders and restore its original bytes.
21
+ * @param {string} source - Complete Markdown source.
22
+ * @returns {{markdown: string, restore: (formatted: string) => string}} Protected source and exact restoration.
23
+ */
24
+ export function protectVerseSource(source) {
25
+ const syntax = source.replace(/^\uFEFF?---\r?\n[^]*?\r?\n---(?:\r?\n|$)/, (frontmatter) =>
26
+ frontmatter.replace(/[^\r\n]/g, " "),
27
+ );
28
+ const ranges = [
29
+ ...verseRegions(syntax)
30
+ .filter((region) => region.closed)
31
+ .map((region) => ({ from: region.from, to: region.to, block: true })),
32
+ ...scanSpans(syntax)
33
+ .spans.filter((span) =>
34
+ span.attrs.some(
35
+ ([key, value]) => key === "class" && value.split(/\s+/).includes("verse"),
36
+ ),
37
+ )
38
+ .map((span) => ({ from: span.start, to: span.end, block: false })),
39
+ ].sort((a, b) => a.from - b.from || b.to - a.to);
40
+ const outer = ranges.filter(
41
+ (range, index) =>
42
+ !ranges
43
+ .slice(0, index)
44
+ .some((parent) => parent.from <= range.from && parent.to >= range.to),
45
+ );
46
+ let prefix = "VERSEPROTECTEDTOKEN";
47
+ while (source.includes(prefix)) prefix += "X";
48
+ const replacements = outer.map((range, index) => {
49
+ const original = source.slice(range.from, range.to);
50
+ const marker = range.block ? `<!--${prefix}${index}-->` : `${prefix}${index}END`;
51
+ return { ...range, original, marker, newline: range.block && /\r?\n$/.test(original) };
52
+ });
53
+ let markdown = source;
54
+ for (const entry of [...replacements].reverse())
55
+ markdown =
56
+ markdown.slice(0, entry.from) +
57
+ entry.marker +
58
+ (entry.newline ? "\n" : "") +
59
+ markdown.slice(entry.to);
60
+ return {
61
+ markdown,
62
+ restore(formatted) {
63
+ let restored = formatted;
64
+ for (const entry of replacements) {
65
+ const escaped = entry.marker.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
66
+ const pattern =
67
+ entry.block ?
68
+ new RegExp(`^[ \t]*${escaped}${entry.newline ? "\\r?\\n" : ""}`, "m")
69
+ : new RegExp(escaped);
70
+ if (!pattern.test(restored))
71
+ throw new Error("the formatter changed a protected verse placeholder");
72
+ restored = restored.replace(pattern, () => entry.original);
73
+ }
74
+ return restored;
75
+ },
76
+ };
77
+ }
@@ -12,7 +12,7 @@
12
12
  */
13
13
 
14
14
  /**
15
- * **A verse fence declares its metre, and the build counts its beats.** A
15
+ * **A verse div declares its metre, and the build counts its beats.** A
16
16
  * metre is declared once, as an entry of a `doc` note's `data.metres`:
17
17
  *
18
18
  * ```yaml
@@ -21,7 +21,7 @@
21
21
  * - { name: hexameter, beats: 6, kind: accentual }
22
22
  * ```
23
23
  *
24
- * A fence that states `scan=` with the `name` and `lang=` with a language
24
+ * A div that states `scan=` with the `name` and `lang=` with a language
25
25
  * whose note states `data.sounds.stress` is counted line by line, and a line
26
26
  * that cannot have the metre's beats is a warning at that line. No `scan=`
27
27
  * means no check.
@@ -278,7 +278,7 @@ export function lineBeats(text, book) {
278
278
  * The warning for a line that cannot have its metre's beats.
279
279
  *
280
280
  * @param {{min: number, max: number}} beats - The line's beats.
281
- * @param {import("./metre-declaration.mjs").Metre} metre - The fence's metre.
281
+ * @param {import("./metre-declaration.mjs").Metre} metre - The div's metre.
282
282
  * @returns {string} The message.
283
283
  */
284
284
  export function metreMessage({ min, max }, metre) {
@@ -287,13 +287,13 @@ export function metreMessage({ min, max }, metre) {
287
287
  }
288
288
 
289
289
  /**
290
- * Check the verse fences that state `scan=`.
290
+ * Check the verse divs that state `scan=`.
291
291
  *
292
292
  * @param {ReadonlyArray<{start: number, end: number, indent: string, attributes: Record<string, string>, classes: string[]}>} blocks -
293
- * The fences, from {@link module:engine/content-verse.scanVerse}.
293
+ * The divs, from {@link module:engine/content-verse.scanVerse}.
294
294
  * @param {string[]} lines - The source's lines.
295
295
  * @param {{languages?: Readonly<Record<string, {tag: string}>>, metres?: ReadonlyMap<string, import("./metre-declaration.mjs").Metre>, metreBooks?: ReadonlyMap<string, MetreBook>, lexicons?: ReadonlyMap<string, unknown>, attributeColumns: (line: string, key: string) => {key: number, value: number}}} ctx -
296
- * The metres and books, and how the fence says its columns and where its
296
+ * The metres and books, and how the div says its columns and where its
297
297
  * keys sit.
298
298
  * @returns {{errors: object[], warnings: object[]}} The findings at 1-based
299
299
  * positions.
@@ -325,7 +325,7 @@ export function metreFindings(blocks, lines, ctx) {
325
325
  if (lang === undefined) {
326
326
  error(
327
327
  at("scan", "key"),
328
- "`scan=` counts beats by a language's stress rule: the fence needs a `lang=`",
328
+ "`scan=` counts beats by a language's stress rule: the div needs a `lang=`",
329
329
  );
330
330
  continue;
331
331
  }
@@ -351,7 +351,16 @@ export function metreFindings(blocks, lines, ctx) {
351
351
  for (const group of model.groups) {
352
352
  if (group.cantoBreak) continue;
353
353
  for (const reading of group.lines) {
354
- const tokens = verseParser.parseInline(reading.text, {})[0]?.children ?? [];
354
+ const units = reading.text
355
+ .split("")
356
+ .map((text, index) => ({ text, offset: reading.offsets[index] }))
357
+ .filter(
358
+ ({ offset }) =>
359
+ !model.layout.some(([from, to]) => offset >= from && offset < to),
360
+ );
361
+ const tokens =
362
+ verseParser.parseInline(units.map((unit) => unit.text).join(""), {})[0]
363
+ ?.children ?? [];
355
364
  const tag = (name) => (ctx.languages?.[name]?.tag ?? name).toLowerCase();
356
365
  const foreign = tokens.find(
357
366
  (token) =>
@@ -361,7 +370,7 @@ export function metreFindings(blocks, lines, ctx) {
361
370
  );
362
371
  if (foreign) {
363
372
  error(
364
- sourcePosition(reading.offsets[foreign.meta.start] ?? reading.from),
373
+ sourcePosition(units[foreign.meta.start]?.offset ?? reading.from),
365
374
  "`scan=` counts one language's beats; this verse line contains another language",
366
375
  );
367
376
  continue;