58 KiB
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; manifestisDesktopOnly: true,minAppVersion: 1.5.0. - Pure modules (
frontmatter.ts,slug.ts,images.ts,buildPost.ts) MUST NOT importobsidianor Node built-ins — they stay unit-testable in plain node. - Vitest config includes only
src/**/*.test.ts; Playwright specs live ine2e/and run vianpm 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 testharness 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 test —
src/_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 test —
src/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 implementation —
src/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): stringderiveSlug(o: { title?: string; filename: string }): stringderiveDate(o: { frontmatterDate?: unknown; now: Date }): stringpostFilename(o: { date: string; slug: string }): string
-
Step 1: Write the failing test —
src/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 implementation —
src/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:
Strategyis re-used bysettings.tsandPublishModal.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 }): PlanResultrewriteBody(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
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]]",
"",
'<img src="diagram.svg" alt="d">',
"",
"",
].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("");
});
test("multiple images get -1, -2 suffixes and body is rewritten", () => {
const refs = findImageRefs("![[a.png]]\n");
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("\n");
});
});
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("");
});
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
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, ``);
}
return out;
}
Implementation note for the engineer: collapse the two
planImagesblocks into ONE function whose options includebody, and which callsrewriteBodyat the end. The two-block presentation above only highlights the corrected signature. Update the test calls to passbodyin the options object (see Step 1 — adjustplanImages(refs, { slug, strategy, imagesDir, body })), keeping thebodyequal to the source string used forfindImageRefs.
- 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";
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:
Pairfrom./frontmatter,serializeFrontmatter. -
Produces:
buildPost(o: { frontmatterPairs: Pair[]; body: string }): string -
Step 1: Write the failing test —
src/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 implementation —
src/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:
Strategyfrom./images,Pairfrom./frontmatter. -
Produces:
interface JekyllPublishSettings { ... },const DEFAULT_SETTINGS: JekyllPublishSettings. -
Step 1: Write the implementation —
src/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 test —
src/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 implementation —
src/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 AskpassBridgewith: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 test —
src/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 implementation —
src/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 -Uorsocat. If the test environment lacks both, the engineer should switch the helper to a 4-line Node one-liner invoked vianode -eusingprocess.env.JEKYLL_ASKPASS_SOCK(guaranteed available since the plugin bundles for a Node/Electron runtime). Keep the publicAskpassBridgeAPI 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 test —
src/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("");
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
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 Pluginregistering commandjekyll-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.tsfrom../obsidian-multi-vault-links(adjust plugin id tojekyll-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
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 +
promptCredentialin 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. ✓