@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.
Files changed (54) hide show
  1. package/CHANGELOG.md +26 -0
  2. package/README.md +1 -1
  3. package/assets/styles/content-base.css +97 -116
  4. package/docs/api.md +30 -6
  5. package/docs/authoring/links-and-markup.md +214 -126
  6. package/docs/commands.md +2 -2
  7. package/docs/content-format.md +31 -6
  8. package/docs/diagnostics.md +27 -27
  9. package/docs/guides/writing-in-invented-scripts.md +89 -83
  10. package/docs/reference/format-details.md +6 -7
  11. package/docs/reference/note-types.md +15 -15
  12. package/docs/reference/theme-pages.md +38 -57
  13. package/engine/anchors.mjs +14 -0
  14. package/engine/code-fences.mjs +15 -6
  15. package/engine/content-blocks.mjs +7 -4
  16. package/engine/content-figures.mjs +19 -5
  17. package/engine/content-footnotes.mjs +60 -1
  18. package/engine/content-index.mjs +1 -1
  19. package/engine/content-render.mjs +66 -48
  20. package/engine/content-scripts.mjs +357 -101
  21. package/engine/content-spans.mjs +76 -10
  22. package/engine/content-verse.mjs +687 -358
  23. package/engine/first-use.mjs +43 -12
  24. package/engine/heading-attributes.mjs +2 -1
  25. package/engine/journals.mjs +8 -1
  26. package/engine/markdown-blocks.mjs +3 -2
  27. package/engine/markup-validation.mjs +17 -0
  28. package/engine/name-markup.mjs +1 -0
  29. package/engine/note-vocabulary.mjs +2 -2
  30. package/engine/pdf-render.mjs +89 -130
  31. package/engine/prose-lint.mjs +14 -2
  32. package/engine/roman-verse.mjs +68 -57
  33. package/engine/run-rows.mjs +1 -1
  34. package/engine/sql-tables.mjs +1 -1
  35. package/engine/verse-format.mjs +77 -0
  36. package/engine/verse-metre.mjs +49 -47
  37. package/engine/verse-regions.mjs +181 -0
  38. package/engine/wikilink-pattern.mjs +31 -0
  39. package/engine/wikilink-syntax.mjs +2 -16
  40. package/package.json +1 -1
  41. package/site-theme/static/css/style.css +68 -96
  42. package/types/engine/code-fences.d.mts +1 -3
  43. package/types/engine/content-blocks.d.mts +3 -1
  44. package/types/engine/content-footnotes.d.mts +23 -0
  45. package/types/engine/content-scripts.d.mts +12 -7
  46. package/types/engine/content-spans.d.mts +3 -1
  47. package/types/engine/content-verse.d.mts +263 -82
  48. package/types/engine/name-markup.d.mts +5 -0
  49. package/types/engine/roman-verse.d.mts +7 -6
  50. package/types/engine/verse-format.d.mts +9 -0
  51. package/types/engine/verse-metre.d.mts +4 -4
  52. package/types/engine/verse-regions.d.mts +78 -0
  53. package/types/engine/wikilink-pattern.d.mts +17 -0
  54. package/types/engine/wikilink-syntax.d.mts +1 -41
@@ -11,12 +11,18 @@
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 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
- * 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;
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
- * 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.
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 {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.
83
+ * @param {string} text - Text, after any map.
84
+ * @returns {boolean} Whether it starts right to left.
143
85
  */
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);
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 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
- */
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
- "numstep",
213
- "cols",
107
+ "work",
214
108
  "dir",
215
109
  "data-pagefind-ignore",
216
110
  ]);
217
111
 
218
- /** Read verse code fences; start/end are inclusive zero-based source lines.
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 lines = String(source ?? "").split("\n"),
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 literalLines = new Set();
226
- for (const token of blockTokens(source)) {
227
- if (!token.map || !["blockquote_open", "html_block"].includes(token.type)) continue;
228
- for (let line = token.map[0]; line < token.map[1]; line++) literalLines.add(line);
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
- let opening = null;
231
- for (let i = 0; i < lines.length; i++) {
232
- if (literalLines.has(i)) continue;
233
- const match = /^ *(`{3,}|~{3,})(.*)$/.exec(lines[i]);
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
- for (const [key, value] of Object.entries(parsed.values)) {
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
- line: i + 1,
295
- column: keyColumn(lines[i], key),
144
+ ...at,
145
+ column: keyColumn(lines[region.start], key),
296
146
  message: `${key}= is not a verse attribute`,
297
147
  });
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))
308
- errors.push({
309
- line: i + 1,
310
- column: keyColumn(lines[i], key),
311
- message: "verse numstep= numbers every Nth line: a whole number, 0 for none",
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 fence's opening line.
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
- /** 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;
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
- /** Whether a verse uses the numbered multi-column protocol. */
349
- export function hasColumns(block) {
350
- return verseColumnCount(block) > 1;
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
- /** A line separating stanzas, including escaped output from a native run. */
354
- export const STANZA_BREAK = /^(?:\\?\*){3}$/;
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
- * 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}>}>}
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
- export function verseGroups(block, lines) {
366
- const groups = [];
367
- let parts = [];
368
- const end = () => {
369
- if (parts.length) groups.push({ parts });
370
- parts = [];
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
- for (let at = block.start + 1; at < block.end; at++) {
373
- const line = lines[at];
374
- if (!line.trim()) {
375
- end();
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 (STANZA_BREAK.test(line.trim())) {
379
- end();
380
- groups.push({ stanzaBreak: true, line: at });
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
- 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
- });
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
- end();
401
- return groups;
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
- /** 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;
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
- * 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.
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 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
- );
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, "&amp;")
710
+ .replace(/"/g, "&quot;")
711
+ .replace(/</g, "&lt;")
712
+ .replace(/>/g, "&gt;");
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
- /** Replace verse fences with HTML wrappers whose body remains inline Markdown.
474
- * @param {string} source - Authored Markdown.
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, "&amp;")
485
775
  .replace(/"/g, "&quot;")
486
776
  .replace(/</g, "&lt;")
487
777
  .replace(/>/g, "&gt;");
778
+ let cursor = 0;
488
779
  for (const block of blocks) {
489
780
  output.push(...lines.slice(cursor, block.start));
490
- const step = Number(block.attributes.numstep ?? 0) || 0;
491
- const columns = hasColumns(block);
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, ...(columns ? [COLUMNS_CLASS] : []), ...(step ? ["verse-numbered"] : [])].join(" "))}"`,
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 (!refusedAttributes({ [key]: value }).length)
499
- attrs.push(
500
- `${["lang", "dir", "data-pagefind-ignore"].includes(key) ? key : `data-${key}`}="${escape(value)}"`,
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
- ...(columns ? columnsMarkdown(block, lines, step) : verseMarkdown(block.body, step))
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, "&amp;")
845
+ .replace(/"/g, "&quot;")}"`,
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
+ }