@heroiclands/package-build 25.4.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 +24 -0
- package/docs/authoring/links-and-markup.md +10 -3
- package/docs/commands.md +6 -0
- package/docs/encoding-core.md +18 -4
- package/docs/guides/writing-in-invented-scripts.md +25 -6
- package/docs/reference/format-details.md +4 -5
- package/engine/content-footnotes.mjs +9 -0
- package/engine/content-scripts.mjs +104 -23
- package/engine/content-scroll.mjs +8 -3
- package/engine/content-spans.mjs +35 -2
- package/engine/content-verse.mjs +27 -15
- package/engine/encoding-standard.mjs +1 -0
- package/engine/encoding-template.mjs +5 -2
- package/engine/encoding.mjs +19 -14
- package/engine/markdownlint-run.mjs +190 -5
- package/engine/roman-characters.mjs +32 -10
- package/engine/roman-run.mjs +3 -0
- package/engine/roman-verse.mjs +1 -0
- package/engine/verse-format.mjs +25 -13
- package/engine/verse-regions.mjs +9 -0
- package/package.json +1 -1
- package/types/engine/content-scroll.d.mts +1 -1
- package/types/engine/content-verse.d.mts +7 -2
- package/types/engine/roman-run.d.mts +3 -1
- package/types/engine/verse-format.d.mts +3 -2
- package/types/engine/verse-regions.d.mts +4 -0
package/engine/content-verse.mjs
CHANGED
|
@@ -124,16 +124,19 @@ export function scanVerse(source, { checkWorks = true } = {}) {
|
|
|
124
124
|
blocks = [],
|
|
125
125
|
errors = [];
|
|
126
126
|
for (const token of blockTokens(text)) {
|
|
127
|
-
if (token.type === "fence" && /^verse(?:\s|$)/.test(token.info.trim()))
|
|
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);
|
|
128
130
|
errors.push({
|
|
129
131
|
line: token.map[0] + 1,
|
|
130
|
-
column: 1,
|
|
132
|
+
...(marker !== undefined && marker >= 0 ? { column: marker + 1 } : {}),
|
|
131
133
|
message: "verse uses a .verse div: write ::: {.verse …} and a closing :::",
|
|
132
134
|
});
|
|
135
|
+
}
|
|
133
136
|
}
|
|
134
137
|
const definedFootnotes = footnoteLabels(text);
|
|
135
138
|
for (const region of verseRegions(text)) {
|
|
136
|
-
const at = { line: region.start + 1, column: region.
|
|
139
|
+
const at = { line: region.start + 1, column: region.contentStarts[0] - region.from + 1 };
|
|
137
140
|
for (const message of [...region.problems, ...refusedAttributes(region.attributes)])
|
|
138
141
|
errors.push({ ...at, message });
|
|
139
142
|
if (!region.closed) errors.push({ ...at, message: "verse div needs a closing ::: line" });
|
|
@@ -164,7 +167,7 @@ export function scanVerse(source, { checkWorks = true } = {}) {
|
|
|
164
167
|
message: "tabs are not allowed in verse",
|
|
165
168
|
});
|
|
166
169
|
}
|
|
167
|
-
blocks.push({ ...region, model });
|
|
170
|
+
blocks.push({ ...region, column: at.column, model });
|
|
168
171
|
}
|
|
169
172
|
if (checkWorks) {
|
|
170
173
|
const openings = [...pageOpenings(source).keys()];
|
|
@@ -176,7 +179,7 @@ export function scanVerse(source, { checkWorks = true } = {}) {
|
|
|
176
179
|
}
|
|
177
180
|
for (const page of pages.values()) errors.push(...workShapeFindings(page));
|
|
178
181
|
}
|
|
179
|
-
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));
|
|
180
183
|
return { blocks, errors };
|
|
181
184
|
}
|
|
182
185
|
|
|
@@ -237,6 +240,7 @@ function keyColumn(opening, key) {
|
|
|
237
240
|
* An authored verse div with its parsed logical structure.
|
|
238
241
|
* @typedef {object} VerseBlock
|
|
239
242
|
* @property {number} start - Opening div's zero-based source line.
|
|
243
|
+
* @property {number} [column] - Opening delimiter's one-based source column, when known.
|
|
240
244
|
* @property {number} end - Closing div's zero-based source line.
|
|
241
245
|
* @property {number} from - Complete div first offset.
|
|
242
246
|
* @property {number} to - Offset past complete div.
|
|
@@ -253,7 +257,7 @@ function keyColumn(opening, key) {
|
|
|
253
257
|
* @property {VerseModel} model - Logical structure relative to the body.
|
|
254
258
|
*/
|
|
255
259
|
|
|
256
|
-
/** @typedef {{line: number, column
|
|
260
|
+
/** @typedef {{line: number, column?: number, message: string}} VerseDiagnostic */
|
|
257
261
|
|
|
258
262
|
/**
|
|
259
263
|
* An encoded verse's shared display model and native-cell presentation.
|
|
@@ -594,15 +598,20 @@ export function parseVerse(body, { inline = false, footnotes = new Set() } = {})
|
|
|
594
598
|
|
|
595
599
|
/**
|
|
596
600
|
* Validate a quoted verse span using its original source coordinates.
|
|
597
|
-
* @param {{start: number, text: string, attrs: Array<[string,string]
|
|
601
|
+
* @param {{start: number, text: string, attrs: Array<[string,string]>, verseModel?: VerseModel}} span - Authored span.
|
|
598
602
|
* @param {string} source - Complete Markdown source.
|
|
599
603
|
* @returns {VerseDiagnostic[]} Located inline-only findings.
|
|
600
604
|
*/
|
|
601
605
|
export function inlineVerseFindings(span, source) {
|
|
602
|
-
const findings =
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
+
const findings = [
|
|
607
|
+
...(
|
|
608
|
+
span.verseModel ??
|
|
609
|
+
parseVerse(span.text, {
|
|
610
|
+
inline: true,
|
|
611
|
+
footnotes: footnoteLabels(source),
|
|
612
|
+
})
|
|
613
|
+
).errors,
|
|
614
|
+
];
|
|
606
615
|
for (const [key] of span.attrs)
|
|
607
616
|
if (["form", "work", "scan"].includes(key))
|
|
608
617
|
findings.push({ offset: 0, message: `${key}= is not an inline .verse attribute` });
|
|
@@ -641,6 +650,10 @@ export const stanzaSeparator = (group) => group.length === 1 && STANZA_BREAK.tes
|
|
|
641
650
|
* @returns {VerseDiagnostic[]} Findings at affected opening divs.
|
|
642
651
|
*/
|
|
643
652
|
export function workShapeFindings(blocks) {
|
|
653
|
+
const location = (block) => ({
|
|
654
|
+
line: block.start + 1,
|
|
655
|
+
...(block.column === undefined ? {} : { column: block.column }),
|
|
656
|
+
});
|
|
644
657
|
const works = new Map(),
|
|
645
658
|
errors = [];
|
|
646
659
|
for (const block of blocks) {
|
|
@@ -652,8 +665,7 @@ export function workShapeFindings(blocks) {
|
|
|
652
665
|
for (const [key, versions] of works) {
|
|
653
666
|
if (versions.length === 1) {
|
|
654
667
|
errors.push({
|
|
655
|
-
|
|
656
|
-
column: 1,
|
|
668
|
+
...location(versions[0]),
|
|
657
669
|
message: `work=${key} has only one version`,
|
|
658
670
|
});
|
|
659
671
|
continue;
|
|
@@ -669,7 +681,7 @@ export function workShapeFindings(blocks) {
|
|
|
669
681
|
for (const block of [first, version]) {
|
|
670
682
|
const identity = `${block.start}:${message}`;
|
|
671
683
|
if (!seen.has(identity)) {
|
|
672
|
-
errors.push({
|
|
684
|
+
errors.push({ ...location(block), message });
|
|
673
685
|
seen.add(identity);
|
|
674
686
|
}
|
|
675
687
|
}
|
|
@@ -680,7 +692,7 @@ export function workShapeFindings(blocks) {
|
|
|
680
692
|
for (const block of [first, version]) {
|
|
681
693
|
const identity = `${block.start}:${message}`;
|
|
682
694
|
if (!seen.has(identity)) {
|
|
683
|
-
errors.push({
|
|
695
|
+
errors.push({ ...location(block), message });
|
|
684
696
|
seen.add(identity);
|
|
685
697
|
}
|
|
686
698
|
}
|
|
@@ -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;
|
|
@@ -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
|
+
}
|
|
@@ -121,15 +121,25 @@ export function romanTextProblems(text, inventory) {
|
|
|
121
121
|
);
|
|
122
122
|
const quotes = [];
|
|
123
123
|
let sentenceStart = true;
|
|
124
|
+
const sentenceText = () => {
|
|
125
|
+
sentenceStart = false;
|
|
126
|
+
if (quotes.length) quotes.at(-1).hasText = true;
|
|
127
|
+
};
|
|
124
128
|
const report = (offset, message) => out.push({ offset, message });
|
|
125
129
|
for (let offset = 0; offset < text.length;) {
|
|
126
130
|
const char = String.fromCodePoint(text.codePointAt(offset));
|
|
131
|
+
// Verse retains this escape so the encoder can distinguish a literal
|
|
132
|
+
// slash from its line control. The slash itself is standard Roman.
|
|
133
|
+
if (text.startsWith("\\/", offset)) {
|
|
134
|
+
offset++;
|
|
135
|
+
continue;
|
|
136
|
+
}
|
|
127
137
|
if (char === "⟦") {
|
|
128
138
|
// The encoding validates codage and its boundary grammar. Roman
|
|
129
139
|
// punctuation and language letters never reinterpret its contents.
|
|
130
140
|
const close = text.indexOf("⟧", offset + 1);
|
|
131
141
|
offset = close < 0 ? text.length : close + 1;
|
|
132
|
-
|
|
142
|
+
sentenceText();
|
|
133
143
|
continue;
|
|
134
144
|
}
|
|
135
145
|
const cluster = decomposed.get(offset);
|
|
@@ -139,16 +149,25 @@ export function romanTextProblems(text, inventory) {
|
|
|
139
149
|
`\`${cluster}\` is not NFC spelling in \`${inventory.language}\`; write \`${cluster.normalize("NFC")}\``,
|
|
140
150
|
);
|
|
141
151
|
offset += cluster.length;
|
|
142
|
-
|
|
152
|
+
sentenceText();
|
|
153
|
+
continue;
|
|
154
|
+
}
|
|
155
|
+
// An independent language child projects to an opaque empty name.
|
|
156
|
+
if (text.startsWith("{}", offset)) {
|
|
157
|
+
offset += 2;
|
|
158
|
+
sentenceText();
|
|
143
159
|
continue;
|
|
144
160
|
}
|
|
145
|
-
|
|
161
|
+
// Layout, pauses and name boundaries supply no sentence text.
|
|
162
|
+
if (isRomanWhitespace(char) || "/,{}".includes(char)) {
|
|
146
163
|
offset += char.length;
|
|
147
164
|
continue;
|
|
148
165
|
}
|
|
149
166
|
if (char === "^") {
|
|
150
|
-
|
|
151
|
-
|
|
167
|
+
let slash = text.lastIndexOf("/", offset - 1);
|
|
168
|
+
while (slash >= 0 && (/\\+$/.exec(text.slice(0, slash))?.[0].length ?? 0) % 2)
|
|
169
|
+
slash = text.lastIndexOf("/", slash - 1);
|
|
170
|
+
const start = Math.max(text.lastIndexOf("\n", offset - 1), slash) + 1;
|
|
152
171
|
if (/^\^*$/.test(text.slice(start, offset))) {
|
|
153
172
|
// Indentation is layout and leaves sentence-start ellipsis
|
|
154
173
|
// semantics intact. The reference encoder uses these bounds.
|
|
@@ -199,7 +218,7 @@ export function romanTextProblems(text, inventory) {
|
|
|
199
218
|
const expected = quotes.length % 2 === 0 ? "«" : "‹";
|
|
200
219
|
if (char !== expected)
|
|
201
220
|
report(offset, `a quotation at this depth opens with \`${expected}\``);
|
|
202
|
-
quotes.push({ char, offset });
|
|
221
|
+
quotes.push({ char, offset, sentenceStart, hasText: false });
|
|
203
222
|
sentenceStart = true;
|
|
204
223
|
} else if (char === "»" || char === "›") {
|
|
205
224
|
const opening = quotes.at(-1);
|
|
@@ -209,7 +228,8 @@ export function romanTextProblems(text, inventory) {
|
|
|
209
228
|
if (char !== expected)
|
|
210
229
|
report(offset, `this quotation closes with \`${expected}\`, not \`${char}\``);
|
|
211
230
|
quotes.pop();
|
|
212
|
-
sentenceStart =
|
|
231
|
+
sentenceStart = opening.sentenceStart && !opening.hasText;
|
|
232
|
+
if (opening.hasText) sentenceText();
|
|
213
233
|
}
|
|
214
234
|
} else if (char === "—" || (char === "…" && !sentenceStart)) {
|
|
215
235
|
const rest = text.slice(offset + char.length);
|
|
@@ -218,9 +238,11 @@ export function romanTextProblems(text, inventory) {
|
|
|
218
238
|
offset,
|
|
219
239
|
`\`${char}\` is immediately followed by a terminal (\`.\`, \`?\`, \`‼\` or \`!\`) except at the end of a run`,
|
|
220
240
|
);
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
241
|
+
} else if (TERMINALS.has(char)) {
|
|
242
|
+
if (sentenceStart)
|
|
243
|
+
report(offset, `terminal \`${char}\` has no sentence text before it`);
|
|
244
|
+
sentenceStart = true;
|
|
245
|
+
} else sentenceText();
|
|
224
246
|
offset += char.length;
|
|
225
247
|
}
|
|
226
248
|
for (const opening of quotes)
|
package/engine/roman-run.mjs
CHANGED
|
@@ -52,6 +52,7 @@
|
|
|
52
52
|
* @param {(span: RomanSpan) => string} [options.opaqueSpan] - Placeholder for an independent child. The default empty name keeps sentence state without introducing that child's letters.
|
|
53
53
|
* @param {ReadonlyArray<readonly [number, number]>} [options.exclude] - Absolute source ranges that carry layout or presentation syntax.
|
|
54
54
|
* @param {boolean} [options.markNames] - Whether name children contribute braces.
|
|
55
|
+
* @param {boolean} [options.preserveSlashEscape] - Keep a literal verse slash distinct from a line control.
|
|
55
56
|
* @returns {{text: string, offsets: number[], spans: ProjectedSpan[]}} Logical text and source locations, with child ranges for presentation.
|
|
56
57
|
*/
|
|
57
58
|
export function plainRomanRun(
|
|
@@ -64,6 +65,7 @@ export function plainRomanRun(
|
|
|
64
65
|
opaqueSpan = () => "{}",
|
|
65
66
|
exclude = [],
|
|
66
67
|
markNames = true,
|
|
68
|
+
preserveSlashEscape = false,
|
|
67
69
|
} = {},
|
|
68
70
|
) {
|
|
69
71
|
let text = "";
|
|
@@ -134,6 +136,7 @@ export function plainRomanRun(
|
|
|
134
136
|
const escaped =
|
|
135
137
|
source[at] === "\\" && at + 1 < past && /[!-/:-@[-`{-~]/.test(source[at + 1]);
|
|
136
138
|
const offset = at + (escaped ? 1 : 0);
|
|
139
|
+
if (escaped && preserveSlashEscape && source[offset] === "/") append("\\", at);
|
|
137
140
|
// A terminal Markdown line-break slash is layout, not Roman.
|
|
138
141
|
if (source[at] === "\\" && !escaped && /[\r\n]/.test(source[at + 1] ?? "")) {
|
|
139
142
|
at++;
|
package/engine/roman-verse.mjs
CHANGED
|
@@ -38,6 +38,7 @@ export function plainRomanVerse(source, options = {}) {
|
|
|
38
38
|
.filter((boundary) => boundary.sourceOffset >= from && boundary.sourceOffset <= to);
|
|
39
39
|
const projected = plainRomanRun(source, {
|
|
40
40
|
...options,
|
|
41
|
+
preserveSlashEscape: true,
|
|
41
42
|
from,
|
|
42
43
|
to,
|
|
43
44
|
exclude: [
|
package/engine/verse-format.mjs
CHANGED
|
@@ -15,11 +15,13 @@
|
|
|
15
15
|
|
|
16
16
|
import { verseRegions } from "./verse-regions.mjs";
|
|
17
17
|
import { scanSpans } from "./content-spans.mjs";
|
|
18
|
+
import { util } from "prettier";
|
|
18
19
|
|
|
19
20
|
/**
|
|
20
|
-
* Replace verse markup with opaque placeholders
|
|
21
|
+
* Replace verse markup with opaque placeholders, retaining container prefixes
|
|
22
|
+
* and inline display widths. Restoration follows the formatter's line endings.
|
|
21
23
|
* @param {string} source - Complete Markdown source.
|
|
22
|
-
* @returns {{markdown: string, restore: (formatted: string) => string}} Protected source and
|
|
24
|
+
* @returns {{markdown: string, restore: (formatted: string) => string}} Protected source and restoration.
|
|
23
25
|
*/
|
|
24
26
|
export function protectVerseSource(source) {
|
|
25
27
|
const syntax = source.replace(/^\uFEFF?---\r?\n[^]*?\r?\n---(?:\r?\n|$)/, (frontmatter) =>
|
|
@@ -28,7 +30,7 @@ export function protectVerseSource(source) {
|
|
|
28
30
|
const ranges = [
|
|
29
31
|
...verseRegions(syntax)
|
|
30
32
|
.filter((region) => region.closed)
|
|
31
|
-
.map((region) => ({ from: region.
|
|
33
|
+
.map((region) => ({ from: region.contentStarts[0], to: region.to, block: true })),
|
|
32
34
|
...scanSpans(syntax)
|
|
33
35
|
.spans.filter((span) =>
|
|
34
36
|
span.attrs.some(
|
|
@@ -43,33 +45,43 @@ export function protectVerseSource(source) {
|
|
|
43
45
|
.slice(0, index)
|
|
44
46
|
.some((parent) => parent.from <= range.from && parent.to >= range.to),
|
|
45
47
|
);
|
|
46
|
-
let
|
|
47
|
-
|
|
48
|
+
let nonce = 0;
|
|
49
|
+
let prefix = `V${nonce}V`;
|
|
50
|
+
while (source.includes(prefix)) prefix = `V${++nonce}V`;
|
|
48
51
|
const replacements = outer.map((range, index) => {
|
|
49
52
|
const original = source.slice(range.from, range.to);
|
|
50
|
-
const
|
|
51
|
-
|
|
53
|
+
const key = `${prefix}${index.toString(36)}V`;
|
|
54
|
+
const marker =
|
|
55
|
+
range.block ? `<!--${key}-->` : key.padEnd(util.getStringWidth(original), "X");
|
|
56
|
+
return {
|
|
57
|
+
...range,
|
|
58
|
+
original,
|
|
59
|
+
marker,
|
|
60
|
+
newline: range.block ? /(?:\r\n|\r|\n)$/.exec(original)?.[0] : undefined,
|
|
61
|
+
};
|
|
52
62
|
});
|
|
53
63
|
let markdown = source;
|
|
54
64
|
for (const entry of [...replacements].reverse())
|
|
55
65
|
markdown =
|
|
56
66
|
markdown.slice(0, entry.from) +
|
|
57
67
|
entry.marker +
|
|
58
|
-
(entry.newline
|
|
68
|
+
(entry.newline ?? "") +
|
|
59
69
|
markdown.slice(entry.to);
|
|
60
70
|
return {
|
|
61
71
|
markdown,
|
|
62
72
|
restore(formatted) {
|
|
73
|
+
const newline = /\r\n|\r|\n/.exec(formatted)?.[0] ?? "\n";
|
|
63
74
|
let restored = formatted;
|
|
64
75
|
for (const entry of replacements) {
|
|
65
76
|
const escaped = entry.marker.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
66
|
-
const pattern =
|
|
67
|
-
entry.
|
|
68
|
-
|
|
69
|
-
: new RegExp(escaped);
|
|
77
|
+
const pattern = new RegExp(
|
|
78
|
+
`${escaped}${entry.newline ? "(?:\\r\\n|\\r|\\n)" : ""}`,
|
|
79
|
+
);
|
|
70
80
|
if (!pattern.test(restored))
|
|
71
81
|
throw new Error("the formatter changed a protected verse placeholder");
|
|
72
|
-
restored = restored.replace(pattern, () =>
|
|
82
|
+
restored = restored.replace(pattern, () =>
|
|
83
|
+
entry.original.replace(/\r\n|\r|\n/g, newline),
|
|
84
|
+
);
|
|
73
85
|
}
|
|
74
86
|
return restored;
|
|
75
87
|
},
|