1/**
2 * Markdown prose as an editable source.
3 *
4 * {@link markdownRuns} splits one block of inline markdown (a paragraph,
5 * heading, or list item) into the runs its rendered text nodes come from:
6 * text between emphasis delimiters, code spans, link text, and so on. The
7 * markdown can live inside another file (a JSDoc comment, a string) — the
8 * `linePrefix` option describes text that starts each continuation line.
9 */
10import {
11 CopyEditError,
12 decodeEntities,
13 type Dialect,
14 type Run,
15 type TextToken,
16} from "./text.ts";
17
18/** A syntax whose rendered text isn't literal source text (e.g. `{@link X}`). */
19export interface Atomic {
20 /** Matched at the current position (compiled with the sticky flag). */
21 pattern: RegExp;
22 /** The rendered text for a match (continuation-line prefixes removed). */
23 text: (match: RegExpExecArray) => string;
24}
25
26export interface MarkdownOptions {
27 /**
28 * Source of a regex matching what follows `\n` on continuation lines and
29 * isn't content, e.g. `"[ \\t]*\\*(?!/)[ \\t]?"` for JSDoc's ` * `.
30 */
31 linePrefix?: string;
32 /** Extra inline syntaxes rendered as generated, non-editable text. */
33 atomics?: Atomic[];
34}
35
36const PUNCT = /[!-/:-@[-`{-~\p{P}\p{S}]/u;
37const isWs = (ch: string | undefined) => ch === undefined || /\s/.test(ch);
38const isPunct = (ch: string | undefined) => ch !== undefined && PUNCT.test(ch);
39
40/** Straight quotes, dashes, and dots: how smart punctuation renders them. */
41const canon = (text: string) =>
42 text
43 .replace(/[“”„]/g, '"')
44 .replace(/[‘’‚]/g, "'")
45 .replace(/—/g, "---")
46 .replace(/–/g, "--")
47 .replace(/…/g, "...")
48 .replace(/\u00a0/g, " ");
49
50const decodeMarkdown = (raw: string) =>
51 decodeEntities(raw.replace(/\\([!-/:-@[-`{-~])/g, "$1"));
52
53export const markdownDialect = (options: MarkdownOptions = {}): Dialect => {
54 const ws = new RegExp(
55 `(?:[ \\t\\r\\f]|\\n${options.linePrefix ? `(?:${options.linePrefix})?` : ""})+`,
56 "y",
57 );
58 return {
59 tokenize(raw, kind) {
60 const tokens: TextToken[] = [];
61 let i = 0;
62 while (i < raw.length) {
63 ws.lastIndex = i;
64 const m = ws.exec(raw);
65 if (m && m[0].length > 0) {
66 tokens.push({ raw: m[0], ws: true, text: " " });
67 i += m[0].length;
68 continue;
69 }
70 let j = i;
71 while (j < raw.length && !/[ \t\r\f\n]/.test(raw[j])) j++;
72 const word = raw.slice(i, j);
73 tokens.push({
74 raw: word,
75 ws: false,
76 text: kind === "code" ? word : decodeMarkdown(word),
77 });
78 i = j;
79 }
80 return tokens;
81 },
82 compare: canon,
83 escape(word, { kind, lineStart }) {
84 if (kind === "code") {
85 if (word.includes("`") || word.includes("*/")) {
86 throw new CopyEditError("Code spans can't contain ` or */.");
87 }
88 return word;
89 }
90 let out = word
91 .replace(/[“”]/g, '"')
92 .replace(/[‘’]/g, "'")
93 .replace(/…/g, "...")
94 .replace(/[\\`*[\]<]/g, "\\$&")
95 .replace(/^_|_$/g, "\\_")
96 .replace(/~~/g, "\\~\\~")
97 .replace(/\{@/g, "{\\@");
98 if (lineStart) {
99 out = out
100 .replace(/^[#>+\-=|:@]/, "\\$&")
101 .replace(/^(\d+)([.)])/, "$1\\$2");
102 }
103 // Never let prose close a surrounding block comment.
104 return out.replace(/\*\//g, "*\\/");
105 },
106 };
107};
108
109/**
110 * Splits the inline markdown in `code[start, end)` into runs matching its
111 * rendered text nodes. The first run starts a line.
112 */
113export const markdownRuns = (
114 code: string,
115 start: number,
116 end: number,
117 options: MarkdownOptions = {},
118): Run[] => {
119 const prefix = options.linePrefix
120 ? new RegExp(`\\n(?:${options.linePrefix})`, "g")
121 : undefined;
122 const clean = (raw: string) => (prefix ? raw.replace(prefix, "\n") : raw);
123 const atomics = (options.atomics ?? []).map(({ pattern, text }) => ({
124 pattern: new RegExp(
125 pattern.source,
126 `${pattern.flags.replace(/[gy]/g, "")}y`,
127 ),
128 text,
129 }));
130
131 const runs: Run[] = [];
132 let runStart = start;
133 const boundary = (at: number, resume: number) => {
134 runs.push({ start: runStart, end: at, lineStart: runs.length === 0 });
135 runStart = resume;
136 };
137 const locked = (at: number, resume: number, expected: string) => {
138 boundary(at, at);
139 runs.push({ start: at, end: resume, kind: "locked", expected });
140 runStart = resume;
141 };
142 // Positions of `]` that close link text, mapped to the end of `](...)`.
143 const linkEnds = new Map<number, number>();
144
145 let i = start;
146 while (i < end) {
147 const ch = code[i];
148
149 if (ch === "\\" && i + 1 < end && /[!-/:-@[-`{-~]/.test(code[i + 1])) {
150 i += 2;
151 continue;
152 }
153
154 const atomic = atomics.find((a) => {
155 a.pattern.lastIndex = i;
156 return a.pattern.test(code) && a.pattern.lastIndex <= end;
157 });
158 if (atomic) {
159 atomic.pattern.lastIndex = i;
160 const m = atomic.pattern.exec(code)!;
161 const cleaned = Object.assign(m.map(clean), {
162 index: m.index,
163 input: m.input,
164 });
165 locked(i, i + m[0].length, atomic.text(cleaned as RegExpExecArray));
166 i += m[0].length;
167 continue;
168 }
169
170 if (ch === "`") {
171 let n = 1;
172 while (code[i + n] === "`") n++;
173 const fence = "`".repeat(n);
174 let close = code.indexOf(fence, i + n);
175 while (close !== -1 && close < end && code[close + n] === "`") {
176 close = code.indexOf(fence, close + n + 1);
177 }
178 if (close !== -1 && close + n <= end) {
179 boundary(i, i + n);
180 runs.push({ start: i + n, end: close, kind: "code" });
181 runStart = close + n;
182 i = close + n;
183 continue;
184 }
185 i += n;
186 continue;
187 }
188
189 if (ch === "<") {
190 const autolink = /^<(https?:\/\/[^\s<>]+)>/.exec(code.slice(i, end));
191 if (autolink) {
192 locked(i, i + autolink[0].length, autolink[1]);
193 i += autolink[0].length;
194 continue;
195 }
196 const tag = /^<\/?[A-Za-z][^<>]*>|^<!--[\s\S]*?-->/.exec(
197 code.slice(i, end),
198 );
199 if (tag) {
200 boundary(i, i + tag[0].length);
201 i += tag[0].length;
202 continue;
203 }
204 }
205
206 if (
207 ((ch === "h" || ch === "w") && isWs(code[i - 1])) ||
208 (i === start && (ch === "h" || ch === "w"))
209 ) {
210 const url = /^(?:https?:\/\/|www\.)[^\s<]*[^\s<.,:;"')\]!?*_~]/.exec(
211 code.slice(i, end),
212 );
213 if (url) {
214 locked(i, i + url[0].length, url[0]);
215 i += url[0].length;
216 continue;
217 }
218 }
219
220 if (ch === "!" && code[i + 1] === "[") {
221 const img = /^!\[[^\]]*\]\([^)]*\)/.exec(code.slice(i, end));
222 if (img) {
223 boundary(i, i + img[0].length);
224 i += img[0].length;
225 continue;
226 }
227 }
228
229 if (ch === "[") {
230 const close = matchLink(code, i, end);
231 if (close) {
232 linkEnds.set(close.bracket, close.end);
233 boundary(i, i + 1);
234 i += 1;
235 continue;
236 }
237 }
238 if (ch === "]" && linkEnds.has(i)) {
239 const after = linkEnds.get(i)!;
240 boundary(i, after);
241 i = after;
242 continue;
243 }
244
245 if (ch === "*" || ch === "_" || ch === "~") {
246 let n = 1;
247 while (code[i + n] === ch) n++;
248 const prev = code[i - 1];
249 const next = code[i + n];
250 const left =
251 !isWs(next) && (!isPunct(next) || isWs(prev) || isPunct(prev));
252 const right =
253 !isWs(prev) && (!isPunct(prev) || isWs(next) || isPunct(next));
254 const delimiter =
255 ch === "~"
256 ? n === 2 && (left || right)
257 : ch === "*"
258 ? left || right
259 : (left && (!right || isPunct(prev))) ||
260 (right && (!left || isPunct(next)));
261 if (delimiter) {
262 boundary(i, i + n);
263 i += n;
264 continue;
265 }
266 i += n;
267 continue;
268 }
269
270 i++;
271 }
272 boundary(end, end);
273 return runs;
274};
275
276/** Finds the `]` closing `[` at `open` when followed by `(destination)`. */
277const matchLink = (code: string, open: number, end: number) => {
278 let depth = 0;
279 for (let i = open; i < end; i++) {
280 const ch = code[i];
281 if (ch === "\\") {
282 i++;
283 continue;
284 }
285 if (ch === "`") {
286 const close = code.indexOf("`", i + 1);
287 if (close === -1 || close >= end) return undefined;
288 i = close;
289 continue;
290 }
291 if (ch === "[") depth++;
292 else if (ch === "]" && --depth === 0) {
293 if (code[i + 1] !== "(") return undefined;
294 let parens = 0;
295 for (let j = i + 1; j < end; j++) {
296 if (code[j] === "(") parens++;
297 else if (code[j] === ")" && --parens === 0) {
298 return { bracket: i, end: j + 1 };
299 }
300 }
301 return undefined;
302 }
303 }
304 return undefined;
305};