@symbo.ls/brender
v3.14.681
Published
Server-side renderer for DOMQL/Symbols apps. Converts DOMQL element trees into static HTML on the server (or at build time). In the browser the app's runtime (`smbls`) either **adopts** that HTML — every element takes the server's node for its place, so n
Maintainers
Keywords
Readme
@symbo.ls/brender
Server-side renderer for DOMQL/Symbols apps. Converts DOMQL element trees into static HTML on the server (or at build time). In the browser the app's runtime (smbls) either adopts that HTML — every element takes the server's node for its place, so nothing is drawn twice — or replaces it with a fresh client render. Which one is the page's choice (adoptDom, below): smbls build makes every page for adoption unless symbols.json says brender: { hydrate: false }.
How it works
Brender has two phases: render (server) and boot (browser).
Render (DOMQL -> HTML)
On the server (or at build time), brender:
- Creates a virtual DOM environment using linkedom
- Runs DOMQL
create()against that virtual DOM — the full component tree resolves, extends merge, props apply, and real DOM nodes are produced - Walks every element node and stamps a
data-br="br-N"attribute (sequential key) - Walks the DOMQL element tree and records which
data-brkey belongs to which DOMQL element (__ref.__brKey) - Returns the HTML string, a registry (
{br-key: domqlElement}), and the element tree
DOMQL Source Virtual DOM Output
{ tag: 'div', <div> <div data-br="br-1">
Title: { <h1>Hello</h1> <h1 data-br="br-2">Hello</h1>
tag: 'h1', <p>World</p> <p data-br="br-3">World</p>
text: 'Hello' </div> </div>
},
Body: { + registry:
tag: 'p', br-1 -> root element
text: 'World' br-2 -> Title element
} br-3 -> Body element
}Boot (HTML -> DOMQL), in smbls
In the browser, when the app JS loads (smbls src/prerender.js):
- The pre-rendered HTML is already in the DOM — the visitor sees the page at once
- The runtime sees the page's mark (
<meta name="brender">, below) and reads the element-path registry - With a registry (an adoption page): the app's normal
create()runs, but each element takes the server's node at its path — its attributes and text are written again by the client, its element children are adopted the same way. A node of another tag is not adopted (that element gets a fresh one) - The adoption stays open until the first view has settled: the first route rendered (also behind a guard that waits), and no
@symbo.ls/fetchrequest in flight — so a list that renders with its first data adopts the server's rows. Meanwhile the server nodes still waiting for their element stay on screen, where they were. It closes at the latest after 3 s (hydrate: { timeout }in the project's config), or at the visitor's first click or key press; then whatever no element adopted goes, with the server's<style>tags - Without a registry (a replace page, or
hydrate: falsein the project's config): the server's markup and styles go and the app renders fresh - Either way the recorded data answers (
data-fetch-seed, below) are adopted by the requests that ask for them, so the client does not ask again for what the server had, andel.context.prerenderedistrue. An element whose query the render ran — at its create, or later (a state change, anenabledthat turned true) — takes that query's recorded answer at its init, so it is taken over with the server's data and never shows its loading look in between; a gate that reads that data (a part busy until its rows are in) needs no special case for the takeover
Browser DOM (static) DOMQL Tree (no nodes) After hydrate()
<div data-br="br-1"> root { root.node = <div>
<h1 data-br="br-2">Hello</h1> __ref: {__brKey: 'br-1'} Title.node = <h1>
<p data-br="br-3">World</p> Title: { Body.node = <p>
</div> __ref: {__brKey: 'br-2'}
} domNode.ref = element
collectBrNodes() Body: { (bidirectional link)
{br-1: div, __ref: {__brKey: 'br-3'}
br-2: h1, }
br-3: p} }Files
| File | Purpose |
|------|---------|
| render.js | render() — full project render via smbls pipeline; renderElement() — single component; renderPage() — complete HTML page |
| hydrate.js | collectBrNodes() — scans DOM for data-br nodes; hydrate() — reconnects DOMQL tree to DOM |
| env.js | createEnv() — linkedom virtual DOM with browser API stubs (requestAnimationFrame, history, location, etc.) |
| keys.js | resetKeys(), assignKeys() — stamps data-br on DOM nodes; mapKeysToElements() — builds registry |
| metadata.js | Re-exports from @symbo.ls/helmet — extractMetadata(), generateHeadHtml(), resolveMetadata(), applyMetadata() |
| prefetch.js | fetchSSRTranslations() — a project's table/RPC labels before a render; prefetchPageData() / injectPrefetchedState() — deprecated: a page's fetch data outside a render (render() does not use them) |
| load.js | loadProject() — imports a symbols/ directory; loadAndRenderAll() — renders every route |
| sitemap.js | generateSitemap() — generates sitemap.xml from routes |
| index.js | Re-exports everything |
API
renderElement(elementDef, options?)
Renders a single DOMQL element definition to HTML. Uses @domql/element create directly — no full smbls bootstrap needed.
import { renderElement } from '@symbo.ls/brender'
const result = await renderElement(
{ tag: 'div', text: 'Hello', Child: { tag: 'span', text: 'World' } }
)
// result.html -> '<div data-br="br-1">Hello<span data-br="br-2">World</span></div>'
// result.registry -> { 'br-1': rootElement, 'br-2': childElement }
// result.element -> the DOMQL element treeWith components and designSystem context:
const result = await renderElement(pageDef, {
context: {
components: { Nav, Footer, HeroSection },
designSystem: { color, font, spacing },
state: { user: null },
functions: { initApp },
methods: { navigate }
}
})render(data, options?)
Renders a full Symbols project. Requires the smbls pipeline (createDomqlElement) — handles routing, state, designSystem initialization, the full app context. Uses esbuild to bundle the smbls source tree (cached after first call).
import { render, loadProject } from '@symbo.ls/brender'
const data = await loadProject('/path/to/project')
const result = await render(data, { route: '/about' })
// result.html -> full page HTML with data-br keys
// result.metadata -> { title, description, og:image, ... }
// result.css -> array of CSS rule strings from the atomic CSS engine
// result.registry -> { br-key: domqlElement }
// result.element -> root DOMQL elementOne render at a time. A render puts its environment on the process's bare globals (document, location, localStorage, sessionStorage, window) because the framework reads them bare, so render() — and renderRoute() / renderPage(), which call it — runs one render at a time per process: a call made while another render runs waits until that one has ended (returned, thrown or timed out), then starts. Calls made one after another (smbls build, smbls brender) do not wait. N renders started together take as long as N renders one after another.
timeout (default 30000 ms; 0 or false: none — then a render that never ends holds every later one) is how long a render may run once its turn starts — the wait for earlier renders does not count, and neither does building the process's framework bundle on its first render. A render past it is abandoned: the call rejects with an error whose code is BRENDER_RENDER_TIMEOUT and whose message, route and pathname name the page; the bare globals it put in place go back; the next render starts. When the wait it hung on ends later, its own remaining steps stop: it never puts its globals back over a later render's, and never makes an environment of its own after its time. What it cannot stop: framework or page code of the abandoned render that resumes with that wait reads and writes the bare globals of whatever render runs then, and process-wide framework state (the active root state behind getRootState() without this). Until it has unwound, a render that runs meanwhile carries no fetch seed and diagnostics: null: its recording could hold the abandoned render's requests.
renderPage(data, route, options?)
Renders a complete, ready-to-serve HTML page. Combines render() output with metadata, CSS (atomic + global), fonts, and optional ISR client bundle.
import { renderPage, loadProject } from '@symbo.ls/brender'
const data = await loadProject('/path/to/project')
const result = await renderPage(data, '/about', {
lang: 'ka',
prefetch: true // fetch table/RPC labels first, wait for the page's requests
})
// result.html -> complete <!DOCTYPE html> page
// result.route -> '/about'
// result.brKeyCount -> number of data-br keys
// result.diagnostics -> { failedRequests, rawLabelKeys } (see below)Options:
| Option | Default | What it does |
|--------|---------|--------------|
| pathname | the route | The address the page is rendered at. The router matches it, and location (canonical, og:url) reads it. A route with parameters needs one (renderPage(data, '/:lang/about', { pathname: '/en/about' })); the not-found page /* is rendered at /404 by smbls build. |
| search | — | The query of the address the page is rendered at ('?view=agent'; the ? is added when missing). The render's location.search and location.href carry it, as a browser's do at that address, so a page that reads its query (an agent view its app shows at init) renders that view; the head is the page's own. render() and renderRoute() take it too. '', '?', null or none: no query, and the render is the same as before, byte for byte. A value that is not a string, or that holds a #, throws a TypeError that names the option. |
| shell | — | A built app document (e.g. dist/index.html) to render INTO. |
| preloadScripts | true | With shell: a high-priority modulepreload (preload as=script for a classic script) in <head> for each script the shell boots from, so the pre-rendered images do not take its bandwidth. async and nomodule scripts are left alone. |
| eagerImages | 2 | Images past the first eagerImages in the markup get loading="lazy", unless they name their own loading. false turns it off. |
| lang | the page's | <html lang>. With shell and a project with languages, the page's language (the root state's lang). |
| prefetch | true | Fetch table/RPC labels before the render and wait for the page's requests (up to 2 s). false fetches no labels first and waits only a 50 ms quiet period. Either way the page's data comes from its fetch declarations during the render. |
| timeout | 30000 | Milliseconds the render may run once its turn starts; past it the render is abandoned and the call rejects (BRENDER_RENDER_TIMEOUT). 0 or false: no limit. See render(). |
| depth | 0 | Directory depth of the output file: rebases the shell's relative script/link URLs |
| adoptDom | false | Ship the element-path registry, so the client adopts the markup instead of replacing it (smbls build passes it unless brender: { hydrate: false }) |
| isr | — | { clientScript }: a standalone document that boots this client bundle |
| hydrate | true | With isr: boot the client over the markup (false: the legacy swap script) |
| stamp | the render time (ISO) | The build's data stamp, written into <meta name="brender-stamp"> — a string or a number |
| time | now | The build time in ms, written as its data-time. A build passes one stamp and time to every page |
| nonce | — | Content-Security-Policy nonce for the inline scripts the isr document still has (the label seed, the legacy swap script) — a page rendered into a shell has none |
| themeColor | | See render.js. |
| lazyGroups | — | A lazy route (@symbo.ls/router "Lazy Routes") built as its group's REAL page, not its placeholder: 'load' for every group, or an array of group names. The render's first navigation loads the group, then renders the route; the group's when is asked as in the browser, and a when that says false (or a load that fails) builds the placeholder. Unset, the render does not wait for a group: the route shows the group's page when the group loaded during the render (its loader settled in time — a context that declares lazy), else its placeholder. Either way the page names the groups whose page it shows (<meta name="brender-lazy">, result.lazyGroups); the browser's first navigation loads them before it adopts the page. smbls build reads it from symbols.json brender.lazyGroups and preloads each group's chunk on those pages. |
| paint | — | { whole: true }: the first paint waits until the page's markup is in — <link rel="expect" href="#smbls-whole" blocking="render"> right after the charset declaration, and an empty <template id="smbls-whole"> right after the markup (before the data blocks and the app's scripts). No script; the browser also stops the wait when it finishes parsing a document without the element. smbls build passes it with symbols.json brender.paint |
Data and labels. A page's declarative fetch runs during the render, through the fetch plugin, with the real element: its transform(data, el, state), and children re-run on the answer. The render waits for the requests it starts. Labels: a table/RPC polyglot.fetch ({ rpc }) is fetched before the render, for every language; a FUNCTION polyglot.fetch is the project's own loader and runs inside the render, called by the polyglot plugin with the root element. Labels resolve in the page's language — the root state's lang (a router guard that reads /en/… sets it), else polyglot.defaultLang.
The address's language. A page at an address whose first segment is one of the project's languages (/en/about with polyglot.languages: ['ka', 'en']) starts in that language: before the app starts, the render stores it in its own environment's storage under polyglot's key (storageLangKey, default smbls_lang) — where a site's pre-boot script puts it in the browser — so polyglot starts in it and the shell's first requests (a header's menu, created before any router guard runs) are made, and recorded in the fetch seed, in the page's language. The seed is keyed by address and language: the browser adopts those answers only if it starts in the same language, so a site with language-prefixed addresses stores the address's language before its app starts (a pre-boot script that runs before the app's own). An address with no language segment starts as before: the stored choice, else the root state's lang, else polyglot.defaultLang. A standalone page with an ISR client (isr) starts its client itself: for a visitor with no language stored yet, its start-up script stores the page's language (it stored polyglot.defaultLang), so that client starts where the server started.
Diagnostics. result.diagnostics names what a page was made without: failedRequests ({ url, reason }, origin and path only — a query can carry a key) and rawLabelKeys (label keys the page shows raw). null when another render overlapped this one in the same process (since renders run one at a time, only a render abandoned at its timeout and still unwinding can). smbls build prints them; smbls build --strict fails on them.
Every render is the same render. Each render processes the design system on a config of its own, the way the page's single boot does (context.isolateDesignSystem), with its own localStorage/sessionStorage, so the tenth page of a build has the theme variables, language and labels of the first. Each render also has a @symbo.ls/fetch query cache of its own (context.isolateFetchCache): a page makes every request its markup depends on itself, so its fetch seed carries every answer it shows, even when an earlier page of the build read the same key; and renders in one process — one after another or at once, of one project or of several — never share rows, requests in flight or invalidations. Its queries bind no window focus, reconnect or refetchInterval refetch: once its markup is taken, a render's queries leave no listener, no timer and no request behind. A process builds the framework bundle once, also when its first renders start together.
App code knows it runs on the server. Every render sets el.context.ssr to true (render, renderRoute, renderPage, renderElement); no payload key and no context override turns it off. The server document lays nothing out and observes nothing: no getComputedStyle, no ResizeObserver or IntersectionObserver, no scroll. Code that measures, observes or plays an entrance renders the final state there — test el.context.ssr === true before it hides anything (an entrance that sets opacity: 0 and waits for an observer stores a blank block in the page). A browser has no ssr key; a client that boots over a pre-rendered page has el.context.prerendered === true instead (step 6 above).
Page-weight trims (pageWeight.js)
renderPage options spriteIcons: 'page', cssClasses: 'page' and cssVariables: 'page' keep only what the page paints before the client boots: the icons its markup uses, the atomic rules of its classes, the CSS variables its CSS and markup read (each defaults to 'all'; smbls build turns the first two on). The client adds the rest as it boots. The four trims behind them are exported for a tool that weighs a page of its own — the CLI's publish pre-render (@symbo.ls/cli helpers/ssrCache.js) stores each route's own icons with them — with stylesheetRules, which reads a stored stylesheet text back into the rules classRulesForPage takes (the platform's renderer trims a stored route's CSS with them as it serves the route):
| Export | Returns |
|--------|---------|
| trimSpriteToPage(html, context?) | { html, kept, dropped }: html with its <svg id="svgSprite"> holding only the symbols the rest of html (and context) references, and the symbols those reference. Text and comments between the symbols stay in place and never stop the trim (a library icon whose value is not an <svg> leaves a stray <> there); a sprite that holds an element other than a symbol is left whole (kept: 0, dropped: 0). |
| markupClasses(...texts) | a Set of the class names in the texts' class attributes |
| classRulesForPage(rules, classes) | the rules (cssText) that name one of classes, and every rule that names no class (an @font-face), in their order |
| trimVariablesToPage(rootCss, ...readers) | { css, kept, dropped }: rootCss with only the custom properties the readers read through var(--…), and the ones those read |
| stylesheetRules(css) | the rules of a stylesheet text, each as its own cssText, in order — whatever the layout (one rule per line, as a stored pre-render keeps them, or several per line); strings, comments, escapes and parentheses are skipped, a comment between rules is dropped. null for a text it cannot read whole (an unclosed block, string, comment or parenthesis): keep that text as it is. Never pass a string to classRulesForPage — it reads it a character at a time |
The page the browser gets (the client's contract)
No inline script — a strict Content-Security-Policy (script-src 'self', no 'unsafe-inline') blocks inline scripts, never <meta> tags or JSON data blocks:
| Part | Where | Meaning |
|------|-------|---------|
| <meta name="brender" content="hydrate\|replace"> | <head> | The page is pre-rendered; hydrate when it carries the registry |
| data-br="br-N" | each server element node | The node's key |
| <script type="application/json" data-brender="registry">{…}</script> | after the markup | Element path → data-br key (__root for the app's root); adoption pages only |
| <script type="application/json" data-fetch-seed>[…]</script> | after the markup | The anonymous GET answers the render got (@symbo.ls/fetch), adopted by the client request that sends exactly the same request |
| <meta name="brender-stamp" content="<stamp>" data-time="<ms>"> | <head>, after the mark | The build the page comes from: stamp (the stamp option, else the render time as an ISO string) and the render time in ms. App code reads it as el.context.prerender ({ stamp, time }) |
| <link rel="expect" href="#smbls-whole" blocking="render"> + <template id="smbls-whole"> | after the charset declaration; after the markup | paint: { whole: true } only: the first paint waits for the whole markup |
| <style data-smbls-faces data-smbls-faces-version="…" data-smbls-faces-files="…"> | <head>, ahead of the page's styles | Every @font-face rule of the page, in one sheet, with two marks for a host (below). Only when the page has faces |
The faces sheet and its marks. A host that serves a returning reader the faces it keeps by address, and the rest inline, reads two marks on the sheet; brender writes no other faces file:
| Mark | Value |
|------|-------|
| data-smbls-faces-version | A content hash of the sheet's rules (12 hex characters): equal rules in the same order give the same version on every page and every render; any change to a rule (a file, a range, a font-display), a rule more or less, or another order gives a new one. |
| data-smbls-faces-files | Per @font-face rule, in rule order, its file address: the first url() of its src — a local() before it is skipped, a later url() (another format) is not listed — as the rule names it. - for a rule that names no file (only local(), or a data: URL). Space-separated; a space in an address is written %20. |
The marks describe the faces by address. smbls build marks each page again after its file: URLs point into the build output, so the version follows the files the page ships (every page of a build carries the same one when the font URLs are site-root or absolute; a relative URL differs by the page's directory, and so does the version). Its brender.faces.inline step moves files into the sheet as data: URLs and keeps both marks as they were: an inlined face still names its file. A tool that changes the addresses in a page itself calls markFacesSheet(html) (exported): it writes both marks again from the rules the sheet holds, leaves the sheet's text and its other attributes as they are, and returns a page without a faces sheet unchanged. facesSheetMarks(rules) returns { version, files } for a sheet's text or its rules (null without a @font-face).
The faces a page draws. drawnFaces(html, { readSheet }) (exported, also as @symbo.ls/brender/drawnFaces) reads which faces of the sheet the page's elements draw, as the browser picks them: each element's family, weight and style from the cascade (!important, specificity, order, inheritance, var(), the font shorthand, the browser's own rules for headings, emphasis and form controls), each character of its text against the faces' unicode-range. Rules under @media / @supports / @container / @layer and for states (:hover, …) count as possibilities. It returns a Map from a @font-face rule's index in the sheet to the characters drawn in it, or null when a stylesheet of the page cannot be read: readSheet(href, from) returns the text of a <link rel="stylesheet"> file or an @import (from: the address of the sheet that imports it), or null. A <link data-smbls-font> (a font service's @font-face sheet) is not read. smbls build uses it for brender.faces.inline: true and window.__smblsFacesReady (CLI README, "Which faces a page draws").
The runtime also reads what pages from brender up to 3.14.664 carry — the inline window.__BRENDER__ flag, window.__BR_REGISTRY__, window.__BRENDER_HYDRATE__ — and, when a policy blocked that flag, takes a data-br element directly in the mount node for the mark. An smbls runtime older than this format does not know the <meta>: keep smbls and @symbo.ls/brender at the same release.
The data is the build's. A pre-rendered page shows the answers its render got until it is rendered again — a smbls build page until the next build. A page flagged stale (<html data-smbls-stale> from the host, or the project's fetch.seed.isStale(stamp) / fetch.seed.maxAge) shows them at once and then, after the adoption, asks again and swaps in what changed; fetch.seed: false keeps the answers out of the page. See @symbo.ls/fetch, "A pre-built page shows the data of its build".
What is recorded for the seed: a GET whose only headers are Accept / Accept-Language — so an anonymous read of a keyed backend counts when fetch.reads moves its key into the query string (reads: { query: { apikey: 'apikey' }, rpc: 'get' }, read-only RPCs as GETs) — with an ok JSON or text answer, at most 256 KB each and 1 MB per page. Never a POST, a call with Authorization or another header, or anything from a render that overlapped another.
prefetchPageData(data, route, options?) (deprecated)
Fetches data for a page's declarative fetch definitions OUTSIDE a render, using the project's DB adapter. render() does not use it, and neither does the prefetch option: the fetch plugin loads a page's data during the render. A transform here receives no element. Deprecated with injectPrefetchedState; both stay exported for now.
import { prefetchPageData } from '@symbo.ls/brender'
const stateUpdates = await prefetchPageData(data, '/blog')
// stateUpdates -> { articles: [...], events: [...] }hydrate(element, options?)
Client-side. Reconnects a DOMQL element tree to existing DOM nodes via data-br keys.
import { collectBrNodes, hydrate } from '@symbo.ls/brender/hydrate'
// After DOMQL creates the element tree (without DOM nodes):
const hydrated = hydrate(elementTree, { root: document.body })
// Now every element.node points to the real DOM node
// and every domNode.ref points back to the DOMQL elementloadProject(path, options?)
options.dir — the symbols directory (symbols.json dir, default symbols). options.context — a module, relative to path, whose default export is the context the build's entry boots with ({ state, components, pages, functions, designSystem, …, ...config }, as symbols/context.js); everything but app.js then comes from it, so a public entry with fewer pages (no admin) is pre-rendered with exactly those pages.
new URL('…', import.meta.url) in a project file resolves against THAT file (a file: URL of the source asset); smbls build maps it to the built asset.
Without context, imports a standard symbols/ directory structure:
project/
symbols/
app.js -> data.app
state.js -> data.state
config.js -> data.config
dependencies.js -> data.dependencies
components/
index.js -> data.components
pages/
index.js -> data.pages
designSystem/
index.js -> data.designSystem
functions/
index.js -> data.functions
methods/
index.js -> data.methods
snippets/
index.js -> data.snippets
files/
index.js -> data.filescreateEnv(html?)
Creates a linkedom virtual DOM environment with stubs for browser APIs that DOMQL expects:
window.requestAnimationFrame/cancelAnimationFramewindow.history(pushState, replaceState)window.location(pathname, search, hash, origin)window.URL,window.scrollToglobalThis.Node,globalThis.HTMLElement,globalThis.Window(for instanceof checks in @domql/utils)
generateHeadHtml(metadata)
Converts a metadata object into HTML head tags. Provided by @symbo.ls/helmet:
generateHeadHtml({ title: 'My Page', description: 'About', 'og:image': '/img.png' })
// -> '<meta charset="UTF-8">\n<title>My Page</title>\n<meta name="description" content="About">\n<meta property="og:image" content="/img.png">'Metadata values can also be functions — see the helmet plugin for details.
CLI
The smbls brender command renders all static routes for a project:
# Basic usage — renders all static routes to dist-brender/
smbls brender
# Custom output directory
smbls brender --out-dir build
# Disable ISR client bundle
smbls brender --no-isr
# Fetch no labels first and do not wait for each page's requests
smbls brender --no-prefetch
# Fail when a page renders with failed requests or raw label keys
smbls brender --strict
# Watch mode — re-renders on file changes
smbls brender --watchOutput directory defaults to dist-brender (or brenderDistDir from symbols.json) to avoid conflicting with the SPA's dist/ folder.
Param routes (e.g. /blog/:id) are skipped unless brender.paths lists addresses for them.
smbls brender and smbls build share one route loop: the same brender settings from symbols.json (context, paths, exclude, strict, eagerImages, timeout, stamp), file: URLs resolved into the output (a file: URL that names no project file fails its route), and the same diagnostics. smbls brender writes standalone pages in a directory layout (/about → about/index.html), each with the CSS of every page it rendered.
smbls build
With brender on (the default), smbls build pre-renders every static route INTO the bundler's dist/index.html:
/about→about.html,/→index.html,/*→404.htmlrendered at/404. Two routes that end in one file (/404and/*) are written once — the first in page order — and the other is reported.- The bundler's own document is kept as
200.html— serve it for client-side routes, neverindex.html, which is now the home page (see below). A200.htmlalready in the output (public/) is not replaced. - Every
file:URL in a page (anew URL('…', import.meta.url)asset) is mapped to the file the bundler emitted from the same source (found by content), or copied into the output under a content hash. Afile:URL that names no project file fails the build. - A page made with failed data or label requests, or with raw label keys, is reported;
smbls build --strict(orbrender.strict) fails the build on it. - Every page carries the data of the build time until the next build, and says which build that is: one stamp per build (
<meta name="brender-stamp">).brender.stampinsymbols.json(a string or a number), elsebrender.stampin the project'sconfig.js— a value, or a function({ time, data }) => stamprun once per build (it may be async: the data's own version, such as the time of its latest change) — else the build time. A stamp function that throws fails the build. How a page learns that its data moved on:@symbo.ls/fetch, "A pre-built page shows the data of its build". - Opt-in, per page:
brender.onProblem('skip'leaves out only a page with a failed request or a raw label key),brender.report(a JSON report of every page),brender.pathsas a function,brender.paint: { whole: true },brender.faces: { inline, wait }(faces inline, the page unseen until they are in),brender.app: 'afterFirstView'(the app's files after the first view) andbrender.beforeWrite(html, page); functions live in a build-onlybrender.module. See the CLI README, "Built-page options".
Routes without their own page: 200.html
Serve 200.html, never another page, for a route without its own page — a route with parameters that brender.paths does not list (/campaigns/:id), a page with prerender: false, an excluded or failed one. index.html is now the HOME page: a host's single-page fallback that serves it shows the home page first, then the client throws its nodes away and draws the right page (XMA: removed: 439 on a campaign page). A host's 404 handling serves the pre-rendered not-found page, with a 404 status, under the right page's client. smbls build writes the rules into <outDir>/_redirects (after the project's own rules from public/; brender.redirects: false writes none). A pre-rendered address that a route pattern also matches gets a rule to itself first — Cloudflare follows a rule even where a file matches. The rule per host:
| Host | The rule | Written by |
|------|----------|------------|
| Cloudflare Workers (static assets) | _redirects in the output: each route without its own page → /200 with status 200; assets.not_found_handling: "404-page" with a 404 page, else "none" — never "single-page-application", which serves index.html | smbls build (_redirects), smbls deploy --target workers (wrangler.jsonc) |
| Cloudflare Pages | the same _redirects; every other address gets 404.html. Without a 404.html, Pages falls back to index.html: give the project a /* page | smbls build |
| Netlify | the same _redirects (a file always wins over a rewrite there) | smbls build |
| Vercel | vercel.json: "cleanUrls": true and "rewrites": [{ "source": "/(.*)", "destination": "/200" }] — Vercel checks the filesystem first, so every pre-rendered page keeps its own file; an unknown address gets the app's not-found page with status 200 | smbls deploy --provider vercel |
| GitHub Pages | no rewrites: an address with no file gets 404.html (status 404). Without a 404 page of the project's own, copy 200.html to 404.html; better, pre-render the routes with parameters (brender.paths) | smbls deploy --provider github-pages (the workflow) |
| Surge | serves 200.html for every unknown address by itself | — |
| Your own server or Worker | try the file, then <path>.html, then 200.html — nginx: try_files $uri $uri.html /200.html;; a Worker: env.ASSETS.fetch(new URL('/200', request.url)) | — |
symbols.json
{
"brender": { "hydrate": true },
"brenderDistDir": "dist-brender"
}brender: true (default for smbls build), false (no pre-render), or an object, which turns it on — hydrate: false makes smbls build ship replace pages (no registry; the client renders fresh).
brender can be an object (still on):
{
"brender": {
"context": "symbols/siteContext.js",
"exclude": ["/admin*"],
"paths": { "/:lang": ["/en"], "/:lang/about": ["/en/about"] },
"strict": true,
"eagerImages": 2,
"timeout": 30000
}
}| Key | |
|---|---|
| context | The module the build's entry boots with (see loadProject). |
| exclude | Page keys not pre-rendered; a trailing * matches every key that starts with what precedes it. A page can also say prerender: false. |
| paths | Route with parameters → the addresses to render it at (/en/about → en/about.html). Each must fit its route. |
| strict | As --strict. |
| eagerImages | As renderPage's. |
| hydrate | true (default): every page ships its element-path registry and the client adopts it; false: replace pages (no registry; the client renders fresh). |
| timeout | Each page's render time limit in ms, as render()'s timeout (default 30000; 0: none). A page past it fails with BRENDER_RENDER_TIMEOUT and the next page renders. smbls build and smbls brender pass it to every render. |
Examples
The examples/ directory contains runnable experiments. Copy a project's source into examples/ first (gitignored), then run:
Render to static HTML
# Render all routes
node examples/render.js rita
# Render specific route
node examples/render.js rita /about
# Output goes to examples/rita_built/
# index.html - static HTML with data-br keys
# index-tree.json - DOMQL element tree (for inspection)
# index-registry.json - br-key -> element path mappingLiquidate (full round-trip)
node examples/liquidate.js rita /
# Output:
# Step 1: Render DOMQL -> HTML (server side)
# HTML: 7338 chars
# data-br keys assigned: 129
#
# Step 2: Parse HTML into DOM (simulating browser)
# DOM nodes with data-br: 129
#
# Step 3: DOMQL element tree ready
# DOMQL elements: 201
#
# Step 4: Hydrate - remap DOMQL elements to DOM nodes
# Linked: 129 elements
# Unlinked: 0 elements
#
# Step 5: data-br -> DOMQL element mapping
# br-1 <main > root
# br-2 <nav > root.Nav
# br-3 <div > root.Nav.Inner
# br-4 <div > root.Nav.Inner.Logo "Rita Katona"
# br-11 <section > root.Hero
# br-15 <h1 > root.Hero.Content.Headline "Are you looking for..."
# ...
#
# Step 6: Mutate via DOMQL (proves elements own their nodes)
# Before: "Rita Katona..."
# After: "[LIQUIDATED] Rita Katona..."
# Same ref: truenpm scripts
npm run render:rita # render rita project
npm run render:survey # render survey project
npm run render:all # render both
npm run liquidate:rita # full liquidation round-trip for rita
npm run liquidate:survey # full liquidation round-trip for surveyExperiment results
Tested against two real Symbols projects:
rita (portfolio site, 6 routes, 39 components)
| Route | HTML size | data-br keys | DOMQL elements | Link rate |
|-------|-----------|-------------|----------------|-----------|
| / | 7,338 | 129 | 201 | 100% |
| /about | 4,623 | 87 | 133 | 100% |
| /from-workshops-to-1-on-1s | 7,043 | 119 | - | 100% |
| /hire-me-as-a-freelancer | 5,195 | 82 | - | 100% |
| /references-and-partners | 6,102 | 91 | - | 100% |
| /angel-investment | 4,800 | 85 | - | 100% |
survey (benchmark app, 1 route, 7 components)
| Route | HTML size | data-br keys | Link rate |
|-------|-----------|-------------|-----------|
| / | 29,625 | 203 | 100% |
The difference between "data-br keys" and "DOMQL elements" is that some elements are virtual (text nodes, internal refs) and don't produce HTML element nodes.
The data-br contract
The data-br attribute is the bridge between server and client. The contract:
- Sequential: Keys are assigned in DOM tree order (
br-0,br-1,br-2, ...) by depth-first traversal - Deterministic: Same DOMQL input always produces the same key assignments — because DOMQL's
create()is deterministic andassignKeys()walks in document order - Element nodes only: Only
nodeType === 1(elements) get keys, not text nodes or comments - Bidirectional: After hydration,
element.nodeandnode.refpoint to each other
The client finds an element's node through the registry the page carries (element path → key): an element whose node never reached the DOM (an if that was false, a text node) has no key, so keys alone do not line up with elements.
Architecture notes
renderElement()uses@domql/elementcreate directly — lightweight, no smbls bootstrap. Good for individual componentsrender()uses the fullsmbls/src/createDomql.jspipeline — handles routing, designSystem initialization, uikit defaults, the works. Needed for complete apps- The smbls source is bundled with esbuild (cached after first call) because the monorepo uses extensionless/directory imports that Node.js ESM can't resolve natively. The esbuild plugin patches SSR-incompatible code (window references, createRequire, circular imports) and stubs out browser-only packages
render()setsglobalThis.document,globalThis.location,globalThis.localStorageandglobalThis.sessionStoragebefore each render to match the linkedom virtual env, then restores them after (or at its timeout). This allows the bundled smbls code (which readswindow = globalThis) to work in SSR. Renders run one at a time per process, so no two hold these globals at oncehydrate.jsis browser-only code (no linkedom dependency) — it's exported separately via@symbo.ls/brender/hydratecreateEnv()setsglobalThis.window/document/Node/HTMLElementbecause@domql/utilsisDOMNodeusesinstanceofchecks against global constructors- CSS is extracted from the atomic CSS engine's generated rules
onRendercallbacks that do network requests or calls.update()will error during SSR — this is expected and harmless since the HTML is already produced before those callbacks fire- Data comes from the fetch plugin during the render (with the real element); the render waits for its requests.
prefetch.jskeeps the deprecatedprefetchPageDatafor callers that want a page's data outside a render
Theme support
Brender defaults to globalTheme: 'auto', generating CSS with both prefers-color-scheme media queries and [data-theme] selectors. The SSR output includes theme-switching CSS variables that work without JavaScript:
@media (prefers-color-scheme: dark) {
:root:not([data-theme]) { --theme-document-background: #000; }
}
[data-theme="dark"] { --theme-document-background: #000; }
[data-theme="ocean"] { --theme-document-background: #0a2e4e; }Custom themes beyond dark/light are activated via data-theme attribute on the root element.
