feat: image detection, naming strategies, body rewrite
Co-Authored-By: Claude
This commit is contained in:
70
src/images.test.ts
Normal file
70
src/images.test.ts
Normal 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]]",
|
||||
"",
|
||||
'<img src="diagram.svg" alt="d">',
|
||||
"",
|
||||
"",
|
||||
].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<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(
|
||||
"\n\n"
|
||||
);
|
||||
});
|
||||
});
|
||||
106
src/images.ts
Normal file
106
src/images.ts
Normal 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: 
|
||||
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, ``);
|
||||
}
|
||||
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) };
|
||||
}
|
||||
Reference in New Issue
Block a user