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]]",
+ "",
+ '
',
+ "",
+ "",
+ ].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("");
+ });
+ test("multiple images get -1, -2 suffixes and body is rewritten", () => {
+ const body = "![[a.png]]\n";
+ 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("\n");
+ });
+});
+
+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("");
+ });
+ 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\n
';
+ 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(
+ "\n\n"
+ );
+ });
+});
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: 
+ 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, ``);
+ }
+ 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) };
+}