How compilation works
jx build
turns a Jx project (JSON documents in
pages/
,
layouts/
, and
components/
) into a production site in
dist/
. The compiler erases the Jx abstractions at build time: no JSON documents, no interpreter, and no framework code ship to production. A page only gets JavaScript when the compiler can prove it needs some, and the proof runs in one direction: everything is static until something dynamic is found.
The only client-side dependencies a page can end up with are
@vue/reactivity
(7.8 kB gzip) and
lit-html
(3.3 kB gzip), loaded through an import map, and only on pages that need them. Both are served from your own site, under
/assets/
.
What
jx build
does
Running
jx build
(see
CLI commands
) orchestrates one pipeline:
Load
project.jsonand register the extensions it declares (extensions), which contribute file formats such as Markdown pages.Discover routes by scanning
pages/:pages/index.json→/,pages/about.json→/about,pages/blog/[slug].json→/blog/:slug,pages/docs/[...path].json→ catch-all. Files starting with_are not routed..jsonpages are native; other extensions (like.md) route through a format registered by an enabled extension.Expand dynamic routes : a
[param]page's$pathsdefinition produces one concrete route per entry. The page's own URL pattern is handed to the extension that owns the$pathsshape, which is how a content type with arouteproduces exactly the parameters each page needs (see Routing ).Compile each route : resolve its layout, merge
$headfrom site + layout + page, inject the read-only$site/$pagecontext, resolve build-time data, transform images for responsive output, then hand the assembled document to the compiler.Emit
dist/: oneindex.htmlper route (withbuild.trailingSlash: "always", the default), compiled component modules and CSS underdist/components/,public/copied verbatim, plussitemap.xml(whenurlis set inproject.json),_redirects, and a bundled server worker whenbuild.adapteris set (worker.js, or_worker.js+_routes.jsonfor Cloudflare Pages).
The build finishes with a summary (
Done: 12 routes → 34 files
) and exits non-zero if any route failed.
Static detection
The routing decision is a single recursive tree walk (
isDynamic()
) that never executes your code. A document compiles to plain HTML unless the walk finds one of:
a
stateentry that exists at runtime (a plain value, a$prototypeentry, or anything with adefault)a
${…}template string in a property, style, or attribute valuea
$refbinding on an element propertya
$switchnodea mapped array (
$prototype: "Array")any child that is itself dynamic
Three kinds of state are exempt because they are resolved during the build and then removed:
the injected
$siteand$pagecontext (read-only, never reactive)entries with
timing: "compiler"(resolved at build time and baked into the HTML)schema-only definitions (pure type information produces no output)
What compiles away
Site and page context looks like state, but it is data the compiler already has. This heading:
{
"tagName": "h1",
"textContent": "${state.$site.name} — ${state.$page.title}"
}
compiles to literal HTML with the template evaluated and gone:
<h1>Acme Realty — About us</h1>
The same applies to
timing: "compiler"
data and to content collections: template strings over build-time values are evaluated against the resolved data, mapped arrays over resolved content are unrolled into repeated markup, and the now-spent state entries are stripped. What reaches
isDynamic()
afterwards is a plain tree, so a blog index that lists every post can still be a zero-JS page.
The output tiers
Fully static
When nothing dynamic survives the build-time resolution, the page is plain HTML with its CSS in the head: zero JavaScript, no import map, no module scripts. This is the default outcome, not an optimization you opt into.
Islands in a static shell
A dynamic subtree inside an otherwise-static document doesn't drag the whole page into the client tier. The compiler cuts the subtree out, compiles it to a small custom-element module, and leaves a placeholder tag in the HTML:
<jx-island-0></jx-island-0>
<script type="module" src="./_islands/jx-island-0.js"></script>
The module upgrades the element in place. Components you author with a hyphenated
tagName
(say
site-counter
) work the same way: the page HTML stays static, and
dist/components/site-counter.js
loads only on pages that use the element. The import map added to those pages resolves
@vue/reactivity
and
lit-html
for the island modules.
Where the runtime comes from
Those two modules are bundled into your
dist/
at build time and the import map points at them:
<script type="importmap">
{
"imports": {
"@vue/reactivity": "/assets/vue-reactivity.js",
"@vue/reactivity/": "/assets/@vue/reactivity/",
"lit-html": "/assets/lit-html.js",
"lit-html/": "/assets/lit-html/"
}
}
</script>
They come from
@jxsuite/compiler
's own dependencies, not your project's, so you don't have to install anything. The runtime always matches the compiler that produced the page.
Which pages get a map is decided per page, and what the build writes is read back out of the pages it just wrote: every path any emitted map names is bundled into
dist/
, and a site whose pages are all static writes no
dist/assets/
at all. That direction matters, because the alternative is a build that promises a module it never produced: a page 404ing on its runtime and rendering blank while the build reports success, which passes every structural check there is.
The page also emits a
modulepreload
hint for each of them, and for each component module it loads:
<link rel="modulepreload" href="/assets/vue-reactivity.js" />
<link rel="modulepreload" href="/components/site-counter.js" />
The import map is written into
<head>
, ahead of those hints and every module script. Browsers refuse an import map that arrives after a module load or preload has started, and Firefox enforces it strictly: a map placed later is dropped, and every component module fails on the bare
@vue/reactivity
specifier.
An import map says where a bare specifier lives; it does not ask for it. Without the hints the browser only discovers
/assets/vue-reactivity.js
after fetching
and parsing
a component module: three round trips deep on a slow connection, one after another. The hints name only what the page actually loads.
The trailing-slash entries cover package
subpaths
. A component or a
$src
sidecar rarely imports only
lit-html
. It imports
lit-html/directives/class-map.js
too, and an import map with only exact keys cannot resolve that. The build scans its own output for those imports, bundles each one it finds to
/assets/lit-html/…
, and repeats until nothing new turns up; which subpaths exist is a property of the third-party code your pages use, so the set is discovered rather than listed. Each one shares the single copy of the package core the exact key already points at, because two copies of lit on one page break in ways a size budget would not notice.
They used to load from
https://esm.sh
. Self-hosting removes a third party from the load path of every interactive page, and it's what makes a
default-src 'self'
Content-Security-Policy possible at all. The old argument for a shared CDN was a shared browser cache, and that stopped being true when browsers began partitioning the HTTP cache by site.
Dynamic pages
When the page document itself is dynamic (it declares live
state
, binds
$ref
s, or uses runtime template strings), the compiler still pre-renders the full HTML, marks bound elements with
data-bind
attributes, and emits one small module that wires reactivity onto the existing DOM: reactive state,
computed()
for derived values,
effect()
per binding, and event handler hookup. There is no client-side re-render of the initial view; the JS only maintains what changes.
Two more outputs sit alongside these tiers:
.class.json
documents compile to ES class modules, and
timing: "server"
entries generate server handlers, either a per-route
_server.js
or a single site-wide worker when
build.adapter
is set.
Across all three tiers the emitter indents nested children so the generated HTML stays readable, except inside
pre
,
textarea
, and the rest of the tags browsers render with
white-space: pre
. There, and everywhere below them (
white-space
inherits), children are concatenated with no separator, because indentation between elements would be content rather than formatting. This is what keeps a syntax-highlighted code block, one
<span>
per token, rendering as the code you wrote.
CSS extraction
Style never ships as JavaScript. During compilation, every static
style
definition (the project-level
style
from
project.json
, the layout's, the page's, and each node's) is extracted into a single
<style>
block in the page
<head>
, with
$media
breakpoint names expanded to real media queries. Styles found in component slot content are collected into the same block. See
Styling
for the authoring model.
Component CSS is inlined into that block too
, after the page's own rules so the cascade is unchanged, and no
<link rel="stylesheet">
is emitted for it. A stylesheet link per component is a render-blocking request per component. jxsuite.com's home page had eleven of them, 22 kB in total, for sheets averaging 2 kB each: the whole of its critical request chain, spent on round trips rather than bytes.
The sidecars are still written to
dist/components/<tag>.css
for anything that references them directly, and a component in
shadow mode
is unaffected: its sheet is linked from inside the declarative shadow root, where it belongs.
The trade is deliberate. Inlined CSS repeats on every page instead of caching across navigations. It is small, it compresses against the markup around it, and on a phone a round trip costs more than the bytes do.
What prerendering will and won't bake
Every page is prerendered: the compiler evaluates
${state.…}
against the build-time scope and writes the result into the HTML. That is what makes a page's content visible to crawlers and to readers with no JavaScript. But baking a template
replaces
it (the binding is gone, not stale), so the compiler bakes only what it can prove will never change:
A constant bakes.
{"tagline": {"type": "string", "default": "Ship JSON"}}read as${state.tagline}becomes text in the HTML, with no JavaScript behind it.An entry a handler writes to stays bound. If any handler in the document assigns to
state.saved(=,+=,++, or an in-placepush/splice/sort), every template reading it keeps its binding, even though the entry has a perfectly ordinary build-time value.A computed over changing state stays bound. A
$prototype: "Function"whose body returns is evaluated at build time, so a computed is left unresolved too when it reads a written entry, a$srcvalue, or aRequest, however many steps removed.An array a computed still reads stays in client state. Expanding an array into a list at build time no longer drops it: if a computed or a template still reads
state.rowsat runtime, the array ships with the island. Mark ittiming: "compiler"to say the data really is build-time only.
One case the compiler cannot see: a handler loaded through
$src
lives in a JavaScript file the build does not open, so a state entry written
only
from there is still treated as a constant and baked. Declare a writer for it in the document if a binding over it goes dead.
Components inside components
A component instance is expanded wherever it is written: on a page, slotted into another component, or inside another component's own
children
. Each level is prerendered against its own props, so a card that renders a button that renders an icon comes out as three levels of real markup, with no JavaScript when all three are static. Two details follow from that:
Props flow down at build time. A
$propsvalue written as a template ("icon": "${state.icon}") resolves against the parent component's state, and the child's hoststyletemplates (a mask image chosen from a prop, say) resolve against the child's own props (the definition's defaults when the instance passes none) and land on the element as an inlinestyle.Each component decides its own JavaScript. A live component nested inside a static one is prerendered as a shell that loads its own module and upgrades in place; the static parent still ships none.
Children land in the slot they name, at every level. A live component finds the children it was given when it upgrades, so its first paint and its live render place them alike.
A component that renders itself with the same props can never finish expanding, so the build fails that route with a message naming the chain (
a-loop → b-loop → a-loop
) instead of overflowing. A component that renders itself with props that change at each level (a tree node) is allowed up to 32 levels deep, which is where a recursion that never bottoms out is reported the same way.
Why is my page shipping JavaScript?
Work the static-detection list backwards:
Live
stateon the page document , even{"count": 0}, makes the page dynamic. If the data never changes after the build, move it totiming: "compiler"or reference$site/$pagecontext instead.A
${…}template string whose inputs exist only at runtime keeps its binding alive. Templates over build-time data compile away.$switch,$refbindings, and mapped arrays over runtime state need the client tier; mapped arrays over build-resolved content do not.Interactive components cost their own module on the pages that use them. The rest of the page stays static.
Related
CLI commands :
jx buildflags and the rest of the CLIThe dev server : the development-time counterpart
Site architecture : the directory layout the build consumes
Reactivity : what the dynamic tier compiles from
Components : the component model behind islands