mirror of
https://github.com/svemagie/obsidian-micropub.git
synced 2026-05-14 19:38:50 +02:00
feat: initial scaffold — Micropub publisher for Obsidian
This commit is contained in:
@@ -0,0 +1,147 @@
|
|||||||
|
# obsidian-micropub
|
||||||
|
|
||||||
|
An Obsidian plugin to publish notes to **any Micropub-compatible endpoint** — Indiekit, Micro.blog, or any server implementing the [W3C Micropub spec](https://www.w3.org/TR/micropub/).
|
||||||
|
|
||||||
|
Forked and generalised from [svemagie/obsidian-microblog](https://github.com/svemagie/obsidian-microblog) (MIT).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Features
|
||||||
|
|
||||||
|
- **Any Micropub endpoint** — not locked to Micro.blog; works with Indiekit and other servers
|
||||||
|
- **Auto-discovery** — reads `<link rel="micropub">` from your site to find the endpoint automatically
|
||||||
|
- **Digital Garden stage mapping** — Obsidian tags `#garden/plant`, `#garden/cultivate`, etc. become a `gardenStage` property on the published post, matching the Eleventy blog's garden system
|
||||||
|
- **Create + Update** — if the note has a `mp-url` frontmatter key, Publish will update the existing post instead of creating a new one
|
||||||
|
- **Image upload** — local images (wiki-embeds and markdown) are uploaded to the media endpoint and URLs rewritten
|
||||||
|
- **URL write-back** — the returned post URL is saved to `mp-url` in the note's frontmatter after publishing
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Installation
|
||||||
|
|
||||||
|
### From source (development)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /path/to/your/obsidian/vault/.obsidian/plugins
|
||||||
|
git clone https://github.com/yourname/obsidian-micropub
|
||||||
|
cd obsidian-micropub
|
||||||
|
npm install
|
||||||
|
npm run dev
|
||||||
|
```
|
||||||
|
|
||||||
|
Then enable the plugin in Obsidian → Settings → Community plugins.
|
||||||
|
|
||||||
|
### Manual
|
||||||
|
|
||||||
|
1. Download the latest release (main.js + manifest.json)
|
||||||
|
2. Create a folder `.obsidian/plugins/obsidian-micropub/` in your vault
|
||||||
|
3. Copy both files there
|
||||||
|
4. Enable in Obsidian → Settings → Community plugins
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Configuration
|
||||||
|
|
||||||
|
Open **Settings → Micropub Publisher**.
|
||||||
|
|
||||||
|
| Setting | Description |
|
||||||
|
|---|---|
|
||||||
|
| **Site URL** | Your site's home page — used for endpoint auto-discovery |
|
||||||
|
| **Micropub endpoint** | e.g. `https://example.com/micropub` |
|
||||||
|
| **Media endpoint** | For image uploads; auto-discovered if blank |
|
||||||
|
| **Access token** | Bearer token from your IndieAuth token endpoint |
|
||||||
|
| **Default visibility** | `public` / `unlisted` / `private` |
|
||||||
|
| **Write URL to note** | Save the published post URL as `mp-url` in frontmatter |
|
||||||
|
| **Map #garden/* tags** | Convert `#garden/plant` → `garden-stage: plant` property |
|
||||||
|
|
||||||
|
### Getting a token from Indiekit
|
||||||
|
|
||||||
|
1. Log into your Indiekit admin panel
|
||||||
|
2. Go to **Tokens** → Create new token with `create update` scope
|
||||||
|
3. Paste the token into the plugin settings
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Digital Garden workflow
|
||||||
|
|
||||||
|
Tag any note in Obsidian with a `#garden/*` tag:
|
||||||
|
|
||||||
|
| Obsidian tag | Published property | Blog display |
|
||||||
|
|---|---|---|
|
||||||
|
| `#garden/plant` | `gardenStage: plant` | 🌱 Seedling |
|
||||||
|
| `#garden/cultivate` | `gardenStage: cultivate` | 🌿 Growing |
|
||||||
|
| `#garden/question` | `gardenStage: question` | ❓ Open Question |
|
||||||
|
| `#garden/repot` | `gardenStage: repot` | 🪴 Repotting |
|
||||||
|
| `#garden/revitalize` | `gardenStage: revitalize` | ✨ Revitalizing |
|
||||||
|
| `#garden/revisit` | `gardenStage: revisit` | 🔄 Revisit |
|
||||||
|
|
||||||
|
The Eleventy blog renders a coloured badge on each post and groups all garden posts at `/garden/`.
|
||||||
|
|
||||||
|
### Example note
|
||||||
|
|
||||||
|
```markdown
|
||||||
|
---
|
||||||
|
title: "On building in public"
|
||||||
|
tags:
|
||||||
|
- garden/cultivate
|
||||||
|
- indieweb
|
||||||
|
---
|
||||||
|
|
||||||
|
Some early thoughts on the merits of building in public...
|
||||||
|
```
|
||||||
|
|
||||||
|
After publishing, the frontmatter gains:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
mp-url: "https://example.com/articles/2026/on-building-in-public"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Frontmatter properties recognised
|
||||||
|
|
||||||
|
| Property | Effect |
|
||||||
|
|---|---|
|
||||||
|
| `title` | Sets the post `name` (article mode) |
|
||||||
|
| `date` | Sets `published` (ISO 8601) |
|
||||||
|
| `tags` / `category` | Becomes Micropub `category` (excluding `garden/*` tags) |
|
||||||
|
| `visibility` | `public` / `unlisted` / `private` |
|
||||||
|
| `mp-url` | Triggers an **update** rather than create |
|
||||||
|
| `mp-syndicate-to` | Pre-fills syndication target list |
|
||||||
|
| `mp-*` | Any other `mp-*` keys passed through verbatim |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Development
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run dev # watch mode with inline sourcemaps
|
||||||
|
npm run build # production bundle (minified)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Architecture
|
||||||
|
|
||||||
|
```
|
||||||
|
src/
|
||||||
|
main.ts Plugin entry point, command/ribbon registration
|
||||||
|
types.ts Shared interfaces and constants
|
||||||
|
MicropubClient.ts Low-level HTTP (create, update, upload, discover)
|
||||||
|
Publisher.ts Orchestrates publish flow (parse → upload → send → write-back)
|
||||||
|
SettingsTab.ts Obsidian settings UI
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Roadmap
|
||||||
|
|
||||||
|
- [ ] Publish dialog with syndication target checkboxes
|
||||||
|
- [ ] Scheduled publishing (`mp-published-at`)
|
||||||
|
- [ ] Pull categories from Micropub `?q=category` for autocomplete
|
||||||
|
- [ ] Multi-endpoint support (publish to multiple blogs)
|
||||||
|
- [ ] Post type selector (note / article / bookmark / reply)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## License
|
||||||
|
|
||||||
|
MIT — see [LICENSE](LICENSE)
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,303 @@
|
|||||||
|
/**
|
||||||
|
* Publisher.ts
|
||||||
|
*
|
||||||
|
* Orchestrates a full publish flow:
|
||||||
|
* 1. Parse the active note's frontmatter + body
|
||||||
|
* 2. Upload any local images to the media endpoint
|
||||||
|
* 3. Build the Micropub properties object
|
||||||
|
* 4. POST to the Micropub endpoint
|
||||||
|
* 5. Optionally write the returned URL back to frontmatter
|
||||||
|
*
|
||||||
|
* Garden tag mapping:
|
||||||
|
* Obsidian tags #garden/plant → gardenStage: "plant" in properties
|
||||||
|
* The blog reads this as `gardenStage` frontmatter, so the Indiekit
|
||||||
|
* Micropub server must be configured to pass through unknown properties.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { App, TFile, parseFrontMatterAliases, parseYaml, stringifyYaml } from "obsidian";
|
||||||
|
import type { MicropubSettings, GardenStage, PublishResult } from "./types";
|
||||||
|
import { MicropubClient } from "./MicropubClient";
|
||||||
|
|
||||||
|
const GARDEN_TAG_PREFIX = "garden/";
|
||||||
|
|
||||||
|
export class Publisher {
|
||||||
|
private client: MicropubClient;
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private readonly app: App,
|
||||||
|
private readonly settings: MicropubSettings,
|
||||||
|
) {
|
||||||
|
this.client = new MicropubClient(
|
||||||
|
() => settings.micropubEndpoint,
|
||||||
|
() => settings.mediaEndpoint,
|
||||||
|
() => settings.accessToken,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Publish the given file. Returns PublishResult. */
|
||||||
|
async publish(file: TFile): Promise<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"] ?? frontmatter["url"] ?? undefined;
|
||||||
|
|
||||||
|
// Upload local images and rewrite markdown references
|
||||||
|
const { content: processedBody, uploadedUrls } =
|
||||||
|
await this.processImages(body);
|
||||||
|
|
||||||
|
// Build Micropub properties
|
||||||
|
const properties = this.buildProperties(frontmatter, processedBody, uploadedUrls);
|
||||||
|
|
||||||
|
let result: PublishResult;
|
||||||
|
|
||||||
|
if (existingUrl) {
|
||||||
|
// Update existing post
|
||||||
|
const replace: Record<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 back to frontmatter
|
||||||
|
if (result.success && result.url && this.settings.writeUrlToFrontmatter) {
|
||||||
|
await this.writeUrlToNote(file, raw, result.url);
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Property builder ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
private buildProperties(
|
||||||
|
fm: Record<string, unknown>,
|
||||||
|
body: string,
|
||||||
|
uploadedUrls: string[],
|
||||||
|
): Record<string, unknown> {
|
||||||
|
const props: Record<string, unknown> = {};
|
||||||
|
|
||||||
|
// Required: content
|
||||||
|
props["content"] = [{ html: body }];
|
||||||
|
|
||||||
|
// Title (articles have titles; notes/micro-posts don't)
|
||||||
|
if (fm["title"]) {
|
||||||
|
props["name"] = [String(fm["title"])];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Published date
|
||||||
|
if (fm["date"]) {
|
||||||
|
props["published"] = [new Date(String(fm["date"])).toISOString()];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Categories from frontmatter `category` or `tags` (excluding garden/* tags)
|
||||||
|
const rawTags = this.resolveArray(fm["tags"] ?? fm["category"]);
|
||||||
|
const gardenStage = this.extractGardenStage(rawTags);
|
||||||
|
const normalTags = rawTags.filter(
|
||||||
|
(t) => !t.startsWith(GARDEN_TAG_PREFIX) && t !== "garden",
|
||||||
|
);
|
||||||
|
if (normalTags.length > 0) {
|
||||||
|
props["category"] = normalTags;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Garden stage → dedicated property
|
||||||
|
if (this.settings.mapGardenTags && gardenStage) {
|
||||||
|
// Indiekit stores this as gardenStage in front matter;
|
||||||
|
// Micropub JSON uses hyphenated keys
|
||||||
|
props["garden-stage"] = [gardenStage];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Syndication targets
|
||||||
|
const syndicateTo = this.resolveArray(fm["mp-syndicate-to"]);
|
||||||
|
const allSyndicateTo = [
|
||||||
|
...new Set([...this.settings.defaultSyndicateTo, ...syndicateTo]),
|
||||||
|
];
|
||||||
|
if (allSyndicateTo.length > 0) {
|
||||||
|
props["mp-syndicate-to"] = allSyndicateTo;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Visibility
|
||||||
|
const visibility =
|
||||||
|
(fm["visibility"] as string) ?? this.settings.defaultVisibility;
|
||||||
|
if (visibility && visibility !== "public") {
|
||||||
|
props["visibility"] = [visibility];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Uploaded images (from local → remote URL conversion)
|
||||||
|
if (uploadedUrls.length > 0) {
|
||||||
|
props["photo"] = uploadedUrls.map((url) => ({ value: url }));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pass through any `mp-*` properties from frontmatter verbatim
|
||||||
|
for (const [k, v] of Object.entries(fm)) {
|
||||||
|
if (k.startsWith("mp-") && k !== "mp-url" && k !== "mp-syndicate-to") {
|
||||||
|
props[k] = this.resolveArray(v);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return props;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Garden tag extraction ────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Find the first #garden/<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", "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<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,
|
||||||
|
): Promise<void> {
|
||||||
|
const fmMatch = originalContent.match(
|
||||||
|
/^(---\r?\n[\s\S]*?\r?\n---\r?\n)([\s\S]*)$/,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!fmMatch) {
|
||||||
|
// No existing frontmatter — prepend it
|
||||||
|
const newFm = `---\nmp-url: "${url}"\n---\n`;
|
||||||
|
await this.app.vault.modify(file, newFm + originalContent);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Inject mp-url into existing frontmatter block
|
||||||
|
const fmBlock = fmMatch[1];
|
||||||
|
const body = fmMatch[2];
|
||||||
|
|
||||||
|
if (fmBlock.includes("mp-url:")) {
|
||||||
|
// Replace existing mp-url line
|
||||||
|
const updated = fmBlock.replace(
|
||||||
|
/mp-url:.*(\r?\n)/,
|
||||||
|
`mp-url: "${url}"$1`,
|
||||||
|
);
|
||||||
|
await this.app.vault.modify(file, updated + body);
|
||||||
|
} else {
|
||||||
|
// Insert mp-url before closing ---
|
||||||
|
const updated = fmBlock.replace(
|
||||||
|
/(\r?\n---\r?\n)$/,
|
||||||
|
`\nmp-url: "${url}"$1`,
|
||||||
|
);
|
||||||
|
await this.app.vault.modify(file, updated + body);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private resolveArray(value: unknown): string[] {
|
||||||
|
if (!value) return [];
|
||||||
|
if (Array.isArray(value)) return value.map(String);
|
||||||
|
return [String(value)];
|
||||||
|
}
|
||||||
|
|
||||||
|
private guessMimeType(ext: string): string {
|
||||||
|
const map: Record<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";
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,214 @@
|
|||||||
|
/**
|
||||||
|
* SettingsTab.ts — Obsidian settings UI for obsidian-micropub
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { App, Notice, PluginSettingTab, Setting } from "obsidian";
|
||||||
|
import type MicropubPlugin from "./main";
|
||||||
|
import { MicropubClient } from "./MicropubClient";
|
||||||
|
|
||||||
|
export class MicropubSettingsTab extends PluginSettingTab {
|
||||||
|
constructor(
|
||||||
|
app: App,
|
||||||
|
private readonly plugin: MicropubPlugin,
|
||||||
|
) {
|
||||||
|
super(app, plugin);
|
||||||
|
}
|
||||||
|
|
||||||
|
display(): void {
|
||||||
|
const { containerEl } = this;
|
||||||
|
containerEl.empty();
|
||||||
|
|
||||||
|
containerEl.createEl("h2", { text: "Micropub Publisher" });
|
||||||
|
|
||||||
|
// ── Endpoint discovery ───────────────────────────────────────────────
|
||||||
|
containerEl.createEl("h3", { text: "Endpoint Configuration" });
|
||||||
|
|
||||||
|
new Setting(containerEl)
|
||||||
|
.setName("Site URL")
|
||||||
|
.setDesc(
|
||||||
|
"Your site's home page. Used to auto-discover Micropub and token endpoints " +
|
||||||
|
"from <link rel=\"micropub\"> headers.",
|
||||||
|
)
|
||||||
|
.addText((text) =>
|
||||||
|
text
|
||||||
|
.setPlaceholder("https://example.com")
|
||||||
|
.setValue(this.plugin.settings.siteUrl)
|
||||||
|
.onChange(async (value) => {
|
||||||
|
this.plugin.settings.siteUrl = value.trim();
|
||||||
|
await this.plugin.saveSettings();
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.addButton((btn) =>
|
||||||
|
btn
|
||||||
|
.setButtonText("Discover")
|
||||||
|
.setCta()
|
||||||
|
.onClick(async () => {
|
||||||
|
if (!this.plugin.settings.siteUrl) {
|
||||||
|
new Notice("Enter a site URL first.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
btn.setDisabled(true);
|
||||||
|
btn.setButtonText("Discovering…");
|
||||||
|
try {
|
||||||
|
const client = new MicropubClient(
|
||||||
|
() => this.plugin.settings.micropubEndpoint,
|
||||||
|
() => this.plugin.settings.mediaEndpoint,
|
||||||
|
() => this.plugin.settings.accessToken,
|
||||||
|
);
|
||||||
|
const discovered = await client.discoverEndpoints(
|
||||||
|
this.plugin.settings.siteUrl,
|
||||||
|
);
|
||||||
|
if (discovered.micropubEndpoint) {
|
||||||
|
this.plugin.settings.micropubEndpoint =
|
||||||
|
discovered.micropubEndpoint;
|
||||||
|
}
|
||||||
|
if (discovered.mediaEndpoint) {
|
||||||
|
this.plugin.settings.mediaEndpoint = discovered.mediaEndpoint;
|
||||||
|
}
|
||||||
|
await this.plugin.saveSettings();
|
||||||
|
this.display(); // Refresh UI
|
||||||
|
new Notice("✅ Endpoints discovered!");
|
||||||
|
} catch (err: unknown) {
|
||||||
|
new Notice(`Discovery failed: ${String(err)}`);
|
||||||
|
} finally {
|
||||||
|
btn.setDisabled(false);
|
||||||
|
btn.setButtonText("Discover");
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
new Setting(containerEl)
|
||||||
|
.setName("Micropub endpoint")
|
||||||
|
.setDesc("e.g. https://example.com/micropub")
|
||||||
|
.addText((text) =>
|
||||||
|
text
|
||||||
|
.setPlaceholder("https://example.com/micropub")
|
||||||
|
.setValue(this.plugin.settings.micropubEndpoint)
|
||||||
|
.onChange(async (value) => {
|
||||||
|
this.plugin.settings.micropubEndpoint = value.trim();
|
||||||
|
await this.plugin.saveSettings();
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
new Setting(containerEl)
|
||||||
|
.setName("Media endpoint")
|
||||||
|
.setDesc(
|
||||||
|
"Leave blank to discover automatically from the Micropub config response.",
|
||||||
|
)
|
||||||
|
.addText((text) =>
|
||||||
|
text
|
||||||
|
.setPlaceholder("https://example.com/micropub/media")
|
||||||
|
.setValue(this.plugin.settings.mediaEndpoint)
|
||||||
|
.onChange(async (value) => {
|
||||||
|
this.plugin.settings.mediaEndpoint = value.trim();
|
||||||
|
await this.plugin.saveSettings();
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
// ── Authentication ───────────────────────────────────────────────────
|
||||||
|
containerEl.createEl("h3", { text: "Authentication" });
|
||||||
|
|
||||||
|
new Setting(containerEl)
|
||||||
|
.setName("Access token")
|
||||||
|
.setDesc(
|
||||||
|
"Bearer token from your site's IndieAuth token endpoint or admin panel.",
|
||||||
|
)
|
||||||
|
.addText((text) => {
|
||||||
|
text
|
||||||
|
.setPlaceholder("your-bearer-token")
|
||||||
|
.setValue(this.plugin.settings.accessToken)
|
||||||
|
.onChange(async (value) => {
|
||||||
|
this.plugin.settings.accessToken = value.trim();
|
||||||
|
await this.plugin.saveSettings();
|
||||||
|
});
|
||||||
|
text.inputEl.type = "password";
|
||||||
|
})
|
||||||
|
.addButton((btn) =>
|
||||||
|
btn
|
||||||
|
.setButtonText("Verify")
|
||||||
|
.onClick(async () => {
|
||||||
|
if (
|
||||||
|
!this.plugin.settings.micropubEndpoint ||
|
||||||
|
!this.plugin.settings.accessToken
|
||||||
|
) {
|
||||||
|
new Notice("Set endpoint and token first.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
btn.setDisabled(true);
|
||||||
|
try {
|
||||||
|
const client = new MicropubClient(
|
||||||
|
() => this.plugin.settings.micropubEndpoint,
|
||||||
|
() => this.plugin.settings.mediaEndpoint,
|
||||||
|
() => this.plugin.settings.accessToken,
|
||||||
|
);
|
||||||
|
await client.fetchConfig();
|
||||||
|
new Notice("✅ Token is valid!");
|
||||||
|
} catch (err: unknown) {
|
||||||
|
new Notice(`Auth check failed: ${String(err)}`);
|
||||||
|
} finally {
|
||||||
|
btn.setDisabled(false);
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
// ── Publish behaviour ────────────────────────────────────────────────
|
||||||
|
containerEl.createEl("h3", { text: "Publish Behaviour" });
|
||||||
|
|
||||||
|
new Setting(containerEl)
|
||||||
|
.setName("Default visibility")
|
||||||
|
.setDesc("Applies when the note has no explicit visibility property.")
|
||||||
|
.addDropdown((drop) =>
|
||||||
|
drop
|
||||||
|
.addOption("public", "Public")
|
||||||
|
.addOption("unlisted", "Unlisted")
|
||||||
|
.addOption("private", "Private")
|
||||||
|
.setValue(this.plugin.settings.defaultVisibility)
|
||||||
|
.onChange(async (value) => {
|
||||||
|
this.plugin.settings.defaultVisibility = value as
|
||||||
|
| "public"
|
||||||
|
| "unlisted"
|
||||||
|
| "private";
|
||||||
|
await this.plugin.saveSettings();
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
new Setting(containerEl)
|
||||||
|
.setName("Write URL back to note")
|
||||||
|
.setDesc(
|
||||||
|
"After publishing, store the returned post URL as `mp-url` in the note's " +
|
||||||
|
"frontmatter. Subsequent publishes will use this URL to update the post.",
|
||||||
|
)
|
||||||
|
.addToggle((toggle) =>
|
||||||
|
toggle
|
||||||
|
.setValue(this.plugin.settings.writeUrlToFrontmatter)
|
||||||
|
.onChange(async (value) => {
|
||||||
|
this.plugin.settings.writeUrlToFrontmatter = value;
|
||||||
|
await this.plugin.saveSettings();
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
// ── Digital Garden ───────────────────────────────────────────────────
|
||||||
|
containerEl.createEl("h3", { text: "Digital Garden" });
|
||||||
|
|
||||||
|
new Setting(containerEl)
|
||||||
|
.setName("Map #garden/* tags to gardenStage")
|
||||||
|
.setDesc(
|
||||||
|
"When enabled, Obsidian tags like #garden/plant are converted to a " +
|
||||||
|
"`garden-stage: plant` Micropub property. The Eleventy blog theme renders " +
|
||||||
|
"these as growth stage badges and groups posts in the /garden/ index.",
|
||||||
|
)
|
||||||
|
.addToggle((toggle) =>
|
||||||
|
toggle
|
||||||
|
.setValue(this.plugin.settings.mapGardenTags)
|
||||||
|
.onChange(async (value) => {
|
||||||
|
this.plugin.settings.mapGardenTags = value;
|
||||||
|
await this.plugin.saveSettings();
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
containerEl.createEl("p", {
|
||||||
|
text: "Supported stages: plant 🌱 · cultivate 🌿 · question ❓ · repot 🪴 · revitalize ✨ · revisit 🔄",
|
||||||
|
cls: "setting-item-description",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
+133
@@ -0,0 +1,133 @@
|
|||||||
|
/**
|
||||||
|
* main.ts — obsidian-micropub plugin entry point
|
||||||
|
*
|
||||||
|
* Publishes the active note to any Micropub-compatible endpoint.
|
||||||
|
* Designed to work with Indiekit (https://getindiekit.com) but compatible
|
||||||
|
* with any server that implements the Micropub spec (W3C).
|
||||||
|
*
|
||||||
|
* Key features vs. the original obsidian-microblog:
|
||||||
|
* - Configurable endpoint URL (not hardcoded to Micro.blog)
|
||||||
|
* - Auto-discovery of micropub/media endpoints from <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 } from "obsidian";
|
||||||
|
import { DEFAULT_SETTINGS, type MicropubSettings } from "./types";
|
||||||
|
import { MicropubSettingsTab } from "./SettingsTab";
|
||||||
|
import { Publisher } from "./Publisher";
|
||||||
|
|
||||||
|
export default class MicropubPlugin extends Plugin {
|
||||||
|
settings!: MicropubSettings;
|
||||||
|
|
||||||
|
async onload(): Promise<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;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── 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;
|
||||||
|
}
|
||||||
|
|
||||||
|
const notice = new Notice("Publishing…", 0 /* persist until dismissed */);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const publisher = new Publisher(this.app, this.settings);
|
||||||
|
const result = await publisher.publish(file);
|
||||||
|
|
||||||
|
notice.hide();
|
||||||
|
|
||||||
|
if (result.success) {
|
||||||
|
const urlDisplay = result.url
|
||||||
|
? `\n${result.url}`
|
||||||
|
: "";
|
||||||
|
new Notice(`✅ Published!${urlDisplay}`, 8000);
|
||||||
|
} else {
|
||||||
|
new Notice(`❌ Publish failed: ${result.error}`, 10000);
|
||||||
|
console.error("[micropub] Publish failed:", result.error);
|
||||||
|
}
|
||||||
|
} catch (err: unknown) {
|
||||||
|
notice.hide();
|
||||||
|
const msg = err instanceof Error ? err.message : String(err);
|
||||||
|
new Notice(`❌ Error: ${msg}`, 10000);
|
||||||
|
console.error("[micropub] Unexpected error:", err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Settings persistence ──────────────────────────────────────────────────
|
||||||
|
|
||||||
|
async loadSettings(): Promise<void> {
|
||||||
|
this.settings = Object.assign(
|
||||||
|
{},
|
||||||
|
DEFAULT_SETTINGS,
|
||||||
|
await this.loadData(),
|
||||||
|
) as MicropubSettings;
|
||||||
|
}
|
||||||
|
|
||||||
|
async saveSettings(): Promise<void> {
|
||||||
|
await this.saveData(this.settings);
|
||||||
|
}
|
||||||
|
}
|
||||||
+107
@@ -0,0 +1,107 @@
|
|||||||
|
/**
|
||||||
|
* types.ts — shared interfaces for obsidian-micropub
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** Plugin settings stored in data.json */
|
||||||
|
export interface MicropubSettings {
|
||||||
|
/** Full URL of the Micropub endpoint, e.g. https://example.com/micropub */
|
||||||
|
micropubEndpoint: string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Full URL of the media endpoint for image uploads.
|
||||||
|
* If empty, discovered automatically from the Micropub config query,
|
||||||
|
* or derived from the micropubEndpoint (some servers use /micropub/media).
|
||||||
|
*/
|
||||||
|
mediaEndpoint: string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Bearer token for Authorization: Bearer <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. */
|
||||||
|
siteUrl: string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* When true, after a successful publish the post URL returned by the server
|
||||||
|
* is written back to the note's frontmatter as `mp-url`.
|
||||||
|
*/
|
||||||
|
writeUrlToFrontmatter: boolean;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Map Obsidian #garden/* tags to a `gardenStage` Micropub property.
|
||||||
|
* When enabled, a tag like #garden/plant becomes { "garden-stage": "plant" }
|
||||||
|
* in the Micropub request (and gardenStage: plant in the server's front matter).
|
||||||
|
*/
|
||||||
|
mapGardenTags: boolean;
|
||||||
|
|
||||||
|
/** Visibility default for new posts: "public" | "unlisted" | "private" */
|
||||||
|
defaultVisibility: "public" | "unlisted" | "private";
|
||||||
|
}
|
||||||
|
|
||||||
|
export const DEFAULT_SETTINGS: MicropubSettings = {
|
||||||
|
micropubEndpoint: "",
|
||||||
|
mediaEndpoint: "",
|
||||||
|
accessToken: "",
|
||||||
|
defaultSyndicateTo: [],
|
||||||
|
autoDiscover: false,
|
||||||
|
siteUrl: "",
|
||||||
|
writeUrlToFrontmatter: true,
|
||||||
|
mapGardenTags: true,
|
||||||
|
defaultVisibility: "public",
|
||||||
|
};
|
||||||
|
|
||||||
|
/** A syndication target as returned by Micropub config query */
|
||||||
|
export interface SyndicationTarget {
|
||||||
|
uid: string;
|
||||||
|
name: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Micropub config response (?q=config) */
|
||||||
|
export interface MicropubConfig {
|
||||||
|
"media-endpoint"?: string;
|
||||||
|
"syndicate-to"?: SyndicationTarget[];
|
||||||
|
"post-types"?: Array<{ type: string; name: string }>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Garden stages — matches Obsidian #garden/* tags and blog gardenStage values.
|
||||||
|
* The Micropub property name is "garden-stage" (hyphenated, Micropub convention).
|
||||||
|
*/
|
||||||
|
export type GardenStage =
|
||||||
|
| "plant"
|
||||||
|
| "cultivate"
|
||||||
|
| "question"
|
||||||
|
| "repot"
|
||||||
|
| "revitalize"
|
||||||
|
| "revisit";
|
||||||
|
|
||||||
|
export const GARDEN_STAGE_LABELS: Record<GardenStage, string> = {
|
||||||
|
plant: "🌱 Seedling",
|
||||||
|
cultivate: "🌿 Growing",
|
||||||
|
question: "❓ Open Question",
|
||||||
|
repot: "🪴 Repotting",
|
||||||
|
revitalize: "✨ Revitalizing",
|
||||||
|
revisit: "🔄 Revisit",
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Result returned by Publisher.publish() */
|
||||||
|
export interface PublishResult {
|
||||||
|
success: boolean;
|
||||||
|
/** URL of the published post (from Location response header) */
|
||||||
|
url?: string;
|
||||||
|
error?: string;
|
||||||
|
}
|
||||||
@@ -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"]
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user