export type Strategy = "flat-slug" | "per-post-folder"; export interface ImageRef { raw: string; linktext: string; alt: string; kind: "embed" | "markdown" | "html"; } export interface ImagePlanItem { linktext: string; repoPath: string; siteUrl: string; } export interface PlanResult { rewrittenBody: string; plan: ImagePlanItem[]; } const IMG_EXT = /\.(png|jpe?g|gif|webp|svg|avif|bmp|tiff?)$/i; function isLocal(path: string): boolean { return !/^[a-z]+:\/\//i.test(path) && !path.startsWith("/") && IMG_EXT.test(path); } export function findImageRefs(body: string): ImageRef[] { const refs: ImageRef[] = []; // Obsidian embed syntax: ![[linktext]] or ![[linktext|alt]] const embed = /!\[\[([^\]|#^]+?)(?:#[^\]|]*)?(?:\|([^\]]*))?\]\]/g; for (let m; (m = embed.exec(body)); ) { if (!isLocal(m[1].trim())) continue; refs.push({ raw: m[0], linktext: m[1].trim(), alt: (m[2] ?? "").trim(), kind: "embed" }); } // Markdown image: ![alt](path) const md = /!\[([^\]]*)\]\(([^)\s]+)(?:\s+"[^"]*")?\)/g; for (let m; (m = md.exec(body)); ) { if (!isLocal(m[2])) continue; refs.push({ raw: m[0], linktext: m[2], alt: m[1], kind: "markdown" }); } // HTML img: ... const html = /]*?\bsrc=["']([^"']+)["'][^>]*?>/gi; for (let m; (m = html.exec(body)); ) { if (!isLocal(m[1])) continue; const altM = /\balt=["']([^"']*)["']/i.exec(m[0]); refs.push({ raw: m[0], linktext: m[1], alt: altM ? altM[1] : "", kind: "html" }); } // Order refs by position of their raw match for deterministic numbering. return refs.sort((a, b) => body.indexOf(a.raw) - body.indexOf(b.raw)); } function ext(path: string): string { const m = IMG_EXT.exec(path); return m ? m[0] : ""; } function basename(path: string): string { const parts = path.split("/"); return parts[parts.length - 1]; } export function rewriteBody(body: string, refs: ImageRef[], plan: ImagePlanItem[]): string { const url = new Map(plan.map((p) => [p.linktext, p.siteUrl])); let out = body; for (const ref of refs) { const siteUrl = url.get(ref.linktext); if (!siteUrl) continue; out = out.replace(ref.raw, `![${ref.alt}](${siteUrl})`); } return out; } export function planImages( refs: ImageRef[], opts: { slug: string; strategy: Strategy; imagesDir: string; body: string } ): PlanResult { const { slug, strategy, imagesDir, body } = opts; const byLinktext = new Map(); const usedNames = new Set(); const distinct = refs.filter((r, i) => refs.findIndex((o) => o.linktext === r.linktext) === i); distinct.forEach((ref, i) => { let repoRel: string; if (strategy === "flat-slug") { const suffix = distinct.length > 1 ? `-${i + 1}` : ""; repoRel = `${slug}${suffix}${ext(ref.linktext)}`; } else { let name = basename(ref.linktext); while (usedNames.has(`${slug}/${name}`)) { const e = ext(name); name = `${name.slice(0, name.length - e.length)}-1${e}`; } usedNames.add(`${slug}/${name}`); repoRel = `${slug}/${name}`; } const repoPath = `${imagesDir}/${repoRel}`; byLinktext.set(ref.linktext, { linktext: ref.linktext, repoPath, siteUrl: `/${repoPath}` }); }); const plan = distinct.map((r) => byLinktext.get(r.linktext)!); return { plan, rewrittenBody: rewriteBody(body, refs, plan) }; }