Coding agent instructions for the tinyblog repository. tinyblog is a personal blog CMS running on Cloudflare Workers with Hono, D1 (SQLite), and R2.
| Layer | Technology |
|---|---|
| Runtime | Cloudflare Workers (Wrangler 4, ES2022 modules) |
| Framework | Hono |
| Database | Cloudflare D1 (SQLite) — post metadata, tags, site config |
| Object storage | Cloudflare R2 — markdown/HTML content, uploaded assets, RSS cache |
| Language | TypeScript (strict mode, noEmit, bundled by Wrangler/esbuild) |
| Frontend | Vanilla HTML/CSS/JS in public/ (no bundler) |
# Start local dev server (http://localhost:8787)
npm run dev
# Type-check only (no output emitted)
npm run typecheck
# Deploy to Cloudflare production
npm run deploy
# Apply ALL DB migrations to the local emulated D1 instance
npm run db:migrate:local
# Apply ALL DB migrations to the remote (production) D1 instance
npm run db:migrate:remoteThere is no linter, formatter, or test framework configured. The only automated quality gate is TypeScript strict-mode type-checking:
npm run typecheckRun this before every commit to catch type errors. There are no test files or test scripts; the project has no testing infrastructure.
If you add tests, use Vitest with @cloudflare/vitest-pool-workers (the standard for Cloudflare Workers projects). Add a test script to package.json and place test files alongside source files as *.test.ts.
src/
index.ts # Hono app entrypoint — middleware, route mounting, error handler
types.ts # Env interface, shared types, R2 key helpers
lib/
db.ts # D1 query helpers (prefixed db*)
markdown.ts # marked render wrapper
r2.ts # R2 read/write/delete/list helpers (prefixed r2*)
slugify.ts # Title → URL slug
templates.ts # HTML rendering (shell, post list, RSS feed)
routes/
api.ts # REST API (CRUD posts, upload, templates, tags, site-config)
public.ts # Public blog routes (/, /posts/:slug, /about, /rss, /assets/*)
migrations/
0001_schema.sql # D1 schema — posts, tags, post_tags tables
0002_published_at_site_config.sql # Adds published_at to posts; adds site_config table
public/ # Static assets (style.css, admin HTML pages, robots.txt)
wrangler.jsonc # Wrangler config (bindings: ASSETS, DB, BUCKET)
tsconfig.json # TypeScript config
| Column | Notes |
|---|---|
id |
PK autoincrement |
title, slug (UNIQUE), author, excerpt |
Post metadata |
status |
'draft' | 'published' |
created_at |
Row insertion time (draft creation) |
published_at |
Set on first publish only; NULL until published |
updated_at |
Updated on every write |
Many-to-many join. Tags are case-insensitive (COLLATE NOCASE).
Key/value table (key TEXT PRIMARY KEY, value TEXT). Current keys:
blog_name— displayed in the header, page titles, and RSS feedblog_tagline— used as the homepage meta description and RSS channel descriptionsite_url— canonical origin (e.g.https://myblog.com); used for RSS<link>/<guid>andsitemap.xmlURLs; no trailing slash
posts/<slug>/content.md Raw Markdown source
posts/<slug>/content.html Pre-rendered HTML (written at save time)
assets/<slug>/<filename> Uploaded images and files
templates/header.html Custom site header HTML
templates/footer.html Custom site footer HTML
templates/about.md About page Markdown source
templates/about.html About page pre-rendered HTML
cache/rss.xml Pre-built RSS feed (invalidated on publish/unpublish/delete/site-config change)
"strict": trueis enforced — no implicitany, strict null checks, etc.- All exported functions must have explicit return types.
- Use
import type { ... }for type-only imports. - Use union literal types for constrained values:
'draft' | 'published'. - Use
Partial<{ ... }>for partial update payloads. - Prefer
Promise.all([...])for independent parallel async operations. - Generic typed D1 queries:
.first<PostRow>(),.all<PostRow>().
- Always use the
.jsextension on local imports (required by"moduleResolution": "bundler"):import { dbGetAllPosts } from '../lib/db.js'; import type { Env } from './types.js';
- Named imports only from local modules; no default imports except
appfromindex.ts. - Group imports: external packages first, then local modules.
| Entity | Convention | Example |
|---|---|---|
| Files | camelCase.ts |
slugify.ts, templates.ts |
| Functions | camelCase |
dbGetAllPosts, r2PutBinary |
| DB helper prefix | db* |
dbGetPostById, dbPublishPost |
| R2 helper prefix | r2* |
r2GetText, r2PutBinary |
| Template/render | render* / build* |
renderPostPage, buildRssFeed |
| Interfaces/types | PascalCase |
PostRow, PostWithTags, SiteConfig |
| Hono router instances | role-named | api, pub (re-exported as apiRouter, publicRouter) |
Use guard-clause early returns with appropriate HTTP status codes. Never use nested if trees for validation.
// Parse and validate request body
let body: CreatePostBody;
try {
body = await c.req.json<CreatePostBody>();
} catch {
return c.json({ error: 'Invalid JSON body' }, 400);
}
// Guard-clause pattern for missing/invalid params
const id = Number(c.req.param('id'));
if (isNaN(id)) return c.json({ error: 'Invalid id' }, 400);
const post = await dbGetPostById(c.env.DB, id);
if (!post) return c.json({ error: 'Not found' }, 404);Top-level error handler in index.ts logs to console.error and returns 500. Do not swallow errors silently; always log before returning an error response.
Use null-coalescing for optional values: data.excerpt ?? null, header ?? ''.
- Use
// ── Section Name ──────────────────────────────────────────────────────────dash separators as visual section headers within longer files. - Use JSDoc comments with
@paramtags on all exported functions. - Tag template literal HTML strings with
/* html */for IDE syntax highlighting:return /* html */ `<!DOCTYPE html>...`;
- No semicolons rule or quote style is enforced by tooling — follow the existing file's style.
- Access bindings via
c.envin Hono handlers:c.env.DB,c.env.BUCKET,c.env.ASSETS. - The
Envinterface insrc/types.tsdeclares all bindings — add new bindings there first, then updatewrangler.jsonc. - Use
@cloudflare/workers-typesfor all Workers-specific globals (D1Database,R2Bucket,ExecutionContext, etc.). - D1 queries must be awaited; use
.first<T>()for single rows,.all<T>()for result sets,.run()for mutations. - R2 reads return
R2ObjectBody | null— always null-check before calling.text()/.arrayBuffer(). - Do not use Node.js built-ins unless covered by the
nodejs_compat_v2compatibility flag. - Use
c.executionCtx.waitUntil(promise)for fire-and-forget work after the response is sent (e.g. writing the RSS cache).
published_atis write-once:dbPublishPostusesCOALESCE(published_at, CURRENT_TIMESTAMP)so the first-publish date is never overwritten. Never setpublished_atdirectly from user input.- RSS cache: call
invalidateRssCache(env)(exported fromapi.ts) after any publish, unpublish, or delete. The/rssroute readscache/rss.xmlfrom R2 first and falls back to on-demand generation. The cache is also regenerated whenPUT /api/site-configis called, since changingsite_urlaffects all feed links. The cached XML contains fully-qualified URLs (usessite_urlfromsite_config) — no string-patching on serve. - Public sort order: published posts are ordered by
COALESCE(published_at, created_at) DESC. Admin lists useupdated_at DESC. - Duplicate-tag fix:
dbGetPostsByTaguses aWHERE EXISTSsubquery (not a join) to filter by tag, avoidingGROUP_CONCATduplicates. - Orphaned tag cleanup:
dbPruneOrphanedTagsdeletes tags with no remainingpost_tagsassociations. Called viawaitUntilafter tag updates and post deletes — fire-and-forget, non-blocking. - Save Draft safety: the editor's "Save Draft" button does NOT send
statusin the PUT body when editing an existing post. Only "Publish" sendsstatus: 'published'. This prevents accidentally unpublishing live posts. - Slug on create: the API accepts an optional
slugfield inCreatePostBody. If provided (and non-empty), it is slugified and used as the base slug instead of deriving from the title. - Pagination: the public index uses
PAGE_SIZE = 10(exported fromdb.ts).dbGetAllPosts(db, false, page)handles paging. The admin API always fetches all posts (includeAll = true). - Sitemap:
GET /sitemap.xmlgenerates a sitemap for homepage,/about, and all published posts. Usessite_urlfrom config; falls back to request origin. 2-dayCache-Control.
- Add any new shared types to
src/types.ts. - Add D1 query helpers to
src/lib/db.tswith thedb*prefix. - Add R2 helpers to
src/lib/r2.tswith ther2*prefix. - Register routes in the appropriate router (
src/routes/api.tsorsrc/routes/public.ts). - If a new binding is needed, declare it in
Env(src/types.ts) andwrangler.jsonc. - If adding a new D1 schema change, create
migrations/000N_description.sqland update bothdb:migratescripts inpackage.json. - Run
npm run typecheckand confirm zero errors before committing.