Personal site and technical blog. EmDash on Astro, deployed as a Cloudflare Worker with D1 and R2.
One repo, two origins:
seanbehan.ca— name-first, résumé in the navcodebam.ca— handle-first, work leads, no résumé
Same posts. Identity is chosen at build time by PUBLIC_SITE.
- Node.js 26 (see
.node-version) - npm
- A Cloudflare account. EmDash runs plugins in Worker sandboxes, which needs a paid Workers plan; everything else here works on the free one.
git clone https://github.com/codebam/seanbehan.ca.git
cd seanbehan.ca
npm install
npm run devnpm run dev runs emdash dev: it applies any pending migrations, seeds an empty database from seed/seed.json, and starts Astro. The site is at http://localhost:4321 and the admin panel at http://localhost:4321/_emdash/admin — on localhost the dev bypass signs you in without a passkey.
Use npm run dev:codebam for the codebam variant and its private storefront bindings.
The local database is a SQLite file under .wrangler/state, not committed. An empty one is seeded with the schema (posts, pages, tags) but no posts; see Content below for how the real ones got there.
Posts live in D1 and are written in the admin panel. They are not files in this repo — that is the point of the migration this site went through: the words are content, and the repo is the thing that renders them.
seed/seed.json is the schema, not the writing. It describes the collections, their fields and the tag taxonomy, and it is what a fresh database is built from.
Posts that predate the CMS came out of the old src/routes/posts/*.md files via scripts/import-markdown.mjs, which is kept for the record and for anyone repeating the move onto a fresh instance.
npm run build # seanbehan.ca
npm run build:codebam # codebam.canpm run deploy # build seanbehan, wrangler deploy
npm run deploy:codebam # build codebam, deploy as codebam-caBoth Workers read the same D1 database and R2 bucket, so a post published from either admin panel is live on both origins.
First deploy needs the bindings to exist:
npx wrangler d1 create seanbehan-ca
npx wrangler r2 bucket create seanbehan-ca-media
npx wrangler r2 bucket create codebam-product-downloads
npx emdash secrets generate
npx wrangler secret put EMDASH_ENCRYPTION_KEY
npx wrangler secret put EMDASH_ENCRYPTION_KEY --env codebamUse the same generated encryption key for both Worker secret prompts.
The production kit uses Stripe Checkout and a private R2 download. Product, Managed Payments,
webhook, artifact, email, and Worker secret setup is documented in
docs/stripe-checkout.md.
HTML is served with s-maxage so Cloudflare can hold it at the edge, but that only takes effect once a Cache Rule marks HTML cacheable — a zone setting, not a repo one. docs/edge-caching.md has the rule and the deploy-time purge it requires.
Published posts on both origins canonical to seanbehan.ca. Homes stay self-canonical.
The posts used to be files in this repo, so every clone was a backup. They live
in D1 now, and .github/workflows/backup.yml runs nightly to keep that from
meaning "one copy": it exports the database and writes it to the
seanbehan-ca-backups bucket under a dated key, which the bucket expires after
90 days.
npm run backup # writes backup.sql from the deployed databasewrangler d1 export refuses a database containing FTS5 virtual tables, and
EmDash builds one per searchable collection, so scripts/backup-d1.mjs asks the
database for its table list and exports everything that is not virtual. The
search indexes are derived data — after a restore, rebuild each with
INSERT INTO <fts_table>(<fts_table>) VALUES('rebuild').
npm run check # astro check (types across .astro and .ts)
npm run test:run # unit tests over src/lib
npm run smoke # ask a running site for one of everythingnpm run smoke takes a base URL and defaults to http://localhost:4321. It is the check that matters most here: pages assemble on a Worker out of a database, so a page, a feed, a social card and a 404 have to be fetched to know they work.
GitHub Actions runs the formatter check, astro check, the tests and both builds on every push and pull request. .githooks is wired by prepare so commits run Prettier and the type check first.
- Profile image is WebP (13.7 kB), with a 112px
avatar.webp(1.4 kB) for the hero byline - Fonts are self-hosted from
public/fonts, latin-only and axis-trimmed, and preloaded from the layout. Rebuild them withtools/fonts/build-fonts.shafter upgrading either Fontsource package — seetools/fonts/README.md public/fonts/og-*.woffare the static cuts the social-card renderer needs: satori misreads the variable fonts'fvartable, and a card needs one weight of each face- Social cards are drawn per request at
/og/<slug>.pngfrom the title in the database
| Path | What it is |
|---|---|
src/pages |
Routes. Every page is server-rendered. |
src/layouts/Base.astro |
Head metadata, header, footer — the frame every page renders into |
src/lib |
The site's own logic: post adapter, identity, feed, highlighter |
src/components |
Plain Astro templates, including the decorative canvas markup |
src/scripts |
Canvas effects and small delegated client behaviours |
src/middleware.ts |
Security headers and the cache policy |
seed/seed.json |
Schema a fresh database is built from |
scripts/ |
The markdown import, its backfill, and the smoke test |
tools/ |
Font, favicon and Cloudflare cache utilities |