Compare commits
17 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c5504b6999 | ||
| ac1fa01578 | |||
| 06f0d4bd60 | |||
| c87ad25d4d | |||
| a426e74684 | |||
| d730ead6ae | |||
| e09d5e5e2f | |||
| c31e7323b8 | |||
| e4117a3d28 | |||
| 87d27f2f4c | |||
| 06350d6c62 | |||
| ac37b6a6f5 | |||
| a135038b40 | |||
|
|
4be12fb6e6 | ||
| fb5d11dd6a | |||
|
|
f2a4040ae8 | ||
| 61910acac3 |
7
LICENSE
Normal file
7
LICENSE
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
Copyright © 2026 Brendan Chen
|
||||||
|
|
||||||
|
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the “Software”), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
|
||||||
|
|
||||||
|
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
|
||||||
|
|
||||||
|
THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||||
80
README.md
80
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,35 +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. 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.2.0",
|
||||||
"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.2.0",
|
||||||
"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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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(); }
|
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"),
|
||||||
test("merge appends doc keys and doc overrides preset value", () => {
|
draft: true,
|
||||||
const r = resolveFrontmatter(presets, { kind: "note", title: "X" }, [], true);
|
});
|
||||||
expect(r).toEqual([
|
expect(pairs).toEqual([
|
||||||
{ key: "layout", value: "post" },
|
{ key: "title", value: "Hello" },
|
||||||
{ key: "kind", value: "note" },
|
{ key: "date", value: "2026-02-06" },
|
||||||
{ key: "title", value: "X" },
|
{ key: "draft", value: "true" },
|
||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
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");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
10
src/slug.ts
10
src/slug.ts
@@ -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`;
|
||||||
}
|
}
|
||||||
|
|||||||
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