Skip to content
Source
Sign inSign up

pullRequestMarkdown.ts

301 lines11.3 KB

1import { sourceControlClients, type ReferenceAutolink } from "./sourceControlClients.ts";
2​
3/** `id` is positional on purpose: the same attachment can be embedded twice in one body. */
4export type PullRequestBodySegment =
5 | {
6 readonly id: string;
7 readonly kind: "markdown";
8 readonly text: string;
9 /** Where `text` starts in the body, so an edit made to it can be written back. */
10 readonly sourceOffset: number;
11 }
12 | {
13 readonly id: string;
14 readonly kind: "attachment";
15 readonly url: string;
16 readonly media: "video" | "image" | "unknown";
17 }
18 | {
19 readonly id: string;
20 readonly kind: "table";
21 readonly rows: ReadonlyArray<ReadonlyArray<PullRequestBodyTableCell>>;
22 };
23​
24type PullRequestBodyTableCell = {
25 readonly header: boolean;
26 readonly segments: ReadonlyArray<PullRequestBodySegment>;
27};
28​
29// `.` stops at the `\r` of a CRLF body, which is how GitHub stores one written in its web editor.
30const FENCE_PATTERN = /^\s{0,3}((?:`{3,})|(?:~{3,}))(.*)\r?$/u;
31/**
32 * How far a `<video>` tag may reach for its closing tag. Real embeds are one to three lines,
33 * and the bound keeps an unclosed tag from making each one rescan the rest of the body.
34 */
35const VIDEO_TAG_MAX_LINES = 8;
36​
37// A tab after up to three spaces still reaches the fourth column.
38const INDENTED_CODE_PATTERN = /^(?: {4}| {0,3}\t)/u;
39const BARE_URL_PATTERN = /^<?(https?:\/\/\S+?)>?$/u;
40const IMAGE_EXTENSION_PATTERN = /\.(?:png|jpe?g|gif|webp|avif|heic|heif|svg)(?:$|[?#])/iu;
41const MARKDOWN_LINK_PATTERN = /^\[[^\]]*\]\((https?:\/\/[^\s)]+)\)$/u;
42const VIDEO_EXTENSION_PATTERN = /\.(?:mp4|webm|mov|m4v|ogv)(?:$|[?#])/iu;
43​
44const VIDEO_TAG_SRC_PATTERN = /<(?:video|source)\b[^>]*\bsrc\s*=\s*["']([^"']+)["']/iu;
45​
46const STANDALONE_VIDEO_TAG_PATTERN = /^\s*<video\b/iu;
47const VIDEO_TAG_END_PATTERN = /<\/video>\s*$/iu;
48​
49/** Bounded like `VIDEO_TAG_MAX_LINES`; room for a before/after table with several rows. */
50const TABLE_MAX_LINES = 64;
51const TABLE_START_PATTERN = /^\s*<table\b/iu;
52const TABLE_END_PATTERN = /<\/table>\s*$/iu;
53const TABLE_OPEN_PATTERN = /<table\b/giu;
54const TABLE_CLOSE_PATTERN = /<\/table\s*>/giu;
55/**
56 * Quoted attribute values may hold `>`. The alternatives start on disjoint characters and an
57 * unquoted run stops at the next `<`, so a run of unterminated tags stays linear.
58 */
59const TABLE_STRUCTURE_TAG_PATTERN =
60 /<(\/?)(table|thead|tbody|tfoot|tr|td|th)\b((?:[^<>"']|"[^"]*"|'[^']*')*)>/giu;
61/** Rows are laid out independently, so a spanning cell would shift the cells after it. */
62const CELL_SPAN_PATTERN = /\b(?:col|row)span\b/iu;
63​
64function isWebUrl(url: string): boolean {
65 try {
66 const parsed = new URL(url);
67 return parsed.protocol === "https:" || parsed.protocol === "http:";
68 } catch {
69 return false;
70 }
72​
73function attachmentFromLine(
74 line: string,
75): { url: string; media: "video" | "image" | "unknown" } | null {
76 const url =
77 BARE_URL_PATTERN.exec(line.trim())?.[1] ?? MARKDOWN_LINK_PATTERN.exec(line.trim())?.[1];
78 if (url === undefined || !isWebUrl(url)) return null;
79 if (VIDEO_EXTENSION_PATTERN.test(url) || sourceControlClients.mediaUrl(url)?.kind === "video") {
80 return { url, media: "video" };
81 }
82 if (IMAGE_EXTENSION_PATTERN.test(url)) return { url, media: "image" };
83 return null;
85​
86type TableRows = ReadonlyArray<ReadonlyArray<PullRequestBodyTableCell>>;
87​
88/**
89 * Cells of a table made only of rows and cells, or null when anything else is in it: a caption,
90 * text between cells, a nested table. Cell contents are split on their own, so tables never nest.
91 */
92function tableRows(html: string, htmlOffset: number): TableRows | null {
93 const rows: PullRequestBodyTableCell[][] = [];
94 let row: PullRequestBodyTableCell[] | null = null;
95 let cell: { tag: string; contentStart: number } | null = null;
96 let opened = false;
97 let closed = false;
98 let cursor = 0;
99 for (const match of html.matchAll(TABLE_STRUCTURE_TAG_PATTERN)) {
100 const closing = match[1] === "/";
101 const tag = match[2]!.toLowerCase();
102 if (cell !== null) {
103 if (!closing || tag !== cell.tag) return null;
104 const content = html.slice(cell.contentStart, match.index);
105 // Indented HTML would render as a code block once the cell is markdown on its own.
106 if (content.split("\n").some((line) => INDENTED_CODE_PATTERN.test(line) && line.trim()))
107 return null;
108 row!.push({
109 header: tag === "th",
110 segments: splitBodyAt(content, htmlOffset + cell.contentStart),
111 });
112 cell = null;
113 } else {
114 if (html.slice(cursor, match.index).trim().length > 0) return null;
115 const tableOpen = opened && !closed;
116 if (tag === "table") {
117 if (closing ? !tableOpen || row !== null : opened) return null;
118 opened = true;
119 closed = closing;
120 } else if (tag === "tr") {
121 if (!tableOpen || closing === (row === null)) return null;
122 if (closing) rows.push(row!);
123 row = closing ? null : [];
124 } else if (tag === "td" || tag === "th") {
125 if (closing || row === null || CELL_SPAN_PATTERN.test(match[3]!)) return null;
126 cell = { tag, contentStart: match.index + match[0].length };
127 } else if (!tableOpen || row !== null) {
128 return null;
129 }
130 }
131 cursor = match.index + match[0].length;
132 }
133 if (!closed || html.slice(cursor).trim().length > 0) return null;
134 return rows.filter((cells) => cells.length > 0);
136​
137/**
138 * The table opening at `start`, through its matching close. `end` is the last line looked at
139 * even when the table is not usable, so the caller never re-reads an inner table on its own.
140 */
141function mediaTableAt(
142 lines: ReadonlyArray<string>,
143 start: number,
144 startOffset: number,
145): { end: number; rows: TableRows | null } {
146 const lastCandidate = Math.min(start + TABLE_MAX_LINES, lines.length) - 1;
147 let depth = 0;
148 let end = start;
149 for (; ; end += 1) {
150 const line = lines[end]!;
151 depth +=
152 (line.match(TABLE_OPEN_PATTERN)?.length ?? 0) -
153 (line.match(TABLE_CLOSE_PATTERN)?.length ?? 0);
154 if (depth <= 0 || end === lastCandidate) break;
155 }
156 if (depth !== 0 || !TABLE_END_PATTERN.test(lines[end]!)) return { end, rows: null };
157 // Lifting attachment links out of a table's cells would tear the table apart.
158 const rows = tableRows(lines.slice(start, end + 1).join("\n"), startOffset);
159 const hasMedia = rows?.some((cells) =>
160 cells.some((cell) => cell.segments.some((segment) => segment.kind === "attachment")),
161 );
162 return { end, rows: hasMedia ? rows : null };
164​
165export function splitPullRequestBody(body: string): ReadonlyArray<PullRequestBodySegment> {
166 return splitBodyAt(body, 0);
168​
169/** `baseOffset` is where `body` starts in the whole body, so a table cell's edits write back. */
170function splitBodyAt(body: string, baseOffset: number): ReadonlyArray<PullRequestBodySegment> {
171 const segments: PullRequestBodySegment[] = [];
172 const markdown: string[] = [];
173 let markdownStart = 0;
174 let openFence: string | null = null;
175​
176 // Blank lines around a run are dropped, but never leading spaces: four of them open an
177 // indented code block, so trimming a run would silently turn code into prose.
178 const flushMarkdown = () => {
179 const joined = markdown.join("\n");
180 const text = joined.replace(/^(?:\r?\n)+/u, "");
181 const sourceOffset = markdownStart + joined.length - text.length;
182 markdown.length = 0;
183 if (text.trim().length > 0) {
184 segments.push({
185 id: `markdown:${segments.length}`,
186 kind: "markdown",
187 text: text.replace(/\s+$/u, ""),
188 sourceOffset,
189 });
190 }
191 };
192​
193 const lines = body.split("\n");
194 let tableScannedThrough = -1;
195 let lineStart = baseOffset;
196 for (let index = 0; index < lines.length; lineStart += lines[index]!.length + 1, index += 1) {
197 const line = lines[index]!;
198 if (markdown.length === 0) markdownStart = lineStart;
199 const fenceMatch = FENCE_PATTERN.exec(line);
200 if (fenceMatch !== null) {
201 const fence = fenceMatch[1]!;
202 // A fence closes only on the same marker, at least as long as the one that opened it,
203 // and with nothing after it: `~~~` cannot end a ``` block, and ```` ```ts ```` is an
204 // info string opening a nested fence, not a close.
205 const closes =
206 openFence !== null &&
207 fence[0] === openFence[0] &&
208 fence.length >= openFence.length &&
209 fenceMatch[2]!.trim().length === 0;
210 if (openFence === null) {
211 openFence = fence;
212 } else if (closes) {
213 openFence = null;
214 }
215 markdown.push(line);
216 continue;
217 }
218 if (openFence !== null || INDENTED_CODE_PATTERN.test(line)) {
219 markdown.push(line);
220 continue;
221 }
222​
223 if (index > tableScannedThrough && TABLE_START_PATTERN.test(line)) {
224 const table = mediaTableAt(lines, index, lineStart);
225 tableScannedThrough = table.end;
226 if (table.rows !== null) {
227 flushMarkdown();
228 segments.push({ id: `table:${segments.length}`, kind: "table", rows: table.rows });
229 for (; index < table.end; index += 1) lineStart += lines[index]!.length + 1;
230 continue;
231 }
232 }
233​
234 const bareAttachment = attachmentFromLine(line);
235 if (bareAttachment !== null) {
236 flushMarkdown();
237 segments.push({ id: `attachment:${segments.length}`, kind: "attachment", ...bareAttachment });
238 continue;
239 }
240​
241 if (!STANDALONE_VIDEO_TAG_PATTERN.test(line)) {
242 markdown.push(line);
243 continue;
244 }
245 const lastCandidate = Math.min(index + VIDEO_TAG_MAX_LINES, lines.length) - 1;
246 let cursor = index;
247 while (cursor < lastCandidate && !VIDEO_TAG_END_PATTERN.test(lines[cursor]!)) {
248 cursor += 1;
249 }
250 const source = VIDEO_TAG_END_PATTERN.test(lines[cursor]!)
251 ? VIDEO_TAG_SRC_PATTERN.exec(lines.slice(index, cursor + 1).join("\n"))?.[1]
252 : undefined;
253 if (source !== undefined && isWebUrl(source)) {
254 flushMarkdown();
255 segments.push({
256 id: `attachment:${segments.length}`,
257 kind: "attachment",
258 url: source,
259​
260 media: "video",
261 });
262 for (; index < cursor; index += 1) lineStart += lines[index]!.length + 1;
263 } else {
264 // Unclosed, or nothing linkable in it: prose. Only this line is consumed, so the
265 // lines that were looked at are still parsed on their own terms.
266 markdown.push(line);
267 }
268 }
269​
270 flushMarkdown();
271 return segments;
273​
274/**
275 * Finds the references the change request's host links in plain text, resolved against the change
276 * request's repository. Null when the host links none.
277 */
278export function changeRequestReferenceAutolinker(
279 changeRequest: { readonly provider: string; readonly url: string } | null | undefined,
280): ((text: string) => ReadonlyArray<ReferenceAutolink>) | null {
281 if (!changeRequest) return null;
282 const find = sourceControlClients.get(changeRequest.provider).referenceAutolinks;
283 const repositoryUrl = sourceControlClients.parseChangeRequestUrl(changeRequest.url)?.link
284 .repositoryUrl;
285 return find === undefined || repositoryUrl === undefined
286 ? null
287 : (text) => find(text, repositoryUrl);
289​
290/**
291 * Forges link uploads root-relative, as GitCafe's `/api/attachments/<id>`. In a change request
292 * body that always means the forge's origin, never a path on the machine rendering it.
293 */
294export function resolveChangeRequestMediaUrl(url: string, changeRequestUrl: string): string {
295 if (!/^\/(?![/\\])/u.test(url)) return url;
296 try {
297 return new URL(url, changeRequestUrl).href;
298 } catch {
299 return url;
300 }