Merge pull request 'feat/publish-any-page' (#1) from feat/publish-any-page into main
Some checks failed
CI / test (push) Has been cancelled

Reviewed-on: #1
This commit was merged in pull request #1.
This commit is contained in:
2026-08-09 19:16:23 +00:00
10 changed files with 129 additions and 55 deletions

View File

@@ -1,13 +1,17 @@
# Obsidian Jekyll Publish # 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 ## What it does
1. Opens a "Publish to Jekyll" modal for the currently active markdown file. 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. 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. 3. Clones (or updates) your Jekyll site repository into a temporary directory.
4. Writes the post as `_posts/<date>-<slug>.md` (or your configured path). 4. Writes the note to your configured directory.
5. Copies any embedded images according to the selected strategy. 5. Copies any embedded images according to the selected strategy.
6. Commits and pushes to your configured remote and branch. 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.
``` ```
<your-vault>/.obsidian/plugins/jekyll-publish/ <your-vault>/.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 ## Settings
| Setting | Description | Default | | Setting | Description | Default |
|---|---|---| |-------------------------|-----------------------------------------------------|----------------------|
| Remote URL | Git remote URL for your Jekyll site (HTTPS or SSH) | _(empty)_ | | Remote URL | Git remote URL for your Jekyll site (HTTPS or SSH) | _(empty)_ |
| Branch | Branch to push to | `main` | | Branch | Branch to push to | `main` |
| Posts directory | Destination directory inside the repo | `_posts` | | Posts directory | Destination directory inside the repo | `_posts` |
| Images directory | Where images are copied inside the repo | `assets/img` | | Images directory | Where images are copied inside the repo | `assets/img` |
| Default image strategy | `flat-slug` or `per-post-folder` (see below) | `flat-slug` | | Default image strategy | `flat-slug` or `per-post-folder` (see below) | `flat-slug` |
| Preset frontmatter | Key/value pairs always injected into every post | _(empty)_ | | Preset frontmatter | Key/value pairs always injected into every post | _(empty)_ |
| Commit message template | Template; `{{title}}` is replaced by the note title | `Publish: {{title}}` | | Commit message template | Template; `{{title}}` is replaced by the note title | `Publish: {{title}}` |
| Author name | Git author name for commits | _(empty)_ | | Author name | Git author name for commits | _(empty)_ |
| Author email | Git author email for commits | _(empty)_ | | Author email | Git author email for commits | _(empty)_ |
## Image strategies ## Image strategies
**flat-slug** — all images for the post are copied flat into `<images-dir>/`. A single image is renamed to `<slug>.<ext>`; multiple images are renamed to `<slug>-1.<ext>`, `<slug>-2.<ext>`, … (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 `<images-dir>/`. A
single image is renamed to `<slug>.<ext>`; multiple images are renamed to
`<slug>-1.<ext>`, `<slug>-2.<ext>`, etc.
**per-post-folder** — images are copied into `<images-dir>/<slug>/` preserving their original filenames. Keeps each post's images grouped together; suitable for posts with many images. **per-post-folder** — images are copied into `<images-dir>/<slug>/` preserving
their original filenames.
## Credentials model ## 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). - **HTTPS remotes** — git uses your configured credential helper: **Git
- **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. 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 ## Development
@@ -70,11 +92,13 @@ npm run build
npm test 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 ### 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:** **Prerequisites:**
@@ -87,9 +111,14 @@ npm run e2e
``` ```
The script: The script:
1. Extracts the AppImage (once, cached). 1. Extracts the AppImage (once, cached).
2. Provisions a throwaway vault at `.obsidian-e2e/` with the plugin installed. 2. Provisions a throwaway vault at `.obsidian-e2e/` with the plugin installed.
3. Launches Obsidian with `--remote-debugging-port` and connects Playwright over CDP. 3. Launches Obsidian with `--remote-debugging-port` and connects Playwright over
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". 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.

View File

@@ -1083,7 +1083,7 @@ Co-Authored-By: Claude"
- Create: `src/publish.ts`, `src/publish.test.ts` - Create: `src/publish.ts`, `src/publish.test.ts`
**Interfaces:** **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: - Produces:
- `interface PublishInput { noteText: string; frontmatterPairs: Pair[]; slug: string; date: string; strategy: Strategy; commitMessage: string }` - `interface PublishInput { noteText: string; frontmatterPairs: Pair[]; slug: string; date: string; strategy: Strategy; commitMessage: string }`
- `interface ImageResolver { (linktext: string): Promise<Buffer | null> }` - `interface ImageResolver { (linktext: string): Promise<Buffer | null> }`

View File

@@ -7,7 +7,7 @@ import { deriveDate, deriveSlug } from "./slug";
export interface ModalResult { export interface ModalResult {
frontmatterPairs: Pair[]; frontmatterPairs: Pair[];
slug: string; slug: string;
date: string; date?: string;
strategy: Strategy; strategy: Strategy;
commitMessage: string; commitMessage: string;
} }
@@ -38,7 +38,7 @@ export class PublishModal extends Modal {
this.result = { this.result = {
frontmatterPairs: [], frontmatterPairs: [],
slug: deriveSlug({ title, filename }), slug: deriveSlug({ title, filename }),
date: deriveDate({ frontmatterDate: frontmatter.date, now: new Date() }), date: deriveDate({ frontmatterDate: frontmatter.date }),
strategy: settings.defaultImageStrategy, strategy: settings.defaultImageStrategy,
commitMessage: settings.commitMessageTemplate.replace("{{title}}", title || filename), 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)) t.setValue(this.result.slug).onChange((v) => (this.result.slug = v))
); );
new Setting(contentEl).setName("Date").addText((t) => 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) => new Setting(contentEl).setName("Image strategy").addDropdown((d) =>
d.addOption("flat-slug", "Flat, renamed to slug") d.addOption("flat-slug", "Flat, renamed to slug")
.addOption("per-post-folder", "Per-post subfolder") .addOption("per-post-folder", "Per-post subfolder")

View File

@@ -16,8 +16,8 @@ export class JekyllPublishSettingTab extends PluginSettingTab {
.addText((t) => t.setValue(s.remoteUrl).onChange((v) => { s.remoteUrl = v; save(); })); .addText((t) => t.setValue(s.remoteUrl).onChange((v) => { s.remoteUrl = v; save(); }));
new Setting(containerEl).setName("Branch") new Setting(containerEl).setName("Branch")
.addText((t) => t.setValue(s.branch).onChange((v) => { s.branch = v; save(); })); .addText((t) => t.setValue(s.branch).onChange((v) => { s.branch = v; save(); }));
new Setting(containerEl).setName("Posts directory") new Setting(containerEl).setName("Page directory")
.addText((t) => t.setValue(s.postsDir).onChange((v) => { s.postsDir = v; save(); })); .addText((t) => t.setValue(s.publishDir).onChange((v) => { s.publishDir = v; save(); }));
new Setting(containerEl).setName("Images directory") new Setting(containerEl).setName("Images directory")
.addText((t) => t.setValue(s.imagesDir).onChange((v) => { s.imagesDir = v; save(); })); .addText((t) => t.setValue(s.imagesDir).onChange((v) => { s.imagesDir = v; save(); }));
new Setting(containerEl).setName("Default image strategy") 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(); })); .onChange((v) => { s.defaultImageStrategy = v as typeof s.defaultImageStrategy; save(); }));
new Setting(containerEl).setName("Commit message template").setDesc("{{title}} is substituted") new Setting(containerEl).setName("Commit message template").setDesc("{{title}} is substituted")
.addText((t) => t.setValue(s.commitMessageTemplate).onChange((v) => { s.commitMessageTemplate = v; save(); })); .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(); })); .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(); })); .addText((t) => t.setValue(s.authorEmail).onChange((v) => { s.authorEmail = v; save(); }));
containerEl.createEl("h3", { text: "Preset frontmatter" }); containerEl.createEl("h3", { text: "Preset frontmatter" });

View File

@@ -54,7 +54,16 @@ export default class JekyllPublishPlugin extends Plugin {
{ noteText, ...r }, { noteText, ...r },
this.settings, this.settings,
git, 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 const warn = result.unresolved.length
? ` (${result.unresolved.length} image(s) unresolved)` ? ` (${result.unresolved.length} image(s) unresolved)`

View File

@@ -1,4 +1,4 @@
import { expect, test, vi } from "vitest"; import { expect, test, vi, vitest } from "vitest";
import { publish } from "./publish"; import { publish } from "./publish";
import { DEFAULT_SETTINGS } from "./settings"; import { DEFAULT_SETTINGS } from "./settings";
import type { GitClient, GitFile } from "./git"; import type { GitClient, GitFile } from "./git";
@@ -63,3 +63,27 @@ test("throws when remoteUrl is empty", async () => {
), ),
).rejects.toThrow(/remote/i); ).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');
});

View File

@@ -1,6 +1,6 @@
import { parseNote, Pair } from "./frontmatter"; import { parseNote, Pair } from "./frontmatter";
import { buildPost } from "./buildPost"; import { buildPost } from "./buildPost";
import { postFilename } from "./slug"; import { pageFilename } from "./slug";
import { findImageRefs, planImages, Strategy } from "./images"; import { findImageRefs, planImages, Strategy } from "./images";
import { GitClient, GitFile } from "./git"; import { GitClient, GitFile } from "./git";
import { JekyllPublishSettings } from "./settings"; import { JekyllPublishSettings } from "./settings";
@@ -9,12 +9,13 @@ export interface PublishInput {
noteText: string; noteText: string;
frontmatterPairs: Pair[]; frontmatterPairs: Pair[];
slug: string; slug: string;
date: string; date?: string;
strategy: Strategy; strategy: Strategy;
commitMessage: string; commitMessage: string;
} }
export type ImageResolver = (linktext: string) => Promise<Buffer | null>; export type ImageResolver = (linktext: string) => Promise<Buffer | null>;
export type PublishEventsListener = (on: 'beforeClone' | 'beforeWrite' | 'beforePush') => void;
export interface PublishResult { export interface PublishResult {
postPath: string; postPath: string;
@@ -26,7 +27,8 @@ export async function publish(
input: PublishInput, input: PublishInput,
settings: JekyllPublishSettings, settings: JekyllPublishSettings,
git: GitClient, git: GitClient,
resolveImage: ImageResolver imageResolver: ImageResolver,
onProgress?: PublishEventsListener
): Promise<PublishResult> { ): Promise<PublishResult> {
if (!settings.remoteUrl.trim()) throw new Error("No git remote URL configured"); if (!settings.remoteUrl.trim()) throw new Error("No git remote URL configured");
@@ -37,7 +39,7 @@ export async function publish(
const unresolved: string[] = []; const unresolved: string[] = [];
for (const ref of refs) { for (const ref of refs) {
if (resolved.some((r) => r.linktext === ref.linktext)) continue; 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 }); if (data) resolved.push({ linktext: ref.linktext, data });
else unresolved.push(ref.linktext); else unresolved.push(ref.linktext);
} }
@@ -51,16 +53,19 @@ export async function publish(
}); });
const postText = buildPost({ frontmatterPairs: input.frontmatterPairs, body: rewrittenBody }); 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) { for (const item of plan) {
const r = resolved.find((x) => x.linktext === item.linktext)!; const r = resolved.find((x) => x.linktext === item.linktext)!;
files.push({ repoPath: item.repoPath, data: r.data }); files.push({ repoPath: item.repoPath, data: r.data });
} }
onProgress?.('beforeClone');
await git.syncClone({ url: settings.remoteUrl, branch: settings.branch }); await git.syncClone({ url: settings.remoteUrl, branch: settings.branch });
onProgress?.('beforeWrite');
await git.writeFiles(files); await git.writeFiles(files);
onProgress?.('beforePush');
await git.commitAndPush({ await git.commitAndPush({
message: input.commitMessage, message: input.commitMessage,
branch: settings.branch, branch: settings.branch,
@@ -68,5 +73,5 @@ export async function publish(
authorEmail: settings.authorEmail || undefined, authorEmail: settings.authorEmail || undefined,
}); });
return { postPath, imageCount: plan.length, unresolved }; return { postPath: notePath, imageCount: plan.length, unresolved };
} }

View File

@@ -4,7 +4,7 @@ import { Pair } from "./frontmatter";
export interface JekyllPublishSettings { export interface JekyllPublishSettings {
remoteUrl: string; remoteUrl: string;
branch: string; branch: string;
postsDir: string; publishDir: string;
imagesDir: string; imagesDir: string;
defaultImageStrategy: Strategy; defaultImageStrategy: Strategy;
presetFrontmatter: Pair[]; presetFrontmatter: Pair[];
@@ -16,7 +16,7 @@ export interface JekyllPublishSettings {
export const DEFAULT_SETTINGS: JekyllPublishSettings = { export const DEFAULT_SETTINGS: JekyllPublishSettings = {
remoteUrl: "", remoteUrl: "",
branch: "main", branch: "main",
postsDir: "_posts", publishDir: "_posts",
imagesDir: "assets/img", imagesDir: "assets/img",
defaultImageStrategy: "flat-slug", defaultImageStrategy: "flat-slug",
presetFrontmatter: [], presetFrontmatter: [],

View File

@@ -1,5 +1,5 @@
import { describe, expect, test } from "vitest"; import { describe, expect, test } from "vitest";
import { slugify, deriveSlug, deriveDate, postFilename } from "./slug"; import { slugify, deriveSlug, deriveDate, pageFilename } from "./slug";
describe("slugify", () => { describe("slugify", () => {
test("lowercases, drops apostrophes, hyphenates", () => { test("lowercases, drops apostrophes, hyphenates", () => {
@@ -19,14 +19,18 @@ describe("deriveSlug", () => {
describe("deriveDate", () => { describe("deriveDate", () => {
const now = new Date("2026-06-19T12:00:00Z"); const now = new Date("2026-06-19T12:00:00Z");
test("uses frontmatter Date or string, else now", () => { 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: new Date("2026-02-06T00:00:00Z"), })).toBe("2026-02-06");
expect(deriveDate({ frontmatterDate: "2026-02-18 09:00", now })).toBe("2026-02-18"); expect(deriveDate({ frontmatterDate: "2026-02-18 09:00" })).toBe("2026-02-18");
expect(deriveDate({ now })).toBe("2026-06-19"); expect(deriveDate({ frontmatterDate: "unparsable" })).toBeUndefined();
}); });
}); });
describe("postFilename", () => { describe("postFilename", () => {
test("joins date and slug", () => { 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");
}); });
}); });

View File

@@ -12,11 +12,10 @@ export function deriveSlug(o: { title?: string; filename: string }): string {
return slugify(base); return slugify(base);
} }
export function deriveDate(o: { frontmatterDate?: unknown; now: Date }): string { export function deriveDate(o: { frontmatterDate?: unknown }): string | undefined {
const d = o.frontmatterDate; const d = o.frontmatterDate;
if (d instanceof Date && !isNaN(d.getTime())) return fmt(d); 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); 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 { function fmt(d: Date): string {
@@ -24,6 +23,9 @@ function fmt(d: Date): string {
return `${d.getUTCFullYear()}-${p(d.getUTCMonth() + 1)}-${p(d.getUTCDate())}`; return `${d.getUTCFullYear()}-${p(d.getUTCMonth() + 1)}-${p(d.getUTCDate())}`;
} }
export function postFilename(o: { date: string; slug: string }): string { export function pageFilename(o: { date?: string; slug: string }): string {
return `${o.date}-${o.slug}.md`; if (o.date) {
return `${o.date}-${o.slug}.md`;
}
return `${o.slug}.md`;
} }