4 Commits
0.1.0 ... 0.1.3

Author SHA1 Message Date
github-actions[bot]
4be12fb6e6 chore: release 0.1.3 2026-06-19 03:25:29 +00:00
fb5d11dd6a feat: unified, editable frontmatter editor in publish modal
All checks were successful
CI / test (push) Successful in 16s
- Preset fields (from settings) are now seeded as editable/deletable rows in
  the modal alongside custom ones, using one consistent row style.
- The note's own merged frontmatter is shown as read-only (disabled) rows at
  the top of the list (only when 'Merge document frontmatter' is on, and only
  for keys not overridden by an editable row).
- Drops the separate read-only 'resolved' text preview; the rows are the
  WYSIWYG result.

Adds pure, unit-tested composeFrontmatter()/docFrontmatterToPairs() and removes
the superseded resolveFrontmatter(). Editable rows are authoritative: on a key
collision they override the merged document field. Presets are deep-copied so
editing rows never mutates saved settings.

Co-Authored-By: Claude
2026-06-19 03:21:04 +00:00
github-actions[bot]
f2a4040ae8 chore: release 0.1.1 2026-06-19 03:04:07 +00:00
61910acac3 fix: remove non-portable askpass bridge; rely on native git credentials
All checks were successful
CI / test (push) Successful in 17s
The askpass bridge bound a Unix-domain socket to a vault filesystem path
(net.createServer().listen(...sock)). That is invalid on Windows, where
Node's listen() expects a named pipe, so publish aborted with
'listen EACCES: permission denied ...askpass.sock' before git ever ran —
even when the user's git could authenticate. The bridge was non-portable in
general (Windows named pipes; the helper script needs a node binary on PATH).

Remove the bridge and let the user's system git handle credentials via its
native helpers (Git Credential Manager / osxkeychain / libsecret) for https
and ssh-agent for ssh — the 'store nothing' model we already chose.
GIT_TERMINAL_PROMPT=0 stays so git fails fast instead of hanging, and
publish errors now include a credential/identity hint. Deletes askpass.ts
and its test (3 tests); suite 32/32, tsc clean, build OK.

Co-Authored-By: Claude
2026-06-19 03:02:28 +00:00
10 changed files with 204 additions and 269 deletions

View File

@@ -42,11 +42,12 @@ An Obsidian plugin that publishes the active note as a Jekyll blog post via git.
## 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

View File

@@ -1,7 +1,7 @@
{
"id": "jekyll-publish",
"name": "Jekyll Publish",
"version": "0.1.0",
"version": "0.1.3",
"minAppVersion": "1.5.0",
"description": "Publish the active note as a Jekyll post (with images) via git.",
"author": "Claude",

View File

@@ -1,6 +1,6 @@
{
"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.",
"main": "dist/main.js",
"type": "module",

View File

@@ -1,5 +1,5 @@
import { App, Modal, Setting } from "obsidian";
import { Pair, parseNote, resolveFrontmatter } from "./frontmatter";
import { Pair, composeFrontmatter, docFrontmatterToPairs, parseNote } from "./frontmatter";
import { Strategy } from "./images";
import { JekyllPublishSettings } from "./settings";
import { deriveDate, deriveSlug } from "./slug";
@@ -15,18 +15,26 @@ export interface ModalResult {
export class PublishModal extends Modal {
private result: ModalResult;
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(
app: App,
private settings: JekyllPublishSettings,
settings: JekyllPublishSettings,
private noteText: string,
private filename: string,
filename: string,
private onSubmit: (r: ModalResult) => void
) {
super(app);
const { frontmatter } = parseNote(noteText);
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 = {
frontmatterPairs: [],
slug: deriveSlug({ title, filename }),
@@ -37,13 +45,7 @@ export class PublishModal extends Modal {
}
private recompute() {
const { frontmatter } = parseNote(this.noteText);
this.result.frontmatterPairs = resolveFrontmatter(
this.settings.presetFrontmatter,
frontmatter,
this.custom,
this.mergeDoc
);
this.result.frontmatterPairs = composeFrontmatter(this.docPairs, this.editableRows, this.mergeDoc);
}
onOpen() {
@@ -63,16 +65,23 @@ export class PublishModal extends Modal {
.setValue(this.result.strategy)
.onChange((v) => (this.result.strategy = v as Strategy))
);
new Setting(contentEl).setName("Merge document frontmatter").addToggle((t) =>
t.setValue(this.mergeDoc).onChange((v) => { this.mergeDoc = v; this.renderFrontmatter(); })
);
new Setting(contentEl)
.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.renderFrontmatter();
new Setting(contentEl).addButton((b) =>
b.setButtonText("Add property").onClick(() => {
this.custom.push({ key: "", value: "" });
this.editableRows = [...this.editableRows, { key: "", value: "" }];
this.renderFrontmatter();
})
);
@@ -88,25 +97,52 @@ export class PublishModal extends Modal {
);
}
private fmEl!: HTMLElement;
private renderFrontmatter() {
this.recompute();
this.fmEl.empty();
this.fmEl.createEl("h3", { text: "Frontmatter (resolved)" });
for (const p of this.result.frontmatterPairs) {
this.fmEl.createDiv({ cls: "jekyll-publish-row", text: `${p.key}: ${p.value}` });
}
if (this.custom.length) {
this.fmEl.createEl("h4", { text: "Custom properties" });
this.custom.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.custom[i].key = k.value; this.recompute(); };
v.oninput = () => { this.custom[i].value = v.value; this.recompute(); };
});
const editableKeys = new Set(
this.editableRows.filter((r) => r.key.trim() !== "").map((r) => r.key)
);
// Document fields (read-only) first — only those not overridden by an
// editable row, so the modal never shows a key twice.
if (this.mergeDoc) {
const docRows = this.docPairs.filter((p) => !editableKeys.has(p.key));
for (const p of docRows) {
const row = this.fmEl.createDiv({ cls: "jekyll-publish-row" });
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();
}
}

View File

@@ -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 });
});

View File

@@ -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 */ }
}
}

View File

@@ -1,5 +1,5 @@
import { describe, expect, test } from "vitest";
import { parseNote, resolveFrontmatter, serializeFrontmatter } from "./frontmatter";
import { composeFrontmatter, docFrontmatterToPairs, parseNote, serializeFrontmatter } from "./frontmatter";
describe("parseNote", () => {
test("splits frontmatter and body, strips BOM", () => {
@@ -12,27 +12,70 @@ describe("parseNote", () => {
});
});
describe("resolveFrontmatter", () => {
const presets = [{ key: "layout", value: "post" }, { key: "kind", value: "essay" }];
test("presets only when mergeDoc is false", () => {
expect(resolveFrontmatter(presets, { title: "X" }, [], false)).toEqual(presets);
});
test("merge appends doc keys and doc overrides preset value", () => {
const r = resolveFrontmatter(presets, { kind: "note", title: "X" }, [], true);
expect(r).toEqual([
{ key: "layout", value: "post" },
{ key: "kind", value: "note" },
{ key: "title", value: "X" },
describe("docFrontmatterToPairs", () => {
test("converts a record to ordered string pairs", () => {
const pairs = docFrontmatterToPairs({
title: "Hello",
date: new Date("2026-02-06T00:00:00Z"),
draft: true,
});
expect(pairs).toEqual([
{ key: "title", value: "Hello" },
{ key: "date", value: "2026-02-06" },
{ key: "draft", value: "true" },
]);
});
test("custom rows come last and override everything", () => {
const r = resolveFrontmatter(presets, {}, [{ key: "layout", value: "page" }], false);
expect(r[0]).toEqual({ key: "layout", value: "page" });
expect(r).toHaveLength(2);
test("empty record yields an empty array", () => {
expect(docFrontmatterToPairs({})).toEqual([]);
});
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" }]);
});
});

View File

@@ -14,12 +14,26 @@ export function parseNote(text: string): { frontmatter: Record<string, unknown>;
return { frontmatter, body: stripped.slice(m[0].length) };
}
export function resolveFrontmatter(
presets: Pair[],
docFrontmatter: Record<string, unknown>,
custom: Pair[],
mergeDoc: boolean
): Pair[] {
/**
* Convert a parsed note's frontmatter object into ordered, stringified pairs
* suitable for display (read-only) and composition.
*/
export function docFrontmatterToPairs(docFrontmatter: Record<string, unknown>): 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 idx = new Map<string, number>();
const put = (key: string, value: string) => {
@@ -27,9 +41,8 @@ export function resolveFrontmatter(
if (at !== undefined) out[at] = { 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 [k, v] of Object.entries(docFrontmatter)) put(k, stringifyScalar(v));
for (const c of custom) put(c.key, c.value);
if (mergeDoc) for (const p of docPairs) if (!editableKeys.has(p.key)) put(p.key, p.value);
for (const r of editable) put(r.key, r.value);
return out;
}

View File

@@ -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 { JekyllPublishSettingTab } from "./SettingsTab";
import { ModalResult, PublishModal } from "./PublishModal";
import { ChildProcessGitClient } from "./git";
import { AskpassBridge } from "./askpass";
import { publish } from "./publish";
export default class JekyllPublishPlugin extends Plugin {
@@ -43,31 +42,26 @@ export default class JekyllPublishPlugin extends Plugin {
new Notice("Jekyll Publish: set a remote URL in settings first.");
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 {
const env = await bridge.start();
const git = new ChildProcessGitClient({ env });
// Credentials are handled entirely by the user's system git: its
// 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(
{ noteText, ...r },
this.settings,
git,
(linktext) => this.resolveImage(linktext)
);
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}`);
} catch (e) {
new Notice(`Publish failed: ${(e as Error).message}`);
} finally {
bridge.stop();
new Notice(`Publish failed: ${withCredentialHint((e as Error).message)}`, 12000);
}
}
@@ -77,26 +71,24 @@ export default class JekyllPublishPlugin extends Plugin {
const ab = await this.app.vault.readBinary(dest);
return Buffer.from(ab);
}
private promptCredential(prompt: string): Promise<string> {
return new Promise((resolve) => {
const modal = new Modal(this.app);
modal.titleEl.setText("Git credentials");
modal.contentEl.createEl("p", { text: prompt });
let value = "";
const masked = /pass|secret|token/i.test(prompt);
new Setting(modal.contentEl).addText((t) => {
if (masked) t.inputEl.type = "password";
t.onChange((v) => (value = v));
t.inputEl.addEventListener("keydown", (e) => {
if (e.key === "Enter") { modal.close(); resolve(value); }
});
});
new Setting(modal.contentEl).addButton((b) =>
b.setButtonText("OK").setCta().onClick(() => { modal.close(); resolve(value); })
);
modal.onClose = () => resolve(value);
modal.open();
});
}
}
/**
* When git fails for an authentication reason, append guidance pointing the
* user at their system credential helper / ssh setup, since the plugin
* deliberately does not store or prompt for credentials itself.
*/
function withCredentialHint(message: string): string {
const authPattern =
/could not read (Username|Password)|terminal prompts disabled|Authentication failed|Permission denied|access denied|fatal: Authentication|no email was given|Author identity unknown/i;
if (authPattern.test(message)) {
return (
`${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 " +
"sure your SSH key/agent is configured. You can also set an author name/email " +
"in the plugin settings."
);
}
return message;
}

View File

@@ -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; }
/* 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;
}