11 KiB
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 | /<images-dir>/... (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<string, unknown>, body: string }— strips a leading BOM, splits a leading---\n…\n---block, parses YAML (viajs-yaml); no frontmatter →{}+ full text as body.resolveFrontmatter(presets, docFrontmatter, custom, mergeDoc): OrderedPairs— ordered result:presets→ (ifmergeDoc) doc keys (doc value overrides a matching preset, new doc keys appended) →customrows. 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-DDfrom the note'sdate(string or Date) else the suppliednow(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
 - HTML
<img src="…" alt="…">Each ref:{ raw, linktext, alt, kind }. Externalhttp(s)://srcs are ignored (left untouched).
- Obsidian embeds
planImages(refs, { slug, strategy, imagesDir }): { rewrittenBody, plan }flat-slug(default):slug.ext, andslug-1.ext,slug-2.ext, … when a post has >1 image. Original names discarded.per-post-folder:<imagesDir>/<slug>/<original-name>.ext, original names kept; de-duped with-1suffix on collision.- Rewrites each ref to a site-absolute URL
/<imagesDir>/<newpath>preserving alt text and ref kind (embed → markdown). 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
interface GitFile { repoPath: string; data: Buffer | string }
interface GitClient {
syncClone(opts: { url; branch; workdir }): Promise<void> // clone --depth 1 OR fetch+reset
writeFiles(files: GitFile[]): Promise<void>
commitAndPush(opts: { message; branch; authorName?; authorEmail? }): Promise<void>
}
ChildProcessGitClientshells out to systemgit(require('child_process'), desktop only). Managed working dir:os.tmpdir()/obsidian-jekyll-publish/<sha1(url+branch)>. If it exists →git fetch origin <branch>+git reset --hard origin/<branch>git clean -fd; elsegit clone --depth 1 --branch <branch> <url> <dir>.
- Every git invocation runs with env:
GIT_ASKPASS=<helper>,SSH_ASKPASS=<helper>,GIT_TERMINAL_PROMPT=0,SSH_ASKPASS_REQUIRE=force. Secrets are never written to.git/configor the remote URL on disk.
4.6 askpass.ts (credential bridge)
- Plugin writes a small executable helper script to its data dir.
gitinvokes it with the prompt string asargv[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):
parseNote→ doc frontmatter + body.- Resolve frontmatter pairs from
modalResult(presets/merge/custom already resolved by the modal; orchestrator just trusts the ordered pairs). findImageRefson body → resolve each linktext to a vaultTFile(deps.resolveImage); unresolved refs surface a warning and are left as-is.planImages→ rewritten body + image plan; read bytes for each resolved image.buildPost→ post text.GitClient.syncClone→writeFiles([post, ...images])→commitAndPush.- Return a summary (
postPath, image count, commit ref) for a success notice.
5. Settings (data.json, plaintext — no secrets stored)
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;resolveFrontmatterordering & 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 (injectednow); filename assembly.images.test.ts— detect embeds/markdown/html; ignore external URLs;flat-slugsingle vs multi numbering;per-post-foldernaming & collisions; body rewrite preserves alt; embed→markdown conversion.buildPost.test.ts— assembly, trailing newline, empty-frontmatter case.
Integration (vitest)
git.test.ts—git init --barea repo inos.tmpdir(), pointremoteUrlat it (file:// or path), run a fullsyncClone → 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.jsonwithisDesktopOnly: true.- vitest config scoped to
src/**/*.test.ts; Playwright config +scripts/e2e.shported from multi-vault. .gitea/workflows/release.yml(workflow_dispatch,taginput) 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.