Alpha: Ships on npm at 7.0.0-alpha.07.0.0-alpha.0

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/themes at the pinned version:

    pnpm add @mdxui/themes@7.0.0-alpha.0
  • The Brand's colors, a text typeface and a mono typeface.

Steps

  1. Start from the bundled Theme closest to the Brand:

    const base = THEMES.quaypoint.theme;
  2. 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' };
  3. Set the typefaces in the sans and mono roles:

    light.sans = "'Atkinson Hyperlegible Next', system-ui, sans-serif";
    light.mono = "'Martian Mono', ui-monospace, monospace";
  4. Choose the policies:

    const policies = { dividers: { default: 'rule' }, markers: false, featured: 'rule', icons: 'inline' } satisfies Partial<Theme>;
  5. Redefine for dark only the roles that change:

    const dark = { ...base.dark, accent: '#66d9e8', 'accent-ink': '#0b1f24' };
  6. Compile it. compileTheme throws 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
  7. Write the <head> tags that load the typefaces and set the browser color, then export the Theme in the shape THEMES holds 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 };
  8. Render the record under harbor. Save this as site.ts: render runs renderBlock, compose and document with your Theme in place of a bundled one, and writes the <head> tags a record's meta adds.

    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, because theme takes only the 5 bundled names: a record names the Theme yours starts from, and render draws 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));
  9. Ship it. Ship a Site passes harbor to render, so every record in pages/ 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.

certificateOpen the frame

Under each record's own Theme, render returns what renderPage returns, byte for byte, on 54 of 54 living records.

Next steps

On this page