@heroiclands/package-build 23.12.7 → 23.13.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 +54 -0
- package/README.md +1 -1
- package/assets/preview/gitea.ini +2 -2
- package/assets/styles/content-base.css +143 -23
- package/bin/content-commands.mjs +15 -4
- package/bin/package-build-preview.mjs +2 -2
- package/docs/api.md +18 -18
- package/docs/authoring/assets.md +1 -1
- package/docs/authoring/links-and-markup.md +156 -22
- package/docs/commands.md +32 -10
- package/docs/configuration.md +8 -1
- package/docs/content-format.md +3 -3
- package/docs/diagnostics.md +42 -0
- package/docs/reference/format-details.md +105 -22
- package/docs/reference/note-types.md +8 -1
- package/docs/reference/theme-pages.md +51 -0
- package/docs/source-preview.md +1 -1
- package/e2e.mjs +1 -1
- package/engine/anchors.mjs +2 -2
- package/engine/asset-shipping.mjs +22 -8
- package/engine/code-fences.mjs +3 -3
- package/engine/content-blocks.mjs +3 -3
- package/engine/content-figures.mjs +5 -5
- package/engine/content-index.mjs +1 -1
- package/engine/content-scripts.mjs +55 -21
- package/engine/content-spans.mjs +2 -2
- package/engine/content-verse.mjs +750 -0
- package/engine/dictionary-rows.mjs +15 -4
- package/engine/field-reference.mjs +4 -4
- package/engine/first-use.mjs +17 -27
- package/engine/key-case-collisions.mjs +136 -0
- package/engine/language-data.mjs +203 -6
- package/engine/language-terms.mjs +74 -5
- package/engine/map-lexicon.mjs +42 -23
- package/engine/names.mjs +36 -9
- package/engine/note-matter.mjs +66 -0
- package/engine/pdf-build.mjs +1 -0
- package/engine/pdf-render.mjs +107 -18
- package/engine/read-stdin.mjs +29 -0
- package/engine/run-rows.mjs +2 -2
- package/engine/run-words.mjs +4 -111
- package/engine/site-build.mjs +4 -4
- package/engine/site-preview.mjs +14 -14
- package/engine/sql-tables.mjs +20 -3
- package/engine/web-wikilinks.mjs +1 -1
- package/engine/word-forms.mjs +349 -0
- package/engine/word-terms.mjs +9 -0
- package/hugo-theme/static/css/style.css +92 -14
- package/package.json +3 -3
- package/types/e2e.d.mts +2 -2
- package/types/engine/address-fields.d.mts +1 -1
- package/types/engine/address-values.d.mts +1 -1
- package/types/engine/asset-shipping.d.mts +5 -0
- package/types/engine/code-fences.d.mts +3 -3
- package/types/engine/content-format-check.d.mts +1 -1
- package/types/engine/content-scripts.d.mts +8 -4
- package/types/engine/content-verse.d.mts +240 -0
- package/types/engine/dictionary-rows.d.mts +10 -0
- package/types/engine/field-reference.d.mts +1 -1
- package/types/engine/key-case-collisions.d.mts +28 -0
- package/types/engine/language-data.d.mts +0 -2
- package/types/engine/map-lexicon.d.mts +19 -6
- package/types/engine/names.d.mts +34 -3
- package/types/engine/note-matter.d.mts +48 -0
- package/types/engine/note-vocabulary.d.mts +1 -1
- package/types/engine/pdf-coverage.d.mts +1 -1
- package/types/engine/read-stdin.d.mts +11 -0
- package/types/engine/run-rows.d.mts +2 -2
- package/types/engine/run-words.d.mts +0 -15
- package/types/engine/sql-tables.d.mts +4 -1
- package/types/engine/text-maps.d.mts +1 -1
- package/types/engine/web-wikilinks.d.mts +1 -1
- package/types/engine/word-forms.d.mts +82 -0
- package/types/release.d.mts +1 -1
- package/engine/content-poetry.mjs +0 -240
- package/types/engine/content-poetry.d.mts +0 -58
|
@@ -0,0 +1,750 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* This file is part of the Song of Heroic Lands (SoHL) system for Foundry VTT.
|
|
3
|
+
* Copyright (c) 2024-2026 Tom Rodriguez ("Toasty") — <toasty@heroiclands.org>
|
|
4
|
+
*
|
|
5
|
+
* This work is licensed under the GNU General Public License v3.0 (GPLv3).
|
|
6
|
+
* You may copy, modify, and distribute it under the terms of that license.
|
|
7
|
+
*
|
|
8
|
+
* For full terms, see the LICENSE.md file in the project root or visit:
|
|
9
|
+
* https://www.gnu.org/licenses/gpl-3.0.html
|
|
10
|
+
*
|
|
11
|
+
* SPDX-License-Identifier: GPL-3.0-or-later
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
/** The authored verse lines, excluding stanza separators. */
|
|
15
|
+
export function verseLines(body) {
|
|
16
|
+
return String(body ?? "")
|
|
17
|
+
.split("\n")
|
|
18
|
+
.filter((line) => line.trim());
|
|
19
|
+
}
|
|
20
|
+
|
|
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.
|
|
25
|
+
*/
|
|
26
|
+
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) {
|
|
34
|
+
if (!line.trim()) {
|
|
35
|
+
if (stanza.length) stanzas.push(stanza);
|
|
36
|
+
stanza = [];
|
|
37
|
+
continue;
|
|
38
|
+
}
|
|
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
|
+
});
|
|
44
|
+
}
|
|
45
|
+
if (stanza.length) stanzas.push(stanza);
|
|
46
|
+
return stanzas;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** A verse is inline Markdown even when it starts with block syntax. */
|
|
50
|
+
export function inlineVerse(text) {
|
|
51
|
+
return String(text)
|
|
52
|
+
.replace(/^(#{1,6}\s|>\s|[-+*]\s|:{1,2}\s|`{3,}|~{3,}|-{3,}\s*$)/, "\\$1")
|
|
53
|
+
.replace(/^(\d+)([.)])\s/, "$1\\$2 ");
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Keep stanzas as Markdown paragraphs and make every verse line a hard break.
|
|
58
|
+
* The surrounding renderer still sees the entire note, so links and footnote
|
|
59
|
+
* definitions remain in scope. The span carries the indent for each line.
|
|
60
|
+
*/
|
|
61
|
+
export function verseMarkdown(body, step = 0) {
|
|
62
|
+
let count = 0;
|
|
63
|
+
return verseStanzas(body)
|
|
64
|
+
.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"),
|
|
71
|
+
)
|
|
72
|
+
.join("\n\n");
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* The number a line shows: `undefined` when the verse is not numbered, `""`
|
|
77
|
+
* when this line is not one of every `step`th.
|
|
78
|
+
*
|
|
79
|
+
* @param {number} line - The line's number, 1-based across the verse.
|
|
80
|
+
* @param {number} step - The verse's `numstep`.
|
|
81
|
+
* @returns {string|undefined} The number shown.
|
|
82
|
+
*/
|
|
83
|
+
export function lineNumber(line, step) {
|
|
84
|
+
if (!step) return undefined;
|
|
85
|
+
return line % step === 0 ? String(line) : "";
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
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,
|
|
91
|
+
* the first in the normal tone and each later one `verse-secondary`. A part
|
|
92
|
+
* whose text runs right to left carries `dir="rtl"`. A row is a line of a
|
|
93
|
+
* paragraph, and a paragraph a stanza, so each part stays inline Markdown.
|
|
94
|
+
*
|
|
95
|
+
* @param {object} block - An expanded verse from {@link scanVerse}.
|
|
96
|
+
* @param {string[]} lines - The source, by line.
|
|
97
|
+
* @param {number} step - The verse's `numstep`.
|
|
98
|
+
* @returns {string} The rows as Markdown with inline HTML.
|
|
99
|
+
*/
|
|
100
|
+
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);
|
|
103
|
+
const stanzas = [[]];
|
|
104
|
+
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++;
|
|
114
|
+
continue;
|
|
115
|
+
}
|
|
116
|
+
const parts = body.slice(at, at + total);
|
|
117
|
+
at += total;
|
|
118
|
+
const number = lineNumber(++count, step);
|
|
119
|
+
let next = 0;
|
|
120
|
+
const cells = counts.map(
|
|
121
|
+
(n) =>
|
|
122
|
+
'<span class="verse-column">' +
|
|
123
|
+
parts
|
|
124
|
+
.slice(next, (next += n))
|
|
125
|
+
.map(
|
|
126
|
+
(part, k) =>
|
|
127
|
+
`<span class="verse-part${k ? " verse-secondary" : ""}"${rightToLeft(part) ? ' dir="rtl"' : ""}>${part}</span>`,
|
|
128
|
+
)
|
|
129
|
+
.join("") +
|
|
130
|
+
"</span>",
|
|
131
|
+
);
|
|
132
|
+
stanzas
|
|
133
|
+
.at(-1)
|
|
134
|
+
.push(
|
|
135
|
+
'<span class="verse-row">' +
|
|
136
|
+
(number === undefined ? "" : `<span class="verse-number">${number}</span>`) +
|
|
137
|
+
cells.join("") +
|
|
138
|
+
"</span>",
|
|
139
|
+
);
|
|
140
|
+
}
|
|
141
|
+
return stanzas
|
|
142
|
+
.filter((rows) => rows.length)
|
|
143
|
+
.map((rows) => rows.join("\n"))
|
|
144
|
+
.join("\n\n");
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
import MarkdownIt from "markdown-it";
|
|
148
|
+
import { slugify } from "./content-slug.mjs";
|
|
149
|
+
import { parseExtensionAttributes, refusedAttributes } from "./extension-attributes.mjs";
|
|
150
|
+
|
|
151
|
+
const structureParser = new MarkdownIt({ html: true });
|
|
152
|
+
|
|
153
|
+
/** The keys that set a verse in columns, the left first. */
|
|
154
|
+
export const COLUMN_KEYS = Object.freeze(["col1", "col2"]);
|
|
155
|
+
|
|
156
|
+
/** The class the build gives a verse in columns once its lines are expanded. */
|
|
157
|
+
export const COLUMNS_CLASS = "verse-columns";
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* The keys a verse fence's attribute block takes. `lang`, `font` and `map`
|
|
161
|
+
* are read by `scanRuns` in content-scripts.mjs; `dir`, and on the site
|
|
162
|
+
* `data-pagefind-ignore`, are written by its rewrite of a fence set in one of
|
|
163
|
+
* the faces, and reach the element as they are written.
|
|
164
|
+
*/
|
|
165
|
+
export const VERSE_KEYS = Object.freeze([
|
|
166
|
+
"form",
|
|
167
|
+
"meter",
|
|
168
|
+
"rhyme",
|
|
169
|
+
"syllables",
|
|
170
|
+
"lang",
|
|
171
|
+
"font",
|
|
172
|
+
"map",
|
|
173
|
+
"numstep",
|
|
174
|
+
...COLUMN_KEYS,
|
|
175
|
+
"dir",
|
|
176
|
+
"data-pagefind-ignore",
|
|
177
|
+
]);
|
|
178
|
+
|
|
179
|
+
/** Read verse code fences; start/end are inclusive zero-based source lines.
|
|
180
|
+
* @param {string} source - Authored Markdown.
|
|
181
|
+
*/
|
|
182
|
+
export function scanVerse(source) {
|
|
183
|
+
const lines = String(source ?? "").split("\n"),
|
|
184
|
+
blocks = [],
|
|
185
|
+
errors = [];
|
|
186
|
+
const literalLines = new Set();
|
|
187
|
+
for (const token of structureParser.parse(String(source ?? ""), {})) {
|
|
188
|
+
if (!token.map || !["blockquote_open", "html_block"].includes(token.type)) continue;
|
|
189
|
+
for (let line = token.map[0]; line < token.map[1]; line++) literalLines.add(line);
|
|
190
|
+
}
|
|
191
|
+
let opening = null;
|
|
192
|
+
for (let i = 0; i < lines.length; i++) {
|
|
193
|
+
if (literalLines.has(i)) continue;
|
|
194
|
+
const match = /^ *(`{3,}|~{3,})(.*)$/.exec(lines[i]);
|
|
195
|
+
if (opening) {
|
|
196
|
+
if (
|
|
197
|
+
match &&
|
|
198
|
+
match[1][0] === opening.fence[0] &&
|
|
199
|
+
match[1].length >= opening.fence.length &&
|
|
200
|
+
!match[2].trim()
|
|
201
|
+
) {
|
|
202
|
+
if (opening.name === "verse") {
|
|
203
|
+
const body = lines.slice(opening.start + 1, i).join("\n");
|
|
204
|
+
if (!body.trim())
|
|
205
|
+
errors.push({
|
|
206
|
+
line: opening.start + 1,
|
|
207
|
+
column: 1,
|
|
208
|
+
message: "verse block is empty",
|
|
209
|
+
});
|
|
210
|
+
for (let n = opening.start + 1; n < i; n++) {
|
|
211
|
+
const col = lines[n].indexOf("\t");
|
|
212
|
+
if (col >= 0)
|
|
213
|
+
errors.push({
|
|
214
|
+
line: n + 1,
|
|
215
|
+
column: col + 1,
|
|
216
|
+
message: "tabs are not allowed in verse",
|
|
217
|
+
});
|
|
218
|
+
}
|
|
219
|
+
const block = { ...opening, end: i, body };
|
|
220
|
+
const columned = hasColumns(block);
|
|
221
|
+
const count =
|
|
222
|
+
columned ?
|
|
223
|
+
verseGroups(block, lines).filter((group) => !group.stanzaBreak).length
|
|
224
|
+
: verseLines(body).length;
|
|
225
|
+
if (
|
|
226
|
+
opening.attributes.syllables &&
|
|
227
|
+
opening.attributes.syllables.split(",").length !== count
|
|
228
|
+
)
|
|
229
|
+
errors.push({
|
|
230
|
+
line: opening.start + 1,
|
|
231
|
+
column: 1,
|
|
232
|
+
message: `verse syllables= gives ${opening.attributes.syllables.split(",").length} counts for ${count} verse lines`,
|
|
233
|
+
});
|
|
234
|
+
if (columned) errors.push(...columnFindings(block, lines));
|
|
235
|
+
blocks.push(block);
|
|
236
|
+
}
|
|
237
|
+
opening = null;
|
|
238
|
+
}
|
|
239
|
+
continue;
|
|
240
|
+
}
|
|
241
|
+
if (!match) continue;
|
|
242
|
+
const info = match[2].trim();
|
|
243
|
+
opening = {
|
|
244
|
+
start: i,
|
|
245
|
+
fence: match[1],
|
|
246
|
+
name: info.split(/\s/)[0],
|
|
247
|
+
attributes: {},
|
|
248
|
+
id: "",
|
|
249
|
+
classes: [],
|
|
250
|
+
indent: /^ */.exec(lines[i])[0],
|
|
251
|
+
};
|
|
252
|
+
if (opening.name !== "verse") continue;
|
|
253
|
+
const raw = info.slice(6).trim();
|
|
254
|
+
const at = { line: i + 1, column: 1 };
|
|
255
|
+
if (raw && !/^\{[^}\n]*\}$/.test(raw)) {
|
|
256
|
+
errors.push({ ...at, message: "verse attributes need {…} braces" });
|
|
257
|
+
continue;
|
|
258
|
+
}
|
|
259
|
+
const parsed = parseExtensionAttributes(raw ? raw.slice(1, -1) : "");
|
|
260
|
+
opening.id = parsed.id;
|
|
261
|
+
opening.classes = parsed.classes;
|
|
262
|
+
opening.attributes = parsed.values;
|
|
263
|
+
for (const message of [...parsed.problems, ...refusedAttributes(parsed.values)])
|
|
264
|
+
errors.push({ ...at, message });
|
|
265
|
+
for (const [key, value] of Object.entries(parsed.values)) {
|
|
266
|
+
if (/^col/.test(key) && !COLUMN_KEYS.includes(key))
|
|
267
|
+
errors.push({
|
|
268
|
+
line: i + 1,
|
|
269
|
+
column: keyColumn(lines[i], key),
|
|
270
|
+
message: `\`${key}=\` is not a column: a verse has at most two, \`col1=\` and \`col2=\``,
|
|
271
|
+
});
|
|
272
|
+
else if (!VERSE_KEYS.includes(key))
|
|
273
|
+
errors.push({ ...at, message: `${key}= is not a verse attribute` });
|
|
274
|
+
else if (key === "numstep" && !/^\d+$/.test(value))
|
|
275
|
+
errors.push({
|
|
276
|
+
line: i + 1,
|
|
277
|
+
column: keyColumn(lines[i], key),
|
|
278
|
+
message: "verse numstep= numbers every Nth line: a whole number, 0 for none",
|
|
279
|
+
});
|
|
280
|
+
else if (key === "syllables" && !/^[1-9]\d*(?:,[1-9]\d*)*$/.test(value))
|
|
281
|
+
errors.push({
|
|
282
|
+
...at,
|
|
283
|
+
message: "verse syllables= needs positive comma-separated counts",
|
|
284
|
+
});
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
if (opening?.name === "verse")
|
|
288
|
+
errors.push({
|
|
289
|
+
line: opening.start + 1,
|
|
290
|
+
column: 1,
|
|
291
|
+
message: "verse block needs a closing code fence",
|
|
292
|
+
});
|
|
293
|
+
errors.sort((a, b) => a.line - b.line || a.column - b.column);
|
|
294
|
+
return { blocks, errors };
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
/**
|
|
298
|
+
* The 1-based column of `key=`, or of a class or id token, in an opening line.
|
|
299
|
+
*
|
|
300
|
+
* @param {string} opening - The fence's opening line.
|
|
301
|
+
* @param {string} key - The key, or a `.class` or `#id` token.
|
|
302
|
+
* @returns {number} Its column, or the brace's when it is not found.
|
|
303
|
+
*/
|
|
304
|
+
function keyColumn(opening, key) {
|
|
305
|
+
const brace = opening.indexOf("{");
|
|
306
|
+
const token = /^[.#]/.test(key) ? key : `${key}=`;
|
|
307
|
+
const at = opening
|
|
308
|
+
.slice(brace)
|
|
309
|
+
.search(new RegExp(`(?<=[{\\s])${token.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}`));
|
|
310
|
+
return (at < 0 ? brace : brace + at) + 1;
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
/** Whether a verse states a column. */
|
|
314
|
+
export function hasColumns(block) {
|
|
315
|
+
return COLUMN_KEYS.some((key) => Object.hasOwn(block.attributes ?? {}, key));
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
/** One part of a column entry, `language[/mapping[/font]]`. */
|
|
319
|
+
const ENTRY_PART = /^[A-Za-z0-9_-]+$/;
|
|
320
|
+
|
|
321
|
+
/**
|
|
322
|
+
* A column's stacked entries, each with its offset in the written value.
|
|
323
|
+
*
|
|
324
|
+
* @param {string} value - A `col1=` or `col2=` value.
|
|
325
|
+
* @returns {Array<{raw: string, offset: number, lang?: string, map?: string, font?: string, offsets: {lang: number, map?: number, font?: number}}>}
|
|
326
|
+
* Each entry; one whose `lang` is `undefined` is malformed.
|
|
327
|
+
*/
|
|
328
|
+
export function columnEntries(value) {
|
|
329
|
+
const entries = [];
|
|
330
|
+
let offset = 0;
|
|
331
|
+
for (const raw of String(value).split(":")) {
|
|
332
|
+
const parts = raw.split("/");
|
|
333
|
+
const sound = parts.length <= 3 && parts.every((part) => ENTRY_PART.test(part));
|
|
334
|
+
const offsets = { lang: offset };
|
|
335
|
+
let at = offset;
|
|
336
|
+
for (const [n, name] of [
|
|
337
|
+
[1, "map"],
|
|
338
|
+
[2, "font"],
|
|
339
|
+
]) {
|
|
340
|
+
if (parts.length <= n) break;
|
|
341
|
+
at += parts[n - 1].length + 1;
|
|
342
|
+
offsets[name] = at;
|
|
343
|
+
}
|
|
344
|
+
entries.push({
|
|
345
|
+
raw,
|
|
346
|
+
offset,
|
|
347
|
+
offsets,
|
|
348
|
+
...(sound ?
|
|
349
|
+
{
|
|
350
|
+
lang: parts[0],
|
|
351
|
+
...(parts[1] === undefined ? {} : { map: parts[1] }),
|
|
352
|
+
...(parts[2] === undefined ? {} : { font: parts[2] }),
|
|
353
|
+
}
|
|
354
|
+
: {}),
|
|
355
|
+
});
|
|
356
|
+
offset += raw.length + 1;
|
|
357
|
+
}
|
|
358
|
+
return entries;
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
/**
|
|
362
|
+
* The columns of a verse, the left first, each entry located in the opening
|
|
363
|
+
* line.
|
|
364
|
+
*
|
|
365
|
+
* @param {object} block - A verse from {@link scanVerse}.
|
|
366
|
+
* @param {string} opening - Its opening line.
|
|
367
|
+
* @returns {Array<{key: string, entries: Array<ReturnType<typeof columnEntries>[number] & {column: number, columns: {lang: number, map?: number, font?: number}}>}>}
|
|
368
|
+
* 1-based columns.
|
|
369
|
+
*/
|
|
370
|
+
export function verseColumns(block, opening) {
|
|
371
|
+
return COLUMN_KEYS.filter((key) => Object.hasOwn(block.attributes, key)).map((key) => {
|
|
372
|
+
const start = keyColumn(opening, key) + key.length + 1;
|
|
373
|
+
const quoted = opening[start - 1] === '"' ? 1 : 0;
|
|
374
|
+
return {
|
|
375
|
+
key,
|
|
376
|
+
entries: columnEntries(block.attributes[key]).map((entry) => ({
|
|
377
|
+
...entry,
|
|
378
|
+
column: start + quoted + entry.offset,
|
|
379
|
+
columns: Object.fromEntries(
|
|
380
|
+
Object.entries(entry.offsets).map(([name, at]) => [name, start + quoted + at]),
|
|
381
|
+
),
|
|
382
|
+
})),
|
|
383
|
+
};
|
|
384
|
+
});
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
/**
|
|
388
|
+
* The line that breaks a stanza in a verse in columns, `***`, also as the
|
|
389
|
+
* rewrite of marked text escapes it.
|
|
390
|
+
*/
|
|
391
|
+
export const STANZA_BREAK = /^(?:\\?\*){3}$/;
|
|
392
|
+
|
|
393
|
+
/** A whole-line span, `[text]{attributes}`. */
|
|
394
|
+
const LINE_SPAN = /^\[(.*)\]\{([^{}]*)\}$/;
|
|
395
|
+
|
|
396
|
+
/**
|
|
397
|
+
* The verse lines of a verse in columns: each blank-line group of span lines
|
|
398
|
+
* is one verse line, and a line holding only `***` breaks the stanza.
|
|
399
|
+
*
|
|
400
|
+
* @param {object} block - A verse from {@link scanVerse}.
|
|
401
|
+
* @param {string[]} lines - The source, by line.
|
|
402
|
+
* @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}>}>}
|
|
403
|
+
* 0-based lines, 1-based columns.
|
|
404
|
+
*/
|
|
405
|
+
export function verseGroups(block, lines) {
|
|
406
|
+
const groups = [];
|
|
407
|
+
let parts = [];
|
|
408
|
+
const end = () => {
|
|
409
|
+
if (parts.length) groups.push({ parts });
|
|
410
|
+
parts = [];
|
|
411
|
+
};
|
|
412
|
+
const body = lines.slice(block.start + 1, block.end).filter((line) => line.trim());
|
|
413
|
+
const baseline = body.length ? Math.min(...body.map((line) => /^ */.exec(line)[0].length)) : 0;
|
|
414
|
+
for (let at = block.start + 1; at < block.end; at++) {
|
|
415
|
+
const line = lines[at];
|
|
416
|
+
if (!line.trim()) {
|
|
417
|
+
end();
|
|
418
|
+
continue;
|
|
419
|
+
}
|
|
420
|
+
if (line.trim() === "***") {
|
|
421
|
+
end();
|
|
422
|
+
groups.push({ stanzaBreak: true, line: at });
|
|
423
|
+
continue;
|
|
424
|
+
}
|
|
425
|
+
const spaces = /^ */.exec(line)[0].length;
|
|
426
|
+
const written = line.slice(spaces).trimEnd();
|
|
427
|
+
const span = LINE_SPAN.exec(written);
|
|
428
|
+
const parsed = span ? parseExtensionAttributes(span[2]) : undefined;
|
|
429
|
+
parts.push({
|
|
430
|
+
line: at,
|
|
431
|
+
column: spaces + 1,
|
|
432
|
+
indent: spaces - baseline,
|
|
433
|
+
span: Boolean(span),
|
|
434
|
+
text: span ? span[1] : written,
|
|
435
|
+
textColumn: spaces + 1 + (span ? 1 : 0),
|
|
436
|
+
lang: span ? parsed.values.lang : block.attributes.lang,
|
|
437
|
+
extra:
|
|
438
|
+
Boolean(parsed) &&
|
|
439
|
+
(parsed.problems.length > 0 ||
|
|
440
|
+
Boolean(parsed.id) ||
|
|
441
|
+
parsed.classes.length > 0 ||
|
|
442
|
+
Object.keys(parsed.values).some((key) => key !== "lang") ||
|
|
443
|
+
parsed.values.lang === undefined),
|
|
444
|
+
});
|
|
445
|
+
}
|
|
446
|
+
end();
|
|
447
|
+
return groups;
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
/**
|
|
451
|
+
* What is wrong with a verse in columns, each finding at its own text.
|
|
452
|
+
*
|
|
453
|
+
* @param {object} block - A verse from {@link scanVerse} stating a column.
|
|
454
|
+
* @param {string[]} lines - The source, by line.
|
|
455
|
+
* @returns {Array<{line: number, column: number, message: string}>} 1-based.
|
|
456
|
+
*/
|
|
457
|
+
function columnFindings(block, lines) {
|
|
458
|
+
const errors = [];
|
|
459
|
+
const opening = lines[block.start];
|
|
460
|
+
const report = (line, column, message) => errors.push({ line: line + 1, column, message });
|
|
461
|
+
for (const key of ["font", "map"])
|
|
462
|
+
if (Object.hasOwn(block.attributes, key))
|
|
463
|
+
report(
|
|
464
|
+
block.start,
|
|
465
|
+
keyColumn(opening, key),
|
|
466
|
+
`a verse in columns sets each column's fonts and maps in \`col1=\` and \`col2=\`, not with \`${key}=\``,
|
|
467
|
+
);
|
|
468
|
+
if (block.classes.includes(COLUMNS_CLASS))
|
|
469
|
+
report(
|
|
470
|
+
block.start,
|
|
471
|
+
keyColumn(opening, `.${COLUMNS_CLASS}`),
|
|
472
|
+
`\`.${COLUMNS_CLASS}\` is the class the build gives a verse in columns; leave it out`,
|
|
473
|
+
);
|
|
474
|
+
const columns = verseColumns(block, opening);
|
|
475
|
+
for (const { key, entries } of columns)
|
|
476
|
+
for (const entry of entries)
|
|
477
|
+
if (entry.lang === undefined)
|
|
478
|
+
report(
|
|
479
|
+
block.start,
|
|
480
|
+
entry.column,
|
|
481
|
+
`\`${entry.raw}\` in \`${key}=\` is not an entry: an entry is \`language[/mapping[/font]]\``,
|
|
482
|
+
);
|
|
483
|
+
const named = new Map();
|
|
484
|
+
for (const { key, entries } of columns)
|
|
485
|
+
for (const entry of entries)
|
|
486
|
+
if (entry.lang !== undefined && !named.has(entry.lang)) named.set(entry.lang, key);
|
|
487
|
+
for (const group of verseGroups(block, lines)) {
|
|
488
|
+
if (group.stanzaBreak) continue;
|
|
489
|
+
const seen = new Set();
|
|
490
|
+
let unlabelled = false;
|
|
491
|
+
for (const part of group.parts) {
|
|
492
|
+
if (part.indent > 0)
|
|
493
|
+
report(part.line, part.column, "a line of a verse in columns is not indented");
|
|
494
|
+
if (part.span && part.extra) {
|
|
495
|
+
report(
|
|
496
|
+
part.line,
|
|
497
|
+
part.column,
|
|
498
|
+
"a line of a verse in columns is `[text]{lang=…}`, or plain text in the verse's `lang=`, and takes no other key",
|
|
499
|
+
);
|
|
500
|
+
if (part.lang === undefined) {
|
|
501
|
+
unlabelled = true;
|
|
502
|
+
continue;
|
|
503
|
+
}
|
|
504
|
+
}
|
|
505
|
+
if (part.lang === undefined) {
|
|
506
|
+
unlabelled = true;
|
|
507
|
+
report(
|
|
508
|
+
part.line,
|
|
509
|
+
part.column,
|
|
510
|
+
"this line names no language: write it `[text]{lang=…}`, or give the verse `lang=`",
|
|
511
|
+
);
|
|
512
|
+
continue;
|
|
513
|
+
}
|
|
514
|
+
if (!named.has(part.lang))
|
|
515
|
+
report(
|
|
516
|
+
part.line,
|
|
517
|
+
part.column,
|
|
518
|
+
`no column sets \`${part.lang}\`; a verse line gives the languages \`col1=\` and \`col2=\` name`,
|
|
519
|
+
);
|
|
520
|
+
else if (seen.has(part.lang))
|
|
521
|
+
report(
|
|
522
|
+
part.line,
|
|
523
|
+
part.column,
|
|
524
|
+
`this verse line gives \`${part.lang}\` twice; give each language once`,
|
|
525
|
+
);
|
|
526
|
+
seen.add(part.lang);
|
|
527
|
+
}
|
|
528
|
+
if (unlabelled) continue;
|
|
529
|
+
for (const [lang, key] of named)
|
|
530
|
+
if (!seen.has(lang))
|
|
531
|
+
report(
|
|
532
|
+
group.parts[0].line,
|
|
533
|
+
group.parts[0].column,
|
|
534
|
+
`this verse line has no \`${lang}\` line, which \`${key}=\` sets`,
|
|
535
|
+
);
|
|
536
|
+
}
|
|
537
|
+
return errors;
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
/**
|
|
541
|
+
* Whether text runs right to left: its first letter is in a right-to-left
|
|
542
|
+
* block. A face's right-to-left code points sit in U+1E800–U+1EFFF.
|
|
543
|
+
*
|
|
544
|
+
* @param {string} text - Text, after any map.
|
|
545
|
+
* @returns {boolean} Whether it starts right to left.
|
|
546
|
+
*/
|
|
547
|
+
export function rightToLeft(text) {
|
|
548
|
+
const first = /[\p{L}\p{M}\p{Co}\p{Cn}]/u.exec(String(text).replace(/\{[^}]*\}/g, ""));
|
|
549
|
+
return Boolean(
|
|
550
|
+
first &&
|
|
551
|
+
/[\u0590-\u08FF\uFB1D-\uFDFF\uFE70-\uFEFF\u{10800}-\u{10FFF}\u{1E800}-\u{1EFFF}]/u.test(
|
|
552
|
+
first[0],
|
|
553
|
+
),
|
|
554
|
+
);
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
/** An attribute value as the shared attribute grammar reads it back. */
|
|
558
|
+
function attributeValue(value) {
|
|
559
|
+
return /^[A-Za-z0-9_.:/@%+-]+$/.test(value) ? value : `"${value.replace(/[\\"]/g, "\\$&")}"`;
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
/**
|
|
563
|
+
* Where a line of {@link expandVerseOrigins}'s output came from.
|
|
564
|
+
*
|
|
565
|
+
* @typedef {object} VerseOrigin
|
|
566
|
+
* @property {number} line - The source line, 0-based.
|
|
567
|
+
* @property {number} [textColumn] - For an entry's line, the 1-based source
|
|
568
|
+
* column of its text's first character.
|
|
569
|
+
* @property {boolean} [span] - Whether the source line is a span, whose `[`
|
|
570
|
+
* sits before its text.
|
|
571
|
+
* @property {number} [textLength] - The length of the entry's text.
|
|
572
|
+
* @property {{line: number, columns: {lang: number, map?: number, font?: number}}} [entry] -
|
|
573
|
+
* Where the entry is written in the opening line.
|
|
574
|
+
*/
|
|
575
|
+
|
|
576
|
+
/**
|
|
577
|
+
* Write each verse line of a sound verse in columns as one span per column
|
|
578
|
+
* entry, the left column's entries first, each with the entry's language,
|
|
579
|
+
* mapping and font. Every target runs it before marked text is rewritten, so
|
|
580
|
+
* each entry is set exactly as a span with those keys is. The verse gains the
|
|
581
|
+
* class {@link COLUMNS_CLASS}; a verse already carrying it, a verse with a
|
|
582
|
+
* finding, and a verse without columns are left as written.
|
|
583
|
+
*
|
|
584
|
+
* @param {string} source - Markdown.
|
|
585
|
+
* @returns {{markdown: string, origins: VerseOrigin[]}} The Markdown, and
|
|
586
|
+
* where each of its lines came from.
|
|
587
|
+
*/
|
|
588
|
+
export function expandVerseOrigins(source) {
|
|
589
|
+
const text = String(source ?? "");
|
|
590
|
+
const lines = text.split("\n");
|
|
591
|
+
const identity = () => lines.map((_, line) => ({ line }));
|
|
592
|
+
if (!/\bcol[12]=/.test(text)) return { markdown: text, origins: identity() };
|
|
593
|
+
const { blocks, errors } = scanVerse(text);
|
|
594
|
+
const output = [];
|
|
595
|
+
const origins = [];
|
|
596
|
+
let cursor = 0;
|
|
597
|
+
const copy = (from, to) => {
|
|
598
|
+
for (let line = from; line < to; line++) {
|
|
599
|
+
output.push(lines[line]);
|
|
600
|
+
origins.push({ line });
|
|
601
|
+
}
|
|
602
|
+
};
|
|
603
|
+
for (const block of blocks) {
|
|
604
|
+
if (!hasColumns(block) || block.classes.includes(COLUMNS_CLASS)) continue;
|
|
605
|
+
if (errors.some((error) => error.line - 1 >= block.start && error.line - 1 <= block.end))
|
|
606
|
+
continue;
|
|
607
|
+
copy(cursor, block.start);
|
|
608
|
+
const opening = lines[block.start];
|
|
609
|
+
const close = opening.lastIndexOf("}");
|
|
610
|
+
output.push(`${opening.slice(0, close)} .${COLUMNS_CLASS}${opening.slice(close)}`);
|
|
611
|
+
origins.push({ line: block.start });
|
|
612
|
+
const entries = verseColumns(block, opening).flatMap((column) => column.entries);
|
|
613
|
+
let first = true;
|
|
614
|
+
for (const group of verseGroups(block, lines)) {
|
|
615
|
+
if (!first) {
|
|
616
|
+
output.push(block.indent);
|
|
617
|
+
origins.push({ line: block.start });
|
|
618
|
+
}
|
|
619
|
+
first = false;
|
|
620
|
+
if (group.stanzaBreak) {
|
|
621
|
+
output.push(`${block.indent}***`);
|
|
622
|
+
origins.push({ line: group.line });
|
|
623
|
+
continue;
|
|
624
|
+
}
|
|
625
|
+
for (const entry of entries) {
|
|
626
|
+
const part = group.parts.find((candidate) => candidate.lang === entry.lang);
|
|
627
|
+
const keys = [
|
|
628
|
+
`lang=${attributeValue(entry.lang)}`,
|
|
629
|
+
...(entry.map === undefined ? [] : [`map=${attributeValue(entry.map)}`]),
|
|
630
|
+
...(entry.font === undefined ? [] : [`font=${attributeValue(entry.font)}`]),
|
|
631
|
+
];
|
|
632
|
+
output.push(`${block.indent}[${part.text}]{${keys.join(" ")}}`);
|
|
633
|
+
origins.push({
|
|
634
|
+
line: part.line,
|
|
635
|
+
textColumn: part.textColumn,
|
|
636
|
+
indent: block.indent.length,
|
|
637
|
+
span: part.span,
|
|
638
|
+
textLength: part.text.length,
|
|
639
|
+
entry: { line: block.start, columns: entry.columns },
|
|
640
|
+
});
|
|
641
|
+
}
|
|
642
|
+
}
|
|
643
|
+
output.push(lines[block.end]);
|
|
644
|
+
origins.push({ line: block.end });
|
|
645
|
+
cursor = block.end + 1;
|
|
646
|
+
}
|
|
647
|
+
copy(cursor, lines.length);
|
|
648
|
+
return { markdown: output.join("\n"), origins };
|
|
649
|
+
}
|
|
650
|
+
|
|
651
|
+
/**
|
|
652
|
+
* {@link expandVerseOrigins}'s Markdown alone.
|
|
653
|
+
*
|
|
654
|
+
* @param {string} source - Markdown.
|
|
655
|
+
* @returns {string} The Markdown with each verse in columns expanded.
|
|
656
|
+
*/
|
|
657
|
+
export function expandVerse(source) {
|
|
658
|
+
return expandVerseOrigins(source).markdown;
|
|
659
|
+
}
|
|
660
|
+
|
|
661
|
+
/**
|
|
662
|
+
* Where a position in {@link expandVerseOrigins}'s output was written.
|
|
663
|
+
*
|
|
664
|
+
* A position in an entry's text is the same character of the source line; a
|
|
665
|
+
* position in its keys is the entry's part in the opening line that states
|
|
666
|
+
* the key it falls in.
|
|
667
|
+
*
|
|
668
|
+
* @param {{line: number, column: number}} at - 1-based, in the output.
|
|
669
|
+
* @param {string[]} expanded - The output, by line.
|
|
670
|
+
* @param {VerseOrigin[]} origins - From {@link expandVerseOrigins}.
|
|
671
|
+
* @returns {{line: number, column: number}} 1-based, in the source.
|
|
672
|
+
*/
|
|
673
|
+
export function sourcePosition(at, expanded, origins) {
|
|
674
|
+
const origin = origins[at.line - 1];
|
|
675
|
+
if (!origin) return at;
|
|
676
|
+
if (origin.entry === undefined) return { line: origin.line + 1, column: at.column };
|
|
677
|
+
const local = at.column - (origin.indent ?? 0);
|
|
678
|
+
if (local <= origin.textLength + 1) {
|
|
679
|
+
const column = origin.textColumn + local - 2;
|
|
680
|
+
return {
|
|
681
|
+
line: origin.line + 1,
|
|
682
|
+
column: Math.max(origin.textColumn - (origin.span ? 1 : 0), column),
|
|
683
|
+
};
|
|
684
|
+
}
|
|
685
|
+
const line = expanded[at.line - 1];
|
|
686
|
+
const brace = line.lastIndexOf("{");
|
|
687
|
+
let key = "lang";
|
|
688
|
+
let best = -1;
|
|
689
|
+
for (const name of ["lang", "map", "font"]) {
|
|
690
|
+
const found = line.indexOf(`${name}=`, brace);
|
|
691
|
+
if (found >= 0 && found <= at.column - 1 && found > best) {
|
|
692
|
+
best = found;
|
|
693
|
+
key = name;
|
|
694
|
+
}
|
|
695
|
+
}
|
|
696
|
+
const column = origin.entry.columns[key] ?? origin.entry.columns.lang;
|
|
697
|
+
return { line: origin.entry.line + 1, column };
|
|
698
|
+
}
|
|
699
|
+
|
|
700
|
+
/** Replace verse fences with HTML wrappers whose body remains inline Markdown.
|
|
701
|
+
* @param {string} source - Authored Markdown.
|
|
702
|
+
*/
|
|
703
|
+
export function renderVerse(source) {
|
|
704
|
+
const { errors } = scanVerse(source);
|
|
705
|
+
source = expandVerse(source);
|
|
706
|
+
const { blocks } = scanVerse(source),
|
|
707
|
+
lines = String(source ?? "").split("\n"),
|
|
708
|
+
output = [];
|
|
709
|
+
let cursor = 0;
|
|
710
|
+
const escape = (value) =>
|
|
711
|
+
String(value)
|
|
712
|
+
.replace(/&/g, "&")
|
|
713
|
+
.replace(/"/g, """)
|
|
714
|
+
.replace(/</g, "<")
|
|
715
|
+
.replace(/>/g, ">");
|
|
716
|
+
for (const block of blocks) {
|
|
717
|
+
output.push(...lines.slice(cursor, block.start));
|
|
718
|
+
const step = Number(block.attributes.numstep ?? 0) || 0;
|
|
719
|
+
const columns = block.classes.includes(COLUMNS_CLASS);
|
|
720
|
+
const attrs = [
|
|
721
|
+
`class="${escape(["verse", ...block.classes, ...(step ? ["verse-numbered"] : [])].join(" "))}"`,
|
|
722
|
+
];
|
|
723
|
+
if (block.id) attrs.push(`id="${escape(slugify(block.id))}"`);
|
|
724
|
+
for (const [key, value] of Object.entries(block.attributes))
|
|
725
|
+
if (!refusedAttributes({ [key]: value }).length)
|
|
726
|
+
attrs.push(
|
|
727
|
+
`${["lang", "dir", "data-pagefind-ignore"].includes(key) ? key : `data-${key}`}="${escape(value)}"`,
|
|
728
|
+
);
|
|
729
|
+
let indent = block.indent;
|
|
730
|
+
if (
|
|
731
|
+
indent.length >= 4 &&
|
|
732
|
+
!lines.slice(0, block.start).some((line) => /^ *(?:[-+*]|\d+[.)]) +/.test(line))
|
|
733
|
+
)
|
|
734
|
+
indent = "";
|
|
735
|
+
output.push(
|
|
736
|
+
"",
|
|
737
|
+
`${indent}<div ${attrs.join(" ")}>`,
|
|
738
|
+
"",
|
|
739
|
+
...(columns ? columnsMarkdown(block, lines, step) : verseMarkdown(block.body, step))
|
|
740
|
+
.split("\n")
|
|
741
|
+
.map((line) => (line ? indent + line : line)),
|
|
742
|
+
"",
|
|
743
|
+
`${indent}</div>`,
|
|
744
|
+
"",
|
|
745
|
+
);
|
|
746
|
+
cursor = block.end + 1;
|
|
747
|
+
}
|
|
748
|
+
output.push(...lines.slice(cursor));
|
|
749
|
+
return { markdown: output.join("\n"), errors };
|
|
750
|
+
}
|