docs: design spec for obsidian-jekyll-publish

Co-Authored-By: Claude
This commit is contained in:
2026-06-19 01:28:11 +00:00
commit 2f6d83860f
2 changed files with 243 additions and 0 deletions

View File

@@ -0,0 +1,238 @@
# 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 }): 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 `<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 }): 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<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.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.