Files
obsidian-jekyll-publish/docs/superpowers/plans/2026-06-19-obsidian-jekyll-publish.md
2026-06-19 01:37:29 +00:00

1688 lines
58 KiB
Markdown
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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`**
```json
{
"name": "obsidian-jekyll-publish",
"version": "0.1.0",
"description": "Publish the active Obsidian note as a Jekyll post (with images) via git.",
"main": "dist/main.js",
"type": "module",
"scripts": {
"dev": "node esbuild.config.mjs",
"build": "tsc -noEmit -skipLibCheck && node esbuild.config.mjs production",
"test": "vitest run",
"test:watch": "vitest",
"e2e": "bash scripts/e2e.sh"
},
"license": "MIT",
"devDependencies": {
"@playwright/test": "^1.60.0",
"@types/js-yaml": "^4.0.9",
"@types/node": "^20.11.0",
"builtin-modules": "^3.3.0",
"esbuild": "^0.20.0",
"obsidian": "^1.5.7",
"typescript": "^5.4.0",
"vitest": "^1.4.0"
},
"dependencies": {
"js-yaml": "^4.1.0"
}
}
```
- [ ] **Step 2: Write `tsconfig.json`, `vitest.config.ts`, `manifest.json`, `styles.css`, `esbuild.config.mjs`**
`tsconfig.json`:
```json
{
"compilerOptions": {
"baseUrl": ".", "inlineSourceMap": true, "inlineSources": true,
"module": "ESNext", "target": "ES2020", "allowJs": true,
"noImplicitAny": true, "moduleResolution": "node", "importHelpers": true,
"isolatedModules": true, "strictNullChecks": true, "strict": true,
"esModuleInterop": true, "lib": ["DOM", "ES2020"]
},
"include": ["src/**/*.ts"]
}
```
`vitest.config.ts`:
```ts
import { defineConfig } from "vitest/config";
export default defineConfig({
test: { include: ["src/**/*.test.ts"] },
});
```
`manifest.json`:
```json
{
"id": "jekyll-publish",
"name": "Jekyll Publish",
"version": "0.1.0",
"minAppVersion": "1.5.0",
"description": "Publish the active note as a Jekyll post (with images) via git.",
"author": "Claude",
"isDesktopOnly": true
}
```
`styles.css`:
```css
.jekyll-publish-row { display: flex; gap: 8px; margin-bottom: 6px; }
.jekyll-publish-row input { flex: 1; }
```
`esbuild.config.mjs` (ported from multi-vault, output to `dist/`):
```js
import esbuild from "esbuild";
import process from "process";
import builtins from "builtin-modules";
import { copyFileSync, mkdirSync } from "fs";
const prod = process.argv[2] === "production";
mkdirSync("dist", { recursive: true });
for (const f of ["manifest.json", "styles.css"]) copyFileSync(f, `dist/${f}`);
const ctx = await esbuild.context({
entryPoints: ["src/main.ts"],
bundle: true,
external: ["obsidian", "electron", ...builtins],
format: "cjs",
target: "es2020",
logLevel: "info",
sourcemap: prod ? false : "inline",
treeShaking: true,
outfile: "dist/main.js",
platform: "node",
});
if (prod) { await ctx.rebuild(); process.exit(0); }
else { await ctx.watch(); }
```
- [ ] **Step 3: Write the smoke test**`src/_smoke.test.ts`
```ts
import { expect, test } from "vitest";
test("vitest harness works", () => {
expect(1 + 1).toBe(2);
});
```
- [ ] **Step 4: Install deps and run**
Run: `npm install && npm test`
Expected: install succeeds; 1 test passes.
- [ ] **Step 5: Commit**
```bash
git add -A
git commit -m "chore: scaffold obsidian-jekyll-publish
Co-Authored-By: Claude"
```
---
## Task 2: `frontmatter.ts`
**Files:**
- Create: `src/frontmatter.ts`, `src/frontmatter.test.ts`
**Interfaces:**
- Produces:
- `interface Pair { key: string; value: string }`
- `parseNote(text: string): { frontmatter: Record<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`
```ts
import { describe, expect, test } from "vitest";
import { parseNote, resolveFrontmatter, serializeFrontmatter } from "./frontmatter";
describe("parseNote", () => {
test("splits frontmatter and body, strips BOM", () => {
const { frontmatter, body } = parseNote("---\ntitle: Hi\n---\nHello\n");
expect(frontmatter).toEqual({ title: "Hi" });
expect(body).toBe("Hello\n");
});
test("no frontmatter returns empty object and full body", () => {
expect(parseNote("Just text")).toEqual({ frontmatter: {}, body: "Just text" });
});
});
describe("resolveFrontmatter", () => {
const presets = [{ key: "layout", value: "post" }, { key: "kind", value: "essay" }];
test("presets only when mergeDoc is false", () => {
expect(resolveFrontmatter(presets, { title: "X" }, [], false)).toEqual(presets);
});
test("merge appends doc keys and doc overrides preset value", () => {
const r = resolveFrontmatter(presets, { kind: "note", title: "X" }, [], true);
expect(r).toEqual([
{ key: "layout", value: "post" },
{ key: "kind", value: "note" },
{ key: "title", value: "X" },
]);
});
test("custom rows come last and override everything", () => {
const r = resolveFrontmatter(presets, {}, [{ key: "layout", value: "page" }], false);
expect(r[0]).toEqual({ key: "layout", value: "page" });
expect(r).toHaveLength(2);
});
test("date object is rendered as YYYY-MM-DD", () => {
const r = resolveFrontmatter([], { date: new Date("2026-06-11T00:00:00Z") }, [], true);
expect(r).toEqual([{ key: "date", value: "2026-06-11" }]);
});
});
describe("serializeFrontmatter", () => {
test("bare-safe values unquoted, others JSON-quoted", () => {
const out = serializeFrontmatter([
{ key: "layout", value: "post" },
{ key: "date", value: "2026-06-11" },
{ key: "title", value: "On making a game" },
{ key: "description", value: "And how it's different" },
]);
expect(out).toBe(
`---\nlayout: post\ndate: 2026-06-11\ntitle: On making a game\ndescription: "And how it's different"\n---\n`
);
});
test("empty value becomes quoted empty string", () => {
expect(serializeFrontmatter([{ key: "tags", value: "" }])).toBe(`---\ntags: ""\n---\n`);
});
});
```
- [ ] **Step 2: Run to verify it fails**
Run: `npx vitest run src/frontmatter.test.ts`
Expected: FAIL (`Cannot find module './frontmatter'`).
- [ ] **Step 3: Write the implementation**`src/frontmatter.ts`
```ts
import yaml from "js-yaml";
export interface Pair {
key: string;
value: string;
}
export function parseNote(text: string): { frontmatter: Record<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**
```bash
git add src/frontmatter.ts src/frontmatter.test.ts
git commit -m "feat: frontmatter parse/resolve/serialize
Co-Authored-By: Claude"
```
---
## Task 3: `slug.ts`
**Files:**
- Create: `src/slug.ts`, `src/slug.test.ts`
**Interfaces:**
- Produces:
- `slugify(s: string): string`
- `deriveSlug(o: { title?: string; filename: string }): string`
- `deriveDate(o: { frontmatterDate?: unknown; now: Date }): string`
- `postFilename(o: { date: string; slug: string }): string`
- [ ] **Step 1: Write the failing test**`src/slug.test.ts`
```ts
import { describe, expect, test } from "vitest";
import { slugify, deriveSlug, deriveDate, postFilename } from "./slug";
describe("slugify", () => {
test("lowercases, drops apostrophes, hyphenates", () => {
expect(slugify("On Making a Game")).toBe("on-making-a-game");
expect(slugify("It's a Test!")).toBe("its-a-test");
expect(slugify(" Spaced out ")).toBe("spaced-out");
});
});
describe("deriveSlug", () => {
test("prefers title, falls back to filename", () => {
expect(deriveSlug({ title: "Hello World", filename: "note" })).toBe("hello-world");
expect(deriveSlug({ title: " ", filename: "My Note" })).toBe("my-note");
});
});
describe("deriveDate", () => {
const now = new Date("2026-06-19T12:00:00Z");
test("uses frontmatter Date or string, else now", () => {
expect(deriveDate({ frontmatterDate: new Date("2026-02-06T00:00:00Z"), now })).toBe("2026-02-06");
expect(deriveDate({ frontmatterDate: "2026-02-18 09:00", now })).toBe("2026-02-18");
expect(deriveDate({ now })).toBe("2026-06-19");
});
});
describe("postFilename", () => {
test("joins date and slug", () => {
expect(postFilename({ date: "2026-06-19", slug: "hello" })).toBe("2026-06-19-hello.md");
});
});
```
- [ ] **Step 2: Run to verify it fails**
Run: `npx vitest run src/slug.test.ts`
Expected: FAIL (`Cannot find module './slug'`).
- [ ] **Step 3: Write the implementation**`src/slug.ts`
```ts
export function slugify(s: string): string {
return s
.toLowerCase()
.trim()
.replace(/[']/g, "")
.replace(/[^a-z0-9]+/g, "-")
.replace(/^-+|-+$/g, "");
}
export function deriveSlug(o: { title?: string; filename: string }): string {
const base = o.title && o.title.trim() ? o.title : o.filename;
return slugify(base);
}
export function deriveDate(o: { frontmatterDate?: unknown; now: Date }): string {
const d = o.frontmatterDate;
if (d instanceof Date && !isNaN(d.getTime())) return fmt(d);
if (typeof d === "string" && /^\d{4}-\d{2}-\d{2}/.test(d)) return d.slice(0, 10);
return fmt(o.now);
}
function fmt(d: Date): string {
const p = (n: number) => String(n).padStart(2, "0");
return `${d.getUTCFullYear()}-${p(d.getUTCMonth() + 1)}-${p(d.getUTCDate())}`;
}
export function postFilename(o: { date: string; slug: string }): string {
return `${o.date}-${o.slug}.md`;
}
```
- [ ] **Step 4: Run to verify it passes**
Run: `npx vitest run src/slug.test.ts`
Expected: PASS.
- [ ] **Step 5: Commit**
```bash
git add src/slug.ts src/slug.test.ts
git commit -m "feat: slug and date derivation
Co-Authored-By: Claude"
```
---
## Task 4: `images.ts`
**Files:**
- Create: `src/images.ts`, `src/images.test.ts`
**Interfaces:**
- Consumes: `Strategy` is re-used by `settings.ts` and `PublishModal.ts`.
- Produces:
- `type Strategy = "flat-slug" | "per-post-folder"`
- `interface ImageRef { raw: string; linktext: string; alt: string; kind: "embed" | "markdown" | "html" }`
- `interface ImagePlanItem { linktext: string; repoPath: string; siteUrl: string }`
- `interface PlanResult { rewrittenBody: string; plan: ImagePlanItem[] }`
- `findImageRefs(body: string): ImageRef[]`
- `planImages(refs: ImageRef[], opts: { slug: string; strategy: Strategy; imagesDir: string }): PlanResult`
- `rewriteBody(body: string, refs: ImageRef[], plan: ImagePlanItem[]): string`
Note: `planImages` calls `rewriteBody` internally and returns the rewritten body; `rewriteBody` is exported for targeted testing.
- [ ] **Step 1: Write the failing test**`src/images.test.ts`
```ts
import { describe, expect, test } from "vitest";
import { findImageRefs, planImages } from "./images";
describe("findImageRefs", () => {
test("detects embeds, markdown, html; ignores external and site-absolute", () => {
const body = [
"![[shot.png]]",
"![cap](pics/local.jpeg)",
'<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 implementation**`src/images.ts`
```ts
export type Strategy = "flat-slug" | "per-post-folder";
export interface ImageRef {
raw: string;
linktext: string;
alt: string;
kind: "embed" | "markdown" | "html";
}
export interface ImagePlanItem {
linktext: string;
repoPath: string;
siteUrl: string;
}
export interface PlanResult {
rewrittenBody: string;
plan: ImagePlanItem[];
}
const IMG_EXT = /\.(png|jpe?g|gif|webp|svg|avif|bmp|tiff?)$/i;
function isLocal(path: string): boolean {
return !/^[a-z]+:\/\//i.test(path) && !path.startsWith("/") && IMG_EXT.test(path);
}
export function findImageRefs(body: string): ImageRef[] {
const refs: ImageRef[] = [];
const seen = new Set<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`:
```ts
// REPLACE planImages signature/body tail with this version:
export function planImages(
refs: ImageRef[],
opts: { slug: string; strategy: Strategy; imagesDir: string; body: string }
): PlanResult {
// ...identical planning logic as above using opts.slug/strategy/imagesDir...
// then:
const plan = distinct.map((r) => byLinktext.get(r.linktext)!);
return { plan, rewrittenBody: rewriteBody(opts.body, refs, plan) };
}
export function rewriteBody(body: string, refs: ImageRef[], plan: ImagePlanItem[]): string {
const url = new Map(plan.map((p) => [p.linktext, p.siteUrl]));
let out = body;
for (const ref of refs) {
const siteUrl = url.get(ref.linktext);
if (!siteUrl) continue;
out = out.replace(ref.raw, `![${ref.alt}](${siteUrl})`);
}
return out;
}
```
> Implementation note for the engineer: collapse the two `planImages` blocks into ONE function whose options include `body`, and which calls `rewriteBody` at the end. The two-block presentation above only highlights the corrected signature. Update the test calls to pass `body` in the options object (see Step 1 — adjust `planImages(refs, { slug, strategy, imagesDir, body })`), keeping the `body` equal to the source string used for `findImageRefs`.
- [ ] **Step 4: Adjust the test calls to pass `body`, then run**
Each `planImages(refs, { ... })` in `src/images.test.ts` gains a `body` field equal to the string passed to `findImageRefs`. Example:
```ts
const body = "![[a.png]]\n![alt](b.jpeg)";
const refs = findImageRefs(body);
const { rewrittenBody, plan } = planImages(refs, { slug: "post", strategy: "flat-slug", imagesDir: "assets/img", body });
```
Run: `npx vitest run src/images.test.ts`
Expected: PASS.
- [ ] **Step 5: Commit**
```bash
git add src/images.ts src/images.test.ts
git commit -m "feat: image detection, naming strategies, body rewrite
Co-Authored-By: Claude"
```
---
## Task 5: `buildPost.ts`
**Files:**
- Create: `src/buildPost.ts`, `src/buildPost.test.ts`
**Interfaces:**
- Consumes: `Pair` from `./frontmatter`, `serializeFrontmatter`.
- Produces: `buildPost(o: { frontmatterPairs: Pair[]; body: string }): string`
- [ ] **Step 1: Write the failing test**`src/buildPost.test.ts`
```ts
import { expect, test } from "vitest";
import { buildPost } from "./buildPost";
test("assembles frontmatter + body with single trailing newline", () => {
const out = buildPost({
frontmatterPairs: [{ key: "layout", value: "post" }],
body: "Hello world",
});
expect(out).toBe("---\nlayout: post\n---\n\nHello world\n");
});
test("empty frontmatter still emits a delimiter block", () => {
expect(buildPost({ frontmatterPairs: [], body: "x" })).toBe("---\n\n---\n\nx\n");
});
```
- [ ] **Step 2: Run to verify it fails**
Run: `npx vitest run src/buildPost.test.ts`
Expected: FAIL (`Cannot find module './buildPost'`).
- [ ] **Step 3: Write the implementation**`src/buildPost.ts`
```ts
import { Pair, serializeFrontmatter } from "./frontmatter";
export function buildPost(o: { frontmatterPairs: Pair[]; body: string }): string {
const fm = serializeFrontmatter(o.frontmatterPairs);
const body = o.body.replace(/\s+$/, "");
return `${fm}\n${body}\n`;
}
```
- [ ] **Step 4: Run to verify it passes**
Run: `npx vitest run src/buildPost.test.ts`
Expected: PASS.
- [ ] **Step 5: Commit**
```bash
git add src/buildPost.ts src/buildPost.test.ts
git commit -m "feat: assemble final post text
Co-Authored-By: Claude"
```
---
## Task 6: `settings.ts`
**Files:**
- Create: `src/settings.ts`
**Interfaces:**
- Consumes: `Strategy` from `./images`, `Pair` from `./frontmatter`.
- Produces: `interface JekyllPublishSettings { ... }`, `const DEFAULT_SETTINGS: JekyllPublishSettings`.
- [ ] **Step 1: Write the implementation**`src/settings.ts`
```ts
import { Strategy } from "./images";
import { Pair } from "./frontmatter";
export interface JekyllPublishSettings {
remoteUrl: string;
branch: string;
postsDir: string;
imagesDir: string;
defaultImageStrategy: Strategy;
presetFrontmatter: Pair[];
commitMessageTemplate: string;
authorName: string;
authorEmail: string;
}
export const DEFAULT_SETTINGS: JekyllPublishSettings = {
remoteUrl: "",
branch: "main",
postsDir: "_posts",
imagesDir: "assets/img",
defaultImageStrategy: "flat-slug",
presetFrontmatter: [],
commitMessageTemplate: "Publish: {{title}}",
authorName: "",
authorEmail: "",
};
```
- [ ] **Step 2: Verify it type-checks**
Run: `npx tsc -noEmit -skipLibCheck`
Expected: no errors.
- [ ] **Step 3: Commit**
```bash
git add src/settings.ts
git commit -m "feat: settings type and defaults
Co-Authored-By: Claude"
```
---
## Task 7: `git.ts`
**Files:**
- Create: `src/git.ts`, `src/git.test.ts`
**Interfaces:**
- Produces:
- `interface GitFile { repoPath: string; data: Buffer | string }`
- `interface GitClient { syncClone(o): Promise<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`
```ts
import { afterAll, beforeAll, expect, test } from "vitest";
import { execFileSync } from "node:child_process";
import { mkdtempSync, mkdirSync, rmSync, readFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { ChildProcessGitClient } from "./git";
let root: string, bare: string, base: string;
beforeAll(() => {
root = mkdtempSync(join(tmpdir(), "jp-git-"));
bare = join(root, "remote.git");
base = join(root, "work");
mkdirSync(bare); mkdirSync(base);
execFileSync("git", ["init", "--bare", "-b", "main", bare]);
// seed an initial commit so the branch exists
const seed = join(root, "seed");
mkdirSync(seed);
execFileSync("git", ["init", "-b", "main", seed]);
execFileSync("git", ["-C", seed, "config", "user.email", "t@t"]);
execFileSync("git", ["-C", seed, "config", "user.name", "t"]);
execFileSync("git", ["-C", seed, "commit", "--allow-empty", "-m", "init"]);
execFileSync("git", ["-C", seed, "remote", "add", "origin", bare]);
execFileSync("git", ["-C", seed, "push", "origin", "main"]);
});
afterAll(() => rmSync(root, { recursive: true, force: true }));
test("clone, write, commit, push lands files in the remote", async () => {
const client = new ChildProcessGitClient({ baseDir: base });
await client.syncClone({ url: bare, branch: "main" });
await client.writeFiles([
{ repoPath: "_posts/2026-06-19-hi.md", data: "---\nlayout: post\n---\n\nHi\n" },
{ repoPath: "assets/img/hi.png", data: Buffer.from([1, 2, 3]) },
]);
await client.commitAndPush({ message: "Publish: Hi", branch: "main", authorName: "t", authorEmail: "t@t" });
const verify = join(root, "verify");
execFileSync("git", ["clone", bare, verify]);
expect(readFileSync(join(verify, "_posts/2026-06-19-hi.md"), "utf8")).toContain("Hi");
expect(Array.from(readFileSync(join(verify, "assets/img/hi.png")))).toEqual([1, 2, 3]);
});
test("second sync resets cleanly (fetch+reset path)", async () => {
const client = new ChildProcessGitClient({ baseDir: base });
await client.syncClone({ url: bare, branch: "main" });
await client.writeFiles([{ repoPath: "_posts/second.md", data: "x" }]);
await client.commitAndPush({ message: "second", branch: "main" });
const verify = join(root, "verify2");
execFileSync("git", ["clone", bare, verify]);
expect(readFileSync(join(verify, "_posts/second.md"), "utf8")).toBe("x");
});
```
- [ ] **Step 2: Run to verify it fails**
Run: `npx vitest run src/git.test.ts`
Expected: FAIL (`Cannot find module './git'`).
- [ ] **Step 3: Write the implementation**`src/git.ts`
```ts
import { execFile } from "node:child_process";
import { createHash } from "node:crypto";
import { mkdirSync, writeFileSync } from "node:fs";
import { dirname, join } from "node:path";
import { tmpdir } from "node:os";
import { promisify } from "node:util";
const run = promisify(execFile);
export interface GitFile {
repoPath: string;
data: Buffer | string;
}
export interface GitClient {
syncClone(o: { url: string; branch: string }): Promise<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**
```bash
git add src/git.ts src/git.test.ts
git commit -m "feat: GitClient shell-out with temp clone + integration test
Co-Authored-By: Claude"
```
---
## Task 8: `askpass.ts`
**Files:**
- Create: `src/askpass.ts`, `src/askpass.test.ts`
**Interfaces:**
- Produces:
- `class AskpassBridge` with:
- `constructor(opts: { dir: string; onPrompt: (prompt: string) => Promise<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`
```ts
import { afterEach, expect, test } from "vitest";
import { execFile } from "node:child_process";
import { mkdtempSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { promisify } from "node:util";
import { AskpassBridge } from "./askpass";
const run = promisify(execFile);
let bridge: AskpassBridge | undefined;
afterEach(() => bridge?.stop());
test("helper script round-trips a prompt to onPrompt and returns the answer", async () => {
const dir = mkdtempSync(join(tmpdir(), "jp-ask-"));
bridge = new AskpassBridge({
dir,
onPrompt: async (p) => (p.includes("Password") ? "s3cret" : "alice"),
});
const env = await bridge.start();
const { stdout } = await run(env.GIT_ASKPASS, ["Password for 'https://x':"], {
env: { ...process.env, JEKYLL_ASKPASS_SOCK: env.JEKYLL_ASKPASS_SOCK },
});
expect(stdout.trim()).toBe("s3cret");
rmSync(dir, { recursive: true, force: true });
});
```
- [ ] **Step 2: Run to verify it fails**
Run: `npx vitest run src/askpass.test.ts`
Expected: FAIL (`Cannot find module './askpass'`).
- [ ] **Step 3: Write the implementation**`src/askpass.ts`
```ts
import { createServer, Server } from "node:net";
import { chmodSync, writeFileSync } from "node:fs";
import { join } from "node:path";
export class AskpassBridge {
private server?: Server;
private sock = "";
private script = "";
constructor(private opts: { dir: string; onPrompt: (prompt: string) => Promise<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**
```bash
git add src/askpass.ts src/askpass.test.ts
git commit -m "feat: askpass bridge from git prompts to a callback
Co-Authored-By: Claude"
```
---
## Task 9: `publish.ts` (orchestrator)
**Files:**
- Create: `src/publish.ts`, `src/publish.test.ts`
**Interfaces:**
- Consumes: `parseNote`, `Pair` (frontmatter); `findImageRefs`, `planImages`, `Strategy` (images); `buildPost`; `postFilename` (slug); `GitClient`, `GitFile` (git); `JekyllPublishSettings`.
- Produces:
- `interface PublishInput { noteText: string; frontmatterPairs: Pair[]; slug: string; date: string; strategy: Strategy; commitMessage: string }`
- `interface ImageResolver { (linktext: string): Promise<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`
```ts
import { expect, test, vi } from "vitest";
import { publish } from "./publish";
import { DEFAULT_SETTINGS } from "./settings";
import type { GitClient, GitFile } from "./git";
function fakeGit() {
const calls: { files: GitFile[]; message?: string } = { files: [] };
const git: GitClient = {
syncClone: vi.fn(async () => {}),
writeFiles: vi.fn(async (files) => { calls.files = files; }),
commitAndPush: vi.fn(async (o) => { calls.message = o.message; }),
};
return { git, calls };
}
test("writes post + resolved image, rewrites body, reports counts", async () => {
const { git, calls } = fakeGit();
const result = await publish(
{
noteText: "---\nlayout: post\n---\n\n![[shot.png]]\nbody",
frontmatterPairs: [{ key: "layout", value: "post" }],
slug: "my-post",
date: "2026-06-19",
strategy: "flat-slug",
commitMessage: "Publish: My Post",
},
{ ...DEFAULT_SETTINGS, remoteUrl: "ssh://x/y.git" },
git,
async () => Buffer.from([9]),
);
expect(result.postPath).toBe("_posts/2026-06-19-my-post.md");
expect(result.imageCount).toBe(1);
expect(result.unresolved).toEqual([]);
const post = calls.files.find((f) => f.repoPath === "_posts/2026-06-19-my-post.md")!;
expect(post.data).toContain("![](/assets/img/my-post.png)");
expect(calls.files.some((f) => f.repoPath === "assets/img/my-post.png")).toBe(true);
expect(calls.message).toBe("Publish: My Post");
});
test("unresolved images are reported and left in the body", async () => {
const { git } = fakeGit();
const result = await publish(
{
noteText: "![[missing.png]]",
frontmatterPairs: [],
slug: "p", date: "2026-06-19", strategy: "flat-slug", commitMessage: "m",
},
{ ...DEFAULT_SETTINGS, remoteUrl: "ssh://x" },
git,
async () => null,
);
expect(result.unresolved).toEqual(["missing.png"]);
expect(result.imageCount).toBe(0);
});
test("throws when remoteUrl is empty", async () => {
const { git } = fakeGit();
await expect(
publish(
{ noteText: "x", frontmatterPairs: [], slug: "p", date: "2026-06-19", strategy: "flat-slug", commitMessage: "m" },
DEFAULT_SETTINGS, git, async () => null,
),
).rejects.toThrow(/remote/i);
});
```
- [ ] **Step 2: Run to verify it fails**
Run: `npx vitest run src/publish.test.ts`
Expected: FAIL (`Cannot find module './publish'`).
- [ ] **Step 3: Write the implementation**`src/publish.ts`
```ts
import { parseNote, Pair } from "./frontmatter";
import { buildPost } from "./buildPost";
import { postFilename } from "./slug";
import { findImageRefs, planImages, Strategy } from "./images";
import { GitClient, GitFile } from "./git";
import { JekyllPublishSettings } from "./settings";
export interface PublishInput {
noteText: string;
frontmatterPairs: Pair[];
slug: string;
date: string;
strategy: Strategy;
commitMessage: string;
}
export type ImageResolver = (linktext: string) => Promise<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**
```bash
git add src/publish.ts src/publish.test.ts
git commit -m "feat: publish orchestrator wiring transforms to GitClient
Co-Authored-By: Claude"
```
---
## Task 10: UI glue — `PublishModal.ts`, `SettingsTab.ts`, `main.ts`
**Files:**
- Create: `src/PublishModal.ts`, `src/SettingsTab.ts`, `src/main.ts`
**Interfaces:**
- Consumes: everything above; Obsidian `Plugin`, `Modal`, `PluginSettingTab`, `Setting`, `Notice`, `TFile`, `normalizePath`.
- Produces: default-exported `JekyllPublishPlugin extends Plugin` registering command `jekyll-publish:publish-current-note`.
This task is UI wiring not covered by unit tests; correctness is verified by `tsc`, the build, and the E2E smoke (Task 11). Keep logic thin — all real work lives in the tested modules.
- [ ] **Step 1: Write `src/PublishModal.ts`**
```ts
import { App, Modal, Setting } from "obsidian";
import { Pair, parseNote, resolveFrontmatter } from "./frontmatter";
import { Strategy } from "./images";
import { JekyllPublishSettings } from "./settings";
import { deriveDate, deriveSlug } from "./slug";
export interface ModalResult {
frontmatterPairs: Pair[];
slug: string;
date: string;
strategy: Strategy;
commitMessage: string;
}
export class PublishModal extends Modal {
private result: ModalResult;
private mergeDoc = true;
private custom: Pair[] = [];
constructor(
app: App,
private settings: JekyllPublishSettings,
private noteText: string,
private filename: string,
private onSubmit: (r: ModalResult) => void
) {
super(app);
const { frontmatter } = parseNote(noteText);
const title = typeof frontmatter.title === "string" ? frontmatter.title : "";
this.result = {
frontmatterPairs: [],
slug: deriveSlug({ title, filename }),
date: deriveDate({ frontmatterDate: frontmatter.date, now: new Date() }),
strategy: settings.defaultImageStrategy,
commitMessage: settings.commitMessageTemplate.replace("{{title}}", title || filename),
};
}
private recompute() {
const { frontmatter } = parseNote(this.noteText);
this.result.frontmatterPairs = resolveFrontmatter(
this.settings.presetFrontmatter,
frontmatter,
this.custom,
this.mergeDoc
);
}
onOpen() {
this.recompute();
const { contentEl } = this;
contentEl.createEl("h2", { text: "Publish to Jekyll" });
new Setting(contentEl).setName("Slug").addText((t) =>
t.setValue(this.result.slug).onChange((v) => (this.result.slug = v))
);
new Setting(contentEl).setName("Date").addText((t) =>
t.setValue(this.result.date).onChange((v) => (this.result.date = v))
);
new Setting(contentEl).setName("Image strategy").addDropdown((d) =>
d.addOption("flat-slug", "Flat, renamed to slug")
.addOption("per-post-folder", "Per-post subfolder")
.setValue(this.result.strategy)
.onChange((v) => (this.result.strategy = v as Strategy))
);
new Setting(contentEl).setName("Merge document frontmatter").addToggle((t) =>
t.setValue(this.mergeDoc).onChange((v) => { this.mergeDoc = v; this.renderFrontmatter(); })
);
this.fmEl = contentEl.createDiv();
this.renderFrontmatter();
new Setting(contentEl).addButton((b) =>
b.setButtonText("Add property").onClick(() => {
this.custom.push({ key: "", value: "" });
this.renderFrontmatter();
})
);
new Setting(contentEl).setName("Commit message").addText((t) =>
t.setValue(this.result.commitMessage).onChange((v) => (this.result.commitMessage = v))
);
new Setting(contentEl).addButton((b) =>
b.setButtonText("Publish").setCta().onClick(() => {
this.recompute();
this.close();
this.onSubmit(this.result);
})
);
}
private fmEl!: HTMLElement;
private renderFrontmatter() {
this.recompute();
this.fmEl.empty();
this.fmEl.createEl("h3", { text: "Frontmatter (resolved)" });
for (const p of this.result.frontmatterPairs) {
this.fmEl.createDiv({ cls: "jekyll-publish-row", text: `${p.key}: ${p.value}` });
}
if (this.custom.length) {
this.fmEl.createEl("h4", { text: "Custom properties" });
this.custom.forEach((row, i) => {
const div = this.fmEl.createDiv({ cls: "jekyll-publish-row" });
const k = div.createEl("input", { value: row.key, placeholder: "key" });
const v = div.createEl("input", { value: row.value, placeholder: "value" });
k.oninput = () => { this.custom[i].key = k.value; this.recompute(); };
v.oninput = () => { this.custom[i].value = v.value; this.recompute(); };
});
}
}
onClose() { this.contentEl.empty(); }
}
```
- [ ] **Step 2: Write `src/SettingsTab.ts`**
```ts
import { App, PluginSettingTab, Setting } from "obsidian";
import type JekyllPublishPlugin from "./main";
export class JekyllPublishSettingTab extends PluginSettingTab {
constructor(app: App, private plugin: JekyllPublishPlugin) {
super(app, plugin);
}
display(): void {
const { containerEl } = this;
containerEl.empty();
const s = this.plugin.settings;
const save = () => this.plugin.saveSettings();
new Setting(containerEl).setName("Remote URL").setDesc("https:// or ssh:// git URL")
.addText((t) => t.setValue(s.remoteUrl).onChange((v) => { s.remoteUrl = v; save(); }));
new Setting(containerEl).setName("Branch")
.addText((t) => t.setValue(s.branch).onChange((v) => { s.branch = v; save(); }));
new Setting(containerEl).setName("Posts directory")
.addText((t) => t.setValue(s.postsDir).onChange((v) => { s.postsDir = v; save(); }));
new Setting(containerEl).setName("Images directory")
.addText((t) => t.setValue(s.imagesDir).onChange((v) => { s.imagesDir = v; save(); }));
new Setting(containerEl).setName("Default image strategy")
.addDropdown((d) => d.addOption("flat-slug", "Flat, renamed to slug")
.addOption("per-post-folder", "Per-post subfolder")
.setValue(s.defaultImageStrategy)
.onChange((v) => { s.defaultImageStrategy = v as typeof s.defaultImageStrategy; save(); }));
new Setting(containerEl).setName("Commit message template").setDesc("{{title}} is substituted")
.addText((t) => t.setValue(s.commitMessageTemplate).onChange((v) => { s.commitMessageTemplate = v; save(); }));
new Setting(containerEl).setName("Author name (optional)")
.addText((t) => t.setValue(s.authorName).onChange((v) => { s.authorName = v; save(); }));
new Setting(containerEl).setName("Author email (optional)")
.addText((t) => t.setValue(s.authorEmail).onChange((v) => { s.authorEmail = v; save(); }));
containerEl.createEl("h3", { text: "Preset frontmatter" });
s.presetFrontmatter.forEach((row, i) => {
new Setting(containerEl)
.addText((t) => t.setPlaceholder("key").setValue(row.key).onChange((v) => { s.presetFrontmatter[i].key = v; save(); }))
.addText((t) => t.setPlaceholder("value").setValue(row.value).onChange((v) => { s.presetFrontmatter[i].value = v; save(); }))
.addExtraButton((b) => b.setIcon("trash").onClick(() => { s.presetFrontmatter.splice(i, 1); save(); this.display(); }));
});
new Setting(containerEl).addButton((b) =>
b.setButtonText("Add preset property").onClick(() => { s.presetFrontmatter.push({ key: "", value: "" }); save(); this.display(); })
);
}
}
```
- [ ] **Step 3: Write `src/main.ts`**
```ts
import { Modal, Notice, Plugin, Setting, TFile, normalizePath } from "obsidian";
import { DEFAULT_SETTINGS, JekyllPublishSettings } from "./settings";
import { JekyllPublishSettingTab } from "./SettingsTab";
import { ModalResult, PublishModal } from "./PublishModal";
import { ChildProcessGitClient } from "./git";
import { AskpassBridge } from "./askpass";
import { publish } from "./publish";
export default class JekyllPublishPlugin extends Plugin {
settings!: JekyllPublishSettings;
async onload() {
await this.loadSettings();
this.addSettingTab(new JekyllPublishSettingTab(this.app, this));
this.addCommand({
id: "publish-current-note",
name: "Publish current note to Jekyll",
checkCallback: (checking) => {
const file = this.app.workspace.getActiveFile();
if (!file) return false;
if (!checking) void this.openPublishModal(file);
return true;
},
});
}
async loadSettings() {
this.settings = Object.assign({}, DEFAULT_SETTINGS, await this.loadData());
}
async saveSettings() {
await this.saveData(this.settings);
}
private async openPublishModal(file: TFile) {
const noteText = await this.app.vault.read(file);
new PublishModal(this.app, this.settings, noteText, file.basename, (r) =>
void this.runPublish(noteText, r)
).open();
}
private async runPublish(noteText: string, r: ModalResult) {
if (!this.settings.remoteUrl.trim()) {
new Notice("Jekyll Publish: set a remote URL in settings first.");
return;
}
const dir = normalizePath(this.app.vault.configDir + "/plugins/jekyll-publish");
const bridge = new AskpassBridge({
dir: (this.app.vault.adapter as any).getFullPath
? (this.app.vault.adapter as any).getFullPath(dir)
: dir,
onPrompt: (prompt) => this.promptCredential(prompt),
});
try {
const env = await bridge.start();
const git = new ChildProcessGitClient({ env });
const result = await publish(
{ noteText, ...r },
this.settings,
git,
(linktext) => this.resolveImage(linktext)
);
const warn = result.unresolved.length ? ` (${result.unresolved.length} image(s) unresolved)` : "";
new Notice(`Published ${result.postPath} with ${result.imageCount} image(s)${warn}`);
} catch (e) {
new Notice(`Publish failed: ${(e as Error).message}`);
} finally {
bridge.stop();
}
}
private async resolveImage(linktext: string): Promise<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**
```bash
git add src/PublishModal.ts src/SettingsTab.ts src/main.ts
git commit -m "feat: publish modal, settings tab, plugin entry
Co-Authored-By: Claude"
```
---
## Task 11: E2E smoke + README + release workflow
**Files:**
- Create: `playwright.config.ts`, `scripts/e2e.sh`, `e2e/harness.ts`, `e2e/publish.spec.ts`, `README.md`, `.gitea/workflows/release.yml`
- Reference: port `playwright.config.ts`, `scripts/e2e.sh`, `e2e/harness.ts` from `../obsidian-multi-vault-links` (adjust plugin id to `jekyll-publish`).
**Interfaces:**
- Consumes: the built `dist/`.
- [ ] **Step 1: Port the E2E harness**
Copy `obsidian-multi-vault-links/playwright.config.ts`, `scripts/e2e.sh`, and `e2e/harness.ts` into this repo. In the harness, change every `multi-vault-links` plugin id to `jekyll-publish`, and install the built `dist/` into the throwaway vault's `.obsidian/plugins/jekyll-publish/`. (Harness handles: spawn Obsidian with `--remote-debugging-port`, `connectOverCDP`, dismiss trust modal, `setEnable(true)` + `enablePlugin`.)
- [ ] **Step 2: Write the smoke spec**`e2e/publish.spec.ts`
```ts
import { test, expect } from "@playwright/test";
import { launchObsidian, withPlugin } from "./harness";
test("plugin loads, command registered, modal opens & prefills", async () => {
const obs = await launchObsidian();
try {
await withPlugin(obs, "jekyll-publish");
const hasCommand = await obs.page.evaluate(() =>
Boolean((window as any).app.commands.commands["jekyll-publish:publish-current-note"])
);
expect(hasCommand).toBe(true);
const modalOpened = await obs.page.evaluate(async () => {
const app = (window as any).app;
const file = app.vault.getFiles().find((f: any) => f.extension === "md");
await app.workspace.getLeaf(true).openFile(file);
app.commands.executeCommandById("jekyll-publish:publish-current-note");
await new Promise((r) => setTimeout(r, 500));
const heading = document.querySelector(".modal-container h2");
return heading?.textContent ?? "";
});
expect(modalOpened).toContain("Publish to Jekyll");
} finally {
await obs.close();
}
});
```
- [ ] **Step 3: Run the E2E**
Run: `npm run e2e`
Expected: the smoke test passes (plugin loads, command exists, modal opens). If the headless Obsidian AppImage is absent, `scripts/e2e.sh` downloads it (as in multi-vault); document the requirement in the README.
- [ ] **Step 4: Write `README.md`**
Include: what it does; install (copy `dist/` to `.obsidian/plugins/jekyll-publish/`); settings (remote URL, branch, dirs, presets, strategy); credentials model (system git + askpass bridge, nothing stored); the two image strategies; running tests (`npm test`, `npm run e2e`).
- [ ] **Step 5: Port `.gitea/workflows/release.yml`**
Copy from multi-vault; change names/paths to `jekyll-publish`. Keep `workflow_dispatch` with a `tag` input that builds, syncs `manifest.json` version to the tag, tags, and publishes a Gitea Release with `main.js` + `manifest.json` + `styles.css` + a zip.
- [ ] **Step 6: Commit**
```bash
git add e2e playwright.config.ts scripts README.md .gitea
git commit -m "test: e2e smoke; docs: README; ci: release workflow
Co-Authored-By: Claude"
```
---
## Task 12: Final verification + push to Gitea
**Files:** none (verification + remote setup)
- [ ] **Step 1: Full verification**
Run: `npm test && npx tsc -noEmit -skipLibCheck && npm run build`
Expected: all tests pass; no type errors; `dist/` built.
- [ ] **Step 2: Create the Gitea repo via the proxy API**
```bash
curl -fsS -X POST https://gitea.int.exe.xyz/api/v1/user/repos \
-H "Content-Type: application/json" \
-d '{"name":"obsidian-jekyll-publish","private":true,"auto_init":false}'
```
- [ ] **Step 3: Push**
```bash
git remote add origin https://gitea.int.exe.xyz/claude/obsidian-jekyll-publish.git
git push -u origin main
```
Expected: branch pushed; `origin/main` tracking.
- [ ] **Step 4: Verify remote**
Run: `git ls-remote origin`
Expected: lists `refs/heads/main` at the latest commit.
---
## Self-Review
**Spec coverage:**
- §3 configurable site assumptions → Task 6 settings + Task 10 SettingsTab. ✓
- §4.1 frontmatter (presets/merge/custom, BOM, ordering) → Task 2. ✓
- §4.2 slug/date → Task 3. ✓
- §4.3 images (2 strategies, detection, numbering, rewrite) → Task 4. ✓
- §4.4 buildPost → Task 5. ✓
- §4.5 GitClient (temp clone, fetch+reset, env) → Task 7. ✓
- §4.6 askpass bridge (no persistence) → Task 8 + `promptCredential` in Task 10. ✓
- §4.7 orchestrator (unresolved warning, summary) → Task 9. ✓
- §5 settings schema → Task 6. ✓
- §6 publish modal UX (merge checkbox, add property, WYSIWYG preview) → Task 10. ✓
- §7 error handling (validate remote, surface stderr, unresolved) → Tasks 9 & 10. ✓
- §8 testing (unit + git integration + E2E smoke) → Tasks 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. ✓