docs: design spec for obsidian-jekyll-publish
Co-Authored-By: Claude
This commit is contained in:
5
.gitignore
vendored
Normal file
5
.gitignore
vendored
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
node_modules/
|
||||||
|
dist/
|
||||||
|
test-results/
|
||||||
|
.obsidian-e2e/
|
||||||
|
*.Zone.Identifier
|
||||||
@@ -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 ``
|
||||||
|
- 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 ``).
|
||||||
|
- `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.
|
||||||
Reference in New Issue
Block a user