English | 简体中文
Status: phase-1 features are ready · you deploy remotely (phases 0–4 done; phase 5 docs are ready; this repo does not run remote commands for you) Fuwari-style public site + Cloudflare D1 content + in-site
/adminwriting.
It looks like Fuwari, but posts live in D1 and can be edited in the admin UI. Everything runs on one Cloudflare Worker. After you publish, a refresh of the public site is enough — you do not git-push Markdown and rebuild.
Phased plan and acceptance checks: PLAN.md. Major changes: MONUMENTS.md. Longer design notes (Chinese): docs/初版开发思路.md.
Same Worker: the public site looks like Fuwari; the admin logs into /admin and writes Markdown; bodies go to D1; after publish, SSR reads the database.
| Want | Usual gap |
|---|---|
| Looks like Fuwari | Many Cloudflare CMS tools are capable but do not look right |
| Admin + a real database | Fuwari itself is static Markdown — no D1, no admin |
| Hosted on Cloudflare | Needs Workers + D1, plus R2 for cover uploads (Workers AI later) |
This is not a reskin of another CMS, and Git is not the database.
Strategy: copy the theme shell, replace the content source. Take Layout / Navbar / cards / Markdown styles from Fuwari commit 6d39b0d; drop Content Collections and getCollection. Post Markdown is stored as D1 body_md and rendered at write time to body_html (Worker by default; /admin/site can switch to the browser). Public pages only read cached HTML. Drafts never enter public queries.
Phase-1 limits: avatar and banner stay URL strings. A cover can still be a remote or in-site URL, or an upload from the editor (R2, public path /media/<uuid>.<ext>). No comments, no FTS full-text index, no real AI (/api/admin/ai/* returns 501). Navbar search is GET /api/search (LIKE on published posts only). Empty site_settings fields overlay the current frontend defaults in src/config.ts.
Admin stays plain: a Markdown textarea plus a preview modal. Looks belong on the public site.
Against docs/初版开发思路.md section 12, the code already meets the items below. Production still depends on you creating remote D1, putting the secret, and running pnpm run deploy.
- Admin can sign in, create / edit / publish / unpublish drafts
- Home, post pages, tags / archive look like Fuwari
- A refresh of the public site shows newly published D1 content; no
git pushof post files - Drafts never appear in public lists
- Attribution is clear: the theme comes from Fuwari (MIT)
flowchart LR
browser[Browser]
worker[CloudflareWorker]
subgraph sameWorker [SameWorker]
hono["Hono /api and /admin guard"]
astro["astro/hono pages"]
end
d1[D1]
browser --> worker
worker --> hono
hono --> astro
hono --> d1
astro --> d1
| Layer | Choice |
|---|---|
| Public UI | Astro 7 SSR + Tailwind 3 (Fuwari 6d39b0d, MIT) |
| Runtime | Cloudflare Workers (@astrojs/cloudflare 14) |
| Entry | src/fetch.ts: Hono /api/* → /media/:key → /admin session guard → astro/hono actions() / middleware() / pages() / i18n() |
| API | Hono (src/api/app.ts, unit-testable without Astro) |
| Database | D1, binding name DB (not an env var) |
| Auth | Single admin; PBKDF2-SHA256; D1 sessions + HMAC-signed sid cookie |
| Markdown | src/lib/markdown.ts at write time (Worker by default; /admin/site can use the browser); fences via rehype-expressive-code (Shiki JavaScript engine) |
| Public reads | src/lib/posts.ts, always status = 'published' |
| Admin writes | src/lib/admin-posts.ts (includes drafts) |
Secret: exactly one required value — SESSION_SECRET. Local: .dev.vars. Production: wrangler secret put. Never put it in wrangler.jsonc or Git.
UI language: visitor locale cookie first; else D1 site_settings.lang; else siteConfig.lang in src/config.ts.
Swup ignores /admin; the admin UI swaps tabs itself.
Schema is migrations/. On read paths, empty string / NULL fields fall back to src/config.ts.
| Column | Notes |
|---|---|
id |
INTEGER PK |
username |
UNIQUE; phase 1 is always admin |
password_hash |
PBKDF2 string, written on first visit to /admin/login |
created_at |
Default datetime('now') |
| Column | Notes |
|---|---|
id |
TEXT PK (32-byte hex) |
user_id |
FK → users, ON DELETE CASCADE |
expires_at |
Set at login to datetime('now', '+7 days') |
created_at |
Index: idx_sessions_expires_at.
| Column | Notes |
|---|---|
id |
INTEGER PK |
slug |
UNIQUE, kebab-case |
title / description |
|
body_md / body_html |
Source Markdown and write-time HTML (Worker or browser) |
excerpt / headings_json |
List excerpt and TOC |
cover_url |
Remote URL, or /media/<key> after upload |
status |
draft | published |
category |
Nullable |
lang |
Post content language, default en |
published_at |
Required when published (table CHECK) |
created_at / updated_at |
|
word_count / reading_minutes |
Computed at write time |
Index: idx_posts_status_published_at (status, published_at DESC). Slug uniqueness is the UNIQUE constraint.
Many-to-many. Sidebar tags and /archive/?tag= use these tables. post_tags PK is (post_id, tag_id); deleting a post cascades.
Single row; id must be 1.
| Column | Migration | Use |
|---|---|---|
avatar / name / bio / links_json |
0002 | Sidebar profile and three links |
banner_json |
0002 | Home banner |
title / subtitle / footer |
0003 | Nav brand, subtitle, footer second line |
about_md / about_html |
0003 | About page |
lang |
0004 | Default UI language when there is no cookie |
client_markdown |
0005 | Admin: render Markdown in the browser (NULL = off) |
updated_at |
Uploaded images. Bytes are in R2 (binding MEDIA, bucket fuwari-cms-media); this table stores the key, public path, MIME, and size. Migration 0006.
| Column | Notes |
|---|---|
id |
INTEGER PK |
key |
UNIQUE, uuid.ext, not the user's filename |
url |
/media/<key>, stored on the post as cover_url |
mime / size |
From magic bytes, not the client Content-Type |
created_at |
Default datetime('now') |
Do not apply scripts/seed.sql with --remote (local demo posts only).
Node ≥ 22.12. pnpm comes from Corepack via packageManager in package.json (corepack enable --install-directory <user dir>, then PATH).
pnpm install
cp .dev.vars.example .dev.vars # set SESSION_SECRET; do not commit
pnpm db:migrate:local # migrations/ → local D1 (includes media)
pnpm db:update # after git pull: local always, remote if this account owns the DB
pnpm db:seed:local # demo posts; not --remote
pnpm dev # http://localhost:4321
pnpm cf:deploy # one-shot deploy for the logged-in account (rewrites database_id)
pnpm build # astro check + build → dist/
pnpm preview # wrangler serves the production bundle
pnpm lint / pnpm format # Biome
pnpm test # Vitest, inside workerdpnpm db:migrate:local applies pending SQL to the on-disk SQLite. Cover uploads use Wrangler's simulated R2 under .wrangler/state: no bucket create and no login. After a pull, run pnpm db:update. Schema-only pulls do not need a deploy; Worker code changes need pnpm run deploy or pnpm cf:deploy.
Replace SESSION_SECRET in .dev.vars with a long random string. First visit to http://localhost:4321/admin/login sets the password for username admin (≥ 8, when users is empty). After login:
/adminposts (timeline by publish date; search; filter by tag / status; Re-render posts refreshes stored HTML with the current renderer, without changing status or dates)/admin/profileavatar, bio, three links, banner/admin/sitetitle, footer, default language, Render Markdown in the browser/admin/aboutAbout Markdown
Fields not stored in D1 keep src/config.ts. To redo local setup:
pnpm exec wrangler d1 execute fuwari-cms --local --command "DELETE FROM sessions; DELETE FROM users;"Phase 1 has one secret and no other vars.
| Name | Local | Production | Notes |
|---|---|---|---|
SESSION_SECRET |
.dev.vars |
wrangler secret put SESSION_SECRET |
HMAC for the sid cookie; secrets.required in wrangler.jsonc checks it exists on deploy — do not put the value in jsonc |
DB |
Wrangler local SQLite | D1 binding | wrangler.jsonc d1_databases, not env |
MEDIA |
Wrangler local bucket | R2 binding | Same file, r2_buckets, bucket name fuwari-cms-media. Do not set remote, or pnpm dev will call the remote bucket |
Random secret examples:
# Unix
openssl rand -base64 32
# PowerShell
[Convert]::ToBase64String((1..32 | ForEach-Object { Get-Random -Maximum 256 }) -as [byte[]])This repo does not run remote deploy / secret put / migrations apply --remote for you. After the steps below you get a *.workers.dev public site and /admin.
If you are logged in and .dev.vars has a real SESSION_SECRET (not the example placeholder), pnpm cf:deploy does it in one step: create or select the D1 database named fuwari-cms in this account (rewriting the single database_id), create fuwari-cms-media if it is missing, upload SESSION_SECRET from .dev.vars only when the Worker does not have it yet, migrate local and remote, then pnpm run deploy. Before you commit, check that the rewritten database_id belongs to your account. GitHub Actions must not run pnpm cf:deploy. If the script fails, use the manual steps below.
Local pnpm dev uses Wrangler’s on-disk SQLite under .wrangler/. That is not the Cloudflare D1 database: local migrations, posts, and passwords do not appear in production. The remote posts table starts empty (expected; the empty DB is not seeded, and you should not run pnpm db:seed:local remotely).
Both methods share steps 0–4. Method A pushes the Worker from your machine. Method B does the same with GitHub Actions.
- A Cloudflare account (free plan is enough; D1 and Workers are included).
- Node ≥ 22.12; pnpm 9 via Corepack (see Local development).
pnpm installat the repo root. - Log in to Wrangler (browser OAuth). If you see
Timed out waiting for authorization code, run it again and approve before it times out:
pnpm exec wrangler loginMethod B does not need local OAuth; it uses an API token instead. wrangler whoami shows the current account.
The Worker name is "name" in wrangler.jsonc (default fuwari-cms). After deploy the URL looks like https://fuwari-cms.<your-subdomain>.workers.dev — trust the URL the CLI prints. If the name is taken, change name and deploy again.
Use the same database_name as in jsonc (default fuwari-cms):
pnpm exec wrangler d1 create fuwari-cmsSuccess prints a database_id (UUID). That name can only be created once per account. If it already exists, do not create again; use:
pnpm exec wrangler d1 listCopy the ID from the list.
Newer Wrangler asks Would you like Wrangler to add it on your behalf?
- Choose no and edit jsonc yourself (recommended).
- If you choose yes, it appends another block, often named
fuwari_cms, and does not update your existingDB. Code and tests useenv.DB; the extra block is unused, and a leftover placeholder ID still points production at the wrong database.
Either way, wrangler.jsonc d1_databases must have exactly one entry:
Notes:
bindingmust beDB, not the CLI’sfuwari_cms.- Replace the placeholder
00000000-0000-0000-0000-000000000000with the real UUID. Do not keep two blocks. - Keep
"migrations_dir": "migrations"orpnpm db:migrate:remotewill not find the SQL. - If the CLI asks whether local should talk to remote resources: choose no.
pnpm devkeeps using local SQLite; the remote DB is for the production Worker only. database_idmay be committed (it is not a secret; it only tells Wrangler which database to bind). Localpnpm devignores it. Before you deploy, replace it with the UUID from your ownd1 create/d1 list. Deploying someone else's ID fails with unauthorized.
Cover uploads need a bucket of the same name in the same account. Local pnpm dev does not.
pnpm exec wrangler r2 bucket create fuwari-cms-mediaCreate it once per account; skip if it already exists. If the CLI asks to write the config, choose no. The binding must stay MEDIA as already written in jsonc. Do not append a second block, and do not set remote.
This value lives only in Cloudflare Worker secrets. Do not put it in jsonc or in GitHub Actions env / vars. Local .dev.vars is for pnpm dev only and is not uploaded by pnpm run deploy.
Generate a random value (see Env and bindings), then:
pnpm exec wrangler secret put SESSION_SECRETThe CLI stops at Enter a secret value: and hides input. That is normal: paste the secret and press Enter. It will confirm upload. Without this step, secrets.required in jsonc fails the deploy.
Use a different production secret from local .dev.vars. To rotate later, secret put again; existing sid cookies become invalid and you must log in again.
pnpm db:migrate:remoteSame as wrangler d1 migrations apply fuwari-cms --remote, reading migrations/ (0001–0006). pnpm db:migrate:local only changes the machine copy. After a pull, pnpm db:update always migrates locally, then migrates remote only when d1 list for the logged-in account contains the database_id in jsonc. If it does not, remote is skipped and the script tells you to run pnpm cf:deploy.
Do not run pnpm db:seed:local or scripts/seed.sql against remote (those are local demo posts). An empty remote posts table is expected; write in /admin.
If you get database not found / unauthorized: check database_id and that wrangler login is the same account that created the database.
pnpm run deployThe run is required. pnpm 9 treats bare pnpm deploy as “copy a package from the workspace” and errors with ERR_PNPM_CANNOT_DEPLOY. The script in package.json is pnpm build (wrangler types + astro check + astro build) then wrangler deploy.
First time, do 1 (including the R2 bucket) → 2 → 3 → 4. The bucket must exist or a deploy that binds MEDIA fails. After that:
| You changed | Do this |
|---|---|
| Pages / API / theme | pnpm run deploy again |
New files in migrations/ |
pnpm db:update (or pnpm db:migrate:remote first). No deploy when the Worker code did not change |
Only SESSION_SECRET |
secret put again; no rebuild |
When deploy finishes, the terminal prints a *.workers.dev URL.
- Open
https://<the-printed-host>/admin/login. - If
usersis empty you set a password: username is alwaysadmin, password ≥ 8. That write goes to remote D1 and is unrelated to the local/admin/loginpassword. - After login: write posts at
/admin; edit profile / site / about. Empty fields fall back tosrc/config.ts. - Public home, archive, and tags should load; drafts must not appear on the logged-out public lists.
- Optional: Cloudflare Dashboard → Workers → this Worker → custom domain (follow Dashboard DNS). The same Worker serves that domain; admin is still
/admin/login.
Useful for deploy-on-push. This repo does not ship a .github/workflows file (so CI does not go red before tokens exist). Add one if you want it.
- Cloudflare Dashboard → My Profile / API Tokens → Create Token. Minimum:
- Account · Workers Scripts · Edit
- Account · D1 · Edit
- Account · Workers R2 Storage · Edit Account ID is on the right of the Dashboard or the Workers overview.
- GitHub repo Settings → Secrets and variables → Actions:
CLOUDFLARE_API_TOKENCLOUDFLARE_ACCOUNT_ID
SESSION_SECRETis still set only with method A’swrangler secret puton the Worker (once; unrelated to CI). Do not put it in GitHub Secrets and inject it as buildvars— it leaks into logs and looks like a plaintext env var. Same caution forwrangler secret bulkunless you know the risk.wrangler.jsoncdatabase_idmust already be the real ID in the account that owns the token, and it must be committed; the secret must not be committed. That account must already have the bucketfuwari-cms-media. The workflow does not runpnpm cf:deploy.- Suggested workflow:
pnpm install→pnpm build→pnpm db:migrate:remote→pnpm exec wrangler deploy. First run withworkflow_dispatchby hand; addpushafter that works.
Example (save as .github/workflows/deploy.yml yourself):
name: Deploy
on:
workflow_dispatch:
jobs:
deploy:
runs-on: ubuntu-latest
timeout-minutes: 15
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
with:
version: 9
- uses: actions/setup-node@v4
with:
node-version: 22
cache: pnpm
- run: pnpm install --frozen-lockfile
- run: pnpm build
- run: pnpm db:migrate:remote
env:
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
- run: pnpm exec wrangler deploy
env:
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}Do not write pnpm deploy in CI (same pnpm built-in clash). Do not run db:seed:local or pnpm cf:deploy.
| Symptom | Cause / fix |
|---|---|
ERR_PNPM_CANNOT_DEPLOY |
You ran pnpm deploy; use pnpm run deploy. |
Enter a secret value: sits there |
secret put is waiting for paste + Enter, not hung. |
Two d1_databases blocks in jsonc |
The CLI appended fuwari_cms. Delete that block and put the UUID on the original DB. |
Deploy says SESSION_SECRET is missing |
You have not secret put, or you put it on another account / Worker name. |
| No posts / no admin online | Remote DB is empty. Do not seed; set a password at /admin/login and write posts. Local data does not copy over. |
| Local profile changes, production still defaults | site_settings is local vs remote. Save again in production admin, or keep config.ts defaults. |
d1 create says the name exists |
Use wrangler d1 list for the existing ID; do not create a second database with the same name. |
| Deploy says the R2 bucket does not exist | r2 bucket create has not been run, or it was created in another account. |
| Covers work locally, uploads fail in production | Remote D1 is not at 0006 yet, or the Worker is an older deploy without MEDIA. Run pnpm db:update, then pnpm run deploy if the code changed. |
db:update says remote was skipped |
Not logged in, or the database_id in jsonc is not in this account. Run pnpm cf:deploy. |
.
├── src/
│ ├── fetch.ts # Worker entry: Hono(/api) → /media → /admin guard → astro/hono
│ ├── api/app.ts # Hono routes (unit-testable without Astro)
│ ├── lib/posts.ts # Public posts (published only)
│ ├── lib/admin-posts.ts # Admin posts (includes drafts)
│ ├── lib/site-settings.ts # Profile / banner / site identity / about
│ ├── lib/markdown.ts # Write-time Markdown → body_html (Worker or browser)
│ ├── lib/auth/ # PBKDF2 + D1 session + first-run password
│ ├── plugins/ # Remark plugins adapted from Fuwari
│ ├── types/post.ts # PostEntry: pre-rendered bodyHtml + excerpt/words/TOC
│ ├── pages/ components/ layouts/ styles/ i18n/ utils/ constants/ assets/ config.ts
│ │ # Fuwari theme (see third_party/fuwari/README.md)
├── tests/ # Vitest (workerd)
├── migrations/ # D1 SQL migrations
├── scripts/seed.sql # Local demo posts (not --remote)
├── astro.config.mjs wrangler.jsonc vitest.config.ts biome.json tsconfig.json
├── README.md # English (GitHub default)
├── README.zh-CN.md # Simplified Chinese
├── PLAN.md MONUMENTS.md AGENTS.md
├── docs/初版开发思路.md
├── LICENSE NOTICE
└── third_party/fuwari/ # Upstream MIT text + copy list
Phase 1 (MVP, implemented)
Login, post CRUD, profile/site/about, EN/zh-CN UI, public list/detail/tag/archive matching Fuwari, drafts never public, navbar search (/api/search), fenced-code highlighting (admin can batch re-render existing posts).
Phase 2
R2 cover upload is implemented (POST /api/admin/media, pnpm db:update / pnpm cf:deploy). D1 FTS5 remains.
Phase 3 Workers AI (title/excerpt/…), a nicer editor, stats, and other extras as needed.
Original work in this project is MIT License Copyright (c) 2026 Angelen Atano
The public theme is based on Fuwari (MIT © 2024 saicaca) commit 6d39b0d. Upstream license: third_party/fuwari/LICENSE. Copy and adaptation list: third_party/fuwari/README.md. Full third-party notes: NOTICE.
MIT matches Fuwari and dependencies such as Astro / Hono, so the theme can be reused and later original code stays permissively licensed. This repo is not derived from GPL or other strong-copyleft products.
If you also want a good-looking personal blog plus a real CMS on Cloudflare, Watch / Star, or open an Issue about needs and trade-offs. Work lands directly in this repo.