Live demo: bravada.comfus.io
A port of the Bravada WordPress theme (Cryout Creations) to Astro, powered by EmDash and built on the EmDash blog template. Runs on any Node.js server with SQLite and local file storage.
The port carries Bravada's visual language — Playfair Display headings over Mulish body text, the teal/gold palette, the gold-ribbon wordmark, highlighter title sweeps, ghost section headers, the slow zoom-under-teal image hover, the dark footer — and rebuilds its landing page as Portable Text blocks: bands an editor can reorder or remove without touching code, with reusable groups of them saved as EmDash sections.
Most of the demo imagery — heroes, headers, shop and portfolio photography —
is real CC0/CC BY photography rather than placeholder art, with some
generated CC0 artwork remaining; see CREDITS.md for the full
attribution table. Local images ship with pre-built AVIF/WebP siblings
served through <picture> for the hero, page headers, portfolio, and
project-grid images.
The landing page's composition is content, not code. Its bands are
bravada.* blocks in the admin's editor: drag to reorder them, delete the
ones you don't want, or drop a saved section into any page. Their settings
are not editable there yet — see Blocks. Posts, portfolio,
products, menus, widgets and media all live in the same admin at
/_emdash/admin.
Bravada's page furniture is rebuilt as custom Portable Text block types.
Components live in src/components/blocks/ and are dispatched by
src/components/RichText.astro — use that wherever editor content renders,
and every block type comes with it.
The four that make up Bravada's front page:
| Block type | Bravada original |
|---|---|
bravada.hero |
LP slider / static slider |
bravada.blocks |
LP blocks |
bravada.boxes |
LP boxes (animated) |
bravada.text |
LP text areas |
The rest, with where the demo puts them:
| Block type | Renders | Seeded on |
|---|---|---|
bravada.portfolio |
Portfolio grid band | home, portfolio |
bravada.testimonials |
Testimonial carousel | home |
bravada.team |
Team member grid | about-us |
bravada.accordion |
Collapsible panels | about-us |
bravada.contactform |
Contact form | contact |
bravada.map |
Map band | contact |
bravada.shopgrid |
Product grid | shop |
bravada.postswidget |
Recent posts | sidebar-b |
bravada.products |
Product list | page-sidebar |
bravada.search |
Search box | sidebar-b |
bravada.tabswidget |
Tabbed widget | sidebar-b |
bravada.widgetportfolio |
Portfolio thumbnails | footer |
bravada.projectgrid |
Project grid | used by project archives |
bravada.projectfeatured |
Featured project | used by /portfolio/:slug |
Image fields on the hero and boxes blocks take a plain URL string — a
media-library file URL or an external one — not the { src, alt } object
that entry image fields use.
A block's own settings — a boxes band's variant, columns, accent and
tint, a hero's effect — are edited in the seed or the page's stored
content, not through a form in the admin. The types are not registered with
the editor, so it can reorder and delete bands but has no field UI for them.
Registering them is deliberately on hold: the upstream
blocks field type
would supersede the plugin route, and the Block Kit element surface has
moved twice recently.
A block is one band of content sitting inside a Portable Text field. A
section is a saved, reusable group of blocks that an editor drops into
any Portable Text field with the /section command.
The two are independent, which is worth knowing before you go editing:
the seeded home page holds its own inline blocks, not references to
sections. Editing the hero-slider section will not change the homepage.
The seed ships nine sections — hero-slider, icon-blocks,
icon-blocks-dark, text-band, featured-boxes-static,
featured-boxes-animated, portfolio-grid, testimonials and
footer-portfolio — as ready-made starting points, and several are
variations on the same block type.
The homepage renders the page with slug home full-width above the latest
posts; the seeded one is thirteen blocks across six types. Edit, reorder or
delete bands there like any other content, or delete the page to fall back
to a plain blog front page.
seed/seed.minimal.json— the same structure with no demo content, for starting a clean site (see First run).seed/seed.json— four collections (posts, pages, portfolio, products), five taxonomies (category, tag, project-type, project-tag, product-category), primary/social/mobile menus, sidebar and footer widget areas, nine Bravada sections, and the demo content: ahomelanding page, twelve posts, twelve projects, twenty-three products and the template pages.src/styles/theme.css— the Bravada design tokens and signature styles. All colors uselight-dark(); dark mode is automatic.src/styles/tokens.css— template defaults (don't edit; override in theme.css).
| Page | Route |
|---|---|
| Homepage (landing + latest) | / |
| All posts | /posts (ten per page, ?page=N) |
| Single post | /posts/:slug |
| Category / tag archive | /category/:slug, /tag/:slug |
| Portfolio project | /portfolio/:slug |
| Project type / tag archive | /project-type/:slug, /project-tag/:slug |
| Product | /product/:slug (zoomable gallery: hover lens, click for a magnifier lightbox) |
| Search | /search |
| Static pages (about, contact, shop, …) | /:slug |
RSS lives at /rss.xml; sitemap.xml and robots.txt are served by the
EmDash integration.
One thing to do once, after seeding: open the Home page in the admin and
tick hide from search engines in its SEO panel. The landing page is a
real page entry, so it also has a slug — /home — which this theme redirects
to / the way WordPress redirects a static front page. The sitemap is built
from content rather than routes, though, so without that flag it lists
/home and search engines are handed a URL that only redirects. The flag
removes it from the sitemap and leaves / untouched, because the home route
doesn't read per-entry SEO. There is no seed field for it, which is why it
can't ship pre-set.
The default robots.txt disallows /_emdash/, which is where EmDash's
media proxy serves uploads from (/_emdash/api/media/file/…). Anything
you upload through the admin is served from that path — getMediaUrl in
src/utils/media-url.ts builds those URLs — so it sits behind the
Disallow.
This may not affect you at all. Images shipped in public/, as this
theme's demo images are, come from the site root and the rule never
touches them. On-page images going through Astro's image endpoint, an
external provider such as Cloudflare Images, or a public R2 or CDN origin
are outside /_emdash/ too. Check what your pages actually emit before
changing anything.
The exception is social previews. This theme builds og:image from the
stored path made absolute, with no transform, so a media-library upload
used as a preview image is still served from /_emdash/api/media/file/…
even on a site whose on-page images are all transformed.
If you do need the fix, it goes in the site settings SEO panel (the
seo.robotsTxt field), which EmDash serves verbatim in place of its
default:
User-agent: *
Allow: /
# Uploaded images sit under the admin prefix. Allow them by extension, so
# nothing else served from that path becomes crawlable with them.
Allow: /_emdash/api/media/file/*.avif$
Allow: /_emdash/api/media/file/*.gif$
Allow: /_emdash/api/media/file/*.jpeg$
Allow: /_emdash/api/media/file/*.jpg$
Allow: /_emdash/api/media/file/*.png$
Allow: /_emdash/api/media/file/*.svg$
Allow: /_emdash/api/media/file/*.webp$
# Admin UI, content API and auth routes stay out of the index.
Disallow: /_emdash/
Listing extensions rather than allowing the whole media path matters
because the proxy serves every uploaded file type from that prefix —
a blanket Allow would put PDFs and any other upload into the index
alongside the images.
Google resolves a conflict between Allow and Disallow by the longest
matching path, so these rules win while everything else under /_emdash/
stays blocked. * and $ are widely supported but not universal; a
crawler that ignores them simply falls back to the Disallow, which is the
behaviour you have today. Media URLs carry no query string, so the $
anchor matches. You don't need a Sitemap: line — EmDash appends one when
your text doesn't already name it.
Everything is server-rendered (output: "server"): content lives in SQLite
and pages query it per request, so edits in the admin are live immediately —
no rebuilds.
flowchart LR
V[Visitor] --> P["src/pages/*"] --> Q[EmDash] --> DB[(SQLite)]
P --> B["bravada.* blocks"]
E[Editor] --> A["/_emdash/admin"] --> DB
The theme layer is deliberately thin: routes in src/pages/ query EmDash and
hand Portable Text to RichText.astro, which dispatches the
bravada.* block types; design tokens in src/styles/theme.css restyle the
base template without touching its layout primitives.
Start from a clean copy — either click Use this template on GitHub, or:
npm create astro@latest -- --template vhscom/emdash-theme-bravadaRequires Node 22+ and pnpm (fonts are fetched from Google at build time, so the first build needs network access).
pnpm install
pnpm dev # localhost:4321 — migrations run, but the site starts EMPTYThen visit http://localhost:4321/_emdash/admin — the setup wizard creates
your first admin account and offers "Include sample content", which
applies the full Bravada demo seed for you. Prefer the command line?
npx emdash seed seed/seed.json # same thing, without the wizard checkboxPrefer to skip the demo content? Seed the structure only (collections, taxonomies, menus, widget areas, sections — no posts, shop, or portfolio):
npx emdash seed seed/seed.minimal.jsonTo start over at any point: stop the dev server, rm data.db*, and run
pnpm dev again.
Full-text search, RSS, sitemap/robots, SEO/JSON-LD, comments-ready routes, dark/light mode and the audit-log plugin come from EmDash and the underlying blog template.
-
Site title, tagline, logo live in the CMS, not the code: admin → Settings. The header wordmark, footer, RSS feed, and meta titles all follow.
-
Menus and widgets are admin-editable (Appearance → Menus / Widgets); the seed's
primary,social, andmobilemenus are starting points. -
Colours and fonts: override tokens in
src/styles/theme.css(see the notes at the top of that file); webfonts are configured inastro.config.mjs. Don't editsrc/styles/tokens.css.Every colour is declared with
light-dark(<light>, <dark>), so each token carries both modes and there is no separate dark palette to maintain — overriding with a plain colour changes both at once. The tokens worth knowing:--color-brand/--color-brand-hover/--color-on-brand,--color-bg/--color-surface/--color-text/--color-border,--font-body/--font-heading/--font-display/--font-mono,--content-width(680px article column),--wide-width(1200px), and--sidebar-width(320px).A note before you "fix" the contrast: the palette deliberately reproduces the original Bravada demo — gold
#E9B44Cand teal#0F8B8D— rather than meeting WCAG AA contrast. That's a fidelity decision, not an oversight. Everything else in the theme (focus order, landmarks, keyboard operation, reduced motion) does target AA. -
Post-page furniture: admin → Plugins → Bravada Theme toggles the post author attribution and the docked prev/next buttons (see Theme settings below).
The template builds to a self-hosted Node server:
pnpm build
node ./dist/server/entry.mjs # honours HOST / PORT env varsProduction checklist:
- Set the Site URL (admin → Settings) — canonicals, Open Graph URLs, the sitemap, and the RSS feed all derive absolute URLs from it.
- Generate an encryption key:
npx emdash secrets generateand setEMDASH_ENCRYPTION_KEYin the server environment (encrypts plugin secrets at rest). - Persist
data.db*anduploads/— both live on disk; put them on a volume that survives restarts and back them up together.
The live demo runs from the deploy/cloudflare branch, which swaps the Node
adapter for @astrojs/cloudflare and backs the site with D1, KV, and R2
(wrangler.jsonc). Keep emdash and @emdash-cms/cloudflare on the same
version — a mismatch surfaces at runtime, not at build time.
That branch pins vite and rolldown via overrides in
pnpm-workspace.yaml. Newer rolldown emits a bare require("path") shim for
CJS dependencies that throws at module init under workerd, which a successful
astro build will not catch. Smoke-test the built Worker before deploying:
npx wrangler dev # runs the real Workers runtime locally
npx wrangler deployFor other targets (Postgres, S3 storage) see the EmDash deployment docs.
The demo's database also has D1 read replication turned on, which no
repository can carry for you. session: "auto" is already in the deploy
branch's config, but that alone does nothing until replication is enabled on
the database itself (Cloudflare dashboard → Storage & Databases → D1
→ your database → Settings → Enable Read Replication). It is optional
and currently in beta; skip it unless your readers are far from your
database's region.
On the Cloudflare deploy, rendered pages are cached at the edge and content edits purge the pages that showed them. Two documentation pages are the reference and stay current when this file will not: Deploy to Cloudflare → Workers Cache and Object Cache.
Set this before you deploy, or half of the caching below does not work. In the Cloudflare dashboard, open your domain → Caching → Configuration → Browser Cache TTL, and set it to Respect Existing Headers. The theme tells each page how long a browser may keep it; the zone has its own value for that, defaulting to four hours, and Cloudflare serves whichever of the two is higher. So without this the theme's five minutes becomes four hours on the way out, an edit stays invisible to returning readers for that long, and no purge reaches a browser to fix it. It is easy to miss because it shows only on cached responses — a cache-busted request passes the theme's own value straight through and looks correct.
What this theme does today:
- Pages are cached in front of the site, not inside it. Two tiers sit ahead of the Worker, so a reader is answered from their nearest data centre and the site never runs. Measured on a sister site: a cached page went from roughly 400ms to 95ms for a reader on the other side of the world.
- Pages are held for an hour, and for ten days after that a reader gets
the stored copy immediately while a fresh one is fetched behind them.
Publishing clears the pages an entry appears on, so that window bounds
only the things tags cannot see — site settings, menus, and the theme
itself. Change it in
routeRulesinastro.config.mjs. - No secrets needed. Purging happens from inside the Worker. Earlier
versions used the Cloudflare REST API and needed
CF_ZONE_IDand a cache-purge token; if you set those up for this theme, nothing reads them any more. - Every route you add has to state how it should be cached. A page that
says nothing is not left alone — it is cached anyway, on whatever the host
guesses. Add a rule for it in
routeRules. - To keep a route out of the cache, give it
maxAge: 0inrouteRules./searchis the example. Do not reach forAstro.cache.set(false)in the page: HTML streams, so the footer's own cache hint runs afterwards and clears it, andset(false)also wipes the rule you set. The two together produce a page offered to the cache with no lifetime at all, which is read as roughly two hours — the opposite of what you asked for. A rule survives, because a hint only ever adds tags and never changes the lifetime. - Pages that query content call
Astro.cache.set(cacheHint), which is what lets a publish purge exactly the pages that showed an entry. On the Node build there is no cache provider, so those calls do nothing and cost nothing.
Smoke-test a deploy with a cache buster, then check the plain URL. After
wrangler deploy the plain URL keeps serving the previously cached page for
up to the hour above, which references the /_astro/* filenames of the build
before it — so a fix you just shipped can look unshipped, or the page can
arrive unstyled. Fetch ?bust=<random> to learn the current build's asset
hash, then check the plain URL against it. A client Cache-Control: no-cache
is ignored inside that window, so an instant flush means purging from the
Cloudflare dashboard.
The CLI can talk to a live site, not just a local one, which beats editing the database underneath it — writes go through the site's own API, so revisions and cache invalidation are handled for you. Sign in once:
npx emdash login --url https://your-site.example.comIt prints a code and a URL (/_emdash/admin/device); open that in a
browser, enter the code, and authenticate as you normally would. The token
is saved to ~/.config/emdash/auth.json, so from then on any remote
command just needs --url:
npx emdash content list posts --url https://your-site.example.com
npx emdash schema get products --url https://your-site.example.com
npx emdash types --url https://your-site.example.comTwo things to know before you rely on it:
- Updating content writes a draft, it does not publish. The entry shows
as "published with pending changes" in the admin and the public page keeps
serving the previous version until you follow up with
emdash content publish. Easy to mistake for a caching problem. - The CLI cannot create every field type.
schema add-fieldtakes no sub-fields and no select options, so a repeater or a select has to be built from Content Types in the admin. It can delete such a field, which is a good way to strand yourself — check you can rebuild a field before removing it.
Post-page display toggles live in a template-local plugin
(src/plugins/bravada-theme/) and are edited in the admin:
Plugins → Bravada Theme. Changes apply immediately — no restart, no
seed edits.
- Show post author (default on) — post pages attribute content to the byline in three places: the avatar + name chip in the post hero, the byline in the article meta line, and the author card below the article. For a single-author site where attribution is noise, turn all three off. The hero excerpt (the entry's Excerpt field, which is also the search-engine description) is independent of author display — it keeps rendering, exactly as Bravada treats its excerpt and author-meta options as separate toggles.
- Show prev/next buttons (default on) — the docked buttons that fade
in beside the article on scroll (demo
#nav-fixed). Turning them off leaves the full-bleed previous/next image band above the footer intact.
- EmDash docs — querying content, schema, menus, widgets, plugins, deployment.
- EmDash docs MCP — this repo ships
.mcp.json/.cursor/mcp.json/.vscode/mcp.json, so Claude Code, Cursor, and VS Code can search the EmDash docs while you work. - Astro docs — the underlying framework.
- Bravada — the upstream WordPress theme this port is matched against.
© 2026 vhs. A port of Bravada, © 2020–25 Cryout Creations. Licensed under GPL-3.0-or-later — see LICENSE for the full text and CREDITS.md for attribution details.
Maintained by vhs at Comfusion.

