@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
@@ -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",
@@ -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: [
@@ -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,89 @@
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
+ import { util } from "prettier";
19
+
20
+ /**
21
+ * Replace verse markup with opaque placeholders, retaining container prefixes
22
+ * and inline display widths. Restoration follows the formatter's line endings.
23
+ * @param {string} source - Complete Markdown source.
24
+ * @returns {{markdown: string, restore: (formatted: string) => string}} Protected source and restoration.
25
+ */
26
+ export function protectVerseSource(source) {
27
+ const syntax = source.replace(/^\uFEFF?---\r?\n[^]*?\r?\n---(?:\r?\n|$)/, (frontmatter) =>
28
+ frontmatter.replace(/[^\r\n]/g, " "),
29
+ );
30
+ const ranges = [
31
+ ...verseRegions(syntax)
32
+ .filter((region) => region.closed)
33
+ .map((region) => ({ from: region.contentStarts[0], to: region.to, block: true })),
34
+ ...scanSpans(syntax)
35
+ .spans.filter((span) =>
36
+ span.attrs.some(
37
+ ([key, value]) => key === "class" && value.split(/\s+/).includes("verse"),
38
+ ),
39
+ )
40
+ .map((span) => ({ from: span.start, to: span.end, block: false })),
41
+ ].sort((a, b) => a.from - b.from || b.to - a.to);
42
+ const outer = ranges.filter(
43
+ (range, index) =>
44
+ !ranges
45
+ .slice(0, index)
46
+ .some((parent) => parent.from <= range.from && parent.to >= range.to),
47
+ );
48
+ let nonce = 0;
49
+ let prefix = `V${nonce}V`;
50
+ while (source.includes(prefix)) prefix = `V${++nonce}V`;
51
+ const replacements = outer.map((range, index) => {
52
+ const original = source.slice(range.from, range.to);
53
+ const key = `${prefix}${index.toString(36)}V`;
54
+ const marker =
55
+ range.block ? `<!--${key}-->` : key.padEnd(util.getStringWidth(original), "X");
56
+ return {
57
+ ...range,
58
+ original,
59
+ marker,
60
+ newline: range.block ? /(?:\r\n|\r|\n)$/.exec(original)?.[0] : undefined,
61
+ };
62
+ });
63
+ let markdown = source;
64
+ for (const entry of [...replacements].reverse())
65
+ markdown =
66
+ markdown.slice(0, entry.from) +
67
+ entry.marker +
68
+ (entry.newline ?? "") +
69
+ markdown.slice(entry.to);
70
+ return {
71
+ markdown,
72
+ restore(formatted) {
73
+ const newline = /\r\n|\r|\n/.exec(formatted)?.[0] ?? "\n";
74
+ let restored = formatted;
75
+ for (const entry of replacements) {
76
+ const escaped = entry.marker.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
77
+ const pattern = new RegExp(
78
+ `${escaped}${entry.newline ? "(?:\\r\\n|\\r|\\n)" : ""}`,
79
+ );
80
+ if (!pattern.test(restored))
81
+ throw new Error("the formatter changed a protected verse placeholder");
82
+ restored = restored.replace(pattern, () =>
83
+ entry.original.replace(/\r\n|\r|\n/g, newline),
84
+ );
85
+ }
86
+ return restored;
87
+ },
88
+ };
89
+ }
@@ -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;
@@ -0,0 +1,190 @@
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
+ import MarkdownIt from "markdown-it";
15
+ import { WIKILINK } from "./wikilink-pattern.mjs";
16
+ import { parseExtensionAttributes } from "./extension-attributes.mjs";
17
+
18
+ /**
19
+ * A raw verse div, opaque to ordinary Markdown block syntax.
20
+ * @typedef {object} VerseRegion
21
+ * @property {number} start - Opening line, zero based.
22
+ * @property {number} end - Closing line, zero based and inclusive.
23
+ * @property {number} from - First offset of the complete region.
24
+ * @property {number} to - Offset past the complete region.
25
+ * @property {number[]} contentStarts - Original-source content offsets for each region line, after container prefixes.
26
+ * @property {number} bodyStart - First body offset.
27
+ * @property {number} bodyEnd - Offset past body text, excluding its final newline.
28
+ * @property {string} body - Authored body bytes.
29
+ * @property {Record<string,string>} attributes - Authored values.
30
+ * @property {string} id - Anchor or empty string.
31
+ * @property {string[]} classes - Authored classes including verse.
32
+ * @property {string} indent - Opening indentation.
33
+ * @property {"verse"} name - Region kind.
34
+ * @property {boolean} closed - Whether a closing delimiter is present.
35
+ * @property {string[]} problems - Attribute syntax findings.
36
+ */
37
+
38
+ /** Recognize an opener without interpreting its body as Markdown blocks. */
39
+ function opener(line) {
40
+ const named = /^ *:::verse(?:[ \t]+(.*))?$/.exec(line);
41
+ const generic = /^ *:::[ \t]+(\{.*)$/.exec(line);
42
+ if (!named && !generic) return undefined;
43
+ const raw = (named?.[1] ?? generic?.[1] ?? "").trim();
44
+ const braced = /^\{[^\n]*\}$/.test(raw);
45
+ const parsed = parseExtensionAttributes(braced ? raw.slice(1, -1) : raw.replace(/^\{/, ""));
46
+ if (!named && !parsed.classes.includes("verse")) return undefined;
47
+ if (raw && !braced) parsed.problems.push("verse div attributes need {…} braces");
48
+ if (!parsed.classes.includes("verse")) parsed.classes.unshift("verse");
49
+ return parsed;
50
+ }
51
+
52
+ /**
53
+ * Install the opaque verse-div block rule. Tokens carry raw source regions.
54
+ * @param {import("markdown-it")} md - Markdown parser.
55
+ */
56
+ export function verseDivPlugin(md) {
57
+ md.block.ruler.before(
58
+ "code",
59
+ "verse_div",
60
+ (state, first, past, silent) => {
61
+ if (state.sCount[first] - state.blkIndent >= 4) return false;
62
+ const line = state.src.slice(
63
+ state.bMarks[first] + state.tShift[first],
64
+ state.eMarks[first],
65
+ );
66
+ const parsed = opener(line);
67
+ if (!parsed) return false;
68
+ if (silent) return true;
69
+ let close = first + 1;
70
+ while (close < past) {
71
+ const text = state.src.slice(
72
+ state.bMarks[close] + state.tShift[close],
73
+ state.eMarks[close],
74
+ );
75
+ if (/^ *:::[ \t]*$/.test(text)) break;
76
+ close++;
77
+ }
78
+ const closed = close < past;
79
+ const original = state.env.verseSource ?? state.src;
80
+ const rawLines = original.split(/\r\n|\r|\n/);
81
+ const starts = [0];
82
+ for (const match of original.matchAll(/\r\n|\r|\n/g))
83
+ starts.push(match.index + match[0].length);
84
+ const from = starts[first] ?? original.length;
85
+ const contentStarts = [];
86
+ for (let number = first; number < (closed ? close + 1 : past); number++) {
87
+ const normalizedStart = state.src.lastIndexOf("\n", state.bMarks[number] - 1) + 1;
88
+ contentStarts.push(
89
+ starts[number] + state.bMarks[number] + state.tShift[number] - normalizedStart,
90
+ );
91
+ }
92
+ const bodyStart = starts[first + 1] ?? original.length;
93
+ let bodyEnd = starts[close] ?? original.length;
94
+ if (bodyEnd > bodyStart) {
95
+ if (original[bodyEnd - 1] === "\n") bodyEnd--;
96
+ if (original[bodyEnd - 1] === "\r") bodyEnd--;
97
+ }
98
+ const region = {
99
+ start: first,
100
+ end: closed ? close : past - 1,
101
+ from,
102
+ contentStarts,
103
+ to: starts[closed ? close + 1 : past] ?? original.length,
104
+ bodyStart,
105
+ bodyEnd,
106
+ body: original.slice(bodyStart, bodyEnd),
107
+ attributes: parsed.values,
108
+ id: parsed.id,
109
+ classes: parsed.classes,
110
+ indent: /^ */.exec(rawLines[first] ?? "")[0],
111
+ name: "verse",
112
+ closed,
113
+ problems: parsed.problems,
114
+ };
115
+ const token = state.push("verse_div", "div", 0);
116
+ token.block = true;
117
+ token.map = [first, closed ? close + 1 : past];
118
+ token.content = region.body;
119
+ token.meta = { verseRegion: region };
120
+ state.line = closed ? close + 1 : past;
121
+ return true;
122
+ },
123
+ { alt: ["paragraph", "reference", "blockquote", "list"] },
124
+ );
125
+ }
126
+
127
+ const parser = new MarkdownIt({ html: true }).use(verseDivPlugin);
128
+ parser.core.ruler.disable(["inline", "linkify", "replacements", "smartquotes", "text_join"]);
129
+
130
+ /**
131
+ * Locate complete raw verse divs while leaving literal examples untouched.
132
+ * @param {string} source - Authored Markdown.
133
+ * @returns {VerseRegion[]} Regions in source order, including unclosed divs.
134
+ */
135
+ export function verseRegions(source) {
136
+ return parser
137
+ .parse(source, { verseSource: source })
138
+ .filter((token) => token.type === "verse_div")
139
+ .map((token) => token.meta.verseRegion);
140
+ }
141
+
142
+ /**
143
+ * Source ranges belonging to inline syntax rather than visible verse text.
144
+ * @param {string} source - Inline Markdown.
145
+ * @returns {Array<[number,number]>} Protected UTF-16 ranges.
146
+ */
147
+ export function inlineSyntaxRanges(source) {
148
+ const ranges = [];
149
+ for (const match of source.matchAll(new RegExp(WIKILINK.source, "g"))) {
150
+ const separator = match[0].indexOf("|");
151
+ const close = match[0].indexOf("]]");
152
+ ranges.push([match.index, match.index + (separator < 0 ? close + 2 : separator + 1)]);
153
+ ranges.push([match.index + close, match.index + match[0].length]);
154
+ }
155
+ for (let at = 0; at < source.length; at++) {
156
+ if (source[at] === "\\") {
157
+ at++;
158
+ continue;
159
+ }
160
+ const char = source[at];
161
+ let past = -1;
162
+ if (char === "⟦") past = source.indexOf("⟧", at + 1) + 1;
163
+ else if (char === "`") {
164
+ const mark = /^`+/.exec(source.slice(at))[0];
165
+ const close = source.indexOf(mark, at + mark.length);
166
+ if (close >= 0) past = close + mark.length;
167
+ } else if (source.startsWith("<!--", at)) past = source.indexOf("-->", at + 4) + 3;
168
+ else if (char === "<") past = source.indexOf(">", at + 1) + 1;
169
+ else if (char === "{") past = source.indexOf("}", at + 1) + 1;
170
+ else if (char === "(" && source[at - 1] === "]") {
171
+ let depth = 1;
172
+ for (let next = at + 1; next < source.length; next++) {
173
+ if (source[next] === "\\") {
174
+ next++;
175
+ continue;
176
+ }
177
+ if (source[next] === "(") depth++;
178
+ if (source[next] === ")" && !--depth) {
179
+ past = next + 1;
180
+ break;
181
+ }
182
+ }
183
+ }
184
+ if (past > at) {
185
+ ranges.push([at, past]);
186
+ at = past - 1;
187
+ }
188
+ }
189
+ return ranges.sort((a, b) => a[0] - b[0] || b[1] - a[1]);
190
+ }