diff --git a/docs/superpowers/plans/2026-06-19-obsidian-jekyll-publish.md b/docs/superpowers/plans/2026-06-19-obsidian-jekyll-publish.md new file mode 100644 index 0000000..42ad6e0 --- /dev/null +++ b/docs/superpowers/plans/2026-06-19-obsidian-jekyll-publish.md @@ -0,0 +1,1687 @@ +# 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. ✓