Compare commits
12 Commits
0.1.0
...
d730ead6ae
| Author | SHA1 | Date | |
|---|---|---|---|
| d730ead6ae | |||
| e09d5e5e2f | |||
| c31e7323b8 | |||
| e4117a3d28 | |||
| 87d27f2f4c | |||
| 06350d6c62 | |||
| ac37b6a6f5 | |||
| a135038b40 | |||
|
|
4be12fb6e6 | ||
| fb5d11dd6a | |||
|
|
f2a4040ae8 | ||
| 61910acac3 |
62
README.md
62
README.md
@@ -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,12 +22,13 @@ 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` |
|
||||||
@@ -36,17 +41,35 @@ An Obsidian plugin that publishes the active note as a Jekyll blog post via git.
|
|||||||
|
|
||||||
## 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. No passwords or tokens are stored inside Obsidian or the plugin's data files.
|
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:
|
||||||
|
|
||||||
When git needs a credential (e.g. HTTPS password or a personal access token), an **askpass bridge** intercepts the prompt and shows a native Obsidian modal so you can type the value. The value is passed directly to git through a temporary socket and is never persisted.
|
- **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.
|
||||||
|
|
||||||
For SSH remotes, the plugin relies on your existing SSH agent or `~/.ssh` key configuration — no extra steps needed.
|
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
|
||||||
|
|
||||||
@@ -69,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:**
|
||||||
|
|
||||||
@@ -86,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.
|
||||||
|
|||||||
@@ -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> }`
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
{
|
{
|
||||||
"id": "jekyll-publish",
|
"id": "jekyll-publish",
|
||||||
"name": "Jekyll Publish",
|
"name": "Jekyll Publish",
|
||||||
"version": "0.1.0",
|
"version": "0.1.3",
|
||||||
"minAppVersion": "1.5.0",
|
"minAppVersion": "1.5.0",
|
||||||
"description": "Publish the active note as a Jekyll post (with images) via git.",
|
"description": "Publish the active note as a Jekyll post, with images, via git.",
|
||||||
"author": "Claude",
|
"author": "Brendan Chen",
|
||||||
"isDesktopOnly": true
|
"isDesktopOnly": true
|
||||||
}
|
}
|
||||||
|
|||||||
27
package-lock.json
generated
27
package-lock.json
generated
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "obsidian-jekyll-publish",
|
"name": "obsidian-jekyll-publish",
|
||||||
"version": "0.1.0",
|
"version": "0.1.3",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "obsidian-jekyll-publish",
|
"name": "obsidian-jekyll-publish",
|
||||||
"version": "0.1.0",
|
"version": "0.1.3",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"js-yaml": "^4.1.0"
|
"js-yaml": "^4.1.0"
|
||||||
@@ -1197,9 +1197,9 @@
|
|||||||
"dev": true
|
"dev": true
|
||||||
},
|
},
|
||||||
"node_modules/js-yaml": {
|
"node_modules/js-yaml": {
|
||||||
"version": "4.2.0",
|
"version": "4.3.1",
|
||||||
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.2.0.tgz",
|
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.1.tgz",
|
||||||
"integrity": "sha512-ePWsvanv0DWuDRsW8dnt+R4jQ31SCRCQ7hhNcPXZPsoBZiemuZNYGf7adZdqX2D86j6rvKp3RpCxVTSb8WQlOw==",
|
"integrity": "sha512-CY6crGq313MX8GkwvB7tzgp99vjQxY1++5y10/BKN/GUfHqWaOGQMNZkBvqSzsZKWk/ijwHlWzzkLulsGHhjWQ==",
|
||||||
"funding": [
|
"funding": [
|
||||||
{
|
{
|
||||||
"type": "github",
|
"type": "github",
|
||||||
@@ -1210,6 +1210,7 @@
|
|||||||
"url": "https://github.com/sponsors/nodeca"
|
"url": "https://github.com/sponsors/nodeca"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"argparse": "^2.0.1"
|
"argparse": "^2.0.1"
|
||||||
},
|
},
|
||||||
@@ -1303,9 +1304,9 @@
|
|||||||
"dev": true
|
"dev": true
|
||||||
},
|
},
|
||||||
"node_modules/nanoid": {
|
"node_modules/nanoid": {
|
||||||
"version": "3.3.13",
|
"version": "3.3.18",
|
||||||
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.13.tgz",
|
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.18.tgz",
|
||||||
"integrity": "sha512-sPdqC6ByMVVGvF1ynvvMo0/o+oD1VX7DaHhijt1bFgjvBkHBib4t49GoNDhf2NDta4oeUNlaGbSt5K7qjZ955Q==",
|
"integrity": "sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"funding": [
|
"funding": [
|
||||||
{
|
{
|
||||||
@@ -1313,6 +1314,7 @@
|
|||||||
"url": "https://github.com/sponsors/ai"
|
"url": "https://github.com/sponsors/ai"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
|
"license": "MIT",
|
||||||
"bin": {
|
"bin": {
|
||||||
"nanoid": "bin/nanoid.cjs"
|
"nanoid": "bin/nanoid.cjs"
|
||||||
},
|
},
|
||||||
@@ -1469,9 +1471,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/postcss": {
|
"node_modules/postcss": {
|
||||||
"version": "8.5.15",
|
"version": "8.5.26",
|
||||||
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.15.tgz",
|
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.26.tgz",
|
||||||
"integrity": "sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==",
|
"integrity": "sha512-u82N74LFzG8ca+dD8puPnplTXoGH4fTPpVGuIbt36G3qvNlkvfD0lEAZSxaly3KX8TS/L1A1gsCEmvKmBcVbkQ==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"funding": [
|
"funding": [
|
||||||
{
|
{
|
||||||
@@ -1487,8 +1489,9 @@
|
|||||||
"url": "https://github.com/sponsors/ai"
|
"url": "https://github.com/sponsors/ai"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"nanoid": "^3.3.12",
|
"nanoid": "^3.3.17",
|
||||||
"picocolors": "^1.1.1",
|
"picocolors": "^1.1.1",
|
||||||
"source-map-js": "^1.2.1"
|
"source-map-js": "^1.2.1"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "obsidian-jekyll-publish",
|
"name": "obsidian-jekyll-publish",
|
||||||
"version": "0.1.0",
|
"version": "0.1.3",
|
||||||
"description": "Publish the active Obsidian note as a Jekyll post (with images) via git.",
|
"description": "Publish the active Obsidian note as a Jekyll post (with images) via git.",
|
||||||
"main": "dist/main.js",
|
"main": "dist/main.js",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { App, Modal, Setting } from "obsidian";
|
import { App, Modal, Setting } from "obsidian";
|
||||||
import { Pair, parseNote, resolveFrontmatter } from "./frontmatter";
|
import { Pair, composeFrontmatter, docFrontmatterToPairs, parseNote } from "./frontmatter";
|
||||||
import { Strategy } from "./images";
|
import { Strategy } from "./images";
|
||||||
import { JekyllPublishSettings } from "./settings";
|
import { JekyllPublishSettings } from "./settings";
|
||||||
import { deriveDate, deriveSlug } from "./slug";
|
import { deriveDate, deriveSlug } from "./slug";
|
||||||
@@ -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;
|
||||||
}
|
}
|
||||||
@@ -15,35 +15,37 @@ export interface ModalResult {
|
|||||||
export class PublishModal extends Modal {
|
export class PublishModal extends Modal {
|
||||||
private result: ModalResult;
|
private result: ModalResult;
|
||||||
private mergeDoc = true;
|
private mergeDoc = true;
|
||||||
private custom: Pair[] = [];
|
/** Editable rows = preset fields (seeded from settings) + custom additions. */
|
||||||
|
private editableRows: Pair[];
|
||||||
|
/** The note's own frontmatter, shown read-only when merge is on. */
|
||||||
|
private readonly docPairs: Pair[];
|
||||||
|
private fmEl!: HTMLElement;
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
app: App,
|
app: App,
|
||||||
private settings: JekyllPublishSettings,
|
settings: JekyllPublishSettings,
|
||||||
private noteText: string,
|
private noteText: string,
|
||||||
private filename: string,
|
filename: string,
|
||||||
private onSubmit: (r: ModalResult) => void
|
private onSubmit: (r: ModalResult) => void
|
||||||
) {
|
) {
|
||||||
super(app);
|
super(app);
|
||||||
const { frontmatter } = parseNote(noteText);
|
const { frontmatter } = parseNote(noteText);
|
||||||
const title = typeof frontmatter.title === "string" ? frontmatter.title : "";
|
const title = typeof frontmatter.title === "string" ? frontmatter.title : "";
|
||||||
|
this.docPairs = docFrontmatterToPairs(frontmatter);
|
||||||
|
// Deep-copy the presets so editing/deleting rows here never mutates the
|
||||||
|
// saved settings.
|
||||||
|
this.editableRows = settings.presetFrontmatter.map((p) => ({ key: p.key, value: p.value }));
|
||||||
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),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
private recompute() {
|
private recompute() {
|
||||||
const { frontmatter } = parseNote(this.noteText);
|
this.result.frontmatterPairs = composeFrontmatter(this.docPairs, this.editableRows, this.mergeDoc);
|
||||||
this.result.frontmatterPairs = resolveFrontmatter(
|
|
||||||
this.settings.presetFrontmatter,
|
|
||||||
frontmatter,
|
|
||||||
this.custom,
|
|
||||||
this.mergeDoc
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
onOpen() {
|
onOpen() {
|
||||||
@@ -55,24 +57,32 @@ 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")
|
||||||
.setValue(this.result.strategy)
|
.setValue(this.result.strategy)
|
||||||
.onChange((v) => (this.result.strategy = v as Strategy))
|
.onChange((v) => (this.result.strategy = v as Strategy))
|
||||||
);
|
);
|
||||||
new Setting(contentEl).setName("Merge document frontmatter").addToggle((t) =>
|
new Setting(contentEl)
|
||||||
t.setValue(this.mergeDoc).onChange((v) => { this.mergeDoc = v; this.renderFrontmatter(); })
|
.setName("Merge document frontmatter")
|
||||||
|
.setDesc("Include the note's own frontmatter (shown read-only below).")
|
||||||
|
.addToggle((t) =>
|
||||||
|
t.setValue(this.mergeDoc).onChange((v) => {
|
||||||
|
this.mergeDoc = v;
|
||||||
|
this.renderFrontmatter();
|
||||||
|
})
|
||||||
);
|
);
|
||||||
|
|
||||||
|
contentEl.createEl("h3", { text: "Frontmatter" });
|
||||||
this.fmEl = contentEl.createDiv();
|
this.fmEl = contentEl.createDiv();
|
||||||
this.renderFrontmatter();
|
this.renderFrontmatter();
|
||||||
|
|
||||||
new Setting(contentEl).addButton((b) =>
|
new Setting(contentEl).addButton((b) =>
|
||||||
b.setButtonText("Add property").onClick(() => {
|
b.setButtonText("Add property").onClick(() => {
|
||||||
this.custom.push({ key: "", value: "" });
|
this.editableRows = [...this.editableRows, { key: "", value: "" }];
|
||||||
this.renderFrontmatter();
|
this.renderFrontmatter();
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
@@ -88,25 +98,52 @@ export class PublishModal extends Modal {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
private fmEl!: HTMLElement;
|
|
||||||
private renderFrontmatter() {
|
private renderFrontmatter() {
|
||||||
this.recompute();
|
this.recompute();
|
||||||
this.fmEl.empty();
|
this.fmEl.empty();
|
||||||
this.fmEl.createEl("h3", { text: "Frontmatter (resolved)" });
|
|
||||||
for (const p of this.result.frontmatterPairs) {
|
const editableKeys = new Set(
|
||||||
this.fmEl.createDiv({ cls: "jekyll-publish-row", text: `${p.key}: ${p.value}` });
|
this.editableRows.filter((r) => r.key.trim() !== "").map((r) => r.key)
|
||||||
}
|
);
|
||||||
if (this.custom.length) {
|
|
||||||
this.fmEl.createEl("h4", { text: "Custom properties" });
|
// Document fields (read-only) first — only those not overridden by an
|
||||||
this.custom.forEach((row, i) => {
|
// editable row, so the modal never shows a key twice.
|
||||||
const div = this.fmEl.createDiv({ cls: "jekyll-publish-row" });
|
if (this.mergeDoc) {
|
||||||
const k = div.createEl("input", { value: row.key, placeholder: "key" });
|
const docRows = this.docPairs.filter((p) => !editableKeys.has(p.key));
|
||||||
const v = div.createEl("input", { value: row.value, placeholder: "value" });
|
for (const p of docRows) {
|
||||||
k.oninput = () => { this.custom[i].key = k.value; this.recompute(); };
|
const row = this.fmEl.createDiv({ cls: "jekyll-publish-row" });
|
||||||
v.oninput = () => { this.custom[i].value = v.value; this.recompute(); };
|
const k = row.createEl("input", { cls: "jekyll-publish-doc", value: p.key });
|
||||||
});
|
const v = row.createEl("input", { cls: "jekyll-publish-doc", value: p.value });
|
||||||
|
k.disabled = true;
|
||||||
|
v.disabled = true;
|
||||||
|
k.title = "From the note's frontmatter (edit the note to change)";
|
||||||
|
v.title = k.title;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
onClose() { this.contentEl.empty(); }
|
// Editable rows (presets + custom) — same style, all editable and deletable.
|
||||||
|
this.editableRows.forEach((row, i) => {
|
||||||
|
const div = this.fmEl.createDiv({ cls: "jekyll-publish-row" });
|
||||||
|
const k = div.createEl("input", { value: row.key, placeholder: "key" });
|
||||||
|
const v = div.createEl("input", { value: row.value, placeholder: "value" });
|
||||||
|
k.oninput = () => {
|
||||||
|
this.editableRows = this.editableRows.map((r, j) => (j === i ? { ...r, key: k.value } : r));
|
||||||
|
this.recompute();
|
||||||
|
};
|
||||||
|
v.oninput = () => {
|
||||||
|
this.editableRows = this.editableRows.map((r, j) => (j === i ? { ...r, value: v.value } : r));
|
||||||
|
this.recompute();
|
||||||
|
};
|
||||||
|
const del = div.createEl("button", { cls: "jekyll-publish-del", text: "×" });
|
||||||
|
del.setAttr("aria-label", "Delete property");
|
||||||
|
del.onclick = () => {
|
||||||
|
this.editableRows = this.editableRows.filter((_, j) => j !== i);
|
||||||
|
this.renderFrontmatter();
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
onClose() {
|
||||||
|
this.contentEl.empty();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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" });
|
||||||
|
|||||||
@@ -1,83 +0,0 @@
|
|||||||
import { afterEach, expect, test } from "vitest";
|
|
||||||
import { execFile } from "node:child_process";
|
|
||||||
import { mkdtempSync, rmSync } from "node:fs";
|
|
||||||
import { tmpdir } from "node:os";
|
|
||||||
import { join } from "node:path";
|
|
||||||
import { promisify } from "node:util";
|
|
||||||
import { AskpassBridge } from "./askpass";
|
|
||||||
|
|
||||||
const run = promisify(execFile);
|
|
||||||
let bridge: AskpassBridge | undefined;
|
|
||||||
afterEach(async () => {
|
|
||||||
bridge?.stop();
|
|
||||||
bridge = undefined;
|
|
||||||
});
|
|
||||||
|
|
||||||
test("helper script round-trips a prompt to onPrompt and returns the answer", async () => {
|
|
||||||
const dir = mkdtempSync(join(tmpdir(), "jp-ask-"));
|
|
||||||
bridge = new AskpassBridge({
|
|
||||||
dir,
|
|
||||||
onPrompt: async (p) => (p.includes("Password") ? "s3cret" : "alice"),
|
|
||||||
});
|
|
||||||
const env = await bridge.start();
|
|
||||||
const { stdout } = await run(env.GIT_ASKPASS, ["Password for 'https://x':"], {
|
|
||||||
env: { ...process.env, JEKYLL_ASKPASS_SOCK: env.JEKYLL_ASKPASS_SOCK },
|
|
||||||
});
|
|
||||||
expect(stdout.trim()).toBe("s3cret");
|
|
||||||
rmSync(dir, { recursive: true, force: true });
|
|
||||||
});
|
|
||||||
|
|
||||||
test("onPrompt rejection: helper process completes promptly and returns empty answer", async () => {
|
|
||||||
const dir = mkdtempSync(join(tmpdir(), "jp-ask-reject-"));
|
|
||||||
bridge = new AskpassBridge({
|
|
||||||
dir,
|
|
||||||
onPrompt: async (_p) => { throw new Error("user cancelled"); },
|
|
||||||
});
|
|
||||||
const env = await bridge.start();
|
|
||||||
|
|
||||||
// The helper must exit within 3 seconds; if it hangs the test times out with a clear error.
|
|
||||||
const result = await Promise.race([
|
|
||||||
run(env.GIT_ASKPASS, ["Password:"], {
|
|
||||||
env: { ...process.env, JEKYLL_ASKPASS_SOCK: env.JEKYLL_ASKPASS_SOCK },
|
|
||||||
}).then(({ stdout }) => ({ timedOut: false, stdout }))
|
|
||||||
.catch(() => ({ timedOut: false, stdout: "" })),
|
|
||||||
new Promise<{ timedOut: true; stdout: string }>((resolve) =>
|
|
||||||
setTimeout(() => resolve({ timedOut: true, stdout: "" }), 3000)
|
|
||||||
),
|
|
||||||
]);
|
|
||||||
|
|
||||||
expect(result.timedOut, "helper process hung instead of exiting promptly").toBe(false);
|
|
||||||
// Empty answer is fine — git will fail fast on its own
|
|
||||||
expect(result.stdout.trim()).toBe("");
|
|
||||||
rmSync(dir, { recursive: true, force: true });
|
|
||||||
});
|
|
||||||
|
|
||||||
test("restart on same dir: stop() then start() again succeeds with no EADDRINUSE", async () => {
|
|
||||||
const dir = mkdtempSync(join(tmpdir(), "jp-ask-restart-"));
|
|
||||||
|
|
||||||
// First session
|
|
||||||
bridge = new AskpassBridge({
|
|
||||||
dir,
|
|
||||||
onPrompt: async (p) => (p.includes("user") ? "bob" : "pass1"),
|
|
||||||
});
|
|
||||||
const env1 = await bridge.start();
|
|
||||||
const { stdout: out1 } = await run(env1.GIT_ASKPASS, ["username:"], {
|
|
||||||
env: { ...process.env, JEKYLL_ASKPASS_SOCK: env1.JEKYLL_ASKPASS_SOCK },
|
|
||||||
});
|
|
||||||
expect(out1.trim()).toBe("bob");
|
|
||||||
bridge.stop();
|
|
||||||
bridge = undefined;
|
|
||||||
|
|
||||||
// Second session — same dir; must not throw EADDRINUSE
|
|
||||||
bridge = new AskpassBridge({
|
|
||||||
dir,
|
|
||||||
onPrompt: async (p) => (p.includes("Password") ? "newpass" : "carol"),
|
|
||||||
});
|
|
||||||
const env2 = await bridge.start();
|
|
||||||
const { stdout: out2 } = await run(env2.GIT_ASKPASS, ["Password for 'https://y':"], {
|
|
||||||
env: { ...process.env, JEKYLL_ASKPASS_SOCK: env2.JEKYLL_ASKPASS_SOCK },
|
|
||||||
});
|
|
||||||
expect(out2.trim()).toBe("newpass");
|
|
||||||
|
|
||||||
rmSync(dir, { recursive: true, force: true });
|
|
||||||
});
|
|
||||||
@@ -1,82 +0,0 @@
|
|||||||
import { createServer, Server } from "node:net";
|
|
||||||
import { chmodSync, unlinkSync, writeFileSync } from "node:fs";
|
|
||||||
import { join } from "node:path";
|
|
||||||
|
|
||||||
export class AskpassBridge {
|
|
||||||
private server?: Server;
|
|
||||||
private sock = "";
|
|
||||||
private script = "";
|
|
||||||
|
|
||||||
constructor(private opts: { dir: string; onPrompt: (prompt: string) => Promise<string> }) {}
|
|
||||||
|
|
||||||
async start(): Promise<{
|
|
||||||
GIT_ASKPASS: string;
|
|
||||||
SSH_ASKPASS: string;
|
|
||||||
SSH_ASKPASS_REQUIRE: string;
|
|
||||||
JEKYLL_ASKPASS_SOCK: string;
|
|
||||||
}> {
|
|
||||||
this.sock = join(this.opts.dir, "askpass.sock");
|
|
||||||
this.script = join(this.opts.dir, "askpass.sh");
|
|
||||||
|
|
||||||
// Best-effort removal of a stale socket from a previous unclean shutdown.
|
|
||||||
try { unlinkSync(this.sock); } catch { /* ignore ENOENT */ }
|
|
||||||
|
|
||||||
this.server = createServer({ allowHalfOpen: true }, (conn) => {
|
|
||||||
let buf = "";
|
|
||||||
conn.on("data", (d) => (buf += d.toString()));
|
|
||||||
conn.on("end", () => {
|
|
||||||
// Wrap onPrompt in try/catch so a rejection never hangs the git/ssh process.
|
|
||||||
this.opts.onPrompt(buf.replace(/\n$/, "")).then(
|
|
||||||
(answer) => {
|
|
||||||
conn.end(answer.endsWith("\n") ? answer : answer + "\n");
|
|
||||||
},
|
|
||||||
(_err) => {
|
|
||||||
// User cancelled or onPrompt threw — send empty reply so git fails fast.
|
|
||||||
conn.end("\n");
|
|
||||||
}
|
|
||||||
);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// Fix: reject the promise on listen error (e.g. EADDRINUSE) instead of hanging.
|
|
||||||
await new Promise<void>((res, rej) => {
|
|
||||||
this.server!.once("error", rej);
|
|
||||||
this.server!.listen(this.sock, () => {
|
|
||||||
this.server!.removeListener("error", rej);
|
|
||||||
res();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// Helper: send argv[1] (the prompt) to the socket, print the reply.
|
|
||||||
// Uses a Node one-liner for reliable EOF signalling across platforms
|
|
||||||
// (nc -U without -N hangs on BSD-derived netcat; socat may also vary).
|
|
||||||
// Protocol: client writes prompt + "\n", server replies with answer + "\n" and closes.
|
|
||||||
// allowHalfOpen ensures the client can still receive after calling end().
|
|
||||||
const sh = [
|
|
||||||
"#!/bin/sh",
|
|
||||||
'node -e "' +
|
|
||||||
"const n=require('net'),s=process.env.JEKYLL_ASKPASS_SOCK,p=process.argv[1];" +
|
|
||||||
"const c=n.createConnection({path:s,allowHalfOpen:true},()=>{c.end(p+'\\n')});" +
|
|
||||||
"c.on('data',d=>process.stdout.write(d));" +
|
|
||||||
'" -- "$1"',
|
|
||||||
"",
|
|
||||||
].join("\n");
|
|
||||||
writeFileSync(this.script, sh);
|
|
||||||
chmodSync(this.script, 0o755);
|
|
||||||
|
|
||||||
return {
|
|
||||||
GIT_ASKPASS: this.script,
|
|
||||||
SSH_ASKPASS: this.script,
|
|
||||||
SSH_ASKPASS_REQUIRE: "force",
|
|
||||||
JEKYLL_ASKPASS_SOCK: this.sock,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
stop(): void {
|
|
||||||
this.server?.close();
|
|
||||||
this.server = undefined;
|
|
||||||
// Clean up socket and helper script to prevent stale-socket EADDRINUSE on restart.
|
|
||||||
try { unlinkSync(this.sock); } catch { /* ignore ENOENT */ }
|
|
||||||
try { unlinkSync(this.script); } catch { /* ignore ENOENT */ }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import { describe, expect, test } from "vitest";
|
import { describe, expect, test } from "vitest";
|
||||||
import { parseNote, resolveFrontmatter, serializeFrontmatter } from "./frontmatter";
|
import { composeFrontmatter, docFrontmatterToPairs, parseNote, serializeFrontmatter } from "./frontmatter";
|
||||||
|
|
||||||
describe("parseNote", () => {
|
describe("parseNote", () => {
|
||||||
test("splits frontmatter and body, strips BOM", () => {
|
test("splits frontmatter and body, strips BOM", () => {
|
||||||
@@ -12,27 +12,70 @@ describe("parseNote", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("resolveFrontmatter", () => {
|
describe("docFrontmatterToPairs", () => {
|
||||||
const presets = [{ key: "layout", value: "post" }, { key: "kind", value: "essay" }];
|
test("converts a record to ordered string pairs", () => {
|
||||||
test("presets only when mergeDoc is false", () => {
|
const pairs = docFrontmatterToPairs({
|
||||||
expect(resolveFrontmatter(presets, { title: "X" }, [], false)).toEqual(presets);
|
title: "Hello",
|
||||||
|
date: new Date("2026-02-06T00:00:00Z"),
|
||||||
|
draft: true,
|
||||||
});
|
});
|
||||||
test("merge appends doc keys and doc overrides preset value", () => {
|
expect(pairs).toEqual([
|
||||||
const r = resolveFrontmatter(presets, { kind: "note", title: "X" }, [], true);
|
{ key: "title", value: "Hello" },
|
||||||
expect(r).toEqual([
|
{ key: "date", value: "2026-02-06" },
|
||||||
{ key: "layout", value: "post" },
|
{ key: "draft", value: "true" },
|
||||||
{ key: "kind", value: "note" },
|
|
||||||
{ key: "title", value: "X" },
|
|
||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
test("custom rows come last and override everything", () => {
|
test("empty record yields an empty array", () => {
|
||||||
const r = resolveFrontmatter(presets, {}, [{ key: "layout", value: "page" }], false);
|
expect(docFrontmatterToPairs({})).toEqual([]);
|
||||||
expect(r[0]).toEqual({ key: "layout", value: "page" });
|
|
||||||
expect(r).toHaveLength(2);
|
|
||||||
});
|
});
|
||||||
test("date object is rendered as YYYY-MM-DD", () => {
|
});
|
||||||
const r = resolveFrontmatter([], { date: new Date("2026-06-11T00:00:00Z") }, [], true);
|
|
||||||
expect(r).toEqual([{ key: "date", value: "2026-06-11" }]);
|
describe("composeFrontmatter", () => {
|
||||||
|
const doc = [
|
||||||
|
{ key: "title", value: "Hello" },
|
||||||
|
{ key: "date", value: "2026-02-06" },
|
||||||
|
];
|
||||||
|
const editable = [
|
||||||
|
{ key: "layout", value: "post" },
|
||||||
|
{ key: "kind", value: "essay" },
|
||||||
|
];
|
||||||
|
|
||||||
|
test("merge on: document fields first, then editable rows", () => {
|
||||||
|
expect(composeFrontmatter(doc, editable, true)).toEqual([
|
||||||
|
{ key: "title", value: "Hello" },
|
||||||
|
{ key: "date", value: "2026-02-06" },
|
||||||
|
{ key: "layout", value: "post" },
|
||||||
|
{ key: "kind", value: "essay" },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
test("merge off: only the editable rows", () => {
|
||||||
|
expect(composeFrontmatter(doc, editable, false)).toEqual(editable);
|
||||||
|
});
|
||||||
|
test("an editable row overrides a document field with the same key (doc field dropped)", () => {
|
||||||
|
const ed = [
|
||||||
|
{ key: "title", value: "Override" },
|
||||||
|
{ key: "layout", value: "post" },
|
||||||
|
];
|
||||||
|
expect(composeFrontmatter(doc, ed, true)).toEqual([
|
||||||
|
{ key: "date", value: "2026-02-06" },
|
||||||
|
{ key: "title", value: "Override" },
|
||||||
|
{ key: "layout", value: "post" },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
test("blank-key editable rows are skipped", () => {
|
||||||
|
const ed = [
|
||||||
|
{ key: "", value: "" },
|
||||||
|
{ key: "layout", value: "post" },
|
||||||
|
{ key: " ", value: "x" },
|
||||||
|
];
|
||||||
|
expect(composeFrontmatter([], ed, true)).toEqual([{ key: "layout", value: "post" }]);
|
||||||
|
});
|
||||||
|
test("duplicate editable keys: last value wins at first position", () => {
|
||||||
|
const ed = [
|
||||||
|
{ key: "tag", value: "a" },
|
||||||
|
{ key: "tag", value: "b" },
|
||||||
|
];
|
||||||
|
expect(composeFrontmatter([], ed, false)).toEqual([{ key: "tag", value: "b" }]);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -14,12 +14,26 @@ export function parseNote(text: string): { frontmatter: Record<string, unknown>;
|
|||||||
return { frontmatter, body: stripped.slice(m[0].length) };
|
return { frontmatter, body: stripped.slice(m[0].length) };
|
||||||
}
|
}
|
||||||
|
|
||||||
export function resolveFrontmatter(
|
/**
|
||||||
presets: Pair[],
|
* Convert a parsed note's frontmatter object into ordered, stringified pairs
|
||||||
docFrontmatter: Record<string, unknown>,
|
* suitable for display (read-only) and composition.
|
||||||
custom: Pair[],
|
*/
|
||||||
mergeDoc: boolean
|
export function docFrontmatterToPairs(docFrontmatter: Record<string, unknown>): Pair[] {
|
||||||
): Pair[] {
|
return Object.entries(docFrontmatter).map(([key, v]) => ({ key, value: stringifyScalar(v) }));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build the final frontmatter for the post.
|
||||||
|
*
|
||||||
|
* Editable rows (the preset fields seeded from settings plus any custom rows
|
||||||
|
* the user added) are authoritative: on a key collision they win over the
|
||||||
|
* document's own merged fields, which appear first (and are shown read-only in
|
||||||
|
* the modal). Blank-key rows are dropped; duplicate editable keys keep the last
|
||||||
|
* value at the first position.
|
||||||
|
*/
|
||||||
|
export function composeFrontmatter(docPairs: Pair[], editableRows: Pair[], mergeDoc: boolean): Pair[] {
|
||||||
|
const editable = editableRows.filter((r) => r.key.trim() !== "");
|
||||||
|
const editableKeys = new Set(editable.map((r) => r.key));
|
||||||
const out: Pair[] = [];
|
const out: Pair[] = [];
|
||||||
const idx = new Map<string, number>();
|
const idx = new Map<string, number>();
|
||||||
const put = (key: string, value: string) => {
|
const put = (key: string, value: string) => {
|
||||||
@@ -27,9 +41,8 @@ export function resolveFrontmatter(
|
|||||||
if (at !== undefined) out[at] = { key, value };
|
if (at !== undefined) out[at] = { key, value };
|
||||||
else { idx.set(key, out.length); out.push({ key, value }); }
|
else { idx.set(key, out.length); out.push({ key, value }); }
|
||||||
};
|
};
|
||||||
for (const p of presets) put(p.key, p.value);
|
if (mergeDoc) for (const p of docPairs) if (!editableKeys.has(p.key)) put(p.key, p.value);
|
||||||
if (mergeDoc) for (const [k, v] of Object.entries(docFrontmatter)) put(k, stringifyScalar(v));
|
for (const r of editable) put(r.key, r.value);
|
||||||
for (const c of custom) put(c.key, c.value);
|
|
||||||
return out;
|
return out;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
83
src/main.ts
83
src/main.ts
@@ -1,9 +1,8 @@
|
|||||||
import { Modal, Notice, Plugin, Setting, TFile, normalizePath } from "obsidian";
|
import { Notice, Plugin, TFile } from "obsidian";
|
||||||
import { DEFAULT_SETTINGS, JekyllPublishSettings } from "./settings";
|
import { DEFAULT_SETTINGS, JekyllPublishSettings } from "./settings";
|
||||||
import { JekyllPublishSettingTab } from "./SettingsTab";
|
import { JekyllPublishSettingTab } from "./SettingsTab";
|
||||||
import { ModalResult, PublishModal } from "./PublishModal";
|
import { ModalResult, PublishModal } from "./PublishModal";
|
||||||
import { ChildProcessGitClient } from "./git";
|
import { ChildProcessGitClient } from "./git";
|
||||||
import { AskpassBridge } from "./askpass";
|
|
||||||
import { publish } from "./publish";
|
import { publish } from "./publish";
|
||||||
|
|
||||||
export default class JekyllPublishPlugin extends Plugin {
|
export default class JekyllPublishPlugin extends Plugin {
|
||||||
@@ -43,31 +42,35 @@ export default class JekyllPublishPlugin extends Plugin {
|
|||||||
new Notice("Jekyll Publish: set a remote URL in settings first.");
|
new Notice("Jekyll Publish: set a remote URL in settings first.");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const dir = normalizePath(this.app.vault.configDir + "/plugins/jekyll-publish");
|
|
||||||
const adapter = this.app.vault.adapter as any;
|
|
||||||
if (typeof adapter.getFullPath !== "function") {
|
|
||||||
new Notice("Jekyll Publish requires desktop Obsidian (filesystem access unavailable).");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const bridge = new AskpassBridge({
|
|
||||||
dir: adapter.getFullPath(dir),
|
|
||||||
onPrompt: (prompt) => this.promptCredential(prompt),
|
|
||||||
});
|
|
||||||
try {
|
try {
|
||||||
const env = await bridge.start();
|
// Credentials are handled entirely by the user's system git: its
|
||||||
const git = new ChildProcessGitClient({ env });
|
// configured credential helper (Git Credential Manager on Windows,
|
||||||
|
// osxkeychain on macOS, libsecret on Linux) for https:// URLs, or the
|
||||||
|
// ssh-agent / key for ssh:// URLs. The plugin stores nothing and sets
|
||||||
|
// GIT_TERMINAL_PROMPT=0 (in ChildProcessGitClient) so git fails fast
|
||||||
|
// with a clear message instead of hanging when no helper is available.
|
||||||
|
const git = new ChildProcessGitClient();
|
||||||
const result = await publish(
|
const result = await publish(
|
||||||
{ 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 ? ` (${result.unresolved.length} image(s) unresolved)` : "";
|
const warn = result.unresolved.length
|
||||||
|
? ` (${result.unresolved.length} image(s) unresolved)`
|
||||||
|
: "";
|
||||||
new Notice(`Published ${result.postPath} with ${result.imageCount} image(s)${warn}`);
|
new Notice(`Published ${result.postPath} with ${result.imageCount} image(s)${warn}`);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
new Notice(`Publish failed: ${(e as Error).message}`);
|
new Notice(`Publish failed: ${withCredentialHint((e as Error).message)}`, 12000);
|
||||||
} finally {
|
|
||||||
bridge.stop();
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -77,26 +80,24 @@ export default class JekyllPublishPlugin extends Plugin {
|
|||||||
const ab = await this.app.vault.readBinary(dest);
|
const ab = await this.app.vault.readBinary(dest);
|
||||||
return Buffer.from(ab);
|
return Buffer.from(ab);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
private promptCredential(prompt: string): Promise<string> {
|
|
||||||
return new Promise((resolve) => {
|
/**
|
||||||
const modal = new Modal(this.app);
|
* When git fails for an authentication reason, append guidance pointing the
|
||||||
modal.titleEl.setText("Git credentials");
|
* user at their system credential helper / ssh setup, since the plugin
|
||||||
modal.contentEl.createEl("p", { text: prompt });
|
* deliberately does not store or prompt for credentials itself.
|
||||||
let value = "";
|
*/
|
||||||
const masked = /pass|secret|token/i.test(prompt);
|
function withCredentialHint(message: string): string {
|
||||||
new Setting(modal.contentEl).addText((t) => {
|
const authPattern =
|
||||||
if (masked) t.inputEl.type = "password";
|
/could not read (Username|Password)|terminal prompts disabled|Authentication failed|Permission denied|access denied|fatal: Authentication|no email was given|Author identity unknown/i;
|
||||||
t.onChange((v) => (value = v));
|
if (authPattern.test(message)) {
|
||||||
t.inputEl.addEventListener("keydown", (e) => {
|
return (
|
||||||
if (e.key === "Enter") { modal.close(); resolve(value); }
|
`${message}\n\n` +
|
||||||
});
|
"Git could not authenticate or identify you. For an https:// URL, set up a " +
|
||||||
});
|
"git credential helper (e.g. Git Credential Manager). For an ssh:// URL, make " +
|
||||||
new Setting(modal.contentEl).addButton((b) =>
|
"sure your SSH key/agent is configured. You can also set an author name/email " +
|
||||||
b.setButtonText("OK").setCta().onClick(() => { modal.close(); resolve(value); })
|
"in the plugin settings."
|
||||||
);
|
);
|
||||||
modal.onClose = () => resolve(value);
|
}
|
||||||
modal.open();
|
return message;
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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');
|
||||||
|
});
|
||||||
|
|||||||
@@ -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 };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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: [],
|
||||||
|
|||||||
@@ -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");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
if (o.date) {
|
||||||
return `${o.date}-${o.slug}.md`;
|
return `${o.date}-${o.slug}.md`;
|
||||||
|
}
|
||||||
|
return `${o.slug}.md`;
|
||||||
}
|
}
|
||||||
|
|||||||
17
styles.css
17
styles.css
@@ -1,2 +1,17 @@
|
|||||||
.jekyll-publish-row { display: flex; gap: 8px; margin-bottom: 6px; }
|
.jekyll-publish-row { display: flex; gap: 8px; margin-bottom: 6px; align-items: center; }
|
||||||
.jekyll-publish-row input { flex: 1; }
|
.jekyll-publish-row input { flex: 1; }
|
||||||
|
|
||||||
|
/* Read-only document frontmatter fields shown when "Merge document
|
||||||
|
frontmatter" is on — visually muted to signal they cannot be edited here. */
|
||||||
|
.jekyll-publish-row input.jekyll-publish-doc {
|
||||||
|
opacity: 0.65;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Delete button on an editable property row. */
|
||||||
|
.jekyll-publish-del {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
width: 28px;
|
||||||
|
padding: 0;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user