A small, single-binary CMS written in Go.
  • Go 63.5%
  • Vue 13.4%
  • CSS 10%
  • HTML 6.2%
  • JavaScript 4.6%
  • Other 2.3%
Find a file
Repository files (latest commit first)
Filename Latest commit message Latest commit date
2026-09-16 15:18:34 -07:00
adapters fix: admin panel automatically selects types 2026-09-16 15:18:34 -07:00
admin fix: admin panel automatically selects types 2026-09-16 15:18:34 -07:00
cache feat: multilingual frontend redesign + backend robustness pass 2026-06-26 18:31:07 -07:00
config feat: improve markdown and admin workflows 2026-09-15 22:19:26 -07:00
docs feat: improve markdown and admin workflows 2026-09-15 22:19:26 -07:00
domain feat: share static assets across language subdomains via static_url 2026-08-02 02:15:16 -07:00
i18n feat: multilingual frontend redesign + backend robustness pass 2026-06-26 18:31:07 -07:00
ports fix: admin panel automatically selects types 2026-09-16 15:18:34 -07:00
render feat: improve markdown and admin workflows 2026-09-15 22:19:26 -07:00
static feat: improve markdown and admin workflows 2026-09-15 22:19:26 -07:00
templates feat: improve markdown and admin workflows 2026-09-15 22:19:26 -07:00
utils feat: improve markdown and admin workflows 2026-09-15 22:19:26 -07:00
.gitignore chore: archive superpowers planning docs, drop stale old/ ignore entry 2026-08-02 02:15:16 -07:00
admin_embed.go fix: security hardening and backend robustness pass 2026-08-02 02:15:16 -07:00
admin_embed_test.go fix: fedi idempotent retries, schema versioning, and admin UX 2026-06-18 17:49:45 -07:00
config.yaml feat: improve markdown and admin workflows 2026-09-15 22:19:26 -07:00
go.mod feat: improve markdown and admin workflows 2026-09-15 22:19:26 -07:00
go.sum feat: improve markdown and admin workflows 2026-09-15 22:19:26 -07:00
LICENSE chore: add MIT license 2026-06-19 14:22:02 -07:00
main.go fix: admin panel automatically selects types 2026-09-16 15:18:34 -07:00
README.md feat: share static assets across language subdomains via static_url 2026-08-02 02:15:16 -07:00

aoi-cms

A small, single-binary blog / personal-site CMS written in Go.

Markdown files in pages/ are the source of truth. The binary scans them on startup (and on change), keeps a SQLite read-model for fast listings and search, renders pages through Go's standard html/template, and ships an embedded Vue 3 admin SPA for everything you'd rather click than retype.

Fediverse (Mastodon / GoToSocial / Misskey) is a first-class citizen: articles can be announced to a configured account, replies sync back as native comments, and visitors can sign in with their own Fedi account to post.

Highlights

  • File-first. Drop a .md into pages/ and reload — that's the whole workflow. The scanner injects a uuid and date for you on first sight, atomically.
  • Hexagonal Go core. domain/ is plain types, ports/ are interfaces, adapters/ plug in SQLite, the filesystem, the Fediverse client, Gin, and so on. Easy to swap or test.
  • Hot reload. Editing a template or a content file refreshes the page on the next request — no rebuild, no restart.
  • Page cache with cheap invalidation. Rendered HTML is held in a 1000-entry FIFO keyed by slug (+ page number); any content change drops the whole cache.
  • Vue 3 admin at /admin. File tree, Markdown editor with keyboard shortcuts, drag-drop uploads, frontmatter panel, manual Fedi publish, comment moderation, live config edit. Bundle is //go:embed-ed — the binary is self-contained.
  • Fediverse. Bot account on a Mastodon-compatible instance (Mastodon, GoToSocial, Pleroma) for per-article announce, comment sync, star-count sync, optional cover upload. New articles can auto-announce (fediverse.auto_publish: true); previously-tracked articles always wait for an explicit admin republish, so the first connection to an established pages/ tree doesn't flood the timeline. Commenter login is cross-protocol: Mastodon OAuth and Misskey MiAuth both work — Like and reply calls dispatch to the right native API and federate back to the bot's instance, so a Misskey user can comment on a GoToSocial-hosted bot post and it shows up natively for everyone. "Quick Reply" via authorize_interaction is the non-logged-in cross-instance escape hatch (Mastodon-side; Misskey users can just sign in). Comment threads are reconstructed by canonical post URL, so nested replies survive the round-trip back through ActivityPub regardless of which instance authored which reply.
  • Static-feel routing. skip_in_url, custom slugs that propagate from a section's _index.md, numeric 01. filename prefixes for sort order — and 301s for old URLs that pre-date a skip_in_url flip, so your backlinks don't break.

Quick start

Requirements: Go 1.25+, Node 20+ (only if you want to rebuild the admin SPA).

git clone <this repo>
cd aoi-cms

# 1. build the admin SPA (the built dist is embedded into the Go
#    binary via //go:embed; skip this step if you don't change it)
cd admin && npm install && npm run build && cd ..

# 2. build the server
go build -o aoi-cms .

# 3. drop some content
mkdir -p pages
cat > pages/hello.md <<'EOF'
---
title: Hello, world
type: post
---
This is my first post.
EOF

# 4. configure and run
cp config.yaml my-config.yaml   # edit url/sitename/etc.
./aoi-cms -c my-config.yaml

Open http://localhost:8080 for the site and http://localhost:8080/admin for the editor.

Flags

Flag Default Purpose
-r <dir> . Project root. The defaults for the next three are derived from it.
-c <file> <root>/config.yaml Config file.
-p <dir> <root>/pages Content directory.
-d <file> <root>/database.db SQLite database path.

Configuration

A complete annotated example lives at config.yaml. The shape is also documented under the runtime keys in docs/api.md. A few callouts:

  • code is a site-wide fallback password for locked: true pages.
  • static_url sets the origin shared by every language subdomain for /static/... assets. Without it, a subdomain-per-language deployment (en.site, ja.site) re-downloads CSS/fonts/JS on each language switch — every subdomain is a separate browser-origin with its own HTTP cache. Empty keeps relative /static paths (single-origin sites).
  • ip_salt should be a long random string — rotate it to invalidate every existing star.
  • admin.password accepts either a plain string (handy for local development) or a bcrypt hash ($2a$...) for production.
  • fediverse.owner_acct is the only way to grant admin access via Fediverse OAuth — without it, only the standalone login works.
  • Leave fediverse.access_token empty on first run: the binary will register an OAuth app and print the authorization URL plus the exact curl command for the token exchange.

Project layout

adapters/
  fedi/         # Mastodon / Misskey / generic ActivityPub client
  file/         # scanner, watcher, asset uploads
  rss/          # RSS / Atom fetcher for type:micro pages
  sqlite/       # SQLite read-model (pages, comments, stars, sources)
  web/          # Gin router and HTTP handlers (handlers/, htmx/)
admin/          # Vue 3 + Vite admin SPA. Built output is embedded.
cache/          # in-memory FIFO HTML cache
config/         # YAML loader + live `SiteConfig` snapshotting
docs/           # this folder — frontmatter / pages / templates / api
domain/         # plain Article, Comment, MicroPost types
pages/          # your Markdown content (you create this)
ports/          # interfaces the adapters implement
render/         # template engine, paginator, query parser
static/         # CSS, JS, fonts, uploads — served at /static
templates/      # html/template files: bases/, partials/, pages/, sections/

See:

  • docs/config.md — full config.yaml reference (site, admin, nav, fediverse, i18n).
  • docs/pages.md — directory layout, type inference, URL derivation, the scan loop, multilingual content.
  • docs/frontmatter.md — every frontmatter key, defaults, and inheritance rules.
  • docs/templates.md — TemplateData fields, helper functions, minimal page / section examples, i18n / LocalizedSite.
  • docs/api.md — public, admin, and HTMX endpoints.

Working on the admin SPA

cd admin
npm install
npm run dev     # Vite dev server, proxies API calls to :8080
npm run build   # writes admin/dist/, which the Go binary embeds

The Go server picks up admin/dist/ at build time via //go:embed (see admin_embed.go), so you must npm run build before go build for SPA changes to ship.

License

See LICENSE if present.