feat: image detection, naming strategies, body rewrite

Co-Authored-By: Claude
This commit is contained in:
2026-06-19 01:53:40 +00:00
parent 0816e1d5db
commit 55faa84f29
2 changed files with 176 additions and 0 deletions

70
src/images.test.ts Normal file
View File

@@ -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)",
'<img src="diagram.svg" alt="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)\n<img src="diagram.svg" alt="d">';
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)"
);
});
});

106
src/images.ts Normal file
View File

@@ -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: <img src="..." alt="...">
const html = /<img\b[^>]*?\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<string, ImagePlanItem>();
const usedNames = new Set<string>();
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) };
}