Published spaces: a space as a small public website (server)
Server side of published spaces (architecture §7.10). Stacked on #158 (locked notes follow exposure); merge that first.
A space's owner publishes it at /@<handle>/, HEY World–style, with nothing to set up: each note with a title and a body is a post.
The site
- Pages: posts with excerpts, 30 to a page (
/,/page/2/); a page per post (<slug>-<short id>, an old slug redirects301) with its tags and newer/older;/tags/as a tree with counts, a page per tag (nested tags included, on one page: under a tag,page/2/would be a nested tag's address);/feed.xml(Atom, newest 50, absolute links from the app'sorigin);/sitemap.xml;/search. - Rendering: the editor's Markdown via goldmark, raw HTML escaped; body headings step down under the title's
<h1>; app images copied in and sized;#tagslinked by the apps' own scanner (search.TagSpans, the same rules, with positions). - Look: the app icon's neutrals with the space's accent (not its icon), generated and contrast-checked by
scripts/space_icons.py→publish/assets/theme.css. Semantic, shallow markup with contextual classes; light/dark from a tiny script in<head>, system preference without it. Custom CSS (≤ 64 KB) comes after the site's styles, which sit in@layer notesd, so it wins without!important; CSS variables are the main hooks; its@imports are hoisted.
Performance (as discussed)
- Rendered in the background to
data/public/<handle>/and served as files from a manifest (never a path built from the URL, never the database): on publish, at startup, and once a space is quiet 10 s (≤ 2 min). Only changed files are rewritten, atomically. - Cacheable by anyone:
max-age=60, stale-while-revalidate=86400+ ETag/304; images, CSS and script content-named andimmutable; gzipped copies stored; no cookies read or set; CSP allows only the site's own script. - Search, the one dynamic page: FTS5 through 2 read connections of its own per file (
database.OpenReader), at most NumCPU (≥ 2) at once →503 Retry-After, 30/min per visitor →429(the forwarded address when the proxy is on a private address), answers cached a minute (keyed as typed),noindex. Untitled or empty notes in the index are skipped by reading up to 4 batches of 50.
Data, by the two-kind rule
system.dbpublished_spaces(migration 0006):file,space_id,handle(unique),origin,published. Unpublishing keeps the handle; deleting the space frees it, deleting an account frees its own spaces' handles (a shared space keeps its address and goes with the hand-off).- Notes file
space_publish(migration 0014): the custom CSS, carried byShareOut/ShareIn; a newsharing.OnSharehook moves the address to the space's file in the transaction recording the share.
Rules
- Publishing is the
publishedtrait and exposes the space (audience: ["public"]), so locked notes follow #158's rule:409 space_has_lockedto publish,422 locked_in_exposed_spacewhile published. Publishing records its row insideWithoutLocked(which now passes its transaction, so the CSS is written in it too). - The default space can't be published (
409 space_default): it's where a refused locked note is kept. - An account publishes its own spaces; a shared space's owner publishes it (
403 not_space_ownerfor members).
API
GET/PUT/DELETE /api/v1/spaces/{id}/publish ({handle?, origin?, css?} → {handle, url, origin, published, css}); errors not_published (404), handle_taken (409). The trait: "At notes.example/@garden" / "Public at notes.example/@garden", problem "Publishing failed".
Also
userdata.Registryopen/change listeners are lists, read at commit time: the mirror and the publisher both hear every file, including those opened before a feature installed.auth.BeforeRemovegetsapi.RemoveAccount(sharing hand-off + dropping the account's own sites); the users endpoint reloads the publisher at once.- Web app: the service worker leaves
/@to the network (it served the app shell for a site's page to anyone with the app installed). - Docs: architecture §7.10 (new), §9 table, §11.1 layout; API reference; where data lives; backups (skip
public/); server README.
Not in this PR
Web Space info UI (phase 2), custom domains (phase 3), Android/iOS on/off + handle (phase 4). Until phase 2, publishing is API-only; the apps already show the published trait and its lock refusal.
Deviation from the plan: GET /spaces doesn't gain a published: {handle, url} field; the trait carries the address and GET /spaces/{id}/publish the settings, so the list doesn't repeat them.
Testing
mise run lint,typecheck,test(Go-race, web 1125, sync engine JVM + JS) ✅- Unit: rendering (headings, raw HTML, images, tags incl. emphasis/code/links/dot levels), slugs/short ids, excerpts, stylesheet layering and
@importhoisting, the whole site's layout (pagination, post page, tag tree counts, 404 root, feed, gzip), search503, visitor keys,TagSpansoffsets, a line ending in a tag, gzip q-values,Limiter.Takeunder a burst, a deleted space or missing file dropping its address (this hung the publisher before the fix). - API end to end: publish/settings/origin/default; trait; home, redirect, unknown site; images only from the manifest; 304 and gzip ETags; custom CSS; re-render after a change; tag pages; slug redirect after a title change; locked-note refusals both ways; unpublish keeps the handle; handles (generated
-2, taken, invalid, renamed, freed on delete); a published space later shared keeps its site and CSS, member403, member sees the trait; account removal takes its site down; search shows only published notes, finds posts past 50 unshown matches, caches per spelling,noindex, per-visitor429. - Smoke: the real binary with a fresh data dir: publish over the API,
/@garden-notes/served, a new post appears after the quiet period, nothing logged as an error.
Summary by cubic
Builds on the locked-notes exposure change: a space's owner can publish it as a small public website at /@<handle>/ with nothing to set up — every note with a title and a body becomes a post. The published trait exposes the space, so locked notes are refused in both directions, the default space can't be published, and only the owner publishes.
The site and serving
- Posts with excerpts, 30 to a page, plus a page per post (old slugs redirect
301), a tags tree with counts and a page per tag, an Atom feed, a sitemap, and search. - Markdown is rendered server-side with raw HTML escaped, app images copied in, tags linked by the apps' own rules; theme colors come from the space's accent with contrast checks, and custom CSS (≤64 KB) overrides the site's styles through
@layer, with its@imports split by CSS tokens (strings, comments, brackets) so a statement insideurl()isn't cut off. - Sites render in the background to
data/public/<handle>/and are served from a manifest — never from the URL or the database — on publish, at startup, and after 10 s of quiet; only changed files are rewritten, and a render finding the space deleted or its file gone drops the address. - Pages are cacheable by anyone (ETags, gzip when accepted, immutable content-named assets, no cookies); search is the only dynamic page, limited to a few concurrent reads (
503) and 30/min per visitor (429), with answers cached a minute as typed,noindex, and up to four batches read to find posts among notes the site doesn't show.
Data and API
- The address lives in
system.db(published_spaces) and the custom CSS in the space's file (space_publish), carried when the space is shared; the address moves to the space's file in the same transaction. - Adds
GET/PUT/DELETE /api/v1/spaces/{id}/publish; deleting a space or account takes the site down and frees its handle. - The web app's service worker leaves
/@to the network;userdata.Registryopen/change listeners are lists read at commit time so the mirror and publisher hear every file.