@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.
Files changed (51) hide show
  1. package/CHANGELOG.md +15 -0
  2. package/README.md +1 -1
  3. package/assets/styles/content-base.css +32 -0
  4. package/docs/api.md +30 -6
  5. package/docs/authoring/links-and-markup.md +136 -53
  6. package/docs/commands.md +2 -2
  7. package/docs/content-format.md +14 -6
  8. package/docs/diagnostics.md +12 -11
  9. package/docs/guides/writing-in-invented-scripts.md +62 -35
  10. package/docs/reference/format-details.md +6 -6
  11. package/docs/reference/note-types.md +15 -15
  12. package/docs/reference/theme-pages.md +26 -18
  13. package/engine/anchors.mjs +14 -0
  14. package/engine/code-fences.mjs +15 -6
  15. package/engine/content-blocks.mjs +4 -2
  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 +59 -43
  20. package/engine/content-scripts.mjs +204 -42
  21. package/engine/content-spans.mjs +73 -5
  22. package/engine/content-verse.mjs +320 -192
  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 +34 -23
  31. package/engine/prose-lint.mjs +14 -2
  32. package/engine/run-rows.mjs +1 -1
  33. package/engine/sql-tables.mjs +1 -1
  34. package/engine/verse-format.mjs +77 -0
  35. package/engine/verse-metre.mjs +18 -9
  36. package/engine/verse-regions.mjs +181 -0
  37. package/engine/wikilink-pattern.mjs +31 -0
  38. package/engine/wikilink-syntax.mjs +2 -16
  39. package/package.json +1 -1
  40. package/site-theme/static/css/style.css +24 -0
  41. package/types/engine/code-fences.d.mts +1 -3
  42. package/types/engine/content-footnotes.d.mts +23 -0
  43. package/types/engine/content-scripts.d.mts +4 -3
  44. package/types/engine/content-spans.d.mts +3 -1
  45. package/types/engine/content-verse.d.mts +69 -74
  46. package/types/engine/name-markup.d.mts +5 -0
  47. package/types/engine/verse-format.d.mts +9 -0
  48. package/types/engine/verse-metre.d.mts +4 -4
  49. package/types/engine/verse-regions.d.mts +78 -0
  50. package/types/engine/wikilink-pattern.d.mts +17 -0
  51. package/types/engine/wikilink-syntax.d.mts +1 -41
@@ -12,14 +12,17 @@
12
12
  */
13
13
 
14
14
  import MarkdownIt from "markdown-it";
15
- import { WIKILINK } from "./wikilink-syntax.mjs";
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 fence. */
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 code fences; start/end are inclusive zero-based source lines.
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 fences and findings.
119
+ * @returns {{blocks: VerseBlock[], errors: VerseDiagnostic[]}} Located divs and findings.
117
120
  */
118
121
  export function scanVerse(source, { checkWorks = true } = {}) {
119
- const lines = String(source ?? "").split("\n"),
122
+ const text = String(source ?? "");
123
+ const lines = text.split("\n"),
120
124
  blocks = [],
121
125
  errors = [];
122
- const literalLines = new Set();
123
- for (const token of blockTokens(source)) {
124
- if (!token.map || !["blockquote_open", "html_block"].includes(token.type)) continue;
125
- 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
+ });
126
133
  }
127
- let opening = null;
128
- for (let i = 0; i < lines.length; i++) {
129
- if (literalLines.has(i)) continue;
130
- const match = /^ *(`{3,}|~{3,})(.*)$/.exec(lines[i]);
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
- for (const [key, value] of Object.entries(parsed.values)) {
197
- if (key === "work" && !value.trim())
198
- errors.push({
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
- line: i + 1,
206
- column: keyColumn(lines[i], key),
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 fence's opening line.
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 fence body.
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 fence with its parsed logical structure.
237
+ * An authored verse div with its parsed logical structure.
330
238
  * @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.
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 fence's leading spaces.
339
- * @property {string} body - Authored fence body.
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 fence body.
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 protectedRanges = syntaxRanges(source);
368
- const spans = (lineParser.parseInline(source, {})[0]?.children ?? [])
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
- lines.push({ text: lineText, level, from, to: at, offsets: lineOffsets });
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
- if (kind === "line") finishLine(at);
449
- else finishStanza(at);
463
+ const result =
464
+ kind === "line" ? { kind: finishLine(at), emitted: false } : finishStanza(at);
450
465
  layout.push([at, past]);
451
- boundaries.push({ kind, sourceOffset: at });
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
- 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: [] });
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
- while (protectedRanges[protectedIndex]?.[1] <= at) protectedIndex++;
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 - Fences belonging to one page.
561
- * @returns {VerseDiagnostic[]} Findings at affected opening fences.
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 fence body.
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
- return model.groups
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
- return `<span class="verse-canto-break" aria-hidden="true">${content}</span>`;
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(group.lines.some((line) => line.romanText !== undefined) ? "\n" : " \n");
657
- return `<div class="verse-stanza">${label}\n\n${rows}\n\n</div>`;
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 fences with wrappers whose bodies remain inline Markdown.
664
- * @param {string} source - Markdown containing verse fences.
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 = model.groups.some(
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, "&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
+ }