@heroiclands/package-build 25.2.1 → 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.
@@ -11,12 +11,15 @@
11
11
  * SPDX-License-Identifier: GPL-3.0-or-later
12
12
  */
13
13
 
14
- /** The authored verse lines, excluding blank lines and stanza separators. */
15
- export function verseLines(body) {
16
- return String(body ?? "")
17
- .split("\n")
18
- .filter((line) => line.trim() && !STANZA_BREAK.test(line.trim()));
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
- * Blank lines divide verse groups. A standalone `***` is its own group and
56
- * separates stanzas. Each text line carries its period-based indentation.
57
- */
58
- export function verseStanzas(body) {
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
- * Keep blank-line verse groups as paragraphs and each text line a hard break.
99
- * A standalone `***` emits a separate stanza gap.
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 {object} block - A numbered-column verse from {@link scanVerse}.
140
- * @param {string[]} lines - The source, by line.
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 columnsMarkdown(block, lines, step) {
145
- const width = verseColumnCount(block);
146
- const stanzas = [[]];
147
- let count = 0;
148
- for (const group of verseGroups(block, lines)) {
149
- if (group.stanzaBreak) {
150
- stanzas.push(['<span class="verse-stanza-break" aria-hidden="true"></span>'], []);
151
- continue;
152
- }
153
- const number = lineNumber(++count, step);
154
- const cells = Array.from({ length: width }, (_, column) => {
155
- const parts = group.parts.filter((part) => part.column === column + 1);
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 layout class the build gives a verse whose cols count exceeds one. */
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
- "numstep",
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
- const columned = hasColumns(block);
260
- if (columned) errors.push(...columnFindings(block, lines));
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 (!VERSE_KEYS.includes(key))
197
+ if (key === "work" && !value.trim())
293
198
  errors.push({
294
199
  line: i + 1,
295
200
  column: keyColumn(lines[i], key),
296
- message: `${key}= is not a verse attribute`,
201
+ message: "work= needs a nonempty page-local key",
297
202
  });
298
- else if (
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: "verse numstep= numbers every Nth line: a whole number, 0 for none",
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
- /** The number of verse columns; an omitted or invalid count uses the ordinary layout. */
342
- export function verseColumnCount(block) {
343
- const raw = block.attributes?.cols;
344
- const value = Number(raw ?? 1);
345
- return /^[1-9]\d*$/.test(String(raw ?? 1)) && Number.isSafeInteger(value) ? value : 1;
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
- /** Whether a verse uses the numbered multi-column protocol. */
349
- export function hasColumns(block) {
350
- return verseColumnCount(block) > 1;
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
- /** A line separating stanzas, including escaped output from a native run. */
354
- export const STANZA_BREAK = /^(?:\\?\*){3}$/;
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
- * Blank-separated multi-column rows with ordered physical parts. Text retains
358
- * its authored inline markup and spacing after the numeric prefix. All source
359
- * offsets are measured before prefixes or indentation periods are removed.
360
- *
361
- * @param {object} block - A verse from scanVerse.
362
- * @param {string[]} lines - The complete source, by line.
363
- * @returns {Array<{stanzaBreak: true, line: number}|{stanzaBreak?: false, parts: Array<{column: number, line: number, sourceColumn: number, textColumn: number, text: string, indent: number, markerStart: number, markerLength: number, prefixLength: number}>}>}
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
- export function verseGroups(block, lines) {
366
- const groups = [];
367
- let parts = [];
368
- const end = () => {
369
- if (parts.length) groups.push({ parts });
370
- parts = [];
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
- for (let at = block.start + 1; at < block.end; at++) {
373
- const line = lines[at];
374
- if (!line.trim()) {
375
- end();
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 (STANZA_BREAK.test(line.trim())) {
379
- end();
380
- groups.push({ stanzaBreak: true, line: at });
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
- const spaces = /^ */.exec(line)[0].length;
384
- const prefix = hasColumns(block) ? /^(\d+)( +)/.exec(line.slice(spaces)) : undefined;
385
- const prefixLength = prefix?.[0].length ?? 0;
386
- const contentStart = spaces + prefixLength;
387
- const parsed = verseLine(line.slice(contentStart));
388
- parts.push({
389
- column: prefix ? Number(prefix[1]) : 1,
390
- line: at,
391
- sourceColumn: spaces + 1,
392
- textColumn: contentStart + 1,
393
- text: parsed.text,
394
- indent: parsed.level,
395
- markerStart: contentStart + parsed.markerStart,
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
- end();
401
- return groups;
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
- /** Locate an invalid numbered line without changing its authored text. */
405
- function columnFindings(block, lines) {
406
- const errors = [];
407
- const width = verseColumnCount(block);
408
- const opening = lines[block.start];
409
- const report = (line, column, message) => errors.push({ line: line + 1, column, message });
410
- if (block.classes.includes(COLUMNS_CLASS))
411
- report(
412
- block.start,
413
- keyColumn(opening, `.${COLUMNS_CLASS}`),
414
- `\`.${COLUMNS_CLASS}\` is the class the build gives a verse in columns; leave it out`,
415
- );
416
- for (const group of verseGroups(block, lines)) {
417
- if (group.stanzaBreak) continue;
418
- let previous = 0;
419
- for (const part of group.parts) {
420
- if (!part.prefixLength) {
421
- report(
422
- part.line,
423
- part.sourceColumn,
424
- "a verse with cols>1 starts each content line with a column number and spaces",
425
- );
426
- continue;
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
- * Whether text runs right to left: its first letter is in a right-to-left
454
- * block. A face's right-to-left code points sit in U+1E800–U+1EFFF.
455
- *
456
- * @param {string} text - Text, after any map.
457
- * @returns {boolean} Whether it starts right to left.
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 rightToLeft(text) {
460
- const first = /[\p{L}\p{M}\p{Co}\p{Cn}]/u.exec(
461
- String(text)
462
- .replace(/<[^>]*>/g, "")
463
- .replace(/\{[^}]*\}/g, ""),
464
- );
465
- return Boolean(
466
- first &&
467
- /[\u0590-\u08FF\uFB1D-\uFDFF\uFE70-\uFEFF\u{10800}-\u{10FFF}\u{1E800}-\u{1EFFF}]/u.test(
468
- first[0],
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, "&amp;")
629
+ .replace(/"/g, "&quot;")
630
+ .replace(/</g, "&lt;")
631
+ .replace(/>/g, "&gt;");
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
- /** Replace verse fences with HTML wrappers whose body remains inline Markdown.
474
- * @param {string} source - Authored Markdown.
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, "&amp;")
485
675
  .replace(/"/g, "&quot;")
486
676
  .replace(/</g, "&lt;")
487
677
  .replace(/>/g, "&gt;");
678
+ let cursor = 0;
488
679
  for (const block of blocks) {
489
680
  output.push(...lines.slice(cursor, block.start));
490
- const step = Number(block.attributes.numstep ?? 0) || 0;
491
- const columns = hasColumns(block);
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, ...(columns ? [COLUMNS_CLASS] : []), ...(step ? ["verse-numbered"] : [])].join(" "))}"`,
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 (!refusedAttributes({ [key]: value }).length)
499
- attrs.push(
500
- `${["lang", "dir", "data-pagefind-ignore"].includes(key) ? key : `data-${key}`}="${escape(value)}"`,
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
- ...(columns ? columnsMarkdown(block, lines, step) : verseMarkdown(block.body, step))
713
+ ...verseMarkdown(block.body, { labelled, model, presentation: transported })
513
714
  .split("\n")
514
715
  .map((line) => (line ? indent + line : line)),
515
716
  "",