@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.
- package/CHANGELOG.md +39 -0
- package/README.md +1 -1
- package/assets/styles/content-base.css +32 -0
- package/docs/api.md +30 -6
- package/docs/authoring/links-and-markup.md +143 -53
- package/docs/commands.md +8 -2
- package/docs/content-format.md +14 -6
- package/docs/diagnostics.md +12 -11
- package/docs/encoding-core.md +18 -4
- package/docs/guides/writing-in-invented-scripts.md +83 -37
- package/docs/reference/format-details.md +10 -11
- package/docs/reference/note-types.md +15 -15
- package/docs/reference/theme-pages.md +26 -18
- package/engine/anchors.mjs +14 -0
- package/engine/code-fences.mjs +15 -6
- package/engine/content-blocks.mjs +4 -2
- package/engine/content-figures.mjs +19 -5
- package/engine/content-footnotes.mjs +69 -1
- package/engine/content-index.mjs +1 -1
- package/engine/content-render.mjs +59 -43
- package/engine/content-scripts.mjs +301 -58
- package/engine/content-scroll.mjs +8 -3
- package/engine/content-spans.mjs +107 -6
- package/engine/content-verse.mjs +338 -198
- package/engine/encoding-standard.mjs +1 -0
- package/engine/encoding-template.mjs +5 -2
- package/engine/encoding.mjs +19 -14
- 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/markdownlint-run.mjs +190 -5
- 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 +34 -23
- package/engine/prose-lint.mjs +14 -2
- package/engine/roman-characters.mjs +32 -10
- package/engine/roman-run.mjs +3 -0
- package/engine/roman-verse.mjs +1 -0
- package/engine/run-rows.mjs +1 -1
- package/engine/sql-tables.mjs +1 -1
- package/engine/verse-format.mjs +89 -0
- package/engine/verse-metre.mjs +18 -9
- package/engine/verse-regions.mjs +190 -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 +24 -0
- package/types/engine/code-fences.d.mts +1 -3
- package/types/engine/content-footnotes.d.mts +23 -0
- package/types/engine/content-scripts.d.mts +4 -3
- package/types/engine/content-scroll.d.mts +1 -1
- package/types/engine/content-spans.d.mts +3 -1
- package/types/engine/content-verse.d.mts +75 -75
- package/types/engine/name-markup.d.mts +5 -0
- package/types/engine/roman-run.d.mts +3 -1
- package/types/engine/verse-format.d.mts +10 -0
- package/types/engine/verse-metre.d.mts +4 -4
- package/types/engine/verse-regions.d.mts +82 -0
- package/types/engine/wikilink-pattern.d.mts +17 -0
- package/types/engine/wikilink-syntax.d.mts +1 -41
package/engine/pdf-render.mjs
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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 :
|
|
518
|
-
|
|
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
|
-
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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+/);
|
package/engine/prose-lint.mjs
CHANGED
|
@@ -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 =
|
|
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 (
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
151
|
-
|
|
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 =
|
|
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
|
-
|
|
222
|
-
|
|
223
|
-
|
|
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)
|
package/engine/roman-run.mjs
CHANGED
|
@@ -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++;
|
package/engine/roman-verse.mjs
CHANGED
|
@@ -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: [
|
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,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
|
+
}
|
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.
|
|
@@ -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.
|
|
@@ -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
|
|
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
|
|
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(
|
|
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
|
+
}
|