Fondu

The document

View as Markdown

Every page a browser loads first is an HTML document around the page: <html>, <head>, <body>. Fondu writes a minimal one; createApp({ document }) replaces it with yours.

The default

<!doctype html>
<html lang="en">
  <head>
    <meta charset="utf-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1" />
    <!-- head: the page's <Head> tags, then the styles and scripts -->
  </head>
  <body>
    <!-- body: the page data and the rendered page -->
  </body>
</html>

Your own

document is a function of the parts Fondu renders, returning the HTML:

// app/server.ts
import { createApp } from 'fondu'

export default createApp({
  document: ({ head, body, c }) =>
    `<!doctype html><html lang="en" class="${theme(c)}"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><link rel="icon" href="/favicon.svg" type="image/svg+xml">${head}</head><body>${body}</body></html>`,
})
  • head: the tags of the page's <Head> (title, meta), then its styles and scripts. Put it last in <head>.
  • body: the page data (<script data-page>) and the rendered page (<div id="app">). Put it alone in <body>.
  • c: the request's Hono context: a cookie, a header, the user. Above, theme(c) reads the theme a visitor chose, so the first paint has it.

The page's own tags come from Inertia's <Head>, in the page or the layout; the document is for what every page shares: <html> attributes, the charset and viewport, icons, fonts.

Fonts

Fonts served by the app itself need nothing more: the Content Security Policy allows the site's own files. With Fontsource, a font is a package and an import in the CSS, and Vite serves the files:

/* app/styles/app.css */
@import '@fontsource-variable/inter';

A font from Google Fonts comes from two other origins: the stylesheet from fonts.googleapis.com, the font files from fonts.gstatic.com. Link the stylesheet in the document, and allow both in the policy:

// app/server.ts
export default createApp({
  document: ({ head, body }) =>
    `<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><link rel="preconnect" href="https://fonts.googleapis.com"><link rel="preconnect" href="https://fonts.gstatic.com" crossorigin><link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Inter:wght@400..700&display=swap">${head}</head><body>${body}</body></html>`,
  secureHeaders: {
    contentSecurityPolicy: {
      styleSrc: ["'self'", "'unsafe-inline'", 'https://fonts.googleapis.com'],
      fontSrc: ["'self'", 'data:', 'https://fonts.gstatic.com'],
    },
  },
})

A directive given replaces Fondu's one, so keep what it had: 'self' and 'unsafe-inline' for styles (React's style attributes), 'self' and data: for fonts.

Scripts in the document

The policy runs the document's scripts only with the request's nonce, which Fondu gives to its own. A script of yours in the document takes it from the context:

document: ({ head, body, c }) => {
  const nonce = c.get('secureHeadersNonce')
  return `<!doctype html><html lang="en"><head><script nonce="${nonce}">…</script>${head}</head><body>${body}</body></html>`
}

A static page has no nonce: the same file is served to everyone. Allow an inline script there by its hash instead, 'sha256-…' in scriptSrc, as this site does for the script that sets the theme before the first paint.