diff --git a/.gitea/workflows/release.yml b/.gitea/workflows/release.yml new file mode 100644 index 0000000..0712052 --- /dev/null +++ b/.gitea/workflows/release.yml @@ -0,0 +1,108 @@ +name: Release + +# Manually-dispatched release: build the plugin, tag it, and publish an +# installable Gitea Release with the files Obsidian needs. +on: + workflow_dispatch: + inputs: + tag: + description: "Version to release (e.g. 0.1.0 — no leading v)" + required: true + default: "0.1.0" + +permissions: + contents: write + +jobs: + release: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Setup Node + uses: actions/setup-node@v4 + with: + node-version: "20" + + - name: Install dependencies + run: npm ci + + - name: Unit tests + run: npm test + + - name: Build + run: npm run build + + - name: Sync version to tag + env: + TAG: ${{ inputs.tag }} + run: | + set -euo pipefail + VERSION="${TAG#v}" + echo "Releasing version $VERSION (tag $TAG)" + tmp=$(mktemp) + jq --arg v "$VERSION" '.version = $v' manifest.json > "$tmp" && mv "$tmp" manifest.json + tmp=$(mktemp) + jq --arg v "$VERSION" '.version = $v' package.json > "$tmp" && mv "$tmp" package.json + # keep the built folder in sync with the bumped manifest + cp manifest.json dist/manifest.json + if ! git diff --quiet -- manifest.json package.json; then + git config user.name "github-actions[bot]" + git config user.email "github-actions[bot]@users.noreply.gitea" + git add manifest.json package.json + git commit -m "chore: release $VERSION" + git push origin "HEAD:${{ github.ref_name }}" + else + echo "Version already $VERSION — no commit needed" + fi + + - name: Create and push tag + env: + TAG: ${{ inputs.tag }} + run: | + set -euo pipefail + git config user.name "github-actions[bot]" + git config user.email "github-actions[bot]@users.noreply.gitea" + if git ls-remote --exit-code --tags origin "refs/tags/$TAG" >/dev/null 2>&1; then + echo "::error::Tag $TAG already exists on origin" + exit 1 + fi + git tag -a "$TAG" -m "Release $TAG" + git push origin "refs/tags/$TAG" + + - name: Package artifacts + env: + TAG: ${{ inputs.tag }} + run: | + set -euo pipefail + ( cd dist && zip -j "../jekyll-publish-$TAG.zip" main.js manifest.json styles.css ) + ls -la dist jekyll-publish-*.zip + + - name: Publish Gitea release + env: + TAG: ${{ inputs.tag }} + TOKEN: ${{ github.token }} + API: ${{ github.server_url }}/api/v1/repos/${{ github.repository }} + run: | + set -euo pipefail + body=$(printf 'Install: download main.js, manifest.json and styles.css into `/.obsidian/plugins/jekyll-publish/`, then enable **Jekyll Publish** under Settings → Community plugins.') + payload=$(jq -n --arg tag "$TAG" --arg name "$TAG" --arg body "$body" \ + '{tag_name:$tag, name:$name, body:$body, draft:false, prerelease:false}') + release=$(curl -sf -X POST "$API/releases" \ + -H "Authorization: token $TOKEN" \ + -H "Content-Type: application/json" \ + -d "$payload") + id=$(echo "$release" | jq -r '.id') + echo "Created release id=$id" + for f in dist/main.js dist/manifest.json dist/styles.css "jekyll-publish-$TAG.zip"; do + name=$(basename "$f") + echo "Uploading $name" + curl -sf -X POST "$API/releases/$id/assets?name=$name" \ + -H "Authorization: token $TOKEN" \ + -H "Content-Type: application/octet-stream" \ + --data-binary "@$f" >/dev/null + done + echo "Release $TAG published with assets" diff --git a/README.md b/README.md new file mode 100644 index 0000000..0b84a05 --- /dev/null +++ b/README.md @@ -0,0 +1,94 @@ +# 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. + +## What it does + +1. Opens a "Publish to Jekyll" modal for the currently active markdown file. +2. Lets you confirm or adjust the slug, date, frontmatter, image strategy, and commit message. +3. Clones (or updates) your Jekyll site repository into a temporary directory. +4. Writes the post as `_posts/-.md` (or your configured path). +5. Copies any embedded images according to the selected strategy. +6. Commits and pushes to your configured remote and branch. + +## Installation + +1. Build the plugin (see Development below) or download the release assets. +2. Copy `dist/main.js`, `dist/manifest.json`, and `dist/styles.css` into: + ``` + /.obsidian/plugins/jekyll-publish/ + ``` +3. Reload Obsidian and enable **Jekyll Publish** under Settings → Community plugins. + +## Settings + +| Setting | Description | Default | +|---|---|---| +| Remote URL | Git remote URL for your Jekyll site (HTTPS or SSH) | _(empty)_ | +| Branch | Branch to push to | `main` | +| Posts directory | Destination directory inside the repo | `_posts` | +| Images directory | Where images are copied inside the repo | `assets/img` | +| Default image strategy | `flat-slug` or `per-post-folder` (see below) | `flat-slug` | +| Preset frontmatter | Key/value pairs always injected into every post | _(empty)_ | +| Commit message template | Template; `{{title}}` is replaced by the note title | `Publish: {{title}}` | +| Author name | Git author name for commits | _(empty)_ | +| Author email | Git author email for commits | _(empty)_ | + +## Image strategies + +**flat-slug** — all images for the post are copied flat into `/` and renamed to `-`. Keeps the images directory shallow; suitable for sites with few images per post. + +**per-post-folder** — images are copied into `//` preserving their original filenames. Keeps each post's images grouped together; suitable for posts with many images. + +## 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. + +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. + +For SSH remotes, the plugin relies on your existing SSH agent or `~/.ssh` key configuration — no extra steps needed. + +## Development + +### Requirements + +- Node.js 20+ +- npm + +### Build + +```sh +npm install +npm run build +# Output: dist/main.js dist/manifest.json dist/styles.css +``` + +### Unit tests + +```sh +npm test +``` + +Runs the Vitest suite (frontmatter parsing, slug derivation, image path logic, git helpers, publish pipeline). + +### E2E tests + +The E2E suite boots the real Obsidian binary in a headless X display and drives it via the Chrome DevTools Protocol. + +**Prerequisites:** + +- `xvfb-run` on PATH (install: `sudo apt install xvfb`) +- Obsidian AppImage placed at `~/.cache/obsidian-e2e/Obsidian.AppImage` + (download from ) + +```sh +npm run e2e +``` + +The script: +1. Extracts the AppImage (once, cached). +2. Provisions a throwaway vault at `.obsidian-e2e/` with the plugin installed. +3. Launches Obsidian with `--remote-debugging-port` and connects Playwright over CDP. +4. Runs the smoke spec: plugin loads, command `jekyll-publish:publish-current-note` is registered, opening a note and executing the command shows a modal with heading "Publish to Jekyll". + +The `.obsidian-e2e/` directory and Playwright `test-results/` are excluded from git. diff --git a/e2e/harness.ts b/e2e/harness.ts new file mode 100644 index 0000000..594c74b --- /dev/null +++ b/e2e/harness.ts @@ -0,0 +1,103 @@ +import { chromium, type Browser, type Page } from "@playwright/test"; +import { spawn, type ChildProcess } from "node:child_process"; + +const OBSIDIAN_BIN = process.env.OBSIDIAN_BIN; +const PORT = Number(process.env.OBSIDIAN_CDP_PORT ?? 9222); + +export interface ObsidianHandle { + proc: ChildProcess; + browser: Browser; + page: Page; + close: () => Promise; +} + +async function waitForCDP(): Promise { + for (let i = 0; i < 60; i++) { + try { + const r = await fetch(`http://127.0.0.1:${PORT}/json/version`); + if (r.ok) return; + } catch { + // not up yet + } + await new Promise((res) => setTimeout(res, 1000)); + } + throw new Error("Obsidian CDP endpoint never came up"); +} + +/** Find the renderer page that owns the Obsidian `app` and has finished layout. */ +async function findReadyWindow(browser: Browser): Promise { + for (let i = 0; i < 60; i++) { + for (const ctx of browser.contexts()) { + for (const p of ctx.pages()) { + try { + const ready = await p.evaluate( + () => (window as any).app?.workspace?.layoutReady === true + ); + if (ready) return p; + } catch { + // page navigating + } + } + } + await new Promise((res) => setTimeout(res, 1000)); + } + throw new Error("No Obsidian window became ready"); +} + +/** + * Close any open Obsidian modal (first-run/update/confirmation dialogs the test + * environment may pop up) so they don't intercept pointer events. + */ +async function dismissModals(page: Page): Promise { + await page.evaluate(() => { + document.querySelectorAll(".modal-container").forEach((m) => { + (m.querySelector(".modal-close-button"))?.click(); + m.remove(); + }); + document.querySelectorAll(".modal-bg").forEach((b) => b.remove()); + }); +} + +/** Boot the real Obsidian binary, connect over CDP. Returns a handle with .page and .close(). */ +export async function launchObsidian(): Promise { + if (!OBSIDIAN_BIN) { + throw new Error("OBSIDIAN_BIN env var is required (set by scripts/e2e.sh)"); + } + const proc = spawn( + OBSIDIAN_BIN, + [`--remote-debugging-port=${PORT}`, "--no-sandbox", "--disable-gpu"], + { env: process.env, stdio: "ignore" } + ); + await waitForCDP(); + const browser = await chromium.connectOverCDP(`http://127.0.0.1:${PORT}`); + const page = await findReadyWindow(browser); + + const close = async () => { + await browser.close(); + proc.kill("SIGKILL"); + }; + + return { proc, browser, page, close }; +} + +/** + * Dismiss modals, enable the plugin, and wait for it to be loaded. + * Call this after launchObsidian() and before assertions. + */ +export async function withPlugin(obs: ObsidianHandle, pluginId: string): Promise { + await dismissModals(obs.page); + + // A fresh vault boots in Restricted Mode, which blocks community plugins. + // Disable it and load our plugin via Obsidian's own API, then wait for it. + await obs.page.evaluate(async (id) => { + const plugins = (window as any).app.plugins; + if (plugins.setEnable) await plugins.setEnable(true); + await plugins.enablePlugin(id); + }, pluginId); + + await obs.page.waitForFunction( + (id) => !!(window as any).app?.plugins?.plugins?.[id], + pluginId, + { timeout: 30_000 } + ); +} diff --git a/e2e/publish.spec.ts b/e2e/publish.spec.ts new file mode 100644 index 0000000..3d2509c --- /dev/null +++ b/e2e/publish.spec.ts @@ -0,0 +1,27 @@ +import { test, expect } from "@playwright/test"; +import { launchObsidian, withPlugin } from "./harness"; + +test("plugin loads, command registered, modal opens & prefills", async () => { + const obs = await launchObsidian(); + try { + await withPlugin(obs, "jekyll-publish"); + + const hasCommand = await obs.page.evaluate(() => + Boolean((window as any).app.commands.commands["jekyll-publish:publish-current-note"]) + ); + expect(hasCommand).toBe(true); + + const modalOpened = await obs.page.evaluate(async () => { + const app = (window as any).app; + const file = app.vault.getFiles().find((f: any) => f.extension === "md"); + await app.workspace.getLeaf(true).openFile(file); + app.commands.executeCommandById("jekyll-publish:publish-current-note"); + await new Promise((r) => setTimeout(r, 500)); + const heading = document.querySelector(".modal-container h2"); + return heading?.textContent ?? ""; + }); + expect(modalOpened).toContain("Publish to Jekyll"); + } finally { + await obs.close(); + } +}); diff --git a/playwright.config.ts b/playwright.config.ts new file mode 100644 index 0000000..8eae155 --- /dev/null +++ b/playwright.config.ts @@ -0,0 +1,11 @@ +import { defineConfig } from "@playwright/test"; + +export default defineConfig({ + testDir: "./e2e", + fullyParallel: false, + workers: 1, + retries: 0, + timeout: 120_000, + expect: { timeout: 30_000 }, + reporter: [["list"]], +}); diff --git a/scripts/e2e.sh b/scripts/e2e.sh new file mode 100755 index 0000000..727fae3 --- /dev/null +++ b/scripts/e2e.sh @@ -0,0 +1,63 @@ +#!/usr/bin/env bash +# End-to-end test driver: builds the plugin, provisions an isolated Obsidian +# vault + config with the plugin installed and enabled, then runs the Playwright +# spec against the real Obsidian binary under a virtual X display. +set -euo pipefail + +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +cd "$ROOT" + +CACHE="${OBSIDIAN_CACHE:-$HOME/.cache/obsidian-e2e}" +APPIMAGE="$CACHE/Obsidian.AppImage" +SQUASHFS="$CACHE/squashfs-root" +E2E_DIR="$ROOT/.obsidian-e2e" +VAULT="$E2E_DIR/vault" +CONFIG_HOME="$E2E_DIR/config" + +if [[ ! -f "$APPIMAGE" ]]; then + echo "Obsidian AppImage not found at $APPIMAGE." >&2 + echo "Download it from https://obsidian.md/download and place it there." >&2 + exit 1 +fi + +# Extract the AppImage once so we can launch the inner Electron binary directly. +if [[ ! -x "$SQUASHFS/obsidian" ]]; then + echo "==> Extracting Obsidian AppImage" + ( cd "$CACHE" && "$APPIMAGE" --appimage-extract >/dev/null ) +fi +export OBSIDIAN_BIN="$SQUASHFS/obsidian" + +echo "==> Building plugin" +npm run build >/dev/null + +echo "==> Provisioning isolated vault + config at $E2E_DIR" +rm -rf "$E2E_DIR" +PLUGIN_DIR="$VAULT/.obsidian/plugins/jekyll-publish" +mkdir -p "$PLUGIN_DIR" "$CONFIG_HOME/obsidian" + +cp dist/main.js dist/manifest.json dist/styles.css "$PLUGIN_DIR/" +# Enable our community plugin (and disable Obsidian's first-run restricted mode prompt). +printf '["jekyll-publish"]\n' > "$VAULT/.obsidian/community-plugins.json" + +# Create a test note so the publish command has an active file. +cat > "$VAULT/Test.md" <<'MD' +--- +title: E2E Test Post +date: 2024-01-01 +--- + +# E2E Test Post + +This is a test note for the E2E smoke test. +MD + +# Register the vault and mark it open so Obsidian boots straight into it. +VAULT_ESCAPED=$(printf '%s' "$VAULT" | sed 's/[\/&]/\\&/g') +cat > "$CONFIG_HOME/obsidian/obsidian.json" < Running Playwright (xvfb + real Obsidian)" +xvfb-run -a --server-args="-screen 0 1280x900x24" npx playwright test "$@"