1import type { Processor } from "unified";
2import { isWindowsAbsolutePath } from "./path.ts";
3import type { PluggableList } from "unified";
4import rehypeRaw from "rehype-raw";
5import rehypeSanitize, { defaultSchema } from "rehype-sanitize";
6import remarkBreaks from "remark-breaks";
7import remarkGfm from "remark-gfm";
8import { remarkGithubAlerts } from "./markdownGithubAlerts.ts";
9import { remarkNormalizeListItemIndentation } from "./markdownListIndentation.ts";
10import {
11 CODEX_ARTIFACT_TEMPLATE_HAST_PROPERTIES,
12 remarkCodexDirectives,
13} from "./codexMarkdownDirectives.ts";
14import { isWindowsDrivePathHref } from "./markdownLinks.ts";
15import { THREAD_LINK_PROTOCOL } from "./threadLinks.ts";
16
17type MarkdownImageHastNode = {
18 type?: string;
19 value?: string;
20 tagName?: string;
21 properties?: Record<string, unknown>;
22 children?: MarkdownImageHastNode[];
23};
24
25function meaningfulHastChildren(node: MarkdownImageHastNode): MarkdownImageHastNode[] {
26 return (node.children ?? []).filter(
27 (child) => !(child.type === "text" && (child as { value?: string }).value?.trim() === ""),
28 );
29}
30
31/**
32 * An image that is the only content of its block (optionally wrapped in a
33 * link) is almost always a screenshot or figure, so it gets a reserved slot
34 * while it loads. Images mixed with text or other images — badge rows, icons
35 * in a sentence — stay inline at their natural size, since a placeholder taller
36 * than the image would move the page more than the image itself does.
37 */
38/** Containers whose sole child image reads as a figure rather than part of a sentence. */
39const STANDALONE_IMAGE_BLOCKS = new Set([
40 "p",
41 "div",
42 "li",
43 "td",
44 "th",
45 "figure",
46 "center",
47 "blockquote",
48]);
49
50function soleImageDescendant(node: MarkdownImageHastNode): MarkdownImageHastNode | undefined {
51 const children = meaningfulHastChildren(node);
52 if (children.length !== 1) return undefined;
53 const only = children[0];
54 if (only?.type !== "element") return undefined;
55 if (only.tagName === "img") return only;
56 // A link, emphasis, or similar inline wrapper around the image still counts
57 // as long as nothing else shares the block.
58 return only.tagName === "a" || only.tagName === "strong" || only.tagName === "em"
59 ? soleImageDescendant(only)
60 : undefined;
61}
62
63function markStandaloneImages(node: MarkdownImageHastNode) {
64 // A raw `<img>` on its own line reaches the root without a paragraph.
65 if (node.type === "root" || (node.tagName && STANDALONE_IMAGE_BLOCKS.has(node.tagName))) {
66 const image = soleImageDescendant(node);
67 if (image) image.properties = { ...image.properties, dataStandalone: true };
68 }
69 node.children?.forEach((child) => {
70 if (child.type === "element") markStandaloneImages(child);
71 });
72}
73
74/** Keep unmatched inline `<A>` placeholders from opening an HTML link over later blocks. */
75function rehypePreserveBareAnchorPlaceholders() {
76 return (tree: MarkdownImageHastNode) => {
77 const anchors: Array<MarkdownImageHastNode | null> = [];
78 let rawTextTag: string | undefined;
79 const visit = (node: MarkdownImageHastNode) => {
80 if (node.type === "raw" && typeof node.value === "string") {
81 // Raw blocks can contain several tags. Consume whole tags, quoted attributes,
82 // and comments so text resembling a closing anchor cannot pair a placeholder.
83 const tags = /<!--[\s\S]*?(?:-->|$)|<\/?[A-Za-z](?:[^"'<>]|"[^"]*"|'[^']*')*>/g;
84 let offset = 0;
85 while (rawTextTag !== "plaintext") {
86 // Raw text ends at its closing tag even inside comment-looking text.
87 const matcher = rawTextTag ? new RegExp(`</${rawTextTag}\\s*>`, "gi") : tags;
88 matcher.lastIndex = offset;
89 const match = matcher.exec(node.value);
90 if (!match) break;
91 const [tag] = match;
92 offset = matcher.lastIndex;
93 if (rawTextTag) {
94 rawTextTag = undefined;
95 continue;
96 }
97 if (tag.startsWith("<!--")) continue;
98 const closing = /^<\/([a-z]+)\s*>$/i.exec(tag)?.[1]?.toLowerCase();
99 const opening = /^<([a-z]+)(?:\s|\/?>)/i.exec(tag)?.[1]?.toLowerCase();
100 if (
101 opening &&
102 /^(?:script|style|textarea|title|xmp|iframe|noembed|noframes|plaintext)$/.test(opening)
103 ) {
104 rawTextTag = opening;
105 } else if (opening === "a") {
106 anchors.push(node.value === tag && /^<a\s*\/?>$/i.test(tag) ? node : null);
107 } else if (closing === "a") {
108 anchors.pop();
109 }
110 }
111 }
112 node.children?.forEach(visit);
113 };
114
115 visit(tree);
116 for (const anchor of anchors) {
117 if (anchor) anchor.type = "text";
118 }
119 };
120}
121
122/** Carries authored image source metadata through the sanitizer to the image renderer. */
123function rehypePreserveImageSourceMeta() {
124 return (tree: MarkdownImageHastNode) => {
125 const visit = (node: MarkdownImageHastNode) => {
126 const src = node.properties?.src;
127 const title = node.properties?.title;
128 if (node.type === "element" && node.tagName === "img") {
129 node.properties = {
130 ...node.properties,
131 ...(typeof src === "string" && isWindowsDrivePathHref(src) ? { dataLocalSrc: src } : {}),
132 ...(typeof title === "string" ? { dataMarkdownTitle: title } : {}),
133 };
134 }
135 node.children?.forEach(visit);
136 };
137
138 visit(tree);
139 markStandaloneImages(tree);
140 };
141}
142
143const CHAT_MARKDOWN_SANITIZE_SCHEMA = {
144 ...defaultSchema,
145 tagNames: [...(defaultSchema.tagNames ?? []), "mark", "u"],
146 attributes: {
147 ...defaultSchema.attributes,
148 "*": (defaultSchema.attributes?.["*"] ?? []).filter((attribute) => attribute !== "title"),
149 code: [...(defaultSchema.attributes?.code ?? []), "dataCodeMeta", "dataInlineCode"],
150 blockquote: [...(defaultSchema.attributes?.blockquote ?? []), "dataAlert"],
151 div: [...(defaultSchema.attributes?.div ?? []), ...CODEX_ARTIFACT_TEMPLATE_HAST_PROPERTIES],
152 a: [...(defaultSchema.attributes?.a ?? []), "dataPullRequestAutolink"],
153 img: [
154 ...(defaultSchema.attributes?.img ?? []),
155 "dataLocalSrc",
156 "dataMarkdownTitle",
157 "dataStandalone",
158 ],
159 },
160 protocols: {
161 ...defaultSchema.protocols,
162 href: [
163 ...(defaultSchema.protocols?.href ?? []),
164 "file",
165 "t3-citation",
166 "t3-context",
167 THREAD_LINK_PROTOCOL,
168 ],
169 src: [...(defaultSchema.protocols?.src ?? []), "file", "t3-context"],
170 },
171} satisfies Parameters<typeof rehypeSanitize>[0];
172
173export const CHAT_MARKDOWN_REMARK_PLUGINS: PluggableList = [
174 remarkGfm,
175 remarkKeepWindowsPathDestinations,
176 remarkGithubAlerts,
177 remarkNormalizeListItemIndentation,
178 remarkCodexDirectives,
179 remarkPreserveCodeMeta,
180 remarkNormalizeLinksAndTagInlineCode,
181];
182
183export const CHAT_MARKDOWN_REMARK_PLUGINS_WITH_BREAKS: PluggableList = [
184 remarkGfm,
185 remarkKeepWindowsPathDestinations,
186 remarkGithubAlerts,
187 remarkNormalizeListItemIndentation,
188 remarkCodexDirectives,
189 remarkBreaks,
190 remarkPreserveCodeMeta,
191 remarkNormalizeLinksAndTagInlineCode,
192];
193
194export const CHAT_MARKDOWN_REHYPE_PLUGINS: PluggableList = [
195 rehypePreserveBareAnchorPlaceholders,
196 rehypeRaw,
197 rehypePreserveImageSourceMeta,
198 [rehypeSanitize, CHAT_MARKDOWN_SANITIZE_SCHEMA],
199];
200
201type MarkdownAstNode = {
202 type?: string;
203 meta?: unknown;
204 url?: string;
205 data?: {
206 hProperties?: Record<string, unknown>;
207 };
208 children?: MarkdownAstNode[];
209};
210
211function remarkPreserveCodeMeta() {
212 return (tree: MarkdownAstNode) => {
213 const visit = (node: MarkdownAstNode) => {
214 if (node.type === "code" && typeof node.meta === "string" && node.meta.trim().length > 0) {
215 node.data = {
216 ...node.data,
217 hProperties: {
218 ...node.data?.hProperties,
219 dataCodeMeta: node.meta.trim(),
220 },
221 };
222 }
223 node.children?.forEach(visit);
224 };
225
226 visit(tree);
227 };
228}
229
230/**
231 * Preserve Windows drive links as allowed `file:` URLs before sanitization.
232 * The same traversal tags inline code while it can still be distinguished
233 * from fenced code. Code inside links stays untagged to avoid nested anchors.
234 */
235function remarkNormalizeLinksAndTagInlineCode() {
236 return (tree: MarkdownAstNode) => {
237 const visit = (node: MarkdownAstNode, insideLink: boolean) => {
238 if (
239 (node.type === "link" || node.type === "definition") &&
240 typeof node.url === "string" &&
241 /^[A-Za-z]:[\\/]/.test(node.url)
242 ) {
243 node.url = `file:///${node.url.replaceAll("\\", "/")}`;
244 }
245 if (node.type === "inlineCode" && !insideLink) {
246 node.data = {
247 ...node.data,
248 hProperties: {
249 ...node.data?.hProperties,
250 dataInlineCode: "",
251 },
252 };
253 }
254 const childInsideLink = insideLink || node.type === "link" || node.type === "linkReference";
255 node.children?.forEach((child) => visit(child, childInsideLink));
256 };
257
258 visit(tree, false);
259 };
260}
261
262interface DestinationCompileContext {
263 readonly stack: ReadonlyArray<{ readonly type: string; url?: string }>;
264 resume(): string;
265 sliceSerialize(token: unknown): string;
266}
267
268function keepWindowsPathDestination(this: DestinationCompileContext, token: unknown) {
269 const decoded = this.resume();
270 const authored = this.sliceSerialize(token);
271 const node = this.stack.at(-1);
272 // Character references still need decoding, so those destinations keep the parsed URL.
273 if (node)
274 node.url = isWindowsAbsolutePath(authored) && !authored.includes("&") ? authored : decoded;
275}
276
277/**
278 * CommonMark reads the `\.` in `C:\me\.t3\shot.png` as an escape, even in a link
279 * destination. Every backslash in a Windows path is a separator, so link, image, and
280 * definition destinations that are Windows paths keep the text as written.
281 */
282function remarkKeepWindowsPathDestinations(this: Processor) {
283 const data = this.data();
284 (data.fromMarkdownExtensions ??= []).push({
285 exit: {
286 resourceDestinationString: keepWindowsPathDestination,
287 definitionDestinationString: keepWindowsPathDestination,
288 },
289 });
290}
291
292export function shouldPreserveAssistantLineBreaks(text: string): boolean {
293 return /^★ Insight(?:\s|─)/mu.test(text);
294}