@heroiclands/package-build 25.2.1 → 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 (54) hide show
  1. package/CHANGELOG.md +26 -0
  2. package/README.md +1 -1
  3. package/assets/styles/content-base.css +97 -116
  4. package/docs/api.md +30 -6
  5. package/docs/authoring/links-and-markup.md +214 -126
  6. package/docs/commands.md +2 -2
  7. package/docs/content-format.md +31 -6
  8. package/docs/diagnostics.md +27 -27
  9. package/docs/guides/writing-in-invented-scripts.md +89 -83
  10. package/docs/reference/format-details.md +6 -7
  11. package/docs/reference/note-types.md +15 -15
  12. package/docs/reference/theme-pages.md +38 -57
  13. package/engine/anchors.mjs +14 -0
  14. package/engine/code-fences.mjs +15 -6
  15. package/engine/content-blocks.mjs +7 -4
  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 +66 -48
  20. package/engine/content-scripts.mjs +357 -101
  21. package/engine/content-spans.mjs +76 -10
  22. package/engine/content-verse.mjs +687 -358
  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 +89 -130
  31. package/engine/prose-lint.mjs +14 -2
  32. package/engine/roman-verse.mjs +68 -57
  33. package/engine/run-rows.mjs +1 -1
  34. package/engine/sql-tables.mjs +1 -1
  35. package/engine/verse-format.mjs +77 -0
  36. package/engine/verse-metre.mjs +49 -47
  37. package/engine/verse-regions.mjs +181 -0
  38. package/engine/wikilink-pattern.mjs +31 -0
  39. package/engine/wikilink-syntax.mjs +2 -16
  40. package/package.json +1 -1
  41. package/site-theme/static/css/style.css +68 -96
  42. package/types/engine/code-fences.d.mts +1 -3
  43. package/types/engine/content-blocks.d.mts +3 -1
  44. package/types/engine/content-footnotes.d.mts +23 -0
  45. package/types/engine/content-scripts.d.mts +12 -7
  46. package/types/engine/content-spans.d.mts +3 -1
  47. package/types/engine/content-verse.d.mts +263 -82
  48. package/types/engine/name-markup.d.mts +5 -0
  49. package/types/engine/roman-verse.d.mts +7 -6
  50. package/types/engine/verse-format.d.mts +9 -0
  51. package/types/engine/verse-metre.d.mts +4 -4
  52. package/types/engine/verse-regions.d.mts +78 -0
  53. package/types/engine/wikilink-pattern.d.mts +17 -0
  54. package/types/engine/wikilink-syntax.d.mts +1 -41
@@ -12,16 +12,15 @@
12
12
  */
13
13
 
14
14
  /** Logical verse text and separately located structural boundaries. @module */
15
- import { verseLine } from "./content-verse.mjs";
15
+ import { parseVerse } from "./content-verse.mjs";
16
16
  import { plainRomanRun } from "./roman-run.mjs";
17
17
 
18
18
  /**
19
- * Remove visual indentation before checking or encoding Roman verse. Each
20
- * content line keeps its own boundary, including the last line before a stanza
21
- * or canto; those extra structural controls do not consume its line boundary.
22
- * The caller writes the boundaries through its selected encoding.
19
+ * Project verse text with explicit authored line, stanza and canto boundaries.
20
+ * Source newlines within a logical line are whitespace. Delimiters and visual
21
+ * indentation do not reach the encoder as letters.
23
22
  * @param {string} source - Complete authored source.
24
- * @param {Parameters<typeof plainRomanRun>[1] & {isCantoLine?: (line: string) => boolean}} [options] - Run bounds, spans and layout projection.
23
+ * @param {Parameters<typeof plainRomanRun>[1] & {model?: ReturnType<typeof parseVerse>, modelOffset?: number}} [options] - Run bounds, spans and layout projection.
25
24
  * @returns {ReturnType<typeof plainRomanRun> & {boundaries: Array<{kind: "line"|"stanza"|"canto", at: number, sourceOffset: number}>, errors: Array<{offset: number, message: string}>}} Visible text, positions and structural events.
26
25
  */
27
26
  export function plainRomanVerse(source, options = {}) {
@@ -29,28 +28,60 @@ export function plainRomanVerse(source, options = {}) {
29
28
  from = 0,
30
29
  to = source.length,
31
30
  exclude = [],
32
- isCantoLine = (line) => /^\s*---\s+\{\.canto\}\s*$/.test(line),
31
+ model: sharedModel,
32
+ modelOffset = from,
33
33
  } = options;
34
- const lines = [];
35
- const layout = [...exclude];
36
- let at = from;
37
- for (const match of source.slice(from, to).matchAll(/([^\r\n]*)(\r\n|\r|\n|$)/g)) {
38
- if (!match[0]) continue;
39
- const line = match[1];
40
- const end = at + line.length;
41
- const kind =
42
- isCantoLine(line) ? "canto"
43
- : line.trim() ? "content"
44
- : "blank";
45
- lines.push({ from: at, to: end, kind });
46
- if (kind === "canto") layout.push([at, end]);
47
- else if (kind === "content") {
48
- const { markerStart, markerLength } = verseLine(line);
49
- if (markerLength) layout.push([at + markerStart, at + markerStart + markerLength]);
50
- }
51
- at += match[0].length;
34
+ const model = sharedModel ?? parseVerse(source.slice(from, to));
35
+ const base = sharedModel ? modelOffset : from;
36
+ const boundaries = model.boundaries
37
+ .map((boundary) => ({ ...boundary, sourceOffset: base + boundary.sourceOffset }))
38
+ .filter((boundary) => boundary.sourceOffset >= from && boundary.sourceOffset <= to);
39
+ const projected = plainRomanRun(source, {
40
+ ...options,
41
+ from,
42
+ to,
43
+ exclude: [
44
+ ...exclude,
45
+ ...model.layout
46
+ .map(([first, past]) => [Math.max(from, base + first), Math.min(to, base + past)])
47
+ .filter(([first, past]) => past > first),
48
+ ],
49
+ });
50
+ const text = [],
51
+ offsets = [],
52
+ positions = [],
53
+ ends = [];
54
+ const pending = [...boundaries];
55
+ let boundaryIndex = 0;
56
+ const appendBoundary = (boundary) => {
57
+ text.push("\n");
58
+ offsets.push(boundary.sourceOffset);
59
+ };
60
+ for (let index = 0; index < projected.text.length; index++) {
61
+ ends[index] = text.length;
62
+ while (
63
+ pending[boundaryIndex] &&
64
+ pending[boundaryIndex].sourceOffset <= projected.offsets[index]
65
+ )
66
+ appendBoundary(pending[boundaryIndex++]);
67
+ positions[index] = text.length;
68
+ const char = projected.text[index];
69
+ if (char === "\n" && projected.text[index - 1] === "\r") continue;
70
+ text.push(/[\r\n]/.test(char) ? " " : char);
71
+ offsets.push(projected.offsets[index]);
52
72
  }
53
- const projected = plainRomanRun(source, { ...options, from, to, exclude: layout });
73
+ ends[projected.text.length] = text.length;
74
+ while (pending[boundaryIndex]) appendBoundary(pending[boundaryIndex++]);
75
+ positions[projected.text.length] = text.length;
76
+ projected.text = text.join("");
77
+ projected.offsets = offsets;
78
+ projected.spans = projected.spans.map((span) => ({
79
+ ...span,
80
+ from: positions[span.from],
81
+ to: ends[span.to],
82
+ contentFrom: positions[span.contentFrom],
83
+ contentTo: ends[span.contentTo],
84
+ }));
54
85
  const logicalAt = (offset) => {
55
86
  let first = 0,
56
87
  past = projected.offsets.length;
@@ -61,35 +92,15 @@ export function plainRomanVerse(source, options = {}) {
61
92
  }
62
93
  return first;
63
94
  };
64
- /** @type {Array<{kind: "line"|"stanza"|"canto", at: number, sourceOffset: number}>} */
65
- const boundaries = [];
66
- for (let index = 0; index < lines.length; index++) {
67
- const line = lines[index];
68
- if (line.kind === "content")
69
- boundaries.push({ kind: "line", at: logicalAt(line.to), sourceOffset: line.to });
70
- else if (line.kind === "canto")
71
- boundaries.push({ kind: "canto", at: logicalAt(line.from), sourceOffset: line.from });
72
- else if (lines[index - 1]?.kind === "content") {
73
- const following = lines.slice(index + 1).find((next) => next.kind !== "blank");
74
- if (following?.kind === "content")
75
- boundaries.push({
76
- kind: "stanza",
77
- at: logicalAt(line.from),
78
- sourceOffset: line.from,
79
- });
80
- }
81
- }
82
- const errors = [];
83
- for (let index = 0; index < projected.text.length; index++) {
84
- if (projected.text[index] === "⟦") {
85
- const close = projected.text.indexOf("⟧", index + 1);
86
- index = close < 0 ? projected.text.length : close;
87
- } else if (projected.text[index] === "/")
88
- errors.push({
89
- offset: projected.offsets[index],
90
- message:
91
- "a verse fence uses authored line endings for verse boundaries; write no literal `/` inside it",
92
- });
93
- }
94
- return { ...projected, boundaries, errors };
95
+ return {
96
+ ...projected,
97
+ boundaries: boundaries.map(({ kind, sourceOffset }) => ({
98
+ kind,
99
+ sourceOffset,
100
+ at: logicalAt(sourceOffset),
101
+ })),
102
+ errors: model.errors
103
+ .map(({ offset, message }) => ({ offset: base + offset, message }))
104
+ .filter((error) => error.offset >= from && error.offset < to),
105
+ };
95
106
  }
@@ -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.
@@ -60,7 +60,7 @@ import { WORD } from "./word-pattern.mjs";
60
60
 
61
61
  import MarkdownIt from "markdown-it";
62
62
  import { spanMarkdownPlugin } from "./content-spans.mjs";
63
- import { STANZA_BREAK, verseLine } from "./content-verse.mjs";
63
+ import { parseVerse } from "./content-verse.mjs";
64
64
  import { inlineText } from "./name-markup.mjs";
65
65
 
66
66
  const verseParser = new MarkdownIt().use(spanMarkdownPlugin);
@@ -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.
@@ -311,13 +311,6 @@ export function metreFindings(blocks, lines, ctx) {
311
311
  column: ctx.attributeColumns(opening, key)[part],
312
312
  });
313
313
  const error = (position, message) => errors.push({ ...position, message });
314
- if (Number(block.attributes.cols ?? 1) > 1) {
315
- error(
316
- at("scan", "key"),
317
- "`scan=` counts the beats of one language's lines; a verse with cols>1 groups independent physical lines",
318
- );
319
- continue;
320
- }
321
314
  const metre = metres.get(name);
322
315
  if (!metre) {
323
316
  const near = nearest(name, [...metres.keys()]);
@@ -332,7 +325,7 @@ export function metreFindings(blocks, lines, ctx) {
332
325
  if (lang === undefined) {
333
326
  error(
334
327
  at("scan", "key"),
335
- "`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=`",
336
329
  );
337
330
  continue;
338
331
  }
@@ -346,40 +339,49 @@ export function metreFindings(blocks, lines, ctx) {
346
339
  );
347
340
  continue;
348
341
  }
349
- for (let line = block.start + 1; line < block.end; line++) {
350
- const text = lines[line];
351
- if (!text.trim() || STANZA_BREAK.test(text.trim())) continue;
352
- const reading = verseLine(text);
353
- const tokens = verseParser.parseInline(reading.text, {})[0]?.children ?? [];
354
- const tag = (name) => (ctx.languages?.[name]?.tag ?? name).toLowerCase();
355
- const foreign = tokens.find(
356
- (token) =>
357
- token.type === "span_open" &&
358
- token.attrGet("lang") &&
359
- tag(token.attrGet("lang")) !== tag(lang),
360
- );
361
- if (foreign) {
362
- error(
363
- {
364
- line: line + 1,
365
- column:
366
- reading.textColumn +
367
- foreign.meta.start +
368
- (reading.markerStart <= reading.textColumn - 1 + foreign.meta.start ?
369
- reading.markerLength
370
- : 0),
371
- },
372
- "`scan=` counts one language's beats; this verse line contains another language",
342
+ const body = lines.slice(block.start + 1, block.end).join("\n");
343
+ const model = block.model ?? parseVerse(body);
344
+ const sourcePosition = (offset) => {
345
+ const preceding = body.slice(0, offset);
346
+ return {
347
+ line: block.start + 2 + preceding.split("\n").length - 1,
348
+ column: preceding.length - preceding.lastIndexOf("\n"),
349
+ };
350
+ };
351
+ for (const group of model.groups) {
352
+ if (group.cantoBreak) continue;
353
+ for (const reading of group.lines) {
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 ?? [];
364
+ const tag = (name) => (ctx.languages?.[name]?.tag ?? name).toLowerCase();
365
+ const foreign = tokens.find(
366
+ (token) =>
367
+ token.type === "span_open" &&
368
+ token.attrGet("lang") &&
369
+ tag(token.attrGet("lang")) !== tag(lang),
373
370
  );
374
- continue;
371
+ if (foreign) {
372
+ error(
373
+ sourcePosition(units[foreign.meta.start]?.offset ?? reading.from),
374
+ "`scan=` counts one language's beats; this verse line contains another language",
375
+ );
376
+ continue;
377
+ }
378
+ const beats = lineBeats(inlineText(tokens), book);
379
+ if (metre.beats >= beats.min && metre.beats <= beats.max) continue;
380
+ warnings.push({
381
+ ...sourcePosition(reading.from),
382
+ message: metreMessage(beats, metre),
383
+ });
375
384
  }
376
- const beats = lineBeats(inlineText(tokens), book);
377
- if (metre.beats >= beats.min && metre.beats <= beats.max) continue;
378
- warnings.push({
379
- line: line + 1,
380
- column: /^\s*/.exec(text)[0].length + 1,
381
- message: metreMessage(beats, metre),
382
- });
383
385
  }
384
386
  }
385
387
  return { errors, warnings };
@@ -0,0 +1,181 @@
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} bodyStart - First body offset.
26
+ * @property {number} bodyEnd - Offset past body text, excluding its final newline.
27
+ * @property {string} body - Authored body bytes.
28
+ * @property {Record<string,string>} attributes - Authored values.
29
+ * @property {string} id - Anchor or empty string.
30
+ * @property {string[]} classes - Authored classes including verse.
31
+ * @property {string} indent - Opening indentation.
32
+ * @property {"verse"} name - Region kind.
33
+ * @property {boolean} closed - Whether a closing delimiter is present.
34
+ * @property {string[]} problems - Attribute syntax findings.
35
+ */
36
+
37
+ /** Recognize an opener without interpreting its body as Markdown blocks. */
38
+ function opener(line) {
39
+ const named = /^ *:::verse(?:[ \t]+(.*))?$/.exec(line);
40
+ const generic = /^ *:::[ \t]+(\{.*)$/.exec(line);
41
+ if (!named && !generic) return undefined;
42
+ const raw = (named?.[1] ?? generic?.[1] ?? "").trim();
43
+ const braced = /^\{[^\n]*\}$/.test(raw);
44
+ const parsed = parseExtensionAttributes(braced ? raw.slice(1, -1) : raw.replace(/^\{/, ""));
45
+ if (!named && !parsed.classes.includes("verse")) return undefined;
46
+ if (raw && !braced) parsed.problems.push("verse div attributes need {…} braces");
47
+ if (!parsed.classes.includes("verse")) parsed.classes.unshift("verse");
48
+ return parsed;
49
+ }
50
+
51
+ /**
52
+ * Install the opaque verse-div block rule. Tokens carry raw source regions.
53
+ * @param {import("markdown-it")} md - Markdown parser.
54
+ */
55
+ export function verseDivPlugin(md) {
56
+ md.block.ruler.before(
57
+ "code",
58
+ "verse_div",
59
+ (state, first, past, silent) => {
60
+ if (state.sCount[first] - state.blkIndent >= 4) return false;
61
+ const line = state.src.slice(
62
+ state.bMarks[first] + state.tShift[first],
63
+ state.eMarks[first],
64
+ );
65
+ const parsed = opener(line);
66
+ if (!parsed) return false;
67
+ if (silent) return true;
68
+ let close = first + 1;
69
+ while (close < past) {
70
+ const text = state.src.slice(
71
+ state.bMarks[close] + state.tShift[close],
72
+ state.eMarks[close],
73
+ );
74
+ if (/^ *:::[ \t]*$/.test(text)) break;
75
+ close++;
76
+ }
77
+ const closed = close < past;
78
+ const original = state.env.verseSource ?? state.src;
79
+ const rawLines = original.split(/\r\n|\r|\n/);
80
+ const starts = [0];
81
+ for (const match of original.matchAll(/\r\n|\r|\n/g))
82
+ starts.push(match.index + match[0].length);
83
+ const from = starts[first] ?? original.length;
84
+ const bodyStart = starts[first + 1] ?? original.length;
85
+ let bodyEnd = starts[close] ?? original.length;
86
+ if (bodyEnd > bodyStart) {
87
+ if (original[bodyEnd - 1] === "\n") bodyEnd--;
88
+ if (original[bodyEnd - 1] === "\r") bodyEnd--;
89
+ }
90
+ const region = {
91
+ start: first,
92
+ end: closed ? close : past - 1,
93
+ from,
94
+ to: starts[closed ? close + 1 : past] ?? original.length,
95
+ bodyStart,
96
+ bodyEnd,
97
+ body: original.slice(bodyStart, bodyEnd),
98
+ attributes: parsed.values,
99
+ id: parsed.id,
100
+ classes: parsed.classes,
101
+ indent: /^ */.exec(rawLines[first] ?? "")[0],
102
+ name: "verse",
103
+ closed,
104
+ problems: parsed.problems,
105
+ };
106
+ const token = state.push("verse_div", "div", 0);
107
+ token.block = true;
108
+ token.map = [first, closed ? close + 1 : past];
109
+ token.content = region.body;
110
+ token.meta = { verseRegion: region };
111
+ state.line = closed ? close + 1 : past;
112
+ return true;
113
+ },
114
+ { alt: ["paragraph", "reference", "blockquote", "list"] },
115
+ );
116
+ }
117
+
118
+ const parser = new MarkdownIt({ html: true }).use(verseDivPlugin);
119
+ parser.core.ruler.disable(["inline", "linkify", "replacements", "smartquotes", "text_join"]);
120
+
121
+ /**
122
+ * Locate complete raw verse divs while leaving literal examples untouched.
123
+ * @param {string} source - Authored Markdown.
124
+ * @returns {VerseRegion[]} Regions in source order, including unclosed divs.
125
+ */
126
+ export function verseRegions(source) {
127
+ return parser
128
+ .parse(source, { verseSource: source })
129
+ .filter((token) => token.type === "verse_div")
130
+ .map((token) => token.meta.verseRegion);
131
+ }
132
+
133
+ /**
134
+ * Source ranges belonging to inline syntax rather than visible verse text.
135
+ * @param {string} source - Inline Markdown.
136
+ * @returns {Array<[number,number]>} Protected UTF-16 ranges.
137
+ */
138
+ export function inlineSyntaxRanges(source) {
139
+ const ranges = [];
140
+ for (const match of source.matchAll(new RegExp(WIKILINK.source, "g"))) {
141
+ const separator = match[0].indexOf("|");
142
+ const close = match[0].indexOf("]]");
143
+ ranges.push([match.index, match.index + (separator < 0 ? close + 2 : separator + 1)]);
144
+ ranges.push([match.index + close, match.index + match[0].length]);
145
+ }
146
+ for (let at = 0; at < source.length; at++) {
147
+ if (source[at] === "\\") {
148
+ at++;
149
+ continue;
150
+ }
151
+ const char = source[at];
152
+ let past = -1;
153
+ if (char === "⟦") past = source.indexOf("⟧", at + 1) + 1;
154
+ else if (char === "`") {
155
+ const mark = /^`+/.exec(source.slice(at))[0];
156
+ const close = source.indexOf(mark, at + mark.length);
157
+ if (close >= 0) past = close + mark.length;
158
+ } else if (source.startsWith("<!--", at)) past = source.indexOf("-->", at + 4) + 3;
159
+ else if (char === "<") past = source.indexOf(">", at + 1) + 1;
160
+ else if (char === "{") past = source.indexOf("}", at + 1) + 1;
161
+ else if (char === "(" && source[at - 1] === "]") {
162
+ let depth = 1;
163
+ for (let next = at + 1; next < source.length; next++) {
164
+ if (source[next] === "\\") {
165
+ next++;
166
+ continue;
167
+ }
168
+ if (source[next] === "(") depth++;
169
+ if (source[next] === ")" && !--depth) {
170
+ past = next + 1;
171
+ break;
172
+ }
173
+ }
174
+ }
175
+ if (past > at) {
176
+ ranges.push([at, past]);
177
+ at = past - 1;
178
+ }
179
+ }
180
+ return ranges.sort((a, b) => a[0] - b[0] || b[1] - a[1]);
181
+ }
@@ -0,0 +1,31 @@
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
+ /** The dependency-free lexical wikilink pattern. @module */
15
+
16
+ /**
17
+ * A wikilink, as authored.
18
+ *
19
+ * **Newlines are excluded deliberately.** A link is written on one line; an
20
+ * unclosed `[[` is a typo, and the alternative is letting it swallow arbitrary
21
+ * prose in search of a closer. Erring towards "not a link" leaves the author's
22
+ * text as written, which is the safe direction for a rewriter.
23
+ *
24
+ * **A leading `!` makes it an embed, not a link**, and the lookbehind is what
25
+ * keeps every reader from seeing one as the other. Stated here rather than in
26
+ * each reader for the reason the rest of this module exists: the checker, the
27
+ * resolver and the embed reader all match on this pattern, and a copy of the
28
+ * exclusion in each is a place for it to be forgotten. An embed's
29
+ * own grammar is {@link module:engine/content-embeds}.
30
+ */
31
+ export const WIKILINK = /(?<!!)\[\[([^\]\n]+)\]\](?:\{(?:[^{}"\n]|"(?:\\.|[^"\\\n])*")*\})?/g;
@@ -42,22 +42,8 @@ import { parseAddress, isAddressTuple, splitAnchor } from "./address.mjs";
42
42
  * @module
43
43
  */
44
44
 
45
- /**
46
- * A wikilink, as authored.
47
- *
48
- * **Newlines are excluded deliberately.** A link is written on one line; an
49
- * unclosed `[[` is a typo, and the alternative is letting it swallow arbitrary
50
- * prose in search of a closer. Erring towards "not a link" leaves the author's
51
- * text as written, which is the safe direction for a rewriter.
52
- *
53
- * **A leading `!` makes it an embed, not a link**, and the lookbehind is what
54
- * keeps every reader from seeing one as the other. Stated here rather than in
55
- * each reader for the reason the rest of this module exists: the checker, the
56
- * resolver and the embed reader all match on this pattern, and a copy of the
57
- * exclusion in each is a place for it to be forgotten. An embed's
58
- * own grammar is {@link module:engine/content-embeds}.
59
- */
60
- export const WIKILINK = /(?<!!)\[\[([^\]\n]+)\]\](?:\{(?:[^{}"\n]|"(?:\\.|[^"\\\n])*")*\})?/g;
45
+ import { WIKILINK } from "./wikilink-pattern.mjs";
46
+ export { WIKILINK } from "./wikilink-pattern.mjs";
61
47
 
62
48
  /**
63
49
  * The parts of a wikilink's interior.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@heroiclands/package-build",
3
- "version": "25.2.1",
3
+ "version": "25.4.0",
4
4
  "description": "Shared toolchain for building and shipping a HeroicLands Foundry VTT package \u2014 content compilation, manifest, localization, staging, bundle, release and deployment.",
5
5
  "license": "GPL-3.0-or-later",
6
6
  "type": "module",