@heroiclands/package-build 24.0.4 → 24.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -11,39 +11,80 @@
11
11
  * SPDX-License-Identifier: GPL-3.0-or-later
12
12
  */
13
13
 
14
- /** The authored verse lines, excluding stanza separators. */
14
+ /** The authored verse lines, excluding blank lines and stanza separators. */
15
15
  export function verseLines(body) {
16
16
  return String(body ?? "")
17
17
  .split("\n")
18
- .filter((line) => line.trim());
18
+ .filter((line) => line.trim() && !STANZA_BREAK.test(line.trim()));
19
19
  }
20
20
 
21
21
  /**
22
- * The least-indented verse is level zero. Every two additional leading spaces
23
- * add one level; a leftover space is discarded and the level stops at eight.
24
- * Blank lines divide stanzas rather than contributing to the baseline.
22
+ * Read one verse line. Marker offsets are zero-based in the authored line;
23
+ * textColumn is the one-based column of its first non-space character.
24
+ * Periods inside a span set indentation only when that span starts the line.
25
+ */
26
+ export function verseLine(line) {
27
+ const source = String(line ?? "");
28
+ const spaces = /^ */.exec(source)[0].length;
29
+ const written = source.slice(spaces).trimEnd();
30
+ let markerStart = spaces;
31
+ let markerLength = /^\.+/.exec(written)?.[0].length ?? 0;
32
+ if (!markerLength) {
33
+ const span = leadingSpan(written);
34
+ const html = /^<span\b[^>]*>/.exec(written);
35
+ const prefix =
36
+ span ? 1
37
+ : html ? html[0].length
38
+ : 0;
39
+ if (prefix) {
40
+ markerStart += prefix;
41
+ markerLength = /^\.+/.exec(written.slice(prefix))?.[0].length ?? 0;
42
+ }
43
+ }
44
+ const start = markerStart - spaces;
45
+ return {
46
+ text: written.slice(0, start) + written.slice(start + markerLength),
47
+ level: markerLength,
48
+ markerStart,
49
+ markerLength,
50
+ textColumn: spaces + 1,
51
+ };
52
+ }
53
+
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.
25
57
  */
26
58
  export function verseStanzas(body) {
27
- const lines = String(body ?? "").split("\n");
28
- const verses = verseLines(body);
29
- if (!verses.length) return [];
30
- const baseline = Math.min(...verses.map((line) => /^ */.exec(line)[0].length));
31
- const stanzas = [];
32
- let stanza = [];
33
- for (const line of lines) {
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")) {
34
66
  if (!line.trim()) {
35
- if (stanza.length) stanzas.push(stanza);
36
- stanza = [];
67
+ end();
37
68
  continue;
38
69
  }
39
- const spaces = /^ */.exec(line)[0].length;
40
- stanza.push({
41
- text: line.slice(spaces).trimEnd(),
42
- level: Math.min(8, Math.floor((spaces - baseline) / 2)),
43
- });
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 });
44
77
  }
45
- if (stanza.length) stanzas.push(stanza);
46
- return stanzas;
78
+ end();
79
+ return groups;
80
+ }
81
+
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
+ /** HTML indentation attributes; large levels retain their full authored depth. */
86
+ function indentStyle(level) {
87
+ return level > 8 ? ` style="--verse-indent: ${level * 1.25}em"` : "";
47
88
  }
48
89
 
49
90
  /** A verse is inline Markdown even when it starts with block syntax. */
@@ -54,7 +95,8 @@ export function inlineVerse(text) {
54
95
  }
55
96
 
56
97
  /**
57
- * Keep stanzas as Markdown paragraphs and make every verse line a hard break.
98
+ * Keep blank-line verse groups as paragraphs and each text line a hard break.
99
+ * A standalone `***` emits a separate stanza gap.
58
100
  * The surrounding renderer still sees the entire note, so links and footnote
59
101
  * definitions remain in scope. The span carries the indent for each line.
60
102
  */
@@ -62,12 +104,14 @@ export function verseMarkdown(body, step = 0) {
62
104
  let count = 0;
63
105
  return verseStanzas(body)
64
106
  .map((stanza) =>
65
- stanza
66
- .map(({ text, level }) => {
67
- const number = lineNumber(++count, step);
68
- return `<span class="verse-line${level ? ` i${level}` : ""}">${number === undefined || number === "" ? "" : `<span class="verse-number">${number}</span>`}${inlineVerse(text)}</span>`;
69
- })
70
- .join(" \n"),
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"),
71
115
  )
72
116
  .join("\n\n");
73
117
  }
@@ -86,49 +130,40 @@ export function lineNumber(line, step) {
86
130
  }
87
131
 
88
132
  /**
89
- * The rows of an expanded verse in columns: one row per verse line, holding
90
- * its number when the verse is numbered, then each column's entries stacked,
133
+ * The numbered-column rows, each holding its logical line number and each
134
+ * column’s physical readings stacked in authored order,
91
135
  * the first in the normal tone and each later one `verse-secondary`. A part
92
136
  * whose text runs right to left carries `dir="rtl"`. A row is a line of a
93
137
  * paragraph, and a paragraph a stanza, so each part stays inline Markdown.
94
138
  *
95
- * @param {object} block - An expanded verse from {@link scanVerse}.
139
+ * @param {object} block - A numbered-column verse from {@link scanVerse}.
96
140
  * @param {string[]} lines - The source, by line.
97
141
  * @param {number} step - The verse's `numstep`.
98
142
  * @returns {string} The rows as Markdown with inline HTML.
99
143
  */
100
144
  function columnsMarkdown(block, lines, step) {
101
- const counts = verseColumns(block, lines[block.start]).map((column) => column.entries.length);
102
- const total = counts.reduce((sum, n) => sum + n, 0);
145
+ const width = verseColumnCount(block);
103
146
  const stanzas = [[]];
104
147
  let count = 0;
105
- const body = lines.slice(block.start + 1, block.end).map((line) => line.trim());
106
- for (let at = 0; at < body.length;) {
107
- if (!body[at]) {
108
- at++;
109
- continue;
110
- }
111
- if (STANZA_BREAK.test(body[at])) {
112
- stanzas.push([]);
113
- at++;
148
+ for (const group of verseGroups(block, lines)) {
149
+ if (group.stanzaBreak) {
150
+ stanzas.push(['<span class="verse-stanza-break" aria-hidden="true"></span>'], []);
114
151
  continue;
115
152
  }
116
- const parts = body.slice(at, at + total);
117
- at += total;
118
153
  const number = lineNumber(++count, step);
119
- let next = 0;
120
- const cells = counts.map(
121
- (n) =>
154
+ const cells = Array.from({ length: width }, (_, column) => {
155
+ const parts = group.parts.filter((part) => part.column === column + 1);
156
+ return (
122
157
  '<span class="verse-column">' +
123
158
  parts
124
- .slice(next, (next += n))
125
159
  .map(
126
- (part, k) =>
127
- `<span class="verse-part${k ? " verse-secondary" : ""}"${rightToLeft(part) ? ' dir="rtl"' : ""}>${part}</span>`,
160
+ ({ text, indent }, index) =>
161
+ `<span class="verse-part${index ? " verse-secondary" : ""}${indent ? ` i${indent}` : ""}"${indentStyle(indent)}${rightToLeft(text) ? ' dir="rtl"' : ""}>${inlineVerse(text)}</span>`,
128
162
  )
129
163
  .join("") +
130
- "</span>",
131
- );
164
+ "</span>"
165
+ );
166
+ });
132
167
  stanzas
133
168
  .at(-1)
134
169
  .push(
@@ -146,14 +181,20 @@ function columnsMarkdown(block, lines, step) {
146
181
 
147
182
  import MarkdownIt from "markdown-it";
148
183
  import { slugify } from "./content-slug.mjs";
184
+ import { spanMarkdownPlugin } from "./content-spans.mjs";
149
185
  import { parseExtensionAttributes, refusedAttributes } from "./extension-attributes.mjs";
150
186
 
151
187
  const structureParser = new MarkdownIt({ html: true });
188
+ const lineParser = new MarkdownIt({ html: true }).use(spanMarkdownPlugin);
152
189
 
153
- /** The keys that set a verse in columns, the left first. */
154
- export const COLUMN_KEYS = Object.freeze(["col1", "col2"]);
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);
195
+ }
155
196
 
156
- /** The class the build gives a verse in columns once its lines are expanded. */
197
+ /** The layout class the build gives a verse whose cols count exceeds one. */
157
198
  export const COLUMNS_CLASS = "verse-columns";
158
199
 
159
200
  /**
@@ -169,7 +210,7 @@ export const VERSE_KEYS = Object.freeze([
169
210
  "font",
170
211
  "map",
171
212
  "numstep",
172
- ...COLUMN_KEYS,
213
+ "cols",
173
214
  "dir",
174
215
  "data-pagefind-ignore",
175
216
  ]);
@@ -216,10 +257,6 @@ export function scanVerse(source) {
216
257
  }
217
258
  const block = { ...opening, end: i, body };
218
259
  const columned = hasColumns(block);
219
- const count =
220
- columned ?
221
- verseGroups(block, lines).filter((group) => !group.stanzaBreak).length
222
- : verseLines(body).length;
223
260
  if (columned) errors.push(...columnFindings(block, lines));
224
261
  blocks.push(block);
225
262
  }
@@ -252,17 +289,20 @@ export function scanVerse(source) {
252
289
  for (const message of [...parsed.problems, ...refusedAttributes(parsed.values)])
253
290
  errors.push({ ...at, message });
254
291
  for (const [key, value] of Object.entries(parsed.values)) {
255
- if (/^col/.test(key) && !COLUMN_KEYS.includes(key))
292
+ if (!VERSE_KEYS.includes(key))
256
293
  errors.push({
257
294
  line: i + 1,
258
295
  column: keyColumn(lines[i], key),
259
- message: `\`${key}=\` is not a column: a verse has at most two, \`col1=\` and \`col2=\``,
296
+ message: `${key}= is not a verse attribute`,
260
297
  });
261
- else if (!VERSE_KEYS.includes(key))
298
+ else if (
299
+ key === "cols" &&
300
+ (!/^[1-9]\d*$/.test(value) || !Number.isSafeInteger(Number(value)))
301
+ )
262
302
  errors.push({
263
303
  line: i + 1,
264
- column: keyColumn(lines[i], key),
265
- message: `${key}= is not a verse attribute`,
304
+ column: keyColumn(lines[i], key) + key.length + 1,
305
+ message: "verse cols= is a positive whole number",
266
306
  });
267
307
  else if (key === "numstep" && !/^\d+$/.test(value))
268
308
  errors.push({
@@ -298,97 +338,29 @@ function keyColumn(opening, key) {
298
338
  return (at < 0 ? brace : brace + at) + 1;
299
339
  }
300
340
 
301
- /** Whether a verse states a column. */
302
- export function hasColumns(block) {
303
- return COLUMN_KEYS.some((key) => Object.hasOwn(block.attributes ?? {}, key));
304
- }
305
-
306
- /** One part of a column entry, `language[/mapping[/font]]`. */
307
- const ENTRY_PART = /^[A-Za-z0-9_-]+$/;
308
-
309
- /**
310
- * A column's stacked entries, each with its offset in the written value.
311
- *
312
- * @param {string} value - A `col1=` or `col2=` value.
313
- * @returns {Array<{raw: string, offset: number, lang?: string, map?: string, font?: string, offsets: {lang: number, map?: number, font?: number}}>}
314
- * Each entry; one whose `lang` is `undefined` is malformed.
315
- */
316
- export function columnEntries(value) {
317
- const entries = [];
318
- let offset = 0;
319
- for (const raw of String(value).split(":")) {
320
- const parts = raw.split("/");
321
- const sound = parts.length <= 3 && parts.every((part) => ENTRY_PART.test(part));
322
- const offsets = { lang: offset };
323
- let at = offset;
324
- for (const [n, name] of [
325
- [1, "map"],
326
- [2, "font"],
327
- ]) {
328
- if (parts.length <= n) break;
329
- at += parts[n - 1].length + 1;
330
- offsets[name] = at;
331
- }
332
- entries.push({
333
- raw,
334
- offset,
335
- offsets,
336
- ...(sound ?
337
- {
338
- lang: parts[0],
339
- ...(parts[1] === undefined ? {} : { map: parts[1] }),
340
- ...(parts[2] === undefined ? {} : { font: parts[2] }),
341
- }
342
- : {}),
343
- });
344
- offset += raw.length + 1;
345
- }
346
- return entries;
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;
347
346
  }
348
347
 
349
- /**
350
- * The columns of a verse, the left first, each entry located in the opening
351
- * line.
352
- *
353
- * @param {object} block - A verse from {@link scanVerse}.
354
- * @param {string} opening - Its opening line.
355
- * @returns {Array<{key: string, entries: Array<ReturnType<typeof columnEntries>[number] & {column: number, columns: {lang: number, map?: number, font?: number}}>}>}
356
- * 1-based columns.
357
- */
358
- export function verseColumns(block, opening) {
359
- return COLUMN_KEYS.filter((key) => Object.hasOwn(block.attributes, key)).map((key) => {
360
- const start = keyColumn(opening, key) + key.length + 1;
361
- const quoted = opening[start - 1] === '"' ? 1 : 0;
362
- return {
363
- key,
364
- entries: columnEntries(block.attributes[key]).map((entry) => ({
365
- ...entry,
366
- column: start + quoted + entry.offset,
367
- columns: Object.fromEntries(
368
- Object.entries(entry.offsets).map(([name, at]) => [name, start + quoted + at]),
369
- ),
370
- })),
371
- };
372
- });
348
+ /** Whether a verse uses the numbered multi-column protocol. */
349
+ export function hasColumns(block) {
350
+ return verseColumnCount(block) > 1;
373
351
  }
374
352
 
375
- /**
376
- * The line that breaks a stanza in a verse in columns, `***`, also as the
377
- * rewrite of marked text escapes it.
378
- */
353
+ /** A line separating stanzas, including escaped output from a native run. */
379
354
  export const STANZA_BREAK = /^(?:\\?\*){3}$/;
380
355
 
381
- /** A whole-line span, `[text]{attributes}`. */
382
- const LINE_SPAN = /^\[(.*)\]\{([^{}]*)\}$/;
383
-
384
356
  /**
385
- * The verse lines of a verse in columns: each blank-line group of span lines
386
- * is one verse line, and a line holding only `***` breaks the stanza.
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.
387
360
  *
388
- * @param {object} block - A verse from {@link scanVerse}.
389
- * @param {string[]} lines - The source, by line.
390
- * @returns {Array<{stanzaBreak: true, line: number}|{stanzaBreak?: false, parts: Array<{line: number, column: number, text: string, textColumn: number, lang?: string, span: boolean, extra: boolean, indent: number}>}>}
391
- * 0-based lines, 1-based columns.
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}>}>}
392
364
  */
393
365
  export function verseGroups(block, lines) {
394
366
  const groups = [];
@@ -397,130 +369,82 @@ export function verseGroups(block, lines) {
397
369
  if (parts.length) groups.push({ parts });
398
370
  parts = [];
399
371
  };
400
- const body = lines.slice(block.start + 1, block.end).filter((line) => line.trim());
401
- const baseline = body.length ? Math.min(...body.map((line) => /^ */.exec(line)[0].length)) : 0;
402
372
  for (let at = block.start + 1; at < block.end; at++) {
403
373
  const line = lines[at];
404
374
  if (!line.trim()) {
405
375
  end();
406
376
  continue;
407
377
  }
408
- if (line.trim() === "***") {
378
+ if (STANZA_BREAK.test(line.trim())) {
409
379
  end();
410
380
  groups.push({ stanzaBreak: true, line: at });
411
381
  continue;
412
382
  }
413
383
  const spaces = /^ */.exec(line)[0].length;
414
- const written = line.slice(spaces).trimEnd();
415
- const span = LINE_SPAN.exec(written);
416
- const parsed = span ? parseExtensionAttributes(span[2]) : undefined;
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));
417
388
  parts.push({
389
+ column: prefix ? Number(prefix[1]) : 1,
418
390
  line: at,
419
- column: spaces + 1,
420
- indent: spaces - baseline,
421
- span: Boolean(span),
422
- text: span ? span[1] : written,
423
- textColumn: spaces + 1 + (span ? 1 : 0),
424
- lang: span ? parsed.values.lang : block.attributes.lang,
425
- extra:
426
- Boolean(parsed) &&
427
- (parsed.problems.length > 0 ||
428
- Boolean(parsed.id) ||
429
- parsed.classes.length > 0 ||
430
- Object.keys(parsed.values).some((key) => key !== "lang") ||
431
- parsed.values.lang === undefined),
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,
432
398
  });
433
399
  }
434
400
  end();
435
401
  return groups;
436
402
  }
437
403
 
438
- /**
439
- * What is wrong with a verse in columns, each finding at its own text.
440
- *
441
- * @param {object} block - A verse from {@link scanVerse} stating a column.
442
- * @param {string[]} lines - The source, by line.
443
- * @returns {Array<{line: number, column: number, message: string}>} 1-based.
444
- */
404
+ /** Locate an invalid numbered line without changing its authored text. */
445
405
  function columnFindings(block, lines) {
446
406
  const errors = [];
407
+ const width = verseColumnCount(block);
447
408
  const opening = lines[block.start];
448
409
  const report = (line, column, message) => errors.push({ line: line + 1, column, message });
449
- for (const key of ["font", "map"])
450
- if (Object.hasOwn(block.attributes, key))
451
- report(
452
- block.start,
453
- keyColumn(opening, key),
454
- `a verse in columns sets each column's fonts and maps in \`col1=\` and \`col2=\`, not with \`${key}=\``,
455
- );
456
410
  if (block.classes.includes(COLUMNS_CLASS))
457
411
  report(
458
412
  block.start,
459
413
  keyColumn(opening, `.${COLUMNS_CLASS}`),
460
414
  `\`.${COLUMNS_CLASS}\` is the class the build gives a verse in columns; leave it out`,
461
415
  );
462
- const columns = verseColumns(block, opening);
463
- for (const { key, entries } of columns)
464
- for (const entry of entries)
465
- if (entry.lang === undefined)
466
- report(
467
- block.start,
468
- entry.column,
469
- `\`${entry.raw}\` in \`${key}=\` is not an entry: an entry is \`language[/mapping[/font]]\``,
470
- );
471
- const named = new Map();
472
- for (const { key, entries } of columns)
473
- for (const entry of entries)
474
- if (entry.lang !== undefined && !named.has(entry.lang)) named.set(entry.lang, key);
475
416
  for (const group of verseGroups(block, lines)) {
476
417
  if (group.stanzaBreak) continue;
477
- const seen = new Set();
478
- let unlabelled = false;
418
+ let previous = 0;
479
419
  for (const part of group.parts) {
480
- if (part.indent > 0)
481
- report(part.line, part.column, "a line of a verse in columns is not indented");
482
- if (part.span && part.extra) {
420
+ if (!part.prefixLength) {
483
421
  report(
484
422
  part.line,
485
- part.column,
486
- "a line of a verse in columns is `[text]{lang=…}`, or plain text in the verse's `lang=`, and takes no other key",
423
+ part.sourceColumn,
424
+ "a verse with cols>1 starts each content line with a column number and spaces",
487
425
  );
488
- if (part.lang === undefined) {
489
- unlabelled = true;
490
- continue;
491
- }
426
+ continue;
492
427
  }
493
- if (part.lang === undefined) {
494
- unlabelled = true;
428
+ if (!part.text.trim())
495
429
  report(
496
430
  part.line,
497
- part.column,
498
- "this line names no language: write it `[text]{lang=…}`, or give the verse `lang=`",
431
+ part.sourceColumn,
432
+ "a numbered verse line needs content after its column number",
499
433
  );
500
- continue;
501
- }
502
- if (!named.has(part.lang))
434
+ if (!Number.isSafeInteger(part.column) || part.column < 1 || part.column > width)
503
435
  report(
504
436
  part.line,
505
- part.column,
506
- `no column sets \`${part.lang}\`; a verse line gives the languages \`col1=\` and \`col2=\` name`,
437
+ part.sourceColumn,
438
+ `column ${part.column} is outside 1..${width}`,
507
439
  );
508
- else if (seen.has(part.lang))
440
+ else if (part.column < previous)
509
441
  report(
510
442
  part.line,
511
- part.column,
512
- `this verse line gives \`${part.lang}\` twice; give each language once`,
443
+ part.sourceColumn,
444
+ "column numbers must not decrease within a verse row",
513
445
  );
514
- seen.add(part.lang);
446
+ previous = part.column;
515
447
  }
516
- if (unlabelled) continue;
517
- for (const [lang, key] of named)
518
- if (!seen.has(lang))
519
- report(
520
- group.parts[0].line,
521
- group.parts[0].column,
522
- `this verse line has no \`${lang}\` line, which \`${key}=\` sets`,
523
- );
524
448
  }
525
449
  return errors;
526
450
  }
@@ -533,7 +457,11 @@ function columnFindings(block, lines) {
533
457
  * @returns {boolean} Whether it starts right to left.
534
458
  */
535
459
  export function rightToLeft(text) {
536
- const first = /[\p{L}\p{M}\p{Co}\p{Cn}]/u.exec(String(text).replace(/\{[^}]*\}/g, ""));
460
+ const first = /[\p{L}\p{M}\p{Co}\p{Cn}]/u.exec(
461
+ String(text)
462
+ .replace(/<[^>]*>/g, "")
463
+ .replace(/\{[^}]*\}/g, ""),
464
+ );
537
465
  return Boolean(
538
466
  first &&
539
467
  /[\u0590-\u08FF\uFB1D-\uFDFF\uFE70-\uFEFF\u{10800}-\u{10FFF}\u{1E800}-\u{1EFFF}]/u.test(
@@ -542,155 +470,11 @@ export function rightToLeft(text) {
542
470
  );
543
471
  }
544
472
 
545
- /** An attribute value as the shared attribute grammar reads it back. */
546
- function attributeValue(value) {
547
- return /^[A-Za-z0-9_.:/@%+-]+$/.test(value) ? value : `"${value.replace(/[\\"]/g, "\\$&")}"`;
548
- }
549
-
550
- /**
551
- * Where a line of {@link expandVerseOrigins}'s output came from.
552
- *
553
- * @typedef {object} VerseOrigin
554
- * @property {number} line - The source line, 0-based.
555
- * @property {number} [textColumn] - For an entry's line, the 1-based source
556
- * column of its text's first character.
557
- * @property {boolean} [span] - Whether the source line is a span, whose `[`
558
- * sits before its text.
559
- * @property {number} [textLength] - The length of the entry's text.
560
- * @property {{line: number, columns: {lang: number, map?: number, font?: number}}} [entry] -
561
- * Where the entry is written in the opening line.
562
- */
563
-
564
- /**
565
- * Write each verse line of a sound verse in columns as one span per column
566
- * entry, the left column's entries first, each with the entry's language,
567
- * mapping and font. Every target runs it before marked text is rewritten, so
568
- * each entry is set exactly as a span with those keys is. The verse gains the
569
- * class {@link COLUMNS_CLASS}; a verse already carrying it, a verse with a
570
- * finding, and a verse without columns are left as written.
571
- *
572
- * @param {string} source - Markdown.
573
- * @returns {{markdown: string, origins: VerseOrigin[]}} The Markdown, and
574
- * where each of its lines came from.
575
- */
576
- export function expandVerseOrigins(source) {
577
- const text = String(source ?? "");
578
- const lines = text.split("\n");
579
- const identity = () => lines.map((_, line) => ({ line }));
580
- if (!/\bcol[12]=/.test(text)) return { markdown: text, origins: identity() };
581
- const { blocks, errors } = scanVerse(text);
582
- const output = [];
583
- const origins = [];
584
- let cursor = 0;
585
- const copy = (from, to) => {
586
- for (let line = from; line < to; line++) {
587
- output.push(lines[line]);
588
- origins.push({ line });
589
- }
590
- };
591
- for (const block of blocks) {
592
- if (!hasColumns(block) || block.classes.includes(COLUMNS_CLASS)) continue;
593
- if (errors.some((error) => error.line - 1 >= block.start && error.line - 1 <= block.end))
594
- continue;
595
- copy(cursor, block.start);
596
- const opening = lines[block.start];
597
- const close = opening.lastIndexOf("}");
598
- output.push(`${opening.slice(0, close)} .${COLUMNS_CLASS}${opening.slice(close)}`);
599
- origins.push({ line: block.start });
600
- const entries = verseColumns(block, opening).flatMap((column) => column.entries);
601
- let first = true;
602
- for (const group of verseGroups(block, lines)) {
603
- if (!first) {
604
- output.push(block.indent);
605
- origins.push({ line: block.start });
606
- }
607
- first = false;
608
- if (group.stanzaBreak) {
609
- output.push(`${block.indent}***`);
610
- origins.push({ line: group.line });
611
- continue;
612
- }
613
- for (const entry of entries) {
614
- const part = group.parts.find((candidate) => candidate.lang === entry.lang);
615
- const keys = [
616
- `lang=${attributeValue(entry.lang)}`,
617
- ...(entry.map === undefined ? [] : [`map=${attributeValue(entry.map)}`]),
618
- ...(entry.font === undefined ? [] : [`font=${attributeValue(entry.font)}`]),
619
- ];
620
- output.push(`${block.indent}[${part.text}]{${keys.join(" ")}}`);
621
- origins.push({
622
- line: part.line,
623
- textColumn: part.textColumn,
624
- indent: block.indent.length,
625
- span: part.span,
626
- textLength: part.text.length,
627
- entry: { line: block.start, columns: entry.columns },
628
- });
629
- }
630
- }
631
- output.push(lines[block.end]);
632
- origins.push({ line: block.end });
633
- cursor = block.end + 1;
634
- }
635
- copy(cursor, lines.length);
636
- return { markdown: output.join("\n"), origins };
637
- }
638
-
639
- /**
640
- * {@link expandVerseOrigins}'s Markdown alone.
641
- *
642
- * @param {string} source - Markdown.
643
- * @returns {string} The Markdown with each verse in columns expanded.
644
- */
645
- export function expandVerse(source) {
646
- return expandVerseOrigins(source).markdown;
647
- }
648
-
649
- /**
650
- * Where a position in {@link expandVerseOrigins}'s output was written.
651
- *
652
- * A position in an entry's text is the same character of the source line; a
653
- * position in its keys is the entry's part in the opening line that states
654
- * the key it falls in.
655
- *
656
- * @param {{line: number, column: number}} at - 1-based, in the output.
657
- * @param {string[]} expanded - The output, by line.
658
- * @param {VerseOrigin[]} origins - From {@link expandVerseOrigins}.
659
- * @returns {{line: number, column: number}} 1-based, in the source.
660
- */
661
- export function sourcePosition(at, expanded, origins) {
662
- const origin = origins[at.line - 1];
663
- if (!origin) return at;
664
- if (origin.entry === undefined) return { line: origin.line + 1, column: at.column };
665
- const local = at.column - (origin.indent ?? 0);
666
- if (local <= origin.textLength + 1) {
667
- const column = origin.textColumn + local - 2;
668
- return {
669
- line: origin.line + 1,
670
- column: Math.max(origin.textColumn - (origin.span ? 1 : 0), column),
671
- };
672
- }
673
- const line = expanded[at.line - 1];
674
- const brace = line.lastIndexOf("{");
675
- let key = "lang";
676
- let best = -1;
677
- for (const name of ["lang", "map", "font"]) {
678
- const found = line.indexOf(`${name}=`, brace);
679
- if (found >= 0 && found <= at.column - 1 && found > best) {
680
- best = found;
681
- key = name;
682
- }
683
- }
684
- const column = origin.entry.columns[key] ?? origin.entry.columns.lang;
685
- return { line: origin.entry.line + 1, column };
686
- }
687
-
688
473
  /** Replace verse fences with HTML wrappers whose body remains inline Markdown.
689
474
  * @param {string} source - Authored Markdown.
690
475
  */
691
476
  export function renderVerse(source) {
692
477
  const { errors } = scanVerse(source);
693
- source = expandVerse(source);
694
478
  const { blocks } = scanVerse(source),
695
479
  lines = String(source ?? "").split("\n"),
696
480
  output = [];
@@ -704,10 +488,11 @@ export function renderVerse(source) {
704
488
  for (const block of blocks) {
705
489
  output.push(...lines.slice(cursor, block.start));
706
490
  const step = Number(block.attributes.numstep ?? 0) || 0;
707
- const columns = block.classes.includes(COLUMNS_CLASS);
491
+ const columns = hasColumns(block);
708
492
  const attrs = [
709
- `class="${escape(["verse", ...block.classes, ...(step ? ["verse-numbered"] : [])].join(" "))}"`,
493
+ `class="${escape(["verse", ...block.classes, ...(columns ? [COLUMNS_CLASS] : []), ...(step ? ["verse-numbered"] : [])].join(" "))}"`,
710
494
  ];
495
+ if (columns) attrs.push(`style="--verse-columns: ${verseColumnCount(block)}"`);
711
496
  if (block.id) attrs.push(`id="${escape(slugify(block.id))}"`);
712
497
  for (const [key, value] of Object.entries(block.attributes))
713
498
  if (!refusedAttributes({ [key]: value }).length)