From 55faa84f29fcc10295a265ec50c998d6a1707f50 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 19 Jun 2026 01:53:40 +0000 Subject: [PATCH] feat: image detection, naming strategies, body rewrite Co-Authored-By: Claude --- src/images.test.ts | 70 ++++++++++++++++++++++++++++++ src/images.ts | 106 +++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 176 insertions(+) create mode 100644 src/images.test.ts create mode 100644 src/images.ts diff --git a/src/images.test.ts b/src/images.test.ts new file mode 100644 index 0000000..2c6d5a0 --- /dev/null +++ b/src/images.test.ts @@ -0,0 +1,70 @@ +import { describe, expect, test } from "vitest"; +import { findImageRefs, planImages, rewriteBody } from "./images"; + +describe("findImageRefs", () => { + test("detects embeds, markdown, html; ignores external and site-absolute", () => { + const body = [ + "![[shot.png]]", + "![cap](pics/local.jpeg)", + 'd', + "![remote](https://x.com/a.png)", + "![done](/assets/img/already.png)", + ].join("\n"); + const refs = findImageRefs(body); + expect(refs.map((r) => r.linktext)).toEqual(["shot.png", "pics/local.jpeg", "diagram.svg"]); + expect(refs.map((r) => r.kind)).toEqual(["embed", "markdown", "html"]); + expect(refs[1].alt).toBe("cap"); + }); + test("embed alias becomes alt text", () => { + expect(findImageRefs("![[a.png|My alt]]")[0].alt).toBe("My alt"); + }); +}); + +describe("planImages flat-slug", () => { + test("single image drops the numeric suffix", () => { + const body = "![[only.png]]"; + const refs = findImageRefs(body); + const { rewrittenBody, plan } = planImages(refs, { slug: "my-post", strategy: "flat-slug", imagesDir: "assets/img", body }); + expect(plan).toEqual([{ linktext: "only.png", repoPath: "assets/img/my-post.png", siteUrl: "/assets/img/my-post.png" }]); + expect(rewrittenBody).toBe("![](/assets/img/my-post.png)"); + }); + test("multiple images get -1, -2 suffixes and body is rewritten", () => { + const body = "![[a.png]]\n![alt](b.jpeg)"; + const refs = findImageRefs(body); + const { rewrittenBody, plan } = planImages(refs, { slug: "post", strategy: "flat-slug", imagesDir: "assets/img", body }); + expect(plan.map((p) => p.repoPath)).toEqual(["assets/img/post-1.png", "assets/img/post-2.jpeg"]); + expect(rewrittenBody).toBe("![](/assets/img/post-1.png)\n![alt](/assets/img/post-2.jpeg)"); + }); +}); + +describe("planImages per-post-folder", () => { + test("keeps original basename under a slug folder", () => { + const body = "![[sub/dir/Photo.PNG|cap]]"; + const refs = findImageRefs(body); + const { plan, rewrittenBody } = planImages(refs, { slug: "post", strategy: "per-post-folder", imagesDir: "assets/img", body }); + expect(plan[0].repoPath).toBe("assets/img/post/Photo.PNG"); + expect(rewrittenBody).toBe("![cap](/assets/img/post/Photo.PNG)"); + }); + test("basename collisions are de-duped with -1", () => { + const body = "![[x/p.png]]\n![[y/p.png]]"; + const refs = findImageRefs(body); + const { plan } = planImages(refs, { slug: "post", strategy: "per-post-folder", imagesDir: "assets/img", body }); + expect(plan.map((p) => p.repoPath)).toEqual(["assets/img/post/p.png", "assets/img/post/p-1.png"]); + }); +}); + +describe("rewriteBody", () => { + test("rewrites all ref types to markdown image syntax", () => { + const body = '![[shot.png]]\n![cap](pics/local.jpeg)\nd'; + const refs = findImageRefs(body); + const plan = [ + { linktext: "shot.png", repoPath: "assets/img/post-1.png", siteUrl: "/assets/img/post-1.png" }, + { linktext: "pics/local.jpeg", repoPath: "assets/img/post-2.jpeg", siteUrl: "/assets/img/post-2.jpeg" }, + { linktext: "diagram.svg", repoPath: "assets/img/post-3.svg", siteUrl: "/assets/img/post-3.svg" }, + ]; + const result = rewriteBody(body, refs, plan); + expect(result).toBe( + "![](/assets/img/post-1.png)\n![cap](/assets/img/post-2.jpeg)\n![d](/assets/img/post-3.svg)" + ); + }); +}); diff --git a/src/images.ts b/src/images.ts new file mode 100644 index 0000000..2011c66 --- /dev/null +++ b/src/images.ts @@ -0,0 +1,106 @@ +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) }; +}