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

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) };
}