custom theme
You absolutely can let customers “theme” this renderer—without breaking your current static-publish pipeline—by treating theme as data and layering it on top of your existing CSS variables, Fumadocs UI preset, and the scoped component system you already have.
Below is a pragmatic, staged menu of options (from “simple color picker” to “power-user CSS”) plus how each would plug into your current code paths (renderer + publish), what security constraints to enforce, and what JSON you’d ship to the renderer.
Some random content for deployment - api key mismatch causing trouble!
TL;DR — A layered theming model that fits your stack
Theme tokens (no‑code):
Expose a tiny set of CSS custom properties (your current:roottokens) as a site “Theme” object your web app saves at publish time. The renderer fetches that JSON and injects ablock (or a tiny CSS file) that overrides variables at runtime for both light and dark modes. This solves “change my primary color / background / radius” instantly. Tailwind v4 + Fumadocs both run on CSS variables, so late overrides work well. (Tailwind CSS)“Advanced” site CSS (low‑code, safe):
Allow an optional site‑wide CSS override that’s sanitized + placed in a last‑in@layer userlayer so it wins the cascade without forcing users to fight specificity. (Keep your current per‑component scoped CSS feature for embedded widgets.) (MDN Web Docs)Code theme selection (no‑code):
When publishing, let customers choose Shiki light/dark themes from a list; serialize those into the manifest and use them during your static Shiki step. (You already render dual themes server‑side.) (shiki.style)Optional: Fumadocs color bridge (no‑/low‑code):
Fumadocs v15 exposes color tokens via Tailwind’s@theme. If you adopt the documented variables (e.g.,--color-fd-primary) or map yours to theirs, your runtime overrides can also restyle Fumadocs UI affordances without rebuilds. Provide site‑level choice of Fumadocs preset (neutral/black/dusk/etc.) as an advanced toggle. (Fumadocs)
This gives basic users a one‑click “brand color” and gives power users a safe CSS escape hatch—all without letting arbitrary code or JS run.
Product tiers (what users actually see)
A) Quick “Brand” mode (90% of teams)
Pick primary color (one color input).
Optional: background style (light | dark emphasis), radius, font (couple of curated families).
We generate a full theme:
--primary,--primary-fg, sidebar colors, etc., for both light and dark. Use OKLCH for perceptual scaling +color-mix()for hover/active states. (Both are well‑supported now). (MDN Web Docs)Preview this live on the “Publish” page.
B) Token editor (designers)
Expose the 10–20 variables that matter:
--bg, --fg, --muted, --border,
--primary, --primary-fg,
--sidebar-bg, --sidebar-fg, --sidebar-fg-muted, --sidebar-border
--radius, --dfy-content-pad
Plus dark‑mode counterparts. (This already covers your whole renderer look.)
C) Advanced site CSS (power users, safe)
A textarea that accepts CSS only (no
@import, no@document, noposition: fixed, no browser‑quirk “behavior/expressions”), and which you post‑process with PostCSS to:strip dangerous at‑rules & declarations,
put everything in
@layer user,(optionally) prefix selectors under a site root (e.g.
.dfy-root), if you want to absolutely prevent bleed. (npm)
You already sanitize per‑page custom components with
sanitize-html+ PostCSS prefixing—this mirrors that, but at site scope. (npm)
D) Code highlighting theme picker (devs)
Dropdown of Shiki themes for light and dark (GitHub, Nord, Rose Pine, etc.). Persist this in theme JSON and pass into your Shiki step at publish time. (It’s standard and simple.) (shiki.style)
Data contract — what the static JSON could look like
Publish a small, versioned theme.json next to manifest.json/tree.json:
{
"version": 1,
"light": {
"tokens": {
"--bg": "oklch(1 0 0)",
"--fg": "oklch(0.18 0 0)",
"--muted": "oklch(0.58 0 0)",
"--border": "oklch(0.92 0 0)",
"--primary": "oklch(0.38 0.07 240)",
"--primary-fg": "oklch(0.98 0 0)",
"--sidebar-bg": "oklch(0.985 0 0)",
"--sidebar-fg": "oklch(0.18 0 0)",
"--sidebar-fg-muted": "oklch(0.45 0 0)",
"--sidebar-border": "oklch(0.92 0 0)"
},
"vars": {
"--radius": "10px",
"--dfy-content-pad": "28px"
}
},
"dark": {
"tokens": {
"--bg": "oklch(0.17 0 0)",
"--fg": "oklch(0.97 0 0)",
"--muted": "oklch(0.7 0 0)",
"--border": "oklch(0.28 0 0)",
"--primary": "oklch(0.75 0.12 260)",
"--primary-fg": "oklch(0.14 0 0)",
"--sidebar-bg": "oklch(0.2 0 0)",
"--sidebar-fg": "oklch(0.98 0 0)",
"--sidebar-fg-muted": "oklch(0.77 0 0)",
"--sidebar-border": "oklch(0.3 0 0)"
}
},
"code": { "light": "github-light", "dark": "github-dark" },
"userCss": { "css": "/* sanitized @layer user */ .dfy-article a{ text-decoration:underline }" },
"fdBridge": { "exportFdColors": true }
}
Why add
fdBridge? IfexportFdColorsis true, the renderer can also mirror your primary palette into Fumadocs’ color tokens (e.g.,--color-fd-primary) to keep Fumadocs UI in sync with your brand—Fumadocs encourages using theme variables or swapping their preset. (Fumadocs)
Where it lives:
Versioned at
sites/{siteId}/{buildId}/theme.json.Add
themeUrlto your domain pointer (and mirror it inx-docufy-theme-urlheader like you already do for manifest/tree).
Current pointer:{ buildId, manifestUrl, treeUrl }→ extend to{..., themeUrl }.
Renderer changes (small, surgical)
Fetch the theme alongside manifest/tree
Add a mini loader (mirroring yourloadNavData) that fetchestheme.jsonif present:
// pseudo
const ptr = await getPointer()
const theme = await fetch(ptr.themeUrl).then(r => r.ok ? r.json() : null)
Inject variables + user CSS in RootLayout
MakeRootLayoutasync and, in, output a singleblock:
/* in <style> */
:root { /* light tokens from theme.light.tokens */ }
:root { /* light vars from theme.light.vars */ }
:root.dark { /* dark tokens */ }
/* Optional bridge for Fumadocs colors */
@layer tokens {
:root{
--color-fd-primary: var(--primary);
--color-fd-background: var(--bg);
/* etc, if fdBridge.exportFdColors */
}
}
/* User overrides go in the last layer */
@layer user {
/* theme.userCss.css, sanitized */
}
Define your overall cascade order once in globals.css:
@layer base, tokens, components, overrides, user;
…and keep your house styles in base/components/overrides. The injected block only writes to tokens and user, which guarantees user CSS wins without !important. (This is exactly the problem cascade layers were designed to solve.) (MDN Web Docs)
Keep Fumadocs preset import
Your@import 'fumadocs-ui/css/neutral.css'and@import 'fumadocs-ui/css/preset.css'remain inglobals.css. Those ship class rules and defaults; your tokens can override them because they’re CSS variables (Fumadocs explicitly documents this pattern). If you want to let users pick a Fumadocs preset, just store the preset name intheme.jsonand compile that choice into the renderer’s CSS at build time—or keep a neutral preset and drive color via variables. (Fumadocs)Code blocks
Usetheme.code.light/darkduring your publish-time Shiki rendering. (You already render once withcodeToHtml(..., { themes: { light, dark }}).) This keeps runtime CSS minimal. (shiki.matsu.io)
Publish‑time changes (web service)
Theme builder UI: quick brand color + preview → generates both light/dark palettes (OKLCH), validates contrast, and writes
theme.json.
(OKLCH is ideal for predictable tints/shades;color-mix()handles hover/active without maintaining extra tokens.) (MDN Web Docs)Advanced CSS: write the user’s CSS through a server‑side pipeline:
postcss→removeDangerousAtRules(you already do similar),sanitizeDeclarations,cssnano,prefixSelector(optional scope under.dfy-root), and wrap in@layer user { ... }. (PostCSS)
Shiki choice: persist
code.light/darkand feed into your existingserializeContent(...)where you call Shiki. (Shiki has a stable list of bundled themes.) (shiki.style)Pointer updates: include
themeUrlin the latest.json you already write per domain.
Security & isolation
No JS injection. Stick to CSS variables + CSS in
@layer user. You already forbid@import/@documentand sanitize CSS for component HTML; reuse that policy for the site‑wide CSS, and consider continuing to dropposition: fixedso user CSS can’t overlay the app chrome. (npm)Selector scope (optional): prefix site CSS to
.dfy-rootwithpostcss-prefix-selectorso even aggressive rules won’t affect future UI. (You already scope component CSS withdata-cscope+ prefixing, which is a similar technique.) (npm)CSP: if you deploy strict CSP, either inline styles with a nonce or serve
theme.cssfrom your blob domain and add it to theLinkheaders you already emit in middleware.
Performance & cache
theme.jsonis tiny and cacheable; setimmutableor longmax-ageplus content‑hashing (you’ve already content‑addressed page blobs).Preload via your middleware
Linkheader, same as manifest/tree.The injected CSS is a few hundred bytes—no noticeable TTFB impact.
How this improves UX 100×
For “just make it our brand color”: One input → full, accessible palette for both modes → instant preview → publish.
For “we have a design system”: Directly edit tokens. Optionally map to Fumadocs’ --color-fd-* family so sidebar/buttons match your brand too. (Fumadocs)
For “we need to nudge styles”: Paste small CSS snippets (sanitized, last‑layered). No brittle hacks, no race with the cascade. (MDN Web Docs)
For code‑heavy docs: Pick Shiki themes that match your brand, still rendered ahead‑of‑time. (shiki.matsu.io)
Concrete renderer touch points (no code dump; just where/what)
middleware.ts– addthemeUrlto forwarded headers andLinkpreloads (like manifest/tree).lib/pointer.ts/getPointer()– includethemeUrlif present.app/layout.tsx(RootLayout) – make async; fetch theme; inject::root&:root.darkvariable overrides (tokens + vars),optional Fumadocs color bridge,
@layer user { /* sanitized site CSS */ }.
globals.css– declare cascade order:@layer base, tokens, components, overrides, user;content-kitShiki call – readtheme.code.light/darkduring serialize.
Open choices (each has a safe default)
Where to put the Fumadocs bridge?
Off by default; opt‑in viatheme.fdBridge.exportFdColors. (Keeps tight control when Fumadocs updates internals.) (Fumadocs)Fonts?
Keep curated list + host CSS @font‑face on your blob (avoid Google Fonts runtime fetch). Expose only family/weights intheme.json.Per‑space overrides?
Optional: allow a subset of tokens per space, applied via a[data-space="..."]attribute on.dfy-root. Start with global only.
Why this architecture fits your stack
You already rely on CSS custom properties everywhere; Tailwind v4 and Fumadocs lean into variables as first‑class tokens → runtime overrides are natural. (Tailwind CSS)
Your scoped HTML components pipeline is a proven sanitization path; the site‑wide CSS just needs the same treatment +
@layer. (npm)OKLCH +
color-mix()give robust, perceptual color generation, so a single “primary color” can generate an accessible theme automatically. (MDN Web Docs)Shiki has first‑class dual light/dark and lots of bundled themes → store selection in JSON, render once. (shiki.matsu.io)
If you want a quick first milestone
Add
theme.jsonto your publish job and pointer.RootLayout: fetch
theme.jsonand inject tokens for:root/:root.dark.Optional: accept “Advanced CSS” textarea; sanitize + wrap in
@layer user.Add Shiki theme dropdown to “Publish” page and pass values to your serializer.
That’s enough to deliver a delightful “brand in 30 seconds” while keeping a strong safety boundary. If you’d like, I can sketch the minimal TypeScript types for theme.json and the small RootLayout changes next.
