bits4723 downloadsPublish selected notes as a static blog: toggle which notes are public from the file explorer, push to GitHub in one click, auto-sync on save, and track build status.
Publish your Obsidian notes as a static blog on GitHub Pages.
You don't need a second vault. Private and public notes live together — going public is just public: true in the frontmatter. Flip the switch next to a note in Obsidian's file explorer, push once, and GitHub Actions builds and deploys the site.

public: true are read and committed; private notes never enter the buildIn Obsidian, open Settings → Community plugins → Browse and search for Note Blog Publisher.
Manual install: download main.js, manifest.json, and styles.css from Releases and put them in your vault's plugin directory. The directory name must be exactly note-blog-publisher:
<your vault>/.obsidian/plugins/note-blog-publisher/
├── main.js
├── manifest.json
└── styles.css
Then enable "Note Blog Publisher" in Obsidian settings → Community plugins (turn off Safe mode first).
| Needed | Why |
|---|---|
| Git (download) | Publishing and fetching go through it; the plugin does not bundle git |
| Node.js | The background core runs in a child process. It prefers Obsidian's bundled utilityProcess; if that's unavailable it falls back to the system node. If neither works you get "plugin installed, but clicking does nothing" |
| A GitHub account | The site is hosted on GitHub Pages |
If clicking does nothing after enabling, first check that git --version and node -v both print something in a terminal.
Download the same three files again and overwrite them (restart Obsidian to apply).
Click "Settings" in the top right. The local vault defaults to the one you have open. Give the blog a name, choose "Create a repo for me", enter a repo name, and click "Create on GitHub".
If you already have a repo, switch to "I already have a repo" and paste the URL. You don't need to enable Pages manually — it's turned on automatically after the first successful publish, and an existing Pages site is never recreated.
The blog name doubles as the display name in the publisher, the page title, and the site name in the top-left corner.
Click "GitHub access" and follow the dialog to create a Personal access token (classic) with these two scopes:
| Scope | Purpose |
|---|---|
repo |
Push content, create repositories |
workflow |
The first deploy pushes .github/workflows/deploy.yml; GitHub requires this scope for any push that touches a workflow file |
repoalone is not enough — the push is rejected withrefusing to allow a Personal Access Token to create or update workflow.
Set the expiration to "No expiration". An expired token makes publishing, repo creation, and automatic Pages setup all fail together, and the error message doesn't tell you it expired.
How the token is stored: encrypted with the OS keychain (Windows DPAPI / macOS Keychain). The ciphertext sits in local.json and only your own account on this machine can decrypt it; it is never committed or uploaded. Linux has no dependency-free equivalent and falls back to plain text, as described below. To revoke access, delete the token in GitHub's Settings → Developer settings → Tokens.
Turn on the switch next to the notes you want public in Obsidian's file explorer, then click "⟳ Publish to GitHub". Wait 1–2 minutes and visit https://<your-username>.github.io/<repo-name>/.
Click the icon in the left ribbon, or run "Open Blog Publisher" from the command palette, and a tab opens in the main area. The interface's language and light/dark theme follow Obsidian.

| Feature | Description |
|---|---|
| Single vault | One Obsidian vault maps to one blog. The top row shows vault name · repo · note count · open site · settings |
| Per-note toggle | In Obsidian's own file explorer: one Public/Private switch per note, written into the frontmatter. There is no second file tree inside the publisher |
| Bulk actions | Every folder row carries "Publish all / Make all private" and a count like 3/3, so a whole folder flips in one click |
| Three change columns | Added / modified / deleted are listed separately, and added notes can be made public right there. Only changes you need to handle appear — categories with auto-sync on are pushed by the background process and drop out of these columns; if the last auto-sync push failed, those categories come back |
| Auto-sync | One switch per change category. When on, that category publishes itself without pressing Publish. Default: modified on, added/deleted off. Fires about 2.5 seconds after a file changes, but only while Obsidian is running |
| Sync log | Below the auto-sync row, the last few background actions: when the push started, which files changed, whether it landed, when the site finished updating, and the gap between. Failed pushes, failed builds, and build timeouts each leave a record. Stored locally, survives restarts |
| Result card | Every publish / source update / appearance save pops a card in the top right, starting as a spinner and settling on ✓ or ✗ with an expandable log. No close button and no auto-dismiss — click anywhere on the card to dismiss it |
| Build diagnostics | After a push it checks the GitHub Actions run, not just the live output. Stuck or failed builds are reported as "build failed" or "build timed out", with a direct link to the Actions page |
| Deploy progress | After a manual publish it polls the live data/index.json and shows "GitHub is building… N seconds elapsed", turning into "Live (took N seconds)" with a link. After 4 minutes it points you to Actions |
| Custom appearance | The "Appearance" button opens the CSS editor with seven presets (default + six) and preview images, ready to fill in and edit. See the next section |
A site's behavior and its appearance are separate here: behavior comes from React components in the source, appearance is concentrated in two overridable places — CSS variables (colors, radius, fonts) and data-region selectors (home and list layout). So you can reskin the site without touching a line of code.
Click "Appearance" in the publisher's top right and pick a preset from the dropdown (hover for a description; the selected one shows a preview image below). It fills the editor, and you edit from there. Hand-written CSS can't be pre-rendered, so "Custom" shows no preview.

| Preset | Home layout | Palette |
|---|---|---|
| Default | Three-column cards | Blue on white |
| Ink green | Three-column paper cards | Warm paper with pine green, all serif |
| Sakura | Single-column large cards, centered enlarged titles | Very pale pink with rose |
| Mono | Single-column plain-text index | Pure white and black, no radius |
| Tree | File tree with vertical guide lines and corner connectors | Green-grey paper |
| Mosaic | Mixed-size blocks, first block takes the lead | Six-color cycle |
| Neon | Numbered index rows | Near-black with cyan/magenta outlines, monospace |
/* Colors: override the variables on :root; dark mode lives under .dark */
:root {
--primary: oklch(0.48 0.11 155); /* accent: links, buttons */
--background: oklch(0.99 0.004 250);
--radius: 0.625rem;
}
.dark {
--primary: oklch(0.72 0.155 252);
}
/* Home and list layout: these blocks expose data-region, so you can change column count, cards vs tree */
[data-region="folders"] { display: grid !important; grid-template-columns: repeat(3, minmax(0,1fr)); } /* home folders */
[data-region="list"] { } /* note list */
[data-region="tags"] { } /* tag bar */
[data-region="article"] { } /* article typography */
/* Fonts */
body { font-family: "Songti SC", Georgia, serif; }
The "Writing guide" button next to it opens a dialog listing the overridable variables, data-region selectors, and how to write font sizes, with a one-click insert of the color/radius block.
Three things to remember:
--content-w (default 78rem), and no preset writes max-width. Change that one value to widen or narrow everything at once.var(--fsu): --fsu is "1rem that follows the reading size". The root font size is deliberately left alone (otherwise zooming would distort column widths and controls), so write calc(1.1 * var(--fsu)) to follow the slider.theme.css and restores the default theme.Saving writes public/theme.css in the repo working copy but doesn't deploy. "Save and publish" commits and pushes only that one file, without dragging along notes you weren't ready to publish. The limit is 200 KB; comments only count as no custom appearance. Changes take effect in 1–2 minutes, and the URL carries a content hash to bypass GitHub Pages caching.
The site is a Next static export; the page structure is compiled into React. Letting users edit HTML would mean exposing a template engine, and the security and upgrade costs aren't something this project should take on. CSS is enough for colors, fonts, spacing, visibility, and position (flex/grid are both overridable), and a stylesheet can never be executed as HTML/JS — theme.css is loaded via a <link>, not an inline <style>, so even a </style> can't escape.
Compared to platforms that hand you a whole skin (HTML + CSS + JS): less freedom, but your skin won't break when the site upgrades, and no scripts can be injected. The trade-off is that you can't change DOM structure — adding a new section, for example.
The reading experience aims to reproduce how Obsidian renders notes.

N public at the topAll › Parent › Current jumps back to any level. Folders nest arbitrarily deep?folder=, so refresh, share, and back/forward all return to the same level. Note pages remember where you came from, and "Back" returns to that levelTag filter: (N notes)N words, publish date, tags--content-w, default 78rem) shared by the top bar, article, footer, and home| Syntax | Result |
|---|---|
[[Note]] |
Clickable, with a hover preview popup |
| Outlinks / backlinks | Lists "Linked from" and "Links out" at the end of each note |
| Uncreated / private | Outlinks distinguish the two; links to private notes show a lock |
![[attachment]] |
Images and other attachments render inline |
[!note] etc. |
13 callout types |
==highlight== |
Highlight |
#tag |
Clickable filter. Pure numbers (#2427, problem IDs) and 3–8 digit hex values (#ff0000, colors) are not tags; #xxx inside link text is not parsed either |
^block-ref |
Jumps to a specific paragraph, falling back to the whole note if the target is gone |
$math$ |
Rendered with KaTeX — including math in the outline |
The site supports Chinese/English switching and light/dark themes, and remembers reading preferences (font size, collapsed folders, filters) locally.
public field in the frontmatterpublished/total plus two buttons, "Publish" and "Make private". Two buttons rather than one switch, because a single switch has one direction and a partially public folder could only be inferred as "all public"3/6 count at the end of the row)Published, but the site didn't change? GitHub Actions takes 1–2 minutes. The result card shows "GitHub is building… N seconds elapsed" and switches to "Live (took N seconds)". After 4 minutes it points you to the repo's Actions page.
What is site-template/ for? Can I delete it?
On first enable the plugin lays down the site source template (42 files, about 408 KB) into site-template/ in the data directory; "Update source" uses it to push site code. There's no need to delete it — the template carries a content fingerprint, so upgrading the plugin re-lays it automatically.
The switches in the file explorer don't respond? They only work when the current vault is the one configured in the publisher. If the vault doesn't match you get a notice and they're disabled, because the publisher serves one vault at a time.
Can I set the interface language and theme separately? No, they follow Obsidian: Simplified and Traditional Chinese both count as Chinese, everything else is English; the theme follows Obsidian's too.
Where is the public flag stored? Will it survive a move?
In each note's frontmatter as the public field, so it travels with the note — switch vaults or machines and it's still whatever you set.
Publishing fails with a push error? Read the expanded log first. The most common cause is an expired token — publishing, repo creation, and automatic Pages setup all fail together, and the message doesn't say why. Generate a new token and paste it in.
Are private notes read or uploaded?
No. The build reads only notes with public: true and the attachments they reference. Private notes' bodies, titles, and paths never appear in the repo.
Can I change the page structure, like adding a section? No. Appearance is CSS only (see "Why CSS only" above). Adding a section is a feature change and means editing the React source.
Can I use my own domain? Set Custom domain in the GitHub repo's Settings → Pages and point your DNS at it. That's done on GitHub; the publisher isn't involved.
"Failed to load plugin" when installing?
Check that main.js, manifest.json, and styles.css are all in <vault>/.obsidian/plugins/note-blog-publisher/ with the directory name unchanged. Then confirm Git is installed (publishing needs it) and that community plugins are enabled.
Can I preview the site locally?
Run npm run build then npm run preview, and open http://localhost:8080. This needs the source repo — not required for plugin users.
Can I run several blogs? Yes. One Obsidian vault maps to one blog; configure a connection inside that vault. Switching vaults switches blogs — the publisher matches the connection to the vault you have open. There's no "switch" button in the plugin because Obsidian opens one vault at a time.
Does auto-sync keep running after I close the publisher? No, deliberately: auto-pushing git shouldn't happen unattended.
The source directory holds only program code. Personal data (connections, tokens, note mirror, build output) lives in your system user directory:
| OS | Path |
|---|---|
| Windows | %APPDATA%\obsidian-blog |
| macOS | ~/Library/Application Support/obsidian-blog |
| Linux | ~/.config/obsidian-blog |
It contains connections.json (blog connections), local.json (GitHub token and each connection's local vault path), repos/<connection-id>/ (repo working copies), content/ (mirror of public notes), and build/ (build output).
The GitHub token in local.json is stored encrypted by the OS: Windows uses DPAPI (CryptProtectData, CurrentUser scope), macOS uses the Keychain, and the ciphertext lives in the githubTokenEnc field — only your own account on this machine can decrypt it. Linux has no dependency-free equivalent and falls back to a plain-text githubToken field; there, any program that can read your user directory can get it. To revoke access entirely, delete the token on GitHub.
To move it elsewhere (USB drive, sync folder), set the OBSIDIAN_BLOG_DATA environment variable and restart Obsidian.
The plugin reads and writes files outside your vault for three reasons:
node_modules and build output, often several hundred MB). Inside the vault it would be indexed as notes, re-uploaded by any sync service, and would pollute search results.Beyond that one data directory, the plugin only reads the vault you currently have open (and only notes with public: true plus the attachments they reference) and the files it generated itself inside the repo working copy. It does not read your Documents, your Desktop, or any other directory.
The plugin talks to GitHub only — no other remote service, and no usage reporting of any kind:
| Target | Purpose |
|---|---|
api.github.com |
Create the repo, read and write repo variables, enable Pages, check Actions run and build status, verify the token |
github.com |
git clone / git push to upload your notes and the site source |
Both happen only after you click Publish, Create repository, or Refresh status, or when auto-sync is on. The token is yours and stays on your machine. Apart from those GitHub requests the plugin sends nothing: it collects no usage data and uploads note content nowhere except your own repository.
Your Obsidian vault (private and public notes mixed together)
│ ① reads only notes with public: true, plus the attachments they reference
▼
Blog Publisher (Obsidian plugin; the same core runs in a background child process)
│ ② git add / commit / push
▼
GitHub content repo (Markdown source)
│ ③ GitHub Actions runs npm run build
▼
GitHub Pages
The repo stores Markdown source only, so the diff is exactly your note changes — readable and revertible. The cost is waiting for CI after each publish.
This codebase is both the site source and an Obsidian plugin. Site styling, the Markdown pipeline, and publishing logic all live here.
The plugin is a separate npm package (obsidian and esbuild are only used there), so after installing the root you need its dependencies too — otherwise npm run typecheck and npm run plugin fail to resolve obsidian:
npm ci
npm --prefix obsidian-plugin ci
Then:
npm run dev # dev server → http://localhost:3010
npm run build # full build (generate content + static export + move output)
npm run preview # preview the build → http://localhost:8080
npm run typecheck # type-check site and plugin
npm run plugin # bundle the Obsidian plugin (single-file main.js, background service included)
npm run presets # regenerate built-in presets from themes/*.css
npm test # end-to-end regression tests
When debugging publishing logic, you can open the same interface in a browser instead of reloading the plugin repeatedly:
npm run dev:manager # → http://localhost:3457
It runs the same core the plugin uses (scripts/manager-core.mjs), just with the UI in a browser so requests and logs are easier to see. npm test starts an instance the same way. This is not how end users run it — inside the plugin the UI lives in Obsidian and no extra process is needed.
npm run dev needs content to exist — on a fresh clone run npm run build once, or connect a vault through the publisher.
Preset CSS sources live in themes/. Don't edit scripts/theme-presets.mjs — it's generated. Edit themes/*.css, then run npm run presets; the presets' Chinese and English names and descriptions live in META in scripts/gen-theme-presets.mjs.
Preview images come from a five-step chain (build the site with sample notes → inject each preset and screenshot → pack into a base64 module), under tools/preview/:
node tools/preview/make-preview-content.mjs # 1. generate sample notes (site name "示例博客")
npm run build # 2. build
node tools/preview/preview-serve.mjs _preview/site 3480 & # 3. start preview server (keep running)
node tools/preview/gen-theme-previews.mjs _preview/site _preview/shots # 4. headless Chrome screenshots
python tools/preview/pack-theme-previews.py # 5. pack into scripts/theme-previews.mjs
Step 3 needs Chrome installed (point CHROME at it if it's not in the default location); step 4 needs Python's Pillow. The sample content is deliberately fake — the previews ship inside the plugin alongside the source, so no real notes may appear.
npm test (i.e. bash scripts/e2e-run.sh) runs the full regression against a separate temporary data directory and never touches your existing connections, tokens, or vaults. Don't run node scripts/e2e-test.mjs directly: it creates and deletes connections, and without a temporary data directory it would write test connections into your real config (the script now refuses to run and tells you to use the command above).
Deploying to your own server: after npm run build, copy the contents of build/site/ from the user data directory to any static server.
obsidian-blog-publisher/
├── README.md / README.zh.md English / Chinese docs
├── CHANGELOG.md / .en.md release notes
├── CONTRIBUTING.md / .en.md contribution guide
├── manifest.json / versions.json plugin manifest and minimum-version map (the community directory reads only this root copy)
├── LICENSE / .editorconfig / .gitattributes / .gitignore
├── package.json dependencies and scripts
├── next.config.ts Next config (output: 'export', supports basePath)
├── .github/
│ ├── workflows/deploy.yml CI: build+deploy in a site repo, typecheck+bundle in the plugin repo
│ └── ISSUE_TEMPLATE/ issue templates
├── docs/screenshots/ screenshots used by the READMEs (English; `zh/` holds the Chinese set)
│
├── scripts/
│ ├── manager-core.mjs ★ publishing core (HTTP server + all endpoints, shared by plugin and local debugging)
│ ├── ui-template.mjs ★ UI HTML/CSS/JS (the same copy for plugin and local debugging)
│ ├── manager.mjs local debugging entry: runs the core outside Obsidian (e2e uses it too)
│ ├── build-content.mjs ★ content compiler (Markdown → JSON) + output mover
│ ├── theme-presets.mjs built-in appearance presets (generated from themes/*.css)
│ ├── theme-previews.mjs preset preview images (base64 PNG)
│ ├── serve.mjs local static preview server
│ ├── e2e-test.mjs end-to-end regression tests
│ └── e2e-run.sh one-shot test runner (separate temp data directory)
│
├── themes/ CSS sources for the six appearance presets
├── tools/preview/ preview generation chain (sample content → screenshots → packing)
│
├── obsidian-plugin/ the Obsidian plugin (reuses the core and UI above)
│ ├── package.json / esbuild.config.mjs
│ ├── main.js build output (what the Release ships; single file with plugin + background service)
│ ├── styles.css styling for the explorer toggle and batch buttons (shipped with the Release)
│ └── src/
│ ├── main.ts plugin entry: starts the core via utilityProcess, opens the view
│ ├── worker-entry.ts the core in a child process (git calls are synchronous and must not block the main thread)
│ ├── explorer.ts public/private controls on the native file explorer
│ └── template.ts lays the site source template into the data directory on first run
│
└── src/
├── app/ root layout, entry page, global styles (light/dark variables)
├── components/blog/ ★ app shell / home / note page / hover preview / Markdown pipeline
├── components/ui/ shadcn/ui primitives
└── lib/
├── wikilink.mjs ★ the single implementation of Obsidian syntax parsing (shared by build and render)
├── obsidian.ts ★ data model: heading extraction, math splitting, path mapping
├── remark-obsidian.ts ★ remark plugin: wikilink / embed / callout / highlight / tag
├── rehype-safety.ts sanitizer: blocks dangerous links and inline scripts
├── api.ts / store.ts site data fetching and view state
└── i18n.ts / site.ts Chinese/English dictionaries, site name and basePath resolution
★ = core file
| Layer | Choice | Why |
|---|---|---|
| Framework | Next.js 16 (App Router, static export) | Component-based UI that still exports to plain static files |
| UI | React 19 + Tailwind CSS v4 + shadcn/ui | No runtime style engine, small output |
| Markdown | react-markdown + a remark/rehype plugin chain | Standard AST pipeline; Obsidian syntax plugs in |
| Math | KaTeX | Renders synchronously — the outline needs it too |
| State | zustand + persist | Reading preferences persist without accounts |
| Publishing UI | Plain JS embedded in ui-template.mjs |
A local tool doesn't justify another build chain |
| Deployment | GitHub Actions + Pages | Free, no server needed |
Render pipeline: remark-gfm (tables / task lists / strikethrough) → remark-math (math) → remark-obsidian (wikilinks / embeds / callouts / highlights / tags) → rehype-raw → rehype-safety (sanitizing) → rehype-highlight (code highlighting) → rehype-katex (math rendering). The two in bold are this project's own plugins.
MIT © 2026 bits47
Free to use, modify, sell, and redistribute — the only requirement is keeping the copyright notice. Provided as-is, without warranty.
MIT covers this project's code only — the blog sites and notes you generate with it are yours.
Built on Next.js, React, Tailwind CSS, KaTeX, and the remark/rehype ecosystem.