1688 lines
58 KiB
Markdown
1688 lines
58 KiB
Markdown
# 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]]",
|
||
"",
|
||
'<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`
|
||
|
||
```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, ``);
|
||
}
|
||
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";
|
||
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`; `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`
|
||
|
||
```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`
|
||
|
||
```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 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. ✓
|