commit f5af7ff1e2a197bf24a98e4aa8844abf989390a6
Author: svemagie <869694+svemagie@users.noreply.github.com>
Date: Tue Mar 31 14:23:33 2026 +0200
fresh
diff --git a/.claude/settings.local.json b/.claude/settings.local.json
new file mode 100644
index 0000000..d8515f1
--- /dev/null
+++ b/.claude/settings.local.json
@@ -0,0 +1,12 @@
+{
+ "permissions": {
+ "allow": [
+ "Bash(npm run:*)",
+ "Bash(echo \"Exit: $?\")",
+ "Bash(git add:*)",
+ "Bash(git commit:*)",
+ "Bash(npm install:*)",
+ "Bash(npm test:*)"
+ ]
+ }
+}
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..9a0f7dd
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,4 @@
+node_modules
+data.json
+package-lock.json
+.worktrees
diff --git a/README.md b/README.md
new file mode 100644
index 0000000..0a1c991
--- /dev/null
+++ b/README.md
@@ -0,0 +1,234 @@
+# 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
+- **IndieAuth sign-in** — browser-based PKCE login, no token copy-paste required
+- **Auto-discovery** — reads `` from your site to find the endpoint automatically
+- **Article vs. note** — auto-detected from frontmatter; override with `postType`
+- **Digital Garden stage mapping** — Obsidian tags `#garden/plant`, `#garden/cultivate`, etc. become a `gardenStage` property on the published post
+- **Create + Update** — if the note has an `mp-url` frontmatter key, Publish updates the existing post instead of creating a new one
+- **Image upload** — local images (`![[file.png]]` and ``) are uploaded to the media endpoint and rewritten to remote URLs in the post content
+- **WikiLink resolution** — `[[Note Name]]` links in the body are resolved to their published blog URLs via `mp-url` frontmatter
+- **Interaction posts** — bookmark, like, reply, repost using standard Micropub properties
+- **AI disclosure** — `ai-text-level`, `ai-tools`, etc. pass through as Micropub properties
+- **URL write-back** — the returned post URL is saved to `mp-url` in the note's frontmatter after publishing
+
+---
+
+## Installation
+
+### 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
+
+### From source
+
+```bash
+cd /path/to/your/obsidian/vault/.obsidian/plugins
+git clone https://github.com/svemagie/obsidian-micropub
+cd obsidian-micropub
+npm install
+npm run build
+```
+
+---
+
+## Configuration
+
+Open **Settings → Micropub Publisher**.
+
+### Sign in with IndieAuth (recommended)
+
+1. Enter your **Site URL** (e.g. `https://blog.example.com`).
+2. Click **Sign in** — your browser opens your site's IndieAuth login page.
+3. Log in. The browser redirects back to Obsidian automatically.
+4. The plugin stores your access token and fills in the endpoint URLs.
+
+The flow uses [PKCE](https://oauth.net/2/pkce/) and a GitHub Pages relay page as the redirect URI, so it works without a local HTTP server.
+
+### Manual token (advanced)
+
+Expand **Or paste a token manually** and enter a bearer token from your Indiekit admin panel (`create update media` scope). Click **Verify** to confirm it works.
+
+### Settings reference
+
+| Setting | Default | Description |
+|---|---|---|
+| Site URL | — | Your site's homepage; used for IndieAuth endpoint discovery |
+| Micropub endpoint | — | e.g. `https://example.com/micropub` |
+| Media endpoint | — | For image uploads; auto-discovered from Micropub config if blank |
+| Default visibility | `public` | Applied when the note has no `visibility` field |
+| Write URL back to note | on | Saves the published post URL as `mp-url` in frontmatter |
+| Map #garden/* tags | on | Converts `#garden/plant` → `gardenStage: plant` Micropub property |
+
+---
+
+## Digital Garden workflow
+
+Tag any note in Obsidian with a `#garden/*` tag, or set `gardenStage` directly in frontmatter:
+
+| Obsidian tag | Published property | Blog display |
+|---|---|---|
+| `#garden/evergreen` | `gardenStage: evergreen` | 🌳 Evergreen |
+| `#garden/cultivate` | `gardenStage: cultivate` | 🌿 Growing |
+| `#garden/plant` | `gardenStage: plant` | 🌱 Seedling |
+| `#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/plant
+category:
+ - indieweb
+---
+
+Some early thoughts on the merits of building in public...
+```
+
+After publishing, the frontmatter/property in Obsidian gains:
+
+```yaml
+mp-url: "https://example.com/articles/2026/on-building-in-public"
+```
+
+---
+
+## Frontmatter properties recognised
+
+### Post identity
+
+| Property | Effect |
+|---|---|
+| `mp-url` / `url` | Existing post URL — triggers an **update** rather than create |
+| `postType` | Force post type: `article` (sets `name`), `note` (skips `name`) |
+| `title` / `name` | Sets the post `name`; presence auto-detects post type as article |
+
+If no `postType` is set: a note with a `title` or `name` field publishes as an article; a note without one publishes as a note.
+
+### Content
+
+| Property | Effect |
+|---|---|
+| `created` / `date` | Sets `published` date; `created` takes priority (matches Obsidian's default) |
+| `tags` + `category` | Both merged into Micropub `category`; `garden/*` and bare `garden` tags are filtered out |
+| `summary` / `excerpt` | Sets the `summary` property |
+| `visibility` | `public` / `unlisted` / `private` |
+| `photo` | Featured photo: a URL string, array of URLs, or `[{url, alt}]` objects |
+| `related` | List of `[[WikiLinks]]` or URLs to related posts; WikiLinks are resolved to `mp-url` |
+
+### Syndication
+
+| Property | Effect |
+|---|---|
+| `mp-syndicate-to` / `mpSyndicateTo` | Per-note syndication targets, merged with the default targets in settings |
+| `mp-*` | Any other `mp-*` key (except `mp-url`) is passed through verbatim |
+
+### Interaction posts
+
+Set one of these to publish a bookmark, like, reply, or repost. Adding body text to an interaction note includes it as a comment or quote; bare likes and reposts omit `content` entirely.
+
+| Property | Effect |
+|---|---|
+| `bookmarkOf` / `bookmark-of` | URL being bookmarked |
+| `likeOf` / `like-of` | URL being liked |
+| `inReplyTo` / `in-reply-to` | URL being replied to |
+| `repostOf` / `repost-of` | URL being reposted |
+
+### AI disclosure
+
+Flat kebab-case keys are recommended; camelCase and a nested `ai:` object are also supported.
+
+| Property | Values | Meaning |
+|---|---|---|
+| `ai-text-level` | `"0"` `"1"` `"2"` `"3"` | None / Editorial / Co-drafted / AI-generated |
+| `ai-code-level` | `"0"` `"1"` `"2"` | None / AI-assisted / AI-generated |
+| `ai-tools` | string | Tools used, e.g. `"Claude"` |
+| `ai-description` | string | Free-text disclosure note |
+
+### Digital Garden stages
+
+Set via `gardenStage` frontmatter or a `#garden/` tag:
+
+| Stage | Badge | Meaning |
+|---|---|---|
+| `plant` | 🌱 Seedling | New, rough idea |
+| `cultivate` | 🌿 Growing | Being actively developed |
+| `evergreen` | 🌳 Evergreen | Mature, lasting content |
+| `question` | ❓ Open Question | An unresolved inquiry |
+| `repot` | 🪴 Repotting | Restructuring needed |
+| `revitalize` | ✨ Revitalizing | Being refreshed |
+| `revisit` | 🔄 Revisit | Flagged to come back to |
+
+When a note is first published with `gardenStage: evergreen`, an `evergreen-since` date is stamped automatically.
+
+**Example article template:**
+
+```yaml
+---
+title: "My Post"
+created: 2026-03-15T10:00:00
+postType: article
+tags:
+ - garden/evergreen
+category:
+ - indieweb
+ - lang/en
+ai-text-level: "1"
+ai-tools: "Claude"
+---
+```
+
+---
+
+## Development
+
+```bash
+npm run dev # watch mode with inline sourcemaps
+npm run build # production bundle (minified)
+```
+
+### Architecture
+
+```
+src/
+ main.ts Plugin entry point, commands, ribbon, protocol handler
+ 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)
+ IndieAuth.ts PKCE IndieAuth sign-in via GitHub Pages relay
+ 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/docs/callback/index.html b/docs/callback/index.html
new file mode 100644
index 0000000..dd29ef2
--- /dev/null
+++ b/docs/callback/index.html
@@ -0,0 +1,74 @@
+
+
+
+
+ Returning to Obsidian…
+
+
+
+
+ This page serves as the OAuth client_id for the IndieAuth sign-in flow.
+ After authorizing, you will be redirected back to Obsidian automatically.
+
+
+
diff --git a/docs/superpowers/specs/2026-03-30-syndication-dialog-design.md b/docs/superpowers/specs/2026-03-30-syndication-dialog-design.md
new file mode 100644
index 0000000..e98b720
--- /dev/null
+++ b/docs/superpowers/specs/2026-03-30-syndication-dialog-design.md
@@ -0,0 +1,217 @@
+# Syndication Dialog Design
+
+**Date:** 2026-03-30
+**Status:** Approved
+**Scope:** obsidian-micropub plugin feature
+
+---
+
+## 1. Overview
+
+Add a dialog that appears when publishing to Micropub, allowing users to select which syndication targets (e.g., Twitter, Mastodon) to cross-post to. The dialog integrates with the existing `?q=config` Micropub endpoint to fetch available targets.
+
+---
+
+## 2. User Flow
+
+1. User clicks "Publish to Micropub"
+2. Plugin fetches `?q=config` to get available syndication targets
+3. Plugin checks frontmatter for `mp-syndicate-to`:
+ - **Has values** → use those, skip dialog, publish
+ - **Empty array `[]`** → force dialog
+ - **Absent** → show dialog with defaults pre-checked
+4. Dialog displays checkboxes for each target from server
+5. User confirms → publish with selected targets
+6. Successful publish writes `mp-syndicate-to` to frontmatter
+
+---
+
+## 3. Configuration
+
+### New Settings
+
+| Setting | Type | Default | Description |
+|---------|------|---------|-------------|
+| `showSyndicationDialog` | enum | `"when-needed"` | When to show the dialog |
+| `defaultSyndicateTo` | string[] | `[]` | Targets checked by default |
+
+### `showSyndicationDialog` Options
+
+- `"when-needed"` — Show only if `mp-syndicate-to` is absent from frontmatter
+- `"always"` — Show every time user publishes
+- `"never"` — Use defaults, never show dialog
+
+### Frontmatter Support
+
+Users can bypass the dialog per-note using frontmatter:
+
+```yaml
+---
+# Skip dialog, auto-syndicate to these targets
+mp-syndicate-to: [twitter, mastodon]
+
+# Force dialog even with defaults set
+mp-syndicate-to: []
+---
+```
+
+---
+
+## 4. Components
+
+### 4.1 SyndicationDialog (New)
+
+**Location:** `src/SyndicationDialog.ts`
+
+**Responsibilities:**
+- Render modal with checkbox list of targets
+- Pre-check targets from `defaultSyndicateTo` setting
+- Handle OK/Cancel actions
+- Return selected target UIDs via promise
+
+**Interface:**
+```typescript
+export class SyndicationDialog extends Modal {
+ constructor(
+ app: App,
+ targets: SyndicationTarget[],
+ defaultSelected: string[]
+ );
+
+ /**
+ * Opens the dialog and waits for user selection.
+ * @returns Selected target UIDs, or null if cancelled.
+ */
+ async awaitSelection(): Promise;
+}
+```
+
+### 4.2 Publisher (Modified)
+
+**Changes:**
+- Accept optional `syndicateToOverride?: string[]` parameter
+- Merge override with frontmatter values (override wins)
+- Write `mp-syndicate-to` to frontmatter on successful publish
+
+### 4.3 SettingsTab (Modified)
+
+**Changes:**
+- Add dropdown for `showSyndicationDialog` behavior
+- Display currently configured default targets (read-only list)
+- Add button to clear defaults
+
+### 4.4 main.ts (Modified)
+
+**Changes:**
+- Before calling `publishActiveNote`:
+ 1. Fetch `?q=config` for syndication targets
+ 2. Check frontmatter for `mp-syndicate-to`
+ 3. Decide whether to show dialog based on setting + frontmatter
+ 4. If showing dialog, wait for user selection
+ 5. Call `publisher.publish()` with selected targets
+
+---
+
+## 5. Data Flow
+
+```
+User clicks "Publish"
+ │
+ ▼
+Fetch ?q=config ──► Check frontmatter mp-syndicate-to
+ │ │
+ │ ┌─────────────┼─────────────┐
+ │ │ │ │
+ │ Has values Absent Empty []
+ │ (skip dialog) (show dialog) (show dialog)
+ │ │ │ │
+ │ └─────────────┴─────────────┘
+ │ │
+ ▼ ▼
+SyndicationDialog (if needed)
+ │
+ ▼
+Publisher.publish(selectedTargets?)
+ │
+ ▼
+Write mp-syndicate-to to frontmatter
+```
+
+---
+
+## 6. Error Handling
+
+| Scenario | Behavior |
+|----------|----------|
+| `?q=config` fails | Warn user, offer to publish without syndication or cancel |
+| Dialog canceled | Abort publish, no changes |
+| Micropub POST fails | Don't write `mp-syndicate-to` to frontmatter |
+| No targets returned from server | Skip dialog, publish normally (backward compatible) |
+
+---
+
+## 7. UI/UX Details
+
+### Dialog Layout
+
+```
+┌─────────────────────────────────────────┐
+│ Publish to Syndication Targets │
+├─────────────────────────────────────────┤
+│ │
+│ [✓] Twitter (@username) │
+│ [✓] Mastodon (@user@instance) │
+│ [ ] LinkedIn │
+│ │
+├─────────────────────────────────────────┤
+│ [Cancel] [Publish] │
+└─────────────────────────────────────────┘
+```
+
+### Settings UI Addition
+
+```
+Publish Behaviour
+├── Default visibility: [public ▼]
+├── Write URL back to note: [✓]
+├── Syndication dialog: [when-needed ▼]
+│ └── when-needed: Show only if no mp-syndicate-to
+├── Default syndication targets:
+│ └── twitter, mastodon [Clear defaults]
+└── ...
+```
+
+---
+
+## 8. Edge Cases
+
+1. **User has no syndication targets configured on server** — Skip dialog, publish normally
+2. **User cancels dialog** — Abort publish entirely, no state changes
+3. **Micropub server returns targets but some are invalid** — Show all, let server reject invalid ones
+4. **User changes targets in settings after publishing** — Affects future publishes only, doesn't retroactively change existing `mp-syndicate-to` frontmatter
+
+---
+
+## 9. Backward Compatibility
+
+- Default `showSyndicationDialog: "when-needed"` means existing behavior unchanged for notes without frontmatter
+- Existing `mp-syndicate-to` frontmatter values continue to work
+- Plugin remains compatible with servers that don't return syndication targets
+
+---
+
+## 10. Testing Considerations
+
+- Unit test: `SyndicationDialog` renders checkboxes correctly
+- Unit test: Frontmatter parsing handles `mp-syndicate-to` array
+- Unit test: Setting `"never"` skips dialog
+- Integration test: Full flow from click to publish with targets
+- Edge case: Server returns empty targets array
+- Edge case: User cancels dialog
+
+---
+
+## Approval
+
+**Approved by:** @svemagie
+**Date:** 2026-03-30
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/main.js b/main.js
new file mode 100644
index 0000000..c1e5478
--- /dev/null
+++ b/main.js
@@ -0,0 +1,21 @@
+/*
+THIS IS A GENERATED/BUNDLED FILE BY ESBUILD
+if you want to view the source, please visit the github repository of this plugin
+*/
+
+"use strict";var xt=Object.create;var U=Object.defineProperty;var Pt=Object.getOwnPropertyDescriptor;var Rt=Object.getOwnPropertyNames;var At=Object.getPrototypeOf,Ct=Object.prototype.hasOwnProperty;var $t=(g,t)=>{for(var e in t)U(g,e,{get:t[e],enumerable:!0})},bt=(g,t,e,n)=>{if(t&&typeof t=="object"||typeof t=="function")for(let i of Rt(t))!Ct.call(g,i)&&i!==e&&U(g,i,{get:()=>t[i],enumerable:!(n=Pt(t,i))||n.enumerable});return g};var Dt=(g,t,e)=>(e=g!=null?xt(At(g)):{},bt(t||!g||!g.__esModule?U(e,"default",{value:g,enumerable:!0}):e,g)),Mt=g=>bt(U({},"__esModule",{value:!0}),g);var Ft={};$t(Ft,{default:()=>O});module.exports=Mt(Ft);var y=require("obsidian");var wt={micropubEndpoint:"",mediaEndpoint:"",accessToken:"",defaultSyndicateTo:[],autoDiscover:!1,siteUrl:"",authorizationEndpoint:"",tokenEndpoint:"",me:"",writeUrlToFrontmatter:!0,mapGardenTags:!0,defaultVisibility:"public",showSyndicationDialog:"when-needed"};var h=require("obsidian");var k=require("obsidian"),b=class{constructor(t,e,n){this.getEndpoint=t;this.getMediaEndpoint=e;this.getToken=n}async fetchConfig(){let t=`${this.getEndpoint()}?q=config`;return(await(0,k.requestUrl)({url:t,method:"GET",headers:this.authHeaders()})).json}async discoverEndpoints(t){let n=(await(0,k.requestUrl)({url:t,method:"GET"})).text,i=this.extractLinkRel(n,"micropub"),s=this.extractLinkRel(n,"token_endpoint"),r;if(i)try{r=(await this.fetchConfigFrom(i))["media-endpoint"]}catch(o){}return{micropubEndpoint:i,tokenEndpoint:s,mediaEndpoint:r}}async createPost(t){var n,i,s;let e={type:["h-entry"],properties:t};try{let r=await(0,k.requestUrl)({url:this.getEndpoint(),method:"POST",headers:{...this.authHeaders(),"Content-Type":"application/json"},body:JSON.stringify(e),throw:!1});if(r.status===201||r.status===202)return{success:!0,url:((n=r.headers)==null?void 0:n.location)||((i=r.headers)==null?void 0:i.Location)||((s=r.json)==null?void 0:s.url)};let o=this.extractError(r.text);return{success:!1,error:`HTTP ${r.status}: ${o}`}}catch(r){return{success:!1,error:String(r)}}}async updatePost(t,e){let n={action:"update",url:t,replace:e};try{let i=await(0,k.requestUrl)({url:this.getEndpoint(),method:"POST",headers:{...this.authHeaders(),"Content-Type":"application/json"},body:JSON.stringify(n),throw:!1});return i.status>=200&&i.status<300?{success:!0,url:t}:{success:!1,error:`HTTP ${i.status}: ${this.extractError(i.text)}`}}catch(i){return{success:!1,error:String(i)}}}async uploadMedia(t,e,n){var u,f,T;let i=this.getMediaEndpoint()||`${this.getEndpoint()}/media`,s=`----MicropubBoundary${Date.now()}`,r=`--${s}\r
+Content-Disposition: form-data; name="file"; filename="${e}"\r
+Content-Type: ${n}\r
+\r
+`,o=`\r
+--${s}--\r
+`,a=new TextEncoder().encode(r),c=new TextEncoder().encode(o),l=new Uint8Array(t),d=new Uint8Array(a.length+l.length+c.length);d.set(a,0),d.set(l,a.length),d.set(c,a.length+l.length);let p=await(0,k.requestUrl)({url:i,method:"POST",headers:{...this.authHeaders(),"Content-Type":`multipart/form-data; boundary=${s}`},body:d.buffer,throw:!1});if(p.status===201||p.status===202){let w=((u=p.headers)==null?void 0:u.location)||((f=p.headers)==null?void 0:f.Location)||((T=p.json)==null?void 0:T.url);if(w)return w}throw new Error(`Media upload failed (HTTP ${p.status}): ${this.extractError(p.text)}`)}authHeaders(){return{Authorization:`Bearer ${this.getToken()}`}}extractLinkRel(t,e){var s;let n=new RegExp(`]+rel=["']${e}["'][^>]+href=["']([^"']+)["']|]+href=["']([^"']+)["'][^>]+rel=["']${e}["']`,"i"),i=t.match(n);return(s=i==null?void 0:i[1])!=null?s:i==null?void 0:i[2]}async fetchConfigFrom(t){return(await(0,k.requestUrl)({url:`${t}?q=config`,method:"GET",headers:this.authHeaders()})).json}extractError(t){var e,n;try{let i=JSON.parse(t);return(n=(e=i.error_description)!=null?e:i.error)!=null?n:t.slice(0,200)}catch(i){return t.slice(0,200)}}};var A=Dt(require("crypto")),j=require("obsidian"),vt="https://svemagie.github.io/obsidian-micropub/",St="https://svemagie.github.io/obsidian-micropub/callback",kt="create update media",Ut=300*1e3,R=null;function Tt(g){if(!R)return;let{resolve:t,state:e}=R;R=null,t(g)}var F=class g{static async discoverEndpoints(t){let n=(await(0,j.requestUrl)({url:t,method:"GET"})).text,i=g.extractLinkRel(n,"authorization_endpoint"),s=g.extractLinkRel(n,"token_endpoint"),r=g.extractLinkRel(n,"micropub");if(!i)throw new Error(`No found at ${t}. Make sure Indiekit is running and SITE_URL is set correctly.`);if(!s)throw new Error(`No found at ${t}.`);return{authorizationEndpoint:i,tokenEndpoint:s,micropubEndpoint:r}}static async signIn(t){var f,T,w,E,$,x;let{authorizationEndpoint:e,tokenEndpoint:n,micropubEndpoint:i}=await g.discoverEndpoints(t),s=g.base64url(A.randomBytes(16)),r=g.base64url(A.randomBytes(64)),o=g.base64url(A.createHash("sha256").update(r).digest()),a=new Promise((D,P)=>{let v=setTimeout(()=>{R=null,P(new Error("Sign-in timed out (5 min). Please try again."))},Ut);R={state:s,resolve:M=>{clearTimeout(v),D(M)}}}),c=new URL(e);c.searchParams.set("response_type","code"),c.searchParams.set("client_id",vt),c.searchParams.set("redirect_uri",St),c.searchParams.set("state",s),c.searchParams.set("code_challenge",o),c.searchParams.set("code_challenge_method","S256"),c.searchParams.set("scope",kt),c.searchParams.set("me",t),window.open(c.toString());let l=await a;if(l.state!==s)throw new Error("State mismatch \u2014 possible CSRF attack. Please try again.");let d=l.code;if(!d)throw new Error((T=(f=l.error_description)!=null?f:l.error)!=null?T:"No authorization code received.");let p=await(0,j.requestUrl)({url:n,method:"POST",headers:{"Content-Type":"application/x-www-form-urlencoded",Accept:"application/json"},body:new URLSearchParams({grant_type:"authorization_code",code:d,client_id:vt,redirect_uri:St,code_verifier:r}).toString(),throw:!1}),u=p.json;if(!u.access_token)throw new Error((E=(w=u.error_description)!=null?w:u.error)!=null?E:`Token exchange failed (HTTP ${p.status})`);return{accessToken:u.access_token,scope:($=u.scope)!=null?$:kt,me:(x=u.me)!=null?x:t,authorizationEndpoint:e,tokenEndpoint:n,micropubEndpoint:i}}static base64url(t){return t.toString("base64").replace(/\+/g,"-").replace(/\//g,"_").replace(/=/g,"")}static extractLinkRel(t,e){var s;let n=new RegExp(`]+rel=["'][^"']*\\b${e}\\b[^"']*["'][^>]+href=["']([^"']+)["']|]+href=["']([^"']+)["'][^>]+rel=["'][^"']*\\b${e}\\b[^"']*["']`,"i"),i=t.match(n);return(s=i==null?void 0:i[1])!=null?s:i==null?void 0:i[2]}};var L=class extends h.PluginSettingTab{constructor(e,n){super(e,n);this.plugin=n}display(){let{containerEl:e}=this;e.empty(),e.createEl("h2",{text:"Micropub Publisher"}),e.createEl("h3",{text:"Account"}),this.plugin.settings.me&&this.plugin.settings.accessToken?this.renderSignedIn(e):this.renderSignedOut(e),e.createEl("h3",{text:"Endpoints"}),e.createEl("p",{text:"These are filled automatically when you sign in. Only edit them manually if your server uses non-standard paths.",cls:"setting-item-description"}),new h.Setting(e).setName("Micropub endpoint").setDesc("e.g. https://blog.giersig.eu/micropub").addText(s=>s.setPlaceholder("https://example.com/micropub").setValue(this.plugin.settings.micropubEndpoint).onChange(async r=>{this.plugin.settings.micropubEndpoint=r.trim(),await this.plugin.saveSettings()})),new h.Setting(e).setName("Media endpoint").setDesc("For image uploads. Auto-discovered if blank.").addText(s=>s.setPlaceholder("https://example.com/micropub/media").setValue(this.plugin.settings.mediaEndpoint).onChange(async r=>{this.plugin.settings.mediaEndpoint=r.trim(),await this.plugin.saveSettings()})),e.createEl("h3",{text:"Publish Behaviour"}),new h.Setting(e).setName("Default visibility").setDesc("Applies when the note has no explicit visibility property.").addDropdown(s=>s.addOption("public","Public").addOption("unlisted","Unlisted").addOption("private","Private").setValue(this.plugin.settings.defaultVisibility).onChange(async r=>{this.plugin.settings.defaultVisibility=r,await this.plugin.saveSettings()})),new h.Setting(e).setName("Write URL back to note").setDesc("After publishing, store the post URL as `mp-url` in frontmatter. Subsequent publishes will update the existing post instead of creating a new one.").addToggle(s=>s.setValue(this.plugin.settings.writeUrlToFrontmatter).onChange(async r=>{this.plugin.settings.writeUrlToFrontmatter=r,await this.plugin.saveSettings()})),new h.Setting(e).setName("Syndication dialog").setDesc("When to show the cross-posting dialog before publishing. 'When needed' shows it only if the note has no mp-syndicate-to frontmatter.").addDropdown(s=>s.addOption("when-needed","When needed").addOption("always","Always").addOption("never","Never").setValue(this.plugin.settings.showSyndicationDialog).onChange(async r=>{this.plugin.settings.showSyndicationDialog=r,await this.plugin.saveSettings()}));let n=this.plugin.settings.defaultSyndicateTo,i=new h.Setting(e).setName("Default syndication targets").setDesc(n.length>0?n.join(", "):"None configured. Targets checked by default in the publish dialog.");n.length>0&&i.addButton(s=>s.setButtonText("Clear defaults").setWarning().onClick(async()=>{this.plugin.settings.defaultSyndicateTo=[],await this.plugin.saveSettings(),this.display()})),e.createEl("h3",{text:"Digital Garden"}),new h.Setting(e).setName("Map #garden/* tags to gardenStage").setDesc("Obsidian tags like #garden/plant become a `garden-stage: plant` Micropub property. The blog renders these as growth stage badges at /garden/.").addToggle(s=>s.setValue(this.plugin.settings.mapGardenTags).onChange(async r=>{this.plugin.settings.mapGardenTags=r,await this.plugin.saveSettings()})),e.createEl("p",{text:"Stages: plant \u{1F331} \xB7 cultivate \u{1F33F} \xB7 evergreen \u{1F333} \xB7 question \u2753 \xB7 repot \u{1FAB4} \xB7 revitalize \u2728 \xB7 revisit \u{1F504}",cls:"setting-item-description"})}renderSignedOut(e){new h.Setting(e).setName("Site URL").setDesc("Your site's home page. Clicking Sign in opens your blog's login page in the browser \u2014 the same flow iA Writer uses.").addText(i=>i.setPlaceholder("https://blog.giersig.eu").setValue(this.plugin.settings.siteUrl).onChange(async s=>{this.plugin.settings.siteUrl=s.trim(),await this.plugin.saveSettings()})).addButton(i=>{i.setButtonText("Sign in").setCta().onClick(async()=>{let s=this.plugin.settings.siteUrl.trim();if(!s){new h.Notice("Enter your site URL first.");return}i.setDisabled(!0),i.setButtonText("Opening browser\u2026");try{let r=await F.signIn(s);if(this.plugin.settings.accessToken=r.accessToken,this.plugin.settings.me=r.me,this.plugin.settings.authorizationEndpoint=r.authorizationEndpoint,this.plugin.settings.tokenEndpoint=r.tokenEndpoint,r.micropubEndpoint&&(this.plugin.settings.micropubEndpoint=r.micropubEndpoint),r.mediaEndpoint&&(this.plugin.settings.mediaEndpoint=r.mediaEndpoint),await this.plugin.saveSettings(),!this.plugin.settings.mediaEndpoint)try{let a=await new b(()=>this.plugin.settings.micropubEndpoint,()=>this.plugin.settings.mediaEndpoint,()=>this.plugin.settings.accessToken).fetchConfig();a["media-endpoint"]&&(this.plugin.settings.mediaEndpoint=a["media-endpoint"],await this.plugin.saveSettings())}catch(o){}new h.Notice(`\u2705 Signed in as ${r.me}`),this.display()}catch(r){new h.Notice(`Sign-in failed: ${String(r)}`,8e3),i.setDisabled(!1),i.setButtonText("Sign in")}})});let n=e.createEl("details");n.createEl("summary",{text:"Or paste a token manually",cls:"setting-item-description"}),n.style.marginTop="8px",n.style.marginBottom="8px",new h.Setting(n).setName("Access token").setDesc("Bearer token from your Indiekit admin panel.").addText(i=>{i.setPlaceholder("your-bearer-token").setValue(this.plugin.settings.accessToken).onChange(async s=>{this.plugin.settings.accessToken=s.trim(),await this.plugin.saveSettings()}),i.inputEl.type="password"}).addButton(i=>i.setButtonText("Verify").onClick(async()=>{if(!this.plugin.settings.micropubEndpoint||!this.plugin.settings.accessToken){new h.Notice("Set the Micropub endpoint and token first.");return}i.setDisabled(!0);try{await new b(()=>this.plugin.settings.micropubEndpoint,()=>this.plugin.settings.mediaEndpoint,()=>this.plugin.settings.accessToken).fetchConfig(),new h.Notice("\u2705 Token is valid!")}catch(s){new h.Notice(`Token check failed: ${String(s)}`)}finally{i.setDisabled(!1)}}))}renderSignedIn(e){let n=this.plugin.settings.me,i=e.createDiv({cls:"micropub-auth-banner"});i.style.cssText="display:flex;align-items:center;gap:12px;padding:12px 16px;border:1px solid var(--background-modifier-border);border-radius:8px;margin-bottom:16px;background:var(--background-secondary);";let s=i.createDiv();s.style.cssText="width:40px;height:40px;border-radius:50%;background:var(--interactive-accent);display:flex;align-items:center;justify-content:center;font-size:1.2rem;flex-shrink:0;",s.textContent="\u{1F310}";let r=i.createDiv();r.createEl("div",{text:"Signed in",attr:{style:"font-size:.75rem;color:var(--text-muted);margin-bottom:2px"}}),r.createEl("div",{text:n,attr:{style:"font-weight:500;word-break:break-all"}}),new h.Setting(e).setName("Site URL").addText(o=>o.setValue(this.plugin.settings.siteUrl).setDisabled(!0)).addButton(o=>o.setButtonText("Sign out").setWarning().onClick(async()=>{this.plugin.settings.accessToken="",this.plugin.settings.me="",this.plugin.settings.authorizationEndpoint="",this.plugin.settings.tokenEndpoint="",await this.plugin.saveSettings(),this.display()}))}};var Et=require("obsidian");var _="garden/",N=class{constructor(t,e){this.app=t;this.settings=e;this.client=new b(()=>e.micropubEndpoint,()=>e.mediaEndpoint,()=>e.accessToken)}async publish(t,e){let n=await this.app.vault.read(t),{frontmatter:i,body:s}=this.parseFrontmatter(n),r=i["mp-url"]!=null?String(i["mp-url"]):i.url!=null?String(i.url):void 0,{content:o,uploadedUrls:a}=await this.processImages(s),c=this.resolveWikilinks(o,t.path),l=this.buildProperties(i,c,a,t.basename,t.path,e),d;if(r){let p={};for(let[u,f]of Object.entries(l))p[u]=Array.isArray(f)?f:[f];d=await this.client.updatePost(r,p)}else d=await this.client.createPost(l);return d.success&&this.settings.writeUrlToFrontmatter&&(d.url?await this.writeUrlToNote(t,n,d.url,e):e!==void 0&&await this.writeSyndicateToNote(t,n,e)),d}buildProperties(t,e,n,i,s,r){var H,q,Y,J,Q,X,K,Z,tt,et,it,nt,st,rt,ot,at,ct,lt,dt,pt,gt,ut,ht,mt,ft,yt;let o={},a=e.trim(),c=(H=t.bookmarkOf)!=null?H:t["bookmark-of"],l=(q=t.likeOf)!=null?q:t["like-of"],d=(Y=t.inReplyTo)!=null?Y:t["in-reply-to"],p=(J=t.repostOf)!=null?J:t["repost-of"];c&&(o["bookmark-of"]=[String(c)]),l&&(o["like-of"]=[String(l)]),d&&(o["in-reply-to"]=[String(d)]),p&&(o["repost-of"]=[String(p)]),(l||p)&&!a||(o.content=a?[{html:a}]:[{html:""}]);let f=(K=(X=(Q=t.postType)!=null?Q:t.posttype)!=null?X:t["post-type"])!=null?K:t.type;if(f==="article"||!f&&!!((Z=t.title)!=null?Z:t.name)){let m=(et=(tt=t.title)!=null?tt:t.name)!=null?et:i;o.name=[String(m)]}((it=t.summary)!=null?it:t.excerpt)&&(o.summary=[String((nt=t.summary)!=null?nt:t.excerpt)]);let w=(st=t.created)!=null?st:t.date;w&&(o.published=[new Date(String(w)).toISOString()]);let E=[...this.resolveArray(t.tags),...this.resolveArray(t.category)],$=this.extractGardenStage(E),x=E.filter(m=>!m.startsWith(_)&&m!=="garden");if(x.length>0&&(o.category=[...new Set(x)]),this.settings.mapGardenTags){let m=(rt=t.gardenStage)!=null?rt:$;if(m&&(o.gardenStage=[m],m==="evergreen")){let S=t["evergreen-since"];S&&(o.evergreenSince=[String(S)])}}let D=r!==void 0?r:[...new Set([...this.settings.defaultSyndicateTo,...this.resolveArray((ot=t["mp-syndicate-to"])!=null?ot:t.mpSyndicateTo)])];D.length>0&&(o["mp-syndicate-to"]=D);let P=(at=t.visibility)!=null?at:this.settings.defaultVisibility;P&&P!=="public"&&(o.visibility=[P]);let v=t.ai&&typeof t.ai=="object"?t.ai:{},M=(lt=(ct=t["ai-text-level"])!=null?ct:t.aiTextLevel)!=null?lt:v.textLevel,G=(pt=(dt=t["ai-code-level"])!=null?dt:t.aiCodeLevel)!=null?pt:v.codeLevel,I=(ht=(ut=(gt=t["ai-tools"])!=null?gt:t.aiTools)!=null?ut:v.aiTools)!=null?ht:v.tools,z=(yt=(ft=(mt=t["ai-description"])!=null?mt:t.aiDescription)!=null?ft:v.aiDescription)!=null?yt:v.description;M!=null&&(o["ai-text-level"]=[String(M)]),G!=null&&(o["ai-code-level"]=[String(G)]),I!=null&&(o["ai-tools"]=[String(I)]),z!=null&&(o["ai-description"]=[String(z)]);let W=this.resolvePhotoArray(t.photo);W.length>0&&(o.photo=W);let V=this.resolveArray(t.related);if(V.length>0){let m=V.map(S=>this.resolveWikilinkToUrl(S,s)).filter(S=>S!==null);m.length>0&&(o.related=m)}for(let[m,S]of Object.entries(t))m.startsWith("mp-")&&m!=="mp-url"&&m!=="mp-syndicate-to"&&(o[m]=this.resolveArray(S));return o}resolvePhotoArray(t){return t?(Array.isArray(t)?t:[t]).map(n=>{var i,s;if(typeof n=="string")return{value:n};if(typeof n=="object"&&n!==null){let r=n,o=String((s=(i=r.url)!=null?i:r.value)!=null?s:"");return o?r.alt?{value:o,alt:String(r.alt)}:{value:o}:null}return null}).filter(n=>n!==null):[]}extractGardenStage(t){for(let e of t){let n=e.replace(/^#/,"");if(n.startsWith(_)){let i=n.slice(_.length);if(["plant","cultivate","evergreen","question","repot","revitalize","revisit"].includes(i))return i}}}async processImages(t){let e=[],n=/!\[\[([^\]]+\.(png|jpg|jpeg|gif|webp|svg))\]\]/gi,i=/!\[([^\]]*)\]\(([^)]+\.(png|jpg|jpeg|gif|webp|svg))\)/gi,s=t,r=[...t.matchAll(n)];for(let a of r){let c=a[1];try{let l=await this.uploadLocalFile(c);l&&(e.push(l),s=s.replace(a[0],``))}catch(l){console.warn(`[micropub] Failed to upload ${c}:`,l)}}let o=[...s.matchAll(i)];for(let a of o){let c=a[1],l=a[2];if(!l.startsWith("http"))try{let d=await this.uploadLocalFile(l);d&&(e.push(d),s=s.replace(a[0],``))}catch(d){console.warn(`[micropub] Failed to upload ${l}:`,d)}}return{content:s,uploadedUrls:e}}async uploadLocalFile(t){let e=this.app.vault.getFiles().find(s=>s.name===t||s.path===t);if(!e)return;let n=await this.app.vault.readBinary(e),i=this.guessMimeType(e.extension);return this.client.uploadMedia(n,e.name,i)}parseFrontmatter(t){var i;let e=t.match(/^---\r?\n([\s\S]*?)\r?\n---\r?\n([\s\S]*)$/);if(!e)return{frontmatter:{},body:t};let n={};try{n=(i=(0,Et.parseYaml)(e[1]))!=null?i:{}}catch(s){}return{frontmatter:n,body:e[2]}}async writeUrlToNote(t,e,n,i){var d;let s=new Date,r=[s.getFullYear(),String(s.getMonth()+1).padStart(2,"0"),String(s.getDate()).padStart(2,"0")].join("-"),o=[["mp-url",`"${n}"`],["post-status","published"],["published",r]];if(i!==void 0&&o.push(["mp-syndicate-to",`[${i.join(", ")}]`]),this.settings.siteUrl)try{let p=new URL(this.settings.siteUrl).hostname.replace(/^www\./,"");o.push(["medium",`"[[${p}]]"`])}catch(p){}{let{frontmatter:p}=this.parseFrontmatter(e);if(!p["evergreen-since"]){let u=[...this.resolveArray(p.tags),...this.resolveArray(p.category)];((d=p.gardenStage)!=null?d:this.extractGardenStage(u))==="evergreen"&&o.push(["evergreen-since",r])}}let a=e.match(/^(---\r?\n[\s\S]*?\r?\n---\r?\n)([\s\S]*)$/);if(!a){let p=o.map(([u,f])=>`${u}: ${f}`).join(`
+`);await this.app.vault.modify(t,`---
+${p}
+---
+`+e);return}let c=a[1],l=a[2];for(let[p,u]of o)c=this.setFrontmatterField(c,p,u);await this.app.vault.modify(t,c+l)}async writeSyndicateToNote(t,e,n){let i=e.match(/^(---\r?\n[\s\S]*?\r?\n---\r?\n)([\s\S]*)$/),s=`[${n.join(", ")}]`;if(!i){await this.app.vault.modify(t,`---
+mp-syndicate-to: ${s}
+---
+`+e);return}let r=this.setFrontmatterField(i[1],"mp-syndicate-to",s);await this.app.vault.modify(t,r+i[2])}setFrontmatterField(t,e,n){let i=new RegExp(`^${e}:.*$`,"m");return i.test(t)?t.replace(i,`${e}: ${n}`):t.replace(/(\r?\n---\r?\n)$/,`
+${e}: ${n}$1`)}resolveWikilinks(t,e){return t.replace(/(?{let o=i.trim(),a=(r==null?void 0:r.trim())||o.split("/").pop()||o,c=this.resolveWikilinkToUrl(`[[${o}]]`,e);if(!c)return a;let l=s?s.toLowerCase().replace(/\s+/g,"-"):"";return`[${a}](${c}${l})`})}resolveWikilinkToUrl(t,e){var s,r,o;if(t.startsWith("http"))return t;let n=t.match(/^\[\[([^\]|#]+)(?:#[^\]|]*)?\|?[^\]]*\]\]$/);if(!n)return null;let i=this.app.metadataCache.getFirstLinkpathDest(n[1].trim(),e);return i&&(o=(r=(s=this.app.metadataCache.getFileCache(i))==null?void 0:s.frontmatter)==null?void 0:r["mp-url"])!=null?o:null}resolveArray(t){return t?Array.isArray(t)?t.map(String):[String(t)]:[]}guessMimeType(t){var n;return(n={png:"image/png",jpg:"image/jpeg",jpeg:"image/jpeg",gif:"image/gif",webp:"image/webp",svg:"image/svg+xml"}[t.toLowerCase()])!=null?n:"application/octet-stream"}};var C=require("obsidian"),B=class extends C.Modal{constructor(e,n,i){super(e);this.targets=n;this.resolvePromise=null;this.resolved=!1;this.selected=new Set(i.filter(s=>n.some(r=>r.uid===s)))}async awaitSelection(){return new Promise(e=>{this.resolvePromise=e,this.open()})}onOpen(){let{contentEl:e}=this;e.createEl("h2",{text:"Syndication targets"}),e.createEl("p",{text:"Choose where to cross-post this note.",cls:"setting-item-description"});for(let n of this.targets)new C.Setting(e).setName(n.name).addToggle(i=>i.setValue(this.selected.has(n.uid)).onChange(s=>{s?this.selected.add(n.uid):this.selected.delete(n.uid)}));new C.Setting(e).addButton(n=>n.setButtonText("Cancel").onClick(()=>{this.finish(null)})).addButton(n=>n.setButtonText("Publish").setCta().onClick(()=>{this.finish([...this.selected])}))}onClose(){this.finish(null),this.contentEl.empty()}finish(e){var n;this.resolved||(this.resolved=!0,(n=this.resolvePromise)==null||n.call(this,e),this.resolvePromise=null)}};var O=class extends y.Plugin{async onload(){await this.loadSettings(),this.addCommand({id:"publish-to-micropub",name:"Publish to Micropub",checkCallback:t=>{let e=this.app.workspace.getActiveFile();return!e||e.extension!=="md"?!1:(t||this.publishActiveNote(e),!0)}}),this.addCommand({id:"publish-to-micropub-update",name:"Update existing Micropub post",checkCallback:t=>{let e=this.app.workspace.getActiveFile();return!e||e.extension!=="md"?!1:(t||this.publishActiveNote(e),!0)}}),this.registerObsidianProtocolHandler("micropub-auth",t=>{Tt(t)}),this.addSettingTab(new L(this.app,this)),this.addRibbonIcon("send","Publish to Micropub",()=>{let t=this.app.workspace.getActiveFile();if(!t||t.extension!=="md"){new y.Notice("Open a Markdown note to publish.");return}this.publishActiveNote(t)})}onunload(){}async publishActiveNote(t){if(!this.settings.micropubEndpoint){new y.Notice("\u26A0\uFE0F Micropub endpoint not configured. Open plugin settings to add it.");return}if(!this.settings.accessToken){new y.Notice("\u26A0\uFE0F Access token not configured. Open plugin settings to add it.");return}let e=await this.resolveSyndicationTargets(t);if(e===null)return;let n=new y.Notice("Publishing\u2026",0);try{let s=await new N(this.app,this.settings).publish(t,e);if(n.hide(),s.success){let r=s.url?`
+${s.url}`:"";new y.Notice(`\u2705 Published!${r}`,8e3)}else new y.Notice(`\u274C Publish failed: ${s.error}`,1e4),console.error("[micropub] Publish failed:",s.error)}catch(i){n.hide();let s=i instanceof Error?i.message:String(i);new y.Notice(`\u274C Error: ${s}`,1e4),console.error("[micropub] Unexpected error:",i)}}async resolveSyndicationTargets(t){var a,c;let e=this.settings.showSyndicationDialog;if(e==="never")return;let n=[];try{n=(a=(await new b(()=>this.settings.micropubEndpoint,()=>this.settings.mediaEndpoint,()=>this.settings.accessToken).fetchConfig())["syndicate-to"])!=null?a:[]}catch(l){new y.Notice("\u26A0\uFE0F Could not fetch syndication targets. Publishing without dialog.",4e3);return}if(n.length===0)return;let i;try{let d=(await this.app.vault.read(t)).match(/^---\r?\n([\s\S]*?)\r?\n---\r?\n/);if(d){let u=((c=(0,y.parseYaml)(d[1]))!=null?c:{})["mp-syndicate-to"];u!==void 0&&(i=Array.isArray(u)?u.map(String):[String(u)])}}catch(l){}if(!(e==="always"||e==="when-needed"&&i===void 0||i!==void 0&&i.length===0))return;let r=i&&i.length>0?i:this.settings.defaultSyndicateTo;return new B(this.app,n,r).awaitSelection()}async loadSettings(){this.settings=Object.assign({},wt,await this.loadData())}async saveSettings(){await this.saveData(this.settings)}};
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/IndieAuth.ts b/src/IndieAuth.ts
new file mode 100644
index 0000000..877c0d8
--- /dev/null
+++ b/src/IndieAuth.ts
@@ -0,0 +1,231 @@
+/**
+ * IndieAuth.ts — IndieAuth PKCE sign-in flow for obsidian-micropub
+ *
+ * Why no local HTTP server:
+ * IndieKit (and most IndieAuth servers) fetch the client_id URL server-side
+ * to retrieve app metadata. A local 127.0.0.1 address is unreachable from a
+ * remote server, so that approach always fails with "fetch failed".
+ *
+ * The solution — GitHub Pages relay:
+ * client_id = https://svemagie.github.io/obsidian-micropub/
+ * redirect_uri = https://svemagie.github.io/obsidian-micropub/callback
+ *
+ * Both are on the same host → IndieKit's host-matching check passes ✓
+ * The callback page is a static HTML file that immediately redirects to
+ * obsidian://micropub-auth?code=CODE&state=STATE
+ * Obsidian's protocol handler (registered in main.ts) receives the code.
+ *
+ * Flow:
+ * 1. Discover authorization_endpoint + token_endpoint from site HTML
+ * 2. Generate PKCE code_verifier + code_challenge (SHA-256)
+ * 3. Open browser → user's IndieAuth login page
+ * 4. User logs in → server redirects to GitHub Pages callback
+ * 5. Callback page redirects to obsidian://micropub-auth?code=...
+ * 6. Plugin protocol handler resolves the pending Promise
+ * 7. Exchange code for token at token_endpoint
+ */
+
+import * as crypto from "crypto";
+import { requestUrl } from "obsidian";
+
+export const CLIENT_ID = "https://svemagie.github.io/obsidian-micropub/";
+export const REDIRECT_URI = "https://svemagie.github.io/obsidian-micropub/callback";
+
+const SCOPE = "create update media";
+const AUTH_TIMEOUT_MS = 5 * 60 * 1000; // 5 minutes
+
+export interface IndieAuthResult {
+ accessToken: string;
+ scope: string;
+ /** Canonical "me" URL returned by the token endpoint */
+ me: string;
+ authorizationEndpoint: string;
+ tokenEndpoint: string;
+ micropubEndpoint?: string;
+ mediaEndpoint?: string;
+}
+
+export interface DiscoveredEndpoints {
+ authorizationEndpoint: string;
+ tokenEndpoint: string;
+ micropubEndpoint?: string;
+}
+
+/** Pending callback set by main.ts protocol handler */
+let pendingCallback:
+ | { resolve: (params: Record) => void; state: string }
+ | null = null;
+
+/**
+ * Called by the Obsidian protocol handler in main.ts when
+ * obsidian://micropub-auth is opened by the browser.
+ */
+export function handleProtocolCallback(params: Record): void {
+ if (!pendingCallback) return;
+
+ const { resolve, state: expectedState } = pendingCallback;
+ pendingCallback = null;
+ resolve(params); // let signIn() validate state + extract code
+}
+
+export class IndieAuth {
+ // ── Public API ────────────────────────────────────────────────────────────
+
+ /**
+ * Discover IndieAuth + Micropub endpoint URLs from the site's home page
+ * by reading tags in the HTML .
+ */
+ static async discoverEndpoints(siteUrl: string): Promise {
+ const resp = await requestUrl({ url: siteUrl, method: "GET" });
+ const html = resp.text;
+
+ const authorizationEndpoint = IndieAuth.extractLinkRel(html, "authorization_endpoint");
+ const tokenEndpoint = IndieAuth.extractLinkRel(html, "token_endpoint");
+ const micropubEndpoint = IndieAuth.extractLinkRel(html, "micropub");
+
+ if (!authorizationEndpoint) {
+ throw new Error(
+ `No found at ${siteUrl}. ` +
+ "Make sure Indiekit is running and SITE_URL is set correctly.",
+ );
+ }
+ if (!tokenEndpoint) {
+ throw new Error(`No found at ${siteUrl}.`);
+ }
+
+ return { authorizationEndpoint, tokenEndpoint, micropubEndpoint };
+ }
+
+ /**
+ * Run the full IndieAuth PKCE sign-in flow.
+ *
+ * Opens the browser at the user's IndieAuth login page. After login the
+ * browser is redirected to the GitHub Pages callback, which triggers
+ * the obsidian://micropub-auth protocol, which resolves the Promise here.
+ *
+ * Requires handleProtocolCallback() to be wired up in main.ts via
+ * this.registerObsidianProtocolHandler("micropub-auth", handleProtocolCallback)
+ */
+ static async signIn(siteUrl: string): Promise {
+ // 1. Discover endpoints
+ const { authorizationEndpoint, tokenEndpoint, micropubEndpoint } =
+ await IndieAuth.discoverEndpoints(siteUrl);
+
+ // 2. Generate PKCE + state
+ const state = IndieAuth.base64url(crypto.randomBytes(16));
+ const codeVerifier = IndieAuth.base64url(crypto.randomBytes(64));
+ const codeChallenge = IndieAuth.base64url(
+ crypto.createHash("sha256").update(codeVerifier).digest(),
+ );
+
+ // 3. Register pending callback — resolved by handleProtocolCallback()
+ const callbackPromise = new Promise>(
+ (resolve, reject) => {
+ const timeout = setTimeout(() => {
+ pendingCallback = null;
+ reject(new Error("Sign-in timed out (5 min). Please try again."));
+ }, AUTH_TIMEOUT_MS);
+
+ pendingCallback = {
+ state,
+ resolve: (params) => {
+ clearTimeout(timeout);
+ resolve(params);
+ },
+ };
+ },
+ );
+
+ // 4. Build the authorization URL and open the browser
+ const authUrl = new URL(authorizationEndpoint);
+ authUrl.searchParams.set("response_type", "code");
+ authUrl.searchParams.set("client_id", CLIENT_ID);
+ authUrl.searchParams.set("redirect_uri", REDIRECT_URI);
+ authUrl.searchParams.set("state", state);
+ authUrl.searchParams.set("code_challenge", codeChallenge);
+ authUrl.searchParams.set("code_challenge_method","S256");
+ authUrl.searchParams.set("scope", SCOPE);
+ authUrl.searchParams.set("me", siteUrl);
+
+ window.open(authUrl.toString());
+
+ // 5. Wait for obsidian://micropub-auth to be called
+ const callbackParams = await callbackPromise;
+
+ // 6. Validate state (CSRF protection)
+ if (callbackParams.state !== state) {
+ throw new Error("State mismatch — possible CSRF attack. Please try again.");
+ }
+
+ const code = callbackParams.code;
+ if (!code) {
+ throw new Error(
+ callbackParams.error_description ??
+ callbackParams.error ??
+ "No authorization code received.",
+ );
+ }
+
+ // 7. Exchange code for token
+ const tokenResp = await requestUrl({
+ url: tokenEndpoint,
+ method: "POST",
+ headers: {
+ "Content-Type": "application/x-www-form-urlencoded",
+ Accept: "application/json",
+ },
+ body: new URLSearchParams({
+ grant_type: "authorization_code",
+ code,
+ client_id: CLIENT_ID,
+ redirect_uri: REDIRECT_URI,
+ code_verifier: codeVerifier,
+ }).toString(),
+ throw: false,
+ });
+
+ const data = tokenResp.json as {
+ access_token?: string;
+ scope?: string;
+ me?: string;
+ error?: string;
+ error_description?: string;
+ };
+
+ if (!data.access_token) {
+ throw new Error(
+ data.error_description ??
+ data.error ??
+ `Token exchange failed (HTTP ${tokenResp.status})`,
+ );
+ }
+
+ return {
+ accessToken: data.access_token,
+ scope: data.scope ?? SCOPE,
+ me: data.me ?? siteUrl,
+ authorizationEndpoint,
+ tokenEndpoint,
+ micropubEndpoint,
+ };
+ }
+
+ // ── Helpers ───────────────────────────────────────────────────────────────
+
+ private static base64url(buf: Buffer): string {
+ return buf.toString("base64")
+ .replace(/\+/g, "-")
+ .replace(/\//g, "_")
+ .replace(/=/g, "");
+ }
+
+ static extractLinkRel(html: string, rel: string): string | undefined {
+ const re = new RegExp(
+ `]+rel=["'][^"']*\\b${rel}\\b[^"']*["'][^>]+href=["']([^"']+)["']` +
+ `|]+href=["']([^"']+)["'][^>]+rel=["'][^"']*\\b${rel}\\b[^"']*["']`,
+ "i",
+ );
+ const m = html.match(re);
+ return m?.[1] ?? m?.[2];
+ }
+}
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..d70a7db
--- /dev/null
+++ b/src/Publisher.ts
@@ -0,0 +1,560 @@
+/**
+ * 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, syndicateToOverride?: string[]): 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"] != null ? String(frontmatter["mp-url"])
+ : frontmatter["url"] != null ? String(frontmatter["url"])
+ : undefined;
+
+ // Upload local images and rewrite markdown references
+ const { content: processedBody, uploadedUrls } =
+ await this.processImages(body);
+
+ // Resolve [[WikiLinks]] in body to blog URLs
+ const linkedBody = this.resolveWikilinks(processedBody, file.path);
+
+ // Build Micropub properties
+ const properties = this.buildProperties(frontmatter, linkedBody, uploadedUrls, file.basename, file.path, syndicateToOverride);
+
+ 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 (and syndication targets) back to frontmatter
+ if (result.success && this.settings.writeUrlToFrontmatter) {
+ if (result.url) {
+ await this.writeUrlToNote(file, raw, result.url, syndicateToOverride);
+ } else if (syndicateToOverride !== undefined) {
+ // No URL returned but we still want to record the syndication targets
+ await this.writeSyndicateToNote(file, raw, syndicateToOverride);
+ }
+ }
+
+ return result;
+ }
+
+ // ── Property builder ─────────────────────────────────────────────────────
+
+ private buildProperties(
+ fm: Record,
+ body: string,
+ uploadedUrls: string[],
+ basename: string,
+ filePath: string,
+ syndicateToOverride?: string[],
+ ): Record {
+ const props: Record = {};
+
+ // ── Post type detection ───────────────────────────────────────────────
+ // Interaction posts (bookmark, like, reply, repost) have no body content.
+ // For those, only include content if the note body is non-empty (i.e. a comment/quote).
+ const trimmedBody = body.trim();
+
+ // ── Interaction URL properties ────────────────────────────────────────
+ // Support both camelCase (Obsidian-friendly) and hyphenated (Micropub-spec).
+ const bookmarkOf = fm["bookmarkOf"] ?? fm["bookmark-of"];
+ const likeOf = fm["likeOf"] ?? fm["like-of"];
+ const inReplyTo = fm["inReplyTo"] ?? fm["in-reply-to"];
+ const repostOf = fm["repostOf"] ?? fm["repost-of"];
+
+ if (bookmarkOf) props["bookmark-of"] = [String(bookmarkOf)];
+ if (likeOf) props["like-of"] = [String(likeOf)];
+ if (inReplyTo) props["in-reply-to"] = [String(inReplyTo)];
+ if (repostOf) props["repost-of"] = [String(repostOf)];
+
+ // Content — omit for bare likes/reposts with no body text
+ const isInteractionWithoutBody =
+ (likeOf || repostOf) && !trimmedBody;
+ if (!isInteractionWithoutBody) {
+ props["content"] = trimmedBody ? [{ html: trimmedBody }] : [{ html: "" }];
+ }
+
+ // ── Standard properties ───────────────────────────────────────────────
+
+ // Post type — explicit `postType` field takes priority over auto-detection.
+ // Set this in your Obsidian template so the post type is declared up front:
+ // postType: article → always publishes as article (sets `name`)
+ // postType: note → always publishes as note (skips `name`)
+ // (absent) → auto-detect: has title → article, otherwise → note
+ const postType = fm["postType"] ?? fm["posttype"] ?? fm["post-type"] ?? fm["type"];
+ const isArticle =
+ postType === "article" ||
+ (!postType && Boolean(fm["title"] ?? fm["name"]));
+
+ if (isArticle) {
+ // Use explicit title/name, or fall back to the note filename (without extension)
+ const titleValue = fm["title"] ?? fm["name"] ?? basename;
+ props["name"] = [String(titleValue)];
+ }
+
+ // Summary / excerpt
+ if (fm["summary"] ?? fm["excerpt"]) {
+ props["summary"] = [String(fm["summary"] ?? fm["excerpt"])];
+ }
+
+ // Published date — prefer `created` (Obsidian default), fall back to `date`
+ const rawDate = fm["created"] ?? fm["date"];
+ if (rawDate) {
+ props["published"] = [new Date(String(rawDate)).toISOString()];
+ }
+
+ // Categories from frontmatter `category` AND `tags` (excluding garden/* tags).
+ // Merge both fields — `tags` may contain garden/* stages while `category`
+ // holds the actual topic categories sent to Micropub.
+ const rawTags = [
+ ...this.resolveArray(fm["tags"]),
+ ...this.resolveArray(fm["category"]),
+ ];
+ const gardenStageFromTags = this.extractGardenStage(rawTags);
+ const normalTags = rawTags.filter(
+ (t) => !t.startsWith(GARDEN_TAG_PREFIX) && t !== "garden",
+ );
+ if (normalTags.length > 0) {
+ props["category"] = [...new Set(normalTags)];
+ }
+
+ // Garden stage — prefer explicit `gardenStage` frontmatter property,
+ // fall back to extracting from #garden/* tags.
+ // Send as camelCase `gardenStage` so Indiekit writes it directly to
+ // frontmatter without needing a preset property mapping for `garden-stage`.
+ if (this.settings.mapGardenTags) {
+ const gardenStage =
+ (fm["gardenStage"] as string | undefined) ?? gardenStageFromTags;
+ if (gardenStage) {
+ props["gardenStage"] = [gardenStage];
+ // Pass through the evergreen date so Indiekit writes it to the blog post.
+ if (gardenStage === "evergreen") {
+ const evergreenSince = fm["evergreen-since"] as string | undefined;
+ if (evergreenSince) {
+ props["evergreenSince"] = [String(evergreenSince)];
+ }
+ }
+ }
+ }
+
+ // Syndication targets
+ // When the dialog was shown, syndicateToOverride contains the user's selection
+ // and takes precedence over frontmatter + settings defaults.
+ // Support both camelCase (mpSyndicateTo) used in existing blog posts and mp-syndicate-to.
+ const allSyndicateTo = syndicateToOverride !== undefined
+ ? syndicateToOverride
+ : [
+ ...new Set([
+ ...this.settings.defaultSyndicateTo,
+ ...this.resolveArray(fm["mp-syndicate-to"] ?? fm["mpSyndicateTo"]),
+ ]),
+ ];
+ 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];
+ }
+
+ // AI disclosure — kebab-case keys (ai-text-level, ai-tools, etc.)
+ // with camelCase fallback for backward compatibility.
+ // Also support nested `ai` object flattening.
+ const aiObj = (fm["ai"] && typeof fm["ai"] === "object")
+ ? fm["ai"] as Record
+ : {};
+ const aiTextLevel = fm["ai-text-level"] ?? fm["aiTextLevel"] ?? aiObj["textLevel"];
+ const aiCodeLevel = fm["ai-code-level"] ?? fm["aiCodeLevel"] ?? aiObj["codeLevel"];
+ const aiTools = fm["ai-tools"] ?? fm["aiTools"] ?? aiObj["aiTools"] ?? aiObj["tools"];
+ const aiDescription = fm["ai-description"] ?? fm["aiDescription"] ?? aiObj["aiDescription"] ?? aiObj["description"];
+ if (aiTextLevel != null) props["ai-text-level"] = [String(aiTextLevel)];
+ if (aiCodeLevel != null) props["ai-code-level"] = [String(aiCodeLevel)];
+ if (aiTools != null) props["ai-tools"] = [String(aiTools)];
+ if (aiDescription != null) props["ai-description"] = [String(aiDescription)];
+
+ // Photos: only use explicitly declared photo frontmatter (with alt text).
+ // Inline images uploaded from the body are already embedded in `content`
+ // and must NOT be added as `photo` — doing so would make Micropub treat
+ // the post as a photo post instead of an article/note.
+ const fmPhotos = this.resolvePhotoArray(fm["photo"]);
+ if (fmPhotos.length > 0) {
+ props["photo"] = fmPhotos;
+ }
+
+ // Related posts — resolve [[WikiLink]] wikilinks to published blog URLs
+ const relatedRaw = this.resolveArray(fm["related"]);
+ if (relatedRaw.length > 0) {
+ const relatedUrls = relatedRaw
+ .map((ref) => this.resolveWikilinkToUrl(ref, filePath))
+ .filter((url): url is string => url !== null);
+ if (relatedUrls.length > 0) {
+ props["related"] = relatedUrls;
+ }
+ }
+
+ // 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;
+ }
+
+ /**
+ * Normalise the `photo` frontmatter field into Micropub photo objects.
+ * Handles three formats:
+ * - string URL: "https://..."
+ * - array of strings: ["https://..."]
+ * - array of objects: [{url: "https://...", alt: "..."}]
+ */
+ private resolvePhotoArray(
+ value: unknown,
+ ): Array<{ value: string; alt?: string }> {
+ if (!value) return [];
+ const items = Array.isArray(value) ? value : [value];
+ return items
+ .map((item) => {
+ if (typeof item === "string") return { value: item };
+ if (typeof item === "object" && item !== null) {
+ const obj = item as Record;
+ const url = String(obj["url"] ?? obj["value"] ?? "");
+ if (!url) return null;
+ return obj["alt"]
+ ? { value: url, alt: String(obj["alt"]) }
+ : { value: url };
+ }
+ return null;
+ })
+ .filter((x): x is { value: string; alt?: string } => x !== null);
+ }
+
+ // ── 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", "evergreen", "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,
+ syndicateToOverride?: string[],
+ ): Promise {
+ // Build all fields to write back after a successful publish
+ const now = new Date();
+ const publishedDate = [
+ now.getFullYear(),
+ String(now.getMonth() + 1).padStart(2, "0"),
+ String(now.getDate()).padStart(2, "0"),
+ ].join("-");
+
+ const fields: Array<[string, string]> = [
+ ["mp-url", `"${url}"`],
+ ["post-status", "published"],
+ ["published", publishedDate],
+ ];
+
+ // Record the syndication targets used so future publishes know what was sent
+ if (syndicateToOverride !== undefined) {
+ fields.push(["mp-syndicate-to", `[${syndicateToOverride.join(", ")}]`]);
+ }
+
+ if (this.settings.siteUrl) {
+ try {
+ const hostname = new URL(this.settings.siteUrl).hostname.replace(/^www\./, "");
+ fields.push(["medium", `"[[${hostname}]]"`]);
+ } catch {
+ // ignore malformed siteUrl
+ }
+ }
+
+ // Stamp evergreen-since on first promotion to the evergreen garden stage.
+ {
+ const { frontmatter: fm } = this.parseFrontmatter(originalContent);
+ if (!fm["evergreen-since"]) {
+ const rawTags = [
+ ...this.resolveArray(fm["tags"]),
+ ...this.resolveArray(fm["category"]),
+ ];
+ const stage =
+ (fm["gardenStage"] as string | undefined) ??
+ this.extractGardenStage(rawTags);
+ if (stage === "evergreen") {
+ fields.push(["evergreen-since", publishedDate]);
+ }
+ }
+ }
+
+ const fmMatch = originalContent.match(
+ /^(---\r?\n[\s\S]*?\r?\n---\r?\n)([\s\S]*)$/,
+ );
+
+ if (!fmMatch) {
+ // No existing frontmatter — prepend all fields
+ const lines = fields.map(([k, v]) => `${k}: ${v}`).join("\n");
+ await this.app.vault.modify(file, `---\n${lines}\n---\n` + originalContent);
+ return;
+ }
+
+ let fmBlock = fmMatch[1];
+ const body = fmMatch[2];
+
+ for (const [key, value] of fields) {
+ fmBlock = this.setFrontmatterField(fmBlock, key, value);
+ }
+
+ await this.app.vault.modify(file, fmBlock + body);
+ }
+
+ /**
+ * Write mp-syndicate-to to frontmatter without touching other fields.
+ * Used when publish succeeds but returns no URL (e.g. update responses).
+ */
+ private async writeSyndicateToNote(
+ file: TFile,
+ originalContent: string,
+ syndicateTo: string[],
+ ): Promise {
+ const fmMatch = originalContent.match(
+ /^(---\r?\n[\s\S]*?\r?\n---\r?\n)([\s\S]*)$/,
+ );
+ const value = `[${syndicateTo.join(", ")}]`;
+
+ if (!fmMatch) {
+ await this.app.vault.modify(
+ file,
+ `---\nmp-syndicate-to: ${value}\n---\n` + originalContent,
+ );
+ return;
+ }
+
+ const fmBlock = this.setFrontmatterField(fmMatch[1], "mp-syndicate-to", value);
+ await this.app.vault.modify(file, fmBlock + fmMatch[2]);
+ }
+
+ /**
+ * Replace the value of an existing frontmatter field, or insert it before
+ * the closing `---` if the field is not yet present.
+ */
+ private setFrontmatterField(fmBlock: string, key: string, value: string): string {
+ const lineRegex = new RegExp(`^${key}:.*$`, "m");
+ if (lineRegex.test(fmBlock)) {
+ return fmBlock.replace(lineRegex, `${key}: ${value}`);
+ }
+ // Insert before closing ---
+ return fmBlock.replace(/(\r?\n---\r?\n)$/, `\n${key}: ${value}$1`);
+ }
+
+ // ── Wikilink resolution ──────────────────────────────────────────────────
+
+ /**
+ * Replace Obsidian [[WikiLinks]] in body text with Markdown hyperlinks.
+ * Uses mp-url from the linked note's frontmatter. Falls back to plain
+ * display text if the note is not found or not yet published.
+ * Image embeds (![[...]]) are left untouched via negative lookbehind.
+ */
+ private resolveWikilinks(body: string, sourcePath: string): string {
+ return body.replace(
+ /(? {
+ const cleanName = noteName.trim();
+ const displayText =
+ alias?.trim() || cleanName.split("/").pop() || cleanName;
+ const url = this.resolveWikilinkToUrl(`[[${cleanName}]]`, sourcePath);
+ if (!url) return displayText;
+ const anchorSuffix = anchor
+ ? anchor.toLowerCase().replace(/\s+/g, "-")
+ : "";
+ return `[${displayText}](${url}${anchorSuffix})`;
+ },
+ );
+ }
+
+ /**
+ * Resolve a single [[WikiLink]] or plain URL string to a published mp-url.
+ * Returns null if the note is not found or has no mp-url.
+ */
+ private resolveWikilinkToUrl(
+ ref: string,
+ sourcePath: string,
+ ): string | null {
+ if (ref.startsWith("http")) return ref;
+ const m = ref.match(/^\[\[([^\]|#]+)(?:#[^\]|]*)?\|?[^\]]*\]\]$/);
+ if (!m) return null;
+ const file = this.app.metadataCache.getFirstLinkpathDest(
+ m[1].trim(),
+ sourcePath,
+ );
+ if (!file) return null;
+ return (
+ (this.app.metadataCache.getFileCache(file)?.frontmatter?.[
+ "mp-url"
+ ] as string | undefined) ?? null
+ );
+ }
+
+ 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..e6c7dce
--- /dev/null
+++ b/src/SettingsTab.ts
@@ -0,0 +1,359 @@
+/**
+ * SettingsTab.ts — Obsidian settings UI for obsidian-micropub
+ *
+ * Authentication section works like iA Writer:
+ * 1. User enters their site URL
+ * 2. Clicks "Sign in" — browser opens at their IndieAuth login page
+ * 3. They log in with their blog password
+ * 4. Browser redirects back; plugin receives the token automatically
+ * 5. Settings show "Signed in as " + a Sign Out button
+ *
+ * Advanced users can still paste a token manually if they prefer.
+ */
+
+import { App, Notice, PluginSettingTab, Setting } from "obsidian";
+import type MicropubPlugin from "./main";
+import { MicropubClient } from "./MicropubClient";
+import { IndieAuth } from "./IndieAuth";
+
+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" });
+
+ // ── Site URL + Sign In ───────────────────────────────────────────────
+ containerEl.createEl("h3", { text: "Account" });
+
+ // Show current sign-in status
+ if (this.plugin.settings.me && this.plugin.settings.accessToken) {
+ this.renderSignedIn(containerEl);
+ } else {
+ this.renderSignedOut(containerEl);
+ }
+
+ // ── Endpoints (collapsed / advanced) ────────────────────────────────
+ containerEl.createEl("h3", { text: "Endpoints" });
+
+ containerEl.createEl("p", {
+ text: "These are filled automatically when you sign in. Only edit them manually if your server uses non-standard paths.",
+ cls: "setting-item-description",
+ });
+
+ new Setting(containerEl)
+ .setName("Micropub endpoint")
+ .setDesc("e.g. https://blog.giersig.eu/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("For image uploads. Auto-discovered if blank.")
+ .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();
+ }),
+ );
+
+ // ── 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 post URL as `mp-url` in frontmatter. " +
+ "Subsequent publishes will update the existing post instead of creating a new one.",
+ )
+ .addToggle((toggle) =>
+ toggle
+ .setValue(this.plugin.settings.writeUrlToFrontmatter)
+ .onChange(async (value) => {
+ this.plugin.settings.writeUrlToFrontmatter = value;
+ await this.plugin.saveSettings();
+ }),
+ );
+
+ new Setting(containerEl)
+ .setName("Syndication dialog")
+ .setDesc(
+ "When to show the cross-posting dialog before publishing. " +
+ "'When needed' shows it only if the note has no mp-syndicate-to frontmatter.",
+ )
+ .addDropdown((drop) =>
+ drop
+ .addOption("when-needed", "When needed")
+ .addOption("always", "Always")
+ .addOption("never", "Never")
+ .setValue(this.plugin.settings.showSyndicationDialog)
+ .onChange(async (value) => {
+ this.plugin.settings.showSyndicationDialog = value as
+ | "when-needed"
+ | "always"
+ | "never";
+ await this.plugin.saveSettings();
+ }),
+ );
+
+ // Show configured defaults with a clear button
+ const defaults = this.plugin.settings.defaultSyndicateTo;
+ const defaultsSetting = new Setting(containerEl)
+ .setName("Default syndication targets")
+ .setDesc(
+ defaults.length > 0
+ ? defaults.join(", ")
+ : "None configured. Targets checked by default in the publish dialog.",
+ );
+ if (defaults.length > 0) {
+ defaultsSetting.addButton((btn) =>
+ btn
+ .setButtonText("Clear defaults")
+ .setWarning()
+ .onClick(async () => {
+ this.plugin.settings.defaultSyndicateTo = [];
+ await this.plugin.saveSettings();
+ this.display();
+ }),
+ );
+ }
+
+ // ── Digital Garden ───────────────────────────────────────────────────
+ containerEl.createEl("h3", { text: "Digital Garden" });
+
+ new Setting(containerEl)
+ .setName("Map #garden/* tags to gardenStage")
+ .setDesc(
+ "Obsidian tags like #garden/plant become a `garden-stage: plant` Micropub " +
+ "property. The blog renders these as growth stage badges at /garden/.",
+ )
+ .addToggle((toggle) =>
+ toggle
+ .setValue(this.plugin.settings.mapGardenTags)
+ .onChange(async (value) => {
+ this.plugin.settings.mapGardenTags = value;
+ await this.plugin.saveSettings();
+ }),
+ );
+
+ containerEl.createEl("p", {
+ text: "Stages: plant 🌱 · cultivate 🌿 · evergreen 🌳 · question ❓ · repot 🪴 · revitalize ✨ · revisit 🔄",
+ cls: "setting-item-description",
+ });
+ }
+
+ // ── Signed-out state ─────────────────────────────────────────────────────
+
+ private renderSignedOut(containerEl: HTMLElement): void {
+ // Site URL input + Sign In button on the same row
+ new Setting(containerEl)
+ .setName("Site URL")
+ .setDesc(
+ "Your site's home page. Clicking Sign in opens your blog's login page " +
+ "in the browser — the same flow iA Writer uses.",
+ )
+ .addText((text) =>
+ text
+ .setPlaceholder("https://blog.giersig.eu")
+ .setValue(this.plugin.settings.siteUrl)
+ .onChange(async (value) => {
+ this.plugin.settings.siteUrl = value.trim();
+ await this.plugin.saveSettings();
+ }),
+ )
+ .addButton((btn) => {
+ btn
+ .setButtonText("Sign in")
+ .setCta()
+ .onClick(async () => {
+ const siteUrl = this.plugin.settings.siteUrl.trim();
+ if (!siteUrl) {
+ new Notice("Enter your site URL first.");
+ return;
+ }
+
+ btn.setDisabled(true);
+ btn.setButtonText("Opening browser…");
+
+ try {
+ const result = await IndieAuth.signIn(siteUrl);
+
+ // Save everything returned by the auth flow
+ this.plugin.settings.accessToken = result.accessToken;
+ this.plugin.settings.me = result.me;
+ this.plugin.settings.authorizationEndpoint = result.authorizationEndpoint;
+ this.plugin.settings.tokenEndpoint = result.tokenEndpoint;
+ if (result.micropubEndpoint) {
+ this.plugin.settings.micropubEndpoint = result.micropubEndpoint;
+ }
+ if (result.mediaEndpoint) {
+ this.plugin.settings.mediaEndpoint = result.mediaEndpoint;
+ }
+
+ await this.plugin.saveSettings();
+
+ // Try to fetch the Micropub config to pick up media endpoint
+ if (!this.plugin.settings.mediaEndpoint) {
+ try {
+ const client = new MicropubClient(
+ () => this.plugin.settings.micropubEndpoint,
+ () => this.plugin.settings.mediaEndpoint,
+ () => this.plugin.settings.accessToken,
+ );
+ const cfg = await client.fetchConfig();
+ if (cfg["media-endpoint"]) {
+ this.plugin.settings.mediaEndpoint = cfg["media-endpoint"];
+ await this.plugin.saveSettings();
+ }
+ } catch {
+ // Non-fatal
+ }
+ }
+
+ new Notice(`✅ Signed in as ${result.me}`);
+ this.display(); // Refresh to show signed-in state
+ } catch (err: unknown) {
+ new Notice(`Sign-in failed: ${String(err)}`, 8000);
+ btn.setDisabled(false);
+ btn.setButtonText("Sign in");
+ }
+ });
+ });
+
+ // Divider + manual token fallback (collapsed by default)
+ const details = containerEl.createEl("details");
+ details.createEl("summary", {
+ text: "Or paste a token manually",
+ cls: "setting-item-description",
+ });
+ details.style.marginTop = "8px";
+ details.style.marginBottom = "8px";
+
+ new Setting(details)
+ .setName("Access token")
+ .setDesc("Bearer token from your Indiekit 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 the Micropub 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(`Token check failed: ${String(err)}`);
+ } finally {
+ btn.setDisabled(false);
+ }
+ }),
+ );
+ }
+
+ // ── Signed-in state ──────────────────────────────────────────────────────
+
+ private renderSignedIn(containerEl: HTMLElement): void {
+ const me = this.plugin.settings.me;
+
+ // Avatar + "Signed in as" banner
+ const banner = containerEl.createDiv({
+ cls: "micropub-auth-banner",
+ });
+ banner.style.cssText =
+ "display:flex;align-items:center;gap:12px;padding:12px 16px;" +
+ "border:1px solid var(--background-modifier-border);" +
+ "border-radius:8px;margin-bottom:16px;background:var(--background-secondary);";
+
+ const icon = banner.createDiv();
+ icon.style.cssText =
+ "width:40px;height:40px;border-radius:50%;background:var(--interactive-accent);" +
+ "display:flex;align-items:center;justify-content:center;" +
+ "font-size:1.2rem;flex-shrink:0;";
+ icon.textContent = "🌐";
+
+ const info = banner.createDiv();
+ info.createEl("div", {
+ text: "Signed in",
+ attr: { style: "font-size:.75rem;color:var(--text-muted);margin-bottom:2px" },
+ });
+ info.createEl("div", {
+ text: me,
+ attr: { style: "font-weight:500;word-break:break-all" },
+ });
+
+ new Setting(containerEl)
+ .setName("Site URL")
+ .addText((text) =>
+ text
+ .setValue(this.plugin.settings.siteUrl)
+ .setDisabled(true),
+ )
+ .addButton((btn) =>
+ btn
+ .setButtonText("Sign out")
+ .setWarning()
+ .onClick(async () => {
+ this.plugin.settings.accessToken = "";
+ this.plugin.settings.me = "";
+ this.plugin.settings.authorizationEndpoint = "";
+ this.plugin.settings.tokenEndpoint = "";
+ await this.plugin.saveSettings();
+ this.display();
+ }),
+ );
+ }
+}
diff --git a/src/SyndicationDialog.ts b/src/SyndicationDialog.ts
new file mode 100644
index 0000000..5111fbb
--- /dev/null
+++ b/src/SyndicationDialog.ts
@@ -0,0 +1,89 @@
+/**
+ * SyndicationDialog.ts
+ *
+ * Modal that lets the user choose which syndication targets to cross-post to.
+ * Opens as a promise — resolves with the selected UIDs, or null if cancelled.
+ */
+
+import { App, Modal, Setting } from "obsidian";
+import type { SyndicationTarget } from "./types";
+
+export class SyndicationDialog extends Modal {
+ private selected: Set;
+ private resolvePromise: ((value: string[] | null) => void) | null = null;
+ private resolved = false;
+
+ constructor(
+ app: App,
+ private readonly targets: SyndicationTarget[],
+ defaultSelected: string[],
+ ) {
+ super(app);
+ this.selected = new Set(defaultSelected.filter((uid) =>
+ targets.some((t) => t.uid === uid),
+ ));
+ }
+
+ /**
+ * Opens the dialog and waits for user selection.
+ * @returns Selected target UIDs, or null if cancelled.
+ */
+ async awaitSelection(): Promise {
+ return new Promise((resolve) => {
+ this.resolvePromise = resolve;
+ this.open();
+ });
+ }
+
+ onOpen(): void {
+ const { contentEl } = this;
+ contentEl.createEl("h2", { text: "Syndication targets" });
+ contentEl.createEl("p", {
+ text: "Choose where to cross-post this note.",
+ cls: "setting-item-description",
+ });
+
+ for (const target of this.targets) {
+ new Setting(contentEl)
+ .setName(target.name)
+ .addToggle((toggle) =>
+ toggle
+ .setValue(this.selected.has(target.uid))
+ .onChange((value) => {
+ if (value) this.selected.add(target.uid);
+ else this.selected.delete(target.uid);
+ }),
+ );
+ }
+
+ new Setting(contentEl)
+ .addButton((btn) =>
+ btn
+ .setButtonText("Cancel")
+ .onClick(() => {
+ this.finish(null);
+ }),
+ )
+ .addButton((btn) =>
+ btn
+ .setButtonText("Publish")
+ .setCta()
+ .onClick(() => {
+ this.finish([...this.selected]);
+ }),
+ );
+ }
+
+ onClose(): void {
+ // Resolve as cancelled if user pressed Escape or clicked outside
+ this.finish(null);
+ this.contentEl.empty();
+ }
+
+ private finish(value: string[] | null): void {
+ if (this.resolved) return;
+ this.resolved = true;
+ this.resolvePromise?.(value);
+ this.resolvePromise = null;
+ }
+}
diff --git a/src/main.ts b/src/main.ts
new file mode 100644
index 0000000..a338424
--- /dev/null
+++ b/src/main.ts
@@ -0,0 +1,227 @@
+/**
+ * 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, parseYaml } from "obsidian";
+import { DEFAULT_SETTINGS, type MicropubSettings } from "./types";
+import { MicropubSettingsTab } from "./SettingsTab";
+import { Publisher } from "./Publisher";
+import { MicropubClient } from "./MicropubClient";
+import { SyndicationDialog } from "./SyndicationDialog";
+import { handleProtocolCallback } from "./IndieAuth";
+
+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;
+ },
+ });
+
+ // ── IndieAuth protocol handler ────────────────────────────────────────
+ // Receives obsidian://micropub-auth?code=...&state=... after the user
+ // approves on their IndieAuth login page. The GitHub Pages callback page
+ // at svemagie.github.io/obsidian-micropub/callback redirects here.
+ this.registerObsidianProtocolHandler("micropub-auth", (params) => {
+ handleProtocolCallback(params as Record);
+ });
+
+ // ── 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;
+ }
+
+ // ── Syndication dialog ────────────────────────────────────────────────
+ // Determine which syndication targets to use, optionally showing a dialog.
+ const syndicateToOverride = await this.resolveSyndicationTargets(file);
+ if (syndicateToOverride === null) {
+ // User cancelled the dialog — abort publish
+ 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, syndicateToOverride);
+
+ 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);
+ }
+ }
+
+ /**
+ * Decide whether to show the syndication dialog and return the selected targets.
+ *
+ * Returns:
+ * string[] — targets to use as override (may be empty)
+ * undefined — no override; Publisher will use frontmatter + settings defaults
+ * null — user cancelled; abort publish
+ */
+ private async resolveSyndicationTargets(
+ file: TFile,
+ ): Promise {
+ const dialogSetting = this.settings.showSyndicationDialog;
+
+ // "never" — skip dialog entirely, let Publisher handle targets from frontmatter + settings
+ if (dialogSetting === "never") return undefined;
+
+ // Fetch available targets from the server
+ let availableTargets: import("./types").SyndicationTarget[] = [];
+ try {
+ const client = new MicropubClient(
+ () => this.settings.micropubEndpoint,
+ () => this.settings.mediaEndpoint,
+ () => this.settings.accessToken,
+ );
+ const config = await client.fetchConfig();
+ availableTargets = config["syndicate-to"] ?? [];
+ } catch {
+ // Config fetch failed — fall back to normal publish without dialog
+ new Notice(
+ "⚠️ Could not fetch syndication targets. Publishing without dialog.",
+ 4000,
+ );
+ return undefined;
+ }
+
+ // No targets on this server — skip dialog (backward compatible)
+ if (availableTargets.length === 0) return undefined;
+
+ // Read mp-syndicate-to from frontmatter
+ let fmSyndicateTo: string[] | undefined;
+ try {
+ const raw = await this.app.vault.read(file);
+ const fmMatch = raw.match(/^---\r?\n([\s\S]*?)\r?\n---\r?\n/);
+ if (fmMatch) {
+ const fm = (parseYaml(fmMatch[1]) ?? {}) as Record;
+ const val = fm["mp-syndicate-to"];
+ if (val !== undefined) {
+ fmSyndicateTo = Array.isArray(val) ? val.map(String) : [String(val)];
+ }
+ }
+ } catch {
+ // Malformed frontmatter — treat as absent
+ }
+
+ // Decide whether to show dialog
+ const showDialog =
+ dialogSetting === "always" ||
+ (dialogSetting === "when-needed" && fmSyndicateTo === undefined) ||
+ (fmSyndicateTo !== undefined && fmSyndicateTo.length === 0);
+
+ if (!showDialog) {
+ // Frontmatter has values and setting is "when-needed" — skip dialog
+ return undefined;
+ }
+
+ // Pre-check: use frontmatter values if non-empty, otherwise plugin defaults
+ const defaultSelected =
+ fmSyndicateTo && fmSyndicateTo.length > 0
+ ? fmSyndicateTo
+ : this.settings.defaultSyndicateTo;
+
+ const dialog = new SyndicationDialog(this.app, availableTargets, defaultSelected);
+ return dialog.awaitSelection();
+ }
+
+ // ── 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..f768da8
--- /dev/null
+++ b/src/types.ts
@@ -0,0 +1,139 @@
+/**
+ * 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 and IndieAuth. */
+ siteUrl: string;
+
+ /**
+ * The authorization_endpoint discovered from the site.
+ * Populated automatically by the IndieAuth sign-in flow.
+ */
+ authorizationEndpoint: string;
+
+ /**
+ * The token_endpoint discovered from the site.
+ * Populated automatically by the IndieAuth sign-in flow.
+ */
+ tokenEndpoint: string;
+
+ /**
+ * The canonical "me" URL returned by the token endpoint after sign-in.
+ * Used to show who is currently logged in.
+ */
+ me: 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";
+
+ /**
+ * Controls when the syndication target dialog is shown before publishing.
+ * "when-needed" — Show only if mp-syndicate-to is absent from frontmatter
+ * "always" — Show every time, even if frontmatter has targets
+ * "never" — Never show dialog; use defaultSyndicateTo + frontmatter
+ */
+ showSyndicationDialog: "when-needed" | "always" | "never";
+}
+
+export const DEFAULT_SETTINGS: MicropubSettings = {
+ micropubEndpoint: "",
+ mediaEndpoint: "",
+ accessToken: "",
+ defaultSyndicateTo: [],
+ autoDiscover: false,
+ siteUrl: "",
+ authorizationEndpoint: "",
+ tokenEndpoint: "",
+ me: "",
+ writeUrlToFrontmatter: true,
+ mapGardenTags: true,
+ defaultVisibility: "public",
+ showSyndicationDialog: "when-needed",
+};
+
+/** 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"
+ | "evergreen"
+ | "question"
+ | "repot"
+ | "revitalize"
+ | "revisit";
+
+export const GARDEN_STAGE_LABELS: Record = {
+ plant: "🌱 Seedling",
+ cultivate: "🌿 Growing",
+ evergreen: "🌳 Evergreen",
+ 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"]
+}