Files
obsidian-jekyll-publish/docs/superpowers/plans/2026-06-19-obsidian-jekyll-publish.md

58 KiB
Raw Blame History

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: /<imagesDir>/<path>.

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

{
  "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:

{
  "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:

import { defineConfig } from "vitest/config";
export default defineConfig({
  test: { include: ["src/**/*.test.ts"] },
});

manifest.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:

.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/):

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 testsrc/_smoke.test.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
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<string, unknown>; body: string }
    • resolveFrontmatter(presets: Pair[], docFrontmatter: Record<string, unknown>, custom: Pair[], mergeDoc: boolean): Pair[]
    • serializeFrontmatter(pairs: Pair[]): string
  • Step 1: Write the failing testsrc/frontmatter.test.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 implementationsrc/frontmatter.ts
import yaml from "js-yaml";

export interface Pair {
  key: string;
  value: string;
}

export function parseNote(text: string): { frontmatter: Record<string, unknown>; 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<string, unknown>) : {};
  return { frontmatter, body: stripped.slice(m[0].length) };
}

export function resolveFrontmatter(
  presets: Pair[],
  docFrontmatter: Record<string, unknown>,
  custom: Pair[],
  mergeDoc: boolean
): Pair[] {
  const out: Pair[] = [];
  const idx = new Map<string, number>();
  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
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 testsrc/slug.test.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 implementationsrc/slug.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
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 testsrc/images.test.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)",
      '<img src="diagram.svg" alt="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 implementationsrc/images.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<number>();

  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 = /<img\b[^>]*?\bsrc=["']([^"']+)["'][^>]*?>/gi;
  for (let m; (m = html.exec(body)); ) {
    if (!isLocal(m[1])) continue;
    const altM = /\balt=["']([^"']*)["']/i.exec(m[0]);
    refs.push({ raw: m[0], linktext: m[1], alt: altM ? altM[1] : "", kind: "html" });
  }

  // Order refs by position of their raw match for deterministic numbering.
  return refs.sort((a, b) => body.indexOf(a.raw) - body.indexOf(b.raw));
}

function ext(path: string): string {
  const m = IMG_EXT.exec(path);
  return m ? m[0] : "";
}

function basename(path: string): string {
  const parts = path.split("/");
  return parts[parts.length - 1];
}

export function planImages(
  refs: ImageRef[],
  opts: { slug: string; strategy: Strategy; imagesDir: string }
): PlanResult {
  const { slug, strategy, imagesDir } = opts;
  const byLinktext = new Map<string, ImagePlanItem>();
  const usedNames = new Set<string>();
  const distinct = refs.filter((r, i) => refs.findIndex((o) => o.linktext === r.linktext) === i);

  distinct.forEach((ref, i) => {
    let repoRel: string;
    if (strategy === "flat-slug") {
      const suffix = distinct.length > 1 ? `-${i + 1}` : "";
      repoRel = `${slug}${suffix}${ext(ref.linktext)}`;
    } else {
      let name = basename(ref.linktext);
      while (usedNames.has(`${slug}/${name}`)) {
        const e = ext(name);
        name = `${name.slice(0, name.length - e.length)}-1${e}`;
      }
      usedNames.add(`${slug}/${name}`);
      repoRel = `${slug}/${name}`;
    }
    const repoPath = `${imagesDir}/${repoRel}`;
    byLinktext.set(ref.linktext, { linktext: ref.linktext, repoPath, siteUrl: `/${repoPath}` });
  });

  const plan = distinct.map((r) => byLinktext.get(r.linktext)!);
  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:

// 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:

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
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 testsrc/buildPost.test.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 implementationsrc/buildPost.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
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 implementationsrc/settings.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
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<void>; writeFiles(files: GitFile[]): Promise<void>; commitAndPush(o): Promise<void> }
    • class ChildProcessGitClient implements GitClient — constructor (opts?: { env?: NodeJS.ProcessEnv; baseDir?: string })
  • Step 1: Write the failing integration testsrc/git.test.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 implementationsrc/git.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<void>;
  writeFiles(files: GitFile[]): Promise<void>;
  commitAndPush(o: { message: string; branch: string; authorName?: string; authorEmail?: string }): Promise<void>;
}

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<string> {
    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<void> {
    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<void> {
    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<void> {
    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
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<string> })
      • 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 testsrc/askpass.test.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 implementationsrc/askpass.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<string> }) {}

  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<void>((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
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; pageFilename (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<Buffer | null> }
    • interface PublishResult { postPath: string; imageCount: number; unresolved: string[] }
    • publish(input: PublishInput, settings: JekyllPublishSettings, git: GitClient, resolveImage: ImageResolver): Promise<PublishResult>
  • Step 1: Write the failing testsrc/publish.test.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 implementationsrc/publish.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<Buffer | null>;

export interface PublishResult {
  postPath: string;
  imageCount: number;
  unresolved: string[];
}

export async function publish(
  input: PublishInput,
  settings: JekyllPublishSettings,
  git: GitClient,
  resolveImage: ImageResolver
): Promise<PublishResult> {
  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
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
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
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
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<Buffer | null> {
    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<string> {
    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
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 spece2e/publish.spec.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
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
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
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 29, 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. ✓