@heroiclands/package-build 25.3.0 → 25.5.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 (62) hide show
  1. package/CHANGELOG.md +39 -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 +143 -53
  6. package/docs/commands.md +8 -2
  7. package/docs/content-format.md +14 -6
  8. package/docs/diagnostics.md +12 -11
  9. package/docs/encoding-core.md +18 -4
  10. package/docs/guides/writing-in-invented-scripts.md +83 -37
  11. package/docs/reference/format-details.md +10 -11
  12. package/docs/reference/note-types.md +15 -15
  13. package/docs/reference/theme-pages.md +26 -18
  14. package/engine/anchors.mjs +14 -0
  15. package/engine/code-fences.mjs +15 -6
  16. package/engine/content-blocks.mjs +4 -2
  17. package/engine/content-figures.mjs +19 -5
  18. package/engine/content-footnotes.mjs +69 -1
  19. package/engine/content-index.mjs +1 -1
  20. package/engine/content-render.mjs +59 -43
  21. package/engine/content-scripts.mjs +301 -58
  22. package/engine/content-scroll.mjs +8 -3
  23. package/engine/content-spans.mjs +107 -6
  24. package/engine/content-verse.mjs +338 -198
  25. package/engine/encoding-standard.mjs +1 -0
  26. package/engine/encoding-template.mjs +5 -2
  27. package/engine/encoding.mjs +19 -14
  28. package/engine/first-use.mjs +43 -12
  29. package/engine/heading-attributes.mjs +2 -1
  30. package/engine/journals.mjs +8 -1
  31. package/engine/markdown-blocks.mjs +3 -2
  32. package/engine/markdownlint-run.mjs +190 -5
  33. package/engine/markup-validation.mjs +17 -0
  34. package/engine/name-markup.mjs +1 -0
  35. package/engine/note-vocabulary.mjs +2 -2
  36. package/engine/pdf-render.mjs +34 -23
  37. package/engine/prose-lint.mjs +14 -2
  38. package/engine/roman-characters.mjs +32 -10
  39. package/engine/roman-run.mjs +3 -0
  40. package/engine/roman-verse.mjs +1 -0
  41. package/engine/run-rows.mjs +1 -1
  42. package/engine/sql-tables.mjs +1 -1
  43. package/engine/verse-format.mjs +89 -0
  44. package/engine/verse-metre.mjs +18 -9
  45. package/engine/verse-regions.mjs +190 -0
  46. package/engine/wikilink-pattern.mjs +31 -0
  47. package/engine/wikilink-syntax.mjs +2 -16
  48. package/package.json +1 -1
  49. package/site-theme/static/css/style.css +24 -0
  50. package/types/engine/code-fences.d.mts +1 -3
  51. package/types/engine/content-footnotes.d.mts +23 -0
  52. package/types/engine/content-scripts.d.mts +4 -3
  53. package/types/engine/content-scroll.d.mts +1 -1
  54. package/types/engine/content-spans.d.mts +3 -1
  55. package/types/engine/content-verse.d.mts +75 -75
  56. package/types/engine/name-markup.d.mts +5 -0
  57. package/types/engine/roman-run.d.mts +3 -1
  58. package/types/engine/verse-format.d.mts +10 -0
  59. package/types/engine/verse-metre.d.mts +4 -4
  60. package/types/engine/verse-regions.d.mts +82 -0
  61. package/types/engine/wikilink-pattern.d.mts +17 -0
  62. 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,63 @@ 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
- }
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;
126
+ for (const token of blockTokens(text)) {
127
+ if (token.type === "fence" && /^verse(?:\s|$)/.test(token.info.trim())) {
128
+ const opening = lines[token.map[0]];
129
+ const marker = opening?.indexOf(token.markup);
130
+ errors.push({
131
+ line: token.map[0] + 1,
132
+ ...(marker !== undefined && marker >= 0 ? { column: marker + 1 } : {}),
133
+ message: "verse uses a .verse div: write ::: {.verse …} and a closing :::",
134
+ });
189
135
  }
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)])
136
+ }
137
+ const definedFootnotes = footnoteLabels(text);
138
+ for (const region of verseRegions(text)) {
139
+ const at = { line: region.start + 1, column: region.contentStarts[0] - region.from + 1 };
140
+ for (const message of [...region.problems, ...refusedAttributes(region.attributes)])
195
141
  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
- });
142
+ if (!region.closed) errors.push({ ...at, message: "verse div needs a closing ::: line" });
143
+ if (!region.body.trim()) errors.push({ ...at, message: "verse div is empty" });
144
+ for (const key of Object.keys(region.attributes))
203
145
  if (!VERSE_KEYS.includes(key))
204
146
  errors.push({
205
- line: i + 1,
206
- column: keyColumn(lines[i], key),
147
+ ...at,
148
+ column: keyColumn(lines[region.start], key),
207
149
  message: `${key}= is not a verse attribute`,
208
150
  });
151
+ if (region.attributes.work !== undefined && !region.attributes.work.trim())
152
+ errors.push({ ...at, message: "work= needs a nonempty page-local key" });
153
+ const model = parseVerse(region.body, { footnotes: definedFootnotes });
154
+ for (const finding of model.errors) {
155
+ const preceding = text.slice(0, region.bodyStart + finding.offset);
156
+ errors.push({
157
+ line: preceding.split("\n").length,
158
+ column: preceding.length - preceding.lastIndexOf("\n"),
159
+ message: finding.message,
160
+ });
161
+ }
162
+ for (const match of region.body.matchAll(/\t/g)) {
163
+ const preceding = text.slice(0, region.bodyStart + match.index);
164
+ errors.push({
165
+ line: preceding.split("\n").length,
166
+ column: preceding.length - preceding.lastIndexOf("\n"),
167
+ message: "tabs are not allowed in verse",
168
+ });
209
169
  }
170
+ blocks.push({ ...region, column: at.column, model });
210
171
  }
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
172
  if (checkWorks) {
218
173
  const openings = [...pageOpenings(source).keys()];
219
174
  const pages = new Map();
@@ -224,14 +179,14 @@ export function scanVerse(source, { checkWorks = true } = {}) {
224
179
  }
225
180
  for (const page of pages.values()) errors.push(...workShapeFindings(page));
226
181
  }
227
- errors.sort((a, b) => a.line - b.line || a.column - b.column);
182
+ errors.sort((a, b) => a.line - b.line || (a.column ?? 0) - (b.column ?? 0));
228
183
  return { blocks, errors };
229
184
  }
230
185
 
231
186
  /**
232
187
  * The 1-based column of `key=`, or of a class or id token, in an opening line.
233
188
  *
234
- * @param {string} opening - The fence's opening line.
189
+ * @param {string} opening - The div's opening line.
235
190
  * @param {string} key - The key, or a `.class` or `#id` token.
236
191
  * @returns {number} Its column, or the brace's when it is not found.
237
192
  */
@@ -244,51 +199,6 @@ function keyColumn(opening, key) {
244
199
  return (at < 0 ? brace : brace + at) + 1;
245
200
  }
246
201
 
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
202
  /**
293
203
  * A logical line with its authored UTF-16 coordinates.
294
204
  * @typedef {object} VerseLine
@@ -310,7 +220,7 @@ function syntaxRanges(source) {
310
220
 
311
221
  /**
312
222
  * @typedef {VerseStanza & {canto: number, stanza: number, label: string, cantoBreak?: false}} VerseTextGroup
313
- * @typedef {{cantoBreak: true, from: number, to: number, text?: string}} VerseCantoBreak
223
+ * @typedef {{cantoBreak: true, from: number, to: number, text?: string, id?: string, title?: string}} VerseCantoBreak
314
224
  * @typedef {{kind: "line"|"stanza"|"canto", sourceOffset: number}} VerseBoundary
315
225
  * @typedef {{offset: number, message: string}} VerseFinding
316
226
  */
@@ -322,25 +232,32 @@ function syntaxRanges(source) {
322
232
  * @property {Array<VerseTextGroup|VerseCantoBreak>} groups - Stanzas and canto breaks.
323
233
  * @property {VerseBoundary[]} boundaries - Explicit authored boundaries.
324
234
  * @property {Array<[number, number]>} layout - Source ranges omitted from encoded letters.
325
- * @property {VerseFinding[]} errors - Findings relative to the fence body.
235
+ * @property {VerseFinding[]} errors - Findings relative to the verse body.
236
+ * @property {VerseLine[]} notes - Defined references without substantive verse text.
326
237
  */
327
238
 
328
239
  /**
329
- * An authored verse fence with its parsed logical structure.
240
+ * An authored verse div with its parsed logical structure.
330
241
  * @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.
242
+ * @property {number} start - Opening div's zero-based source line.
243
+ * @property {number} [column] - Opening delimiter's one-based source column, when known.
244
+ * @property {number} end - Closing div's zero-based source line.
245
+ * @property {number} from - Complete div first offset.
246
+ * @property {number} to - Offset past complete div.
247
+ * @property {number} bodyStart - First body offset.
248
+ * @property {number} bodyEnd - Offset past body text.
249
+ * @property {boolean} closed - Whether the closing delimiter exists.
250
+ * @property {string[]} problems - Attribute syntax findings.
251
+ * @property {"verse"} name - Region name.
335
252
  * @property {Record<string, string>} attributes - Authored attribute values.
336
253
  * @property {string} id - Authored anchor, or the empty string.
337
254
  * @property {string[]} classes - Authored presentation classes.
338
- * @property {string} indent - Opening fence's leading spaces.
339
- * @property {string} body - Authored fence body.
255
+ * @property {string} indent - Opening div's leading spaces.
256
+ * @property {string} body - Authored verse body.
340
257
  * @property {VerseModel} model - Logical structure relative to the body.
341
258
  */
342
259
 
343
- /** @typedef {{line: number, column: number, message: string}} VerseDiagnostic */
260
+ /** @typedef {{line: number, column?: number, message: string}} VerseDiagnostic */
344
261
 
345
262
  /**
346
263
  * An encoded verse's shared display model and native-cell presentation.
@@ -350,22 +267,55 @@ function syntaxRanges(source) {
350
267
  * @property {Record<string, string>} attributes - Native language, direction and search attributes.
351
268
  */
352
269
 
270
+ /** Read a standard canto separator's semantic attributes. */
271
+ function cantoLine(line) {
272
+ const match = /^---[ \t]+(\{.*)$/.exec(line.trim());
273
+ if (!match) return undefined;
274
+ const closed = match[1].endsWith("}");
275
+ const parsed = parseExtensionAttributes(closed ? match[1].slice(1, -1) : match[1].slice(1));
276
+ if (!parsed.classes.includes("canto")) return undefined;
277
+ if (!closed) parsed.problems.push("a canto break needs --- {.canto} attribute braces");
278
+ for (const key of Object.keys(parsed.values))
279
+ if (key !== "title") parsed.problems.push(`${key}= is not a canto attribute`);
280
+ for (const name of parsed.classes)
281
+ if (name !== "canto") parsed.problems.push(`.${name} is not a canto class`);
282
+ return {
283
+ id: parsed.id,
284
+ title: parsed.values.title ?? "",
285
+ problems: [...parsed.problems, ...refusedAttributes(parsed.values)],
286
+ };
287
+ }
288
+
353
289
  /**
354
290
  * Parse logical verse lines, stanzas and cantos without losing authored offsets.
355
291
  * Offsets are UTF-16 positions relative to body. Delimiters and indentation are
356
292
  * layout ranges; source newlines inside a logical line contribute one space.
357
- * @param {string} body - Authored fence body.
293
+ * @param {string} body - Authored verse body.
294
+ * @param {{inline?: boolean, footnotes?: ReadonlySet<string>}} [options] - Inline quotations have no cantos; footnotes are same-note definition labels.
358
295
  * @returns {VerseModel} The shared structure with offsets relative to the body.
359
296
  */
360
- export function parseVerse(body) {
297
+ export function parseVerse(body, { inline = false, footnotes = new Set() } = {}) {
361
298
  const source = String(body ?? "");
362
299
  const groups = [],
363
300
  boundaries = [],
364
301
  layout = [],
365
302
  errors = [];
303
+ if (inline)
304
+ for (const match of source.matchAll(/\r?\n[ \t]*\r?\n/g))
305
+ errors.push({
306
+ offset: match.index,
307
+ message: "an inline .verse quotation uses // for a stanza break, not a blank line",
308
+ });
366
309
  const cantos = [{ stanzas: [] }];
367
- const protectedRanges = syntaxRanges(source);
368
- const spans = (lineParser.parseInline(source, {})[0]?.children ?? [])
310
+ const references = footnoteReferences(source, { definitions: footnotes, inline: true });
311
+ layout.push(...references.map((ref) => [ref.from, ref.to]));
312
+ const protectedRanges = [
313
+ ...inlineSyntaxRanges(source),
314
+ ...references.map((ref) => [ref.from, ref.to]),
315
+ ].sort((a, b) => a[0] - b[0] || b[1] - a[1]);
316
+ const children = [];
317
+ lineParser.inline.parse(source, lineParser, {}, children);
318
+ const spans = children
369
319
  .filter((token) => token.meta?.span)
370
320
  .map((token) => ({
371
321
  start: token.meta.start,
@@ -378,14 +328,43 @@ export function parseVerse(body) {
378
328
  from = 0,
379
329
  level = 0,
380
330
  lines = [];
331
+ const pendingNotes = [];
381
332
  let pendingBreak;
333
+ const cantoIds = new Set();
334
+ if (inline)
335
+ for (const match of source.matchAll(/---[ \t]+\{[^}]*\.canto[^}]*\}/g)) {
336
+ const escaped = /\\+$/.exec(source.slice(0, match.index))?.[0].length ?? 0;
337
+ if (
338
+ escaped % 2 ||
339
+ protectedRanges.some(([first, past]) => match.index >= first && match.index < past)
340
+ )
341
+ continue;
342
+ errors.push({
343
+ offset: match.index,
344
+ message: "an inline .verse quotation has no canto breaks",
345
+ });
346
+ }
382
347
  const append = (value, at) => {
383
348
  text += value;
384
349
  for (let index = 0; index < value.length; index++) offsets.push(at + index);
385
350
  };
386
351
  const finishLine = (at) => {
352
+ let kind = "empty";
387
353
  const trimmed = text.trimEnd();
388
354
  if (trimmed.trim()) {
355
+ if (
356
+ STANZA_BREAK.test(trimmed) &&
357
+ !errors.some(
358
+ (error) =>
359
+ error.offset >= from &&
360
+ error.offset <= at &&
361
+ error.message.includes("--- {.canto}"),
362
+ )
363
+ )
364
+ errors.push({
365
+ offset: from,
366
+ message: "write a canto break as --- {.canto}, preceded by a blank line",
367
+ });
389
368
  let lineText = trimmed,
390
369
  lineOffsets = offsets.slice(0, trimmed.length);
391
370
  for (const span of spans
@@ -423,15 +402,54 @@ export function parseVerse(body) {
423
402
  lineText = kept.map(({ char }) => char).join("");
424
403
  lineOffsets = kept.map(({ offset }) => offset);
425
404
  }
426
- lines.push({ text: lineText, level, from, to: at, offsets: lineOffsets });
405
+ const line = { text: lineText, level, from, to: at, offsets: lineOffsets };
406
+ const visible = [];
407
+ const hasReferences =
408
+ footnoteReferences(lineText, { definitions: footnotes, inline: true }).length > 0;
409
+ if (hasReferences)
410
+ lineParser.inline.parse(
411
+ lineText,
412
+ lineParser,
413
+ {
414
+ footnotes: {
415
+ refs: Object.fromEntries([...footnotes].map((key) => [":" + key, -1])),
416
+ },
417
+ },
418
+ visible,
419
+ );
420
+ const onlyReferences =
421
+ hasReferences &&
422
+ !inlineText(visible).trim() &&
423
+ !visible.some((token) => token.type === "image");
424
+ kind = onlyReferences ? "note" : "content";
425
+ if (onlyReferences) {
426
+ const previous =
427
+ lines.at(-1) ?? groups.findLast((group) => !group.cantoBreak)?.lines.at(-1);
428
+ if (previous) {
429
+ previous.text += line.text;
430
+ previous.offsets.push(...line.offsets);
431
+ } else pendingNotes.push(line);
432
+ } else {
433
+ if (pendingNotes.length) {
434
+ line.text = pendingNotes.map((note) => note.text).join("") + line.text;
435
+ line.offsets = [
436
+ ...pendingNotes.flatMap((note) => note.offsets),
437
+ ...line.offsets,
438
+ ];
439
+ pendingNotes.length = 0;
440
+ }
441
+ lines.push(line);
442
+ }
427
443
  }
428
444
  text = "";
429
445
  offsets = [];
430
446
  level = 0;
431
447
  from = at;
448
+ return kind;
432
449
  };
433
450
  const finishStanza = (at) => {
434
- finishLine(at);
451
+ const kind = finishLine(at);
452
+ const emitted = lines.length > 0;
435
453
  if (lines.length) {
436
454
  const stanza = { lines, from: lines[0].from, to: at };
437
455
  cantos.at(-1).stanzas.push(stanza);
@@ -443,17 +461,25 @@ export function parseVerse(body) {
443
461
  });
444
462
  lines = [];
445
463
  }
464
+ return { kind, emitted };
446
465
  };
447
466
  const event = (kind, at, past) => {
448
- if (kind === "line") finishLine(at);
449
- else finishStanza(at);
467
+ const result =
468
+ kind === "line" ? { kind: finishLine(at), emitted: false } : finishStanza(at);
450
469
  layout.push([at, past]);
451
- boundaries.push({ kind, sourceOffset: at });
470
+ if (!(
471
+ result.kind === "note" &&
472
+ (kind === "line" || (kind === "stanza" && !result.emitted))
473
+ ))
474
+ boundaries.push({ kind, sourceOffset: at });
452
475
  from = past;
453
476
  };
454
477
  for (let at = 0; at < source.length;) {
478
+ while (protectedRanges[protectedIndex]?.[1] <= at) protectedIndex++;
479
+ const protectedRange = protectedRanges[protectedIndex];
480
+ const protectedAt = protectedRange && protectedRange[0] <= at;
455
481
  const lineStart = at === 0 || /[\r\n]/.test(source[at - 1]);
456
- if (lineStart) {
482
+ if (lineStart && !protectedAt) {
457
483
  const physical = /^[^\r\n]*/.exec(source.slice(at))[0];
458
484
  const newline = /^(?:\r\n|\r|\n)/.exec(source.slice(at + physical.length))?.[0] ?? "";
459
485
  if (!physical.trim()) {
@@ -461,25 +487,59 @@ export function parseVerse(body) {
461
487
  at += physical.length + newline.length;
462
488
  continue;
463
489
  }
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: [] });
490
+ if (STANZA_BREAK.test(physical.trim()))
491
+ errors.push({
492
+ offset: at,
493
+ message: "write a canto break as --- {.canto}, preceded by a blank line",
494
+ });
495
+ const canto = cantoLine(physical);
496
+ if (canto) {
497
+ if (inline) {
498
+ if (!errors.some((error) => error.offset === at))
499
+ errors.push({
500
+ offset: at,
501
+ message: "an inline .verse quotation has no canto breaks",
502
+ });
503
+ } else {
504
+ for (const message of canto.problems) errors.push({ offset: at, message });
505
+ if (
506
+ at &&
507
+ source.slice(0, at).trimEnd().length &&
508
+ !/(?:\r?\n)[ \t]*(?:\r?\n)$/.test(source.slice(0, at))
509
+ )
510
+ errors.push({
511
+ offset: at,
512
+ message: "a canto break needs a blank line before it",
513
+ });
514
+ if (canto.id && cantoIds.has(canto.id))
515
+ errors.push({
516
+ offset: at,
517
+ message: `canto anchor #${canto.id} is written more than once`,
518
+ });
519
+ if (canto.id) cantoIds.add(canto.id);
520
+ event("canto", at, at + physical.length + newline.length);
521
+ if (!cantos.at(-1).stanzas.length)
522
+ errors.push({
523
+ offset: at,
524
+ message: "a canto break needs a stanza before and after it",
525
+ });
526
+ else {
527
+ groups.push({
528
+ cantoBreak: true,
529
+ from: at,
530
+ to: at + physical.length,
531
+ id: canto.id,
532
+ title: canto.title,
533
+ });
534
+ cantos.push({ stanzas: [] });
535
+ }
536
+ pendingBreak = at;
537
+ at += physical.length + newline.length;
538
+ continue;
474
539
  }
475
- pendingBreak = at;
476
- at += physical.length + newline.length;
477
- continue;
478
540
  }
479
541
  }
480
- while (protectedRanges[protectedIndex]?.[1] <= at) protectedIndex++;
481
- const protectedRange = protectedRanges[protectedIndex];
482
- const protectedAt = protectedRange && protectedRange[0] <= at;
542
+
483
543
  if (!text && !/\s/.test(source[at])) {
484
544
  if (!level) from = at;
485
545
  const remaining = source.slice(at);
@@ -533,7 +593,36 @@ export function parseVerse(body) {
533
593
  for (const group of groups)
534
594
  if (!group.cantoBreak)
535
595
  group.label = multi ? `${group.canto}:${group.stanza}` : String(group.stanza);
536
- return { cantos, groups, boundaries, layout, errors };
596
+ return { cantos, groups, boundaries, layout, errors, notes: pendingNotes };
597
+ }
598
+
599
+ /**
600
+ * Validate a quoted verse span using its original source coordinates.
601
+ * @param {{start: number, text: string, attrs: Array<[string,string]>, verseModel?: VerseModel}} span - Authored span.
602
+ * @param {string} source - Complete Markdown source.
603
+ * @returns {VerseDiagnostic[]} Located inline-only findings.
604
+ */
605
+ export function inlineVerseFindings(span, source) {
606
+ const findings = [
607
+ ...(
608
+ span.verseModel ??
609
+ parseVerse(span.text, {
610
+ inline: true,
611
+ footnotes: footnoteLabels(source),
612
+ })
613
+ ).errors,
614
+ ];
615
+ for (const [key] of span.attrs)
616
+ if (["form", "work", "scan"].includes(key))
617
+ findings.push({ offset: 0, message: `${key}= is not an inline .verse attribute` });
618
+ return findings.map((finding) => {
619
+ const prefix = source.slice(0, span.start + 1 + finding.offset);
620
+ return {
621
+ line: prefix.split("\n").length,
622
+ column: prefix.length - prefix.lastIndexOf("\n"),
623
+ message: finding.message,
624
+ };
625
+ });
537
626
  }
538
627
 
539
628
  /** Logical line text for consumers that do not need source locations. */
@@ -557,10 +646,14 @@ export const stanzaSeparator = (group) => group.length === 1 && STANZA_BREAK.tes
557
646
 
558
647
  /**
559
648
  * 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.
649
+ * @param {VerseBlock[]} blocks - Divs belonging to one page.
650
+ * @returns {VerseDiagnostic[]} Findings at affected opening divs.
562
651
  */
563
652
  export function workShapeFindings(blocks) {
653
+ const location = (block) => ({
654
+ line: block.start + 1,
655
+ ...(block.column === undefined ? {} : { column: block.column }),
656
+ });
564
657
  const works = new Map(),
565
658
  errors = [];
566
659
  for (const block of blocks) {
@@ -572,8 +665,7 @@ export function workShapeFindings(blocks) {
572
665
  for (const [key, versions] of works) {
573
666
  if (versions.length === 1) {
574
667
  errors.push({
575
- line: versions[0].start + 1,
576
- column: 1,
668
+ ...location(versions[0]),
577
669
  message: `work=${key} has only one version`,
578
670
  });
579
671
  continue;
@@ -589,7 +681,7 @@ export function workShapeFindings(blocks) {
589
681
  for (const block of [first, version]) {
590
682
  const identity = `${block.start}:${message}`;
591
683
  if (!seen.has(identity)) {
592
- errors.push({ line: block.start + 1, column: 1, message });
684
+ errors.push({ ...location(block), message });
593
685
  seen.add(identity);
594
686
  }
595
687
  }
@@ -600,7 +692,7 @@ export function workShapeFindings(blocks) {
600
692
  for (const block of [first, version]) {
601
693
  const identity = `${block.start}:${message}`;
602
694
  if (!seen.has(identity)) {
603
- errors.push({ line: block.start + 1, column: 1, message });
695
+ errors.push({ ...location(block), message });
604
696
  seen.add(identity);
605
697
  }
606
698
  }
@@ -612,16 +704,17 @@ export function workShapeFindings(blocks) {
612
704
 
613
705
  /**
614
706
  * Render a shared verse structure while preserving surrounding inline Markdown.
615
- * @param {string} body - Authored fence body.
707
+ * @param {string} body - Authored verse body.
616
708
  * @param {object} [options] - Shared structure and display choices.
617
709
  * @param {boolean} [options.labelled] - Whether each stanza carries its label.
618
710
  * @param {VerseModel} [options.model] - Parsed structure; defaults to parsing the body.
619
711
  * @param {VersePresentation} [options.presentation] - Native-cell font and language.
712
+ * @param {boolean} [options.inline] - Whether wrappers must remain inline-safe.
620
713
  * @returns {string} Markdown with verse layout wrappers.
621
714
  */
622
715
  export function verseMarkdown(
623
716
  body,
624
- { labelled = false, model = parseVerse(body), presentation } = {},
717
+ { labelled = false, model = parseVerse(body), presentation, inline = false } = {},
625
718
  ) {
626
719
  const escape = (value) =>
627
720
  String(value)
@@ -634,7 +727,7 @@ export function verseMarkdown(
634
727
  .map(([key, value]) => ` ${key}="${escape(value)}"`)
635
728
  .join("");
636
729
  const nativeClasses = ["verse-native", ...(presentation?.classes ?? [])].join(" ");
637
- return model.groups
730
+ const content = model.groups
638
731
  .map((group) => {
639
732
  if (group.cantoBreak) {
640
733
  const mark = group.text ? inlineVerse(group.text) : "";
@@ -642,7 +735,14 @@ export function verseMarkdown(
642
735
  mark && presentation ?
643
736
  `<span class="${escape(nativeClasses)}"${nativeAttrs}${!presentation.attributes?.dir && rightToLeft(group.text) ? ' dir="rtl"' : ""}>${mark}</span>`
644
737
  : mark;
645
- return `<span class="verse-canto-break" aria-hidden="true">${content}</span>`;
738
+ const heading =
739
+ group.title ?
740
+ `<span class="verse-canto-heading" role="heading" aria-level="3"${group.id ? ` id="${escape(slugify(group.id))}"` : ""}>${inlineVerse(group.title)}</span>`
741
+ : group.id ? `<span id="${escape(slugify(group.id))}"></span>`
742
+ : "";
743
+ return (
744
+ heading + `<span class="verse-canto-break" aria-hidden="true">${content}</span>`
745
+ );
646
746
  }
647
747
  const label = labelled ? `<span class="verse-label">${group.label}</span>` : "";
648
748
  const rows = group.lines
@@ -653,15 +753,27 @@ export function verseMarkdown(
653
753
  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
754
  return content(line.text);
655
755
  })
656
- .join(group.lines.some((line) => line.romanText !== undefined) ? "\n" : " \n");
657
- return `<div class="verse-stanza">${label}\n\n${rows}\n\n</div>`;
756
+ .join(
757
+ group.lines.some((line) => line.romanText !== undefined) ?
758
+ inline ? ""
759
+ : "\n"
760
+ : inline ? "<br/>"
761
+ : " \n",
762
+ );
763
+ return inline ?
764
+ `<span class="verse-stanza">${rows}</span>`
765
+ : `<div class="verse-stanza">${label}\n\n${rows}\n\n</div>`;
658
766
  })
659
- .join("\n\n");
767
+ .join(inline ? '<br class="verse-stanza-break"/>' : "\n\n");
768
+ const notes = (model.notes ?? [])
769
+ .map((note) => inlineVerse(note.romanText ?? note.text))
770
+ .join("");
771
+ return content + (notes ? `<span class="verse-footnotes">${notes}</span>` : "");
660
772
  }
661
773
 
662
774
  /**
663
- * Replace verse fences with wrappers whose bodies remain inline Markdown.
664
- * @param {string} source - Markdown containing verse fences.
775
+ * Replace verse divs with wrappers whose bodies remain inline Markdown.
776
+ * @param {string} source - Markdown containing verse divs.
665
777
  * @param {object} [options] - Encoded display models.
666
778
  * @param {Map<string, VersePresentation>} [options.verses] - Presentations keyed by rewritten body.
667
779
  */
@@ -680,13 +792,10 @@ export function renderVerse(source, { verses = new Map() } = {}) {
680
792
  output.push(...lines.slice(cursor, block.start));
681
793
  const transported = verses.get(block.body);
682
794
  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
- );
795
+ const paired = Boolean(transported);
687
796
  const labelled = block.attributes.work !== undefined;
688
797
  const attrs = [
689
- `class="${escape(["verse", ...block.classes.filter((value) => !paired || !transported?.classes?.includes(value)), ...(paired ? ["verse-paired"] : []), ...(labelled ? ["verse-labelled"] : [])].join(" "))}"`,
798
+ `class="${escape(["verse", ...block.classes.filter((value) => value !== "verse" && (!paired || !transported?.classes?.includes(value))), ...(paired ? ["verse-paired"] : []), ...(labelled ? ["verse-labelled"] : [])].join(" "))}"`,
690
799
  ];
691
800
  if (block.id) attrs.push(`id="${escape(slugify(block.id))}"`);
692
801
  for (const [key, value] of Object.entries(block.attributes)) {
@@ -722,3 +831,34 @@ export function renderVerse(source, { verses = new Map() } = {}) {
722
831
  output.push(...lines.slice(cursor));
723
832
  return { markdown: output.join("\n"), errors };
724
833
  }
834
+
835
+ /**
836
+ * Render quoted verse with inline-safe wrappers and shared logical structure.
837
+ * @param {string} source - Markdown after block verse rendering.
838
+ * @returns {string} Markdown retaining surrounding prose.
839
+ */
840
+ export function renderVerseQuotes(source) {
841
+ const spans = scanSpans(source).spans.filter((span) =>
842
+ span.attrs.some(([key, value]) => key === "class" && value.split(/\s+/).includes("verse")),
843
+ );
844
+ const footnotes = footnoteLabels(source);
845
+ let markdown = source;
846
+ for (const span of spans
847
+ .filter(
848
+ (span) => !spans.some((parent) => parent.start < span.start && parent.end >= span.end),
849
+ )
850
+ .sort((a, b) => b.start - a.start)) {
851
+ const model = span.verseModel ?? parseVerse(span.text, { inline: true, footnotes });
852
+ const attrs = span.attrs
853
+ .map(
854
+ ([key, value]) =>
855
+ `${key}="${String(key === "class" ? value + " verse-inline" : value)
856
+ .replace(/&/g, "&amp;")
857
+ .replace(/"/g, "&quot;")}"`,
858
+ )
859
+ .join(" ");
860
+ const html = `<span ${attrs}>${verseMarkdown(span.text, { model, inline: true })}</span>`;
861
+ markdown = markdown.slice(0, span.start) + html + markdown.slice(span.end);
862
+ }
863
+ return markdown;
864
+ }