# obsidian-jekyll-publish Implementation Plan > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. **Goal:** A desktop Obsidian plugin that publishes the active note as a Jekyll `_posts/YYYY-MM-DD-slug.md` (plus images), committed and pushed to any git remote via the system `git` CLI. **Architecture:** A small pure core (frontmatter, slug, images, buildPost) is heavily unit-tested; side effects (vault IO, git shell-out, credential prompts) live at the edges behind interfaces. A review modal gathers frontmatter/slug/strategy before any commit. Mirrors the `obsidian-multi-vault-links` layout, build, and test setup. **Tech Stack:** TypeScript, esbuild, vitest (unit + git integration), Playwright + Obsidian (E2E smoke), `js-yaml`, Node `child_process`. ## Global Constraints - Plugin id: `jekyll-publish`; manifest `isDesktopOnly: true`, `minAppVersion: 1.5.0`. - Pure modules (`frontmatter.ts`, `slug.ts`, `images.ts`, `buildPost.ts`) MUST NOT import `obsidian` or Node built-ins — they stay unit-testable in plain node. - Vitest config includes only `src/**/*.test.ts`; Playwright specs live in `e2e/` and run via `npm run e2e`. - No secrets persisted: credentials come from system git config / ssh-agent; prompts bridge to Obsidian UI in-memory only. - Commit trailer: `Co-Authored-By: Claude`. - Frontmatter is modeled as ordered `Pair[]` (`{ key: string; value: string }`), never a plain object, for deterministic output. - Image URLs emitted are site-absolute: `//`. --- ## File Structure | File | Responsibility | |------|----------------| | `src/frontmatter.ts` | parse / resolve (presets+merge+custom) / serialize YAML frontmatter | | `src/slug.ts` | slugify, derive slug & date, post filename | | `src/images.ts` | detect refs, plan renames (2 strategies), rewrite body | | `src/buildPost.ts` | assemble final post text | | `src/settings.ts` | settings type + `DEFAULT_SETTINGS` | | `src/git.ts` | `GitClient` interface + `ChildProcessGitClient` | | `src/askpass.ts` | GIT_ASKPASS helper + IPC bridge to a prompt callback | | `src/publish.ts` | orchestrator: transforms → GitClient | | `src/PublishModal.ts` | review modal UI | | `src/SettingsTab.ts` | settings UI | | `src/main.ts` | plugin entry + command | | `manifest.json`, `esbuild.config.mjs`, `vitest.config.ts`, `tsconfig.json`, `styles.css` | scaffold | | `e2e/*`, `scripts/e2e.sh`, `playwright.config.ts` | E2E (ported from multi-vault) | | `.gitea/workflows/release.yml` | release | --- ## Task 1: Project scaffold **Files:** - Create: `package.json`, `tsconfig.json`, `vitest.config.ts`, `esbuild.config.mjs`, `manifest.json`, `styles.css`, `src/_smoke.test.ts` **Interfaces:** - Consumes: nothing - Produces: a working `npm test` harness for all later tasks. - [ ] **Step 1: Write `package.json`** ```json { "name": "obsidian-jekyll-publish", "version": "0.1.0", "description": "Publish the active Obsidian note as a Jekyll post (with images) via git.", "main": "dist/main.js", "type": "module", "scripts": { "dev": "node esbuild.config.mjs", "build": "tsc -noEmit -skipLibCheck && node esbuild.config.mjs production", "test": "vitest run", "test:watch": "vitest", "e2e": "bash scripts/e2e.sh" }, "license": "MIT", "devDependencies": { "@playwright/test": "^1.60.0", "@types/js-yaml": "^4.0.9", "@types/node": "^20.11.0", "builtin-modules": "^3.3.0", "esbuild": "^0.20.0", "obsidian": "^1.5.7", "typescript": "^5.4.0", "vitest": "^1.4.0" }, "dependencies": { "js-yaml": "^4.1.0" } } ``` - [ ] **Step 2: Write `tsconfig.json`, `vitest.config.ts`, `manifest.json`, `styles.css`, `esbuild.config.mjs`** `tsconfig.json`: ```json { "compilerOptions": { "baseUrl": ".", "inlineSourceMap": true, "inlineSources": true, "module": "ESNext", "target": "ES2020", "allowJs": true, "noImplicitAny": true, "moduleResolution": "node", "importHelpers": true, "isolatedModules": true, "strictNullChecks": true, "strict": true, "esModuleInterop": true, "lib": ["DOM", "ES2020"] }, "include": ["src/**/*.ts"] } ``` `vitest.config.ts`: ```ts import { defineConfig } from "vitest/config"; export default defineConfig({ test: { include: ["src/**/*.test.ts"] }, }); ``` `manifest.json`: ```json { "id": "jekyll-publish", "name": "Jekyll Publish", "version": "0.1.0", "minAppVersion": "1.5.0", "description": "Publish the active note as a Jekyll post (with images) via git.", "author": "Claude", "isDesktopOnly": true } ``` `styles.css`: ```css .jekyll-publish-row { display: flex; gap: 8px; margin-bottom: 6px; } .jekyll-publish-row input { flex: 1; } ``` `esbuild.config.mjs` (ported from multi-vault, output to `dist/`): ```js import esbuild from "esbuild"; import process from "process"; import builtins from "builtin-modules"; import { copyFileSync, mkdirSync } from "fs"; const prod = process.argv[2] === "production"; mkdirSync("dist", { recursive: true }); for (const f of ["manifest.json", "styles.css"]) copyFileSync(f, `dist/${f}`); const ctx = await esbuild.context({ entryPoints: ["src/main.ts"], bundle: true, external: ["obsidian", "electron", ...builtins], format: "cjs", target: "es2020", logLevel: "info", sourcemap: prod ? false : "inline", treeShaking: true, outfile: "dist/main.js", platform: "node", }); if (prod) { await ctx.rebuild(); process.exit(0); } else { await ctx.watch(); } ``` - [ ] **Step 3: Write the smoke test** — `src/_smoke.test.ts` ```ts import { expect, test } from "vitest"; test("vitest harness works", () => { expect(1 + 1).toBe(2); }); ``` - [ ] **Step 4: Install deps and run** Run: `npm install && npm test` Expected: install succeeds; 1 test passes. - [ ] **Step 5: Commit** ```bash git add -A git commit -m "chore: scaffold obsidian-jekyll-publish Co-Authored-By: Claude" ``` --- ## Task 2: `frontmatter.ts` **Files:** - Create: `src/frontmatter.ts`, `src/frontmatter.test.ts` **Interfaces:** - Produces: - `interface Pair { key: string; value: string }` - `parseNote(text: string): { frontmatter: Record; body: string }` - `resolveFrontmatter(presets: Pair[], docFrontmatter: Record, custom: Pair[], mergeDoc: boolean): Pair[]` - `serializeFrontmatter(pairs: Pair[]): string` - [ ] **Step 1: Write the failing test** — `src/frontmatter.test.ts` ```ts import { describe, expect, test } from "vitest"; import { parseNote, resolveFrontmatter, serializeFrontmatter } from "./frontmatter"; describe("parseNote", () => { test("splits frontmatter and body, strips BOM", () => { const { frontmatter, body } = parseNote("---\ntitle: Hi\n---\nHello\n"); expect(frontmatter).toEqual({ title: "Hi" }); expect(body).toBe("Hello\n"); }); test("no frontmatter returns empty object and full body", () => { expect(parseNote("Just text")).toEqual({ frontmatter: {}, body: "Just text" }); }); }); describe("resolveFrontmatter", () => { const presets = [{ key: "layout", value: "post" }, { key: "kind", value: "essay" }]; test("presets only when mergeDoc is false", () => { expect(resolveFrontmatter(presets, { title: "X" }, [], false)).toEqual(presets); }); test("merge appends doc keys and doc overrides preset value", () => { const r = resolveFrontmatter(presets, { kind: "note", title: "X" }, [], true); expect(r).toEqual([ { key: "layout", value: "post" }, { key: "kind", value: "note" }, { key: "title", value: "X" }, ]); }); test("custom rows come last and override everything", () => { const r = resolveFrontmatter(presets, {}, [{ key: "layout", value: "page" }], false); expect(r[0]).toEqual({ key: "layout", value: "page" }); expect(r).toHaveLength(2); }); test("date object is rendered as YYYY-MM-DD", () => { const r = resolveFrontmatter([], { date: new Date("2026-06-11T00:00:00Z") }, [], true); expect(r).toEqual([{ key: "date", value: "2026-06-11" }]); }); }); describe("serializeFrontmatter", () => { test("bare-safe values unquoted, others JSON-quoted", () => { const out = serializeFrontmatter([ { key: "layout", value: "post" }, { key: "date", value: "2026-06-11" }, { key: "title", value: "On making a game" }, { key: "description", value: "And how it's different" }, ]); expect(out).toBe( `---\nlayout: post\ndate: 2026-06-11\ntitle: On making a game\ndescription: "And how it's different"\n---\n` ); }); test("empty value becomes quoted empty string", () => { expect(serializeFrontmatter([{ key: "tags", value: "" }])).toBe(`---\ntags: ""\n---\n`); }); }); ``` - [ ] **Step 2: Run to verify it fails** Run: `npx vitest run src/frontmatter.test.ts` Expected: FAIL (`Cannot find module './frontmatter'`). - [ ] **Step 3: Write the implementation** — `src/frontmatter.ts` ```ts import yaml from "js-yaml"; export interface Pair { key: string; value: string; } export function parseNote(text: string): { frontmatter: Record; body: string } { const stripped = text.replace(/^/, ""); const m = /^---\r?\n([\s\S]*?)\r?\n---\r?\n?/.exec(stripped); if (!m) return { frontmatter: {}, body: stripped }; const loaded = yaml.load(m[1]); const frontmatter = loaded && typeof loaded === "object" ? (loaded as Record) : {}; return { frontmatter, body: stripped.slice(m[0].length) }; } export function resolveFrontmatter( presets: Pair[], docFrontmatter: Record, custom: Pair[], mergeDoc: boolean ): Pair[] { const out: Pair[] = []; const idx = new Map(); const put = (key: string, value: string) => { const at = idx.get(key); if (at !== undefined) out[at] = { key, value }; else { idx.set(key, out.length); out.push({ key, value }); } }; for (const p of presets) put(p.key, p.value); if (mergeDoc) for (const [k, v] of Object.entries(docFrontmatter)) put(k, stringifyScalar(v)); for (const c of custom) put(c.key, c.value); return out; } function stringifyScalar(v: unknown): string { if (v == null) return ""; if (v instanceof Date) return v.toISOString().slice(0, 10); if (typeof v === "object") return yaml.dump(v).trim(); return String(v); } export function serializeFrontmatter(pairs: Pair[]): string { const lines = pairs.map((p) => `${p.key}: ${formatValue(p.value)}`); return `---\n${lines.join("\n")}\n---\n`; } function formatValue(v: string): string { if (v === "") return '""'; const bareSafe = /^[A-Za-z0-9_./-][A-Za-z0-9_./ -]*$/.test(v) && !/^\s|\s$/.test(v); return bareSafe ? v : JSON.stringify(v); } ``` - [ ] **Step 4: Run to verify it passes** Run: `npx vitest run src/frontmatter.test.ts` Expected: PASS (all cases). - [ ] **Step 5: Commit** ```bash git add src/frontmatter.ts src/frontmatter.test.ts git commit -m "feat: frontmatter parse/resolve/serialize Co-Authored-By: Claude" ``` --- ## Task 3: `slug.ts` **Files:** - Create: `src/slug.ts`, `src/slug.test.ts` **Interfaces:** - Produces: - `slugify(s: string): string` - `deriveSlug(o: { title?: string; filename: string }): string` - `deriveDate(o: { frontmatterDate?: unknown; now: Date }): string` - `postFilename(o: { date: string; slug: string }): string` - [ ] **Step 1: Write the failing test** — `src/slug.test.ts` ```ts import { describe, expect, test } from "vitest"; import { slugify, deriveSlug, deriveDate, postFilename } from "./slug"; describe("slugify", () => { test("lowercases, drops apostrophes, hyphenates", () => { expect(slugify("On Making a Game")).toBe("on-making-a-game"); expect(slugify("It's a Test!")).toBe("its-a-test"); expect(slugify(" Spaced out ")).toBe("spaced-out"); }); }); describe("deriveSlug", () => { test("prefers title, falls back to filename", () => { expect(deriveSlug({ title: "Hello World", filename: "note" })).toBe("hello-world"); expect(deriveSlug({ title: " ", filename: "My Note" })).toBe("my-note"); }); }); describe("deriveDate", () => { const now = new Date("2026-06-19T12:00:00Z"); test("uses frontmatter Date or string, else now", () => { expect(deriveDate({ frontmatterDate: new Date("2026-02-06T00:00:00Z"), now })).toBe("2026-02-06"); expect(deriveDate({ frontmatterDate: "2026-02-18 09:00", now })).toBe("2026-02-18"); expect(deriveDate({ now })).toBe("2026-06-19"); }); }); describe("postFilename", () => { test("joins date and slug", () => { expect(postFilename({ date: "2026-06-19", slug: "hello" })).toBe("2026-06-19-hello.md"); }); }); ``` - [ ] **Step 2: Run to verify it fails** Run: `npx vitest run src/slug.test.ts` Expected: FAIL (`Cannot find module './slug'`). - [ ] **Step 3: Write the implementation** — `src/slug.ts` ```ts export function slugify(s: string): string { return s .toLowerCase() .trim() .replace(/['’]/g, "") .replace(/[^a-z0-9]+/g, "-") .replace(/^-+|-+$/g, ""); } export function deriveSlug(o: { title?: string; filename: string }): string { const base = o.title && o.title.trim() ? o.title : o.filename; return slugify(base); } export function deriveDate(o: { frontmatterDate?: unknown; now: Date }): string { const d = o.frontmatterDate; if (d instanceof Date && !isNaN(d.getTime())) return fmt(d); if (typeof d === "string" && /^\d{4}-\d{2}-\d{2}/.test(d)) return d.slice(0, 10); return fmt(o.now); } function fmt(d: Date): string { const p = (n: number) => String(n).padStart(2, "0"); return `${d.getUTCFullYear()}-${p(d.getUTCMonth() + 1)}-${p(d.getUTCDate())}`; } export function postFilename(o: { date: string; slug: string }): string { return `${o.date}-${o.slug}.md`; } ``` - [ ] **Step 4: Run to verify it passes** Run: `npx vitest run src/slug.test.ts` Expected: PASS. - [ ] **Step 5: Commit** ```bash git add src/slug.ts src/slug.test.ts git commit -m "feat: slug and date derivation Co-Authored-By: Claude" ``` --- ## Task 4: `images.ts` **Files:** - Create: `src/images.ts`, `src/images.test.ts` **Interfaces:** - Consumes: `Strategy` is re-used by `settings.ts` and `PublishModal.ts`. - Produces: - `type Strategy = "flat-slug" | "per-post-folder"` - `interface ImageRef { raw: string; linktext: string; alt: string; kind: "embed" | "markdown" | "html" }` - `interface ImagePlanItem { linktext: string; repoPath: string; siteUrl: string }` - `interface PlanResult { rewrittenBody: string; plan: ImagePlanItem[] }` - `findImageRefs(body: string): ImageRef[]` - `planImages(refs: ImageRef[], opts: { slug: string; strategy: Strategy; imagesDir: string }): PlanResult` - `rewriteBody(body: string, refs: ImageRef[], plan: ImagePlanItem[]): string` Note: `planImages` calls `rewriteBody` internally and returns the rewritten body; `rewriteBody` is exported for targeted testing. - [ ] **Step 1: Write the failing test** — `src/images.test.ts` ```ts import { describe, expect, test } from "vitest"; import { findImageRefs, planImages } from "./images"; describe("findImageRefs", () => { test("detects embeds, markdown, html; ignores external and site-absolute", () => { const body = [ "![[shot.png]]", "![cap](pics/local.jpeg)", '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 refs = findImageRefs("![[only.png]]"); const { rewrittenBody, plan } = planImages(refs, { slug: "my-post", strategy: "flat-slug", imagesDir: "assets/img" }); 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 refs = findImageRefs("![[a.png]]\n![alt](b.jpeg)"); const { rewrittenBody, plan } = planImages(refs, { slug: "post", strategy: "flat-slug", imagesDir: "assets/img" }); 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 refs = findImageRefs("![[sub/dir/Photo.PNG|cap]]"); const { plan, rewrittenBody } = planImages(refs, { slug: "post", strategy: "per-post-folder", imagesDir: "assets/img" }); 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 refs = findImageRefs("![[x/p.png]]\n![[y/p.png]]"); const { plan } = planImages(refs, { slug: "post", strategy: "per-post-folder", imagesDir: "assets/img" }); expect(plan.map((p) => p.repoPath)).toEqual(["assets/img/post/p.png", "assets/img/post/p-1.png"]); }); }); ``` - [ ] **Step 2: Run to verify it fails** Run: `npx vitest run src/images.test.ts` Expected: FAIL (`Cannot find module './images'`). - [ ] **Step 3: Write the implementation** — `src/images.ts` ```ts 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[] = []; const seen = new Set(); 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" }); seen.add(m.index); } 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" }); } 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 planImages( refs: ImageRef[], opts: { slug: string; strategy: Strategy; imagesDir: string } ): PlanResult { const { slug, strategy, imagesDir } = 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)!); const rewrittenBody = rewriteBody( // reconstruct body unavailable here; rewrite done by caller via rewriteBody export "", refs, plan ); return { plan, rewrittenBody }; } ``` The `planImages` above cannot rewrite a body it never received. **Correct the signature** so `planImages` takes the body and returns the rewritten copy. Replace the last lines and add `rewriteBody`: ```ts // REPLACE planImages signature/body tail with this version: export function planImages( refs: ImageRef[], opts: { slug: string; strategy: Strategy; imagesDir: string; body: string } ): PlanResult { // ...identical planning logic as above using opts.slug/strategy/imagesDir... // then: const plan = distinct.map((r) => byLinktext.get(r.linktext)!); return { plan, rewrittenBody: rewriteBody(opts.body, refs, plan) }; } 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; } ``` > Implementation note for the engineer: collapse the two `planImages` blocks into ONE function whose options include `body`, and which calls `rewriteBody` at the end. The two-block presentation above only highlights the corrected signature. Update the test calls to pass `body` in the options object (see Step 1 — adjust `planImages(refs, { slug, strategy, imagesDir, body })`), keeping the `body` equal to the source string used for `findImageRefs`. - [ ] **Step 4: Adjust the test calls to pass `body`, then run** Each `planImages(refs, { ... })` in `src/images.test.ts` gains a `body` field equal to the string passed to `findImageRefs`. Example: ```ts 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 }); ``` Run: `npx vitest run src/images.test.ts` Expected: PASS. - [ ] **Step 5: Commit** ```bash git add src/images.ts src/images.test.ts git commit -m "feat: image detection, naming strategies, body rewrite Co-Authored-By: Claude" ``` --- ## Task 5: `buildPost.ts` **Files:** - Create: `src/buildPost.ts`, `src/buildPost.test.ts` **Interfaces:** - Consumes: `Pair` from `./frontmatter`, `serializeFrontmatter`. - Produces: `buildPost(o: { frontmatterPairs: Pair[]; body: string }): string` - [ ] **Step 1: Write the failing test** — `src/buildPost.test.ts` ```ts import { expect, test } from "vitest"; import { buildPost } from "./buildPost"; test("assembles frontmatter + body with single trailing newline", () => { const out = buildPost({ frontmatterPairs: [{ key: "layout", value: "post" }], body: "Hello world", }); expect(out).toBe("---\nlayout: post\n---\n\nHello world\n"); }); test("empty frontmatter still emits a delimiter block", () => { expect(buildPost({ frontmatterPairs: [], body: "x" })).toBe("---\n\n---\n\nx\n"); }); ``` - [ ] **Step 2: Run to verify it fails** Run: `npx vitest run src/buildPost.test.ts` Expected: FAIL (`Cannot find module './buildPost'`). - [ ] **Step 3: Write the implementation** — `src/buildPost.ts` ```ts import { Pair, serializeFrontmatter } from "./frontmatter"; export function buildPost(o: { frontmatterPairs: Pair[]; body: string }): string { const fm = serializeFrontmatter(o.frontmatterPairs); const body = o.body.replace(/\s+$/, ""); return `${fm}\n${body}\n`; } ``` - [ ] **Step 4: Run to verify it passes** Run: `npx vitest run src/buildPost.test.ts` Expected: PASS. - [ ] **Step 5: Commit** ```bash git add src/buildPost.ts src/buildPost.test.ts git commit -m "feat: assemble final post text Co-Authored-By: Claude" ``` --- ## Task 6: `settings.ts` **Files:** - Create: `src/settings.ts` **Interfaces:** - Consumes: `Strategy` from `./images`, `Pair` from `./frontmatter`. - Produces: `interface JekyllPublishSettings { ... }`, `const DEFAULT_SETTINGS: JekyllPublishSettings`. - [ ] **Step 1: Write the implementation** — `src/settings.ts` ```ts import { Strategy } from "./images"; import { Pair } from "./frontmatter"; export interface JekyllPublishSettings { remoteUrl: string; branch: string; postsDir: string; imagesDir: string; defaultImageStrategy: Strategy; presetFrontmatter: Pair[]; commitMessageTemplate: string; authorName: string; authorEmail: string; } export const DEFAULT_SETTINGS: JekyllPublishSettings = { remoteUrl: "", branch: "main", postsDir: "_posts", imagesDir: "assets/img", defaultImageStrategy: "flat-slug", presetFrontmatter: [], commitMessageTemplate: "Publish: {{title}}", authorName: "", authorEmail: "", }; ``` - [ ] **Step 2: Verify it type-checks** Run: `npx tsc -noEmit -skipLibCheck` Expected: no errors. - [ ] **Step 3: Commit** ```bash git add src/settings.ts git commit -m "feat: settings type and defaults Co-Authored-By: Claude" ``` --- ## Task 7: `git.ts` **Files:** - Create: `src/git.ts`, `src/git.test.ts` **Interfaces:** - Produces: - `interface GitFile { repoPath: string; data: Buffer | string }` - `interface GitClient { syncClone(o): Promise; writeFiles(files: GitFile[]): Promise; commitAndPush(o): Promise }` - `class ChildProcessGitClient implements GitClient` — constructor `(opts?: { env?: NodeJS.ProcessEnv; baseDir?: string })` - [ ] **Step 1: Write the failing integration test** — `src/git.test.ts` ```ts import { afterAll, beforeAll, expect, test } from "vitest"; import { execFileSync } from "node:child_process"; import { mkdtempSync, mkdirSync, rmSync, readFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { ChildProcessGitClient } from "./git"; let root: string, bare: string, base: string; beforeAll(() => { root = mkdtempSync(join(tmpdir(), "jp-git-")); bare = join(root, "remote.git"); base = join(root, "work"); mkdirSync(bare); mkdirSync(base); execFileSync("git", ["init", "--bare", "-b", "main", bare]); // seed an initial commit so the branch exists const seed = join(root, "seed"); mkdirSync(seed); execFileSync("git", ["init", "-b", "main", seed]); execFileSync("git", ["-C", seed, "config", "user.email", "t@t"]); execFileSync("git", ["-C", seed, "config", "user.name", "t"]); execFileSync("git", ["-C", seed, "commit", "--allow-empty", "-m", "init"]); execFileSync("git", ["-C", seed, "remote", "add", "origin", bare]); execFileSync("git", ["-C", seed, "push", "origin", "main"]); }); afterAll(() => rmSync(root, { recursive: true, force: true })); test("clone, write, commit, push lands files in the remote", async () => { const client = new ChildProcessGitClient({ baseDir: base }); await client.syncClone({ url: bare, branch: "main" }); await client.writeFiles([ { repoPath: "_posts/2026-06-19-hi.md", data: "---\nlayout: post\n---\n\nHi\n" }, { repoPath: "assets/img/hi.png", data: Buffer.from([1, 2, 3]) }, ]); await client.commitAndPush({ message: "Publish: Hi", branch: "main", authorName: "t", authorEmail: "t@t" }); const verify = join(root, "verify"); execFileSync("git", ["clone", bare, verify]); expect(readFileSync(join(verify, "_posts/2026-06-19-hi.md"), "utf8")).toContain("Hi"); expect(Array.from(readFileSync(join(verify, "assets/img/hi.png")))).toEqual([1, 2, 3]); }); test("second sync resets cleanly (fetch+reset path)", async () => { const client = new ChildProcessGitClient({ baseDir: base }); await client.syncClone({ url: bare, branch: "main" }); await client.writeFiles([{ repoPath: "_posts/second.md", data: "x" }]); await client.commitAndPush({ message: "second", branch: "main" }); const verify = join(root, "verify2"); execFileSync("git", ["clone", bare, verify]); expect(readFileSync(join(verify, "_posts/second.md"), "utf8")).toBe("x"); }); ``` - [ ] **Step 2: Run to verify it fails** Run: `npx vitest run src/git.test.ts` Expected: FAIL (`Cannot find module './git'`). - [ ] **Step 3: Write the implementation** — `src/git.ts` ```ts import { execFile } from "node:child_process"; import { createHash } from "node:crypto"; import { mkdirSync, writeFileSync } from "node:fs"; import { dirname, join } from "node:path"; import { tmpdir } from "node:os"; import { promisify } from "node:util"; const run = promisify(execFile); export interface GitFile { repoPath: string; data: Buffer | string; } export interface GitClient { syncClone(o: { url: string; branch: string }): Promise; writeFiles(files: GitFile[]): Promise; commitAndPush(o: { message: string; branch: string; authorName?: string; authorEmail?: string }): Promise; } export class ChildProcessGitClient implements GitClient { private readonly baseDir: string; private readonly env: NodeJS.ProcessEnv; private workdir = ""; constructor(opts?: { env?: NodeJS.ProcessEnv; baseDir?: string }) { this.baseDir = opts?.baseDir ?? join(tmpdir(), "obsidian-jekyll-publish"); this.env = { ...process.env, ...opts?.env, GIT_TERMINAL_PROMPT: "0", }; } private async git(cwd: string, args: string[]): Promise { const { stdout } = await run("git", args, { cwd, env: this.env, maxBuffer: 64 * 1024 * 1024 }); return stdout.toString(); } async syncClone(o: { url: string; branch: string }): Promise { const key = createHash("sha1").update(`${o.url}#${o.branch}`).digest("hex").slice(0, 16); this.workdir = join(this.baseDir, key); mkdirSync(this.baseDir, { recursive: true }); let cloned = true; try { await this.git(this.workdir, ["rev-parse", "--is-inside-work-tree"]); } catch { cloned = false; } if (cloned) { await this.git(this.workdir, ["fetch", "origin", o.branch]); await this.git(this.workdir, ["reset", "--hard", `origin/${o.branch}`]); await this.git(this.workdir, ["clean", "-fd"]); } else { await this.git(this.baseDir, ["clone", "--depth", "1", "--branch", o.branch, o.url, this.workdir]); } } async writeFiles(files: GitFile[]): Promise { for (const f of files) { const abs = join(this.workdir, f.repoPath); mkdirSync(dirname(abs), { recursive: true }); writeFileSync(abs, f.data); } } async commitAndPush(o: { message: string; branch: string; authorName?: string; authorEmail?: string }): Promise { await this.git(this.workdir, ["add", "-A"]); const cfg: string[] = []; if (o.authorName) cfg.push("-c", `user.name=${o.authorName}`); if (o.authorEmail) cfg.push("-c", `user.email=${o.authorEmail}`); await this.git(this.workdir, [...cfg, "commit", "-m", o.message]); await this.git(this.workdir, ["push", "origin", `HEAD:${o.branch}`]); } } ``` - [ ] **Step 4: Run to verify it passes** Run: `npx vitest run src/git.test.ts` Expected: PASS (both tests). Note: requires `git` on PATH (present in CI/dev). - [ ] **Step 5: Commit** ```bash git add src/git.ts src/git.test.ts git commit -m "feat: GitClient shell-out with temp clone + integration test Co-Authored-By: Claude" ``` --- ## Task 8: `askpass.ts` **Files:** - Create: `src/askpass.ts`, `src/askpass.test.ts` **Interfaces:** - Produces: - `class AskpassBridge` with: - `constructor(opts: { dir: string; onPrompt: (prompt: string) => Promise })` - `start(): Promise<{ GIT_ASKPASS: string; SSH_ASKPASS: string; SSH_ASKPASS_REQUIRE: string; JEKYLL_ASKPASS_SOCK: string }>` - `stop(): void` Mechanism: a Unix domain socket server. `start()` writes a tiny helper shell script that connects to the socket, sends the prompt (its `argv[1]`), and prints the reply. The server invokes `onPrompt` (which the plugin wires to an Obsidian modal) and writes the answer back. Session-only; nothing persisted. - [ ] **Step 1: Write the failing test** — `src/askpass.test.ts` ```ts import { afterEach, expect, test } from "vitest"; import { execFile } from "node:child_process"; import { mkdtempSync, rmSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { promisify } from "node:util"; import { AskpassBridge } from "./askpass"; const run = promisify(execFile); let bridge: AskpassBridge | undefined; afterEach(() => bridge?.stop()); test("helper script round-trips a prompt to onPrompt and returns the answer", async () => { const dir = mkdtempSync(join(tmpdir(), "jp-ask-")); bridge = new AskpassBridge({ dir, onPrompt: async (p) => (p.includes("Password") ? "s3cret" : "alice"), }); const env = await bridge.start(); const { stdout } = await run(env.GIT_ASKPASS, ["Password for 'https://x':"], { env: { ...process.env, JEKYLL_ASKPASS_SOCK: env.JEKYLL_ASKPASS_SOCK }, }); expect(stdout.trim()).toBe("s3cret"); rmSync(dir, { recursive: true, force: true }); }); ``` - [ ] **Step 2: Run to verify it fails** Run: `npx vitest run src/askpass.test.ts` Expected: FAIL (`Cannot find module './askpass'`). - [ ] **Step 3: Write the implementation** — `src/askpass.ts` ```ts import { createServer, Server } from "node:net"; import { chmodSync, writeFileSync } from "node:fs"; import { join } from "node:path"; export class AskpassBridge { private server?: Server; private sock = ""; private script = ""; constructor(private opts: { dir: string; onPrompt: (prompt: string) => Promise }) {} async start(): Promise<{ GIT_ASKPASS: string; SSH_ASKPASS: string; SSH_ASKPASS_REQUIRE: string; JEKYLL_ASKPASS_SOCK: string; }> { this.sock = join(this.opts.dir, "askpass.sock"); this.script = join(this.opts.dir, "askpass.sh"); this.server = createServer((conn) => { let buf = ""; conn.on("data", (d) => (buf += d.toString())); conn.on("end", async () => { const answer = await this.opts.onPrompt(buf.replace(/\n$/, "")); conn.write(answer.endsWith("\n") ? answer : answer + "\n"); conn.end(); }); }); await new Promise((res) => this.server!.listen(this.sock, res)); // Helper: send argv[1] (the prompt) to the socket, print the reply. const sh = [ "#!/bin/sh", 'printf "%s" "$1" | nc -U "$JEKYLL_ASKPASS_SOCK" 2>/dev/null || \\', 'printf "%s" "$1" | socat - "UNIX-CONNECT:$JEKYLL_ASKPASS_SOCK"', "", ].join("\n"); writeFileSync(this.script, sh); chmodSync(this.script, 0o755); return { GIT_ASKPASS: this.script, SSH_ASKPASS: this.script, SSH_ASKPASS_REQUIRE: "force", JEKYLL_ASKPASS_SOCK: this.sock, }; } stop(): void { this.server?.close(); this.server = undefined; } } ``` > Implementation note: the helper relies on `nc -U` or `socat`. If the test environment lacks both, the engineer should switch the helper to a 4-line Node one-liner invoked via `node -e` using `process.env.JEKYLL_ASKPASS_SOCK` (guaranteed available since the plugin bundles for a Node/Electron runtime). Keep the public `AskpassBridge` API identical; only the generated script body changes. Update the test only if the script body changes the transport. - [ ] **Step 4: Run to verify it passes** Run: `npx vitest run src/askpass.test.ts` Expected: PASS. If `nc`/`socat` are absent, apply the Node-one-liner note above, then re-run. - [ ] **Step 5: Commit** ```bash git add src/askpass.ts src/askpass.test.ts git commit -m "feat: askpass bridge from git prompts to a callback Co-Authored-By: Claude" ``` --- ## Task 9: `publish.ts` (orchestrator) **Files:** - Create: `src/publish.ts`, `src/publish.test.ts` **Interfaces:** - Consumes: `parseNote`, `Pair` (frontmatter); `findImageRefs`, `planImages`, `Strategy` (images); `buildPost`; `postFilename` (slug); `GitClient`, `GitFile` (git); `JekyllPublishSettings`. - Produces: - `interface PublishInput { noteText: string; frontmatterPairs: Pair[]; slug: string; date: string; strategy: Strategy; commitMessage: string }` - `interface ImageResolver { (linktext: string): Promise }` - `interface PublishResult { postPath: string; imageCount: number; unresolved: string[] }` - `publish(input: PublishInput, settings: JekyllPublishSettings, git: GitClient, resolveImage: ImageResolver): Promise` - [ ] **Step 1: Write the failing test** — `src/publish.test.ts` ```ts 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); }); ``` - [ ] **Step 2: Run to verify it fails** Run: `npx vitest run src/publish.test.ts` Expected: FAIL (`Cannot find module './publish'`). - [ ] **Step 3: Write the implementation** — `src/publish.ts` ```ts 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 }; } ``` Note: `planImages` here is called with `body` in its options (matching the Task 4 corrected signature). - [ ] **Step 4: Run to verify it passes** Run: `npx vitest run src/publish.test.ts` Expected: PASS (3 tests). - [ ] **Step 5: Run the whole suite + typecheck** Run: `npm test && npx tsc -noEmit -skipLibCheck` Expected: all unit + integration tests pass; no type errors. - [ ] **Step 6: Commit** ```bash git add src/publish.ts src/publish.test.ts git commit -m "feat: publish orchestrator wiring transforms to GitClient Co-Authored-By: Claude" ``` --- ## Task 10: UI glue — `PublishModal.ts`, `SettingsTab.ts`, `main.ts` **Files:** - Create: `src/PublishModal.ts`, `src/SettingsTab.ts`, `src/main.ts` **Interfaces:** - Consumes: everything above; Obsidian `Plugin`, `Modal`, `PluginSettingTab`, `Setting`, `Notice`, `TFile`, `normalizePath`. - Produces: default-exported `JekyllPublishPlugin extends Plugin` registering command `jekyll-publish:publish-current-note`. This task is UI wiring not covered by unit tests; correctness is verified by `tsc`, the build, and the E2E smoke (Task 11). Keep logic thin — all real work lives in the tested modules. - [ ] **Step 1: Write `src/PublishModal.ts`** ```ts import { App, Modal, Setting } from "obsidian"; import { Pair, parseNote, resolveFrontmatter } from "./frontmatter"; import { Strategy } from "./images"; import { JekyllPublishSettings } from "./settings"; import { deriveDate, deriveSlug } from "./slug"; export interface ModalResult { frontmatterPairs: Pair[]; slug: string; date: string; strategy: Strategy; commitMessage: string; } export class PublishModal extends Modal { private result: ModalResult; private mergeDoc = true; private custom: Pair[] = []; constructor( app: App, private settings: JekyllPublishSettings, private noteText: string, private filename: string, private onSubmit: (r: ModalResult) => void ) { super(app); const { frontmatter } = parseNote(noteText); const title = typeof frontmatter.title === "string" ? frontmatter.title : ""; this.result = { frontmatterPairs: [], slug: deriveSlug({ title, filename }), date: deriveDate({ frontmatterDate: frontmatter.date, now: new Date() }), strategy: settings.defaultImageStrategy, commitMessage: settings.commitMessageTemplate.replace("{{title}}", title || filename), }; } private recompute() { const { frontmatter } = parseNote(this.noteText); this.result.frontmatterPairs = resolveFrontmatter( this.settings.presetFrontmatter, frontmatter, this.custom, this.mergeDoc ); } onOpen() { this.recompute(); const { contentEl } = this; contentEl.createEl("h2", { text: "Publish to Jekyll" }); new Setting(contentEl).setName("Slug").addText((t) => t.setValue(this.result.slug).onChange((v) => (this.result.slug = v)) ); new Setting(contentEl).setName("Date").addText((t) => t.setValue(this.result.date).onChange((v) => (this.result.date = v)) ); new Setting(contentEl).setName("Image strategy").addDropdown((d) => d.addOption("flat-slug", "Flat, renamed to slug") .addOption("per-post-folder", "Per-post subfolder") .setValue(this.result.strategy) .onChange((v) => (this.result.strategy = v as Strategy)) ); new Setting(contentEl).setName("Merge document frontmatter").addToggle((t) => t.setValue(this.mergeDoc).onChange((v) => { this.mergeDoc = v; this.renderFrontmatter(); }) ); this.fmEl = contentEl.createDiv(); this.renderFrontmatter(); new Setting(contentEl).addButton((b) => b.setButtonText("Add property").onClick(() => { this.custom.push({ key: "", value: "" }); this.renderFrontmatter(); }) ); new Setting(contentEl).setName("Commit message").addText((t) => t.setValue(this.result.commitMessage).onChange((v) => (this.result.commitMessage = v)) ); new Setting(contentEl).addButton((b) => b.setButtonText("Publish").setCta().onClick(() => { this.recompute(); this.close(); this.onSubmit(this.result); }) ); } private fmEl!: HTMLElement; private renderFrontmatter() { this.recompute(); this.fmEl.empty(); this.fmEl.createEl("h3", { text: "Frontmatter (resolved)" }); for (const p of this.result.frontmatterPairs) { this.fmEl.createDiv({ cls: "jekyll-publish-row", text: `${p.key}: ${p.value}` }); } if (this.custom.length) { this.fmEl.createEl("h4", { text: "Custom properties" }); this.custom.forEach((row, i) => { const div = this.fmEl.createDiv({ cls: "jekyll-publish-row" }); const k = div.createEl("input", { value: row.key, placeholder: "key" }); const v = div.createEl("input", { value: row.value, placeholder: "value" }); k.oninput = () => { this.custom[i].key = k.value; this.recompute(); }; v.oninput = () => { this.custom[i].value = v.value; this.recompute(); }; }); } } onClose() { this.contentEl.empty(); } } ``` - [ ] **Step 2: Write `src/SettingsTab.ts`** ```ts import { App, PluginSettingTab, Setting } from "obsidian"; import type JekyllPublishPlugin from "./main"; export class JekyllPublishSettingTab extends PluginSettingTab { constructor(app: App, private plugin: JekyllPublishPlugin) { super(app, plugin); } display(): void { const { containerEl } = this; containerEl.empty(); const s = this.plugin.settings; const save = () => this.plugin.saveSettings(); new Setting(containerEl).setName("Remote URL").setDesc("https:// or ssh:// git URL") .addText((t) => t.setValue(s.remoteUrl).onChange((v) => { s.remoteUrl = v; save(); })); new Setting(containerEl).setName("Branch") .addText((t) => t.setValue(s.branch).onChange((v) => { s.branch = v; save(); })); new Setting(containerEl).setName("Posts directory") .addText((t) => t.setValue(s.postsDir).onChange((v) => { s.postsDir = v; save(); })); new Setting(containerEl).setName("Images directory") .addText((t) => t.setValue(s.imagesDir).onChange((v) => { s.imagesDir = v; save(); })); new Setting(containerEl).setName("Default image strategy") .addDropdown((d) => d.addOption("flat-slug", "Flat, renamed to slug") .addOption("per-post-folder", "Per-post subfolder") .setValue(s.defaultImageStrategy) .onChange((v) => { s.defaultImageStrategy = v as typeof s.defaultImageStrategy; save(); })); new Setting(containerEl).setName("Commit message template").setDesc("{{title}} is substituted") .addText((t) => t.setValue(s.commitMessageTemplate).onChange((v) => { s.commitMessageTemplate = v; save(); })); new Setting(containerEl).setName("Author name (optional)") .addText((t) => t.setValue(s.authorName).onChange((v) => { s.authorName = v; save(); })); new Setting(containerEl).setName("Author email (optional)") .addText((t) => t.setValue(s.authorEmail).onChange((v) => { s.authorEmail = v; save(); })); containerEl.createEl("h3", { text: "Preset frontmatter" }); s.presetFrontmatter.forEach((row, i) => { new Setting(containerEl) .addText((t) => t.setPlaceholder("key").setValue(row.key).onChange((v) => { s.presetFrontmatter[i].key = v; save(); })) .addText((t) => t.setPlaceholder("value").setValue(row.value).onChange((v) => { s.presetFrontmatter[i].value = v; save(); })) .addExtraButton((b) => b.setIcon("trash").onClick(() => { s.presetFrontmatter.splice(i, 1); save(); this.display(); })); }); new Setting(containerEl).addButton((b) => b.setButtonText("Add preset property").onClick(() => { s.presetFrontmatter.push({ key: "", value: "" }); save(); this.display(); }) ); } } ``` - [ ] **Step 3: Write `src/main.ts`** ```ts import { Modal, Notice, Plugin, Setting, TFile, normalizePath } from "obsidian"; import { DEFAULT_SETTINGS, JekyllPublishSettings } from "./settings"; import { JekyllPublishSettingTab } from "./SettingsTab"; import { ModalResult, PublishModal } from "./PublishModal"; import { ChildProcessGitClient } from "./git"; import { AskpassBridge } from "./askpass"; import { publish } from "./publish"; export default class JekyllPublishPlugin extends Plugin { settings!: JekyllPublishSettings; async onload() { await this.loadSettings(); this.addSettingTab(new JekyllPublishSettingTab(this.app, this)); this.addCommand({ id: "publish-current-note", name: "Publish current note to Jekyll", checkCallback: (checking) => { const file = this.app.workspace.getActiveFile(); if (!file) return false; if (!checking) void this.openPublishModal(file); return true; }, }); } async loadSettings() { this.settings = Object.assign({}, DEFAULT_SETTINGS, await this.loadData()); } async saveSettings() { await this.saveData(this.settings); } private async openPublishModal(file: TFile) { const noteText = await this.app.vault.read(file); new PublishModal(this.app, this.settings, noteText, file.basename, (r) => void this.runPublish(noteText, r) ).open(); } private async runPublish(noteText: string, r: ModalResult) { if (!this.settings.remoteUrl.trim()) { new Notice("Jekyll Publish: set a remote URL in settings first."); return; } const dir = normalizePath(this.app.vault.configDir + "/plugins/jekyll-publish"); const bridge = new AskpassBridge({ dir: (this.app.vault.adapter as any).getFullPath ? (this.app.vault.adapter as any).getFullPath(dir) : dir, onPrompt: (prompt) => this.promptCredential(prompt), }); try { const env = await bridge.start(); const git = new ChildProcessGitClient({ env }); const result = await publish( { noteText, ...r }, this.settings, git, (linktext) => this.resolveImage(linktext) ); const warn = result.unresolved.length ? ` (${result.unresolved.length} image(s) unresolved)` : ""; new Notice(`Published ${result.postPath} with ${result.imageCount} image(s)${warn}`); } catch (e) { new Notice(`Publish failed: ${(e as Error).message}`); } finally { bridge.stop(); } } private async resolveImage(linktext: string): Promise { const dest = this.app.metadataCache.getFirstLinkpathDest(linktext, ""); if (!dest) return null; const ab = await this.app.vault.readBinary(dest); return Buffer.from(ab); } private promptCredential(prompt: string): Promise { return new Promise((resolve) => { const modal = new Modal(this.app); modal.titleEl.setText("Git credentials"); modal.contentEl.createEl("p", { text: prompt }); let value = ""; const masked = /pass|secret|token/i.test(prompt); new Setting(modal.contentEl).addText((t) => { if (masked) t.inputEl.type = "password"; t.onChange((v) => (value = v)); t.inputEl.addEventListener("keydown", (e) => { if (e.key === "Enter") { modal.close(); resolve(value); } }); }); new Setting(modal.contentEl).addButton((b) => b.setButtonText("OK").setCta().onClick(() => { modal.close(); resolve(value); }) ); modal.onClose = () => resolve(value); modal.open(); }); } } ``` - [ ] **Step 4: Typecheck + build** Run: `npx tsc -noEmit -skipLibCheck && npm run build` Expected: no type errors; `dist/main.js`, `dist/manifest.json`, `dist/styles.css` produced. - [ ] **Step 5: Commit** ```bash git add src/PublishModal.ts src/SettingsTab.ts src/main.ts git commit -m "feat: publish modal, settings tab, plugin entry Co-Authored-By: Claude" ``` --- ## Task 11: E2E smoke + README + release workflow **Files:** - Create: `playwright.config.ts`, `scripts/e2e.sh`, `e2e/harness.ts`, `e2e/publish.spec.ts`, `README.md`, `.gitea/workflows/release.yml` - Reference: port `playwright.config.ts`, `scripts/e2e.sh`, `e2e/harness.ts` from `../obsidian-multi-vault-links` (adjust plugin id to `jekyll-publish`). **Interfaces:** - Consumes: the built `dist/`. - [ ] **Step 1: Port the E2E harness** Copy `obsidian-multi-vault-links/playwright.config.ts`, `scripts/e2e.sh`, and `e2e/harness.ts` into this repo. In the harness, change every `multi-vault-links` plugin id to `jekyll-publish`, and install the built `dist/` into the throwaway vault's `.obsidian/plugins/jekyll-publish/`. (Harness handles: spawn Obsidian with `--remote-debugging-port`, `connectOverCDP`, dismiss trust modal, `setEnable(true)` + `enablePlugin`.) - [ ] **Step 2: Write the smoke spec** — `e2e/publish.spec.ts` ```ts import { test, expect } from "@playwright/test"; import { launchObsidian, withPlugin } from "./harness"; test("plugin loads, command registered, modal opens & prefills", async () => { const obs = await launchObsidian(); try { await withPlugin(obs, "jekyll-publish"); const hasCommand = await obs.page.evaluate(() => Boolean((window as any).app.commands.commands["jekyll-publish:publish-current-note"]) ); expect(hasCommand).toBe(true); const modalOpened = await obs.page.evaluate(async () => { const app = (window as any).app; const file = app.vault.getFiles().find((f: any) => f.extension === "md"); await app.workspace.getLeaf(true).openFile(file); app.commands.executeCommandById("jekyll-publish:publish-current-note"); await new Promise((r) => setTimeout(r, 500)); const heading = document.querySelector(".modal-container h2"); return heading?.textContent ?? ""; }); expect(modalOpened).toContain("Publish to Jekyll"); } finally { await obs.close(); } }); ``` - [ ] **Step 3: Run the E2E** Run: `npm run e2e` Expected: the smoke test passes (plugin loads, command exists, modal opens). If the headless Obsidian AppImage is absent, `scripts/e2e.sh` downloads it (as in multi-vault); document the requirement in the README. - [ ] **Step 4: Write `README.md`** Include: what it does; install (copy `dist/` to `.obsidian/plugins/jekyll-publish/`); settings (remote URL, branch, dirs, presets, strategy); credentials model (system git + askpass bridge, nothing stored); the two image strategies; running tests (`npm test`, `npm run e2e`). - [ ] **Step 5: Port `.gitea/workflows/release.yml`** Copy from multi-vault; change names/paths to `jekyll-publish`. Keep `workflow_dispatch` with a `tag` input that builds, syncs `manifest.json` version to the tag, tags, and publishes a Gitea Release with `main.js` + `manifest.json` + `styles.css` + a zip. - [ ] **Step 6: Commit** ```bash git add e2e playwright.config.ts scripts README.md .gitea git commit -m "test: e2e smoke; docs: README; ci: release workflow Co-Authored-By: Claude" ``` --- ## Task 12: Final verification + push to Gitea **Files:** none (verification + remote setup) - [ ] **Step 1: Full verification** Run: `npm test && npx tsc -noEmit -skipLibCheck && npm run build` Expected: all tests pass; no type errors; `dist/` built. - [ ] **Step 2: Create the Gitea repo via the proxy API** ```bash curl -fsS -X POST https://gitea.int.exe.xyz/api/v1/user/repos \ -H "Content-Type: application/json" \ -d '{"name":"obsidian-jekyll-publish","private":true,"auto_init":false}' ``` - [ ] **Step 3: Push** ```bash git remote add origin https://gitea.int.exe.xyz/claude/obsidian-jekyll-publish.git git push -u origin main ``` Expected: branch pushed; `origin/main` tracking. - [ ] **Step 4: Verify remote** Run: `git ls-remote origin` Expected: lists `refs/heads/main` at the latest commit. --- ## Self-Review **Spec coverage:** - §3 configurable site assumptions → Task 6 settings + Task 10 SettingsTab. ✓ - §4.1 frontmatter (presets/merge/custom, BOM, ordering) → Task 2. ✓ - §4.2 slug/date → Task 3. ✓ - §4.3 images (2 strategies, detection, numbering, rewrite) → Task 4. ✓ - §4.4 buildPost → Task 5. ✓ - §4.5 GitClient (temp clone, fetch+reset, env) → Task 7. ✓ - §4.6 askpass bridge (no persistence) → Task 8 + `promptCredential` in Task 10. ✓ - §4.7 orchestrator (unresolved warning, summary) → Task 9. ✓ - §5 settings schema → Task 6. ✓ - §6 publish modal UX (merge checkbox, add property, WYSIWYG preview) → Task 10. ✓ - §7 error handling (validate remote, surface stderr, unresolved) → Tasks 9 & 10. ✓ - §8 testing (unit + git integration + E2E smoke) → Tasks 2–9, 11. ✓ - §9 build & release → Tasks 1 & 11. ✓ **Placeholder scan:** No TBD/TODO. The one flagged adjustment (Task 4 `planImages` body signature) is explicit with corrected code, and the askpass transport fallback is concrete. ✓ **Type consistency:** `Pair` (frontmatter) used uniformly; `Strategy` from images used by settings/modal; `GitClient`/`GitFile` consistent across Tasks 7/9/10; `planImages` options include `body` everywhere it's called (Tasks 4 & 9); command id `jekyll-publish:publish-current-note` consistent in Tasks 10 & 11. ✓