@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.
- package/CHANGELOG.md +26 -0
- package/README.md +1 -1
- package/assets/styles/content-base.css +97 -116
- package/docs/api.md +30 -6
- package/docs/authoring/links-and-markup.md +214 -126
- package/docs/commands.md +2 -2
- package/docs/content-format.md +31 -6
- package/docs/diagnostics.md +27 -27
- package/docs/guides/writing-in-invented-scripts.md +89 -83
- package/docs/reference/format-details.md +6 -7
- package/docs/reference/note-types.md +15 -15
- package/docs/reference/theme-pages.md +38 -57
- package/engine/anchors.mjs +14 -0
- package/engine/code-fences.mjs +15 -6
- package/engine/content-blocks.mjs +7 -4
- package/engine/content-figures.mjs +19 -5
- package/engine/content-footnotes.mjs +60 -1
- package/engine/content-index.mjs +1 -1
- package/engine/content-render.mjs +66 -48
- package/engine/content-scripts.mjs +357 -101
- package/engine/content-spans.mjs +76 -10
- package/engine/content-verse.mjs +687 -358
- package/engine/first-use.mjs +43 -12
- package/engine/heading-attributes.mjs +2 -1
- package/engine/journals.mjs +8 -1
- package/engine/markdown-blocks.mjs +3 -2
- package/engine/markup-validation.mjs +17 -0
- package/engine/name-markup.mjs +1 -0
- package/engine/note-vocabulary.mjs +2 -2
- package/engine/pdf-render.mjs +89 -130
- package/engine/prose-lint.mjs +14 -2
- package/engine/roman-verse.mjs +68 -57
- package/engine/run-rows.mjs +1 -1
- package/engine/sql-tables.mjs +1 -1
- package/engine/verse-format.mjs +77 -0
- package/engine/verse-metre.mjs +49 -47
- package/engine/verse-regions.mjs +181 -0
- package/engine/wikilink-pattern.mjs +31 -0
- package/engine/wikilink-syntax.mjs +2 -16
- package/package.json +1 -1
- package/site-theme/static/css/style.css +68 -96
- package/types/engine/code-fences.d.mts +1 -3
- package/types/engine/content-blocks.d.mts +3 -1
- package/types/engine/content-footnotes.d.mts +23 -0
- package/types/engine/content-scripts.d.mts +12 -7
- package/types/engine/content-spans.d.mts +3 -1
- package/types/engine/content-verse.d.mts +263 -82
- package/types/engine/name-markup.d.mts +5 -0
- package/types/engine/roman-verse.d.mts +7 -6
- package/types/engine/verse-format.d.mts +9 -0
- package/types/engine/verse-metre.d.mts +4 -4
- package/types/engine/verse-regions.d.mts +78 -0
- package/types/engine/wikilink-pattern.d.mts +17 -0
- package/types/engine/wikilink-syntax.d.mts +1 -41
package/engine/roman-verse.mjs
CHANGED
|
@@ -12,16 +12,15 @@
|
|
|
12
12
|
*/
|
|
13
13
|
|
|
14
14
|
/** Logical verse text and separately located structural boundaries. @module */
|
|
15
|
-
import {
|
|
15
|
+
import { parseVerse } from "./content-verse.mjs";
|
|
16
16
|
import { plainRomanRun } from "./roman-run.mjs";
|
|
17
17
|
|
|
18
18
|
/**
|
|
19
|
-
*
|
|
20
|
-
*
|
|
21
|
-
*
|
|
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] & {
|
|
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
|
-
|
|
31
|
+
model: sharedModel,
|
|
32
|
+
modelOffset = from,
|
|
33
33
|
} = options;
|
|
34
|
-
const
|
|
35
|
-
const
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
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
|
-
|
|
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
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
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
|
}
|
package/engine/run-rows.mjs
CHANGED
|
@@ -12,7 +12,7 @@
|
|
|
12
12
|
*/
|
|
13
13
|
|
|
14
14
|
/**
|
|
15
|
-
* Spans, divs and verse
|
|
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.
|
package/engine/sql-tables.mjs
CHANGED
|
@@ -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
|
|
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
|
+
}
|
package/engine/verse-metre.mjs
CHANGED
|
@@ -12,7 +12,7 @@
|
|
|
12
12
|
*/
|
|
13
13
|
|
|
14
14
|
/**
|
|
15
|
-
* **A verse
|
|
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
|
|
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 {
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
const
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
)
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
{
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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",
|