Static sites
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:
- Build a client from the build's environment.
createClientfrom@quilletcms/sdktakes the whole configuration and reads nothing else, which keeps a build reproducible. - 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. - Read navigation with
tree, every page withwalk, and single documents, such as the site settings, withget. - Render the Markdown, and collect the files the content uses.
- Download those files into the build's output, so the finished site never links to the CMS.
- 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/'). AQuilletErrorthere becomes "The CMS at … is unreachable", with the status, or with "no response" for aNetworkError. - The site stage calls
get('_singletons/site'), which holds the site's settings. ANotFoundErroris allowed on the dev server, or withQUILLET_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 theorderin its_indexand 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'sheadingSlugs, so they match what the CMS's search would point at. - The pages stage calls
list('pages/'), thengetfor each landing page. Every inlined file is found withisAsset. - 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.
Related
- Reading content:
tree,walkand the errors a build should stop on. - Variables and Markdown:
resolveAssetUrland heading ids. - Integrating a site: rebuilds, timed revalidation and webhooks.
- Webhooks: starting a build when content is published.
- Run with Docker: the CMS image a build reads from.
- Search: the delivery search a dynamic site would use instead.