@heroiclands/package-build 25.2.0 → 25.3.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 +18 -0
- package/assets/styles/content-base.css +65 -116
- package/docs/authoring/links-and-markup.md +114 -109
- package/docs/content-format.md +20 -3
- package/docs/diagnostics.md +25 -26
- package/docs/guides/writing-in-invented-scripts.md +41 -62
- package/docs/reference/format-details.md +0 -1
- package/docs/reference/theme-pages.md +30 -57
- package/docs/source-preview.md +2 -1
- package/engine/content-blocks.mjs +3 -2
- package/engine/content-render.mjs +31 -29
- package/engine/content-scripts.mjs +180 -86
- package/engine/content-spans.mjs +4 -6
- package/engine/content-verse.mjs +482 -281
- package/engine/journals.mjs +14 -14
- package/engine/pdf-render.mjs +65 -117
- package/engine/preview-source.mjs +15 -8
- package/engine/roman-verse.mjs +68 -57
- package/engine/verse-metre.mjs +33 -40
- package/package.json +1 -1
- package/site-theme/static/css/style.css +49 -101
- package/types/engine/content-blocks.d.mts +3 -1
- package/types/engine/content-scripts.d.mts +9 -5
- package/types/engine/content-verse.d.mts +269 -83
- package/types/engine/journals.d.mts +3 -3
- package/types/engine/roman-verse.d.mts +7 -6
package/engine/content-verse.mjs
CHANGED
|
@@ -11,12 +11,15 @@
|
|
|
11
11
|
* SPDX-License-Identifier: GPL-3.0-or-later
|
|
12
12
|
*/
|
|
13
13
|
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
}
|
|
14
|
+
import MarkdownIt from "markdown-it";
|
|
15
|
+
import { WIKILINK } from "./wikilink-syntax.mjs";
|
|
16
|
+
import { pageOpenings } from "./heading-attributes.mjs";
|
|
17
|
+
import { blockTokens } from "./markdown-blocks.mjs";
|
|
18
|
+
import { slugify } from "./content-slug.mjs";
|
|
19
|
+
import { spanMarkdownPlugin } from "./content-spans.mjs";
|
|
20
|
+
import { parseExtensionAttributes, refusedAttributes } from "./extension-attributes.mjs";
|
|
21
|
+
|
|
22
|
+
const lineParser = new MarkdownIt({ html: true }).use(spanMarkdownPlugin);
|
|
20
23
|
|
|
21
24
|
/**
|
|
22
25
|
* Read one verse line. Marker offsets are zero-based in the authored line;
|
|
@@ -51,37 +54,13 @@ export function verseLine(line) {
|
|
|
51
54
|
};
|
|
52
55
|
}
|
|
53
56
|
|
|
54
|
-
/**
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
const groups = [];
|
|
60
|
-
let group = [];
|
|
61
|
-
const end = () => {
|
|
62
|
-
if (group.length) groups.push(group);
|
|
63
|
-
group = [];
|
|
64
|
-
};
|
|
65
|
-
for (const line of String(body ?? "").split("\n")) {
|
|
66
|
-
if (!line.trim()) {
|
|
67
|
-
end();
|
|
68
|
-
continue;
|
|
69
|
-
}
|
|
70
|
-
if (STANZA_BREAK.test(line.trim())) {
|
|
71
|
-
end();
|
|
72
|
-
groups.push([{ text: "***", level: 0 }]);
|
|
73
|
-
continue;
|
|
74
|
-
}
|
|
75
|
-
const { text, level } = verseLine(line);
|
|
76
|
-
group.push({ text, level });
|
|
77
|
-
}
|
|
78
|
-
end();
|
|
79
|
-
return groups;
|
|
57
|
+
/** The structural first span of a line, with its source ranges and keys. */
|
|
58
|
+
function leadingSpan(text) {
|
|
59
|
+
if (!text.startsWith("[")) return undefined;
|
|
60
|
+
const tokens = lineParser.parseInline(text, {})[0]?.children ?? [];
|
|
61
|
+
return tokens.find((token) => token.meta?.span && token.meta.start === 0);
|
|
80
62
|
}
|
|
81
63
|
|
|
82
|
-
/** A stanza separator group, independent of the surrounding verse lines. */
|
|
83
|
-
export const stanzaSeparator = (group) => group.length === 1 && STANZA_BREAK.test(group[0].text);
|
|
84
|
-
|
|
85
64
|
/** HTML indentation attributes; large levels retain their full authored depth. */
|
|
86
65
|
function indentStyle(level) {
|
|
87
66
|
return level > 8 ? ` style="--verse-indent: ${level * 1.25}em"` : "";
|
|
@@ -95,130 +74,48 @@ export function inlineVerse(text) {
|
|
|
95
74
|
}
|
|
96
75
|
|
|
97
76
|
/**
|
|
98
|
-
*
|
|
99
|
-
* A
|
|
100
|
-
* The surrounding renderer still sees the entire note, so links and footnote
|
|
101
|
-
* definitions remain in scope. The span carries the indent for each line.
|
|
102
|
-
*/
|
|
103
|
-
export function verseMarkdown(body, step = 0) {
|
|
104
|
-
let count = 0;
|
|
105
|
-
return verseStanzas(body)
|
|
106
|
-
.map((stanza) =>
|
|
107
|
-
stanzaSeparator(stanza) ?
|
|
108
|
-
'<span class="verse-stanza-break" aria-hidden="true"></span>'
|
|
109
|
-
: stanza
|
|
110
|
-
.map(({ text, level }) => {
|
|
111
|
-
const number = lineNumber(++count, step);
|
|
112
|
-
return `<span class="verse-line${level ? ` i${level}` : ""}"${indentStyle(level)}>${number === undefined || number === "" ? "" : `<span class="verse-number">${number}</span>`}${inlineVerse(text)}</span>`;
|
|
113
|
-
})
|
|
114
|
-
.join(" \n"),
|
|
115
|
-
)
|
|
116
|
-
.join("\n\n");
|
|
117
|
-
}
|
|
118
|
-
|
|
119
|
-
/**
|
|
120
|
-
* The number a line shows: `undefined` when the verse is not numbered, `""`
|
|
121
|
-
* when this line is not one of every `step`th.
|
|
122
|
-
*
|
|
123
|
-
* @param {number} line - The line's number, 1-based across the verse.
|
|
124
|
-
* @param {number} step - The verse's `numstep`.
|
|
125
|
-
* @returns {string|undefined} The number shown.
|
|
126
|
-
*/
|
|
127
|
-
export function lineNumber(line, step) {
|
|
128
|
-
if (!step) return undefined;
|
|
129
|
-
return line % step === 0 ? String(line) : "";
|
|
130
|
-
}
|
|
131
|
-
|
|
132
|
-
/**
|
|
133
|
-
* The numbered-column rows, each holding its logical line number and each
|
|
134
|
-
* column’s physical readings stacked in authored order,
|
|
135
|
-
* the first in the normal tone and each later one `verse-secondary`. A part
|
|
136
|
-
* whose text runs right to left carries `dir="rtl"`. A row is a line of a
|
|
137
|
-
* paragraph, and a paragraph a stanza, so each part stays inline Markdown.
|
|
77
|
+
* Whether text runs right to left: its first letter is in a right-to-left
|
|
78
|
+
* block. A face's right-to-left code points sit in U+1E800–U+1EFFF.
|
|
138
79
|
*
|
|
139
|
-
* @param {
|
|
140
|
-
* @
|
|
141
|
-
* @param {number} step - The verse's `numstep`.
|
|
142
|
-
* @returns {string} The rows as Markdown with inline HTML.
|
|
80
|
+
* @param {string} text - Text, after any map.
|
|
81
|
+
* @returns {boolean} Whether it starts right to left.
|
|
143
82
|
*/
|
|
144
|
-
function
|
|
145
|
-
const
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
}
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
return (
|
|
157
|
-
'<span class="verse-column">' +
|
|
158
|
-
parts
|
|
159
|
-
.map(
|
|
160
|
-
({ text, indent }, index) =>
|
|
161
|
-
`<span class="verse-part${index ? " verse-secondary" : ""}${indent ? ` i${indent}` : ""}"${indentStyle(indent)}${rightToLeft(text) ? ' dir="rtl"' : ""}>${inlineVerse(text)}</span>`,
|
|
162
|
-
)
|
|
163
|
-
.join("") +
|
|
164
|
-
"</span>"
|
|
165
|
-
);
|
|
166
|
-
});
|
|
167
|
-
stanzas
|
|
168
|
-
.at(-1)
|
|
169
|
-
.push(
|
|
170
|
-
'<span class="verse-row">' +
|
|
171
|
-
(number === undefined ? "" : `<span class="verse-number">${number}</span>`) +
|
|
172
|
-
cells.join("") +
|
|
173
|
-
"</span>",
|
|
174
|
-
);
|
|
175
|
-
}
|
|
176
|
-
return stanzas
|
|
177
|
-
.filter((rows) => rows.length)
|
|
178
|
-
.map((rows) => rows.join("\n"))
|
|
179
|
-
.join("\n\n");
|
|
180
|
-
}
|
|
181
|
-
|
|
182
|
-
import MarkdownIt from "markdown-it";
|
|
183
|
-
import { blockTokens } from "./markdown-blocks.mjs";
|
|
184
|
-
import { slugify } from "./content-slug.mjs";
|
|
185
|
-
import { spanMarkdownPlugin } from "./content-spans.mjs";
|
|
186
|
-
import { parseExtensionAttributes, refusedAttributes } from "./extension-attributes.mjs";
|
|
187
|
-
|
|
188
|
-
const lineParser = new MarkdownIt({ html: true }).use(spanMarkdownPlugin);
|
|
189
|
-
|
|
190
|
-
/** The structural first span of a line, with its source ranges and keys. */
|
|
191
|
-
function leadingSpan(text) {
|
|
192
|
-
if (!text.startsWith("[")) return undefined;
|
|
193
|
-
const tokens = lineParser.parseInline(text, {})[0]?.children ?? [];
|
|
194
|
-
return tokens.find((token) => token.meta?.span && token.meta.start === 0);
|
|
83
|
+
export function rightToLeft(text) {
|
|
84
|
+
const first = /[\p{L}\p{M}\p{Co}\p{Cn}]/u.exec(
|
|
85
|
+
String(text)
|
|
86
|
+
.replace(/<[^>]*>/g, "")
|
|
87
|
+
.replace(/\{[^}]*\}/g, ""),
|
|
88
|
+
);
|
|
89
|
+
return Boolean(
|
|
90
|
+
first &&
|
|
91
|
+
/[\u0590-\u08FF\uFB1D-\uFDFF\uFE70-\uFEFF\u{10800}-\u{10FFF}\u{1E800}-\u{1EFFF}]/u.test(
|
|
92
|
+
first[0],
|
|
93
|
+
),
|
|
94
|
+
);
|
|
195
95
|
}
|
|
196
96
|
|
|
197
|
-
/** The
|
|
198
|
-
export const COLUMNS_CLASS = "verse-columns";
|
|
199
|
-
|
|
200
|
-
/**
|
|
201
|
-
* The keys a verse fence's attribute block takes. `lang`, `font` and `map`
|
|
202
|
-
* are read by `scanRuns` in content-scripts.mjs; `dir`, and on the site
|
|
203
|
-
* `data-pagefind-ignore`, are written by its rewrite of a fence set in one of
|
|
204
|
-
* the faces, and reach the element as they are written.
|
|
205
|
-
*/
|
|
97
|
+
/** The authored attributes of a verse fence. */
|
|
206
98
|
export const VERSE_KEYS = Object.freeze([
|
|
207
99
|
"form",
|
|
208
100
|
"scan",
|
|
209
101
|
"lang",
|
|
210
102
|
"font",
|
|
211
103
|
"enc",
|
|
212
|
-
"
|
|
213
|
-
"cols",
|
|
104
|
+
"work",
|
|
214
105
|
"dir",
|
|
215
106
|
"data-pagefind-ignore",
|
|
216
107
|
]);
|
|
217
108
|
|
|
109
|
+
/** A canto separator, including the escaped form produced by a native run. */
|
|
110
|
+
export const STANZA_BREAK = /^(?:\\?\*){3}$/;
|
|
111
|
+
|
|
218
112
|
/** Read verse code fences; start/end are inclusive zero-based source lines.
|
|
219
113
|
* @param {string} source - Authored Markdown.
|
|
114
|
+
* @param {object} [options] - Whole-page validation choices.
|
|
115
|
+
* @param {boolean} [options.checkWorks] - Whether to validate page-local versions.
|
|
116
|
+
* @returns {{blocks: VerseBlock[], errors: VerseDiagnostic[]}} Located fences and findings.
|
|
220
117
|
*/
|
|
221
|
-
export function scanVerse(source) {
|
|
118
|
+
export function scanVerse(source, { checkWorks = true } = {}) {
|
|
222
119
|
const lines = String(source ?? "").split("\n"),
|
|
223
120
|
blocks = [],
|
|
224
121
|
errors = [];
|
|
@@ -256,8 +153,16 @@ export function scanVerse(source) {
|
|
|
256
153
|
});
|
|
257
154
|
}
|
|
258
155
|
const block = { ...opening, end: i, body };
|
|
259
|
-
|
|
260
|
-
|
|
156
|
+
block.model = parseVerse(body);
|
|
157
|
+
const bodyOffset = lines.slice(0, opening.start + 1).join("\n").length + 1;
|
|
158
|
+
for (const finding of block.model.errors) {
|
|
159
|
+
const prefix = String(source).slice(0, bodyOffset + finding.offset);
|
|
160
|
+
errors.push({
|
|
161
|
+
line: prefix.split("\n").length,
|
|
162
|
+
column: prefix.length - prefix.lastIndexOf("\n"),
|
|
163
|
+
message: finding.message,
|
|
164
|
+
});
|
|
165
|
+
}
|
|
261
166
|
blocks.push(block);
|
|
262
167
|
}
|
|
263
168
|
opening = null;
|
|
@@ -289,26 +194,17 @@ export function scanVerse(source) {
|
|
|
289
194
|
for (const message of [...parsed.problems, ...refusedAttributes(parsed.values)])
|
|
290
195
|
errors.push({ ...at, message });
|
|
291
196
|
for (const [key, value] of Object.entries(parsed.values)) {
|
|
292
|
-
if (!
|
|
197
|
+
if (key === "work" && !value.trim())
|
|
293
198
|
errors.push({
|
|
294
199
|
line: i + 1,
|
|
295
200
|
column: keyColumn(lines[i], key),
|
|
296
|
-
message:
|
|
201
|
+
message: "work= needs a nonempty page-local key",
|
|
297
202
|
});
|
|
298
|
-
|
|
299
|
-
key === "cols" &&
|
|
300
|
-
(!/^[1-9]\d*$/.test(value) || !Number.isSafeInteger(Number(value)))
|
|
301
|
-
)
|
|
302
|
-
errors.push({
|
|
303
|
-
line: i + 1,
|
|
304
|
-
column: keyColumn(lines[i], key) + key.length + 1,
|
|
305
|
-
message: "verse cols= is a positive whole number",
|
|
306
|
-
});
|
|
307
|
-
else if (key === "numstep" && !/^\d+$/.test(value))
|
|
203
|
+
if (!VERSE_KEYS.includes(key))
|
|
308
204
|
errors.push({
|
|
309
205
|
line: i + 1,
|
|
310
206
|
column: keyColumn(lines[i], key),
|
|
311
|
-
message:
|
|
207
|
+
message: `${key}= is not a verse attribute`,
|
|
312
208
|
});
|
|
313
209
|
}
|
|
314
210
|
}
|
|
@@ -318,6 +214,16 @@ export function scanVerse(source) {
|
|
|
318
214
|
column: 1,
|
|
319
215
|
message: "verse block needs a closing code fence",
|
|
320
216
|
});
|
|
217
|
+
if (checkWorks) {
|
|
218
|
+
const openings = [...pageOpenings(source).keys()];
|
|
219
|
+
const pages = new Map();
|
|
220
|
+
for (const block of blocks) {
|
|
221
|
+
const page = openings.findLast((line) => line <= block.start) ?? -1;
|
|
222
|
+
if (!pages.has(page)) pages.set(page, []);
|
|
223
|
+
pages.get(page).push(block);
|
|
224
|
+
}
|
|
225
|
+
for (const page of pages.values()) errors.push(...workShapeFindings(page));
|
|
226
|
+
}
|
|
321
227
|
errors.sort((a, b) => a.line - b.line || a.column - b.column);
|
|
322
228
|
return { blocks, errors };
|
|
323
229
|
}
|
|
@@ -338,167 +244,462 @@ function keyColumn(opening, key) {
|
|
|
338
244
|
return (at < 0 ? brace : brace + at) + 1;
|
|
339
245
|
}
|
|
340
246
|
|
|
341
|
-
/**
|
|
342
|
-
|
|
343
|
-
const
|
|
344
|
-
const
|
|
345
|
-
|
|
247
|
+
/** Source ranges whose slash characters belong to inline syntax. */
|
|
248
|
+
function syntaxRanges(source) {
|
|
249
|
+
const ranges = [];
|
|
250
|
+
for (const match of source.matchAll(new RegExp(WIKILINK.source, "g"))) {
|
|
251
|
+
const separator = match[0].indexOf("|");
|
|
252
|
+
const close = match[0].indexOf("]]");
|
|
253
|
+
ranges.push([match.index, match.index + (separator < 0 ? close + 2 : separator + 1)]);
|
|
254
|
+
ranges.push([match.index + close, match.index + match[0].length]);
|
|
255
|
+
}
|
|
256
|
+
for (let at = 0; at < source.length; at++) {
|
|
257
|
+
if (source[at] === "\\") {
|
|
258
|
+
at++;
|
|
259
|
+
continue;
|
|
260
|
+
}
|
|
261
|
+
const char = source[at];
|
|
262
|
+
let past = -1;
|
|
263
|
+
if (char === "⟦") past = source.indexOf("⟧", at + 1) + 1;
|
|
264
|
+
else if (char === "`") {
|
|
265
|
+
const mark = /^`+/.exec(source.slice(at))[0];
|
|
266
|
+
const close = source.indexOf(mark, at + mark.length);
|
|
267
|
+
if (close >= 0) past = close + mark.length;
|
|
268
|
+
} else if (char === "<") past = source.indexOf(">", at + 1) + 1;
|
|
269
|
+
else if (char === "{") past = source.indexOf("}", at + 1) + 1;
|
|
270
|
+
else if (char === "(" && source[at - 1] === "]") {
|
|
271
|
+
let depth = 1;
|
|
272
|
+
for (let next = at + 1; next < source.length; next++) {
|
|
273
|
+
if (source[next] === "\\") {
|
|
274
|
+
next++;
|
|
275
|
+
continue;
|
|
276
|
+
}
|
|
277
|
+
if (source[next] === "(") depth++;
|
|
278
|
+
if (source[next] === ")" && !--depth) {
|
|
279
|
+
past = next + 1;
|
|
280
|
+
break;
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
if (past > at) {
|
|
285
|
+
ranges.push([at, past]);
|
|
286
|
+
at = past - 1;
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
return ranges.sort((a, b) => a[0] - b[0] || b[1] - a[1]);
|
|
346
290
|
}
|
|
347
291
|
|
|
348
|
-
/**
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
}
|
|
292
|
+
/**
|
|
293
|
+
* A logical line with its authored UTF-16 coordinates.
|
|
294
|
+
* @typedef {object} VerseLine
|
|
295
|
+
* @property {string} text - Inline Markdown in this presentation.
|
|
296
|
+
* @property {number} level - Leading-period indentation depth.
|
|
297
|
+
* @property {number} from - First authored offset, including indentation.
|
|
298
|
+
* @property {number} to - Offset at the line ending.
|
|
299
|
+
* @property {number[]} offsets - Source offset for each UTF-16 text unit.
|
|
300
|
+
* @property {string} [romanText] - Roman text paired with an encoded line.
|
|
301
|
+
*/
|
|
352
302
|
|
|
353
|
-
/**
|
|
354
|
-
|
|
303
|
+
/**
|
|
304
|
+
* A stanza's logical lines and authored bounds.
|
|
305
|
+
* @typedef {object} VerseStanza
|
|
306
|
+
* @property {VerseLine[]} lines - Lines in reading order.
|
|
307
|
+
* @property {number} from - First authored offset.
|
|
308
|
+
* @property {number} to - Offset at the stanza ending.
|
|
309
|
+
*/
|
|
355
310
|
|
|
356
311
|
/**
|
|
357
|
-
*
|
|
358
|
-
*
|
|
359
|
-
*
|
|
360
|
-
*
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
312
|
+
* @typedef {VerseStanza & {canto: number, stanza: number, label: string, cantoBreak?: false}} VerseTextGroup
|
|
313
|
+
* @typedef {{cantoBreak: true, from: number, to: number, text?: string}} VerseCantoBreak
|
|
314
|
+
* @typedef {{kind: "line"|"stanza"|"canto", sourceOffset: number}} VerseBoundary
|
|
315
|
+
* @typedef {{offset: number, message: string}} VerseFinding
|
|
316
|
+
*/
|
|
317
|
+
|
|
318
|
+
/**
|
|
319
|
+
* Shared authored verse structure and source locations.
|
|
320
|
+
* @typedef {object} VerseModel
|
|
321
|
+
* @property {Array<{stanzas: VerseStanza[]}>} cantos - Cantos in reading order.
|
|
322
|
+
* @property {Array<VerseTextGroup|VerseCantoBreak>} groups - Stanzas and canto breaks.
|
|
323
|
+
* @property {VerseBoundary[]} boundaries - Explicit authored boundaries.
|
|
324
|
+
* @property {Array<[number, number]>} layout - Source ranges omitted from encoded letters.
|
|
325
|
+
* @property {VerseFinding[]} errors - Findings relative to the fence body.
|
|
326
|
+
*/
|
|
327
|
+
|
|
328
|
+
/**
|
|
329
|
+
* An authored verse fence with its parsed logical structure.
|
|
330
|
+
* @typedef {object} VerseBlock
|
|
331
|
+
* @property {number} start - Opening fence's zero-based source line.
|
|
332
|
+
* @property {number} end - Closing fence's zero-based source line.
|
|
333
|
+
* @property {string} fence - Authored fence delimiter.
|
|
334
|
+
* @property {"verse"} name - Fence language.
|
|
335
|
+
* @property {Record<string, string>} attributes - Authored attribute values.
|
|
336
|
+
* @property {string} id - Authored anchor, or the empty string.
|
|
337
|
+
* @property {string[]} classes - Authored presentation classes.
|
|
338
|
+
* @property {string} indent - Opening fence's leading spaces.
|
|
339
|
+
* @property {string} body - Authored fence body.
|
|
340
|
+
* @property {VerseModel} model - Logical structure relative to the body.
|
|
341
|
+
*/
|
|
342
|
+
|
|
343
|
+
/** @typedef {{line: number, column: number, message: string}} VerseDiagnostic */
|
|
344
|
+
|
|
345
|
+
/**
|
|
346
|
+
* An encoded verse's shared display model and native-cell presentation.
|
|
347
|
+
* @typedef {object} VersePresentation
|
|
348
|
+
* @property {VerseModel} model - Logical structure with paired line text.
|
|
349
|
+
* @property {string[]} classes - Native script and font classes.
|
|
350
|
+
* @property {Record<string, string>} attributes - Native language, direction and search attributes.
|
|
364
351
|
*/
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
352
|
+
|
|
353
|
+
/**
|
|
354
|
+
* Parse logical verse lines, stanzas and cantos without losing authored offsets.
|
|
355
|
+
* Offsets are UTF-16 positions relative to body. Delimiters and indentation are
|
|
356
|
+
* layout ranges; source newlines inside a logical line contribute one space.
|
|
357
|
+
* @param {string} body - Authored fence body.
|
|
358
|
+
* @returns {VerseModel} The shared structure with offsets relative to the body.
|
|
359
|
+
*/
|
|
360
|
+
export function parseVerse(body) {
|
|
361
|
+
const source = String(body ?? "");
|
|
362
|
+
const groups = [],
|
|
363
|
+
boundaries = [],
|
|
364
|
+
layout = [],
|
|
365
|
+
errors = [];
|
|
366
|
+
const cantos = [{ stanzas: [] }];
|
|
367
|
+
const protectedRanges = syntaxRanges(source);
|
|
368
|
+
const spans = (lineParser.parseInline(source, {})[0]?.children ?? [])
|
|
369
|
+
.filter((token) => token.meta?.span)
|
|
370
|
+
.map((token) => ({
|
|
371
|
+
start: token.meta.start,
|
|
372
|
+
end: token.meta.end,
|
|
373
|
+
contentEnd: token.meta.start + 1 + token.meta.text.length,
|
|
374
|
+
}));
|
|
375
|
+
let protectedIndex = 0;
|
|
376
|
+
let text = "",
|
|
377
|
+
offsets = [],
|
|
378
|
+
from = 0,
|
|
379
|
+
level = 0,
|
|
380
|
+
lines = [];
|
|
381
|
+
let pendingBreak;
|
|
382
|
+
const append = (value, at) => {
|
|
383
|
+
text += value;
|
|
384
|
+
for (let index = 0; index < value.length; index++) offsets.push(at + index);
|
|
371
385
|
};
|
|
372
|
-
|
|
373
|
-
const
|
|
374
|
-
if (
|
|
375
|
-
|
|
386
|
+
const finishLine = (at) => {
|
|
387
|
+
const trimmed = text.trimEnd();
|
|
388
|
+
if (trimmed.trim()) {
|
|
389
|
+
let lineText = trimmed,
|
|
390
|
+
lineOffsets = offsets.slice(0, trimmed.length);
|
|
391
|
+
for (const span of spans
|
|
392
|
+
.filter((span) => span.start < from && span.contentEnd >= from)
|
|
393
|
+
.sort((a, b) => b.start - a.start)) {
|
|
394
|
+
lineText = "[" + lineText;
|
|
395
|
+
lineOffsets.unshift(span.start);
|
|
396
|
+
}
|
|
397
|
+
for (const span of spans
|
|
398
|
+
.filter((span) => span.start < at && span.contentEnd >= at)
|
|
399
|
+
.sort((a, b) => b.start - a.start)) {
|
|
400
|
+
const suffix = source.slice(span.contentEnd, span.end);
|
|
401
|
+
lineText += suffix;
|
|
402
|
+
for (let index = 0; index < suffix.length; index++)
|
|
403
|
+
lineOffsets.push(span.contentEnd + index);
|
|
404
|
+
}
|
|
405
|
+
const repeatedIds = spans
|
|
406
|
+
.filter((span) => span.start < from && span.contentEnd >= from)
|
|
407
|
+
.flatMap((span) => {
|
|
408
|
+
const raw = source.slice(span.contentEnd + 2, span.end - 1);
|
|
409
|
+
return [...raw.matchAll(/(?:[^\s"]+|"(?:\\.|[^"\\])*")+/g)]
|
|
410
|
+
.filter((match) => match[0].startsWith("#"))
|
|
411
|
+
.map((match) => [
|
|
412
|
+
span.contentEnd + 2 + match.index,
|
|
413
|
+
span.contentEnd + 2 + match.index + match[0].length,
|
|
414
|
+
]);
|
|
415
|
+
});
|
|
416
|
+
if (repeatedIds.length) {
|
|
417
|
+
const kept = lineOffsets
|
|
418
|
+
.map((offset, index) => ({ offset, char: lineText[index] }))
|
|
419
|
+
.filter(
|
|
420
|
+
({ offset }) =>
|
|
421
|
+
!repeatedIds.some(([first, past]) => offset >= first && offset < past),
|
|
422
|
+
);
|
|
423
|
+
lineText = kept.map(({ char }) => char).join("");
|
|
424
|
+
lineOffsets = kept.map(({ offset }) => offset);
|
|
425
|
+
}
|
|
426
|
+
lines.push({ text: lineText, level, from, to: at, offsets: lineOffsets });
|
|
427
|
+
}
|
|
428
|
+
text = "";
|
|
429
|
+
offsets = [];
|
|
430
|
+
level = 0;
|
|
431
|
+
from = at;
|
|
432
|
+
};
|
|
433
|
+
const finishStanza = (at) => {
|
|
434
|
+
finishLine(at);
|
|
435
|
+
if (lines.length) {
|
|
436
|
+
const stanza = { lines, from: lines[0].from, to: at };
|
|
437
|
+
cantos.at(-1).stanzas.push(stanza);
|
|
438
|
+
groups.push({
|
|
439
|
+
...stanza,
|
|
440
|
+
canto: cantos.length,
|
|
441
|
+
stanza: cantos.at(-1).stanzas.length,
|
|
442
|
+
label: "",
|
|
443
|
+
});
|
|
444
|
+
lines = [];
|
|
445
|
+
}
|
|
446
|
+
};
|
|
447
|
+
const event = (kind, at, past) => {
|
|
448
|
+
if (kind === "line") finishLine(at);
|
|
449
|
+
else finishStanza(at);
|
|
450
|
+
layout.push([at, past]);
|
|
451
|
+
boundaries.push({ kind, sourceOffset: at });
|
|
452
|
+
from = past;
|
|
453
|
+
};
|
|
454
|
+
for (let at = 0; at < source.length;) {
|
|
455
|
+
const lineStart = at === 0 || /[\r\n]/.test(source[at - 1]);
|
|
456
|
+
if (lineStart) {
|
|
457
|
+
const physical = /^[^\r\n]*/.exec(source.slice(at))[0];
|
|
458
|
+
const newline = /^(?:\r\n|\r|\n)/.exec(source.slice(at + physical.length))?.[0] ?? "";
|
|
459
|
+
if (!physical.trim()) {
|
|
460
|
+
event("stanza", at, at + physical.length + newline.length);
|
|
461
|
+
at += physical.length + newline.length;
|
|
462
|
+
continue;
|
|
463
|
+
}
|
|
464
|
+
if (STANZA_BREAK.test(physical.trim())) {
|
|
465
|
+
event("canto", at, at + physical.length + newline.length);
|
|
466
|
+
if (!cantos.at(-1).stanzas.length)
|
|
467
|
+
errors.push({
|
|
468
|
+
offset: at,
|
|
469
|
+
message: "a canto break needs a stanza before and after it",
|
|
470
|
+
});
|
|
471
|
+
else {
|
|
472
|
+
groups.push({ cantoBreak: true, from: at, to: at + physical.length });
|
|
473
|
+
cantos.push({ stanzas: [] });
|
|
474
|
+
}
|
|
475
|
+
pendingBreak = at;
|
|
476
|
+
at += physical.length + newline.length;
|
|
477
|
+
continue;
|
|
478
|
+
}
|
|
479
|
+
}
|
|
480
|
+
while (protectedRanges[protectedIndex]?.[1] <= at) protectedIndex++;
|
|
481
|
+
const protectedRange = protectedRanges[protectedIndex];
|
|
482
|
+
const protectedAt = protectedRange && protectedRange[0] <= at;
|
|
483
|
+
if (!text && !/\s/.test(source[at])) {
|
|
484
|
+
if (!level) from = at;
|
|
485
|
+
const remaining = source.slice(at);
|
|
486
|
+
const reading = verseLine(remaining.split(/\r?\n/)[0]);
|
|
487
|
+
if (reading.markerLength) {
|
|
488
|
+
level = reading.level;
|
|
489
|
+
const first = at + reading.markerStart;
|
|
490
|
+
layout.push([first, first + reading.markerLength]);
|
|
491
|
+
// A leading span keeps its brackets and attributes around the indented text.
|
|
492
|
+
if (reading.markerStart) append(source.slice(at, first), at);
|
|
493
|
+
at = first + reading.markerLength;
|
|
494
|
+
continue;
|
|
495
|
+
}
|
|
496
|
+
}
|
|
497
|
+
if (!protectedAt && source[at] === "\\" && at + 1 < source.length) {
|
|
498
|
+
append(source.slice(at, at + 2), at);
|
|
499
|
+
at += 2;
|
|
376
500
|
continue;
|
|
377
501
|
}
|
|
378
|
-
if (
|
|
379
|
-
|
|
380
|
-
|
|
502
|
+
if (!protectedAt && source[at] === "/") {
|
|
503
|
+
const length = source[at + 1] === "/" ? 2 : 1;
|
|
504
|
+
event(length === 2 ? "stanza" : "line", at, at + length);
|
|
505
|
+
at += length;
|
|
381
506
|
continue;
|
|
382
507
|
}
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
markerLength: parsed.markerLength,
|
|
397
|
-
prefixLength,
|
|
398
|
-
});
|
|
508
|
+
if (/[\r\n]/.test(source[at])) {
|
|
509
|
+
const length = source[at] === "\r" && source[at + 1] === "\n" ? 2 : 1;
|
|
510
|
+
if (text && !/\s$/.test(text)) append(" ", at);
|
|
511
|
+
at += length;
|
|
512
|
+
while (source[at] === " ") at++;
|
|
513
|
+
continue;
|
|
514
|
+
}
|
|
515
|
+
if (!text && source[at] === " ") {
|
|
516
|
+
at++;
|
|
517
|
+
continue;
|
|
518
|
+
}
|
|
519
|
+
append(source[at], at);
|
|
520
|
+
at++;
|
|
399
521
|
}
|
|
400
|
-
|
|
401
|
-
|
|
522
|
+
finishStanza(source.length);
|
|
523
|
+
if (pendingBreak !== undefined && !cantos.at(-1).stanzas.length) {
|
|
524
|
+
if (!errors.some((error) => error.offset === pendingBreak))
|
|
525
|
+
errors.push({
|
|
526
|
+
offset: pendingBreak,
|
|
527
|
+
message: "a canto break needs a stanza before and after it",
|
|
528
|
+
});
|
|
529
|
+
cantos.pop();
|
|
530
|
+
if (groups.at(-1)?.cantoBreak) groups.pop();
|
|
531
|
+
}
|
|
532
|
+
const multi = cantos.length > 1;
|
|
533
|
+
for (const group of groups)
|
|
534
|
+
if (!group.cantoBreak)
|
|
535
|
+
group.label = multi ? `${group.canto}:${group.stanza}` : String(group.stanza);
|
|
536
|
+
return { cantos, groups, boundaries, layout, errors };
|
|
402
537
|
}
|
|
403
538
|
|
|
404
|
-
/**
|
|
405
|
-
function
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
539
|
+
/** Logical line text for consumers that do not need source locations. */
|
|
540
|
+
export function verseLines(body) {
|
|
541
|
+
return parseVerse(body).groups.flatMap((group) =>
|
|
542
|
+
group.cantoBreak ? [] : group.lines.map((line) => line.text),
|
|
543
|
+
);
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
/** Paragraph groups with separate canto gaps. */
|
|
547
|
+
export function verseStanzas(body) {
|
|
548
|
+
return parseVerse(body).groups.map((group) =>
|
|
549
|
+
group.cantoBreak ?
|
|
550
|
+
[{ text: "***", level: 0 }]
|
|
551
|
+
: group.lines.map(({ text, level }) => ({ text, level })),
|
|
552
|
+
);
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
/** Whether the projected group is a canto gap. */
|
|
556
|
+
export const stanzaSeparator = (group) => group.length === 1 && STANZA_BREAK.test(group[0].text);
|
|
557
|
+
|
|
558
|
+
/**
|
|
559
|
+
* Compare the stanza counts of each version of a page-local work.
|
|
560
|
+
* @param {VerseBlock[]} blocks - Fences belonging to one page.
|
|
561
|
+
* @returns {VerseDiagnostic[]} Findings at affected opening fences.
|
|
562
|
+
*/
|
|
563
|
+
export function workShapeFindings(blocks) {
|
|
564
|
+
const works = new Map(),
|
|
565
|
+
errors = [];
|
|
566
|
+
for (const block of blocks) {
|
|
567
|
+
const key = block.attributes.work;
|
|
568
|
+
if (key === undefined) continue;
|
|
569
|
+
if (!works.has(key)) works.set(key, []);
|
|
570
|
+
works.get(key).push(block);
|
|
571
|
+
}
|
|
572
|
+
for (const [key, versions] of works) {
|
|
573
|
+
if (versions.length === 1) {
|
|
574
|
+
errors.push({
|
|
575
|
+
line: versions[0].start + 1,
|
|
576
|
+
column: 1,
|
|
577
|
+
message: `work=${key} has only one version`,
|
|
578
|
+
});
|
|
579
|
+
continue;
|
|
580
|
+
}
|
|
581
|
+
const first = versions[0];
|
|
582
|
+
const shape = (block) => block.model.cantos.map((canto) => canto.stanzas.length);
|
|
583
|
+
const expected = shape(first);
|
|
584
|
+
const seen = new Set();
|
|
585
|
+
for (const version of versions.slice(1)) {
|
|
586
|
+
const actual = shape(version);
|
|
587
|
+
if (actual.length !== expected.length) {
|
|
588
|
+
const message = `work=${key}: ${expected.length} cantos in the first version and ${actual.length} in this version`;
|
|
589
|
+
for (const block of [first, version]) {
|
|
590
|
+
const identity = `${block.start}:${message}`;
|
|
591
|
+
if (!seen.has(identity)) {
|
|
592
|
+
errors.push({ line: block.start + 1, column: 1, message });
|
|
593
|
+
seen.add(identity);
|
|
594
|
+
}
|
|
595
|
+
}
|
|
596
|
+
}
|
|
597
|
+
for (let canto = 0; canto < Math.max(expected.length, actual.length); canto++) {
|
|
598
|
+
if (expected[canto] === actual[canto]) continue;
|
|
599
|
+
const message = `work=${key}, canto ${canto + 1}: ${expected[canto] ?? 0} stanzas in the first version and ${actual[canto] ?? 0} in this version`;
|
|
600
|
+
for (const block of [first, version]) {
|
|
601
|
+
const identity = `${block.start}:${message}`;
|
|
602
|
+
if (!seen.has(identity)) {
|
|
603
|
+
errors.push({ line: block.start + 1, column: 1, message });
|
|
604
|
+
seen.add(identity);
|
|
605
|
+
}
|
|
606
|
+
}
|
|
427
607
|
}
|
|
428
|
-
if (!part.text.trim())
|
|
429
|
-
report(
|
|
430
|
-
part.line,
|
|
431
|
-
part.sourceColumn,
|
|
432
|
-
"a numbered verse line needs content after its column number",
|
|
433
|
-
);
|
|
434
|
-
if (!Number.isSafeInteger(part.column) || part.column < 1 || part.column > width)
|
|
435
|
-
report(
|
|
436
|
-
part.line,
|
|
437
|
-
part.sourceColumn,
|
|
438
|
-
`column ${part.column} is outside 1..${width}`,
|
|
439
|
-
);
|
|
440
|
-
else if (part.column < previous)
|
|
441
|
-
report(
|
|
442
|
-
part.line,
|
|
443
|
-
part.sourceColumn,
|
|
444
|
-
"column numbers must not decrease within a verse row",
|
|
445
|
-
);
|
|
446
|
-
previous = part.column;
|
|
447
608
|
}
|
|
448
609
|
}
|
|
449
610
|
return errors;
|
|
450
611
|
}
|
|
451
612
|
|
|
452
613
|
/**
|
|
453
|
-
*
|
|
454
|
-
*
|
|
455
|
-
*
|
|
456
|
-
* @param {
|
|
457
|
-
* @
|
|
614
|
+
* Render a shared verse structure while preserving surrounding inline Markdown.
|
|
615
|
+
* @param {string} body - Authored fence body.
|
|
616
|
+
* @param {object} [options] - Shared structure and display choices.
|
|
617
|
+
* @param {boolean} [options.labelled] - Whether each stanza carries its label.
|
|
618
|
+
* @param {VerseModel} [options.model] - Parsed structure; defaults to parsing the body.
|
|
619
|
+
* @param {VersePresentation} [options.presentation] - Native-cell font and language.
|
|
620
|
+
* @returns {string} Markdown with verse layout wrappers.
|
|
458
621
|
*/
|
|
459
|
-
export function
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
622
|
+
export function verseMarkdown(
|
|
623
|
+
body,
|
|
624
|
+
{ labelled = false, model = parseVerse(body), presentation } = {},
|
|
625
|
+
) {
|
|
626
|
+
const escape = (value) =>
|
|
627
|
+
String(value)
|
|
628
|
+
.replace(/&/g, "&")
|
|
629
|
+
.replace(/"/g, """)
|
|
630
|
+
.replace(/</g, "<")
|
|
631
|
+
.replace(/>/g, ">");
|
|
632
|
+
const nativeAttrs = Object.entries(presentation?.attributes ?? {})
|
|
633
|
+
.filter(([key]) => ["lang", "dir", "data-pagefind-ignore"].includes(key))
|
|
634
|
+
.map(([key, value]) => ` ${key}="${escape(value)}"`)
|
|
635
|
+
.join("");
|
|
636
|
+
const nativeClasses = ["verse-native", ...(presentation?.classes ?? [])].join(" ");
|
|
637
|
+
return model.groups
|
|
638
|
+
.map((group) => {
|
|
639
|
+
if (group.cantoBreak) {
|
|
640
|
+
const mark = group.text ? inlineVerse(group.text) : "";
|
|
641
|
+
const content =
|
|
642
|
+
mark && presentation ?
|
|
643
|
+
`<span class="${escape(nativeClasses)}"${nativeAttrs}${!presentation.attributes?.dir && rightToLeft(group.text) ? ' dir="rtl"' : ""}>${mark}</span>`
|
|
644
|
+
: mark;
|
|
645
|
+
return `<span class="verse-canto-break" aria-hidden="true">${content}</span>`;
|
|
646
|
+
}
|
|
647
|
+
const label = labelled ? `<span class="verse-label">${group.label}</span>` : "";
|
|
648
|
+
const rows = group.lines
|
|
649
|
+
.map((line) => {
|
|
650
|
+
const content = (text) =>
|
|
651
|
+
`<span class="verse-line${line.level ? ` i${line.level}` : ""}"${indentStyle(line.level)}>${inlineVerse(text)}</span>`;
|
|
652
|
+
if (line.romanText !== undefined)
|
|
653
|
+
return `<span class="verse-row"><span class="${escape(nativeClasses)}"${nativeAttrs}${!presentation?.attributes?.dir && rightToLeft(line.text) ? ' dir="rtl"' : ""}>${content(line.text)}</span><span class="verse-roman">${content(line.romanText)}</span></span>`;
|
|
654
|
+
return content(line.text);
|
|
655
|
+
})
|
|
656
|
+
.join(group.lines.some((line) => line.romanText !== undefined) ? "\n" : " \n");
|
|
657
|
+
return `<div class="verse-stanza">${label}\n\n${rows}\n\n</div>`;
|
|
658
|
+
})
|
|
659
|
+
.join("\n\n");
|
|
471
660
|
}
|
|
472
661
|
|
|
473
|
-
/**
|
|
474
|
-
*
|
|
662
|
+
/**
|
|
663
|
+
* Replace verse fences with wrappers whose bodies remain inline Markdown.
|
|
664
|
+
* @param {string} source - Markdown containing verse fences.
|
|
665
|
+
* @param {object} [options] - Encoded display models.
|
|
666
|
+
* @param {Map<string, VersePresentation>} [options.verses] - Presentations keyed by rewritten body.
|
|
475
667
|
*/
|
|
476
|
-
export function renderVerse(source) {
|
|
477
|
-
const { errors } = scanVerse(source)
|
|
478
|
-
const { blocks } = scanVerse(source),
|
|
668
|
+
export function renderVerse(source, { verses = new Map() } = {}) {
|
|
669
|
+
const { blocks, errors } = scanVerse(source),
|
|
479
670
|
lines = String(source ?? "").split("\n"),
|
|
480
671
|
output = [];
|
|
481
|
-
let cursor = 0;
|
|
482
672
|
const escape = (value) =>
|
|
483
673
|
String(value)
|
|
484
674
|
.replace(/&/g, "&")
|
|
485
675
|
.replace(/"/g, """)
|
|
486
676
|
.replace(/</g, "<")
|
|
487
677
|
.replace(/>/g, ">");
|
|
678
|
+
let cursor = 0;
|
|
488
679
|
for (const block of blocks) {
|
|
489
680
|
output.push(...lines.slice(cursor, block.start));
|
|
490
|
-
const
|
|
491
|
-
const
|
|
681
|
+
const transported = verses.get(block.body);
|
|
682
|
+
const model = transported?.model ?? block.model;
|
|
683
|
+
const paired = model.groups.some(
|
|
684
|
+
(group) =>
|
|
685
|
+
!group.cantoBreak && group.lines.some((line) => line.romanText !== undefined),
|
|
686
|
+
);
|
|
687
|
+
const labelled = block.attributes.work !== undefined;
|
|
492
688
|
const attrs = [
|
|
493
|
-
`class="${escape(["verse", ...block.classes, ...(
|
|
689
|
+
`class="${escape(["verse", ...block.classes.filter((value) => !paired || !transported?.classes?.includes(value)), ...(paired ? ["verse-paired"] : []), ...(labelled ? ["verse-labelled"] : [])].join(" "))}"`,
|
|
494
690
|
];
|
|
495
|
-
if (columns) attrs.push(`style="--verse-columns: ${verseColumnCount(block)}"`);
|
|
496
691
|
if (block.id) attrs.push(`id="${escape(slugify(block.id))}"`);
|
|
497
|
-
for (const [key, value] of Object.entries(block.attributes))
|
|
498
|
-
if (
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
)
|
|
692
|
+
for (const [key, value] of Object.entries(block.attributes)) {
|
|
693
|
+
if (
|
|
694
|
+
!VERSE_KEYS.includes(key) ||
|
|
695
|
+
refusedAttributes({ [key]: value }).length ||
|
|
696
|
+
(paired && ["lang", "dir", "data-pagefind-ignore", "font", "enc"].includes(key))
|
|
697
|
+
)
|
|
698
|
+
continue;
|
|
699
|
+
attrs.push(
|
|
700
|
+
`${["lang", "dir", "data-pagefind-ignore"].includes(key) ? key : `data-${key}`}="${escape(value)}"`,
|
|
701
|
+
);
|
|
702
|
+
}
|
|
502
703
|
let indent = block.indent;
|
|
503
704
|
if (
|
|
504
705
|
indent.length >= 4 &&
|
|
@@ -509,7 +710,7 @@ export function renderVerse(source) {
|
|
|
509
710
|
"",
|
|
510
711
|
`${indent}<div ${attrs.join(" ")}>`,
|
|
511
712
|
"",
|
|
512
|
-
...(
|
|
713
|
+
...verseMarkdown(block.body, { labelled, model, presentation: transported })
|
|
513
714
|
.split("\n")
|
|
514
715
|
.map((line) => (line ? indent + line : line)),
|
|
515
716
|
"",
|