Plan at compile time, render anywhere
The Markless compiler decides state, updates, and event code before anything runs, and the same compiled component renders in the browser, on a server, at build time, or in tests.
Last page put compile-time planning first. Here is what the compiler decides, and where its output can run.
Decided before anything runs
Misconception: a framework must run your component to learn what it does. Markless reads it instead. At build time, the compiler works out three things from Counter.tsrx:
- The state.
countbecomes the graph nodestate:count. - The updates. The text
Count {count}becomes one planned text write. - The event code. The click becomes its own symbol: a small module that the runtime loads by ID.
One compile also emits two render modules: one for the browser and one for a server.
Pick an environment. Then click Count.
Where can the same compiled Counter render?
Pick an environment. Then click Count in the page.
- state:countThe state. count starts at 0.
Here: kept in the render module's graph, starts at 0. - symbol:1The text update. Sets the text of h0 to "Count " plus count.
Here: wired to h0 by render(). - symbol:0The click code, count++. Its own module, loaded by ID.
Here: loads on the first click.
Same page and same click behavior from every choice.
// main.tsawait render(Counter, { target }); (body runs once) // DOM inside the target<button>Count 0</button> container.payloadScripts // undefined (no notes)container.resumerScript // undefined (no resumer)- ranrender() ran the Counter body once, in the browser, and built the button.
- noteNo JSON notes and no inline resumer. Nothing for the click has loaded.
Simplified. Artifact names come from compileTsrxModule() on Counter.tsrx. Tests call the click symbol symbol:click. One body run is asserted for render() and renderToString() in packages/web/test/render.test.ts. That file also asserts no symbol loads at mount and one loads on the first click. The server HTML is cut from one real build. The build-time markup follows assemblePrerenderPageParts, not a captured file. Build time is a preview.
Notice that the planned outputs stay the same. The body runs once, and only the first click loads the click code.
Rendered where you need it
| Environment | How you call it | Notes |
|---|---|---|
| Browser | render(App, { target }) from @markless/core |
No server needed |
| Server | renderToString() from @markless/core, or renderToStream() from @markless/web |
HTML plus the plan as JSON |
| Multi-page app | @markless/router |
File routes on Nitro, which deploys to many targets |
| Build time | MARKLESS_PRERENDER=1 in the demos |
Preview, not stable |
| Tests | render() and renderSSR() from @markless/vitest-browser |
renderSSR() renders in Node, then loads the HTML in the test browser |
| Native hosts | UIKit and AppKit, see native targets | Proofs, not wired to the compiler yet |
A browser-only app needs no server:
import { render } from '@markless/core';
import App from './App.tsrx';
await render(App, { target: document.querySelector('#app')! });
What stays the same
- The component body runs once, to set up the page. A browser test and a server test both count 1 run.
- No handler runs before its event. In the browser test, no symbol loads at mount. The first click loads one.
- After a write, only the text, attributes, and list rows that read the changed state update.
The environments differ only in delivery. On a server, the HTML carries the plan as JSON plus a small inline script. Hydration means the browser runs components again over server HTML. Markless skips it, and the browser does not run the body to start the page.
Next: What exactly does the compiler emit for Counter.tsrx? The compiler →
