fix: escape rewrite replacement, diagnostic no-op commit, doc + askpass hardening

- Use function replacer in rewriteBody to prevent $& / $$ / $` / $' pattern
  corruption when alt text or siteUrl contains dollar-sign sequences
- Detect empty staged index after `git add -A` and throw a clear
  "No changes to publish" error instead of a cryptic git failure
- Correct README flat-slug description: single image → <slug>.<ext>,
  multiple → <slug>-1.<ext>, <slug>-2.<ext> (original filename discarded)
- Harden askpass dir resolution: show a Notice and return early if
  getFullPath is absent (desktop-only guard), rather than passing a bad path

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
2026-06-19 02:32:02 +00:00
parent ee56f8c9a2
commit c6f28474e4
6 changed files with 45 additions and 5 deletions

View File

@@ -36,7 +36,7 @@ An Obsidian plugin that publishes the active note as a Jekyll blog post via git.
## Image strategies
**flat-slug** — all images for the post are copied flat into `<images-dir>/` and renamed to `<slug>-<original-name>`. 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>`, … (the original Obsidian filename is discarded). Keeps the images directory shallow; suitable for sites with few images per post.
**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.

View File

@@ -50,3 +50,22 @@ test("second sync resets cleanly (fetch+reset path)", async () => {
execFileSync("git", ["clone", bare, verify]);
expect(readFileSync(join(verify, "_posts/second.md"), "utf8")).toBe("x");
});
test("commitAndPush rejects with diagnostic error when nothing is staged", async () => {
// Use a fresh base dir so we get a clean clone
const base2 = join(root, "work2");
mkdirSync(base2, { recursive: true });
const client = new ChildProcessGitClient({ baseDir: base2 });
// Clone and write a file, then commit it
await client.syncClone({ url: bare, branch: "main" });
await client.writeFiles([{ repoPath: "_posts/no-change.md", data: "same content" }]);
await client.commitAndPush({ message: "initial no-change", branch: "main", authorName: "t", authorEmail: "t@t" });
// Now sync again — tree now matches origin, re-writing the same content stages nothing
const client2 = new ChildProcessGitClient({ baseDir: base2 });
await client2.syncClone({ url: bare, branch: "main" });
await client2.writeFiles([{ repoPath: "_posts/no-change.md", data: "same content" }]);
await expect(
client2.commitAndPush({ message: "noop", branch: "main", authorName: "t", authorEmail: "t@t" })
).rejects.toThrow(/no changes to publish/i);
});

View File

@@ -66,6 +66,16 @@ export class ChildProcessGitClient implements GitClient {
async commitAndPush(o: { message: string; branch: string; authorName?: string; authorEmail?: string }): Promise<void> {
await this.git(this.workdir, ["add", "-A"]);
let hasChanges = false;
try {
await this.git(this.workdir, ["diff", "--cached", "--quiet"]);
// exit 0 => no staged changes
} catch {
hasChanges = true; // exit 1 => staged changes exist
}
if (!hasChanges) {
throw new Error("No changes to publish: the post and images are identical to what is already committed.");
}
const cfg: string[] = [];
if (o.authorName) cfg.push("-c", `user.name=${o.authorName}`);
if (o.authorEmail) cfg.push("-c", `user.email=${o.authorEmail}`);

View File

@@ -67,4 +67,12 @@ describe("rewriteBody", () => {
"![](/assets/img/post-1.png)\n![cap](/assets/img/post-2.jpeg)\n![d](/assets/img/post-3.svg)"
);
});
test("alt text containing $ patterns is preserved verbatim (no replacement-pattern corruption)", () => {
const slug = "my-post";
const body = "![a $& b $$ c](local.png)";
const refs = findImageRefs(body);
const { rewrittenBody } = planImages(refs, { slug, strategy: "flat-slug", imagesDir: "assets/img", body });
expect(rewrittenBody).toBe("![a $& b $$ c](/assets/img/my-post.png)");
});
});

View File

@@ -69,7 +69,7 @@ export function rewriteBody(body: string, refs: ImageRef[], plan: ImagePlanItem[
for (const ref of refs) {
const siteUrl = url.get(ref.linktext);
if (!siteUrl) continue;
out = out.replace(ref.raw, `![${ref.alt}](${siteUrl})`);
out = out.replace(ref.raw, () => `![${ref.alt}](${siteUrl})`);
}
return out;
}

View File

@@ -44,10 +44,13 @@ export default class JekyllPublishPlugin extends Plugin {
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: (this.app.vault.adapter as any).getFullPath
? (this.app.vault.adapter as any).getFullPath(dir)
: dir,
dir: adapter.getFullPath(dir),
onPrompt: (prompt) => this.promptCredential(prompt),
});
try {