# Design: obsidian-jekyll-publish - **Date:** 2026-06-19 - **Status:** Approved (pending written-spec review) - **Models after:** `obsidian-multi-vault-links` (structure, build, test setup) ## 1. Purpose A desktop Obsidian plugin that publishes the active note as a Jekyll blog post — writing `_posts/YYYY-MM-DD-slug.md` plus its images, then committing and pushing to a configured git remote. Works with **any Jekyll site that has `_posts` enabled**; nothing about the target site is hard-coded. The reference target is `new-error` (`ssh://git@gitea.bchen.dev:2222/brendan/new-error.git`), whose conventions informed the *defaults offered in settings*, but every such value is configurable. ## 2. Goals / Non-goals ### Goals - One command: **"Publish current note to Jekyll"**. - A **review modal** before anything is committed. - Frontmatter that is fully user-controlled: configurable presets + optional merge of the note's own frontmatter + ad-hoc custom rows. - Automatic image handling with **two selectable strategies**. - Commit + push via **system `git`** (shell-out), no stored secrets. ### Non-goals (iteration 1) - Non-git transports (GitHub/Gitea REST API). - Mobile (plugin is `isDesktopOnly: true`). - Updating or deleting already-published posts; draft management. - A rich tag/category editor beyond raw frontmatter key/value rows. ## 3. Target-site assumptions (all configurable) | Concern | Setting (default) | Notes | |----------------|------------------------------|-------| | Posts dir | `_posts` | Jekyll standard | | Images dir | `assets/img` | Site uses this | | Post filename | `YYYY-MM-DD-slug.md` | Jekyll standard | | Image URL base | `//...` (site-absolute) | e.g. `/assets/img/foo.png` | | Branch | `main` | Configurable | | Preset frontmatter | *(empty list)* | User adds rows like `layout: post` | Defaults are **suggestions in the settings UI**, not inferred at publish time. ## 4. Architecture Mirrors the multi-vault layout: a small, pure, heavily unit-tested core, with side effects (vault IO, git) at the edges. ``` src/ frontmatter.ts (pure) parse/serialize/merge YAML frontmatter slug.ts (pure) slug + date -> post filename images.ts (pure) detect refs, plan renames, rewrite body buildPost.ts (pure) assemble final post text git.ts GitClient interface + child_process impl askpass.ts GIT_ASKPASS bridge (prompt -> Obsidian modal) publish.ts orchestrator wiring transforms -> GitClient settings.ts settings type + defaults SettingsTab.ts settings UI PublishModal.ts review modal UI main.ts plugin entry, command registration ``` ### 4.1 `frontmatter.ts` (pure) - `parseNote(text): { frontmatter: Record, body: string }` — strips a leading BOM, splits a leading `---\n…\n---` block, parses YAML (via `js-yaml`); no frontmatter → `{}` + full text as body. - `resolveFrontmatter(presets, docFrontmatter, custom, mergeDoc): OrderedPairs` — ordered result: `presets` → (if `mergeDoc`) doc keys (doc value overrides a matching preset, new doc keys appended) → `custom` rows. Later rows override earlier on key collision. Preserves insertion order for stable output. - `serializeFrontmatter(pairs): string` — emits `---\n…\n---\n`. Frontmatter is modeled as **ordered key/value pairs**, not a plain object, so the modal can render and reorder rows and output is deterministic. ### 4.2 `slug.ts` (pure) - `slugify(s): string` — lowercase, spaces→`-`, strip non `[a-z0-9-]`, collapse. - `deriveSlug({ title, filename }): string` — title if present else filename. - `deriveDate({ frontmatterDate, now }): string` — `YYYY-MM-DD` from the note's `date` (string or Date) else the supplied `now` (injected for testability). - `postFilename({ date, slug }): string` → `YYYY-MM-DD-slug.md`. ### 4.3 `images.ts` (pure) - `findImageRefs(body): ImageRef[]` — detects: - Obsidian embeds `![[name.ext|alt]]` - Markdown `![alt](path)` - HTML `…` Each ref: `{ raw, linktext, alt, kind }`. External `http(s)://` srcs are ignored (left untouched). - `planImages(refs, { slug, strategy, imagesDir }): { rewrittenBody, plan }` - **`flat-slug`** (default): `slug.ext`, and `slug-1.ext`, `slug-2.ext`, … when a post has >1 image. Original names discarded. - **`per-post-folder`**: `//.ext`, original names kept; de-duped with `-1` suffix on collision. - Rewrites each ref to a site-absolute URL `//` preserving alt text and ref kind (embed → markdown `![alt](url)`). - `plan: { linktext, repoPath, siteUrl }[]` — repoPath relative to repo root. Vault→file resolution (linktext → actual `TFile` + bytes) is **not** in this module; `main.ts`/`publish.ts` resolve via `metadataCache.getFirstLinkpathDest` and pass byte buffers to the GitClient. The planning/rewrite is pure and gets the detected refs only. ### 4.4 `buildPost.ts` (pure) - `buildPost({ frontmatterPairs, body }): string` — `serializeFrontmatter` + `\n` + rewritten body, with a single trailing newline. ### 4.5 `git.ts` ```ts interface GitFile { repoPath: string; data: Buffer | string } interface GitClient { syncClone(opts: { url; branch; workdir }): Promise // clone --depth 1 OR fetch+reset writeFiles(files: GitFile[]): Promise commitAndPush(opts: { message; branch; authorName?; authorEmail? }): Promise } ``` - `ChildProcessGitClient` shells out to system `git` (`require('child_process')`, desktop only). Managed working dir: `os.tmpdir()/obsidian-jekyll-publish/`. If it exists → `git fetch origin ` + `git reset --hard origin/` + `git clean -fd`; else `git clone --depth 1 --branch `. - Every git invocation runs with env: `GIT_ASKPASS=`, `SSH_ASKPASS=`, `GIT_TERMINAL_PROMPT=0`, `SSH_ASKPASS_REQUIRE=force`. Secrets are never written to `.git/config` or the remote URL on disk. ### 4.6 `askpass.ts` (credential bridge) - Plugin writes a small executable helper script to its data dir. `git` invokes it with the prompt string as `argv[1]` when it needs a username/password. - The helper round-trips the prompt to the running plugin (local IPC: a unix socket / fifo whose path is passed via env), which shows an Obsidian input modal (password-masked for secret prompts) and returns the value on stdout. - Answers are cached **in memory for the session only**, never persisted. - If the bridge can't be established (e.g. platform limitation), git fails fast (because `GIT_TERMINAL_PROMPT=0`) with a clear surfaced error rather than hanging. ### 4.7 `publish.ts` (orchestrator) `publish(note, settings, modalResult, deps)`: 1. `parseNote` → doc frontmatter + body. 2. Resolve frontmatter pairs from `modalResult` (presets/merge/custom already resolved by the modal; orchestrator just trusts the ordered pairs). 3. `findImageRefs` on body → resolve each linktext to a vault `TFile` (`deps.resolveImage`); unresolved refs surface a warning and are left as-is. 4. `planImages` → rewritten body + image plan; read bytes for each resolved image. 5. `buildPost` → post text. 6. `GitClient.syncClone` → `writeFiles([post, ...images])` → `commitAndPush`. 7. Return a summary (`postPath`, image count, commit ref) for a success notice. ## 5. Settings (`data.json`, plaintext — no secrets stored) ```ts interface JekyllPublishSettings { remoteUrl: string; // https:// or ssh:// branch: string; // default "main" postsDir: string; // default "_posts" imagesDir: string; // default "assets/img" defaultImageStrategy: "flat-slug" | "per-post-folder"; // default "flat-slug" presetFrontmatter: { key: string; value: string }[]; // default [] commitMessageTemplate: string; // default "Publish: {{title}}" authorName?: string; // optional git author override authorEmail?: string; } ``` Settings tab renders editable rows for `presetFrontmatter` (add/remove/reorder), the dirs, branch, remote URL, default strategy, and commit template. ## 6. Publish modal UX On command invocation, open a modal pre-filled with: - **Frontmatter section** — preset rows (editable), a **"Merge document frontmatter" checkbox** (toggles inclusion of the note's own frontmatter), and an **"Add property" button** appending custom key/value rows after the rest. Rows render in final output order; the resolved preview is WYSIWYG. - **Slug** (pre-filled from `deriveSlug`, editable). - **Date** (pre-filled from `deriveDate`, editable). - **Image strategy** dropdown (defaults to `defaultImageStrategy`). - **Commit message** (pre-filled from template). - **Publish** button → runs `publish.ts`; **Cancel** closes with no side effects. Errors (no remote configured, clone/push failure, unresolved images) are shown inline / via `Notice` and abort before any push. ## 7. Error handling - Validate settings before publish (remote URL + branch present) — fail fast with a `Notice`. - Wrap each git step; on failure surface stderr (trimmed) and stop. - Unresolved image links: warn listing them; allow the user to proceed (links left untouched) or cancel. - All file writes happen in the temp clone; a failed push leaves the user's vault untouched. ## 8. Testing (RED → GREEN TDD) Primary guarantee is the pure core, exactly like multi-vault's `parse.test.ts`. ### Unit (vitest, `src/**/*.test.ts`) - `frontmatter.test.ts` — BOM strip; no-frontmatter; parse; `resolveFrontmatter` ordering & precedence (presets only / merge on / custom override / key collisions); serialize round-trip. - `slug.test.ts` — slugify edge cases; title vs filename; date from string/Date/missing (injected `now`); filename assembly. - `images.test.ts` — detect embeds/markdown/html; ignore external URLs; `flat-slug` single vs multi numbering; `per-post-folder` naming & collisions; body rewrite preserves alt; embed→markdown conversion. - `buildPost.test.ts` — assembly, trailing newline, empty-frontmatter case. ### Integration (vitest) - `git.test.ts` — `git init --bare` a repo in `os.tmpdir()`, point `remoteUrl` at it (file:// or path), run a full `syncClone → writeFiles → commitAndPush`, then re-clone the bare repo and assert the committed tree (post path + image bytes + commit message). No network. Second publish exercises the fetch+reset path. ### E2E (Playwright + Obsidian, reusing the multi-vault harness) - Smoke: plugin loads in a real vault, command is registered, modal opens and pre-fills frontmatter/slug/date. (Full click-through publish-to-temp-repo is a stretch for this iteration; units + git integration carry correctness.) ## 9. Build & release (mirror multi-vault) - TypeScript + esbuild (`esbuild.config.mjs`), `npm run build` → `dist/` (`main.js`, `manifest.json`, `styles.css`). - `manifest.json` with `isDesktopOnly: true`. - vitest config scoped to `src/**/*.test.ts`; Playwright config + `scripts/e2e.sh` ported from multi-vault. - `.gitea/workflows/release.yml` (`workflow_dispatch`, `tag` input) building and publishing the three files + zip, as in multi-vault. ## 10. Open questions None blocking. The askpass IPC mechanism (unix socket vs fifo) is an implementation detail chosen during build; both satisfy the "bridge prompts to Obsidian UI, store nothing" requirement.