@heroiclands/package-build 25.2.1 → 25.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +26 -0
- package/README.md +1 -1
- package/assets/styles/content-base.css +97 -116
- package/docs/api.md +30 -6
- package/docs/authoring/links-and-markup.md +214 -126
- package/docs/commands.md +2 -2
- package/docs/content-format.md +31 -6
- package/docs/diagnostics.md +27 -27
- package/docs/guides/writing-in-invented-scripts.md +89 -83
- package/docs/reference/format-details.md +6 -7
- package/docs/reference/note-types.md +15 -15
- package/docs/reference/theme-pages.md +38 -57
- package/engine/anchors.mjs +14 -0
- package/engine/code-fences.mjs +15 -6
- package/engine/content-blocks.mjs +7 -4
- package/engine/content-figures.mjs +19 -5
- package/engine/content-footnotes.mjs +60 -1
- package/engine/content-index.mjs +1 -1
- package/engine/content-render.mjs +66 -48
- package/engine/content-scripts.mjs +357 -101
- package/engine/content-spans.mjs +76 -10
- package/engine/content-verse.mjs +687 -358
- package/engine/first-use.mjs +43 -12
- package/engine/heading-attributes.mjs +2 -1
- package/engine/journals.mjs +8 -1
- package/engine/markdown-blocks.mjs +3 -2
- package/engine/markup-validation.mjs +17 -0
- package/engine/name-markup.mjs +1 -0
- package/engine/note-vocabulary.mjs +2 -2
- package/engine/pdf-render.mjs +89 -130
- package/engine/prose-lint.mjs +14 -2
- package/engine/roman-verse.mjs +68 -57
- package/engine/run-rows.mjs +1 -1
- package/engine/sql-tables.mjs +1 -1
- package/engine/verse-format.mjs +77 -0
- package/engine/verse-metre.mjs +49 -47
- package/engine/verse-regions.mjs +181 -0
- package/engine/wikilink-pattern.mjs +31 -0
- package/engine/wikilink-syntax.mjs +2 -16
- package/package.json +1 -1
- package/site-theme/static/css/style.css +68 -96
- package/types/engine/code-fences.d.mts +1 -3
- package/types/engine/content-blocks.d.mts +3 -1
- package/types/engine/content-footnotes.d.mts +23 -0
- package/types/engine/content-scripts.d.mts +12 -7
- package/types/engine/content-spans.d.mts +3 -1
- package/types/engine/content-verse.d.mts +263 -82
- package/types/engine/name-markup.d.mts +5 -0
- package/types/engine/roman-verse.d.mts +7 -6
- package/types/engine/verse-format.d.mts +9 -0
- package/types/engine/verse-metre.d.mts +4 -4
- package/types/engine/verse-regions.d.mts +78 -0
- package/types/engine/wikilink-pattern.d.mts +17 -0
- package/types/engine/wikilink-syntax.d.mts +1 -41
package/engine/content-verse.mjs
CHANGED
|
@@ -11,12 +11,18 @@
|
|
|
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 footnotePlugin from "markdown-it-footnote";
|
|
16
|
+
import { footnoteLabels, footnoteReferences } from "./content-footnotes.mjs";
|
|
17
|
+
import { inlineText } from "./name-markup.mjs";
|
|
18
|
+
import { pageOpenings } from "./heading-attributes.mjs";
|
|
19
|
+
import { inlineSyntaxRanges, verseRegions } from "./verse-regions.mjs";
|
|
20
|
+
import { blockTokens } from "./markdown-blocks.mjs";
|
|
21
|
+
import { slugify } from "./content-slug.mjs";
|
|
22
|
+
import { scanSpans, spanMarkdownPlugin } from "./content-spans.mjs";
|
|
23
|
+
import { parseExtensionAttributes, refusedAttributes } from "./extension-attributes.mjs";
|
|
24
|
+
|
|
25
|
+
const lineParser = new MarkdownIt({ html: true }).use(footnotePlugin).use(spanMarkdownPlugin);
|
|
20
26
|
|
|
21
27
|
/**
|
|
22
28
|
* Read one verse line. Marker offsets are zero-based in the authored line;
|
|
@@ -51,37 +57,13 @@ export function verseLine(line) {
|
|
|
51
57
|
};
|
|
52
58
|
}
|
|
53
59
|
|
|
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;
|
|
60
|
+
/** The structural first span of a line, with its source ranges and keys. */
|
|
61
|
+
function leadingSpan(text) {
|
|
62
|
+
if (!text.startsWith("[")) return undefined;
|
|
63
|
+
const tokens = lineParser.parseInline(text, {})[0]?.children ?? [];
|
|
64
|
+
return tokens.find((token) => token.meta?.span && token.meta.start === 0);
|
|
80
65
|
}
|
|
81
66
|
|
|
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
67
|
/** HTML indentation attributes; large levels retain their full authored depth. */
|
|
86
68
|
function indentStyle(level) {
|
|
87
69
|
return level > 8 ? ` style="--verse-indent: ${level * 1.25}em"` : "";
|
|
@@ -95,229 +77,105 @@ export function inlineVerse(text) {
|
|
|
95
77
|
}
|
|
96
78
|
|
|
97
79
|
/**
|
|
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.
|
|
80
|
+
* Whether text runs right to left: its first letter is in a right-to-left
|
|
81
|
+
* block. A face's right-to-left code points sit in U+1E800–U+1EFFF.
|
|
138
82
|
*
|
|
139
|
-
* @param {
|
|
140
|
-
* @
|
|
141
|
-
* @param {number} step - The verse's `numstep`.
|
|
142
|
-
* @returns {string} The rows as Markdown with inline HTML.
|
|
83
|
+
* @param {string} text - Text, after any map.
|
|
84
|
+
* @returns {boolean} Whether it starts right to left.
|
|
143
85
|
*/
|
|
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);
|
|
86
|
+
export function rightToLeft(text) {
|
|
87
|
+
const first = /[\p{L}\p{M}\p{Co}\p{Cn}]/u.exec(
|
|
88
|
+
String(text)
|
|
89
|
+
.replace(/<[^>]*>/g, "")
|
|
90
|
+
.replace(/\{[^}]*\}/g, ""),
|
|
91
|
+
);
|
|
92
|
+
return Boolean(
|
|
93
|
+
first &&
|
|
94
|
+
/[\u0590-\u08FF\uFB1D-\uFDFF\uFE70-\uFEFF\u{10800}-\u{10FFF}\u{1E800}-\u{1EFFF}]/u.test(
|
|
95
|
+
first[0],
|
|
96
|
+
),
|
|
97
|
+
);
|
|
195
98
|
}
|
|
196
99
|
|
|
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
|
-
*/
|
|
100
|
+
/** The authored attributes of a verse div. */
|
|
206
101
|
export const VERSE_KEYS = Object.freeze([
|
|
207
102
|
"form",
|
|
208
103
|
"scan",
|
|
209
104
|
"lang",
|
|
210
105
|
"font",
|
|
211
106
|
"enc",
|
|
212
|
-
"
|
|
213
|
-
"cols",
|
|
107
|
+
"work",
|
|
214
108
|
"dir",
|
|
215
109
|
"data-pagefind-ignore",
|
|
216
110
|
]);
|
|
217
111
|
|
|
218
|
-
/**
|
|
112
|
+
/** A canto separator, including the escaped form produced by a native run. */
|
|
113
|
+
export const STANZA_BREAK = /^(?:\\?\*){3}$/;
|
|
114
|
+
|
|
115
|
+
/** Read verse divs; start/end are inclusive zero-based source lines.
|
|
219
116
|
* @param {string} source - Authored Markdown.
|
|
117
|
+
* @param {object} [options] - Whole-page validation choices.
|
|
118
|
+
* @param {boolean} [options.checkWorks] - Whether to validate page-local versions.
|
|
119
|
+
* @returns {{blocks: VerseBlock[], errors: VerseDiagnostic[]}} Located divs and findings.
|
|
220
120
|
*/
|
|
221
|
-
export function scanVerse(source) {
|
|
222
|
-
const
|
|
121
|
+
export function scanVerse(source, { checkWorks = true } = {}) {
|
|
122
|
+
const text = String(source ?? "");
|
|
123
|
+
const lines = text.split("\n"),
|
|
223
124
|
blocks = [],
|
|
224
125
|
errors = [];
|
|
225
|
-
const
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
126
|
+
for (const token of blockTokens(text)) {
|
|
127
|
+
if (token.type === "fence" && /^verse(?:\s|$)/.test(token.info.trim()))
|
|
128
|
+
errors.push({
|
|
129
|
+
line: token.map[0] + 1,
|
|
130
|
+
column: 1,
|
|
131
|
+
message: "verse uses a .verse div: write ::: {.verse …} and a closing :::",
|
|
132
|
+
});
|
|
229
133
|
}
|
|
230
|
-
|
|
231
|
-
for (
|
|
232
|
-
|
|
233
|
-
const
|
|
234
|
-
if (opening) {
|
|
235
|
-
if (
|
|
236
|
-
match &&
|
|
237
|
-
match[1][0] === opening.fence[0] &&
|
|
238
|
-
match[1].length >= opening.fence.length &&
|
|
239
|
-
!match[2].trim()
|
|
240
|
-
) {
|
|
241
|
-
if (opening.name === "verse") {
|
|
242
|
-
const body = lines.slice(opening.start + 1, i).join("\n");
|
|
243
|
-
if (!body.trim())
|
|
244
|
-
errors.push({
|
|
245
|
-
line: opening.start + 1,
|
|
246
|
-
column: 1,
|
|
247
|
-
message: "verse block is empty",
|
|
248
|
-
});
|
|
249
|
-
for (let n = opening.start + 1; n < i; n++) {
|
|
250
|
-
const col = lines[n].indexOf("\t");
|
|
251
|
-
if (col >= 0)
|
|
252
|
-
errors.push({
|
|
253
|
-
line: n + 1,
|
|
254
|
-
column: col + 1,
|
|
255
|
-
message: "tabs are not allowed in verse",
|
|
256
|
-
});
|
|
257
|
-
}
|
|
258
|
-
const block = { ...opening, end: i, body };
|
|
259
|
-
const columned = hasColumns(block);
|
|
260
|
-
if (columned) errors.push(...columnFindings(block, lines));
|
|
261
|
-
blocks.push(block);
|
|
262
|
-
}
|
|
263
|
-
opening = null;
|
|
264
|
-
}
|
|
265
|
-
continue;
|
|
266
|
-
}
|
|
267
|
-
if (!match) continue;
|
|
268
|
-
const info = match[2].trim();
|
|
269
|
-
opening = {
|
|
270
|
-
start: i,
|
|
271
|
-
fence: match[1],
|
|
272
|
-
name: info.split(/\s/)[0],
|
|
273
|
-
attributes: {},
|
|
274
|
-
id: "",
|
|
275
|
-
classes: [],
|
|
276
|
-
indent: /^ */.exec(lines[i])[0],
|
|
277
|
-
};
|
|
278
|
-
if (opening.name !== "verse") continue;
|
|
279
|
-
const raw = info.slice(6).trim();
|
|
280
|
-
const at = { line: i + 1, column: 1 };
|
|
281
|
-
if (raw && !/^\{[^}\n]*\}$/.test(raw)) {
|
|
282
|
-
errors.push({ ...at, message: "verse attributes need {…} braces" });
|
|
283
|
-
continue;
|
|
284
|
-
}
|
|
285
|
-
const parsed = parseExtensionAttributes(raw ? raw.slice(1, -1) : "");
|
|
286
|
-
opening.id = parsed.id;
|
|
287
|
-
opening.classes = parsed.classes;
|
|
288
|
-
opening.attributes = parsed.values;
|
|
289
|
-
for (const message of [...parsed.problems, ...refusedAttributes(parsed.values)])
|
|
134
|
+
const definedFootnotes = footnoteLabels(text);
|
|
135
|
+
for (const region of verseRegions(text)) {
|
|
136
|
+
const at = { line: region.start + 1, column: region.indent.length + 1 };
|
|
137
|
+
for (const message of [...region.problems, ...refusedAttributes(region.attributes)])
|
|
290
138
|
errors.push({ ...at, message });
|
|
291
|
-
|
|
139
|
+
if (!region.closed) errors.push({ ...at, message: "verse div needs a closing ::: line" });
|
|
140
|
+
if (!region.body.trim()) errors.push({ ...at, message: "verse div is empty" });
|
|
141
|
+
for (const key of Object.keys(region.attributes))
|
|
292
142
|
if (!VERSE_KEYS.includes(key))
|
|
293
143
|
errors.push({
|
|
294
|
-
|
|
295
|
-
column: keyColumn(lines[
|
|
144
|
+
...at,
|
|
145
|
+
column: keyColumn(lines[region.start], key),
|
|
296
146
|
message: `${key}= is not a verse attribute`,
|
|
297
147
|
});
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
148
|
+
if (region.attributes.work !== undefined && !region.attributes.work.trim())
|
|
149
|
+
errors.push({ ...at, message: "work= needs a nonempty page-local key" });
|
|
150
|
+
const model = parseVerse(region.body, { footnotes: definedFootnotes });
|
|
151
|
+
for (const finding of model.errors) {
|
|
152
|
+
const preceding = text.slice(0, region.bodyStart + finding.offset);
|
|
153
|
+
errors.push({
|
|
154
|
+
line: preceding.split("\n").length,
|
|
155
|
+
column: preceding.length - preceding.lastIndexOf("\n"),
|
|
156
|
+
message: finding.message,
|
|
157
|
+
});
|
|
158
|
+
}
|
|
159
|
+
for (const match of region.body.matchAll(/\t/g)) {
|
|
160
|
+
const preceding = text.slice(0, region.bodyStart + match.index);
|
|
161
|
+
errors.push({
|
|
162
|
+
line: preceding.split("\n").length,
|
|
163
|
+
column: preceding.length - preceding.lastIndexOf("\n"),
|
|
164
|
+
message: "tabs are not allowed in verse",
|
|
165
|
+
});
|
|
166
|
+
}
|
|
167
|
+
blocks.push({ ...region, model });
|
|
168
|
+
}
|
|
169
|
+
if (checkWorks) {
|
|
170
|
+
const openings = [...pageOpenings(source).keys()];
|
|
171
|
+
const pages = new Map();
|
|
172
|
+
for (const block of blocks) {
|
|
173
|
+
const page = openings.findLast((line) => line <= block.start) ?? -1;
|
|
174
|
+
if (!pages.has(page)) pages.set(page, []);
|
|
175
|
+
pages.get(page).push(block);
|
|
313
176
|
}
|
|
177
|
+
for (const page of pages.values()) errors.push(...workShapeFindings(page));
|
|
314
178
|
}
|
|
315
|
-
if (opening?.name === "verse")
|
|
316
|
-
errors.push({
|
|
317
|
-
line: opening.start + 1,
|
|
318
|
-
column: 1,
|
|
319
|
-
message: "verse block needs a closing code fence",
|
|
320
|
-
});
|
|
321
179
|
errors.sort((a, b) => a.line - b.line || a.column - b.column);
|
|
322
180
|
return { blocks, errors };
|
|
323
181
|
}
|
|
@@ -325,7 +183,7 @@ export function scanVerse(source) {
|
|
|
325
183
|
/**
|
|
326
184
|
* The 1-based column of `key=`, or of a class or id token, in an opening line.
|
|
327
185
|
*
|
|
328
|
-
* @param {string} opening - The
|
|
186
|
+
* @param {string} opening - The div's opening line.
|
|
329
187
|
* @param {string} key - The key, or a `.class` or `#id` token.
|
|
330
188
|
* @returns {number} Its column, or the brace's when it is not found.
|
|
331
189
|
*/
|
|
@@ -338,167 +196,607 @@ function keyColumn(opening, key) {
|
|
|
338
196
|
return (at < 0 ? brace : brace + at) + 1;
|
|
339
197
|
}
|
|
340
198
|
|
|
341
|
-
/**
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
}
|
|
199
|
+
/**
|
|
200
|
+
* A logical line with its authored UTF-16 coordinates.
|
|
201
|
+
* @typedef {object} VerseLine
|
|
202
|
+
* @property {string} text - Inline Markdown in this presentation.
|
|
203
|
+
* @property {number} level - Leading-period indentation depth.
|
|
204
|
+
* @property {number} from - First authored offset, including indentation.
|
|
205
|
+
* @property {number} to - Offset at the line ending.
|
|
206
|
+
* @property {number[]} offsets - Source offset for each UTF-16 text unit.
|
|
207
|
+
* @property {string} [romanText] - Roman text paired with an encoded line.
|
|
208
|
+
*/
|
|
347
209
|
|
|
348
|
-
/**
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
}
|
|
210
|
+
/**
|
|
211
|
+
* A stanza's logical lines and authored bounds.
|
|
212
|
+
* @typedef {object} VerseStanza
|
|
213
|
+
* @property {VerseLine[]} lines - Lines in reading order.
|
|
214
|
+
* @property {number} from - First authored offset.
|
|
215
|
+
* @property {number} to - Offset at the stanza ending.
|
|
216
|
+
*/
|
|
352
217
|
|
|
353
|
-
/**
|
|
354
|
-
|
|
218
|
+
/**
|
|
219
|
+
* @typedef {VerseStanza & {canto: number, stanza: number, label: string, cantoBreak?: false}} VerseTextGroup
|
|
220
|
+
* @typedef {{cantoBreak: true, from: number, to: number, text?: string, id?: string, title?: string}} VerseCantoBreak
|
|
221
|
+
* @typedef {{kind: "line"|"stanza"|"canto", sourceOffset: number}} VerseBoundary
|
|
222
|
+
* @typedef {{offset: number, message: string}} VerseFinding
|
|
223
|
+
*/
|
|
355
224
|
|
|
356
225
|
/**
|
|
357
|
-
*
|
|
358
|
-
*
|
|
359
|
-
*
|
|
360
|
-
*
|
|
361
|
-
* @
|
|
362
|
-
* @
|
|
363
|
-
* @
|
|
226
|
+
* Shared authored verse structure and source locations.
|
|
227
|
+
* @typedef {object} VerseModel
|
|
228
|
+
* @property {Array<{stanzas: VerseStanza[]}>} cantos - Cantos in reading order.
|
|
229
|
+
* @property {Array<VerseTextGroup|VerseCantoBreak>} groups - Stanzas and canto breaks.
|
|
230
|
+
* @property {VerseBoundary[]} boundaries - Explicit authored boundaries.
|
|
231
|
+
* @property {Array<[number, number]>} layout - Source ranges omitted from encoded letters.
|
|
232
|
+
* @property {VerseFinding[]} errors - Findings relative to the verse body.
|
|
233
|
+
* @property {VerseLine[]} notes - Defined references without substantive verse text.
|
|
234
|
+
*/
|
|
235
|
+
|
|
236
|
+
/**
|
|
237
|
+
* An authored verse div with its parsed logical structure.
|
|
238
|
+
* @typedef {object} VerseBlock
|
|
239
|
+
* @property {number} start - Opening div's zero-based source line.
|
|
240
|
+
* @property {number} end - Closing div's zero-based source line.
|
|
241
|
+
* @property {number} from - Complete div first offset.
|
|
242
|
+
* @property {number} to - Offset past complete div.
|
|
243
|
+
* @property {number} bodyStart - First body offset.
|
|
244
|
+
* @property {number} bodyEnd - Offset past body text.
|
|
245
|
+
* @property {boolean} closed - Whether the closing delimiter exists.
|
|
246
|
+
* @property {string[]} problems - Attribute syntax findings.
|
|
247
|
+
* @property {"verse"} name - Region name.
|
|
248
|
+
* @property {Record<string, string>} attributes - Authored attribute values.
|
|
249
|
+
* @property {string} id - Authored anchor, or the empty string.
|
|
250
|
+
* @property {string[]} classes - Authored presentation classes.
|
|
251
|
+
* @property {string} indent - Opening div's leading spaces.
|
|
252
|
+
* @property {string} body - Authored verse body.
|
|
253
|
+
* @property {VerseModel} model - Logical structure relative to the body.
|
|
254
|
+
*/
|
|
255
|
+
|
|
256
|
+
/** @typedef {{line: number, column: number, message: string}} VerseDiagnostic */
|
|
257
|
+
|
|
258
|
+
/**
|
|
259
|
+
* An encoded verse's shared display model and native-cell presentation.
|
|
260
|
+
* @typedef {object} VersePresentation
|
|
261
|
+
* @property {VerseModel} model - Logical structure with paired line text.
|
|
262
|
+
* @property {string[]} classes - Native script and font classes.
|
|
263
|
+
* @property {Record<string, string>} attributes - Native language, direction and search attributes.
|
|
364
264
|
*/
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
const
|
|
369
|
-
|
|
370
|
-
|
|
265
|
+
|
|
266
|
+
/** Read a standard canto separator's semantic attributes. */
|
|
267
|
+
function cantoLine(line) {
|
|
268
|
+
const match = /^---[ \t]+(\{.*)$/.exec(line.trim());
|
|
269
|
+
if (!match) return undefined;
|
|
270
|
+
const closed = match[1].endsWith("}");
|
|
271
|
+
const parsed = parseExtensionAttributes(closed ? match[1].slice(1, -1) : match[1].slice(1));
|
|
272
|
+
if (!parsed.classes.includes("canto")) return undefined;
|
|
273
|
+
if (!closed) parsed.problems.push("a canto break needs --- {.canto} attribute braces");
|
|
274
|
+
for (const key of Object.keys(parsed.values))
|
|
275
|
+
if (key !== "title") parsed.problems.push(`${key}= is not a canto attribute`);
|
|
276
|
+
for (const name of parsed.classes)
|
|
277
|
+
if (name !== "canto") parsed.problems.push(`.${name} is not a canto class`);
|
|
278
|
+
return {
|
|
279
|
+
id: parsed.id,
|
|
280
|
+
title: parsed.values.title ?? "",
|
|
281
|
+
problems: [...parsed.problems, ...refusedAttributes(parsed.values)],
|
|
282
|
+
};
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
/**
|
|
286
|
+
* Parse logical verse lines, stanzas and cantos without losing authored offsets.
|
|
287
|
+
* Offsets are UTF-16 positions relative to body. Delimiters and indentation are
|
|
288
|
+
* layout ranges; source newlines inside a logical line contribute one space.
|
|
289
|
+
* @param {string} body - Authored verse body.
|
|
290
|
+
* @param {{inline?: boolean, footnotes?: ReadonlySet<string>}} [options] - Inline quotations have no cantos; footnotes are same-note definition labels.
|
|
291
|
+
* @returns {VerseModel} The shared structure with offsets relative to the body.
|
|
292
|
+
*/
|
|
293
|
+
export function parseVerse(body, { inline = false, footnotes = new Set() } = {}) {
|
|
294
|
+
const source = String(body ?? "");
|
|
295
|
+
const groups = [],
|
|
296
|
+
boundaries = [],
|
|
297
|
+
layout = [],
|
|
298
|
+
errors = [];
|
|
299
|
+
if (inline)
|
|
300
|
+
for (const match of source.matchAll(/\r?\n[ \t]*\r?\n/g))
|
|
301
|
+
errors.push({
|
|
302
|
+
offset: match.index,
|
|
303
|
+
message: "an inline .verse quotation uses // for a stanza break, not a blank line",
|
|
304
|
+
});
|
|
305
|
+
const cantos = [{ stanzas: [] }];
|
|
306
|
+
const references = footnoteReferences(source, { definitions: footnotes, inline: true });
|
|
307
|
+
layout.push(...references.map((ref) => [ref.from, ref.to]));
|
|
308
|
+
const protectedRanges = [
|
|
309
|
+
...inlineSyntaxRanges(source),
|
|
310
|
+
...references.map((ref) => [ref.from, ref.to]),
|
|
311
|
+
].sort((a, b) => a[0] - b[0] || b[1] - a[1]);
|
|
312
|
+
const children = [];
|
|
313
|
+
lineParser.inline.parse(source, lineParser, {}, children);
|
|
314
|
+
const spans = children
|
|
315
|
+
.filter((token) => token.meta?.span)
|
|
316
|
+
.map((token) => ({
|
|
317
|
+
start: token.meta.start,
|
|
318
|
+
end: token.meta.end,
|
|
319
|
+
contentEnd: token.meta.start + 1 + token.meta.text.length,
|
|
320
|
+
}));
|
|
321
|
+
let protectedIndex = 0;
|
|
322
|
+
let text = "",
|
|
323
|
+
offsets = [],
|
|
324
|
+
from = 0,
|
|
325
|
+
level = 0,
|
|
326
|
+
lines = [];
|
|
327
|
+
const pendingNotes = [];
|
|
328
|
+
let pendingBreak;
|
|
329
|
+
const cantoIds = new Set();
|
|
330
|
+
if (inline)
|
|
331
|
+
for (const match of source.matchAll(/---[ \t]+\{[^}]*\.canto[^}]*\}/g)) {
|
|
332
|
+
const escaped = /\\+$/.exec(source.slice(0, match.index))?.[0].length ?? 0;
|
|
333
|
+
if (
|
|
334
|
+
escaped % 2 ||
|
|
335
|
+
protectedRanges.some(([first, past]) => match.index >= first && match.index < past)
|
|
336
|
+
)
|
|
337
|
+
continue;
|
|
338
|
+
errors.push({
|
|
339
|
+
offset: match.index,
|
|
340
|
+
message: "an inline .verse quotation has no canto breaks",
|
|
341
|
+
});
|
|
342
|
+
}
|
|
343
|
+
const append = (value, at) => {
|
|
344
|
+
text += value;
|
|
345
|
+
for (let index = 0; index < value.length; index++) offsets.push(at + index);
|
|
371
346
|
};
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
347
|
+
const finishLine = (at) => {
|
|
348
|
+
let kind = "empty";
|
|
349
|
+
const trimmed = text.trimEnd();
|
|
350
|
+
if (trimmed.trim()) {
|
|
351
|
+
if (
|
|
352
|
+
STANZA_BREAK.test(trimmed) &&
|
|
353
|
+
!errors.some(
|
|
354
|
+
(error) =>
|
|
355
|
+
error.offset >= from &&
|
|
356
|
+
error.offset <= at &&
|
|
357
|
+
error.message.includes("--- {.canto}"),
|
|
358
|
+
)
|
|
359
|
+
)
|
|
360
|
+
errors.push({
|
|
361
|
+
offset: from,
|
|
362
|
+
message: "write a canto break as --- {.canto}, preceded by a blank line",
|
|
363
|
+
});
|
|
364
|
+
let lineText = trimmed,
|
|
365
|
+
lineOffsets = offsets.slice(0, trimmed.length);
|
|
366
|
+
for (const span of spans
|
|
367
|
+
.filter((span) => span.start < from && span.contentEnd >= from)
|
|
368
|
+
.sort((a, b) => b.start - a.start)) {
|
|
369
|
+
lineText = "[" + lineText;
|
|
370
|
+
lineOffsets.unshift(span.start);
|
|
371
|
+
}
|
|
372
|
+
for (const span of spans
|
|
373
|
+
.filter((span) => span.start < at && span.contentEnd >= at)
|
|
374
|
+
.sort((a, b) => b.start - a.start)) {
|
|
375
|
+
const suffix = source.slice(span.contentEnd, span.end);
|
|
376
|
+
lineText += suffix;
|
|
377
|
+
for (let index = 0; index < suffix.length; index++)
|
|
378
|
+
lineOffsets.push(span.contentEnd + index);
|
|
379
|
+
}
|
|
380
|
+
const repeatedIds = spans
|
|
381
|
+
.filter((span) => span.start < from && span.contentEnd >= from)
|
|
382
|
+
.flatMap((span) => {
|
|
383
|
+
const raw = source.slice(span.contentEnd + 2, span.end - 1);
|
|
384
|
+
return [...raw.matchAll(/(?:[^\s"]+|"(?:\\.|[^"\\])*")+/g)]
|
|
385
|
+
.filter((match) => match[0].startsWith("#"))
|
|
386
|
+
.map((match) => [
|
|
387
|
+
span.contentEnd + 2 + match.index,
|
|
388
|
+
span.contentEnd + 2 + match.index + match[0].length,
|
|
389
|
+
]);
|
|
390
|
+
});
|
|
391
|
+
if (repeatedIds.length) {
|
|
392
|
+
const kept = lineOffsets
|
|
393
|
+
.map((offset, index) => ({ offset, char: lineText[index] }))
|
|
394
|
+
.filter(
|
|
395
|
+
({ offset }) =>
|
|
396
|
+
!repeatedIds.some(([first, past]) => offset >= first && offset < past),
|
|
397
|
+
);
|
|
398
|
+
lineText = kept.map(({ char }) => char).join("");
|
|
399
|
+
lineOffsets = kept.map(({ offset }) => offset);
|
|
400
|
+
}
|
|
401
|
+
const line = { text: lineText, level, from, to: at, offsets: lineOffsets };
|
|
402
|
+
const visible = [];
|
|
403
|
+
const hasReferences =
|
|
404
|
+
footnoteReferences(lineText, { definitions: footnotes, inline: true }).length > 0;
|
|
405
|
+
if (hasReferences)
|
|
406
|
+
lineParser.inline.parse(
|
|
407
|
+
lineText,
|
|
408
|
+
lineParser,
|
|
409
|
+
{
|
|
410
|
+
footnotes: {
|
|
411
|
+
refs: Object.fromEntries([...footnotes].map((key) => [":" + key, -1])),
|
|
412
|
+
},
|
|
413
|
+
},
|
|
414
|
+
visible,
|
|
415
|
+
);
|
|
416
|
+
const onlyReferences =
|
|
417
|
+
hasReferences &&
|
|
418
|
+
!inlineText(visible).trim() &&
|
|
419
|
+
!visible.some((token) => token.type === "image");
|
|
420
|
+
kind = onlyReferences ? "note" : "content";
|
|
421
|
+
if (onlyReferences) {
|
|
422
|
+
const previous =
|
|
423
|
+
lines.at(-1) ?? groups.findLast((group) => !group.cantoBreak)?.lines.at(-1);
|
|
424
|
+
if (previous) {
|
|
425
|
+
previous.text += line.text;
|
|
426
|
+
previous.offsets.push(...line.offsets);
|
|
427
|
+
} else pendingNotes.push(line);
|
|
428
|
+
} else {
|
|
429
|
+
if (pendingNotes.length) {
|
|
430
|
+
line.text = pendingNotes.map((note) => note.text).join("") + line.text;
|
|
431
|
+
line.offsets = [
|
|
432
|
+
...pendingNotes.flatMap((note) => note.offsets),
|
|
433
|
+
...line.offsets,
|
|
434
|
+
];
|
|
435
|
+
pendingNotes.length = 0;
|
|
436
|
+
}
|
|
437
|
+
lines.push(line);
|
|
438
|
+
}
|
|
439
|
+
}
|
|
440
|
+
text = "";
|
|
441
|
+
offsets = [];
|
|
442
|
+
level = 0;
|
|
443
|
+
from = at;
|
|
444
|
+
return kind;
|
|
445
|
+
};
|
|
446
|
+
const finishStanza = (at) => {
|
|
447
|
+
const kind = finishLine(at);
|
|
448
|
+
const emitted = lines.length > 0;
|
|
449
|
+
if (lines.length) {
|
|
450
|
+
const stanza = { lines, from: lines[0].from, to: at };
|
|
451
|
+
cantos.at(-1).stanzas.push(stanza);
|
|
452
|
+
groups.push({
|
|
453
|
+
...stanza,
|
|
454
|
+
canto: cantos.length,
|
|
455
|
+
stanza: cantos.at(-1).stanzas.length,
|
|
456
|
+
label: "",
|
|
457
|
+
});
|
|
458
|
+
lines = [];
|
|
459
|
+
}
|
|
460
|
+
return { kind, emitted };
|
|
461
|
+
};
|
|
462
|
+
const event = (kind, at, past) => {
|
|
463
|
+
const result =
|
|
464
|
+
kind === "line" ? { kind: finishLine(at), emitted: false } : finishStanza(at);
|
|
465
|
+
layout.push([at, past]);
|
|
466
|
+
if (!(
|
|
467
|
+
result.kind === "note" &&
|
|
468
|
+
(kind === "line" || (kind === "stanza" && !result.emitted))
|
|
469
|
+
))
|
|
470
|
+
boundaries.push({ kind, sourceOffset: at });
|
|
471
|
+
from = past;
|
|
472
|
+
};
|
|
473
|
+
for (let at = 0; at < source.length;) {
|
|
474
|
+
while (protectedRanges[protectedIndex]?.[1] <= at) protectedIndex++;
|
|
475
|
+
const protectedRange = protectedRanges[protectedIndex];
|
|
476
|
+
const protectedAt = protectedRange && protectedRange[0] <= at;
|
|
477
|
+
const lineStart = at === 0 || /[\r\n]/.test(source[at - 1]);
|
|
478
|
+
if (lineStart && !protectedAt) {
|
|
479
|
+
const physical = /^[^\r\n]*/.exec(source.slice(at))[0];
|
|
480
|
+
const newline = /^(?:\r\n|\r|\n)/.exec(source.slice(at + physical.length))?.[0] ?? "";
|
|
481
|
+
if (!physical.trim()) {
|
|
482
|
+
event("stanza", at, at + physical.length + newline.length);
|
|
483
|
+
at += physical.length + newline.length;
|
|
484
|
+
continue;
|
|
485
|
+
}
|
|
486
|
+
if (STANZA_BREAK.test(physical.trim()))
|
|
487
|
+
errors.push({
|
|
488
|
+
offset: at,
|
|
489
|
+
message: "write a canto break as --- {.canto}, preceded by a blank line",
|
|
490
|
+
});
|
|
491
|
+
const canto = cantoLine(physical);
|
|
492
|
+
if (canto) {
|
|
493
|
+
if (inline) {
|
|
494
|
+
if (!errors.some((error) => error.offset === at))
|
|
495
|
+
errors.push({
|
|
496
|
+
offset: at,
|
|
497
|
+
message: "an inline .verse quotation has no canto breaks",
|
|
498
|
+
});
|
|
499
|
+
} else {
|
|
500
|
+
for (const message of canto.problems) errors.push({ offset: at, message });
|
|
501
|
+
if (
|
|
502
|
+
at &&
|
|
503
|
+
source.slice(0, at).trimEnd().length &&
|
|
504
|
+
!/(?:\r?\n)[ \t]*(?:\r?\n)$/.test(source.slice(0, at))
|
|
505
|
+
)
|
|
506
|
+
errors.push({
|
|
507
|
+
offset: at,
|
|
508
|
+
message: "a canto break needs a blank line before it",
|
|
509
|
+
});
|
|
510
|
+
if (canto.id && cantoIds.has(canto.id))
|
|
511
|
+
errors.push({
|
|
512
|
+
offset: at,
|
|
513
|
+
message: `canto anchor #${canto.id} is written more than once`,
|
|
514
|
+
});
|
|
515
|
+
if (canto.id) cantoIds.add(canto.id);
|
|
516
|
+
event("canto", at, at + physical.length + newline.length);
|
|
517
|
+
if (!cantos.at(-1).stanzas.length)
|
|
518
|
+
errors.push({
|
|
519
|
+
offset: at,
|
|
520
|
+
message: "a canto break needs a stanza before and after it",
|
|
521
|
+
});
|
|
522
|
+
else {
|
|
523
|
+
groups.push({
|
|
524
|
+
cantoBreak: true,
|
|
525
|
+
from: at,
|
|
526
|
+
to: at + physical.length,
|
|
527
|
+
id: canto.id,
|
|
528
|
+
title: canto.title,
|
|
529
|
+
});
|
|
530
|
+
cantos.push({ stanzas: [] });
|
|
531
|
+
}
|
|
532
|
+
pendingBreak = at;
|
|
533
|
+
at += physical.length + newline.length;
|
|
534
|
+
continue;
|
|
535
|
+
}
|
|
536
|
+
}
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
if (!text && !/\s/.test(source[at])) {
|
|
540
|
+
if (!level) from = at;
|
|
541
|
+
const remaining = source.slice(at);
|
|
542
|
+
const reading = verseLine(remaining.split(/\r?\n/)[0]);
|
|
543
|
+
if (reading.markerLength) {
|
|
544
|
+
level = reading.level;
|
|
545
|
+
const first = at + reading.markerStart;
|
|
546
|
+
layout.push([first, first + reading.markerLength]);
|
|
547
|
+
// A leading span keeps its brackets and attributes around the indented text.
|
|
548
|
+
if (reading.markerStart) append(source.slice(at, first), at);
|
|
549
|
+
at = first + reading.markerLength;
|
|
550
|
+
continue;
|
|
551
|
+
}
|
|
552
|
+
}
|
|
553
|
+
if (!protectedAt && source[at] === "\\" && at + 1 < source.length) {
|
|
554
|
+
append(source.slice(at, at + 2), at);
|
|
555
|
+
at += 2;
|
|
376
556
|
continue;
|
|
377
557
|
}
|
|
378
|
-
if (
|
|
379
|
-
|
|
380
|
-
|
|
558
|
+
if (!protectedAt && source[at] === "/") {
|
|
559
|
+
const length = source[at + 1] === "/" ? 2 : 1;
|
|
560
|
+
event(length === 2 ? "stanza" : "line", at, at + length);
|
|
561
|
+
at += length;
|
|
381
562
|
continue;
|
|
382
563
|
}
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
markerLength: parsed.markerLength,
|
|
397
|
-
prefixLength,
|
|
398
|
-
});
|
|
564
|
+
if (/[\r\n]/.test(source[at])) {
|
|
565
|
+
const length = source[at] === "\r" && source[at + 1] === "\n" ? 2 : 1;
|
|
566
|
+
if (text && !/\s$/.test(text)) append(" ", at);
|
|
567
|
+
at += length;
|
|
568
|
+
while (source[at] === " ") at++;
|
|
569
|
+
continue;
|
|
570
|
+
}
|
|
571
|
+
if (!text && source[at] === " ") {
|
|
572
|
+
at++;
|
|
573
|
+
continue;
|
|
574
|
+
}
|
|
575
|
+
append(source[at], at);
|
|
576
|
+
at++;
|
|
399
577
|
}
|
|
400
|
-
|
|
401
|
-
|
|
578
|
+
finishStanza(source.length);
|
|
579
|
+
if (pendingBreak !== undefined && !cantos.at(-1).stanzas.length) {
|
|
580
|
+
if (!errors.some((error) => error.offset === pendingBreak))
|
|
581
|
+
errors.push({
|
|
582
|
+
offset: pendingBreak,
|
|
583
|
+
message: "a canto break needs a stanza before and after it",
|
|
584
|
+
});
|
|
585
|
+
cantos.pop();
|
|
586
|
+
if (groups.at(-1)?.cantoBreak) groups.pop();
|
|
587
|
+
}
|
|
588
|
+
const multi = cantos.length > 1;
|
|
589
|
+
for (const group of groups)
|
|
590
|
+
if (!group.cantoBreak)
|
|
591
|
+
group.label = multi ? `${group.canto}:${group.stanza}` : String(group.stanza);
|
|
592
|
+
return { cantos, groups, boundaries, layout, errors, notes: pendingNotes };
|
|
402
593
|
}
|
|
403
594
|
|
|
404
|
-
/**
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
595
|
+
/**
|
|
596
|
+
* Validate a quoted verse span using its original source coordinates.
|
|
597
|
+
* @param {{start: number, text: string, attrs: Array<[string,string]>}} span - Authored span.
|
|
598
|
+
* @param {string} source - Complete Markdown source.
|
|
599
|
+
* @returns {VerseDiagnostic[]} Located inline-only findings.
|
|
600
|
+
*/
|
|
601
|
+
export function inlineVerseFindings(span, source) {
|
|
602
|
+
const findings = parseVerse(span.text, {
|
|
603
|
+
inline: true,
|
|
604
|
+
footnotes: footnoteLabels(source),
|
|
605
|
+
}).errors;
|
|
606
|
+
for (const [key] of span.attrs)
|
|
607
|
+
if (["form", "work", "scan"].includes(key))
|
|
608
|
+
findings.push({ offset: 0, message: `${key}= is not an inline .verse attribute` });
|
|
609
|
+
return findings.map((finding) => {
|
|
610
|
+
const prefix = source.slice(0, span.start + 1 + finding.offset);
|
|
611
|
+
return {
|
|
612
|
+
line: prefix.split("\n").length,
|
|
613
|
+
column: prefix.length - prefix.lastIndexOf("\n"),
|
|
614
|
+
message: finding.message,
|
|
615
|
+
};
|
|
616
|
+
});
|
|
617
|
+
}
|
|
618
|
+
|
|
619
|
+
/** Logical line text for consumers that do not need source locations. */
|
|
620
|
+
export function verseLines(body) {
|
|
621
|
+
return parseVerse(body).groups.flatMap((group) =>
|
|
622
|
+
group.cantoBreak ? [] : group.lines.map((line) => line.text),
|
|
623
|
+
);
|
|
624
|
+
}
|
|
625
|
+
|
|
626
|
+
/** Paragraph groups with separate canto gaps. */
|
|
627
|
+
export function verseStanzas(body) {
|
|
628
|
+
return parseVerse(body).groups.map((group) =>
|
|
629
|
+
group.cantoBreak ?
|
|
630
|
+
[{ text: "***", level: 0 }]
|
|
631
|
+
: group.lines.map(({ text, level }) => ({ text, level })),
|
|
632
|
+
);
|
|
633
|
+
}
|
|
634
|
+
|
|
635
|
+
/** Whether the projected group is a canto gap. */
|
|
636
|
+
export const stanzaSeparator = (group) => group.length === 1 && STANZA_BREAK.test(group[0].text);
|
|
637
|
+
|
|
638
|
+
/**
|
|
639
|
+
* Compare the stanza counts of each version of a page-local work.
|
|
640
|
+
* @param {VerseBlock[]} blocks - Divs belonging to one page.
|
|
641
|
+
* @returns {VerseDiagnostic[]} Findings at affected opening divs.
|
|
642
|
+
*/
|
|
643
|
+
export function workShapeFindings(blocks) {
|
|
644
|
+
const works = new Map(),
|
|
645
|
+
errors = [];
|
|
646
|
+
for (const block of blocks) {
|
|
647
|
+
const key = block.attributes.work;
|
|
648
|
+
if (key === undefined) continue;
|
|
649
|
+
if (!works.has(key)) works.set(key, []);
|
|
650
|
+
works.get(key).push(block);
|
|
651
|
+
}
|
|
652
|
+
for (const [key, versions] of works) {
|
|
653
|
+
if (versions.length === 1) {
|
|
654
|
+
errors.push({
|
|
655
|
+
line: versions[0].start + 1,
|
|
656
|
+
column: 1,
|
|
657
|
+
message: `work=${key} has only one version`,
|
|
658
|
+
});
|
|
659
|
+
continue;
|
|
660
|
+
}
|
|
661
|
+
const first = versions[0];
|
|
662
|
+
const shape = (block) => block.model.cantos.map((canto) => canto.stanzas.length);
|
|
663
|
+
const expected = shape(first);
|
|
664
|
+
const seen = new Set();
|
|
665
|
+
for (const version of versions.slice(1)) {
|
|
666
|
+
const actual = shape(version);
|
|
667
|
+
if (actual.length !== expected.length) {
|
|
668
|
+
const message = `work=${key}: ${expected.length} cantos in the first version and ${actual.length} in this version`;
|
|
669
|
+
for (const block of [first, version]) {
|
|
670
|
+
const identity = `${block.start}:${message}`;
|
|
671
|
+
if (!seen.has(identity)) {
|
|
672
|
+
errors.push({ line: block.start + 1, column: 1, message });
|
|
673
|
+
seen.add(identity);
|
|
674
|
+
}
|
|
675
|
+
}
|
|
676
|
+
}
|
|
677
|
+
for (let canto = 0; canto < Math.max(expected.length, actual.length); canto++) {
|
|
678
|
+
if (expected[canto] === actual[canto]) continue;
|
|
679
|
+
const message = `work=${key}, canto ${canto + 1}: ${expected[canto] ?? 0} stanzas in the first version and ${actual[canto] ?? 0} in this version`;
|
|
680
|
+
for (const block of [first, version]) {
|
|
681
|
+
const identity = `${block.start}:${message}`;
|
|
682
|
+
if (!seen.has(identity)) {
|
|
683
|
+
errors.push({ line: block.start + 1, column: 1, message });
|
|
684
|
+
seen.add(identity);
|
|
685
|
+
}
|
|
686
|
+
}
|
|
427
687
|
}
|
|
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
688
|
}
|
|
448
689
|
}
|
|
449
690
|
return errors;
|
|
450
691
|
}
|
|
451
692
|
|
|
452
693
|
/**
|
|
453
|
-
*
|
|
454
|
-
*
|
|
455
|
-
*
|
|
456
|
-
* @param {
|
|
457
|
-
* @
|
|
694
|
+
* Render a shared verse structure while preserving surrounding inline Markdown.
|
|
695
|
+
* @param {string} body - Authored verse body.
|
|
696
|
+
* @param {object} [options] - Shared structure and display choices.
|
|
697
|
+
* @param {boolean} [options.labelled] - Whether each stanza carries its label.
|
|
698
|
+
* @param {VerseModel} [options.model] - Parsed structure; defaults to parsing the body.
|
|
699
|
+
* @param {VersePresentation} [options.presentation] - Native-cell font and language.
|
|
700
|
+
* @param {boolean} [options.inline] - Whether wrappers must remain inline-safe.
|
|
701
|
+
* @returns {string} Markdown with verse layout wrappers.
|
|
458
702
|
*/
|
|
459
|
-
export function
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
703
|
+
export function verseMarkdown(
|
|
704
|
+
body,
|
|
705
|
+
{ labelled = false, model = parseVerse(body), presentation, inline = false } = {},
|
|
706
|
+
) {
|
|
707
|
+
const escape = (value) =>
|
|
708
|
+
String(value)
|
|
709
|
+
.replace(/&/g, "&")
|
|
710
|
+
.replace(/"/g, """)
|
|
711
|
+
.replace(/</g, "<")
|
|
712
|
+
.replace(/>/g, ">");
|
|
713
|
+
const nativeAttrs = Object.entries(presentation?.attributes ?? {})
|
|
714
|
+
.filter(([key]) => ["lang", "dir", "data-pagefind-ignore"].includes(key))
|
|
715
|
+
.map(([key, value]) => ` ${key}="${escape(value)}"`)
|
|
716
|
+
.join("");
|
|
717
|
+
const nativeClasses = ["verse-native", ...(presentation?.classes ?? [])].join(" ");
|
|
718
|
+
const content = model.groups
|
|
719
|
+
.map((group) => {
|
|
720
|
+
if (group.cantoBreak) {
|
|
721
|
+
const mark = group.text ? inlineVerse(group.text) : "";
|
|
722
|
+
const content =
|
|
723
|
+
mark && presentation ?
|
|
724
|
+
`<span class="${escape(nativeClasses)}"${nativeAttrs}${!presentation.attributes?.dir && rightToLeft(group.text) ? ' dir="rtl"' : ""}>${mark}</span>`
|
|
725
|
+
: mark;
|
|
726
|
+
const heading =
|
|
727
|
+
group.title ?
|
|
728
|
+
`<span class="verse-canto-heading" role="heading" aria-level="3"${group.id ? ` id="${escape(slugify(group.id))}"` : ""}>${inlineVerse(group.title)}</span>`
|
|
729
|
+
: group.id ? `<span id="${escape(slugify(group.id))}"></span>`
|
|
730
|
+
: "";
|
|
731
|
+
return (
|
|
732
|
+
heading + `<span class="verse-canto-break" aria-hidden="true">${content}</span>`
|
|
733
|
+
);
|
|
734
|
+
}
|
|
735
|
+
const label = labelled ? `<span class="verse-label">${group.label}</span>` : "";
|
|
736
|
+
const rows = group.lines
|
|
737
|
+
.map((line) => {
|
|
738
|
+
const content = (text) =>
|
|
739
|
+
`<span class="verse-line${line.level ? ` i${line.level}` : ""}"${indentStyle(line.level)}>${inlineVerse(text)}</span>`;
|
|
740
|
+
if (line.romanText !== undefined)
|
|
741
|
+
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>`;
|
|
742
|
+
return content(line.text);
|
|
743
|
+
})
|
|
744
|
+
.join(
|
|
745
|
+
group.lines.some((line) => line.romanText !== undefined) ?
|
|
746
|
+
inline ? ""
|
|
747
|
+
: "\n"
|
|
748
|
+
: inline ? "<br/>"
|
|
749
|
+
: " \n",
|
|
750
|
+
);
|
|
751
|
+
return inline ?
|
|
752
|
+
`<span class="verse-stanza">${rows}</span>`
|
|
753
|
+
: `<div class="verse-stanza">${label}\n\n${rows}\n\n</div>`;
|
|
754
|
+
})
|
|
755
|
+
.join(inline ? '<br class="verse-stanza-break"/>' : "\n\n");
|
|
756
|
+
const notes = (model.notes ?? [])
|
|
757
|
+
.map((note) => inlineVerse(note.romanText ?? note.text))
|
|
758
|
+
.join("");
|
|
759
|
+
return content + (notes ? `<span class="verse-footnotes">${notes}</span>` : "");
|
|
471
760
|
}
|
|
472
761
|
|
|
473
|
-
/**
|
|
474
|
-
*
|
|
762
|
+
/**
|
|
763
|
+
* Replace verse divs with wrappers whose bodies remain inline Markdown.
|
|
764
|
+
* @param {string} source - Markdown containing verse divs.
|
|
765
|
+
* @param {object} [options] - Encoded display models.
|
|
766
|
+
* @param {Map<string, VersePresentation>} [options.verses] - Presentations keyed by rewritten body.
|
|
475
767
|
*/
|
|
476
|
-
export function renderVerse(source) {
|
|
477
|
-
const { errors } = scanVerse(source)
|
|
478
|
-
const { blocks } = scanVerse(source),
|
|
768
|
+
export function renderVerse(source, { verses = new Map() } = {}) {
|
|
769
|
+
const { blocks, errors } = scanVerse(source),
|
|
479
770
|
lines = String(source ?? "").split("\n"),
|
|
480
771
|
output = [];
|
|
481
|
-
let cursor = 0;
|
|
482
772
|
const escape = (value) =>
|
|
483
773
|
String(value)
|
|
484
774
|
.replace(/&/g, "&")
|
|
485
775
|
.replace(/"/g, """)
|
|
486
776
|
.replace(/</g, "<")
|
|
487
777
|
.replace(/>/g, ">");
|
|
778
|
+
let cursor = 0;
|
|
488
779
|
for (const block of blocks) {
|
|
489
780
|
output.push(...lines.slice(cursor, block.start));
|
|
490
|
-
const
|
|
491
|
-
const
|
|
781
|
+
const transported = verses.get(block.body);
|
|
782
|
+
const model = transported?.model ?? block.model;
|
|
783
|
+
const paired = Boolean(transported);
|
|
784
|
+
const labelled = block.attributes.work !== undefined;
|
|
492
785
|
const attrs = [
|
|
493
|
-
`class="${escape(["verse", ...block.classes, ...(
|
|
786
|
+
`class="${escape(["verse", ...block.classes.filter((value) => value !== "verse" && (!paired || !transported?.classes?.includes(value))), ...(paired ? ["verse-paired"] : []), ...(labelled ? ["verse-labelled"] : [])].join(" "))}"`,
|
|
494
787
|
];
|
|
495
|
-
if (columns) attrs.push(`style="--verse-columns: ${verseColumnCount(block)}"`);
|
|
496
788
|
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
|
-
)
|
|
789
|
+
for (const [key, value] of Object.entries(block.attributes)) {
|
|
790
|
+
if (
|
|
791
|
+
!VERSE_KEYS.includes(key) ||
|
|
792
|
+
refusedAttributes({ [key]: value }).length ||
|
|
793
|
+
(paired && ["lang", "dir", "data-pagefind-ignore", "font", "enc"].includes(key))
|
|
794
|
+
)
|
|
795
|
+
continue;
|
|
796
|
+
attrs.push(
|
|
797
|
+
`${["lang", "dir", "data-pagefind-ignore"].includes(key) ? key : `data-${key}`}="${escape(value)}"`,
|
|
798
|
+
);
|
|
799
|
+
}
|
|
502
800
|
let indent = block.indent;
|
|
503
801
|
if (
|
|
504
802
|
indent.length >= 4 &&
|
|
@@ -509,7 +807,7 @@ export function renderVerse(source) {
|
|
|
509
807
|
"",
|
|
510
808
|
`${indent}<div ${attrs.join(" ")}>`,
|
|
511
809
|
"",
|
|
512
|
-
...(
|
|
810
|
+
...verseMarkdown(block.body, { labelled, model, presentation: transported })
|
|
513
811
|
.split("\n")
|
|
514
812
|
.map((line) => (line ? indent + line : line)),
|
|
515
813
|
"",
|
|
@@ -521,3 +819,34 @@ export function renderVerse(source) {
|
|
|
521
819
|
output.push(...lines.slice(cursor));
|
|
522
820
|
return { markdown: output.join("\n"), errors };
|
|
523
821
|
}
|
|
822
|
+
|
|
823
|
+
/**
|
|
824
|
+
* Render quoted verse with inline-safe wrappers and shared logical structure.
|
|
825
|
+
* @param {string} source - Markdown after block verse rendering.
|
|
826
|
+
* @returns {string} Markdown retaining surrounding prose.
|
|
827
|
+
*/
|
|
828
|
+
export function renderVerseQuotes(source) {
|
|
829
|
+
const spans = scanSpans(source).spans.filter((span) =>
|
|
830
|
+
span.attrs.some(([key, value]) => key === "class" && value.split(/\s+/).includes("verse")),
|
|
831
|
+
);
|
|
832
|
+
const footnotes = footnoteLabels(source);
|
|
833
|
+
let markdown = source;
|
|
834
|
+
for (const span of spans
|
|
835
|
+
.filter(
|
|
836
|
+
(span) => !spans.some((parent) => parent.start < span.start && parent.end >= span.end),
|
|
837
|
+
)
|
|
838
|
+
.sort((a, b) => b.start - a.start)) {
|
|
839
|
+
const model = span.verseModel ?? parseVerse(span.text, { inline: true, footnotes });
|
|
840
|
+
const attrs = span.attrs
|
|
841
|
+
.map(
|
|
842
|
+
([key, value]) =>
|
|
843
|
+
`${key}="${String(key === "class" ? value + " verse-inline" : value)
|
|
844
|
+
.replace(/&/g, "&")
|
|
845
|
+
.replace(/"/g, """)}"`,
|
|
846
|
+
)
|
|
847
|
+
.join(" ");
|
|
848
|
+
const html = `<span ${attrs}>${verseMarkdown(span.text, { model, inline: true })}</span>`;
|
|
849
|
+
markdown = markdown.slice(0, span.start) + html + markdown.slice(span.end);
|
|
850
|
+
}
|
|
851
|
+
return markdown;
|
|
852
|
+
}
|