This commit is contained in:
svemagie
2026-03-31 14:23:33 +02:00
commit f5af7ff1e2
18 changed files with 2528 additions and 0 deletions

View File

@@ -0,0 +1,12 @@
{
"permissions": {
"allow": [
"Bash(npm run:*)",
"Bash(echo \"Exit: $?\")",
"Bash(git add:*)",
"Bash(git commit:*)",
"Bash(npm install:*)",
"Bash(npm test:*)"
]
}
}

4
.gitignore vendored Normal file
View File

@@ -0,0 +1,4 @@
node_modules
data.json
package-lock.json
.worktrees

234
README.md Normal file
View File

@@ -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 `<link rel="micropub">` 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 `![alt](path)`) 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/<stage>` 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)

74
docs/callback/index.html Normal file
View File

@@ -0,0 +1,74 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>Returning to Obsidian…</title>
<style>
body { font-family: system-ui, sans-serif; display: flex; align-items: center;
justify-content: center; min-height: 100vh; margin: 0; background: #f9fafb; }
.card { text-align: center; padding: 2rem; border-radius: 12px; background: #fff;
box-shadow: 0 2px 16px rgba(0,0,0,.08); max-width: 360px; }
.icon { font-size: 3rem; margin-bottom: .5rem; }
h1 { font-size: 1.3rem; margin: .5rem 0; }
p { color: #6b7280; margin: .5rem 0; font-size: .9rem; }
.error { color: #dc2626; }
</style>
</head>
<body>
<div class="card" id="card">
<div class="icon"></div>
<h1>Returning to Obsidian…</h1>
<p>Opening the Micropub Publisher plugin.</p>
</div>
<script>
(function () {
const params = new URLSearchParams(window.location.search);
const code = params.get("code");
const state = params.get("state");
const error = params.get("error");
const errorDesc = params.get("error_description");
const card = document.getElementById("card");
if (error) {
card.innerHTML =
'<div class="icon">❌</div>' +
'<h1 class="error">Authorization failed</h1>' +
'<p>' + (errorDesc || error) + '</p>' +
'<p>You can close this tab and try again in Obsidian.</p>';
return;
}
if (!code || !state) {
card.innerHTML =
'<div class="icon">❌</div>' +
'<h1 class="error">Missing parameters</h1>' +
'<p>No authorization code was received.</p>';
return;
}
// Redirect to the Obsidian protocol handler.
// obsidian://micropub-auth is registered by the plugin via
// this.registerObsidianProtocolHandler("micropub-auth", handler)
const obsidianUrl =
"obsidian://micropub-auth?code=" +
encodeURIComponent(code) +
"&state=" +
encodeURIComponent(state);
window.location.href = obsidianUrl;
// Fallback message if Obsidian doesn't open
setTimeout(function () {
card.innerHTML =
'<div class="icon">✅</div>' +
'<h1>Authorized!</h1>' +
'<p>If Obsidian did not open automatically, click below.</p>' +
'<p><a href="' + obsidianUrl + '" style="color:#6c3fc7">Open in Obsidian →</a></p>' +
'<p>You can close this tab afterwards.</p>';
}, 2000);
})();
</script>
</body>
</html>

30
docs/index.html Normal file
View File

@@ -0,0 +1,30 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Micropub Publisher for Obsidian</title>
<style>
body { font-family: system-ui, sans-serif; max-width: 600px; margin: 60px auto; padding: 0 20px; color: #1a1a1a; }
h1 { font-size: 1.5rem; }
a { color: #6c3fc7; }
code { background: #f3f0ff; padding: 2px 6px; border-radius: 4px; font-size: .9em; }
</style>
</head>
<body>
<h1>Micropub Publisher for Obsidian</h1>
<p>
An <a href="https://obsidian.md">Obsidian</a> plugin to publish notes to any
<a href="https://micropub.spec.indieweb.org/">Micropub</a>-compatible endpoint —
<a href="https://getindiekit.com">Indiekit</a>, Micro.blog, or any IndieWeb server.
</p>
<p>
<a href="https://github.com/svemagie/obsidian-micropub">View on GitHub →</a>
</p>
<hr>
<p style="font-size:.85rem;color:#666">
This page serves as the OAuth <code>client_id</code> for the IndieAuth sign-in flow.
After authorizing, you will be redirected back to Obsidian automatically.
</p>
</body>
</html>

View File

@@ -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<string[] | null>;
}
```
### 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

55
esbuild.config.mjs Normal file
View File

@@ -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();
}

21
main.js Normal file

File diff suppressed because one or more lines are too long

10
manifest.json Normal file
View File

@@ -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
}

22
package.json Normal file
View File

@@ -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"
}
}

231
src/IndieAuth.ts Normal file
View File

@@ -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<string, string>) => 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<string, string>): 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 <link rel="..."> tags in the HTML <head>.
*/
static async discoverEndpoints(siteUrl: string): Promise<DiscoveredEndpoints> {
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 <link rel="authorization_endpoint"> found at ${siteUrl}. ` +
"Make sure Indiekit is running and SITE_URL is set correctly.",
);
}
if (!tokenEndpoint) {
throw new Error(`No <link rel="token_endpoint"> 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<IndieAuthResult> {
// 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<Record<string, string>>(
(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(
`<link[^>]+rel=["'][^"']*\\b${rel}\\b[^"']*["'][^>]+href=["']([^"']+)["']` +
`|<link[^>]+href=["']([^"']+)["'][^>]+rel=["'][^"']*\\b${rel}\\b[^"']*["']`,
"i",
);
const m = html.match(re);
return m?.[1] ?? m?.[2];
}
}

227
src/MicropubClient.ts Normal file
View File

@@ -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<MicropubConfig> {
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 <link rel="micropub"> and <link rel="token_endpoint"> 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<string, unknown>): Promise<PublishResult> {
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<string, unknown[]>,
): Promise<PublishResult> {
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<string> {
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<string, string> {
return { Authorization: `Bearer ${this.getToken()}` };
}
private extractLinkRel(html: string, rel: string): string | undefined {
// Match both <link> and HTTP Link headers embedded in HTML
const re = new RegExp(
`<link[^>]+rel=["']${rel}["'][^>]+href=["']([^"']+)["']|<link[^>]+href=["']([^"']+)["'][^>]+rel=["']${rel}["']`,
"i",
);
const m = html.match(re);
return m?.[1] ?? m?.[2];
}
private async fetchConfigFrom(endpoint: string): Promise<MicropubConfig> {
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);
}
}
}

560
src/Publisher.ts Normal file
View File

@@ -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<PublishResult> {
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<string, unknown[]> = {};
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<string, unknown>,
body: string,
uploadedUrls: string[],
basename: string,
filePath: string,
syndicateToOverride?: string[],
): Record<string, unknown> {
const props: Record<string, unknown> = {};
// ── 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<string, unknown>
: {};
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<string, unknown>;
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/<stage> 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 `![alt](relative/path.jpg)` in the body,
* upload them to the media endpoint, and replace the references with remote URLs.
*/
private async processImages(
body: string,
): Promise<{ content: string; uploadedUrls: string[] }> {
const uploadedUrls: string[] = [];
// Match wiki-style embeds: ![[filename.ext]]
const wikiPattern = /!\[\[([^\]]+\.(png|jpg|jpeg|gif|webp|svg))\]\]/gi;
// Match markdown images: ![alt](path)
const mdPattern = /!\[([^\]]*)\]\(([^)]+\.(png|jpg|jpeg|gif|webp|svg))\)/gi;
let content = body;
// Process wiki-style embeds
const wikiMatches = [...body.matchAll(wikiPattern)];
for (const match of wikiMatches) {
const filename = match[1];
try {
const remoteUrl = await this.uploadLocalFile(filename);
if (remoteUrl) {
uploadedUrls.push(remoteUrl);
content = content.replace(match[0], `![${filename}](${remoteUrl})`);
}
} catch (err) {
console.warn(`[micropub] Failed to upload ${filename}:`, err);
}
}
// Process markdown image references
const mdMatches = [...content.matchAll(mdPattern)];
for (const match of mdMatches) {
const alt = match[1];
const path = match[2];
if (path.startsWith("http")) continue; // already remote
try {
const remoteUrl = await this.uploadLocalFile(path);
if (remoteUrl) {
uploadedUrls.push(remoteUrl);
content = content.replace(match[0], `![${alt}](${remoteUrl})`);
}
} catch (err) {
console.warn(`[micropub] Failed to upload ${path}:`, err);
}
}
return { content, uploadedUrls };
}
private async uploadLocalFile(path: string): Promise<string | undefined> {
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<string, unknown>;
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<string, unknown> = {};
try {
frontmatter = (parseYaml(fmMatch[1]) ?? {}) as Record<string, unknown>;
} catch {
// Malformed frontmatter — treat as empty
}
return { frontmatter, body: fmMatch[2] };
}
private async writeUrlToNote(
file: TFile,
originalContent: string,
url: string,
syndicateToOverride?: string[],
): Promise<void> {
// 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<void> {
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(
/(?<!!)\[\[([^\]|#]+)(#[^\]|]*)?\|?([^\]]*)\]\]/g,
(_match, noteName: string, anchor: string | undefined, alias: string) => {
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<string, string> = {
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";
}
}

359
src/SettingsTab.ts Normal file
View File

@@ -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 <me>" + 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();
}),
);
}
}

89
src/SyndicationDialog.ts Normal file
View File

@@ -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<string>;
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<string[] | null> {
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;
}
}

227
src/main.ts Normal file
View File

@@ -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 <link rel> 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<void> {
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<string, string>);
});
// ── 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<void> {
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<string[] | undefined | null> {
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<string, unknown>;
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<void> {
this.settings = Object.assign(
{},
DEFAULT_SETTINGS,
await this.loadData(),
) as MicropubSettings;
}
async saveSettings(): Promise<void> {
await this.saveData(this.settings);
}
}

139
src/types.ts Normal file
View File

@@ -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 <token>.
* 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 <link rel="micropub"> 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<GardenStage, string> = {
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;
}

17
tsconfig.json Normal file
View File

@@ -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"]
}