@heroiclands/package-build 25.3.0 → 25.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +15 -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 +136 -53
- package/docs/commands.md +2 -2
- package/docs/content-format.md +14 -6
- package/docs/diagnostics.md +12 -11
- package/docs/guides/writing-in-invented-scripts.md +62 -35
- package/docs/reference/format-details.md +6 -6
- 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 +60 -1
- package/engine/content-index.mjs +1 -1
- package/engine/content-render.mjs +59 -43
- package/engine/content-scripts.mjs +204 -42
- package/engine/content-spans.mjs +73 -5
- package/engine/content-verse.mjs +320 -192
- 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 +34 -23
- package/engine/prose-lint.mjs +14 -2
- 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 +18 -9
- 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 +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-spans.d.mts +3 -1
- package/types/engine/content-verse.d.mts +69 -74
- package/types/engine/name-markup.d.mts +5 -0
- 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/first-use.mjs
CHANGED
|
@@ -45,6 +45,7 @@ import remarkFrontmatter from "remark-frontmatter";
|
|
|
45
45
|
import remarkGfm from "remark-gfm";
|
|
46
46
|
import MarkdownIt from "markdown-it";
|
|
47
47
|
import { readQualifier } from "./address.mjs";
|
|
48
|
+
import { verseRegions } from "./verse-regions.mjs";
|
|
48
49
|
import { scanBlocks } from "./content-blocks.mjs";
|
|
49
50
|
import { scanFigures } from "./content-figures.mjs";
|
|
50
51
|
import { RUN_KEYS } from "./content-scripts.mjs";
|
|
@@ -169,14 +170,37 @@ const overlaps = (values, value) => {
|
|
|
169
170
|
* Explicit inline occurrences, authored strong text and structural exclusions
|
|
170
171
|
* at original source offsets.
|
|
171
172
|
*/
|
|
172
|
-
function prose(source, capturedNames) {
|
|
173
|
+
function prose(source, capturedNames, capturedSpans) {
|
|
173
174
|
const links = [],
|
|
174
175
|
paragraphs = [],
|
|
175
176
|
regions = [],
|
|
176
177
|
excluded = [],
|
|
177
178
|
strong = [],
|
|
178
179
|
html = [];
|
|
179
|
-
const
|
|
180
|
+
const spans = capturedSpans ?? scanSpans(source);
|
|
181
|
+
const verseMasks = verseRegions(source).map((region) => ({
|
|
182
|
+
start: region.from,
|
|
183
|
+
end: region.to,
|
|
184
|
+
block: true,
|
|
185
|
+
}));
|
|
186
|
+
for (const span of spans.spans)
|
|
187
|
+
if (
|
|
188
|
+
span.verseModel ||
|
|
189
|
+
span.attrs.some(
|
|
190
|
+
([key, value]) => key === "class" && value.split(/\s+/).includes("verse"),
|
|
191
|
+
)
|
|
192
|
+
)
|
|
193
|
+
verseMasks.push({ start: span.start, end: span.end, block: false });
|
|
194
|
+
excluded.push(...verseMasks);
|
|
195
|
+
const verseRanges = verseMasks
|
|
196
|
+
.sort((a, b) => a.start - b.start || b.end - a.end)
|
|
197
|
+
.filter(
|
|
198
|
+
(range, index, all) =>
|
|
199
|
+
!all
|
|
200
|
+
.slice(0, index)
|
|
201
|
+
.some((parent) => parent.start <= range.start && parent.end >= range.end),
|
|
202
|
+
);
|
|
203
|
+
let masked = source
|
|
180
204
|
.replace(/!\[\[[^\]\n]+\]\]|\{\{[^}\n]+\}\}/g, (text, start) => {
|
|
181
205
|
excluded.push({ start, end: start + text.length });
|
|
182
206
|
return blank(text);
|
|
@@ -193,6 +217,11 @@ function prose(source, capturedNames) {
|
|
|
193
217
|
});
|
|
194
218
|
return "x".repeat(text.length);
|
|
195
219
|
});
|
|
220
|
+
for (const range of [...verseRanges].reverse())
|
|
221
|
+
masked =
|
|
222
|
+
masked.slice(0, range.start) +
|
|
223
|
+
masked.slice(range.start, range.end).replace(/[^\r\n]/g, range.block ? " " : "x") +
|
|
224
|
+
masked.slice(range.end);
|
|
196
225
|
const tree = parser.parse(masked);
|
|
197
226
|
const env = { references: Object.create(null) };
|
|
198
227
|
const inline = (node) => {
|
|
@@ -285,7 +314,7 @@ function prose(source, capturedNames) {
|
|
|
285
314
|
});
|
|
286
315
|
const authoredLinks = [...new Map(actualLinks.map((link) => [link.start, link])).values()];
|
|
287
316
|
const linked = ranges(authoredLinks);
|
|
288
|
-
const marked = (capturedNames ??
|
|
317
|
+
const marked = (capturedNames ?? spans.names)
|
|
289
318
|
.filter((span) => eligible(span) && !contains(linked, span))
|
|
290
319
|
.map((span) => ({
|
|
291
320
|
...span,
|
|
@@ -373,7 +402,7 @@ export function nameUses(markdown, vocabulary) {
|
|
|
373
402
|
// Every use carries the class, so a body without it is read no further.
|
|
374
403
|
if (!source.includes(".name")) return [];
|
|
375
404
|
const spans = scanSpans(source);
|
|
376
|
-
const { marked, links, strong, inParagraph } = prose(source, spans.names);
|
|
405
|
+
const { marked, links, strong, inParagraph } = prose(source, spans.names, spans);
|
|
377
406
|
const starts = [0];
|
|
378
407
|
for (let i = 0; i < source.length; i++) if (source[i] === "\n") starts.push(i + 1);
|
|
379
408
|
// A caption and a named block's opening and closing lines are not prose.
|
|
@@ -527,14 +556,16 @@ export function createFirstUseChecker({
|
|
|
527
556
|
const { recognize } = catalog;
|
|
528
557
|
return ({ file, raw }) => {
|
|
529
558
|
const authored = parsed?.get(file);
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
)
|
|
536
|
-
|
|
537
|
-
|
|
559
|
+
let captured;
|
|
560
|
+
if (authored?.raw === raw && Number.isInteger(authored.bodyLine)) {
|
|
561
|
+
const inventory = syntax ? syntax(authored.body).spans : scanSpans(authored.body);
|
|
562
|
+
const context = { raw, bodyLine: authored.bodyLine, bodyColumn: authored.bodyColumn };
|
|
563
|
+
captured = {
|
|
564
|
+
names: rebaseNameSpans(inventory.names, context),
|
|
565
|
+
spans: rebaseNameSpans(inventory.spans, context),
|
|
566
|
+
};
|
|
567
|
+
}
|
|
568
|
+
const { marked } = prose(raw, captured?.names, captured);
|
|
538
569
|
const findings = new Map();
|
|
539
570
|
const add = (offset, rule, message) => {
|
|
540
571
|
if (!findings.has(offset)) findings.set(offset, { rules: [], messages: [] });
|
|
@@ -40,7 +40,8 @@ import { blockTokens } from "./markdown-blocks.mjs";
|
|
|
40
40
|
function literalHeadingLines(source) {
|
|
41
41
|
const lines = new Set();
|
|
42
42
|
for (const token of blockTokens(source)) {
|
|
43
|
-
if (!token.map || !["fence", "code_block", "html_block"].includes(token.type))
|
|
43
|
+
if (!token.map || !["fence", "code_block", "html_block", "verse_div"].includes(token.type))
|
|
44
|
+
continue;
|
|
44
45
|
for (let line = token.map[0]; line < token.map[1]; line++) lines.add(line);
|
|
45
46
|
}
|
|
46
47
|
return lines;
|
package/engine/journals.mjs
CHANGED
|
@@ -76,6 +76,7 @@ import { journalHasContent } from "./note-state.mjs";
|
|
|
76
76
|
import { hasLexicalEntries } from "./lexical-sections.mjs";
|
|
77
77
|
import { draftNoticeFor } from "./draft-notice.mjs";
|
|
78
78
|
import { scanFigures } from "./content-figures.mjs";
|
|
79
|
+
import { verseRegions } from "./verse-regions.mjs";
|
|
79
80
|
import { separateFootnotes } from "./content-footnotes.mjs";
|
|
80
81
|
import { WITHHELD_CLASS, pageOpenings } from "./heading-attributes.mjs";
|
|
81
82
|
import { imagesIn } from "./content-images.mjs";
|
|
@@ -154,6 +155,12 @@ export function splitPages(body, leadName = "Introduction", resolveRole, syntax)
|
|
|
154
155
|
scanFigures(markdown, { resolveRole }).figures.map((figure) => [figure.line - 1, figure]),
|
|
155
156
|
);
|
|
156
157
|
const blockLines = namedBlockLines(markdown, syntax);
|
|
158
|
+
const verseLines = new Set();
|
|
159
|
+
for (const region of verseRegions(markdown))
|
|
160
|
+
for (let line = region.start; line <= region.end; line++) {
|
|
161
|
+
blockLines.add(line);
|
|
162
|
+
verseLines.add(line);
|
|
163
|
+
}
|
|
157
164
|
const openings = pageOpenings(markdown, blockLines);
|
|
158
165
|
const pages = [];
|
|
159
166
|
const beforeFirstH1 = [];
|
|
@@ -189,7 +196,7 @@ export function splitPages(body, leadName = "Introduction", resolveRole, syntax)
|
|
|
189
196
|
// carry a shorter fence of its own as literal content, same as
|
|
190
197
|
// {@link module:engine/content-blocks.scanBlocks} and
|
|
191
198
|
// {@link module:engine/content-figures.scanFigures} track it.
|
|
192
|
-
const fence = /^ {0,3}(`{3,}|~{3,})/.exec(line);
|
|
199
|
+
const fence = verseLines.has(lineIndex) ? null : /^ {0,3}(`{3,}|~{3,})/.exec(line);
|
|
193
200
|
if (codeFence) {
|
|
194
201
|
if (fence && fence[1][0] === codeFence[0] && fence[1].length >= codeFence.length) {
|
|
195
202
|
codeFence = null;
|
|
@@ -25,9 +25,10 @@
|
|
|
25
25
|
*/
|
|
26
26
|
|
|
27
27
|
import MarkdownIt from "markdown-it";
|
|
28
|
+
import { verseDivPlugin } from "./verse-regions.mjs";
|
|
28
29
|
|
|
29
30
|
/** The renderer's block grammar, with inline parsing and its passes switched off. */
|
|
30
|
-
const parser = new MarkdownIt({ html: true });
|
|
31
|
+
const parser = new MarkdownIt({ html: true }).use(verseDivPlugin);
|
|
31
32
|
parser.core.ruler.disable(["inline", "linkify", "replacements", "smartquotes", "text_join"]);
|
|
32
33
|
|
|
33
34
|
/** How many recent sources keep their tokens. */
|
|
@@ -51,7 +52,7 @@ export function blockTokens(source) {
|
|
|
51
52
|
recent.set(text, hit);
|
|
52
53
|
return hit;
|
|
53
54
|
}
|
|
54
|
-
const tokens = parser.parse(text, {});
|
|
55
|
+
const tokens = parser.parse(text, { verseSource: text });
|
|
55
56
|
recent.set(text, tokens);
|
|
56
57
|
if (recent.size > RECENT) recent.delete(recent.keys().next().value);
|
|
57
58
|
return tokens;
|
|
@@ -14,6 +14,8 @@
|
|
|
14
14
|
import { scanBlocks } from "./content-blocks.mjs";
|
|
15
15
|
import { scanAlerts } from "./content-alerts.mjs";
|
|
16
16
|
import { scanRuns } from "./content-scripts.mjs";
|
|
17
|
+
import { scanSpans } from "./content-spans.mjs";
|
|
18
|
+
import { verseRegions } from "./verse-regions.mjs";
|
|
17
19
|
import { matchAllOutsideCode } from "./code-fences.mjs";
|
|
18
20
|
|
|
19
21
|
/** The opening of a shortcode: `{{<` or `{{%`. */
|
|
@@ -55,9 +57,24 @@ export function markupFindings(source, file, syntax, runs) {
|
|
|
55
57
|
);
|
|
56
58
|
const scanned = syntax?.(body);
|
|
57
59
|
const marked = runs ? scanRuns(body, runs, syntax) : undefined;
|
|
60
|
+
const spans = scanned?.spans ?? scanSpans(body);
|
|
61
|
+
const verseRanges = [
|
|
62
|
+
...verseRegions(body).map((region) => [region.from, region.to]),
|
|
63
|
+
...spans.spans.filter((span) => span.verseModel).map((span) => [span.start, span.end]),
|
|
64
|
+
];
|
|
65
|
+
const starts = [0];
|
|
66
|
+
for (const match of body.matchAll(/\n/g)) starts.push(match.index + 1);
|
|
67
|
+
const verseErrors = spans.errors.filter((error) => {
|
|
68
|
+
const offset = (starts[error.line - 1] ?? 0) + error.column - 1;
|
|
69
|
+
return (
|
|
70
|
+
error.message.includes(".verse") ||
|
|
71
|
+
verseRanges.some(([from, to]) => offset >= from && offset < to)
|
|
72
|
+
);
|
|
73
|
+
});
|
|
58
74
|
const errors = [
|
|
59
75
|
...(scanned?.blocks ?? scanBlocks(body)).errors,
|
|
60
76
|
...(scanned?.alerts ?? scanAlerts(body)).errors,
|
|
77
|
+
...verseErrors,
|
|
61
78
|
...(marked?.errors ?? []),
|
|
62
79
|
...shortcodeErrors(body),
|
|
63
80
|
].map((error) => ({
|
package/engine/name-markup.mjs
CHANGED
|
@@ -42,6 +42,7 @@
|
|
|
42
42
|
* @property {string} visible - Visible text from the actual parsed children.
|
|
43
43
|
* @property {ReadonlyArray<readonly [string, string]>} attrs - Retained permitted HTML attributes.
|
|
44
44
|
* @property {Record<string, SpanAttributePosition>} attributePositions - Authored key/value sites by retained attribute name.
|
|
45
|
+
* @property {ReturnType<typeof import("./content-verse.mjs").parseVerse>} [verseModel] - Logical model of an inline verse quotation.
|
|
45
46
|
*/
|
|
46
47
|
|
|
47
48
|
/**
|
|
@@ -2338,7 +2338,7 @@ export const NOTE_VOCABULARY = Object.freeze({
|
|
|
2338
2338
|
...TEXT,
|
|
2339
2339
|
required: true,
|
|
2340
2340
|
describe:
|
|
2341
|
-
"What a verse
|
|
2341
|
+
"What a verse div's `scan=` names the metre by: lowercase " +
|
|
2342
2342
|
"letters, digits and `-`, unique in the package.",
|
|
2343
2343
|
},
|
|
2344
2344
|
{
|
|
@@ -2362,7 +2362,7 @@ export const NOTE_VOCABULARY = Object.freeze({
|
|
|
2362
2362
|
]),
|
|
2363
2363
|
}),
|
|
2364
2364
|
describe:
|
|
2365
|
-
"The metres a verse
|
|
2365
|
+
"The metres a verse div's `scan=` names. A div that states one is " +
|
|
2366
2366
|
"warned at each line that cannot have its beats. See " +
|
|
2367
2367
|
"[metres](../authoring/links-and-markup.md#metre).",
|
|
2368
2368
|
},
|
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",
|
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.
|
|
@@ -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;
|