Write a Theme
Write a Theme for one Business from a bundled Theme, its core roles, two typefaces and the policies, then render any record under it.
Steps 1 to 7 are the parts of write-a-theme.ts; step 8 adds site.ts beside it.
Prerequisites
-
The quickstart runs in your project.
-
@mdxui/themesat the pinned version:pnpm add @mdxui/themes@7.0.0-alpha.0 -
The Brand's colors, a text typeface and a mono typeface.
Steps
-
Start from the bundled Theme closest to the Brand:
const base = THEMES.quaypoint.theme; -
Set the core roles the Brand changes. Check each text role against the background it sits on: body text needs a contrast ratio of at least 4.5:1.
const light: Record<string, string> = { ...base.light, accent: '#0b7285', 'accent-ink': '#ffffff' }; -
Set the typefaces in the
sansandmonoroles:light.sans = "'Atkinson Hyperlegible Next', system-ui, sans-serif"; light.mono = "'Martian Mono', ui-monospace, monospace"; -
Choose the policies:
const policies = { dividers: { default: 'rule' }, markers: false, featured: 'rule', icons: 'inline' } satisfies Partial<Theme>; -
Redefine for dark only the roles that change:
const dark = { ...base.dark, accent: '#66d9e8', 'accent-ink': '#0b1f24' }; -
Compile it.
compileThemethrows on a missing core role and names each one:const theme: Theme = { ...base, ...policies, name: 'harbor', light, dark, scheme: 'auto' }; const tokens = compileTheme(theme); // throws on a missing core role, naming each one -
Write the
<head>tags that load the typefaces and set the browser color, then export the Theme in the shapeTHEMESholds each bundled one:const head = [ '<meta name="theme-color" content="#f6f7f4">', '<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Atkinson+Hyperlegible+Next&family=Martian+Mono&display=swap">', ].join('\n'); // The shape THEMES holds each bundled Theme in: the Theme, its CSS and its <head> tags. export const harbor: ThemeEntry = { theme, css: () => tokens, head }; -
Render the record under
harbor. Save this assite.ts:renderrunsrenderBlock,composeanddocumentwith your Theme in place of a bundled one, and writes the<head>tags a record'smetaadds.import { baseCss, blocksCss, compose, context, document, esc, primitivesCss, renderBlock, renderPage, shellCss, type Page } from '@mdxui/site'; import { THEMES, type Theme } from '@mdxui/themes'; /** A Theme in the shape `THEMES` holds each bundled one: the Theme, its CSS, and the <head> tags that load its typefaces. */ export interface ThemeEntry { theme: Theme; css: () => string; head: string; } /** One Page under any Theme: the record's own bundled Theme by default, or yours. */ export function render(page: Page, entry: ThemeEntry = THEMES[page.theme]): string { const { theme, css, head } = entry; const sections = page.sections.map((s) => ({ kind: s.kind, shell: s.shell, html: renderBlock(s.block, context(theme, s.shell.surface ?? 'paper', theme.icons)), })); return document({ title: page.title, description: page.description, colorScheme: theme.dark && theme.scheme !== 'light' ? 'light dark' : 'light', jsonLd: page.jsonLd ?? [], head: [head, ...metaTags(page)].join('\n'), css: `${css()}${baseCss}${primitivesCss}${shellCss}${blocksCss}`, body: compose({ frame: page.frame, sections, dividers: theme.dividers }), }); } /** The <head> tags a record's `meta` adds, in the order `renderPage` writes them. */ function metaTags({ meta = {} }: Page): string[] { const tags: string[] = []; if (meta.canonical) tags.push(`<link rel="canonical" href="${esc(meta.canonical)}">`); if (meta.noindex) tags.push('<meta name="robots" content="noindex">'); if (meta.type) tags.push(`<meta property="og:type" content="${meta.type}">`); if (meta.icon) tags.push(`<link rel="icon" href="${esc(meta.icon)}" type="image/svg+xml">`); if (meta.iconFallback) tags.push(`<link rel="alternate icon" href="${esc(meta.iconFallback)}" sizes="32x32">`); if (meta.appleTouchIcon) tags.push(`<link rel="apple-touch-icon" href="${esc(meta.appleTouchIcon)}">`); if (meta.image) { const { url, width, height } = meta.image; tags.push( `<meta property="og:image" content="${esc(url)}">`, `<meta property="og:image:width" content="${width}">`, `<meta property="og:image:height" content="${height}">`, '<meta name="twitter:card" content="summary_large_image">', `<meta name="twitter:image" content="${esc(url)}">`, ); } return tags; } // Under the record's own Theme, `render` returns what `renderPage` returns, byte for byte. export function sameAsRenderPage(page: Page): boolean { return render(page) === renderPage(page); }The record still names
quaypoint, becausethemetakes only the 5 bundled names: a record names the Theme yours starts from, andrenderdraws it under yours.// The record names `quaypoint`, the bundled Theme yours starts from: `theme` takes only the 5 bundled names. const page = PageSchema.parse({ theme: 'quaypoint', title: 'Harbor tides', description: 'Tide tables for every berth.', frame: 'none', sections: [ { kind: 'hero', shell: { inset: 'contained', density: 'hero' }, block: { type: 'hero', layout: 'centered', stack: 560, title: 'Every berth, every tide.', lede: 'One call returns the tide table for any berth.', actions: [], fine: [] } }, { kind: 'band', shell: { inset: 'contained', density: 'section' }, block: { type: 'statement', text: 'A tide table is a *promise*.' } }, { kind: 'section', shell: { inset: 'contained', density: 'section' }, block: { type: 'command-row', command: 'npm i harbor-tides' } }, ], }); await writeFile('harbor-theme.html', render(page, harbor)); -
Ship it. Ship a Site passes
harbortorender, so every record inpages/renders under your Theme.
Result
harbor-theme.html, the record under harbor. Every Section type reads the new roles, and the page carries no CSS of its own.
Under each record's own Theme, render returns what renderPage returns, byte for byte, on 54 of 54 living records.
Next steps
- Ship a Site under
harbor. - Compare it with the bundled Themes.
- Theme the docs in the same roles.
- Try roles over a live render in the playground.