Merge pull request 'feat/publish-any-page' (#1) from feat/publish-any-page into main
Some checks failed
CI / test (push) Has been cancelled
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:
81
README.md
81
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/<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.
|
||||
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/
|
||||
```
|
||||
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 `<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
|
||||
|
||||
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.
|
||||
|
||||
@@ -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<Buffer | null> }`
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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" });
|
||||
|
||||
11
src/main.ts
11
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)`
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
|
||||
@@ -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<Buffer | null>;
|
||||
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<PublishResult> {
|
||||
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 };
|
||||
}
|
||||
|
||||
@@ -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: [],
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
10
src/slug.ts
10
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`;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user