Documentation Menu

Static sites

9 min read

A static site reads Quillet once, at build time, and ships plain files; the SDK's tree, walk and get do the reading, and this documentation site is built exactly that way.

Why many sites can be built ahead of time

Many sites change when someone publishes, not when someone visits. A site like that can be built ahead of time, and then it needs no server holding a key, and it stays up when the CMS is down. The cost is that a publish shows only after the next build. The SDK fits that shape well. One client, created in the build, reads the folders and documents the site needs, and the build turns them into HTML.

If you are a content manager, Publish on a static site makes your change ready, and the next build puts it on the site. Builds can start by hand, on a schedule or from a webhook. Ask your engineers which one your site uses, so you know when to expect your change.

What a build reads, and in what order

The pattern is the same in Astro, Eleventy, a hand-written script or any other generator:

  1. Build a client from the build's environment. createClient from @quilletcms/sdk takes the whole configuration and reads nothing else, which keeps a build reproducible.
  2. Ask one cheap question first, such as list('docs/'). A wrong address, key or project then fails the build with one clear error, rather than halfway through.
  3. Read navigation with tree, every page with walk, and single documents, such as the site settings, with get.
  4. Render the Markdown, and collect the files the content uses.
  5. Download those files into the build's output, so the finished site never links to the CMS.
  6. Write nothing until all of that has succeeded, so a failed read cannot ship half a site.

This site as the worked example

These docs are an Astro site, and every page, guide and string in it is read from a Quillet CMS at build time. Its content loader is src/loaders/quillet.ts, an Astro content-layer loader that fills four collections, site, sections, guides and pages, from one read of the environment. Its client is built in src/lib/cms.ts with createClient. The values come from variables the build checks itself. The locale is fixed to en-US, refs is 3, cache, files and retries take the SDK's DEFAULTS, and a fetch is passed in so the site's tests can answer with a fake CMS. Condensed, the call is:

return createClient({ apiUrl: env.apiUrl, project: env.project, env: env.env, locale: 'en-US',
  key: { id: env.keyId, secret: env.keySecret }, refs: 3, cache: DEFAULTS.cache,
  assets: DEFAULTS.assets, retry: DEFAULTS.retry, fetch: fetchImpl })

The loader then reads in stages. When it fails, the error names the stage it stopped at:

  • The probe stage calls list('docs/'). A QuilletError there becomes "The CMS at … is unreachable", with the status, or with "no response" for a NetworkError.
  • The site stage calls get('_singletons/site'), which holds the site's settings. A NotFoundError is allowed on the dev server, or with QUILLET_ALLOW_MISSING_PAGES=1, and both of those then use empty settings. In any other build it fails.
  • The tree stage calls tree('docs/'), and the result becomes the sidebar: one section per folder, sorted by the order in its _index and then by key.
  • The guides stage calls walk('docs/'). Each guide must be a Markdown document. The site's own Markdown pipeline renders it, adding tabs, callouts and code highlighting, and takes its heading ids from the SDK's headingSlugs, so they match what the CMS's search would point at.
  • The pages stage calls list('pages/'), then get for each landing page. Every inlined file is found with isAsset.
  • The assets stage downloads every file found in a page or a guide into public/media/, and sanitises every SVG among them on the way.

A Markdown image gets a placeholder at render time, and the downloaded file's path replaces it once the downloads have answered. Astro then builds the site. Pagefind indexes the finished HTML for search, a check looks for broken links and headings, and nginx serves the result from a Docker image. The delivery key's secret enters that image's build as a build secret, so no layer holds it. There is no server-side runtime at all.

The CMS this site reads is the Quillet image on Docker Hub, the same image the quick start runs.

How to

This script writes one HTML file per Markdown document under docs/, with every image downloaded beside the pages. The client is built from the five variables and refuses to start if one is missing, and list('docs/') is the probe. A file's extension is known only once its download answers. So the render writes a placeholder for each image, and the last step swaps in the local path, as this site does:

// build/pages.ts: dist/<key>/index.html for every guide, images in dist/media/.
import { mkdir, writeFile } from 'node:fs/promises'
import { dirname, join } from 'node:path'
import { DEFAULTS, createClient } from '@quilletcms/sdk'

const env = (name: string): string => {
  const value = process.env[name]
  if (!value) throw new Error(`Set ${name} before building.`)
  return value
}

const q = createClient({
  apiUrl: env('QUILLET_API_URL'),
  project: env('QUILLET_PROJECT'),
  env: env('QUILLET_ENV'),
  key: { id: env('QUILLET_KEY_ID'), secret: env('QUILLET_KEY_SECRET') },
  refs: DEFAULTS.refs,
  cache: DEFAULTS.cache,
  assets: DEFAULTS.assets,
  retry: DEFAULTS.retry,
})

await q.list('docs/')

const remote = new Map<string, string>()
const pages: { path: string; html: string }[] = []

for await (const { item, document } of q.walk<string>('docs/')) {
  if (document.type !== 'md') continue
  const html = await q.renderMarkdown(document.content, {
    headingIds: true,
    resolveAssetUrl: ({ id, path }) => {
      remote.set(id, `${q.config.apiUrl}${path}`)
      return `/__asset__/${id}`
    },
  })
  pages.push({ path: join('dist', item.key, 'index.html'), html: `<title>${item.title ?? item.key}</title>\n${html}` })
}

const local = new Map<string, string>()
await mkdir(join('dist', 'media'), { recursive: true })
for (const [id, url] of remote) {
  const response = await fetch(url)
  if (!response.ok) throw new Error(`image ${id} answered ${response.status}`)
  const type = response.headers.get('content-type') ?? ''
  const extension = type.startsWith('image/svg') ? 'svg' : (type.split(/[/;]/)[1] ?? 'bin')
  await writeFile(join('dist', 'media', `${id}.${extension}`), new Uint8Array(await response.arrayBuffer()))
  local.set(id, `/media/${id}.${extension}`)
}

for (const page of pages) {
  const html = page.html.replace(/\/__asset__\/([a-z2-7]{26})/g, (_, id: string) => local.get(id) ?? '')
  await mkdir(dirname(page.path), { recursive: true })
  await writeFile(page.path, html)
}
console.log(`${pages.length} pages, ${local.size} images`)

The pages are written last, so a build that fails writes none of them. A Markdown body holds public files only, which is why the downloads need no key.

In Astro, the same reads go in a content loader. Astro runs it during astro sync and astro build:

// src/loaders/guides.ts: one collection entry per published guide.
import type { Loader } from 'astro/loaders'
import { quillet } from '@quilletcms/sdk/node'

export function guides(): Loader {
  return {
    name: 'quillet-guides',
    async load({ store, parseData, logger }) {
      const q = quillet()
      const entries = []
      for await (const { item, document } of q.walk<string>('docs/')) {
        if (document.type !== 'md') continue
        const data = await parseData({ id: item.key, data: { title: item.title ?? item.key, version: item.version } })
        entries.push({ id: item.key, data, html: await q.renderMarkdown(document.content, { headingIds: true }) })
      }
      store.clear()
      for (const { id, data, html } of entries) store.set({ id, data, rendered: { html } })
      logger.info(`${entries.length} guides`)
    },
  }
}

It fills the store only after every read has succeeded.

Rebuild whenever content changes. A scheduled build is the simplest way, and a build started by a Quillet webhook on content.published is the quickest. Integrating a site compares the two.

Limits

  • A publish appears only after the next build. Nothing in a static site notices a publish on its own.
  • There is no draft preview. A preview token lasts fifteen minutes and opens one document's draft, though a listing made with it names every draft in the environment. A static build is neither quick nor made for one reader at a time. Preview needs a route that runs on request; see Previewing drafts.
  • There are no private files. Anyone who has a static file's address can read it, so a build can ship public files only. Serve private ones from a server with the asset proxy.
  • Delivery search needs a request at the moment someone reads. A static site either calls a small server of its own for it or, as this site does, indexes its own built pages with a tool such as Pagefind.
  • Every build reads every document again. The SDK's store lives in the build's memory and starts empty each time.
  • The delivery key is needed only while the build runs. Keep it out of the output and out of any image layer.
  • An SVG copied to your own domain loses the sandbox the CMS serves it under, and a script inside it would run on your domain. Sanitise SVG files as you download them, as this site does, or serve them with a strict Content-Security-Policy.

Last updated