src / embedLocalImages.ts
import { readFile, stat } from "fs/promises";
import path from "path";
function guessImageMimeTypeByExt(filePath: string): string | null {
const ext = path.extname(filePath).toLowerCase();
switch (ext) {
case ".png":
return "image/png";
case ".jpg":
case ".jpeg":
return "image/jpeg";
case ".webp":
return "image/webp";
case ".gif":
return "image/gif";
case ".svg":
return "image/svg+xml";
case ".bmp":
return "image/bmp";
case ".tif":
case ".tiff":
return "image/tiff";
default:
return null;
}
}
function isEmbeddableUrl(url: string): boolean {
const u = url.trim();
if (!u) return false;
if (/^(data:|https?:|file:)/i.test(u)) return false;
if (/^embedded:/i.test(u)) return false;
return true;
}
function isPathWithinDir(candidatePath: string, baseDir: string): boolean {
const base = path.resolve(baseDir);
const cand = path.resolve(candidatePath);
if (cand === base) return true;
const baseWithSep = base.endsWith(path.sep) ? base : base + path.sep;
return cand.startsWith(baseWithSep);
}
async function fileToDataUrl(filePath: string): Promise<string | null> {
const mime = guessImageMimeTypeByExt(filePath);
if (!mime) return null;
try {
const s = await stat(filePath);
if (!s.isFile()) return null;
} catch {
return null;
}
try {
const buf = await readFile(filePath);
const b64 = buf.toString("base64");
return `data:${mime};base64,${b64}`;
} catch {
return null;
}
}
/**
* Replace Markdown image links that point to *local files under the chat working directory*
* with inline data: URIs.
*
* Only touches markdown image syntax: 
*/
export async function embedLocalImagesInMarkdown(markdown: string, workingDirectory: string): Promise<string> {
const md = String(markdown ?? "");
const wd = String(workingDirectory ?? "").trim();
if (!md || !wd) return md;
// Matches:
// - 
// - 
// - optional title: 
const re = /!\[([^\]]*)\]\(\s*(?:<([^>]+)>|([^\)\s]+))(?:\s+"([^"]*)")?\s*\)/g;
const cacheByResolvedPath = new Map<string, string>();
let out = "";
let lastIndex = 0;
for (;;) {
const m = re.exec(md);
if (!m) break;
const matchStart = m.index;
const matchText = m[0];
const alt = m[1] ?? "";
const url = (m[2] ?? m[3] ?? "").trim();
const title = typeof m[4] === "string" && m[4].length > 0 ? m[4] : null;
out += md.slice(lastIndex, matchStart);
lastIndex = matchStart + matchText.length;
if (!isEmbeddableUrl(url)) {
out += matchText;
continue;
}
const absolutePath = path.isAbsolute(url) ? url : path.resolve(wd, url);
if (!isPathWithinDir(absolutePath, wd)) {
out += matchText;
continue;
}
let dataUrl = cacheByResolvedPath.get(absolutePath);
if (!dataUrl) {
const computed = await fileToDataUrl(absolutePath);
if (!computed) {
out += matchText;
continue;
}
dataUrl = computed;
cacheByResolvedPath.set(absolutePath, dataUrl);
}
const titlePart = title != null ? ` "${title.replace(/"/g, "\\\"")}"` : "";
out += ``;
}
out += md.slice(lastIndex);
return out;
}