Files
obsidian-jekyll-publish/docs/superpowers/specs/2026-06-19-obsidian-jekyll-publish-design.md
2026-06-19 01:28:11 +00:00

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 (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 }): stringYYYY-MM-DD from the note's date (string or Date) else the supplied now (injected for testability).
  • postFilename({ date, slug }): stringYYYY-MM-DD-slug.md.

4.3 images.ts (pure)

  • findImageRefs(body): ImageRef[] — detects:
    • Obsidian embeds ![[name.ext|alt]]
    • Markdown ![alt](path)
    • HTML <img src="…" alt="…"> 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: <imagesDir>/<slug>/<original-name>.ext, original names kept; de-duped with -1 suffix on collision.
    • Rewrites each ref to a site-absolute URL /<imagesDir>/<newpath> 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 }): stringserializeFrontmatter + \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>
}
  • ChildProcessGitClient shells out to system git (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; else git 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/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.syncClonewriteFiles([post, ...images])commitAndPush.
  7. 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; 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.tsgit 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 builddist/ (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.