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 `![alt](relative/path.jpg)` 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: ![alt](path) + 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], `![${filename}](${remoteUrl})`); + } + } 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], `![${alt}](${remoteUrl})`); + } + } 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"] +}