Skip to content
Source
Sign inSign up
Open History to see the last commit

markdownSyntaxGates.ts

204 lines7.6 KB

1import type { Nodes as MdastNodes } from "mdast";
2import {
3 type Extension as MdastExtension,
4 fromMarkdown,
5 type Options as FromMarkdownOptions,
6 type Transform,
7} from "mdast-util-from-markdown";
8import { combineExtensions } from "micromark-util-combine-extensions";
9import type { Construct, Extension, TokenizeContext } from "micromark-util-types";
10import type { Processor } from "unified";
11​
12// A table needs a delimiter row: dashes with a `|` or `:`, after any `>` or indent.
13// One character class per pattern: overlapping ones backtrack quadratically on a run of dashes.
14const DELIMITER_LINE = /^[ \t>|:-]+$/gm;
15const isDelimiterRow = (line: string) => line.includes("-") && /[|:]/.test(line);
16const NEXT_LINE = /[^\r\n]*(?:\r\n?|\n)([^\r\n]*)/y;
17​
18function hasDelimiterRow(text: string) {
19 for (const [line] of text.matchAll(DELIMITER_LINE)) if (isDelimiterRow(line)) return true;
20 return false;
22const PROTOCOL = /https?:\/\//iy;
23const WWW = /www\./iy;
24const EMAIL_ATEXT = /[\w+.-]*@/y;
25​
26const matchesAt = (pattern: RegExp, text: string, offset: number) => {
27 pattern.lastIndex = offset;
28 return pattern.test(text);
29};
30​
31interface Gate {
32 readonly names: ReadonlyArray<string>;
33 /** False when the construct cannot match anywhere in the text. */
34 readonly inText: (text: string) => boolean;
35 /** False when it cannot start at this offset, which keeps micromark from trying it there. */
36 readonly at?: (context: TokenizeContext, text: string, offset: number) => boolean;
38​
39// micromark tries these at every candidate character: email autolinks at every word, tables
40// at every line. That is most of what GFM adds to a parse, and most texts cannot match them.
41const GATES: ReadonlyArray<Gate> = [
42 {
43 names: ["emailAutolink"],
44 inText: (text) => text.includes("@"),
45 at: (_, text, offset) => matchesAt(EMAIL_ATEXT, text, offset),
46 },
47 {
48 names: ["protocolAutolink"],
49 inText: (text) => /https?:\/\//i.test(text),
50 at: (_, text, offset) => matchesAt(PROTOCOL, text, offset),
51 },
52 {
53 names: ["wwwAutolink"],
54 inText: (text) => /www\./i.test(text),
55 at: (_, text, offset) => matchesAt(WWW, text, offset),
56 },
57 {
58 names: ["table"],
59 inText: hasDelimiterRow,
60 at: (context, text, offset) => {
61 NEXT_LINE.lastIndex = offset;
62 const nextLine = NEXT_LINE.exec(text)?.[1];
63 return (nextLine !== undefined && hasDelimiterRow(nextLine)) || continuesTable(context);
64 },
65 },
66 { names: ["strikethrough"], inText: (text) => text.includes("~") },
67 {
68 names: ["gfmFootnoteCall", "gfmPotentialFootnoteCall", "gfmFootnoteDefinition"],
69 inText: (text) => text.includes("[^"),
70 },
71 { names: ["tasklistCheck"], inText: (text) => /\[[\sxX]\]/.test(text) },
72];
73​
74// A body row follows the head or another row. Mirrors `start` in micromark-extension-gfm-table's
75// lib/syntax.js: recheck it when that package changes.
76function continuesTable({ events }: TokenizeContext) {
77 let index = events.length - 1;
78 while (index > -1 && /^(?:lineEnding|linePrefix)$/.test(events[index]![1].type)) index--;
79 // The table extension's token types are not in micromark's map unless its types are loaded.
80 const tail: string | undefined = events[index]?.[1].type;
81 return tail === "tableHead" || tail === "tableRow";
83​
84let parsing = "";
85const gated = new WeakMap<Construct, Construct>();
86​
87function gatedConstruct(construct: Construct, gate: Gate): Construct {
88 const at = gate.at;
89 if (!at) return construct;
90 let wrapped = gated.get(construct);
91 if (!wrapped) {
92 const { previous, tokenize } = construct;
93 wrapped = {
94 ...construct,
95 tokenize(effects, ok, nok) {
96 return at(this, parsing, this.now().offset) ? tokenize.call(this, effects, ok, nok) : nok;
97 },
98 ...(previous && {
99 previous(code) {
100 return previous.call(this, code) && at(this, parsing, this.now().offset);
101 },
102 }),
103 };
104 gated.set(construct, wrapped);
105 }
106 return wrapped;
108​
109type ConstructMap = Record<string, Construct | Construct[] | undefined>;
110​
111function gateExtension(extension: Extension, absent: ReadonlySet<Gate>): Extension {
112 const gateOf = (name: string | undefined) =>
113 GATES.find((gate) => name && gate.names.includes(name));
114 const result: Record<string, unknown> = {};
115 for (const [field, value] of Object.entries(extension)) {
116 if (field === "disable" || field === "attentionMarkers" || !value) {
117 result[field] = value;
118 continue;
119 }
120 const map: ConstructMap = {};
121 for (const [code, entry] of Object.entries(value as ConstructMap)) {
122 const kept = (Array.isArray(entry) ? entry : [entry]).flatMap((construct) => {
123 const gate = construct && gateOf(construct.name);
124 if (!construct || !gate) return construct ? [construct] : [];
125 return absent.has(gate) ? [] : [gatedConstruct(construct, gate)];
126 });
127 if (kept.length > 0) map[code] = Array.isArray(entry) ? kept : kept[0];
128 }
129 result[field] = map;
130 }
131 return result as Extension;
133​
134type MdastExtensions = NonNullable<FromMarkdownOptions["mdastExtensions"]>;
135​
136// Merged once, as mdast-util-from-markdown merges them on every call: lists in order, handlers by key.
137function mergeMdastExtensions(extensions: MdastExtensions): MdastExtension {
138 const merged = {
139 canContainEols: [] as string[],
140 enter: {},
141 exit: {},
142 transforms: [] as Transform[],
143 };
144 for (const extension of extensions.flat()) {
145 merged.canContainEols.push(...(extension.canContainEols ?? []));
146 Object.assign(merged.enter, extension.enter);
147 Object.assign(merged.exit, extension.exit);
148 // GFM looks for autolink literals again in the decoded text outside links, so `www\.x.io`
149 // links too. Most texts have URLs only in link destinations and code, where it finds nothing.
150 for (const transform of extension.transforms ?? []) {
151 merged.transforms.push(
152 extension.enter?.literalAutolink
153 ? (tree) => (hasAutolinkText(tree) ? transform(tree) : undefined)
154 : transform,
155 );
156 }
157 }
158 return merged;
160​
161const AUTOLINK_TEXT = /@|https?:\/\/|www\./i;
162​
163function hasAutolinkText(node: MdastNodes): boolean {
164 if (node.type === "text") return AUTOLINK_TEXT.test(node.value);
165 if (node.type === "link" || node.type === "linkReference" || !("children" in node)) return false;
166 return node.children.some(hasAutolinkText);
168​
169/**
170 * Parses as remark-parse does, without the syntax extension constructs that cannot match the
171 * text. Same tree, in about half the time.
172 */
173function attachParserWithoutUnusedSyntax(this: Processor) {
174 const data = this.data();
175 const byMask = new Map<number, FromMarkdownOptions["extensions"]>();
176 let mdastExtensions: MdastExtension[] | undefined;
177 this.parser = (document) => {
178 let mask = 0;
179 GATES.forEach((gate, index) => {
180 if (!gate.inText(document)) mask |= 1 << index;
181 });
182 let extensions = byMask.get(mask);
183 if (!extensions) {
184 const absent = new Set(GATES.filter((_, index) => mask & (1 << index)));
185 extensions = [
186 combineExtensions(
187 (data.micromarkExtensions ?? []).map((extension) => gateExtension(extension, absent)),
188 ),
189 ];
190 byMask.set(mask, extensions);
191 }
192 mdastExtensions ??= [mergeMdastExtensions(data.fromMarkdownExtensions ?? [])];
193 const outer = parsing;
194 // Gates read micromark offsets, which skip a leading byte order mark.
195 parsing = document.charCodeAt(0) === 0xfeff ? document.slice(1) : document;
196 try {
197 return fromMarkdown(document, { ...data.settings, extensions, mdastExtensions });
198 } finally {
199 parsing = outer;
200 }
201 };
203​
204export const remarkParseWithoutUnusedSyntax = attachParserWithoutUnusedSyntax;