Skip to content
Source
Sign inSign up

composer-rich-text.ts

141 lines4.7 KB

1export type RichTextMark = "bold" | "italic" | "strike" | "code";
2​
3interface RichTextSpan {
4 text: string;
5 marks: RichTextMark[];
6}
7​
8export const RICH_TEXT_DELIMITERS: Record<RichTextMark, string> = {
9 bold: "**",
10 italic: "*",
11 strike: "~~",
12 code: "`",
13};
14​
15/** A run of `text` from `start` to `end` that parses with `marks`. */
16interface SourcePiece {
17 start: number;
18 end: number;
19 marks: RichTextMark[];
21​
22function sameMarks(left: readonly RichTextMark[], right: readonly RichTextMark[]): boolean {
23 return left.length === right.length && left.every((mark, index) => mark === right[index]);
25​
26// Pieces keep source offsets, so callers can map parsed text back onto the
27// characters it came from; parsing only drops delimiters.
28function parseInlineMarkdownPieces(text: string): SourcePiece[] {
29 const root: SourcePiece[] = [];
30 const stack: { delimiter: string; mark: RichTextMark; start: number; pieces: SourcePiece[] }[] =
31 [];
32 const push = (start: number, end: number, marks: RichTextMark[] = []) => {
33 if (end <= start) return;
34 const pieces = stack.at(-1)?.pieces ?? root;
35 const last = pieces.at(-1);
36 if (last?.end === start && sameMarks(last.marks, marks)) last.end = end;
37 else pieces.push({ start, end, marks });
38 };
39 let index = 0;
40 while (index < text.length) {
41 const char = text[index]!;
42 if (char === "\\") {
43 push(index, Math.min(index + 2, text.length));
44 index += 2;
45 continue;
46 }
47 if (char === "`") {
48 const run = text.slice(index).match(/^`+/)![0];
49 const close = text.indexOf(run, index + run.length);
50 if (
51 run.length === 1 &&
52 close > index + 1 &&
53 !text.slice(index, close).includes("\n") &&
54 text[close + 1] !== "`"
55 ) {
56 push(index + 1, close, ["code"]);
57 index = close + 1;
58 } else {
59 push(index, index + run.length);
60 index += run.length;
61 }
62 continue;
63 }
64 if (char !== "*" && char !== "_" && char !== "~") {
65 push(index, index + 1);
66 index += 1;
67 continue;
68 }
69 let end = index;
70 while (text[end] === char) end += 1;
71 const before = text[index - 1] ?? "";
72 const after = text[end] ?? "";
73 const canClose = before !== "" && !/\s/.test(before) && (char !== "_" || !/\w/.test(after));
74 const canOpen = after !== "" && !/\s/.test(after) && (char !== "_" || !/\w/.test(before));
75 while (index < end) {
76 const top = stack.at(-1);
77 const opensNestedBold =
78 top?.mark === "italic" &&
79 end - index === 2 &&
80 canOpen &&
81 (char === "*" || char === "_") &&
82 !stack.some((frame) => frame.mark === "bold");
83 if (
84 !opensNestedBold &&
85 canClose &&
86 top &&
87 text.startsWith(top.delimiter, index) &&
88 index + top.delimiter.length <= end
89 ) {
90 stack.pop();
91 for (const piece of top.pieces) push(piece.start, piece.end, [top.mark, ...piece.marks]);
92 index += top.delimiter.length;
93 } else if (canOpen && (char !== "~" || end - index >= 2)) {
94 const length = char === "~" || end - index >= 2 ? 2 : 1;
95 const mark = char === "~" ? "strike" : length === 2 ? "bold" : "italic";
96 // Prevent self-nesting, including alternate delimiters, to bound the stack by supported styles.
97 if (stack.some((frame) => frame.mark === mark)) {
98 push(index, end);
99 index = end;
100 continue;
101 }
102 stack.push({ delimiter: char.repeat(length), mark, start: index, pieces: [] });
103 index += length;
104 } else {
105 push(index, end);
106 index = end;
107 }
108 }
109 }
110 while (stack.length > 0) {
111 const frame = stack.pop()!;
112 push(frame.start, frame.start + frame.delimiter.length);
113 for (const piece of frame.pieces) push(piece.start, piece.end, piece.marks);
114 }
115 return root;
117​
118export function parseInlineMarkdown(text: string): RichTextSpan[] {
119 const spans: RichTextSpan[] = [];
120 for (const { start, end, marks } of parseInlineMarkdownPieces(text)) {
121 const last = spans.at(-1);
122 if (last && sameMarks(last.marks, marks)) {
123 last.text += text.slice(start, end);
124 } else {
125 spans.push({ text: text.slice(start, end), marks });
126 }
127 }
128 return spans;
130​
131/** Marks text the composer shows as editable Markdown source while the caret is in it. */
132export const MARKDOWN_SOURCE_MARK = "markdownSource";
133​
134/** Marks for each UTF-16 unit of `text`, or null for a delimiter that parsing drops. */
135export function inlineMarkdownUnitMarks(text: string): Array<RichTextMark[] | null> {
136 const result: Array<RichTextMark[] | null> = Array.from({ length: text.length }, () => null);
137 for (const { start, end, marks } of parseInlineMarkdownPieces(text)) {
138 result.fill(marks, start, end);
139 }
140 return result;