@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.
- package/CHANGELOG.md +36 -0
- package/README.md +6 -0
- package/assets/styles/content-base.css +25 -10
- package/docs/authoring/links-and-markup.md +215 -179
- package/docs/commands.md +5 -2
- package/docs/configuration.md +6 -3
- package/docs/content-format.md +4 -3
- package/docs/diagnostics.md +57 -29
- package/docs/guides/book.md +1 -1
- package/docs/guides/module-project.md +2 -2
- package/docs/guides/site.md +2 -2
- package/docs/guides/writing-in-invented-scripts.md +175 -125
- package/docs/reference/format-details.md +53 -35
- package/docs/reference/note-types.md +10 -10
- package/docs/reference/theme-pages.md +18 -9
- package/engine/content-render.mjs +23 -2
- package/engine/content-scripts.mjs +348 -140
- package/engine/content-spans.mjs +13 -1
- package/engine/content-verse.mjs +158 -373
- package/engine/note-vocabulary.mjs +5 -5
- package/engine/pdf-render.mjs +63 -44
- package/engine/run-rows.mjs +18 -20
- package/engine/verse-metre.mjs +36 -8
- package/engine/written-in.mjs +9 -13
- package/package.json +1 -1
- package/site-theme/static/css/style.css +19 -10
- package/types/engine/content-scripts.d.mts +18 -7
- package/types/engine/content-verse.d.mts +37 -163
- package/types/engine/run-rows.d.mts +16 -9
- package/types/engine/verse-metre.d.mts +4 -3
package/engine/content-verse.mjs
CHANGED
|
@@ -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
|
-
*
|
|
23
|
-
*
|
|
24
|
-
*
|
|
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
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
for (const line of
|
|
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
|
-
|
|
36
|
-
stanza = [];
|
|
67
|
+
end();
|
|
37
68
|
continue;
|
|
38
69
|
}
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
text:
|
|
42
|
-
|
|
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
|
-
|
|
46
|
-
return
|
|
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
|
|
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
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
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
|
|
90
|
-
*
|
|
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 -
|
|
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
|
|
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
|
|
106
|
-
|
|
107
|
-
|
|
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
|
-
|
|
120
|
-
|
|
121
|
-
(
|
|
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
|
-
(
|
|
127
|
-
`<span class="verse-part${
|
|
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
|
|
154
|
-
|
|
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
|
|
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
|
-
|
|
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 (
|
|
292
|
+
if (!VERSE_KEYS.includes(key))
|
|
256
293
|
errors.push({
|
|
257
294
|
line: i + 1,
|
|
258
295
|
column: keyColumn(lines[i], key),
|
|
259
|
-
message:
|
|
296
|
+
message: `${key}= is not a verse attribute`,
|
|
260
297
|
});
|
|
261
|
-
else if (
|
|
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:
|
|
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
|
-
/**
|
|
302
|
-
export function
|
|
303
|
-
|
|
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
|
-
|
|
351
|
-
|
|
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
|
-
*
|
|
386
|
-
*
|
|
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
|
|
389
|
-
* @param {string[]} lines - The source, by line.
|
|
390
|
-
* @returns {Array<{stanzaBreak: true, line: number}|{stanzaBreak?: false, parts: Array<{
|
|
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
|
|
415
|
-
const
|
|
416
|
-
const
|
|
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
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
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
|
-
|
|
478
|
-
let unlabelled = false;
|
|
418
|
+
let previous = 0;
|
|
479
419
|
for (const part of group.parts) {
|
|
480
|
-
if (part.
|
|
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.
|
|
486
|
-
"a
|
|
423
|
+
part.sourceColumn,
|
|
424
|
+
"a verse with cols>1 starts each content line with a column number and spaces",
|
|
487
425
|
);
|
|
488
|
-
|
|
489
|
-
unlabelled = true;
|
|
490
|
-
continue;
|
|
491
|
-
}
|
|
426
|
+
continue;
|
|
492
427
|
}
|
|
493
|
-
if (part.
|
|
494
|
-
unlabelled = true;
|
|
428
|
+
if (!part.text.trim())
|
|
495
429
|
report(
|
|
496
430
|
part.line,
|
|
497
|
-
part.
|
|
498
|
-
"
|
|
431
|
+
part.sourceColumn,
|
|
432
|
+
"a numbered verse line needs content after its column number",
|
|
499
433
|
);
|
|
500
|
-
|
|
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.
|
|
506
|
-
`
|
|
437
|
+
part.sourceColumn,
|
|
438
|
+
`column ${part.column} is outside 1..${width}`,
|
|
507
439
|
);
|
|
508
|
-
else if (
|
|
440
|
+
else if (part.column < previous)
|
|
509
441
|
report(
|
|
510
442
|
part.line,
|
|
511
|
-
part.
|
|
512
|
-
|
|
443
|
+
part.sourceColumn,
|
|
444
|
+
"column numbers must not decrease within a verse row",
|
|
513
445
|
);
|
|
514
|
-
|
|
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(
|
|
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
|
|
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)
|