commit fd86604186b14b12c8ea19f864df478e83330197
Author: svemagie <869694+svemagie@users.noreply.github.com>
Date: Sat Mar 14 16:57:44 2026 +0100
feat: initial scaffold — Micropub publisher for Obsidian
diff --git a/README.md b/README.md
new file mode 100644
index 0000000..e01771f
--- /dev/null
+++ b/README.md
@@ -0,0 +1,147 @@
+# obsidian-micropub
+
+An Obsidian plugin to publish notes to **any Micropub-compatible endpoint** — Indiekit, Micro.blog, or any server implementing the [W3C Micropub spec](https://www.w3.org/TR/micropub/).
+
+Forked and generalised from [svemagie/obsidian-microblog](https://github.com/svemagie/obsidian-microblog) (MIT).
+
+---
+
+## Features
+
+- **Any Micropub endpoint** — not locked to Micro.blog; works with Indiekit and other servers
+- **Auto-discovery** — reads `` from your site to find the endpoint automatically
+- **Digital Garden stage mapping** — Obsidian tags `#garden/plant`, `#garden/cultivate`, etc. become a `gardenStage` property on the published post, matching the Eleventy blog's garden system
+- **Create + Update** — if the note has a `mp-url` frontmatter key, Publish will update the existing post instead of creating a new one
+- **Image upload** — local images (wiki-embeds and markdown) are uploaded to the media endpoint and URLs rewritten
+- **URL write-back** — the returned post URL is saved to `mp-url` in the note's frontmatter after publishing
+
+---
+
+## Installation
+
+### From source (development)
+
+```bash
+cd /path/to/your/obsidian/vault/.obsidian/plugins
+git clone https://github.com/yourname/obsidian-micropub
+cd obsidian-micropub
+npm install
+npm run dev
+```
+
+Then enable the plugin in Obsidian → Settings → Community plugins.
+
+### Manual
+
+1. Download the latest release (main.js + manifest.json)
+2. Create a folder `.obsidian/plugins/obsidian-micropub/` in your vault
+3. Copy both files there
+4. Enable in Obsidian → Settings → Community plugins
+
+---
+
+## Configuration
+
+Open **Settings → Micropub Publisher**.
+
+| Setting | Description |
+|---|---|
+| **Site URL** | Your site's home page — used for endpoint auto-discovery |
+| **Micropub endpoint** | e.g. `https://example.com/micropub` |
+| **Media endpoint** | For image uploads; auto-discovered if blank |
+| **Access token** | Bearer token from your IndieAuth token endpoint |
+| **Default visibility** | `public` / `unlisted` / `private` |
+| **Write URL to note** | Save the published post URL as `mp-url` in frontmatter |
+| **Map #garden/* tags** | Convert `#garden/plant` → `garden-stage: plant` property |
+
+### Getting a token from Indiekit
+
+1. Log into your Indiekit admin panel
+2. Go to **Tokens** → Create new token with `create update` scope
+3. Paste the token into the plugin settings
+
+---
+
+## Digital Garden workflow
+
+Tag any note in Obsidian with a `#garden/*` tag:
+
+| Obsidian tag | Published property | Blog display |
+|---|---|---|
+| `#garden/plant` | `gardenStage: plant` | 🌱 Seedling |
+| `#garden/cultivate` | `gardenStage: cultivate` | 🌿 Growing |
+| `#garden/question` | `gardenStage: question` | ❓ Open Question |
+| `#garden/repot` | `gardenStage: repot` | 🪴 Repotting |
+| `#garden/revitalize` | `gardenStage: revitalize` | ✨ Revitalizing |
+| `#garden/revisit` | `gardenStage: revisit` | 🔄 Revisit |
+
+The Eleventy blog renders a coloured badge on each post and groups all garden posts at `/garden/`.
+
+### Example note
+
+```markdown
+---
+title: "On building in public"
+tags:
+ - garden/cultivate
+ - indieweb
+---
+
+Some early thoughts on the merits of building in public...
+```
+
+After publishing, the frontmatter gains:
+
+```yaml
+mp-url: "https://example.com/articles/2026/on-building-in-public"
+```
+
+---
+
+## Frontmatter properties recognised
+
+| Property | Effect |
+|---|---|
+| `title` | Sets the post `name` (article mode) |
+| `date` | Sets `published` (ISO 8601) |
+| `tags` / `category` | Becomes Micropub `category` (excluding `garden/*` tags) |
+| `visibility` | `public` / `unlisted` / `private` |
+| `mp-url` | Triggers an **update** rather than create |
+| `mp-syndicate-to` | Pre-fills syndication target list |
+| `mp-*` | Any other `mp-*` keys passed through verbatim |
+
+---
+
+## Development
+
+```bash
+npm run dev # watch mode with inline sourcemaps
+npm run build # production bundle (minified)
+```
+
+### Architecture
+
+```
+src/
+ main.ts Plugin entry point, command/ribbon registration
+ types.ts Shared interfaces and constants
+ MicropubClient.ts Low-level HTTP (create, update, upload, discover)
+ Publisher.ts Orchestrates publish flow (parse → upload → send → write-back)
+ SettingsTab.ts Obsidian settings UI
+```
+
+---
+
+## Roadmap
+
+- [ ] Publish dialog with syndication target checkboxes
+- [ ] Scheduled publishing (`mp-published-at`)
+- [ ] Pull categories from Micropub `?q=category` for autocomplete
+- [ ] Multi-endpoint support (publish to multiple blogs)
+- [ ] Post type selector (note / article / bookmark / reply)
+
+---
+
+## License
+
+MIT — see [LICENSE](LICENSE)
diff --git a/esbuild.config.mjs b/esbuild.config.mjs
new file mode 100644
index 0000000..1c5fc53
--- /dev/null
+++ b/esbuild.config.mjs
@@ -0,0 +1,55 @@
+import esbuild from "esbuild";
+import process from "process";
+import builtins from "builtin-modules";
+
+const banner = `/*
+THIS IS A GENERATED/BUNDLED FILE BY ESBUILD
+if you want to view the source, please visit the github repository of this plugin
+*/
+`;
+
+const prod = process.argv[2] === "production";
+
+const context = await esbuild.context({
+ banner: { js: banner },
+ entryPoints: ["src/main.ts"],
+ bundle: true,
+ external: [
+ "obsidian",
+ "electron",
+ "@codemirror/autocomplete",
+ "@codemirror/closebrackets",
+ "@codemirror/commands",
+ "@codemirror/fold",
+ "@codemirror/gutter",
+ "@codemirror/highlight",
+ "@codemirror/history",
+ "@codemirror/language",
+ "@codemirror/lint",
+ "@codemirror/matchbrackets",
+ "@codemirror/panel",
+ "@codemirror/rangeset",
+ "@codemirror/rectangular-select",
+ "@codemirror/search",
+ "@codemirror/state",
+ "@codemirror/stream-parser",
+ "@codemirror/text",
+ "@codemirror/tooltip",
+ "@codemirror/view",
+ ...builtins,
+ ],
+ format: "cjs",
+ target: "es2018",
+ logLevel: "info",
+ sourcemap: prod ? false : "inline",
+ treeShaking: true,
+ outfile: "main.js",
+ minify: prod,
+});
+
+if (prod) {
+ await context.rebuild();
+ process.exit(0);
+} else {
+ await context.watch();
+}
diff --git a/manifest.json b/manifest.json
new file mode 100644
index 0000000..f493555
--- /dev/null
+++ b/manifest.json
@@ -0,0 +1,10 @@
+{
+ "id": "obsidian-micropub",
+ "name": "Micropub Publisher",
+ "version": "0.1.0",
+ "minAppVersion": "1.4.0",
+ "description": "Publish notes to any Micropub-compatible endpoint (Indiekit, micro.blog, etc.) with support for Digital Garden stages.",
+ "author": "Sven",
+ "authorUrl": "",
+ "isDesktopOnly": false
+}
diff --git a/package.json b/package.json
new file mode 100644
index 0000000..c2cad56
--- /dev/null
+++ b/package.json
@@ -0,0 +1,22 @@
+{
+ "name": "obsidian-micropub",
+ "version": "0.1.0",
+ "description": "Obsidian plugin: publish to any Micropub endpoint",
+ "main": "main.js",
+ "scripts": {
+ "dev": "node esbuild.config.mjs",
+ "build": "tsc --noEmit --skipLibCheck && node esbuild.config.mjs production",
+ "version": "node version-bump.mjs && git add manifest.json versions.json"
+ },
+ "keywords": ["obsidian", "micropub", "indieweb", "indiekit"],
+ "license": "MIT",
+ "devDependencies": {
+ "@types/node": "^20.0.0",
+ "@types/qrcode": "^1.5.5",
+ "builtin-modules": "^4.0.0",
+ "esbuild": "^0.25.0",
+ "obsidian": "latest",
+ "tslib": "^2.6.0",
+ "typescript": "^5.0.0"
+ }
+}
diff --git a/src/MicropubClient.ts b/src/MicropubClient.ts
new file mode 100644
index 0000000..491104a
--- /dev/null
+++ b/src/MicropubClient.ts
@@ -0,0 +1,227 @@
+/**
+ * MicropubClient.ts
+ *
+ * Low-level HTTP client for Micropub and Media endpoint requests.
+ * Uses Obsidian's requestUrl() so requests are made from the desktop app
+ * (no CORS issues) rather than a browser fetch.
+ */
+
+import { requestUrl, RequestUrlParam } from "obsidian";
+import type { MicropubConfig, PublishResult } from "./types";
+
+export class MicropubClient {
+ constructor(
+ private readonly getEndpoint: () => string,
+ private readonly getMediaEndpoint: () => string,
+ private readonly getToken: () => string,
+ ) {}
+
+ // ── Config discovery ─────────────────────────────────────────────────────
+
+ /** Fetch Micropub server config (syndication targets, media endpoint, etc.) */
+ async fetchConfig(): Promise {
+ const url = `${this.getEndpoint()}?q=config`;
+ const resp = await requestUrl({
+ url,
+ method: "GET",
+ headers: this.authHeaders(),
+ });
+ return resp.json as MicropubConfig;
+ }
+
+ /**
+ * Discover micropub + token endpoint URLs from a site's home page
+ * by reading and tags.
+ */
+ async discoverEndpoints(siteUrl: string): Promise<{
+ micropubEndpoint?: string;
+ tokenEndpoint?: string;
+ mediaEndpoint?: string;
+ }> {
+ const resp = await requestUrl({ url: siteUrl, method: "GET" });
+ const html = resp.text;
+
+ const micropub = this.extractLinkRel(html, "micropub");
+ const tokenEndpoint = this.extractLinkRel(html, "token_endpoint");
+
+ // After discovering the Micropub endpoint, fetch its config for the media URL
+ let mediaEndpoint: string | undefined;
+ if (micropub) {
+ try {
+ const cfg = await this.fetchConfigFrom(micropub);
+ mediaEndpoint = cfg["media-endpoint"];
+ } catch {
+ // Non-fatal — media endpoint stays undefined
+ }
+ }
+
+ return { micropubEndpoint: micropub, tokenEndpoint, mediaEndpoint };
+ }
+
+ // ── Post publishing ──────────────────────────────────────────────────────
+
+ /**
+ * Create a new post via Micropub.
+ * Sends a JSON body with h-entry properties.
+ * Returns the Location header URL on success.
+ */
+ async createPost(properties: Record): Promise {
+ const body = {
+ type: ["h-entry"],
+ properties,
+ };
+
+ try {
+ const resp = await requestUrl({
+ url: this.getEndpoint(),
+ method: "POST",
+ headers: {
+ ...this.authHeaders(),
+ "Content-Type": "application/json",
+ },
+ body: JSON.stringify(body),
+ throw: false,
+ });
+
+ if (resp.status === 201 || resp.status === 202) {
+ const location =
+ resp.headers?.["location"] ||
+ resp.headers?.["Location"] ||
+ (resp.json as { url?: string })?.url;
+ return { success: true, url: location };
+ }
+
+ const detail = this.extractError(resp.text);
+ return { success: false, error: `HTTP ${resp.status}: ${detail}` };
+ } catch (err: unknown) {
+ return { success: false, error: String(err) };
+ }
+ }
+
+ /**
+ * Update an existing post.
+ * @param postUrl The canonical URL of the post to update
+ * @param replace Properties to replace (will overwrite existing values)
+ */
+ async updatePost(
+ postUrl: string,
+ replace: Record,
+ ): Promise {
+ const body = { action: "update", url: postUrl, replace };
+
+ try {
+ const resp = await requestUrl({
+ url: this.getEndpoint(),
+ method: "POST",
+ headers: {
+ ...this.authHeaders(),
+ "Content-Type": "application/json",
+ },
+ body: JSON.stringify(body),
+ throw: false,
+ });
+
+ if (resp.status >= 200 && resp.status < 300) {
+ return { success: true, url: postUrl };
+ }
+
+ return {
+ success: false,
+ error: `HTTP ${resp.status}: ${this.extractError(resp.text)}`,
+ };
+ } catch (err: unknown) {
+ return { success: false, error: String(err) };
+ }
+ }
+
+ // ── Media upload ─────────────────────────────────────────────────────────
+
+ /**
+ * Upload a binary file to the media endpoint.
+ * @returns The URL of the uploaded media, or throws on failure.
+ */
+ async uploadMedia(
+ fileBuffer: ArrayBuffer,
+ fileName: string,
+ mimeType: string,
+ ): Promise {
+ const endpoint = this.getMediaEndpoint() || `${this.getEndpoint()}/media`;
+
+ // Build multipart/form-data manually — Obsidian's requestUrl doesn't
+ // support FormData directly, so we encode the boundary ourselves.
+ const boundary = `----MicropubBoundary${Date.now()}`;
+ const header =
+ `--${boundary}\r\n` +
+ `Content-Disposition: form-data; name="file"; filename="${fileName}"\r\n` +
+ `Content-Type: ${mimeType}\r\n\r\n`;
+ const footer = `\r\n--${boundary}--\r\n`;
+
+ const headerBuf = new TextEncoder().encode(header);
+ const footerBuf = new TextEncoder().encode(footer);
+ const fileBuf = new Uint8Array(fileBuffer);
+
+ const combined = new Uint8Array(
+ headerBuf.length + fileBuf.length + footerBuf.length,
+ );
+ combined.set(headerBuf, 0);
+ combined.set(fileBuf, headerBuf.length);
+ combined.set(footerBuf, headerBuf.length + fileBuf.length);
+
+ const resp = await requestUrl({
+ url: endpoint,
+ method: "POST",
+ headers: {
+ ...this.authHeaders(),
+ "Content-Type": `multipart/form-data; boundary=${boundary}`,
+ },
+ body: combined.buffer,
+ throw: false,
+ });
+
+ if (resp.status === 201 || resp.status === 202) {
+ const location =
+ resp.headers?.["location"] ||
+ resp.headers?.["Location"] ||
+ (resp.json as { url?: string })?.url;
+ if (location) return location;
+ }
+
+ throw new Error(
+ `Media upload failed (HTTP ${resp.status}): ${this.extractError(resp.text)}`,
+ );
+ }
+
+ // ── Helpers ──────────────────────────────────────────────────────────────
+
+ private authHeaders(): Record {
+ return { Authorization: `Bearer ${this.getToken()}` };
+ }
+
+ private extractLinkRel(html: string, rel: string): string | undefined {
+ // Match both and HTTP Link headers embedded in HTML
+ const re = new RegExp(
+ `]+rel=["']${rel}["'][^>]+href=["']([^"']+)["']|]+href=["']([^"']+)["'][^>]+rel=["']${rel}["']`,
+ "i",
+ );
+ const m = html.match(re);
+ return m?.[1] ?? m?.[2];
+ }
+
+ private async fetchConfigFrom(endpoint: string): Promise {
+ const resp = await requestUrl({
+ url: `${endpoint}?q=config`,
+ method: "GET",
+ headers: this.authHeaders(),
+ });
+ return resp.json as MicropubConfig;
+ }
+
+ private extractError(text: string): string {
+ try {
+ const obj = JSON.parse(text) as { error_description?: string; error?: string };
+ return obj.error_description ?? obj.error ?? text.slice(0, 200);
+ } catch {
+ return text.slice(0, 200);
+ }
+ }
+}
diff --git a/src/Publisher.ts b/src/Publisher.ts
new file mode 100644
index 0000000..3611f92
--- /dev/null
+++ b/src/Publisher.ts
@@ -0,0 +1,303 @@
+/**
+ * Publisher.ts
+ *
+ * Orchestrates a full publish flow:
+ * 1. Parse the active note's frontmatter + body
+ * 2. Upload any local images to the media endpoint
+ * 3. Build the Micropub properties object
+ * 4. POST to the Micropub endpoint
+ * 5. Optionally write the returned URL back to frontmatter
+ *
+ * Garden tag mapping:
+ * Obsidian tags #garden/plant → gardenStage: "plant" in properties
+ * The blog reads this as `gardenStage` frontmatter, so the Indiekit
+ * Micropub server must be configured to pass through unknown properties.
+ */
+
+import { App, TFile, parseFrontMatterAliases, parseYaml, stringifyYaml } from "obsidian";
+import type { MicropubSettings, GardenStage, PublishResult } from "./types";
+import { MicropubClient } from "./MicropubClient";
+
+const GARDEN_TAG_PREFIX = "garden/";
+
+export class Publisher {
+ private client: MicropubClient;
+
+ constructor(
+ private readonly app: App,
+ private readonly settings: MicropubSettings,
+ ) {
+ this.client = new MicropubClient(
+ () => settings.micropubEndpoint,
+ () => settings.mediaEndpoint,
+ () => settings.accessToken,
+ );
+ }
+
+ /** Publish the given file. Returns PublishResult. */
+ async publish(file: TFile): Promise {
+ const raw = await this.app.vault.read(file);
+ const { frontmatter, body } = this.parseFrontmatter(raw);
+
+ // Determine if this is an update (post already has a URL) or new post
+ const existingUrl: string | undefined =
+ frontmatter["mp-url"] ?? frontmatter["url"] ?? undefined;
+
+ // Upload local images and rewrite markdown references
+ const { content: processedBody, uploadedUrls } =
+ await this.processImages(body);
+
+ // Build Micropub properties
+ const properties = this.buildProperties(frontmatter, processedBody, uploadedUrls);
+
+ let result: PublishResult;
+
+ if (existingUrl) {
+ // Update existing post
+ const replace: Record = {};
+ for (const [k, v] of Object.entries(properties)) {
+ replace[k] = Array.isArray(v) ? v : [v];
+ }
+ result = await this.client.updatePost(existingUrl, replace);
+ } else {
+ // Create new post
+ result = await this.client.createPost(properties);
+ }
+
+ // Write URL back to frontmatter
+ if (result.success && result.url && this.settings.writeUrlToFrontmatter) {
+ await this.writeUrlToNote(file, raw, result.url);
+ }
+
+ return result;
+ }
+
+ // ── Property builder ─────────────────────────────────────────────────────
+
+ private buildProperties(
+ fm: Record,
+ body: string,
+ uploadedUrls: string[],
+ ): Record {
+ const props: Record = {};
+
+ // Required: content
+ props["content"] = [{ html: body }];
+
+ // Title (articles have titles; notes/micro-posts don't)
+ if (fm["title"]) {
+ props["name"] = [String(fm["title"])];
+ }
+
+ // Published date
+ if (fm["date"]) {
+ props["published"] = [new Date(String(fm["date"])).toISOString()];
+ }
+
+ // Categories from frontmatter `category` or `tags` (excluding garden/* tags)
+ const rawTags = this.resolveArray(fm["tags"] ?? fm["category"]);
+ const gardenStage = this.extractGardenStage(rawTags);
+ const normalTags = rawTags.filter(
+ (t) => !t.startsWith(GARDEN_TAG_PREFIX) && t !== "garden",
+ );
+ if (normalTags.length > 0) {
+ props["category"] = normalTags;
+ }
+
+ // Garden stage → dedicated property
+ if (this.settings.mapGardenTags && gardenStage) {
+ // Indiekit stores this as gardenStage in front matter;
+ // Micropub JSON uses hyphenated keys
+ props["garden-stage"] = [gardenStage];
+ }
+
+ // Syndication targets
+ const syndicateTo = this.resolveArray(fm["mp-syndicate-to"]);
+ const allSyndicateTo = [
+ ...new Set([...this.settings.defaultSyndicateTo, ...syndicateTo]),
+ ];
+ if (allSyndicateTo.length > 0) {
+ props["mp-syndicate-to"] = allSyndicateTo;
+ }
+
+ // Visibility
+ const visibility =
+ (fm["visibility"] as string) ?? this.settings.defaultVisibility;
+ if (visibility && visibility !== "public") {
+ props["visibility"] = [visibility];
+ }
+
+ // Uploaded images (from local → remote URL conversion)
+ if (uploadedUrls.length > 0) {
+ props["photo"] = uploadedUrls.map((url) => ({ value: url }));
+ }
+
+ // Pass through any `mp-*` properties from frontmatter verbatim
+ for (const [k, v] of Object.entries(fm)) {
+ if (k.startsWith("mp-") && k !== "mp-url" && k !== "mp-syndicate-to") {
+ props[k] = this.resolveArray(v);
+ }
+ }
+
+ return props;
+ }
+
+ // ── Garden tag extraction ────────────────────────────────────────────────
+
+ /**
+ * Find the first #garden/ tag and return the stage name.
+ * Supports both "garden/plant" (Obsidian array) and "#garden/plant" (inline).
+ */
+ private extractGardenStage(tags: string[]): GardenStage | undefined {
+ for (const tag of tags) {
+ const clean = tag.replace(/^#/, "");
+ if (clean.startsWith(GARDEN_TAG_PREFIX)) {
+ const stage = clean.slice(GARDEN_TAG_PREFIX.length) as GardenStage;
+ const valid: GardenStage[] = [
+ "plant", "cultivate", "question", "repot", "revitalize", "revisit",
+ ];
+ if (valid.includes(stage)) return stage;
+ }
+ }
+ return undefined;
+ }
+
+ // ── Image processing ─────────────────────────────────────────────────────
+
+ /**
+ * Find all `![[local-image.png]]` or `` in the body,
+ * upload them to the media endpoint, and replace the references with remote URLs.
+ */
+ private async processImages(
+ body: string,
+ ): Promise<{ content: string; uploadedUrls: string[] }> {
+ const uploadedUrls: string[] = [];
+
+ // Match wiki-style embeds: ![[filename.ext]]
+ const wikiPattern = /!\[\[([^\]]+\.(png|jpg|jpeg|gif|webp|svg))\]\]/gi;
+ // Match markdown images: 
+ const mdPattern = /!\[([^\]]*)\]\(([^)]+\.(png|jpg|jpeg|gif|webp|svg))\)/gi;
+
+ let content = body;
+
+ // Process wiki-style embeds
+ const wikiMatches = [...body.matchAll(wikiPattern)];
+ for (const match of wikiMatches) {
+ const filename = match[1];
+ try {
+ const remoteUrl = await this.uploadLocalFile(filename);
+ if (remoteUrl) {
+ uploadedUrls.push(remoteUrl);
+ content = content.replace(match[0], ``);
+ }
+ } catch (err) {
+ console.warn(`[micropub] Failed to upload ${filename}:`, err);
+ }
+ }
+
+ // Process markdown image references
+ const mdMatches = [...content.matchAll(mdPattern)];
+ for (const match of mdMatches) {
+ const alt = match[1];
+ const path = match[2];
+ if (path.startsWith("http")) continue; // already remote
+ try {
+ const remoteUrl = await this.uploadLocalFile(path);
+ if (remoteUrl) {
+ uploadedUrls.push(remoteUrl);
+ content = content.replace(match[0], ``);
+ }
+ } catch (err) {
+ console.warn(`[micropub] Failed to upload ${path}:`, err);
+ }
+ }
+
+ return { content, uploadedUrls };
+ }
+
+ private async uploadLocalFile(path: string): Promise {
+ const file = this.app.vault.getFiles().find(
+ (f) => f.name === path || f.path === path,
+ );
+ if (!file) return undefined;
+
+ const buffer = await this.app.vault.readBinary(file);
+ const mimeType = this.guessMimeType(file.extension);
+
+ return this.client.uploadMedia(buffer, file.name, mimeType);
+ }
+
+ // ── Frontmatter helpers ──────────────────────────────────────────────────
+
+ private parseFrontmatter(raw: string): {
+ frontmatter: Record;
+ body: string;
+ } {
+ const fmMatch = raw.match(/^---\r?\n([\s\S]*?)\r?\n---\r?\n([\s\S]*)$/);
+ if (!fmMatch) return { frontmatter: {}, body: raw };
+
+ let frontmatter: Record = {};
+ try {
+ frontmatter = (parseYaml(fmMatch[1]) ?? {}) as Record;
+ } catch {
+ // Malformed frontmatter — treat as empty
+ }
+
+ return { frontmatter, body: fmMatch[2] };
+ }
+
+ private async writeUrlToNote(
+ file: TFile,
+ originalContent: string,
+ url: string,
+ ): Promise {
+ const fmMatch = originalContent.match(
+ /^(---\r?\n[\s\S]*?\r?\n---\r?\n)([\s\S]*)$/,
+ );
+
+ if (!fmMatch) {
+ // No existing frontmatter — prepend it
+ const newFm = `---\nmp-url: "${url}"\n---\n`;
+ await this.app.vault.modify(file, newFm + originalContent);
+ return;
+ }
+
+ // Inject mp-url into existing frontmatter block
+ const fmBlock = fmMatch[1];
+ const body = fmMatch[2];
+
+ if (fmBlock.includes("mp-url:")) {
+ // Replace existing mp-url line
+ const updated = fmBlock.replace(
+ /mp-url:.*(\r?\n)/,
+ `mp-url: "${url}"$1`,
+ );
+ await this.app.vault.modify(file, updated + body);
+ } else {
+ // Insert mp-url before closing ---
+ const updated = fmBlock.replace(
+ /(\r?\n---\r?\n)$/,
+ `\nmp-url: "${url}"$1`,
+ );
+ await this.app.vault.modify(file, updated + body);
+ }
+ }
+
+ private resolveArray(value: unknown): string[] {
+ if (!value) return [];
+ if (Array.isArray(value)) return value.map(String);
+ return [String(value)];
+ }
+
+ private guessMimeType(ext: string): string {
+ const map: Record = {
+ png: "image/png",
+ jpg: "image/jpeg",
+ jpeg: "image/jpeg",
+ gif: "image/gif",
+ webp: "image/webp",
+ svg: "image/svg+xml",
+ };
+ return map[ext.toLowerCase()] ?? "application/octet-stream";
+ }
+}
diff --git a/src/SettingsTab.ts b/src/SettingsTab.ts
new file mode 100644
index 0000000..74e83c0
--- /dev/null
+++ b/src/SettingsTab.ts
@@ -0,0 +1,214 @@
+/**
+ * SettingsTab.ts — Obsidian settings UI for obsidian-micropub
+ */
+
+import { App, Notice, PluginSettingTab, Setting } from "obsidian";
+import type MicropubPlugin from "./main";
+import { MicropubClient } from "./MicropubClient";
+
+export class MicropubSettingsTab extends PluginSettingTab {
+ constructor(
+ app: App,
+ private readonly plugin: MicropubPlugin,
+ ) {
+ super(app, plugin);
+ }
+
+ display(): void {
+ const { containerEl } = this;
+ containerEl.empty();
+
+ containerEl.createEl("h2", { text: "Micropub Publisher" });
+
+ // ── Endpoint discovery ───────────────────────────────────────────────
+ containerEl.createEl("h3", { text: "Endpoint Configuration" });
+
+ new Setting(containerEl)
+ .setName("Site URL")
+ .setDesc(
+ "Your site's home page. Used to auto-discover Micropub and token endpoints " +
+ "from headers.",
+ )
+ .addText((text) =>
+ text
+ .setPlaceholder("https://example.com")
+ .setValue(this.plugin.settings.siteUrl)
+ .onChange(async (value) => {
+ this.plugin.settings.siteUrl = value.trim();
+ await this.plugin.saveSettings();
+ }),
+ )
+ .addButton((btn) =>
+ btn
+ .setButtonText("Discover")
+ .setCta()
+ .onClick(async () => {
+ if (!this.plugin.settings.siteUrl) {
+ new Notice("Enter a site URL first.");
+ return;
+ }
+ btn.setDisabled(true);
+ btn.setButtonText("Discovering…");
+ try {
+ const client = new MicropubClient(
+ () => this.plugin.settings.micropubEndpoint,
+ () => this.plugin.settings.mediaEndpoint,
+ () => this.plugin.settings.accessToken,
+ );
+ const discovered = await client.discoverEndpoints(
+ this.plugin.settings.siteUrl,
+ );
+ if (discovered.micropubEndpoint) {
+ this.plugin.settings.micropubEndpoint =
+ discovered.micropubEndpoint;
+ }
+ if (discovered.mediaEndpoint) {
+ this.plugin.settings.mediaEndpoint = discovered.mediaEndpoint;
+ }
+ await this.plugin.saveSettings();
+ this.display(); // Refresh UI
+ new Notice("✅ Endpoints discovered!");
+ } catch (err: unknown) {
+ new Notice(`Discovery failed: ${String(err)}`);
+ } finally {
+ btn.setDisabled(false);
+ btn.setButtonText("Discover");
+ }
+ }),
+ );
+
+ new Setting(containerEl)
+ .setName("Micropub endpoint")
+ .setDesc("e.g. https://example.com/micropub")
+ .addText((text) =>
+ text
+ .setPlaceholder("https://example.com/micropub")
+ .setValue(this.plugin.settings.micropubEndpoint)
+ .onChange(async (value) => {
+ this.plugin.settings.micropubEndpoint = value.trim();
+ await this.plugin.saveSettings();
+ }),
+ );
+
+ new Setting(containerEl)
+ .setName("Media endpoint")
+ .setDesc(
+ "Leave blank to discover automatically from the Micropub config response.",
+ )
+ .addText((text) =>
+ text
+ .setPlaceholder("https://example.com/micropub/media")
+ .setValue(this.plugin.settings.mediaEndpoint)
+ .onChange(async (value) => {
+ this.plugin.settings.mediaEndpoint = value.trim();
+ await this.plugin.saveSettings();
+ }),
+ );
+
+ // ── Authentication ───────────────────────────────────────────────────
+ containerEl.createEl("h3", { text: "Authentication" });
+
+ new Setting(containerEl)
+ .setName("Access token")
+ .setDesc(
+ "Bearer token from your site's IndieAuth token endpoint or admin panel.",
+ )
+ .addText((text) => {
+ text
+ .setPlaceholder("your-bearer-token")
+ .setValue(this.plugin.settings.accessToken)
+ .onChange(async (value) => {
+ this.plugin.settings.accessToken = value.trim();
+ await this.plugin.saveSettings();
+ });
+ text.inputEl.type = "password";
+ })
+ .addButton((btn) =>
+ btn
+ .setButtonText("Verify")
+ .onClick(async () => {
+ if (
+ !this.plugin.settings.micropubEndpoint ||
+ !this.plugin.settings.accessToken
+ ) {
+ new Notice("Set endpoint and token first.");
+ return;
+ }
+ btn.setDisabled(true);
+ try {
+ const client = new MicropubClient(
+ () => this.plugin.settings.micropubEndpoint,
+ () => this.plugin.settings.mediaEndpoint,
+ () => this.plugin.settings.accessToken,
+ );
+ await client.fetchConfig();
+ new Notice("✅ Token is valid!");
+ } catch (err: unknown) {
+ new Notice(`Auth check failed: ${String(err)}`);
+ } finally {
+ btn.setDisabled(false);
+ }
+ }),
+ );
+
+ // ── Publish behaviour ────────────────────────────────────────────────
+ containerEl.createEl("h3", { text: "Publish Behaviour" });
+
+ new Setting(containerEl)
+ .setName("Default visibility")
+ .setDesc("Applies when the note has no explicit visibility property.")
+ .addDropdown((drop) =>
+ drop
+ .addOption("public", "Public")
+ .addOption("unlisted", "Unlisted")
+ .addOption("private", "Private")
+ .setValue(this.plugin.settings.defaultVisibility)
+ .onChange(async (value) => {
+ this.plugin.settings.defaultVisibility = value as
+ | "public"
+ | "unlisted"
+ | "private";
+ await this.plugin.saveSettings();
+ }),
+ );
+
+ new Setting(containerEl)
+ .setName("Write URL back to note")
+ .setDesc(
+ "After publishing, store the returned post URL as `mp-url` in the note's " +
+ "frontmatter. Subsequent publishes will use this URL to update the post.",
+ )
+ .addToggle((toggle) =>
+ toggle
+ .setValue(this.plugin.settings.writeUrlToFrontmatter)
+ .onChange(async (value) => {
+ this.plugin.settings.writeUrlToFrontmatter = value;
+ await this.plugin.saveSettings();
+ }),
+ );
+
+ // ── Digital Garden ───────────────────────────────────────────────────
+ containerEl.createEl("h3", { text: "Digital Garden" });
+
+ new Setting(containerEl)
+ .setName("Map #garden/* tags to gardenStage")
+ .setDesc(
+ "When enabled, Obsidian tags like #garden/plant are converted to a " +
+ "`garden-stage: plant` Micropub property. The Eleventy blog theme renders " +
+ "these as growth stage badges and groups posts in the /garden/ index.",
+ )
+ .addToggle((toggle) =>
+ toggle
+ .setValue(this.plugin.settings.mapGardenTags)
+ .onChange(async (value) => {
+ this.plugin.settings.mapGardenTags = value;
+ await this.plugin.saveSettings();
+ }),
+ );
+
+ containerEl.createEl("p", {
+ text: "Supported stages: plant 🌱 · cultivate 🌿 · question ❓ · repot 🪴 · revitalize ✨ · revisit 🔄",
+ cls: "setting-item-description",
+ });
+ }
+}
diff --git a/src/main.ts b/src/main.ts
new file mode 100644
index 0000000..734e489
--- /dev/null
+++ b/src/main.ts
@@ -0,0 +1,133 @@
+/**
+ * main.ts — obsidian-micropub plugin entry point
+ *
+ * Publishes the active note to any Micropub-compatible endpoint.
+ * Designed to work with Indiekit (https://getindiekit.com) but compatible
+ * with any server that implements the Micropub spec (W3C).
+ *
+ * Key features vs. the original obsidian-microblog:
+ * - Configurable endpoint URL (not hardcoded to Micro.blog)
+ * - Auto-discovery of micropub/media endpoints from headers
+ * - #garden/* tag → gardenStage property mapping for Digital Garden
+ * - Writes returned post URL back to note frontmatter for future updates
+ * - Supports create + update flows
+ *
+ * Based on: https://github.com/svemagie/obsidian-microblog (MIT)
+ */
+
+import { Notice, Plugin, TFile } from "obsidian";
+import { DEFAULT_SETTINGS, type MicropubSettings } from "./types";
+import { MicropubSettingsTab } from "./SettingsTab";
+import { Publisher } from "./Publisher";
+
+export default class MicropubPlugin extends Plugin {
+ settings!: MicropubSettings;
+
+ async onload(): Promise {
+ await this.loadSettings();
+
+ // ── Commands ─────────────────────────────────────────────────────────
+
+ this.addCommand({
+ id: "publish-to-micropub",
+ name: "Publish to Micropub",
+ checkCallback: (checking: boolean) => {
+ const file = this.app.workspace.getActiveFile();
+ if (!file || file.extension !== "md") return false;
+ if (checking) return true;
+
+ this.publishActiveNote(file);
+ return true;
+ },
+ });
+
+ this.addCommand({
+ id: "publish-to-micropub-update",
+ name: "Update existing Micropub post",
+ checkCallback: (checking: boolean) => {
+ const file = this.app.workspace.getActiveFile();
+ if (!file || file.extension !== "md") return false;
+ if (checking) return true;
+
+ // Update uses the same publish flow — Publisher detects mp-url and routes to update
+ this.publishActiveNote(file);
+ return true;
+ },
+ });
+
+ // ── Settings tab ─────────────────────────────────────────────────────
+
+ this.addSettingTab(new MicropubSettingsTab(this.app, this));
+
+ // ── Ribbon icon ──────────────────────────────────────────────────────
+
+ this.addRibbonIcon("send", "Publish to Micropub", () => {
+ const file = this.app.workspace.getActiveFile();
+ if (!file || file.extension !== "md") {
+ new Notice("Open a Markdown note to publish.");
+ return;
+ }
+ this.publishActiveNote(file);
+ });
+ }
+
+ onunload(): void {
+ // Nothing to clean up
+ }
+
+ // ── Publish flow ──────────────────────────────────────────────────────────
+
+ private async publishActiveNote(file: TFile): Promise {
+ if (!this.settings.micropubEndpoint) {
+ new Notice(
+ "⚠️ Micropub endpoint not configured. Open plugin settings to add it.",
+ );
+ return;
+ }
+
+ if (!this.settings.accessToken) {
+ new Notice(
+ "⚠️ Access token not configured. Open plugin settings to add it.",
+ );
+ return;
+ }
+
+ const notice = new Notice("Publishing…", 0 /* persist until dismissed */);
+
+ try {
+ const publisher = new Publisher(this.app, this.settings);
+ const result = await publisher.publish(file);
+
+ notice.hide();
+
+ if (result.success) {
+ const urlDisplay = result.url
+ ? `\n${result.url}`
+ : "";
+ new Notice(`✅ Published!${urlDisplay}`, 8000);
+ } else {
+ new Notice(`❌ Publish failed: ${result.error}`, 10000);
+ console.error("[micropub] Publish failed:", result.error);
+ }
+ } catch (err: unknown) {
+ notice.hide();
+ const msg = err instanceof Error ? err.message : String(err);
+ new Notice(`❌ Error: ${msg}`, 10000);
+ console.error("[micropub] Unexpected error:", err);
+ }
+ }
+
+ // ── Settings persistence ──────────────────────────────────────────────────
+
+ async loadSettings(): Promise {
+ this.settings = Object.assign(
+ {},
+ DEFAULT_SETTINGS,
+ await this.loadData(),
+ ) as MicropubSettings;
+ }
+
+ async saveSettings(): Promise {
+ await this.saveData(this.settings);
+ }
+}
diff --git a/src/types.ts b/src/types.ts
new file mode 100644
index 0000000..cf9c287
--- /dev/null
+++ b/src/types.ts
@@ -0,0 +1,107 @@
+/**
+ * types.ts — shared interfaces for obsidian-micropub
+ */
+
+/** Plugin settings stored in data.json */
+export interface MicropubSettings {
+ /** Full URL of the Micropub endpoint, e.g. https://example.com/micropub */
+ micropubEndpoint: string;
+
+ /**
+ * Full URL of the media endpoint for image uploads.
+ * If empty, discovered automatically from the Micropub config query,
+ * or derived from the micropubEndpoint (some servers use /micropub/media).
+ */
+ mediaEndpoint: string;
+
+ /**
+ * Bearer token for Authorization: Bearer .
+ * Obtain from your IndieAuth token endpoint or server admin panel.
+ */
+ accessToken: string;
+
+ /**
+ * The syndication targets to pre-tick in the publish dialog.
+ * Values are uid strings returned by the Micropub config ?q=config.
+ */
+ defaultSyndicateTo: string[];
+
+ /**
+ * When true, perform a discovery fetch against the site URL to auto-detect
+ * the micropub and token endpoints from headers.
+ */
+ autoDiscover: boolean;
+
+ /** Your site's homepage URL — used for endpoint discovery. */
+ siteUrl: string;
+
+ /**
+ * When true, after a successful publish the post URL returned by the server
+ * is written back to the note's frontmatter as `mp-url`.
+ */
+ writeUrlToFrontmatter: boolean;
+
+ /**
+ * Map Obsidian #garden/* tags to a `gardenStage` Micropub property.
+ * When enabled, a tag like #garden/plant becomes { "garden-stage": "plant" }
+ * in the Micropub request (and gardenStage: plant in the server's front matter).
+ */
+ mapGardenTags: boolean;
+
+ /** Visibility default for new posts: "public" | "unlisted" | "private" */
+ defaultVisibility: "public" | "unlisted" | "private";
+}
+
+export const DEFAULT_SETTINGS: MicropubSettings = {
+ micropubEndpoint: "",
+ mediaEndpoint: "",
+ accessToken: "",
+ defaultSyndicateTo: [],
+ autoDiscover: false,
+ siteUrl: "",
+ writeUrlToFrontmatter: true,
+ mapGardenTags: true,
+ defaultVisibility: "public",
+};
+
+/** A syndication target as returned by Micropub config query */
+export interface SyndicationTarget {
+ uid: string;
+ name: string;
+}
+
+/** Micropub config response (?q=config) */
+export interface MicropubConfig {
+ "media-endpoint"?: string;
+ "syndicate-to"?: SyndicationTarget[];
+ "post-types"?: Array<{ type: string; name: string }>;
+}
+
+/**
+ * Garden stages — matches Obsidian #garden/* tags and blog gardenStage values.
+ * The Micropub property name is "garden-stage" (hyphenated, Micropub convention).
+ */
+export type GardenStage =
+ | "plant"
+ | "cultivate"
+ | "question"
+ | "repot"
+ | "revitalize"
+ | "revisit";
+
+export const GARDEN_STAGE_LABELS: Record = {
+ plant: "🌱 Seedling",
+ cultivate: "🌿 Growing",
+ question: "❓ Open Question",
+ repot: "🪴 Repotting",
+ revitalize: "✨ Revitalizing",
+ revisit: "🔄 Revisit",
+};
+
+/** Result returned by Publisher.publish() */
+export interface PublishResult {
+ success: boolean;
+ /** URL of the published post (from Location response header) */
+ url?: string;
+ error?: string;
+}
diff --git a/tsconfig.json b/tsconfig.json
new file mode 100644
index 0000000..05cad47
--- /dev/null
+++ b/tsconfig.json
@@ -0,0 +1,17 @@
+{
+ "compilerOptions": {
+ "baseUrl": ".",
+ "inlineSourceMap": true,
+ "inlineSources": true,
+ "module": "ESNext",
+ "target": "ES2018",
+ "allowImportingTsExtensions": true,
+ "moduleResolution": "bundler",
+ "importHelpers": true,
+ "isolatedModules": true,
+ "strictNullChecks": true,
+ "strict": true,
+ "lib": ["ES2018", "DOM"]
+ },
+ "include": ["src/**/*.ts"]
+}