Step 7: Publish it
One frozen edition, served back through your own render — what freezes, and who can see it.
Your wiki works. Now you want a URL you can send to someone with no account.
No live preview for this step, and it can't have one. Publishing runs your bake in the
publisher's real browser against their real world, then serves the result from a real server
at a real address — three things this sandboxed page has none of. What follows is what the
scaffold already ships working — bake in src/publish-hooks.ts, the baked component and
its two frozen* readers in src/app.tsx — condensed to the load-bearing lines.
Two files, two jobs
Publishing is one frozen edition of the world, served back through your own render. Both halves are code you shipped:
bake(src/publish-hooks.ts) — reads the live world through a reads-only context and returns one immutable payload. It never sees a database;ctxis the only door, and it has no write path.renderBaked(src/app.tsx) — turns that payload into pixels, in a sandboxed iframe, behind a brokered read-only host.
export async function bake(ctx: PublishBakeContext): Promise<PublishedAppContent> {
const [world, docs, mediaUrls, appState] = await Promise.all([
ctx.world(),
ctx.documents(),
ctx.mediaUrls(),
ctx.appState ? ctx.appState() : Promise.resolve(undefined),
])
// Every entry's address, edition-relative — what Step 6's deep links resolve against.
const routes: Record<string, string> = {}
for (const d of docs) routes[d.id] = "/" + encodeURIComponent(d.slug ?? d.id)
// … fill `documents` from ctx.documentContent(d.id), in parallel batches — the
// scaffold ships that loop (and a refs pass) working; see Reference …
const documents: Record<string, unknown> = {}
return { index: docs, routes, mediaUrls, documents, extra: { worldName: world.name, page: appState ?? null } }
}The two frozen* readers below live in the scaffold's src/app.tsx, right next to the
component (the shipped versions defend against malformed input field by field; EMPTY_PAGE
is the all-defaults page state):
/** The frozen Customize values an edition carries (theme + fonts). */
function frozenCustomization(settings: unknown) {
const rec = (settings ?? {}) as Record<string, unknown>
// The site family freezes an envelope ({ v, customization, config });
// older editions froze the bare values map. Read both.
return (rec.customization ?? rec) as AppCustomizationValues
}
/** The frozen page state — what bake put under extra.page. */
function frozenPage(snapshot: unknown) {
const extra = (snapshot as { extra?: { page?: PageState | null } } | null)?.extra
return extra?.page ?? EMPTY_PAGE
}
export function ChronicleBaked({ snapshot, settings, entryPath }: PublishedAppSurfaceProps) {
return (
<WikiPage
editing={false}
values={frozenCustomization(settings)}
page={frozenPage(snapshot)}
actions={null}
entryPath={entryPath}
/>
)
}Because there's no writing, the published half is genuinely simpler than the live one: same
component, editing={false}, actions={null} — which is exactly the shape Step 2 already
built src/app.tsx around.
What actually ships
Not everything in the world reaches the edition, and both filters are authored by the world's owner, not by you:
isViewable === falsenever ships. Step 3's filter keeps your live page honest about it.- Typed cards are gated by their entity type's "site content" setting. Untyped documents — maps, notes, canvases — always ship.
ctx.documents() has already applied both, which is the whole reason Step 3's useWorldQuery
filter and this list agree.
The published host is smaller, on purpose
| Available | Gone |
|---|---|
world · media · refs · nav · embeds | collab · search · presence |
identity (canEdit() always false) | projects · sitePublish · theme · media.pick |
Everything on the right resolves to nothing rather than throwing — useHostTheme() returns
the platform default, media.pick is undefined. Reference
has the full table.
An edition is frozen
Worth saying out loud, because it's the most common surprise: editing the world does not change the published site. The live wiki follows your edits instantly; the published site keeps serving the edition you last published, byte for byte, until you press Publish again. Every publish appends an immutable version — nothing is overwritten, nothing deleted.
Where the button is, and what it isn't
Open the wiki in its world. Publish sits top-right in the wiki chrome. There's no CLI
equivalent, and there can't be a purely server-side one — your bake is third-party code, so
it runs where the publisher is.
Four verbs, and only one of them is this
vvd save uploads a private draft. vvd share points the world at it. vvd publish submits
your template to the Workshop, for other people to install. Publish, in the app, puts
this world's wiki on the public internet — the only one of the four this page is about.
Ship it covers the other three.
Who can see it
Four audiences, enforced at serve time: public (anyone with the address, the default),
unlisted (anyone with <address>/u/<token>), password, and entitled (people granted
access by email).
Recap
- Publishing freezes one edition and serves it through your own
renderBaked— the same component your live wiki already renders, withactions={null}. - Hidden documents and site-content-disabled entity types never ship; filter the live page the same way or the two will disagree.
- An edition is frozen: editing the world doesn't change the published site until you publish again.
- The published host is real but smaller —
collab,searchandthemeare gone; guard for it.