A static site that edits itself
What you get
A static site whose pages are Content documents in its own git repository, and whose authors edit those pages in the built site itself. There is no CMS server, no database and no user table: the repository is the source of truth, a save is a commit, and the commit is the deploy.
Content pages —
/about/— are prerendered fromcontent/about.jsonand ship no editor JavaScript at all.An Editor variant —
/about/edit/— is generated beside each one. It renders the same components inside your own layout, so an author edits the page at the measure, face and ground a reader sees.The Dashboard —
/uncial/— lists, creates and deletes the documents, and manages the site's media and Globals.The whole per-site remainder is a config module, three small route pairs, one JSON allowlist and a ten-line workflow.
Install the packages
pnpm add uncial uncial-cmsuncial is the editor and the SSR renderer; uncial-cms is the forge-backed runtime, the route factories, the Svelte component and the command. Nothing else is needed — in particular there is no monorepo to vendor and no submodule to track.
Declare the site once
A site's CMS configuration is declared in one place and resolved for the current build. Put it in a module with no Svelte and no Kit in its import graph, because both the app and vite.config.ts read it:
// site.options.ts
import type { SiteOptions } from 'uncial-cms';
// Everything under the static dir is copied to the site root at build time, so
// `servedUrl` maps a committed asset path to its served URL by dropping it.
export const STATIC_DIR = 'static';
export const siteOptions: SiteOptions = {
contentDir: 'content', // repo-root-relative, as the forge addresses it
mediaDir: `${STATIC_DIR}/uploads`, // repo-root-relative; must sit under the static dir
github: { repo: 'owner/name', branch: 'main' },
autosaveMs: 400 // honoured only when the resolved forge is local
};// src/lib/site.ts
import { defineSite } from 'uncial-cms';
import { siteOptions } from '../../site.options.js';
export { STATIC_DIR } from '../../site.options.js';
export const site = defineSite(siteOptions);defineSite returns the site object: the resolved config, plus localOnly, autosaveMs and localContentDir. The route factories, the editor component and the Dashboard all take it, so nothing downstream reads the options again.
In a development build the resolved forge is the local checkout.
In a production build it is GitHub when a
githubhalf is declared.Omit
githubentirely and the site is local-only: there is no forge to commit to, and the Editor variants disappear from the production build.
The GitHub half is a repository and a branch. authWorkerUrl and appSlug default to the canonical auth worker and the canonical uncial-cms GitHub App, so most sites never write them down. Self-hosting the worker or running your own App is a matter of setting the two fields.
Wire Vite and Kit
One plugin call installs the development editing endpoint and tells the build which forge it is targeting:
// vite.config.ts
import { sveltekit } from '@sveltejs/kit/vite';
import { defineConfig } from 'vite';
import { uncialCms } from 'uncial-cms/vite';
import { siteOptions } from './site.options.js';
export default defineConfig({
plugins: [...uncialCms(siteOptions), sveltekit()]
});The site is fully prerendered, so adapter-static with no fallback is the whole adapter configuration. BASE_PATH is read here because the deploy workflow sets it for a project site:
// svelte.config.js
import adapter from '@sveltejs/adapter-static';
/** @type {import('@sveltejs/kit').Config} */
export default {
kit: {
adapter: adapter({ pages: 'build', assets: 'build' }),
paths: { base: process.argv.includes('dev') ? '' : (process.env.BASE_PATH ?? '') }
}
};Prerendering and trailing slashes are route options, not adapter options. Declare them once at the root layout so every generated page — content, editor and index — is written as <path>/index.html:
// src/routes/+layout.ts
export const prerender = true;
export const trailingSlash = 'always';The three route pairs
The factories never register a route: you own the files, so the routes stay explicit and debuggable. Each pair is a +page.server.ts that runs at build time and a +page.svelte that renders.
1. The content catch-all. Prerendered, and carrying no CMS code:
// src/routes/[...path]/+page.server.ts
import { createContentHandlers } from 'uncial-cms/sveltekit';
import { blocks, schema, site } from '../site.js';
const handlers = createContentHandlers({ site, blocks, schema });
export const entries = handlers.entries;
export const load = handlers.load;The payload is { document, meta, path } — the page path is returned, so nothing has to re-spread it — and the page renders it with Uncial's SSR Renderer.
2. The editor variant. The server half bakes the mapping only; the document is always fetched live from the forge, never read out of the build:
// src/routes/[...path]/edit/+page.server.ts
import { createEditorHandlers } from 'uncial-cms/sveltekit';
import { blocks, schema, site } from '../../site.js';
const handlers = createEditorHandlers({ site, blocks, schema });
export const entries = handlers.entries;
export const load = handlers.load;Its payload is { sourcePath, pagePath }. The client half puts EditorPage where the Renderer sits, inside the same shell the Content page draws:
<!-- src/routes/[...path]/edit/+page.svelte -->
<script lang="ts">
import { EditorPage } from 'uncial-cms/svelte';
import { blocks, schema, site } from '../../site.js';
let { data } = $props();
</script>
<main>
<article class="uncial-rich-content">
<EditorPage {site} {blocks} {schema} sourcePath={data.sourcePath} pagePath={data.pagePath} />
</article>
</main>EditorPage owns the rest of the editing surface too — the status line and its commit link, the blocking conflict banner, seeding the metadata panel from the loaded document, the Save button under a forge and autosave under the local one. It loads the editor stack dynamically, so nothing about it reaches a Content page.
3. The Dashboard. One custom element, in one route pair:
// src/routes/uncial/+page.server.ts
import { createIndexHandlers } from 'uncial-cms/sveltekit';
import { blocks, schema, site } from '../site.js';
const handlers = createIndexHandlers({ site, blocks, schema });
export const load = handlers.load;<!-- src/routes/uncial/+page.svelte -->
<script lang="ts">
import { onMount } from 'svelte';
import { base } from '$app/paths';
import 'uncial-cms/dashboard';
import { blocks, schema, site } from '../site.js';
let mounted = $state(false);
onMount(() => (mounted = true));
</script>
{#if mounted}
<uncial-dashboard config={site} {blocks} {schema} basePath={base}></uncial-dashboard>
{/if}Pass basePath so the Dashboard's links to live pages resolve under a project site's base path. The path mapping itself always works in site-relative paths, with the base stripped. The Dashboard covers its other options, and its Media, Globals and App sections.
Excluding non-page files, and a schema per path
A content directory usually holds files that are not pages. Both the content and the editor handlers take an exclude predicate, and schema accepts a resolver as readily as a single schema — so an essay can require metadata a landing page does not, without instantiating the factories twice:
const handlers = createContentHandlers({
site,
blocks,
exclude: ({ path }) => path === 'site-settings',
schema: (path) => (path.startsWith('essays/') ? essaySchema : pageSchema)
});Pass the same resolver to EditorPage and to the Content page's renderer, and one page path means one schema everywhere.
Blocks, and an image that uploads
Blocks are ordinary Svelte components registered once and used by both the editor and the renderer. A site's registry and schema live beside its route files:
// src/routes/site.ts
import { createBlockRegistry, createSchema } from 'uncial/core';
import { defineSvelteBlock } from 'uncial/runtime/svelte';
import Callout from '$lib/blocks/Callout.svelte';
import ImageFigure from '$lib/blocks/ImageFigure.svelte';
export { site } from '$lib/site.js';
const callout = defineSvelteBlock({
id: 'callout',
label: 'Callout',
attributes: {
variant: {
default: 'note',
options: [
{ value: 'note', label: 'Note' },
{ value: 'warning', label: 'Warning' },
{ value: 'tip', label: 'Tip' }
]
}
},
component: Callout,
content: { kind: 'flow' }
});
const image = defineSvelteBlock({
id: 'image',
label: 'Image',
attributes: {
src: { default: '', input: 'image' },
alt: { default: '' },
caption: { default: '' }
},
component: ImageFigure
});
export const blocks = createBlockRegistry([callout, image]);
export const schema = createSchema(blocks, {
metaFields: { title: { default: 'Untitled page', required: true } }
});Giving src the setting input: 'image' is all the block needs. The attributes panel shows an image field with Upload and Choose existing buttons, and EditorPage connects it to your repository with cmsImageSource(site.config). Upload commits the file under mediaDir, first shrinking it to WebP if it is too big to commit, so a phone photo just works. Choose existing lists the images already in mediaDir. The block itself only renders its attributes:
<!-- src/lib/blocks/ImageFigure.svelte -->
<script lang="ts">
import { base } from '$app/paths';
import { resolveImageSrc } from 'uncial/render';
let { src = '', alt = '', caption = '' } = $props();
</script>
<figure>
{#if src}<img src={resolveImageSrc(src, base)} {alt} />{/if}
{#if caption}<figcaption>{caption}</figcaption>{/if}
</figure>The stored src is the URL the built site serves the file from, starting at the site root with no base path, so the same content works at any base. resolveImageSrc adds base in front and leaves blob:, data: and full URLs alone. If your site is built under a base path, or its static folder is not static, pass cmsImageSource(site.config, { base, staticDir }) to EditorPage's imageSource prop.
Authorize the editor's origin
Signing in mints a GitHub App installation token scoped to your one repository, with contents read/write and about an hour's life. Three things have to be true before the auth worker will mint it.
Commit the allowlist. The worker reads .uncial/cms.json from the target repository's default branch and refuses any origin it does not name:
{ "allowedOrigins": ["https://example.com", "http://localhost:5173"] }Install the App. Install the uncial-cms GitHub App on the repository, granting it contents read/write there and nowhere else. The Index page links to the install screen for the configured appSlug.
Check the whole of it before the first sign-in.
pnpm exec uncial-cms doctor --origin https://example.comThrough your authenticated gh CLI, doctor checks that the App is installed on the repository, that the allowlist is committed on the default branch and lists your origin, and that Pages is enabled and serving that origin. Each failure names the worker refusal it would have produced and what to do about it, so a missed step is not something you learn from a failed sign-in.
Deploy
Uncial publishes a reusable workflow that does check, unit tests, build, the zero-CMS-JS gate, upload and deploy to Pages. Your own workflow names the trigger, the permissions and the call:
# .github/workflows/pages.yml
name: Pages
on:
push: { branches: [main] }
workflow_dispatch:
permissions: { contents: read, pages: write, id-token: write }
jobs:
site:
uses: d-flood/uncial/.github/workflows/static-site.yml@main
with:
base-path: /${{ github.event.repository.name }}Permissions are the caller's: a reusable workflow inherits the calling job's token scopes, so those three have to be declared in your file. base-path is exported to the build as BASE_PATH — a GitHub Pages project site is served under /<repo>/, and that is where the value above comes from. A user or organization site, or a custom domain, is served at the root; leave base-path out.
The other inputs are seams rather than everyday knobs: package-filter for a monorepo, build-dir, local-only for the gate's other mode, pre-build, and artifact-name for a caller that merges several builds into one Pages artifact. This repository's own docs deploy uses the last two, and is the workflow's live exercise.
The gate the workflow runs is a command the package ships, and you can run it yourself:
pnpm exec uncial-cms assert-clean-pages buildIt walks every index.html in the build, resolves the static import closure of its scripts, and checks for the CMS runtime sentinel: absent from every Content page, present on every Editor variant, ignored on the Dashboard.
The first committed edit
Open an Editor variant of a page you have —
https://example.com/about/edit/.Sign in. A popup runs the auth dance against the worker and returns a repo-scoped token; your OAuth token never reaches the browser. The status line reads Editing content/about.json as <you>.
Edit the page in place. It is the same components and the same layout a reader gets.
Press Save. The document is validated, serialized and committed with the blob sha it was loaded at.
Watch the status line go building… → live as it polls the commit's status. The commit link goes to the commit itself. Your edit is now a line in the repository's history, and the deploy that publishes it is your ordinary deploy.
If someone else committed to that document while you were editing, the sha you hold is stale, the write is refused with a 409, and a blocking conflict banner appears rather than a silent overwrite. It offers three things: download your version as JSON so no work is lost, reload the latest from the forge and start again from it, or dismiss and keep looking at what you wrote. Nothing is written until you choose.
The local-only variant
Some sites are edited only by the person holding the checkout. Omitting the github half is the whole of that configuration:
export const siteOptions: SiteOptions = {
contentDir: 'content',
autosaveMs: 400
};There is no forge, so there is nothing to authorize: no App, no allowlist, no worker, no doctor. Everything else — the route pairs, the blocks, the schema, EditorPage — is unchanged.
Run
pnpm devand open/about/edit/. The local forge reads and writes the checkout through the development-only endpoint the Vite plugin installs.There is no Save button. On a local checkout the file is the document, so
autosaveMswrites it that many milliseconds after the last change and there is nothing a save step would reconcile. On a forge, where every keystroke would be a commit, the same field is ignored.The production build has no Editor variants: the editor routes prerender no entries, and
EditorPagegates its dynamic imports on a build-time literal, so the editor stack is unreachable and Rollup drops it rather than merely leaving it unrouted.
Gate that claim in the build, in the mode that matches:
pnpm exec uncial-cms assert-clean-pages build --local-onlyIn this mode the command asserts that no Editor variant exists at all and that no page's import closure carries the sentinel or the editor stack — a stronger claim than the default mode's, and the right one for a build that should contain no editor to find. The reusable workflow runs it for you when you pass local-only: true.