fresh
This commit is contained in:
12
.claude/settings.local.json
Normal file
12
.claude/settings.local.json
Normal 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
4
.gitignore
vendored
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
node_modules
|
||||||
|
data.json
|
||||||
|
package-lock.json
|
||||||
|
.worktrees
|
||||||
234
README.md
Normal file
234
README.md
Normal 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 ``) 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
74
docs/callback/index.html
Normal 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
30
docs/index.html
Normal 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>
|
||||||
217
docs/superpowers/specs/2026-03-30-syndication-dialog-design.md
Normal file
217
docs/superpowers/specs/2026-03-30-syndication-dialog-design.md
Normal 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
55
esbuild.config.mjs
Normal 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();
|
||||||
|
}
|
||||||
10
manifest.json
Normal file
10
manifest.json
Normal 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
22
package.json
Normal 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
231
src/IndieAuth.ts
Normal 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
227
src/MicropubClient.ts
Normal 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
560
src/Publisher.ts
Normal 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 `` 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,
|
||||||
|
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
359
src/SettingsTab.ts
Normal 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
89
src/SyndicationDialog.ts
Normal 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
227
src/main.ts
Normal 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
139
src/types.ts
Normal 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
17
tsconfig.json
Normal 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"]
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user