commit 2f6d83860f1b3b7253c2c8f0f0bf410a2bbf319f Author: Claude Date: Fri Jun 19 01:28:11 2026 +0000 docs: design spec for obsidian-jekyll-publish Co-Authored-By: Claude diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..18f8e19 --- /dev/null +++ b/.gitignore @@ -0,0 +1,5 @@ +node_modules/ +dist/ +test-results/ +.obsidian-e2e/ +*.Zone.Identifier diff --git a/docs/superpowers/specs/2026-06-19-obsidian-jekyll-publish-design.md b/docs/superpowers/specs/2026-06-19-obsidian-jekyll-publish-design.md new file mode 100644 index 0000000..892e063 --- /dev/null +++ b/docs/superpowers/specs/2026-06-19-obsidian-jekyll-publish-design.md @@ -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 | `//...` (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.