The compiler
The compiler is the bridge between the parser and the rest of the
framework. The parser produces an in-memory PyxParseResult; the
compiler turns that result into three files on disk that the dev
server, the SSR pipeline, and the Vite bundler all consume:
pages/index.pyxl ← your source
│
│ PyxParser().parse()
▼
PyxParseResult ← in-memory
│
│ ArtifactWriter().write()
▼
.pyxle-build/server/pages/index.py ← Python loader, importable by Starlette
.pyxle-build/client/pages/index.jsx ← JSX component, bundleable by Vite
.pyxle-build/metadata/pages/index.json ← extracted metadata, used by route discoveryThis doc walks through what's in each of those files, why they exist as separate artifacts, and the small but important transformations the compiler applies along the way.
Files:
compiler/core.py(~70 lines) — top-levelcompile_file()entrycompiler/writers.py(~310 lines) —ArtifactWriterand the runtime-import injection helperscompiler/jsx_imports.py(~370 lines) — the.pyxl → .jsximport rewritercompiler/jsx_parser.py(~125 lines) — Babel subprocess wrappercompiler/model.py(~130 lines) —CompilationResult,PageMetadata, and the small declaration dataclasses
The top-level entry: compile_file
def compile_file(
source_path: Path,
*,
build_root: Path,
client_root: Path | None = None,
server_root: Path | None = None,
) -> CompilationResult:Source: compiler/core.py:15.
This is the only function the rest of the framework calls. Its job is small:
- Resolve the page-relative path (the file's path inside
pages/). - Compute the route paths (primary and any aliases — see Routing).
- Parse the source file with
PyxParser().parse(source_path). - Hand the result to
ArtifactWriterto emit the three files. - Return a
CompilationResultdescribing what was written.
It's deliberately thin. All the interesting decisions live in the parser and the writer.
Three artifacts, three jobs
1. The Python artifact (.py)
.pyxle-build/server/pages/index.py is the executable Python
module for one route. It's what pyxle dev and pyxle serve
actually import and call.
Here's a real example. Source .pyxl:
# pages/index.pyxl
import time
@server
async def load_home(request):
return {"now": time.time()}
import React from 'react';
export default function Home({ data }) {
return <h1>Now: {data.now}</h1>;
}Compiled .py:
from pyxle.runtime import server
import time
@server
async def load_home(request):
return {"now": time.time()}A few things happened:
- The JSX is gone. The compiled
.pyis purely the Python half of the source. The dev server never imports the JSX. from pyxle.runtime import serverwas added at the top. The source file uses@serverwithout importing it because the user never has to import Pyxle's runtime decorators — the compiler does it for them. Same for@action.- The original imports (
import time) are preserved verbatim. The compiler doesn't reformat your code, doesn't reorder imports, doesn't rewrite anything. The only change is the runtime import insertion.
2. The JSX artifact (.jsx)
.pyxle-build/client/pages/index.jsx is the bundleable JSX module
for the same route. Vite reads it, bundles it with esbuild, and ships
it to the browser.
For our example:
import React from 'react';
export default function Home({ data }) {
return <h1>Now: {data.now}</h1>;
}This is a verbatim copy of the JSX half of the source — almost. The "almost" is the JSX import rewriter (next section).
3. The metadata artifact (.json)
.pyxle-build/metadata/pages/index.json is the extracted metadata
for the route. It's a small JSON document the dev server reads at
startup to build its routing table without re-parsing the source:
{
"source_relative_path": "index.pyxl",
"route_path": "/",
"alternate_route_paths": [],
"loader_name": "load_home",
"loader_line": 4,
"head_elements": [],
"head_is_dynamic": false,
"head_jsx_blocks": [],
"script_declarations": [],
"image_declarations": [],
"actions": [],
"module_key": "pyxle.server.pages.index",
"client_path": "pages/index.jsx",
"server_path": "pages/index.py",
"content_hash": "abc123..."
}The dev server's MetadataRegistry (devserver/registry.py) loads
all of these at startup, builds a RouteTable, and uses it to
dispatch requests. No .pyxl file is parsed during a request — that
work is done once, at compile time.
The runtime import injection pass
When you write:
@server
async def loader(request):
return {}…you don't write from pyxle.runtime import server first. Pyxle's
philosophy is that the framework should stay out of your way for the
common case. The compiler adds the import for you, but in a careful,
AST-aware way.
The injection logic lives in three helper functions in
compiler/writers.py:
ensure_server_import(code)— addsfrom pyxle.runtime import serverensure_action_import(code)— addsfrom pyxle.runtime import actionensure_server_action_import(code)— adds the combined import when both decorators are present
The compiler chooses one of the three based on what the parser found:
if has_loader and has_actions:
python_code = ensure_server_action_import(python_code)
elif has_loader:
python_code = ensure_server_import(python_code)
elif has_actions:
python_code = ensure_action_import(python_code)Source: compiler/writers.py:55-60.
Why AST-aware?
A naive version would do:
def ensure_server_import(code):
return "from pyxle.runtime import server\n" + codeThis works for most files. But it breaks on:
"""Module docstring.
This must remain the first statement in the module.
"""
from __future__ import annotations
@server
async def loader(request):
...PEP 257 says the module docstring must be the first statement. PEP
236 says from __future__ import annotations must come before any
other code (after the docstring). A naive prepend would put the
runtime import before the docstring, breaking both PEPs and
producing a SyntaxError on the next compile cycle.
The injection helper uses ast.parse to find the right insertion
point: after the docstring, after any from __future__ imports, but
before any other code. It also checks for an existing
from pyxle.runtime import server and skips the injection if one is
already present (so you can import it explicitly if you want).
The complete logic is short — about 70 lines of careful AST walking
in compiler/writers.py:139-215.
Pyxle in plain Python: This is the only place where the compiler "modifies" your code. Everywhere else, the compiled
.pyfile is byte-for-byte the same as the Python half of your source. The injection is the smallest possible change consistent with not requiring you to write boilerplate imports.
The JSX import rewriter
JSX files import each other. A page might import a shared component:
// pages/index.pyxl
import Sidebar from './Sidebar.pyxl';
export default function Home() {
return <Sidebar />;
}But after compilation, Sidebar.pyxl doesn't exist on disk anymore —
its compiled version is Sidebar.jsx. The bundler can't resolve
./Sidebar.pyxl because there's nothing there. We need to rewrite
the import specifier so it points at the compiled artifact:
import Sidebar from './Sidebar.jsx'; // ← rewrittenThe rewriter (compiler/jsx_imports.py) handles this. It's a
character-by-character JS lexer that walks the JSX source tracking:
- When we're inside a string literal (
',",`) - When we're inside a
//line comment or/* ... */block comment - When we're inside a JSX tag (so a
fromkeyword inside JSX is not a module import) - The current parsing context (top-level, inside an import statement,
inside an export-from, inside a dynamic
import(...)expression)
When the lexer sees a string literal in import-specifier position
(which can be import x from "...", import "...", export ... from "...", import("..."), etc.), it checks if the specifier
ends with .pyxl (with optional ?query and #fragment suffixes
preserved) and rewrites the extension to .jsx.
It is not a complete JS parser — it only tracks enough state to know when a string literal is a module specifier vs ordinary string content. The decision was deliberate: we don't want a Babel subprocess in the inner loop of every compile.
Source: compiler/jsx_imports.py:1-372.
Why a custom lexer instead of regex?
A regex like import\s+\w+\s+from\s+["']([^"']+)["'] would catch
most cases but break on:
- Strings that contain the word "import" (
const msg = "import was removed") - Comments that contain import statements (
// import './foo.pyxl') - Template literals containing import statements
- Dynamic imports with concatenated paths (
import("./" + name + ".pyxl")— we don't rewrite these because we don't know the literal value) - Re-exports (
export { foo } from "./bar.pyxl")
The lexer handles all of these correctly because it tracks state. Regexes don't track state.
The Babel-backed JSX validator
Sometimes the compiler needs to understand the JSX, not just rewrite imports. Specifically, it needs to find:
<Script src="..." strategy="..." />declarations (so the SSR pipeline can inject scripts at the right hydration point)<Image src="..." width="..." height="..." />declarations (so the build can optimize image assets)<Head>...</Head>JSX blocks (so their children can be hoisted into the server-rendered<head>)
To extract this information reliably, Pyxle calls Babel via a
small Node.js helper script (jsx_component_extractor.mjs). The
helper parses the JSX, walks the AST looking for the target
components, and returns a JSON description of each match.
The Python wrapper:
def parse_jsx_components(jsx_code: str, target_components: set[str]) -> Result:
"""Returns parsed component declarations or an error."""Source: compiler/jsx_parser.py:33-127.
The wrapper:
- Writes the JSX to a temp file.
- Spawns Node.js with the helper script and the temp file path.
- Parses the JSON output (or captures the error if parsing failed).
- Returns a
Resultwith eithercomponentsorerror.
This is the same Babel integration that backs validate_jsx=True in
the parser. Both use cases share one Babel call per file.
If Node.js or the helper script isn't available (e.g., the user
hasn't run npm install yet), the wrapper returns an empty result
and the compiler proceeds without the metadata. The dev server logs a
warning but doesn't fail.
The data flow, end to end
Putting it all together, here's what happens when the compiler
processes one .pyxl file:
pages/index.pyxl
│
│ 1. PyxParser().parse(source_path)
▼
PyxParseResult
├── python_code: str
├── jsx_code: str
├── loader: LoaderDetails | None
├── actions: tuple[ActionDetails, ...]
├── head_elements: tuple[str, ...]
├── head_is_dynamic: bool
├── script_declarations: tuple[dict, ...]
├── image_declarations: tuple[dict, ...]
└── head_jsx_blocks: tuple[str, ...]
│
│ 2. ArtifactWriter().write(...)
│ a. ensure_*_import(python_code) ← inject runtime decorators
│ b. rewrite_pyxl_import_specifiers(jsx) ← .pyxl → .jsx in imports
│ c. PageMetadata(...).to_json() ← serialize the metadata
▼
.pyxle-build/server/pages/index.py
.pyxle-build/client/pages/index.jsx
.pyxle-build/metadata/pages/index.jsonSource: compiler/core.py + compiler/writers.py:28-136.
Every .pyxl file produces exactly three artifacts. There's no
in-memory state shared between compilation of different files —
each compile_file() call is independent and reentrant. This is
what makes incremental compilation possible: when the file watcher
sees one file change, it can call compile_file() for just that
one file and trust that all the artifacts are correct.
Stubs for "empty" cases
Two edge cases produce stub content instead of the user's source:
A pure-JSX file (no @server, no Python code at all) gets a
Python stub:
"""Generated by Pyxle for a static page."""This stub is just a docstring; the file is importable but has no loader. The dev server detects this and skips the loader-execution step at request time.
A pure-Python file (no JSX, no export default, just a Python
loader and possibly some server-side helpers) gets a JSX stub:
// Generated by Pyxle: no client component provided.Vite can still bundle this — the bundle is empty — and the dev
server falls back to a minimal placeholder rendering. This is mostly
useful for pages/api/*.py files (which never have a client half by
definition).
Source: compiler/writers.py:14-15.
CompilationResult and PageMetadata
The compiler returns a single dataclass that summarises everything it did:
@dataclass(frozen=True)
class CompilationResult:
source_path: Path
page_relative_path: Path
server_output_path: Path
client_output_path: Path
metadata_output_path: Path
metadata: PageMetadataPageMetadata (compiler/model.py:66) is the data structure that
gets serialized to the .json artifact. It's a frozen dataclass with
an exhaustive list of everything the dev server might need to know
about a page without re-parsing it:
@dataclass(frozen=True)
class PageMetadata:
source_relative_path: Path
route_path: str
alternate_route_paths: tuple[str, ...]
loader_name: str | None
loader_line: int | None
head_elements: tuple[str, ...]
head_is_dynamic: bool
head_jsx_blocks: tuple[str, ...]
script_declarations: tuple[ScriptDeclaration, ...]
image_declarations: tuple[ImageDeclaration, ...]
actions: tuple[ActionDeclaration, ...]
module_key: str
client_path: str
server_path: str
content_hash: strThe content_hash is a SHA256 of the source file contents. The
incremental builder uses it to detect "this file's content hasn't
changed since the last compile" and skip recompilation. Source:
devserver/builder.py:62.
Why three files instead of one?
You might reasonably ask: "Why not put everything in one file?"
Three reasons:
The dev server imports
.pyfiles with Python's normal import machinery. That requires the file to be syntactically valid Python. A file containingexport default function ...is not valid Python and never will be.Vite expects
.jsxfiles for client-side bundling. Vite has no idea what a.pyxlfile is. Giving it actual JSX files lets us take advantage of Vite's existing toolchain (esbuild, Vue plugin, React Refresh, HMR) without modifying Vite at all.Metadata is read more often than it's written. A typical project has 20-100
.pyxlfiles but the dev server reads the metadata for every request. Keeping it as parsed JSON instead of re-parsing source files on each request is a major performance win.
Where to read next
Routing — How
.pyxlfile paths get translated into URL routes, including dynamic segments, catch-all routes, and index collapsing.The dev server — How the dev server uses the compiled artifacts at runtime, including the file watcher that triggers incremental compilation when you save.
The build pipeline — How
pyxle buildtakes the same compiled artifacts and packages them for production deployment, including how it bridges Pyxle's compiled JSX to Vite's bundle output via the page manifest.