1import type { Processor } from "unified";
2import { isWindowsAbsolutePath } from "./path.ts";
3
4interface DestinationCompileContext {
5 readonly stack: ReadonlyArray<{ readonly type: string; url?: string }>;
6 resume(): string;
7 sliceSerialize(token: unknown): string;
8}
9
10function keepWindowsPathDestination(this: DestinationCompileContext, token: unknown) {
11 const decoded = this.resume();
12 const authored = this.sliceSerialize(token);
13 const node = this.stack.at(-1);
14 // Character references still need decoding, so those destinations keep the parsed URL.
15 if (node)
16 node.url = isWindowsAbsolutePath(authored) && !authored.includes("&") ? authored : decoded;
17}
18
19// A drive path that has not reached whitespace yet: `C:`, `C:\Users\me`, `C:/work`.
20const UNFINISHED_DRIVE_PATH = /(?:^|\W)[A-Za-z]:(?:[\\/]\S*)?$/;
21
22interface EscapeCompileContext {
23 readonly stack: ReadonlyArray<{
24 readonly type: string;
25 children?: Array<{ type: string; value?: string }>;
26 }>;
27}
28
29const WHITESPACE = /\s/g;
30// Text nodes only grow by appending while parsing, so each one is scanned for whitespace once.
31const wordScans = new WeakMap<object, { scannedTo: number; wordStart: number }>();
32
33function keepWindowsPathEscape(this: EscapeCompileContext) {
34 const text = this.stack.at(-1)?.children?.at(-1);
35 if (text?.type !== "text" || text.value === undefined) return;
36 let scan = wordScans.get(text);
37 if (!scan) wordScans.set(text, (scan = { scannedTo: 0, wordStart: 0 }));
38 WHITESPACE.lastIndex = scan.scannedTo;
39 while (WHITESPACE.exec(text.value)) scan.wordStart = WHITESPACE.lastIndex;
40 // A prose path cannot cross whitespace, so preceding words cannot affect this escape.
41 const end = text.value.length - 1;
42 if (UNFINISHED_DRIVE_PATH.test(text.value.slice(scan.wordStart, end))) {
43 text.value = `${text.value.slice(0, end)}\\${text.value.slice(end)}`;
44 }
45 scan.scannedTo = text.value.length;
46}
47
48function keepWindowsProsePathEscape(this: EscapeCompileContext) {
49 // Destinations, titles, and labels are buffered in a fragment.
50 if (this.stack.at(-1)?.type !== "fragment") keepWindowsPathEscape.call(this);
51}
52
53/**
54 * Keeps escapes inside drive paths in prose only, for parsers that already escape Windows
55 * destinations themselves. Prose paths end at whitespace: past that, nothing tells a path
56 * from a sentence, and `C:\x and \*this\*` must still read `*this*`.
57 */
58export function remarkKeepWindowsProsePaths(this: Processor) {
59 (this.data().fromMarkdownExtensions ??= []).push({
60 exit: { characterEscape: keepWindowsProsePathEscape },
61 });
62}
63
64/**
65 * CommonMark reads the `\.` in `C:\me\.t3\shot.png` as an escape. Every backslash in a
66 * Windows path is a separator, so link, image, and definition destinations that are
67 * Windows paths keep the text as written, and so do escapes inside drive paths.
68 */
69export function remarkKeepWindowsPaths(this: Processor) {
70 (this.data().fromMarkdownExtensions ??= []).push({
71 exit: {
72 resourceDestinationString: keepWindowsPathDestination,
73 definitionDestinationString: keepWindowsPathDestination,
74 characterEscape: keepWindowsPathEscape,
75 },
76 });
77}