diff --git a/README.md b/README.md index 7179c9c..3a85083 100644 --- a/README.md +++ b/README.md @@ -1,13 +1,17 @@ # Obsidian Jekyll Publish -An Obsidian plugin that publishes the active note as a Jekyll blog post via git. Images are copied alongside the post using one of two configurable strategies, and credentials are handled by your system git — nothing is stored inside Obsidian. +An Obsidian plugin that publishes the active note as a Jekyll page via git. +Images are copied alongside the post using one of two configurable strategies, +and credentials are handled by your system git. Nothing is stored inside +Obsidian. ## What it does -1. Opens a "Publish to Jekyll" modal for the currently active markdown file. -2. Lets you confirm or adjust the slug, date, frontmatter, image strategy, and commit message. +1. Opens a "Publish to Jekyll" modal for the currently active Markdown file. +2. Lets you confirm or adjust the slug, date, frontmatter, image strategy, and + commit message. 3. Clones (or updates) your Jekyll site repository into a temporary directory. -4. Writes the post as `_posts/-.md` (or your configured path). +4. Writes the note to your configured directory. 5. Copies any embedded images according to the selected strategy. 6. Commits and pushes to your configured remote and branch. @@ -18,36 +22,54 @@ An Obsidian plugin that publishes the active note as a Jekyll blog post via git. ``` /.obsidian/plugins/jekyll-publish/ ``` -3. Reload Obsidian and enable **Jekyll Publish** under Settings → Community plugins. +3. Reload Obsidian and enable **Jekyll Publish** under Settings → Community + plugins. ## Settings -| Setting | Description | Default | -|---|---|---| -| Remote URL | Git remote URL for your Jekyll site (HTTPS or SSH) | _(empty)_ | -| Branch | Branch to push to | `main` | -| Posts directory | Destination directory inside the repo | `_posts` | -| Images directory | Where images are copied inside the repo | `assets/img` | -| Default image strategy | `flat-slug` or `per-post-folder` (see below) | `flat-slug` | -| Preset frontmatter | Key/value pairs always injected into every post | _(empty)_ | +| Setting | Description | Default | +|-------------------------|-----------------------------------------------------|----------------------| +| Remote URL | Git remote URL for your Jekyll site (HTTPS or SSH) | _(empty)_ | +| Branch | Branch to push to | `main` | +| Posts directory | Destination directory inside the repo | `_posts` | +| Images directory | Where images are copied inside the repo | `assets/img` | +| Default image strategy | `flat-slug` or `per-post-folder` (see below) | `flat-slug` | +| Preset frontmatter | Key/value pairs always injected into every post | _(empty)_ | | Commit message template | Template; `{{title}}` is replaced by the note title | `Publish: {{title}}` | -| Author name | Git author name for commits | _(empty)_ | -| Author email | Git author email for commits | _(empty)_ | +| Author name | Git author name for commits | _(empty)_ | +| Author email | Git author email for commits | _(empty)_ | ## Image strategies -**flat-slug** — all images for the post are copied flat into `/`. A single image is renamed to `.`; multiple images are renamed to `-1.`, `-2.`, … (the original Obsidian filename is discarded). Keeps the images directory shallow; suitable for sites with few images per post. +**flat-slug** — all images for the post are copied flat into `/`. A +single image is renamed to `.`; multiple images are renamed to +`-1.`, `-2.`, etc. -**per-post-folder** — images are copied into `//` preserving their original filenames. Keeps each post's images grouped together; suitable for posts with many images. +**per-post-folder** — images are copied into `//` preserving +their original filenames. ## Credentials model -The plugin uses your **system git** binary for all remote operations and stores **no** passwords or tokens inside Obsidian or the plugin's data files. Authentication is delegated entirely to git's own credential handling, which works across platforms: +The plugin uses your **system git** binary for all remote operations and stores +**no** passwords or tokens inside Obsidian or the plugin's data files. +Authentication is delegated entirely to git's own credential handling, which +works across platforms: -- **HTTPS remotes** — git uses your configured credential helper: **Git Credential Manager** (bundled with Git for Windows), **osxkeychain** (macOS), or libsecret (Linux). The first push prompts you through that helper's own dialog and caches the result in your OS keychain. If no helper is configured, the publish fails fast with a clear message (the plugin sets `GIT_TERMINAL_PROMPT=0` so git never hangs waiting on a non-existent terminal). -- **SSH remotes** — git uses your existing SSH agent / `~/.ssh` key configuration. Use an `ssh://` URL and make sure your key (or agent) is set up; no extra steps in the plugin. +- **HTTPS remotes** — git uses your configured credential helper: **Git + Credential Manager** (bundled with Git for Windows), **osxkeychain** (macOS), + or libsecret (Linux). The first push prompts you through that helper's own + dialog and caches the result in your OS keychain. If no helper is configured, + the publish fails fast with a clear message (the plugin sets + `GIT_TERMINAL_PROMPT=0` so git never hangs waiting on a non-existent + terminal). +- **SSH remotes** — git uses your existing SSH agent / `~/.ssh` key + configuration. Use an `ssh://` URL and make sure your key (or agent) is set + up; no extra steps in the plugin. -Commits are authored with the **Author name/email** from the plugin settings if set; otherwise git uses your machine's git identity (`user.name` / `user.email`). If neither is configured, git will refuse to commit — set an author in settings or configure a global git identity. +Commits are authored with the **Author name/email** from the plugin settings if +set; otherwise git uses your machine's git identity (`user.name` / +`user.email`). If neither is configured, git will refuse to commit — set an +author in settings or configure a global git identity. ## Development @@ -70,11 +92,13 @@ npm run build npm test ``` -Runs the Vitest suite (frontmatter parsing, slug derivation, image path logic, git helpers, publish pipeline). +Runs the Vitest suite (frontmatter parsing, slug derivation, image path logic, +git helpers, publish pipeline). ### E2E tests -The E2E suite boots the real Obsidian binary in a headless X display and drives it via the Chrome DevTools Protocol. +The E2E suite boots the real Obsidian binary in a headless X display and drives +it via the Chrome DevTools Protocol. **Prerequisites:** @@ -87,9 +111,14 @@ npm run e2e ``` The script: + 1. Extracts the AppImage (once, cached). 2. Provisions a throwaway vault at `.obsidian-e2e/` with the plugin installed. -3. Launches Obsidian with `--remote-debugging-port` and connects Playwright over CDP. -4. Runs the smoke spec: plugin loads, command `jekyll-publish:publish-current-note` is registered, opening a note and executing the command shows a modal with heading "Publish to Jekyll". +3. Launches Obsidian with `--remote-debugging-port` and connects Playwright over + CDP. +4. Runs the smoke spec: plugin loads, command + `jekyll-publish:publish-current-note` is registered, opening a note and + executing the command shows a modal with heading "Publish to Jekyll". -The `.obsidian-e2e/` directory and Playwright `test-results/` are excluded from git. +The `.obsidian-e2e/` directory and Playwright `test-results/` are excluded from +git. diff --git a/docs/superpowers/plans/2026-06-19-obsidian-jekyll-publish.md b/docs/superpowers/plans/2026-06-19-obsidian-jekyll-publish.md index 42ad6e0..3332315 100644 --- a/docs/superpowers/plans/2026-06-19-obsidian-jekyll-publish.md +++ b/docs/superpowers/plans/2026-06-19-obsidian-jekyll-publish.md @@ -1083,7 +1083,7 @@ Co-Authored-By: Claude" - Create: `src/publish.ts`, `src/publish.test.ts` **Interfaces:** -- Consumes: `parseNote`, `Pair` (frontmatter); `findImageRefs`, `planImages`, `Strategy` (images); `buildPost`; `postFilename` (slug); `GitClient`, `GitFile` (git); `JekyllPublishSettings`. +- Consumes: `parseNote`, `Pair` (frontmatter); `findImageRefs`, `planImages`, `Strategy` (images); `buildPost`; `pageFilename` (slug); `GitClient`, `GitFile` (git); `JekyllPublishSettings`. - Produces: - `interface PublishInput { noteText: string; frontmatterPairs: Pair[]; slug: string; date: string; strategy: Strategy; commitMessage: string }` - `interface ImageResolver { (linktext: string): Promise }` diff --git a/src/PublishModal.ts b/src/PublishModal.ts index a0c24d3..e10837b 100644 --- a/src/PublishModal.ts +++ b/src/PublishModal.ts @@ -7,7 +7,7 @@ import { deriveDate, deriveSlug } from "./slug"; export interface ModalResult { frontmatterPairs: Pair[]; slug: string; - date: string; + date?: string; strategy: Strategy; commitMessage: string; } @@ -38,7 +38,7 @@ export class PublishModal extends Modal { this.result = { frontmatterPairs: [], slug: deriveSlug({ title, filename }), - date: deriveDate({ frontmatterDate: frontmatter.date, now: new Date() }), + date: deriveDate({ frontmatterDate: frontmatter.date }), strategy: settings.defaultImageStrategy, commitMessage: settings.commitMessageTemplate.replace("{{title}}", title || filename), }; @@ -57,8 +57,9 @@ export class PublishModal extends Modal { t.setValue(this.result.slug).onChange((v) => (this.result.slug = v)) ); new Setting(contentEl).setName("Date").addText((t) => - t.setValue(this.result.date).onChange((v) => (this.result.date = v)) - ); + t.setValue(this.result.date ?? "").onChange((v) => (this.result.date = v)) + ).setDesc("The date to prepend to the filename. Useful for blog posts."); + new Setting(contentEl).setName("Image strategy").addDropdown((d) => d.addOption("flat-slug", "Flat, renamed to slug") .addOption("per-post-folder", "Per-post subfolder") diff --git a/src/SettingsTab.ts b/src/SettingsTab.ts index 5b49704..3533651 100644 --- a/src/SettingsTab.ts +++ b/src/SettingsTab.ts @@ -16,8 +16,8 @@ export class JekyllPublishSettingTab extends PluginSettingTab { .addText((t) => t.setValue(s.remoteUrl).onChange((v) => { s.remoteUrl = v; save(); })); new Setting(containerEl).setName("Branch") .addText((t) => t.setValue(s.branch).onChange((v) => { s.branch = v; save(); })); - new Setting(containerEl).setName("Posts directory") - .addText((t) => t.setValue(s.postsDir).onChange((v) => { s.postsDir = v; save(); })); + new Setting(containerEl).setName("Page directory") + .addText((t) => t.setValue(s.publishDir).onChange((v) => { s.publishDir = v; save(); })); new Setting(containerEl).setName("Images directory") .addText((t) => t.setValue(s.imagesDir).onChange((v) => { s.imagesDir = v; save(); })); new Setting(containerEl).setName("Default image strategy") @@ -27,9 +27,9 @@ export class JekyllPublishSettingTab extends PluginSettingTab { .onChange((v) => { s.defaultImageStrategy = v as typeof s.defaultImageStrategy; save(); })); new Setting(containerEl).setName("Commit message template").setDesc("{{title}} is substituted") .addText((t) => t.setValue(s.commitMessageTemplate).onChange((v) => { s.commitMessageTemplate = v; save(); })); - new Setting(containerEl).setName("Author name (optional)") + new Setting(containerEl).setName("Git author name (optional)") .addText((t) => t.setValue(s.authorName).onChange((v) => { s.authorName = v; save(); })); - new Setting(containerEl).setName("Author email (optional)") + new Setting(containerEl).setName("Git author email (optional)") .addText((t) => t.setValue(s.authorEmail).onChange((v) => { s.authorEmail = v; save(); })); containerEl.createEl("h3", { text: "Preset frontmatter" }); diff --git a/src/main.ts b/src/main.ts index c5907a7..5a1affb 100644 --- a/src/main.ts +++ b/src/main.ts @@ -54,7 +54,16 @@ export default class JekyllPublishPlugin extends Plugin { { noteText, ...r }, this.settings, git, - (linktext) => this.resolveImage(linktext) + (linktext) => this.resolveImage(linktext), + (event) => { + if (event === 'beforeClone') { + new Notice("Jekyll Publish: cloning"); + } else if (event === 'beforeWrite') { + new Notice("Jekyll Publish: writing contents"); + } else if (event === 'beforePush') { + new Notice("Jekyll Publish: pushing contents"); + } + } ); const warn = result.unresolved.length ? ` (${result.unresolved.length} image(s) unresolved)` diff --git a/src/publish.test.ts b/src/publish.test.ts index d1a7f37..74b43af 100644 --- a/src/publish.test.ts +++ b/src/publish.test.ts @@ -1,4 +1,4 @@ -import { expect, test, vi } from "vitest"; +import { expect, test, vi, vitest } from "vitest"; import { publish } from "./publish"; import { DEFAULT_SETTINGS } from "./settings"; import type { GitClient, GitFile } from "./git"; @@ -63,3 +63,27 @@ test("throws when remoteUrl is empty", async () => { ), ).rejects.toThrow(/remote/i); }); + +test("calls all the events", async () => { + const { git } = fakeGit(); + const listener = vitest.fn(); + + await publish( + { + noteText: "---\nlayout: post\n---\n\n![[shot.png]]\nbody", + frontmatterPairs: [{ key: "layout", value: "post" }], + slug: "my-post", + date: "2026-06-19", + strategy: "flat-slug", + commitMessage: "Publish: My Post", + }, + { ...DEFAULT_SETTINGS, remoteUrl: "ssh://x/y.git" }, + git, + async () => Buffer.from([9]), + listener + ); + + expect(listener).toHaveBeenCalledWith('beforeClone'); + expect(listener).toHaveBeenCalledWith('beforeWrite'); + expect(listener).toHaveBeenCalledWith('beforePush'); +}); diff --git a/src/publish.ts b/src/publish.ts index 8d655a8..87fa581 100644 --- a/src/publish.ts +++ b/src/publish.ts @@ -1,6 +1,6 @@ import { parseNote, Pair } from "./frontmatter"; import { buildPost } from "./buildPost"; -import { postFilename } from "./slug"; +import { pageFilename } from "./slug"; import { findImageRefs, planImages, Strategy } from "./images"; import { GitClient, GitFile } from "./git"; import { JekyllPublishSettings } from "./settings"; @@ -9,12 +9,13 @@ export interface PublishInput { noteText: string; frontmatterPairs: Pair[]; slug: string; - date: string; + date?: string; strategy: Strategy; commitMessage: string; } export type ImageResolver = (linktext: string) => Promise; +export type PublishEventsListener = (on: 'beforeClone' | 'beforeWrite' | 'beforePush') => void; export interface PublishResult { postPath: string; @@ -26,7 +27,8 @@ export async function publish( input: PublishInput, settings: JekyllPublishSettings, git: GitClient, - resolveImage: ImageResolver + imageResolver: ImageResolver, + onProgress?: PublishEventsListener ): Promise { if (!settings.remoteUrl.trim()) throw new Error("No git remote URL configured"); @@ -37,7 +39,7 @@ export async function publish( const unresolved: string[] = []; for (const ref of refs) { if (resolved.some((r) => r.linktext === ref.linktext)) continue; - const data = await resolveImage(ref.linktext); + const data = await imageResolver(ref.linktext); if (data) resolved.push({ linktext: ref.linktext, data }); else unresolved.push(ref.linktext); } @@ -51,16 +53,19 @@ export async function publish( }); const postText = buildPost({ frontmatterPairs: input.frontmatterPairs, body: rewrittenBody }); - const postPath = `${settings.postsDir}/${postFilename({ date: input.date, slug: input.slug })}`; + const notePath = `${settings.publishDir}/${pageFilename({ date: input.date, slug: input.slug })}`; - const files: GitFile[] = [{ repoPath: postPath, data: postText }]; + const files: GitFile[] = [{ repoPath: notePath, data: postText }]; for (const item of plan) { const r = resolved.find((x) => x.linktext === item.linktext)!; files.push({ repoPath: item.repoPath, data: r.data }); } + onProgress?.('beforeClone'); await git.syncClone({ url: settings.remoteUrl, branch: settings.branch }); + onProgress?.('beforeWrite'); await git.writeFiles(files); + onProgress?.('beforePush'); await git.commitAndPush({ message: input.commitMessage, branch: settings.branch, @@ -68,5 +73,5 @@ export async function publish( authorEmail: settings.authorEmail || undefined, }); - return { postPath, imageCount: plan.length, unresolved }; + return { postPath: notePath, imageCount: plan.length, unresolved }; } diff --git a/src/settings.ts b/src/settings.ts index 96c296f..349f77f 100644 --- a/src/settings.ts +++ b/src/settings.ts @@ -4,7 +4,7 @@ import { Pair } from "./frontmatter"; export interface JekyllPublishSettings { remoteUrl: string; branch: string; - postsDir: string; + publishDir: string; imagesDir: string; defaultImageStrategy: Strategy; presetFrontmatter: Pair[]; @@ -16,7 +16,7 @@ export interface JekyllPublishSettings { export const DEFAULT_SETTINGS: JekyllPublishSettings = { remoteUrl: "", branch: "main", - postsDir: "_posts", + publishDir: "_posts", imagesDir: "assets/img", defaultImageStrategy: "flat-slug", presetFrontmatter: [], diff --git a/src/slug.test.ts b/src/slug.test.ts index 06717f4..71b7006 100644 --- a/src/slug.test.ts +++ b/src/slug.test.ts @@ -1,5 +1,5 @@ import { describe, expect, test } from "vitest"; -import { slugify, deriveSlug, deriveDate, postFilename } from "./slug"; +import { slugify, deriveSlug, deriveDate, pageFilename } from "./slug"; describe("slugify", () => { test("lowercases, drops apostrophes, hyphenates", () => { @@ -19,14 +19,18 @@ describe("deriveSlug", () => { describe("deriveDate", () => { const now = new Date("2026-06-19T12:00:00Z"); test("uses frontmatter Date or string, else now", () => { - expect(deriveDate({ frontmatterDate: new Date("2026-02-06T00:00:00Z"), now })).toBe("2026-02-06"); - expect(deriveDate({ frontmatterDate: "2026-02-18 09:00", now })).toBe("2026-02-18"); - expect(deriveDate({ now })).toBe("2026-06-19"); + expect(deriveDate({ frontmatterDate: new Date("2026-02-06T00:00:00Z"), })).toBe("2026-02-06"); + expect(deriveDate({ frontmatterDate: "2026-02-18 09:00" })).toBe("2026-02-18"); + expect(deriveDate({ frontmatterDate: "unparsable" })).toBeUndefined(); }); }); describe("postFilename", () => { test("joins date and slug", () => { - expect(postFilename({ date: "2026-06-19", slug: "hello" })).toBe("2026-06-19-hello.md"); + expect(pageFilename({ date: "2026-06-19", slug: "hello" })).toBe("2026-06-19-hello.md"); + }); + + test("renders correctly without date", () => { + expect(pageFilename({ slug: "hello-without-date" })).toBe("hello-without-date.md"); }); }); diff --git a/src/slug.ts b/src/slug.ts index 670813a..5f160ec 100644 --- a/src/slug.ts +++ b/src/slug.ts @@ -12,11 +12,10 @@ export function deriveSlug(o: { title?: string; filename: string }): string { return slugify(base); } -export function deriveDate(o: { frontmatterDate?: unknown; now: Date }): string { +export function deriveDate(o: { frontmatterDate?: unknown }): string | undefined { const d = o.frontmatterDate; if (d instanceof Date && !isNaN(d.getTime())) return fmt(d); if (typeof d === "string" && /^\d{4}-\d{2}-\d{2}/.test(d)) return d.slice(0, 10); - return fmt(o.now); } function fmt(d: Date): string { @@ -24,6 +23,9 @@ function fmt(d: Date): string { return `${d.getUTCFullYear()}-${p(d.getUTCMonth() + 1)}-${p(d.getUTCDate())}`; } -export function postFilename(o: { date: string; slug: string }): string { - return `${o.date}-${o.slug}.md`; +export function pageFilename(o: { date?: string; slug: string }): string { + if (o.date) { + return `${o.date}-${o.slug}.md`; + } + return `${o.slug}.md`; }