@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/content-verse.mjs
CHANGED
|
@@ -12,14 +12,17 @@
|
|
|
12
12
|
*/
|
|
13
13
|
|
|
14
14
|
import MarkdownIt from "markdown-it";
|
|
15
|
-
import
|
|
15
|
+
import footnotePlugin from "markdown-it-footnote";
|
|
16
|
+
import { footnoteLabels, footnoteReferences } from "./content-footnotes.mjs";
|
|
17
|
+
import { inlineText } from "./name-markup.mjs";
|
|
16
18
|
import { pageOpenings } from "./heading-attributes.mjs";
|
|
19
|
+
import { inlineSyntaxRanges, verseRegions } from "./verse-regions.mjs";
|
|
17
20
|
import { blockTokens } from "./markdown-blocks.mjs";
|
|
18
21
|
import { slugify } from "./content-slug.mjs";
|
|
19
|
-
import { spanMarkdownPlugin } from "./content-spans.mjs";
|
|
22
|
+
import { scanSpans, spanMarkdownPlugin } from "./content-spans.mjs";
|
|
20
23
|
import { parseExtensionAttributes, refusedAttributes } from "./extension-attributes.mjs";
|
|
21
24
|
|
|
22
|
-
const lineParser = new MarkdownIt({ html: true }).use(spanMarkdownPlugin);
|
|
25
|
+
const lineParser = new MarkdownIt({ html: true }).use(footnotePlugin).use(spanMarkdownPlugin);
|
|
23
26
|
|
|
24
27
|
/**
|
|
25
28
|
* Read one verse line. Marker offsets are zero-based in the authored line;
|
|
@@ -94,7 +97,7 @@ export function rightToLeft(text) {
|
|
|
94
97
|
);
|
|
95
98
|
}
|
|
96
99
|
|
|
97
|
-
/** The authored attributes of a verse
|
|
100
|
+
/** The authored attributes of a verse div. */
|
|
98
101
|
export const VERSE_KEYS = Object.freeze([
|
|
99
102
|
"form",
|
|
100
103
|
"scan",
|
|
@@ -109,111 +112,60 @@ export const VERSE_KEYS = Object.freeze([
|
|
|
109
112
|
/** A canto separator, including the escaped form produced by a native run. */
|
|
110
113
|
export const STANZA_BREAK = /^(?:\\?\*){3}$/;
|
|
111
114
|
|
|
112
|
-
/** Read verse
|
|
115
|
+
/** Read verse divs; start/end are inclusive zero-based source lines.
|
|
113
116
|
* @param {string} source - Authored Markdown.
|
|
114
117
|
* @param {object} [options] - Whole-page validation choices.
|
|
115
118
|
* @param {boolean} [options.checkWorks] - Whether to validate page-local versions.
|
|
116
|
-
* @returns {{blocks: VerseBlock[], errors: VerseDiagnostic[]}} Located
|
|
119
|
+
* @returns {{blocks: VerseBlock[], errors: VerseDiagnostic[]}} Located divs and findings.
|
|
117
120
|
*/
|
|
118
121
|
export function scanVerse(source, { checkWorks = true } = {}) {
|
|
119
|
-
const
|
|
122
|
+
const text = String(source ?? "");
|
|
123
|
+
const lines = text.split("\n"),
|
|
120
124
|
blocks = [],
|
|
121
125
|
errors = [];
|
|
122
|
-
const
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
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
|
+
});
|
|
126
133
|
}
|
|
127
|
-
|
|
128
|
-
for (
|
|
129
|
-
|
|
130
|
-
const
|
|
131
|
-
if (opening) {
|
|
132
|
-
if (
|
|
133
|
-
match &&
|
|
134
|
-
match[1][0] === opening.fence[0] &&
|
|
135
|
-
match[1].length >= opening.fence.length &&
|
|
136
|
-
!match[2].trim()
|
|
137
|
-
) {
|
|
138
|
-
if (opening.name === "verse") {
|
|
139
|
-
const body = lines.slice(opening.start + 1, i).join("\n");
|
|
140
|
-
if (!body.trim())
|
|
141
|
-
errors.push({
|
|
142
|
-
line: opening.start + 1,
|
|
143
|
-
column: 1,
|
|
144
|
-
message: "verse block is empty",
|
|
145
|
-
});
|
|
146
|
-
for (let n = opening.start + 1; n < i; n++) {
|
|
147
|
-
const col = lines[n].indexOf("\t");
|
|
148
|
-
if (col >= 0)
|
|
149
|
-
errors.push({
|
|
150
|
-
line: n + 1,
|
|
151
|
-
column: col + 1,
|
|
152
|
-
message: "tabs are not allowed in verse",
|
|
153
|
-
});
|
|
154
|
-
}
|
|
155
|
-
const block = { ...opening, end: i, body };
|
|
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
|
-
}
|
|
166
|
-
blocks.push(block);
|
|
167
|
-
}
|
|
168
|
-
opening = null;
|
|
169
|
-
}
|
|
170
|
-
continue;
|
|
171
|
-
}
|
|
172
|
-
if (!match) continue;
|
|
173
|
-
const info = match[2].trim();
|
|
174
|
-
opening = {
|
|
175
|
-
start: i,
|
|
176
|
-
fence: match[1],
|
|
177
|
-
name: info.split(/\s/)[0],
|
|
178
|
-
attributes: {},
|
|
179
|
-
id: "",
|
|
180
|
-
classes: [],
|
|
181
|
-
indent: /^ */.exec(lines[i])[0],
|
|
182
|
-
};
|
|
183
|
-
if (opening.name !== "verse") continue;
|
|
184
|
-
const raw = info.slice(6).trim();
|
|
185
|
-
const at = { line: i + 1, column: 1 };
|
|
186
|
-
if (raw && !/^\{[^}\n]*\}$/.test(raw)) {
|
|
187
|
-
errors.push({ ...at, message: "verse attributes need {…} braces" });
|
|
188
|
-
continue;
|
|
189
|
-
}
|
|
190
|
-
const parsed = parseExtensionAttributes(raw ? raw.slice(1, -1) : "");
|
|
191
|
-
opening.id = parsed.id;
|
|
192
|
-
opening.classes = parsed.classes;
|
|
193
|
-
opening.attributes = parsed.values;
|
|
194
|
-
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)])
|
|
195
138
|
errors.push({ ...at, message });
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
line: i + 1,
|
|
200
|
-
column: keyColumn(lines[i], key),
|
|
201
|
-
message: "work= needs a nonempty page-local key",
|
|
202
|
-
});
|
|
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))
|
|
203
142
|
if (!VERSE_KEYS.includes(key))
|
|
204
143
|
errors.push({
|
|
205
|
-
|
|
206
|
-
column: keyColumn(lines[
|
|
144
|
+
...at,
|
|
145
|
+
column: keyColumn(lines[region.start], key),
|
|
207
146
|
message: `${key}= is not a verse attribute`,
|
|
208
147
|
});
|
|
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
|
+
});
|
|
209
166
|
}
|
|
167
|
+
blocks.push({ ...region, model });
|
|
210
168
|
}
|
|
211
|
-
if (opening?.name === "verse")
|
|
212
|
-
errors.push({
|
|
213
|
-
line: opening.start + 1,
|
|
214
|
-
column: 1,
|
|
215
|
-
message: "verse block needs a closing code fence",
|
|
216
|
-
});
|
|
217
169
|
if (checkWorks) {
|
|
218
170
|
const openings = [...pageOpenings(source).keys()];
|
|
219
171
|
const pages = new Map();
|
|
@@ -231,7 +183,7 @@ export function scanVerse(source, { checkWorks = true } = {}) {
|
|
|
231
183
|
/**
|
|
232
184
|
* The 1-based column of `key=`, or of a class or id token, in an opening line.
|
|
233
185
|
*
|
|
234
|
-
* @param {string} opening - The
|
|
186
|
+
* @param {string} opening - The div's opening line.
|
|
235
187
|
* @param {string} key - The key, or a `.class` or `#id` token.
|
|
236
188
|
* @returns {number} Its column, or the brace's when it is not found.
|
|
237
189
|
*/
|
|
@@ -244,51 +196,6 @@ function keyColumn(opening, key) {
|
|
|
244
196
|
return (at < 0 ? brace : brace + at) + 1;
|
|
245
197
|
}
|
|
246
198
|
|
|
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]);
|
|
290
|
-
}
|
|
291
|
-
|
|
292
199
|
/**
|
|
293
200
|
* A logical line with its authored UTF-16 coordinates.
|
|
294
201
|
* @typedef {object} VerseLine
|
|
@@ -310,7 +217,7 @@ function syntaxRanges(source) {
|
|
|
310
217
|
|
|
311
218
|
/**
|
|
312
219
|
* @typedef {VerseStanza & {canto: number, stanza: number, label: string, cantoBreak?: false}} VerseTextGroup
|
|
313
|
-
* @typedef {{cantoBreak: true, from: number, to: number, text?: string}} VerseCantoBreak
|
|
220
|
+
* @typedef {{cantoBreak: true, from: number, to: number, text?: string, id?: string, title?: string}} VerseCantoBreak
|
|
314
221
|
* @typedef {{kind: "line"|"stanza"|"canto", sourceOffset: number}} VerseBoundary
|
|
315
222
|
* @typedef {{offset: number, message: string}} VerseFinding
|
|
316
223
|
*/
|
|
@@ -322,21 +229,27 @@ function syntaxRanges(source) {
|
|
|
322
229
|
* @property {Array<VerseTextGroup|VerseCantoBreak>} groups - Stanzas and canto breaks.
|
|
323
230
|
* @property {VerseBoundary[]} boundaries - Explicit authored boundaries.
|
|
324
231
|
* @property {Array<[number, number]>} layout - Source ranges omitted from encoded letters.
|
|
325
|
-
* @property {VerseFinding[]} errors - Findings relative to the
|
|
232
|
+
* @property {VerseFinding[]} errors - Findings relative to the verse body.
|
|
233
|
+
* @property {VerseLine[]} notes - Defined references without substantive verse text.
|
|
326
234
|
*/
|
|
327
235
|
|
|
328
236
|
/**
|
|
329
|
-
* An authored verse
|
|
237
|
+
* An authored verse div with its parsed logical structure.
|
|
330
238
|
* @typedef {object} VerseBlock
|
|
331
|
-
* @property {number} start - Opening
|
|
332
|
-
* @property {number} end - Closing
|
|
333
|
-
* @property {
|
|
334
|
-
* @property {
|
|
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.
|
|
335
248
|
* @property {Record<string, string>} attributes - Authored attribute values.
|
|
336
249
|
* @property {string} id - Authored anchor, or the empty string.
|
|
337
250
|
* @property {string[]} classes - Authored presentation classes.
|
|
338
|
-
* @property {string} indent - Opening
|
|
339
|
-
* @property {string} body - Authored
|
|
251
|
+
* @property {string} indent - Opening div's leading spaces.
|
|
252
|
+
* @property {string} body - Authored verse body.
|
|
340
253
|
* @property {VerseModel} model - Logical structure relative to the body.
|
|
341
254
|
*/
|
|
342
255
|
|
|
@@ -350,22 +263,55 @@ function syntaxRanges(source) {
|
|
|
350
263
|
* @property {Record<string, string>} attributes - Native language, direction and search attributes.
|
|
351
264
|
*/
|
|
352
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
|
+
|
|
353
285
|
/**
|
|
354
286
|
* Parse logical verse lines, stanzas and cantos without losing authored offsets.
|
|
355
287
|
* Offsets are UTF-16 positions relative to body. Delimiters and indentation are
|
|
356
288
|
* layout ranges; source newlines inside a logical line contribute one space.
|
|
357
|
-
* @param {string} body - Authored
|
|
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.
|
|
358
291
|
* @returns {VerseModel} The shared structure with offsets relative to the body.
|
|
359
292
|
*/
|
|
360
|
-
export function parseVerse(body) {
|
|
293
|
+
export function parseVerse(body, { inline = false, footnotes = new Set() } = {}) {
|
|
361
294
|
const source = String(body ?? "");
|
|
362
295
|
const groups = [],
|
|
363
296
|
boundaries = [],
|
|
364
297
|
layout = [],
|
|
365
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
|
+
});
|
|
366
305
|
const cantos = [{ stanzas: [] }];
|
|
367
|
-
const
|
|
368
|
-
|
|
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
|
|
369
315
|
.filter((token) => token.meta?.span)
|
|
370
316
|
.map((token) => ({
|
|
371
317
|
start: token.meta.start,
|
|
@@ -378,14 +324,43 @@ export function parseVerse(body) {
|
|
|
378
324
|
from = 0,
|
|
379
325
|
level = 0,
|
|
380
326
|
lines = [];
|
|
327
|
+
const pendingNotes = [];
|
|
381
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
|
+
}
|
|
382
343
|
const append = (value, at) => {
|
|
383
344
|
text += value;
|
|
384
345
|
for (let index = 0; index < value.length; index++) offsets.push(at + index);
|
|
385
346
|
};
|
|
386
347
|
const finishLine = (at) => {
|
|
348
|
+
let kind = "empty";
|
|
387
349
|
const trimmed = text.trimEnd();
|
|
388
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
|
+
});
|
|
389
364
|
let lineText = trimmed,
|
|
390
365
|
lineOffsets = offsets.slice(0, trimmed.length);
|
|
391
366
|
for (const span of spans
|
|
@@ -423,15 +398,54 @@ export function parseVerse(body) {
|
|
|
423
398
|
lineText = kept.map(({ char }) => char).join("");
|
|
424
399
|
lineOffsets = kept.map(({ offset }) => offset);
|
|
425
400
|
}
|
|
426
|
-
|
|
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
|
+
}
|
|
427
439
|
}
|
|
428
440
|
text = "";
|
|
429
441
|
offsets = [];
|
|
430
442
|
level = 0;
|
|
431
443
|
from = at;
|
|
444
|
+
return kind;
|
|
432
445
|
};
|
|
433
446
|
const finishStanza = (at) => {
|
|
434
|
-
finishLine(at);
|
|
447
|
+
const kind = finishLine(at);
|
|
448
|
+
const emitted = lines.length > 0;
|
|
435
449
|
if (lines.length) {
|
|
436
450
|
const stanza = { lines, from: lines[0].from, to: at };
|
|
437
451
|
cantos.at(-1).stanzas.push(stanza);
|
|
@@ -443,17 +457,25 @@ export function parseVerse(body) {
|
|
|
443
457
|
});
|
|
444
458
|
lines = [];
|
|
445
459
|
}
|
|
460
|
+
return { kind, emitted };
|
|
446
461
|
};
|
|
447
462
|
const event = (kind, at, past) => {
|
|
448
|
-
|
|
449
|
-
|
|
463
|
+
const result =
|
|
464
|
+
kind === "line" ? { kind: finishLine(at), emitted: false } : finishStanza(at);
|
|
450
465
|
layout.push([at, past]);
|
|
451
|
-
|
|
466
|
+
if (!(
|
|
467
|
+
result.kind === "note" &&
|
|
468
|
+
(kind === "line" || (kind === "stanza" && !result.emitted))
|
|
469
|
+
))
|
|
470
|
+
boundaries.push({ kind, sourceOffset: at });
|
|
452
471
|
from = past;
|
|
453
472
|
};
|
|
454
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;
|
|
455
477
|
const lineStart = at === 0 || /[\r\n]/.test(source[at - 1]);
|
|
456
|
-
if (lineStart) {
|
|
478
|
+
if (lineStart && !protectedAt) {
|
|
457
479
|
const physical = /^[^\r\n]*/.exec(source.slice(at))[0];
|
|
458
480
|
const newline = /^(?:\r\n|\r|\n)/.exec(source.slice(at + physical.length))?.[0] ?? "";
|
|
459
481
|
if (!physical.trim()) {
|
|
@@ -461,25 +483,59 @@ export function parseVerse(body) {
|
|
|
461
483
|
at += physical.length + newline.length;
|
|
462
484
|
continue;
|
|
463
485
|
}
|
|
464
|
-
if (STANZA_BREAK.test(physical.trim()))
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
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;
|
|
474
535
|
}
|
|
475
|
-
pendingBreak = at;
|
|
476
|
-
at += physical.length + newline.length;
|
|
477
|
-
continue;
|
|
478
536
|
}
|
|
479
537
|
}
|
|
480
|
-
|
|
481
|
-
const protectedRange = protectedRanges[protectedIndex];
|
|
482
|
-
const protectedAt = protectedRange && protectedRange[0] <= at;
|
|
538
|
+
|
|
483
539
|
if (!text && !/\s/.test(source[at])) {
|
|
484
540
|
if (!level) from = at;
|
|
485
541
|
const remaining = source.slice(at);
|
|
@@ -533,7 +589,31 @@ export function parseVerse(body) {
|
|
|
533
589
|
for (const group of groups)
|
|
534
590
|
if (!group.cantoBreak)
|
|
535
591
|
group.label = multi ? `${group.canto}:${group.stanza}` : String(group.stanza);
|
|
536
|
-
return { cantos, groups, boundaries, layout, errors };
|
|
592
|
+
return { cantos, groups, boundaries, layout, errors, notes: pendingNotes };
|
|
593
|
+
}
|
|
594
|
+
|
|
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
|
+
});
|
|
537
617
|
}
|
|
538
618
|
|
|
539
619
|
/** Logical line text for consumers that do not need source locations. */
|
|
@@ -557,8 +637,8 @@ export const stanzaSeparator = (group) => group.length === 1 && STANZA_BREAK.tes
|
|
|
557
637
|
|
|
558
638
|
/**
|
|
559
639
|
* Compare the stanza counts of each version of a page-local work.
|
|
560
|
-
* @param {VerseBlock[]} blocks -
|
|
561
|
-
* @returns {VerseDiagnostic[]} Findings at affected opening
|
|
640
|
+
* @param {VerseBlock[]} blocks - Divs belonging to one page.
|
|
641
|
+
* @returns {VerseDiagnostic[]} Findings at affected opening divs.
|
|
562
642
|
*/
|
|
563
643
|
export function workShapeFindings(blocks) {
|
|
564
644
|
const works = new Map(),
|
|
@@ -612,16 +692,17 @@ export function workShapeFindings(blocks) {
|
|
|
612
692
|
|
|
613
693
|
/**
|
|
614
694
|
* Render a shared verse structure while preserving surrounding inline Markdown.
|
|
615
|
-
* @param {string} body - Authored
|
|
695
|
+
* @param {string} body - Authored verse body.
|
|
616
696
|
* @param {object} [options] - Shared structure and display choices.
|
|
617
697
|
* @param {boolean} [options.labelled] - Whether each stanza carries its label.
|
|
618
698
|
* @param {VerseModel} [options.model] - Parsed structure; defaults to parsing the body.
|
|
619
699
|
* @param {VersePresentation} [options.presentation] - Native-cell font and language.
|
|
700
|
+
* @param {boolean} [options.inline] - Whether wrappers must remain inline-safe.
|
|
620
701
|
* @returns {string} Markdown with verse layout wrappers.
|
|
621
702
|
*/
|
|
622
703
|
export function verseMarkdown(
|
|
623
704
|
body,
|
|
624
|
-
{ labelled = false, model = parseVerse(body), presentation } = {},
|
|
705
|
+
{ labelled = false, model = parseVerse(body), presentation, inline = false } = {},
|
|
625
706
|
) {
|
|
626
707
|
const escape = (value) =>
|
|
627
708
|
String(value)
|
|
@@ -634,7 +715,7 @@ export function verseMarkdown(
|
|
|
634
715
|
.map(([key, value]) => ` ${key}="${escape(value)}"`)
|
|
635
716
|
.join("");
|
|
636
717
|
const nativeClasses = ["verse-native", ...(presentation?.classes ?? [])].join(" ");
|
|
637
|
-
|
|
718
|
+
const content = model.groups
|
|
638
719
|
.map((group) => {
|
|
639
720
|
if (group.cantoBreak) {
|
|
640
721
|
const mark = group.text ? inlineVerse(group.text) : "";
|
|
@@ -642,7 +723,14 @@ export function verseMarkdown(
|
|
|
642
723
|
mark && presentation ?
|
|
643
724
|
`<span class="${escape(nativeClasses)}"${nativeAttrs}${!presentation.attributes?.dir && rightToLeft(group.text) ? ' dir="rtl"' : ""}>${mark}</span>`
|
|
644
725
|
: mark;
|
|
645
|
-
|
|
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
|
+
);
|
|
646
734
|
}
|
|
647
735
|
const label = labelled ? `<span class="verse-label">${group.label}</span>` : "";
|
|
648
736
|
const rows = group.lines
|
|
@@ -653,15 +741,27 @@ export function verseMarkdown(
|
|
|
653
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>`;
|
|
654
742
|
return content(line.text);
|
|
655
743
|
})
|
|
656
|
-
.join(
|
|
657
|
-
|
|
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>`;
|
|
658
754
|
})
|
|
659
|
-
.join("\n\n");
|
|
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>` : "");
|
|
660
760
|
}
|
|
661
761
|
|
|
662
762
|
/**
|
|
663
|
-
* Replace verse
|
|
664
|
-
* @param {string} source - Markdown containing verse
|
|
763
|
+
* Replace verse divs with wrappers whose bodies remain inline Markdown.
|
|
764
|
+
* @param {string} source - Markdown containing verse divs.
|
|
665
765
|
* @param {object} [options] - Encoded display models.
|
|
666
766
|
* @param {Map<string, VersePresentation>} [options.verses] - Presentations keyed by rewritten body.
|
|
667
767
|
*/
|
|
@@ -680,13 +780,10 @@ export function renderVerse(source, { verses = new Map() } = {}) {
|
|
|
680
780
|
output.push(...lines.slice(cursor, block.start));
|
|
681
781
|
const transported = verses.get(block.body);
|
|
682
782
|
const model = transported?.model ?? block.model;
|
|
683
|
-
const paired =
|
|
684
|
-
(group) =>
|
|
685
|
-
!group.cantoBreak && group.lines.some((line) => line.romanText !== undefined),
|
|
686
|
-
);
|
|
783
|
+
const paired = Boolean(transported);
|
|
687
784
|
const labelled = block.attributes.work !== undefined;
|
|
688
785
|
const attrs = [
|
|
689
|
-
`class="${escape(["verse", ...block.classes.filter((value) => !paired || !transported?.classes?.includes(value)), ...(paired ? ["verse-paired"] : []), ...(labelled ? ["verse-labelled"] : [])].join(" "))}"`,
|
|
786
|
+
`class="${escape(["verse", ...block.classes.filter((value) => value !== "verse" && (!paired || !transported?.classes?.includes(value))), ...(paired ? ["verse-paired"] : []), ...(labelled ? ["verse-labelled"] : [])].join(" "))}"`,
|
|
690
787
|
];
|
|
691
788
|
if (block.id) attrs.push(`id="${escape(slugify(block.id))}"`);
|
|
692
789
|
for (const [key, value] of Object.entries(block.attributes)) {
|
|
@@ -722,3 +819,34 @@ export function renderVerse(source, { verses = new Map() } = {}) {
|
|
|
722
819
|
output.push(...lines.slice(cursor));
|
|
723
820
|
return { markdown: output.join("\n"), errors };
|
|
724
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
|
+
}
|