Skip to content
Source
Sign inSign up

generatedImageMarkdown.ts

69 lines3.0 KB

1import remarkParse from "remark-parse";
2import { unified } from "unified";
3​
4import { markdownDestinationHostPath } from "./markdownLinks.ts";
5import { chatMarkdownImageSources } from "./markdownPipeline.ts";
6import { isUncPath, isWindowsDrivePath, normalizeProjectPathForComparison } from "./path.ts";
7​
8const parser = unified().use(remarkParse);
9​
10function imageEmbed(path: string): string {
11 // Forward slashes read the same in every client parser; UNC paths also need file: to
12 // distinguish them from protocol-relative web URLs.
13 const destination = isWindowsDrivePath(path)
14 ? path.replaceAll("\\", "/")
15 : isUncPath(path)
16 ? `file:${path.replaceAll("\\", "/")}`
17 : path;
18 // Clients split `?`/`#`, decode `%` and `&` entities, and read POSIX backslashes as escapes;
19 // line breaks end an angle destination.
20 const escaped = destination.replace(/[%?#&\\\r\n]/g, encodeURIComponent).replace(/[<>]/g, "\\$&");
21 return `![](<${escaped}>)`;
23​
24// An unclosed fence or raw HTML block swallows the rest of the reply. Only a root-level block
25// can swallow appended embeds; nested ones end with their container.
26function unclosedBlockEnd(text: string): string | undefined {
27 const last = parser.parse(text).children.at(-1);
28 const source = text.slice(last?.position?.start.offset, last?.position?.end.offset);
29 if (last?.type === "html") {
30 const tag = /^ {0,3}<(script|pre|style|textarea)(?=[\s>]|$)/i.exec(source)?.[1];
31 if (tag !== undefined) {
32 return /<\/(?:script|pre|style|textarea)>/i.test(source) ? undefined : `</${tag}>`;
33 }
34 return /^ {0,3}<!--/.test(source) && !source.includes("-->") ? "-->" : undefined;
35 }
36 if (last?.type !== "code") return undefined;
37 const fence = /^ {0,3}(`{3,}|~{3,})/.exec(source)?.[1];
38 if (fence === undefined) return undefined;
39 const lines = source.split(/\r\n|\r|\n/);
40 const closing = /^ {0,3}(`{3,}|~{3,})[\t ]*$/.exec(lines.at(-1) ?? "")?.[1];
41 const closed =
42 lines.length > 1 &&
43 closing !== undefined &&
44 closing[0] === fence[0] &&
45 closing.length >= fence.length;
46 return closed ? undefined : fence;
48​
49/** Appends an embed for each saved image the reply does not already show. */
50export function appendGeneratedImageEmbeds(text: string, savedPaths: Iterable<string>): string {
51 const shown = new Set(
52 chatMarkdownImageSources(text).flatMap((source) => {
53 const path = markdownDestinationHostPath(source);
54 return path === null ? [] : [normalizeProjectPathForComparison(path)];
55 }),
56 );
57 const missing = new Map<string, string>();
58 for (const path of savedPaths) {
59 const key = normalizeProjectPathForComparison(path);
60 if (!shown.has(key) && !missing.has(key)) missing.set(key, path);
61 }
62 if (missing.size === 0) return text;
63 const end = unclosedBlockEnd(text);
64 let result = end === undefined ? text : `${text}${/[\r\n]$/.test(text) ? "" : "\n"}${end}`;
65 for (const path of missing.values()) {
66 result += `${result.length > 0 ? "\n\n" : ""}${imageEmbed(path)}`;
67 }
68 return result;