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