@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
|
@@ -207,7 +207,7 @@ export function templateProgram(data, options, fault) {
|
|
|
207
207
|
fault(key, "Grave accents are forbidden");
|
|
208
208
|
};
|
|
209
209
|
const validateOutput = (from, to, key) => {
|
|
210
|
-
if (/[\u{E010D}
|
|
210
|
+
if (/[\u{E010D}-\u{E010F}\u{E0112}\u{E0113}\u{E0115}]/u.test(to))
|
|
211
211
|
fault(key, "Structural controls cannot be mapping outputs");
|
|
212
212
|
if (from.normalize("NFD").includes("\u0301") && stress) {
|
|
213
213
|
const plain = from.normalize("NFD").replaceAll("\u0301", "").normalize("NFC");
|
|
@@ -343,7 +343,10 @@ export function templateProgram(data, options, fault) {
|
|
|
343
343
|
definition: {
|
|
344
344
|
name: "encoding-note",
|
|
345
345
|
fonts: [data.font],
|
|
346
|
-
accepts: [
|
|
346
|
+
accepts: [
|
|
347
|
+
...accepts,
|
|
348
|
+
...STANDARD_ROWS.flatMap((r) => [...(Array.isArray(r) ? r[0] : r.from)]),
|
|
349
|
+
],
|
|
347
350
|
separator: " ",
|
|
348
351
|
roundTrip: canonical ? "canonical" : "exact",
|
|
349
352
|
vowels,
|
package/engine/encoding.mjs
CHANGED
|
@@ -256,7 +256,7 @@ export function compileRows(definition, metadata = {}) {
|
|
|
256
256
|
const prefix = d.separator ? [separatorRow] : [];
|
|
257
257
|
const controlRows = [];
|
|
258
258
|
if (controls.verseStanza) controlRows.push({ from: "\n\n", to: controls.verseStanza });
|
|
259
|
-
if (controls.verseCanto) controlRows.push({ from: "
|
|
259
|
+
if (controls.verseCanto) controlRows.push({ from: "***", to: controls.verseCanto });
|
|
260
260
|
const shared = tier(sharedRows, [], [...prefix, ...controlRows]);
|
|
261
261
|
const faces = new Map();
|
|
262
262
|
if (d.faces !== undefined && !object(d.faces))
|
|
@@ -270,7 +270,7 @@ export function compileRows(definition, metadata = {}) {
|
|
|
270
270
|
tier(
|
|
271
271
|
rows(face.rows ?? [], `faces.${font}.rows`, vowels, d.roundTrip === "canonical"),
|
|
272
272
|
sharedRows,
|
|
273
|
-
prefix,
|
|
273
|
+
[...prefix, ...controlRows],
|
|
274
274
|
),
|
|
275
275
|
);
|
|
276
276
|
}
|
|
@@ -330,10 +330,10 @@ function beforeHolds(condition, char, vowels) {
|
|
|
330
330
|
function holds(row, text, start, end, vowels) {
|
|
331
331
|
if (row.before && !beforeHolds(row.before, firstAt(text, end), vowels)) return false;
|
|
332
332
|
if (row.at === "line-start") {
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
);
|
|
333
|
+
let slash = text.lastIndexOf("/", start - 1);
|
|
334
|
+
while (slash >= 0 && (/\\+$/.exec(text.slice(0, slash))?.[0].length ?? 0) % 2)
|
|
335
|
+
slash = text.lastIndexOf("/", slash - 1);
|
|
336
|
+
const prefix = text.slice(Math.max(text.lastIndexOf("\n", start - 1), slash) + 1, start);
|
|
337
337
|
if (prefix.replaceAll("^", "")) return false;
|
|
338
338
|
}
|
|
339
339
|
if (row.at === "start" && start > 0 && !boundary(previousAt(text, start))) return false;
|
|
@@ -514,8 +514,7 @@ function encodeRawPieces(enc, text, { font, verse = false, start = 0, end = text
|
|
|
514
514
|
out.push(piece(text.slice(at, end), enc.controls.verseCanto, at, end, null));
|
|
515
515
|
at = end;
|
|
516
516
|
if (verse) {
|
|
517
|
-
|
|
518
|
-
else if (/[\r\n]/.test(text[at] ?? "")) at++;
|
|
517
|
+
while (at < bound && /[ \t\r\n]/.test(text[at])) at++;
|
|
519
518
|
}
|
|
520
519
|
continue;
|
|
521
520
|
}
|
|
@@ -523,10 +522,6 @@ function encodeRawPieces(enc, text, { font, verse = false, start = 0, end = text
|
|
|
523
522
|
let end = at;
|
|
524
523
|
while (end < bound && /[ \t\r\n]/.test(text[end])) end++;
|
|
525
524
|
const run = text.slice(at, end).replaceAll("\r\n", "\n").replaceAll("\r", "\n");
|
|
526
|
-
if (text.startsWith("--- {.canto}", end)) {
|
|
527
|
-
at = end;
|
|
528
|
-
continue;
|
|
529
|
-
}
|
|
530
525
|
const row =
|
|
531
526
|
run.split("\n").length === 2 ?
|
|
532
527
|
rowAt(enc, groups, "/", 0)
|
|
@@ -614,12 +609,22 @@ function sourceProjection(enc, text, options) {
|
|
|
614
609
|
return { normalized, sourceAt, begin, bound };
|
|
615
610
|
}
|
|
616
611
|
function cantoEnd(enc, text, at, bound) {
|
|
617
|
-
const source = "
|
|
612
|
+
const source = "***";
|
|
618
613
|
return (
|
|
619
614
|
enc.controls.verseCanto &&
|
|
620
615
|
at + source.length <= bound &&
|
|
621
616
|
text.startsWith(source, at) &&
|
|
622
|
-
!text
|
|
617
|
+
!text
|
|
618
|
+
.slice(
|
|
619
|
+
Math.max(text.lastIndexOf("\n", at - 1), text.lastIndexOf("\r", at - 1)) +
|
|
620
|
+
1,
|
|
621
|
+
at,
|
|
622
|
+
)
|
|
623
|
+
.trim() &&
|
|
624
|
+
!text
|
|
625
|
+
.slice(at + source.length, bound)
|
|
626
|
+
.split(/[\r\n]/, 1)[0]
|
|
627
|
+
.trim()
|
|
623
628
|
) ?
|
|
624
629
|
at + source.length
|
|
625
630
|
: undefined;
|
package/engine/first-use.mjs
CHANGED
|
@@ -45,6 +45,7 @@ import remarkFrontmatter from "remark-frontmatter";
|
|
|
45
45
|
import remarkGfm from "remark-gfm";
|
|
46
46
|
import MarkdownIt from "markdown-it";
|
|
47
47
|
import { readQualifier } from "./address.mjs";
|
|
48
|
+
import { verseRegions } from "./verse-regions.mjs";
|
|
48
49
|
import { scanBlocks } from "./content-blocks.mjs";
|
|
49
50
|
import { scanFigures } from "./content-figures.mjs";
|
|
50
51
|
import { RUN_KEYS } from "./content-scripts.mjs";
|
|
@@ -169,14 +170,37 @@ const overlaps = (values, value) => {
|
|
|
169
170
|
* Explicit inline occurrences, authored strong text and structural exclusions
|
|
170
171
|
* at original source offsets.
|
|
171
172
|
*/
|
|
172
|
-
function prose(source, capturedNames) {
|
|
173
|
+
function prose(source, capturedNames, capturedSpans) {
|
|
173
174
|
const links = [],
|
|
174
175
|
paragraphs = [],
|
|
175
176
|
regions = [],
|
|
176
177
|
excluded = [],
|
|
177
178
|
strong = [],
|
|
178
179
|
html = [];
|
|
179
|
-
const
|
|
180
|
+
const spans = capturedSpans ?? scanSpans(source);
|
|
181
|
+
const verseMasks = verseRegions(source).map((region) => ({
|
|
182
|
+
start: region.from,
|
|
183
|
+
end: region.to,
|
|
184
|
+
block: true,
|
|
185
|
+
}));
|
|
186
|
+
for (const span of spans.spans)
|
|
187
|
+
if (
|
|
188
|
+
span.verseModel ||
|
|
189
|
+
span.attrs.some(
|
|
190
|
+
([key, value]) => key === "class" && value.split(/\s+/).includes("verse"),
|
|
191
|
+
)
|
|
192
|
+
)
|
|
193
|
+
verseMasks.push({ start: span.start, end: span.end, block: false });
|
|
194
|
+
excluded.push(...verseMasks);
|
|
195
|
+
const verseRanges = verseMasks
|
|
196
|
+
.sort((a, b) => a.start - b.start || b.end - a.end)
|
|
197
|
+
.filter(
|
|
198
|
+
(range, index, all) =>
|
|
199
|
+
!all
|
|
200
|
+
.slice(0, index)
|
|
201
|
+
.some((parent) => parent.start <= range.start && parent.end >= range.end),
|
|
202
|
+
);
|
|
203
|
+
let masked = source
|
|
180
204
|
.replace(/!\[\[[^\]\n]+\]\]|\{\{[^}\n]+\}\}/g, (text, start) => {
|
|
181
205
|
excluded.push({ start, end: start + text.length });
|
|
182
206
|
return blank(text);
|
|
@@ -193,6 +217,11 @@ function prose(source, capturedNames) {
|
|
|
193
217
|
});
|
|
194
218
|
return "x".repeat(text.length);
|
|
195
219
|
});
|
|
220
|
+
for (const range of [...verseRanges].reverse())
|
|
221
|
+
masked =
|
|
222
|
+
masked.slice(0, range.start) +
|
|
223
|
+
masked.slice(range.start, range.end).replace(/[^\r\n]/g, range.block ? " " : "x") +
|
|
224
|
+
masked.slice(range.end);
|
|
196
225
|
const tree = parser.parse(masked);
|
|
197
226
|
const env = { references: Object.create(null) };
|
|
198
227
|
const inline = (node) => {
|
|
@@ -285,7 +314,7 @@ function prose(source, capturedNames) {
|
|
|
285
314
|
});
|
|
286
315
|
const authoredLinks = [...new Map(actualLinks.map((link) => [link.start, link])).values()];
|
|
287
316
|
const linked = ranges(authoredLinks);
|
|
288
|
-
const marked = (capturedNames ??
|
|
317
|
+
const marked = (capturedNames ?? spans.names)
|
|
289
318
|
.filter((span) => eligible(span) && !contains(linked, span))
|
|
290
319
|
.map((span) => ({
|
|
291
320
|
...span,
|
|
@@ -373,7 +402,7 @@ export function nameUses(markdown, vocabulary) {
|
|
|
373
402
|
// Every use carries the class, so a body without it is read no further.
|
|
374
403
|
if (!source.includes(".name")) return [];
|
|
375
404
|
const spans = scanSpans(source);
|
|
376
|
-
const { marked, links, strong, inParagraph } = prose(source, spans.names);
|
|
405
|
+
const { marked, links, strong, inParagraph } = prose(source, spans.names, spans);
|
|
377
406
|
const starts = [0];
|
|
378
407
|
for (let i = 0; i < source.length; i++) if (source[i] === "\n") starts.push(i + 1);
|
|
379
408
|
// A caption and a named block's opening and closing lines are not prose.
|
|
@@ -527,14 +556,16 @@ export function createFirstUseChecker({
|
|
|
527
556
|
const { recognize } = catalog;
|
|
528
557
|
return ({ file, raw }) => {
|
|
529
558
|
const authored = parsed?.get(file);
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
)
|
|
536
|
-
|
|
537
|
-
|
|
559
|
+
let captured;
|
|
560
|
+
if (authored?.raw === raw && Number.isInteger(authored.bodyLine)) {
|
|
561
|
+
const inventory = syntax ? syntax(authored.body).spans : scanSpans(authored.body);
|
|
562
|
+
const context = { raw, bodyLine: authored.bodyLine, bodyColumn: authored.bodyColumn };
|
|
563
|
+
captured = {
|
|
564
|
+
names: rebaseNameSpans(inventory.names, context),
|
|
565
|
+
spans: rebaseNameSpans(inventory.spans, context),
|
|
566
|
+
};
|
|
567
|
+
}
|
|
568
|
+
const { marked } = prose(raw, captured?.names, captured);
|
|
538
569
|
const findings = new Map();
|
|
539
570
|
const add = (offset, rule, message) => {
|
|
540
571
|
if (!findings.has(offset)) findings.set(offset, { rules: [], messages: [] });
|
|
@@ -40,7 +40,8 @@ import { blockTokens } from "./markdown-blocks.mjs";
|
|
|
40
40
|
function literalHeadingLines(source) {
|
|
41
41
|
const lines = new Set();
|
|
42
42
|
for (const token of blockTokens(source)) {
|
|
43
|
-
if (!token.map || !["fence", "code_block", "html_block"].includes(token.type))
|
|
43
|
+
if (!token.map || !["fence", "code_block", "html_block", "verse_div"].includes(token.type))
|
|
44
|
+
continue;
|
|
44
45
|
for (let line = token.map[0]; line < token.map[1]; line++) lines.add(line);
|
|
45
46
|
}
|
|
46
47
|
return lines;
|
package/engine/journals.mjs
CHANGED
|
@@ -76,6 +76,7 @@ import { journalHasContent } from "./note-state.mjs";
|
|
|
76
76
|
import { hasLexicalEntries } from "./lexical-sections.mjs";
|
|
77
77
|
import { draftNoticeFor } from "./draft-notice.mjs";
|
|
78
78
|
import { scanFigures } from "./content-figures.mjs";
|
|
79
|
+
import { verseRegions } from "./verse-regions.mjs";
|
|
79
80
|
import { separateFootnotes } from "./content-footnotes.mjs";
|
|
80
81
|
import { WITHHELD_CLASS, pageOpenings } from "./heading-attributes.mjs";
|
|
81
82
|
import { imagesIn } from "./content-images.mjs";
|
|
@@ -154,6 +155,12 @@ export function splitPages(body, leadName = "Introduction", resolveRole, syntax)
|
|
|
154
155
|
scanFigures(markdown, { resolveRole }).figures.map((figure) => [figure.line - 1, figure]),
|
|
155
156
|
);
|
|
156
157
|
const blockLines = namedBlockLines(markdown, syntax);
|
|
158
|
+
const verseLines = new Set();
|
|
159
|
+
for (const region of verseRegions(markdown))
|
|
160
|
+
for (let line = region.start; line <= region.end; line++) {
|
|
161
|
+
blockLines.add(line);
|
|
162
|
+
verseLines.add(line);
|
|
163
|
+
}
|
|
157
164
|
const openings = pageOpenings(markdown, blockLines);
|
|
158
165
|
const pages = [];
|
|
159
166
|
const beforeFirstH1 = [];
|
|
@@ -189,7 +196,7 @@ export function splitPages(body, leadName = "Introduction", resolveRole, syntax)
|
|
|
189
196
|
// carry a shorter fence of its own as literal content, same as
|
|
190
197
|
// {@link module:engine/content-blocks.scanBlocks} and
|
|
191
198
|
// {@link module:engine/content-figures.scanFigures} track it.
|
|
192
|
-
const fence = /^ {0,3}(`{3,}|~{3,})/.exec(line);
|
|
199
|
+
const fence = verseLines.has(lineIndex) ? null : /^ {0,3}(`{3,}|~{3,})/.exec(line);
|
|
193
200
|
if (codeFence) {
|
|
194
201
|
if (fence && fence[1][0] === codeFence[0] && fence[1].length >= codeFence.length) {
|
|
195
202
|
codeFence = null;
|
|
@@ -25,9 +25,10 @@
|
|
|
25
25
|
*/
|
|
26
26
|
|
|
27
27
|
import MarkdownIt from "markdown-it";
|
|
28
|
+
import { verseDivPlugin } from "./verse-regions.mjs";
|
|
28
29
|
|
|
29
30
|
/** The renderer's block grammar, with inline parsing and its passes switched off. */
|
|
30
|
-
const parser = new MarkdownIt({ html: true });
|
|
31
|
+
const parser = new MarkdownIt({ html: true }).use(verseDivPlugin);
|
|
31
32
|
parser.core.ruler.disable(["inline", "linkify", "replacements", "smartquotes", "text_join"]);
|
|
32
33
|
|
|
33
34
|
/** How many recent sources keep their tokens. */
|
|
@@ -51,7 +52,7 @@ export function blockTokens(source) {
|
|
|
51
52
|
recent.set(text, hit);
|
|
52
53
|
return hit;
|
|
53
54
|
}
|
|
54
|
-
const tokens = parser.parse(text, {});
|
|
55
|
+
const tokens = parser.parse(text, { verseSource: text });
|
|
55
56
|
recent.set(text, tokens);
|
|
56
57
|
if (recent.size > RECENT) recent.delete(recent.keys().next().value);
|
|
57
58
|
return tokens;
|
|
@@ -51,6 +51,7 @@ import {
|
|
|
51
51
|
printParseErrorCode,
|
|
52
52
|
} from "jsonc-parser";
|
|
53
53
|
import markdownIt from "markdown-it";
|
|
54
|
+
import footnotePlugin from "markdown-it-footnote";
|
|
54
55
|
import { minimatch } from "minimatch";
|
|
55
56
|
import { LineCounter, parse as parseYaml, parseDocument } from "yaml";
|
|
56
57
|
|
|
@@ -62,6 +63,13 @@ import {
|
|
|
62
63
|
resolveModule,
|
|
63
64
|
} from "../vendor/markdownlint.mjs";
|
|
64
65
|
import { walkFiles } from "./walk-files.mjs";
|
|
66
|
+
import { verseRegions } from "./verse-regions.mjs";
|
|
67
|
+
import { scanSpans, spanMarkdownPlugin } from "./content-spans.mjs";
|
|
68
|
+
import { parseVerse } from "./content-verse.mjs";
|
|
69
|
+
import { footnoteLabels, footnoteReferences } from "./content-footnotes.mjs";
|
|
70
|
+
|
|
71
|
+
/** References inside verse obey inline Markdown, without block interpretation. */
|
|
72
|
+
const referenceParser = markdownIt({ html: true }).use(footnotePlugin).use(spanMarkdownPlugin);
|
|
65
73
|
|
|
66
74
|
/** Option-file names, in the order the first existing one is taken. */
|
|
67
75
|
const OPTIONS_FILES = Object.freeze([
|
|
@@ -542,12 +550,33 @@ export async function runMarkdownlint(params) {
|
|
|
542
550
|
for (const [dir, files] of groups) {
|
|
543
551
|
const options = await lintOptions(dir, files);
|
|
544
552
|
const malformed = new Set();
|
|
553
|
+
const sources = new Map();
|
|
545
554
|
for (const file of files) {
|
|
546
|
-
const
|
|
555
|
+
const source = fs.readFileSync(file, "utf8");
|
|
556
|
+
sources.set(file, protectLintVerse(source));
|
|
557
|
+
const findings = markupFindings(source, file);
|
|
547
558
|
if (findings.length) malformed.add(file);
|
|
548
559
|
markup.push(...findings);
|
|
549
560
|
}
|
|
550
|
-
|
|
561
|
+
const lintSources = async (selected) => {
|
|
562
|
+
const { files: _files, ...shared } = options;
|
|
563
|
+
const results = await lint({
|
|
564
|
+
...shared,
|
|
565
|
+
strings: Object.fromEntries(selected.map((file) => [file, sources.get(file).text])),
|
|
566
|
+
});
|
|
567
|
+
for (const file of selected) {
|
|
568
|
+
const protectedSource = sources.get(file);
|
|
569
|
+
results[file] = (results[file] ?? [])
|
|
570
|
+
.filter((error) => !protectedSource.containsFinding(error))
|
|
571
|
+
.map((error) => {
|
|
572
|
+
if (!error.fixInfo || !protectedSource.containsFix(error)) return error;
|
|
573
|
+
const { fixInfo: _fixInfo, ...unfixable } = error;
|
|
574
|
+
return unfixable;
|
|
575
|
+
});
|
|
576
|
+
}
|
|
577
|
+
return results;
|
|
578
|
+
};
|
|
579
|
+
let results = await lintSources(files);
|
|
551
580
|
if (fix) {
|
|
552
581
|
const toFix = [];
|
|
553
582
|
for (const file of files) {
|
|
@@ -555,11 +584,15 @@ export async function runMarkdownlint(params) {
|
|
|
555
584
|
const fixable = (results[file] ?? []).filter((error) => error.fixInfo);
|
|
556
585
|
if (!fixable.length) continue;
|
|
557
586
|
const original = fs.readFileSync(file, "utf8");
|
|
558
|
-
|
|
587
|
+
const fixed =
|
|
588
|
+
sources.get(file).protected ?
|
|
589
|
+
applyVerseSafeFixes(original, fixable)
|
|
590
|
+
: applyFixes(original, fixable);
|
|
591
|
+
fs.writeFileSync(file, fixed, "utf8");
|
|
592
|
+
sources.set(file, protectLintVerse(fixed));
|
|
559
593
|
toFix.push(file);
|
|
560
594
|
}
|
|
561
|
-
if (toFix.length)
|
|
562
|
-
results = { ...results, ...(await lint({ ...options, files: toFix })) };
|
|
595
|
+
if (toFix.length) results = { ...results, ...(await lintSources(toFix)) };
|
|
563
596
|
}
|
|
564
597
|
for (const [file, errors] of Object.entries(results)) resultsByFile.set(file, errors);
|
|
565
598
|
}
|
|
@@ -581,3 +614,155 @@ export async function runMarkdownlint(params) {
|
|
|
581
614
|
);
|
|
582
615
|
return { results: flat, markup };
|
|
583
616
|
}
|
|
617
|
+
|
|
618
|
+
/**
|
|
619
|
+
* Hide verse content from Markdown rules and inline configuration without moving
|
|
620
|
+
* any authored coordinate. Container prefixes and div delimiters remain visible.
|
|
621
|
+
* @param {string} source - Original file contents.
|
|
622
|
+
* @returns {object} Masked text and predicates guarding findings and fixes.
|
|
623
|
+
*/
|
|
624
|
+
function protectLintVerse(source) {
|
|
625
|
+
const syntax = source.replace(/^\uFEFF?---\r?\n[^]*?\r?\n---(?:\r?\n|$)/, (frontmatter) =>
|
|
626
|
+
frontmatter.replace(/[^\r\n]/g, " "),
|
|
627
|
+
);
|
|
628
|
+
const starts = [0];
|
|
629
|
+
for (const match of source.matchAll(/\r\n|\r|\n/g)) starts.push(match.index + match[0].length);
|
|
630
|
+
const endOfLine = (line) => {
|
|
631
|
+
const past = starts[line + 1] ?? source.length;
|
|
632
|
+
return past - (/\r\n$|[\r\n]$/.exec(source.slice(starts[line], past))?.[0].length ?? 0);
|
|
633
|
+
};
|
|
634
|
+
const ranges = [],
|
|
635
|
+
masks = [],
|
|
636
|
+
inlineMasks = [],
|
|
637
|
+
spanBrackets = [],
|
|
638
|
+
bodyLines = new Set(),
|
|
639
|
+
verseLines = [],
|
|
640
|
+
usedReferences = new Set(),
|
|
641
|
+
footnotes = footnoteLabels(syntax);
|
|
642
|
+
const referencesInVerse = (text, model) => {
|
|
643
|
+
const titles = model.groups
|
|
644
|
+
.filter((group) => group.cantoBreak && group.title)
|
|
645
|
+
.map((group) => group.title);
|
|
646
|
+
for (const fragment of [text, ...titles])
|
|
647
|
+
for (const reference of footnoteReferences(fragment, {
|
|
648
|
+
definitions: footnotes,
|
|
649
|
+
inline: true,
|
|
650
|
+
}))
|
|
651
|
+
usedReferences.add(referenceParser.utils.normalizeReference(`^${reference.key}`));
|
|
652
|
+
for (const group of model.groups) {
|
|
653
|
+
if (group.cantoBreak) {
|
|
654
|
+
if (group.title) verseLines.push(group.title);
|
|
655
|
+
} else for (const line of group.lines) verseLines.push(line.text);
|
|
656
|
+
}
|
|
657
|
+
};
|
|
658
|
+
for (const region of verseRegions(syntax)) {
|
|
659
|
+
if (!region.closed) continue;
|
|
660
|
+
referencesInVerse(region.body, parseVerse(region.body, { footnotes }));
|
|
661
|
+
ranges.push([region.bodyStart, starts[region.end]]);
|
|
662
|
+
for (let line = region.start + 1; line < region.end; line++) {
|
|
663
|
+
bodyLines.add(line + 1);
|
|
664
|
+
masks.push([
|
|
665
|
+
Math.min(region.contentStarts[line - region.start], endOfLine(line)),
|
|
666
|
+
endOfLine(line),
|
|
667
|
+
]);
|
|
668
|
+
}
|
|
669
|
+
}
|
|
670
|
+
for (const span of scanSpans(syntax).spans) {
|
|
671
|
+
if (!span.verseModel || source[span.end - 1] !== "}") continue;
|
|
672
|
+
referencesInVerse(span.text, span.verseModel);
|
|
673
|
+
ranges.push([span.start + 1, span.contentEnd]);
|
|
674
|
+
spanBrackets.push(span.start, span.contentEnd);
|
|
675
|
+
// Inline tokens omit quote/list prefixes. Continuation content ends
|
|
676
|
+
// at its physical line ending (or the located closing bracket), so
|
|
677
|
+
// its parsed length gives the content start without parsing prefixes.
|
|
678
|
+
const contentLines = span.text.replace(/\r\n?/g, "\n").split("\n");
|
|
679
|
+
for (const [index, content] of contentLines.entries()) {
|
|
680
|
+
const line = span.line - 1 + index;
|
|
681
|
+
const to = Math.min(endOfLine(line), span.contentEnd);
|
|
682
|
+
inlineMasks.push([index === 0 ? span.start + 1 : to - content.length, to]);
|
|
683
|
+
}
|
|
684
|
+
}
|
|
685
|
+
const raw = source.split("");
|
|
686
|
+
for (const [from, to] of masks)
|
|
687
|
+
for (let at = from; at < to; at++) if (!/[\r\n]/.test(raw[at])) raw[at] = " ";
|
|
688
|
+
// A blanked physical span line would split its surrounding paragraph.
|
|
689
|
+
// Neutralize its bracket delimiters too, so the mask cannot manufacture
|
|
690
|
+
// a reference shortcut to an unrelated definition with the same label.
|
|
691
|
+
for (const [from, to] of inlineMasks)
|
|
692
|
+
for (let at = from; at < to; at++) if (/\S/.test(raw[at])) raw[at] = "x";
|
|
693
|
+
for (const at of spanBrackets) raw[at] = "x";
|
|
694
|
+
const masked = raw.join("");
|
|
695
|
+
if (ranges.length) {
|
|
696
|
+
const env = {};
|
|
697
|
+
referenceParser.parse(masked, env);
|
|
698
|
+
// Reference links and images carry their normalized definition label
|
|
699
|
+
// on the actual emitted token. Speculative parser lookups do not count.
|
|
700
|
+
const collect = (tokens) => {
|
|
701
|
+
for (const token of tokens) {
|
|
702
|
+
const label = token.meta?.label;
|
|
703
|
+
if (["link_open", "image"].includes(token.type) && label) usedReferences.add(label);
|
|
704
|
+
if (token.children) collect(token.children);
|
|
705
|
+
}
|
|
706
|
+
};
|
|
707
|
+
for (const line of verseLines) {
|
|
708
|
+
const tokens = [];
|
|
709
|
+
referenceParser.inline.parse(line, referenceParser, env, tokens);
|
|
710
|
+
collect(tokens);
|
|
711
|
+
}
|
|
712
|
+
}
|
|
713
|
+
const intersects = (from, to) => ranges.some(([start, end]) => from < end && to > start);
|
|
714
|
+
return {
|
|
715
|
+
text: masked,
|
|
716
|
+
protected: ranges.length > 0,
|
|
717
|
+
containsFinding(error) {
|
|
718
|
+
if (error.ruleNames.includes("MD053")) {
|
|
719
|
+
const unused = /^Unused link or image reference definition: "([^]*)"$/.exec(
|
|
720
|
+
error.errorDetail ?? "",
|
|
721
|
+
);
|
|
722
|
+
if (
|
|
723
|
+
unused &&
|
|
724
|
+
usedReferences.has(referenceParser.utils.normalizeReference(unused[1]))
|
|
725
|
+
)
|
|
726
|
+
return true;
|
|
727
|
+
}
|
|
728
|
+
if (bodyLines.has(error.lineNumber)) return true;
|
|
729
|
+
if (!error.errorRange) return false;
|
|
730
|
+
const from = starts[error.lineNumber - 1] + error.errorRange[0] - 1;
|
|
731
|
+
return intersects(from, from + error.errorRange[1]);
|
|
732
|
+
},
|
|
733
|
+
containsFix(error) {
|
|
734
|
+
const fix = error.fixInfo;
|
|
735
|
+
const line = (fix.lineNumber || error.lineNumber) - 1;
|
|
736
|
+
const from = starts[line] + (fix.editColumn || 1) - 1;
|
|
737
|
+
if (fix.deleteCount === -1)
|
|
738
|
+
return intersects(starts[line], starts[line + 1] ?? source.length);
|
|
739
|
+
if (intersects(from, from + (fix.deleteCount ?? 0))) return true;
|
|
740
|
+
return Boolean(
|
|
741
|
+
fix.insertText && ranges.some(([start, end]) => from >= start && from <= end),
|
|
742
|
+
);
|
|
743
|
+
},
|
|
744
|
+
};
|
|
745
|
+
}
|
|
746
|
+
|
|
747
|
+
/**
|
|
748
|
+
* Apply fixes per original line so fixing prose cannot normalize the untouched
|
|
749
|
+
* line endings of a verse region elsewhere in a mixed-ending file.
|
|
750
|
+
* @param {string} source - Original file contents.
|
|
751
|
+
* @param {object[]} errors - Findings whose fixes do not intersect verse.
|
|
752
|
+
* @returns {string} Source with only the permitted fixes applied.
|
|
753
|
+
*/
|
|
754
|
+
function applyVerseSafeFixes(source, errors) {
|
|
755
|
+
const byLine = new Map();
|
|
756
|
+
for (const error of errors) {
|
|
757
|
+
const line = error.fixInfo.lineNumber || error.lineNumber;
|
|
758
|
+
if (!byLine.has(line)) byLine.set(line, []);
|
|
759
|
+
byLine
|
|
760
|
+
.get(line)
|
|
761
|
+
.push({ ...error, lineNumber: 1, fixInfo: { ...error.fixInfo, lineNumber: 1 } });
|
|
762
|
+
}
|
|
763
|
+
let line = 0;
|
|
764
|
+
return source.replace(/[^\r\n]*(?:\r\n|\r|\n|$)/g, (original) => {
|
|
765
|
+
const fixes = byLine.get(++line);
|
|
766
|
+
return fixes ? applyFixes(original, fixes) : original;
|
|
767
|
+
});
|
|
768
|
+
}
|
|
@@ -14,6 +14,8 @@
|
|
|
14
14
|
import { scanBlocks } from "./content-blocks.mjs";
|
|
15
15
|
import { scanAlerts } from "./content-alerts.mjs";
|
|
16
16
|
import { scanRuns } from "./content-scripts.mjs";
|
|
17
|
+
import { scanSpans } from "./content-spans.mjs";
|
|
18
|
+
import { verseRegions } from "./verse-regions.mjs";
|
|
17
19
|
import { matchAllOutsideCode } from "./code-fences.mjs";
|
|
18
20
|
|
|
19
21
|
/** The opening of a shortcode: `{{<` or `{{%`. */
|
|
@@ -55,9 +57,24 @@ export function markupFindings(source, file, syntax, runs) {
|
|
|
55
57
|
);
|
|
56
58
|
const scanned = syntax?.(body);
|
|
57
59
|
const marked = runs ? scanRuns(body, runs, syntax) : undefined;
|
|
60
|
+
const spans = scanned?.spans ?? scanSpans(body);
|
|
61
|
+
const verseRanges = [
|
|
62
|
+
...verseRegions(body).map((region) => [region.from, region.to]),
|
|
63
|
+
...spans.spans.filter((span) => span.verseModel).map((span) => [span.start, span.end]),
|
|
64
|
+
];
|
|
65
|
+
const starts = [0];
|
|
66
|
+
for (const match of body.matchAll(/\n/g)) starts.push(match.index + 1);
|
|
67
|
+
const verseErrors = spans.errors.filter((error) => {
|
|
68
|
+
const offset = (starts[error.line - 1] ?? 0) + error.column - 1;
|
|
69
|
+
return (
|
|
70
|
+
error.message.includes(".verse") ||
|
|
71
|
+
verseRanges.some(([from, to]) => offset >= from && offset < to)
|
|
72
|
+
);
|
|
73
|
+
});
|
|
58
74
|
const errors = [
|
|
59
75
|
...(scanned?.blocks ?? scanBlocks(body)).errors,
|
|
60
76
|
...(scanned?.alerts ?? scanAlerts(body)).errors,
|
|
77
|
+
...verseErrors,
|
|
61
78
|
...(marked?.errors ?? []),
|
|
62
79
|
...shortcodeErrors(body),
|
|
63
80
|
].map((error) => ({
|
package/engine/name-markup.mjs
CHANGED
|
@@ -42,6 +42,7 @@
|
|
|
42
42
|
* @property {string} visible - Visible text from the actual parsed children.
|
|
43
43
|
* @property {ReadonlyArray<readonly [string, string]>} attrs - Retained permitted HTML attributes.
|
|
44
44
|
* @property {Record<string, SpanAttributePosition>} attributePositions - Authored key/value sites by retained attribute name.
|
|
45
|
+
* @property {ReturnType<typeof import("./content-verse.mjs").parseVerse>} [verseModel] - Logical model of an inline verse quotation.
|
|
45
46
|
*/
|
|
46
47
|
|
|
47
48
|
/**
|
|
@@ -2338,7 +2338,7 @@ export const NOTE_VOCABULARY = Object.freeze({
|
|
|
2338
2338
|
...TEXT,
|
|
2339
2339
|
required: true,
|
|
2340
2340
|
describe:
|
|
2341
|
-
"What a verse
|
|
2341
|
+
"What a verse div's `scan=` names the metre by: lowercase " +
|
|
2342
2342
|
"letters, digits and `-`, unique in the package.",
|
|
2343
2343
|
},
|
|
2344
2344
|
{
|
|
@@ -2362,7 +2362,7 @@ export const NOTE_VOCABULARY = Object.freeze({
|
|
|
2362
2362
|
]),
|
|
2363
2363
|
}),
|
|
2364
2364
|
describe:
|
|
2365
|
-
"The metres a verse
|
|
2365
|
+
"The metres a verse div's `scan=` names. A div that states one is " +
|
|
2366
2366
|
"warned at each line that cannot have its beats. See " +
|
|
2367
2367
|
"[metres](../authoring/links-and-markup.md#metre).",
|
|
2368
2368
|
},
|