From 62ae95504471633143da7e7bf20dec0d5b32a96a Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 19 Jun 2026 02:12:58 +0000 Subject: [PATCH] feat: publish orchestrator wiring transforms to GitClient Co-Authored-By: Claude --- src/publish.test.ts | 65 ++++++++++++++++++++++++++++++++++++++++ src/publish.ts | 72 +++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 137 insertions(+) create mode 100644 src/publish.test.ts create mode 100644 src/publish.ts diff --git a/src/publish.test.ts b/src/publish.test.ts new file mode 100644 index 0000000..d1a7f37 --- /dev/null +++ b/src/publish.test.ts @@ -0,0 +1,65 @@ +import { expect, test, vi } from "vitest"; +import { publish } from "./publish"; +import { DEFAULT_SETTINGS } from "./settings"; +import type { GitClient, GitFile } from "./git"; + +function fakeGit() { + const calls: { files: GitFile[]; message?: string } = { files: [] }; + const git: GitClient = { + syncClone: vi.fn(async () => {}), + writeFiles: vi.fn(async (files) => { calls.files = files; }), + commitAndPush: vi.fn(async (o) => { calls.message = o.message; }), + }; + return { git, calls }; +} + +test("writes post + resolved image, rewrites body, reports counts", async () => { + const { git, calls } = fakeGit(); + const result = await publish( + { + noteText: "---\nlayout: post\n---\n\n![[shot.png]]\nbody", + frontmatterPairs: [{ key: "layout", value: "post" }], + slug: "my-post", + date: "2026-06-19", + strategy: "flat-slug", + commitMessage: "Publish: My Post", + }, + { ...DEFAULT_SETTINGS, remoteUrl: "ssh://x/y.git" }, + git, + async () => Buffer.from([9]), + ); + + expect(result.postPath).toBe("_posts/2026-06-19-my-post.md"); + expect(result.imageCount).toBe(1); + expect(result.unresolved).toEqual([]); + const post = calls.files.find((f) => f.repoPath === "_posts/2026-06-19-my-post.md")!; + expect(post.data).toContain("![](/assets/img/my-post.png)"); + expect(calls.files.some((f) => f.repoPath === "assets/img/my-post.png")).toBe(true); + expect(calls.message).toBe("Publish: My Post"); +}); + +test("unresolved images are reported and left in the body", async () => { + const { git } = fakeGit(); + const result = await publish( + { + noteText: "![[missing.png]]", + frontmatterPairs: [], + slug: "p", date: "2026-06-19", strategy: "flat-slug", commitMessage: "m", + }, + { ...DEFAULT_SETTINGS, remoteUrl: "ssh://x" }, + git, + async () => null, + ); + expect(result.unresolved).toEqual(["missing.png"]); + expect(result.imageCount).toBe(0); +}); + +test("throws when remoteUrl is empty", async () => { + const { git } = fakeGit(); + await expect( + publish( + { noteText: "x", frontmatterPairs: [], slug: "p", date: "2026-06-19", strategy: "flat-slug", commitMessage: "m" }, + DEFAULT_SETTINGS, git, async () => null, + ), + ).rejects.toThrow(/remote/i); +}); diff --git a/src/publish.ts b/src/publish.ts new file mode 100644 index 0000000..8d655a8 --- /dev/null +++ b/src/publish.ts @@ -0,0 +1,72 @@ +import { parseNote, Pair } from "./frontmatter"; +import { buildPost } from "./buildPost"; +import { postFilename } from "./slug"; +import { findImageRefs, planImages, Strategy } from "./images"; +import { GitClient, GitFile } from "./git"; +import { JekyllPublishSettings } from "./settings"; + +export interface PublishInput { + noteText: string; + frontmatterPairs: Pair[]; + slug: string; + date: string; + strategy: Strategy; + commitMessage: string; +} + +export type ImageResolver = (linktext: string) => Promise; + +export interface PublishResult { + postPath: string; + imageCount: number; + unresolved: string[]; +} + +export async function publish( + input: PublishInput, + settings: JekyllPublishSettings, + git: GitClient, + resolveImage: ImageResolver +): Promise { + if (!settings.remoteUrl.trim()) throw new Error("No git remote URL configured"); + + const { body } = parseNote(input.noteText); + const refs = findImageRefs(body); + + const resolved: { linktext: string; data: Buffer }[] = []; + const unresolved: string[] = []; + for (const ref of refs) { + if (resolved.some((r) => r.linktext === ref.linktext)) continue; + const data = await resolveImage(ref.linktext); + if (data) resolved.push({ linktext: ref.linktext, data }); + else unresolved.push(ref.linktext); + } + + const usableRefs = refs.filter((r) => resolved.some((x) => x.linktext === r.linktext)); + const { rewrittenBody, plan } = planImages(usableRefs, { + slug: input.slug, + strategy: input.strategy, + imagesDir: settings.imagesDir, + body, + }); + + const postText = buildPost({ frontmatterPairs: input.frontmatterPairs, body: rewrittenBody }); + const postPath = `${settings.postsDir}/${postFilename({ date: input.date, slug: input.slug })}`; + + const files: GitFile[] = [{ repoPath: postPath, data: postText }]; + for (const item of plan) { + const r = resolved.find((x) => x.linktext === item.linktext)!; + files.push({ repoPath: item.repoPath, data: r.data }); + } + + await git.syncClone({ url: settings.remoteUrl, branch: settings.branch }); + await git.writeFiles(files); + await git.commitAndPush({ + message: input.commitMessage, + branch: settings.branch, + authorName: settings.authorName || undefined, + authorEmail: settings.authorEmail || undefined, + }); + + return { postPath, imageCount: plan.length, unresolved }; +}