OML — Object Markup Language
OML is a stable, serializable intermediate representation of a rendered component tree. Where Davaux's standard JSX runtime renders directly to an HTML string, the OML runtime produces a plain JavaScript object — an OmlNode tree that can be inspected, cached, diffed, stored as JSON, and rendered to HTML or JSX source at any time.
Why it exists:
- Caching — cache an
OmlNodetree instead of an opaque HTML string; re-render only the subtrees that changed - Inspection — the component tree is structured data, not a black-box string; the visual editor and dev inspector read it directly
- Blueprints — store component definitions as
.oml.jsonfiles that round-trip cleanly to and from.tsxsource - SurrealDB —
OmlNodeis plain JSON, mapping directly to SurrealDB records with graph edges for child relationships
Node types
An OmlNode is a discriminated union on the type field:
import type { OmlNode } from 'davaux'
type OmlNode = OmlElement | OmlComponent | OmlFragment | OmlText | OmlRaw | null
| Type | Description |
|---|---|
element | An HTML element — tag, props, children |
#component | A function component call — preserves name and props for the inspector |
#fragment | A JSX Fragment — children with no wrapper element |
#text | A plain text node |
#raw | Raw HTML passthrough — value is emitted unescaped (island server stubs, layout children) |
null | Empty / no output |
OmlElement
type OmlElement = {
type: 'element'
tag: string // HTML tag name: 'div', 'span', 'button', etc.
id?: string // from JSX key prop — stable identity for diffing
props: Record<string, unknown>
children: OmlNode[]
}
OmlComponent
Component boundaries are preserved in the tree — the name and props are stored alongside the resolved return output. This is what allows the visual editor to show the component tree, not just the rendered HTML.
type OmlComponent = {
type: '#component'
name: string // component function name
id?: string
props: Record<string, unknown>
return: OmlNode // the component's rendered output
}
OmlFragment, OmlText, and OmlRaw
type OmlFragment = { type: '#fragment'; children: OmlNode[] }
type OmlText = { type: '#text'; value: string }
type OmlRaw = { type: '#raw'; value: string }
OmlRaw holds pre-rendered HTML that must not be escaped — used internally for island server stubs, layout children passed as strings, and any Promise<string> resolved from a standard-runtime component used inside an OML page. renderToHtml emits the value verbatim.
The OML JSX runtime
Import the OML JSX runtime via jsxImportSource in your tsconfig.json:
{
"compilerOptions": {
"jsxImportSource": "davaux/oml"
}
}
With that set, JSX in your file produces an OmlNode tree instead of an HTML string. The API is identical to the standard runtime — async components, fragments, and all HTML intrinsic elements are fully supported.
You can also set jsxImportSource: "davaux/oml" globally (at the top-level compilerOptions rather than per-file) and mix standard-runtime components (e.g. @davaux/ui) freely — they return Promise<string>, which the OML runtime wraps in an OmlRaw node automatically at runtime. TypeScript accepts the mixed usage without errors.
// jsxImportSource: "davaux/oml" in tsconfig
async function Card({ title }: { title: string }) {
return (
<div class="card">
<h2>{title}</h2>
</div>
)
}
const tree = await <Card title="Hello" />
// tree is an OmlNode, not a string
You can also use the factory directly:
import { jsx, Fragment } from 'davaux/oml/jsx-runtime'
const node = await jsx('div', { class: 'box', children: 'Hello' })
Rendering to HTML
renderToHtml converts an OmlNode tree to an HTML string — the same output you'd get from the standard JSX runtime, but operating on the pre-built tree:
import { renderToHtml } from 'davaux'
const html = renderToHtml(tree)
// '<div class="card"><h2>Hello</h2></div>'
This separation is what enables caching: build the OmlNode once, store it, and call renderToHtml on every request without re-executing component functions.
Rendering to JSX source
renderToJsx converts an OmlNode tree back to JSX source text. #component nodes render as component invocations (<Card title="Hello" />), preserving component boundaries rather than expanding the resolved output:
import { renderToJsx } from 'davaux'
const jsx = renderToJsx(tree)
// '<div class="card"><h2>Hello</h2></div>'
// With a component boundary:
// '<Card title="Hello" />'
Blueprints
A Blueprint is a component definition stored as a JSON file. It contains the component's name, its prop schema, and the return node — the root of the component's render tree.
{
"id": "bp:button",
"name": "Button",
"props": {
"label": { "type": "string", "required": true },
"disabled": { "type": "boolean", "required": false }
},
"return": {
"type": "element",
"tag": "button",
"props": { "className": "btn" },
"children": [{ "type": "#text", "value": "Click me" }]
},
"imports": {}
}
The imports field
When a Blueprint's render tree references other components by name, the imports map records where to find them — enabling a complete round-trip back to .tsx source without losing the import path:
{
"id": "bp:card",
"name": "Card",
"imports": {
"Badge": "./Badge.js",
"Avatar": "./Avatar.js"
},
"props": { "title": { "type": "string", "required": true } },
"return": {
"type": "element",
"tag": "div",
"props": { "className": "card" },
"children": [
{ "type": "#component", "name": "Badge", "props": {}, "return": null },
{ "type": "#text", "value": "Card content" }
]
}
}
Parsing Blueprint JSON
parseOml and parseOmlBlueprint validate untrusted JSON before use — useful when loading Blueprint files from disk or a database:
import { parseOml, parseOmlBlueprint } from 'davaux'
import { readFileSync } from 'node:fs'
const raw = JSON.parse(readFileSync('./Button.oml.json', 'utf-8'))
const blueprint = parseOmlBlueprint(raw) // throws with a descriptive error if invalid
// Or parse a bare node:
const node = parseOml(raw)
Both functions throw a descriptive error if the shape doesn't match — they do not silently coerce invalid input.
Exporting a Blueprint to .tsx
blueprintToFile converts a Blueprint into a complete TypeScript component file, using the imports map to generate import statements and the prop schema to generate TypeScript types:
import { blueprintToFile, parseOmlBlueprint } from 'davaux'
import { readFileSync, writeFileSync } from 'node:fs'
const raw = JSON.parse(readFileSync('./Card.oml.json', 'utf-8'))
const blueprint = parseOmlBlueprint(raw)
const source = blueprintToFile(blueprint)
writeFileSync('./Card.tsx', source)
Given the Card blueprint above, the output would be:
import Badge from './Badge.js'
import Avatar from './Avatar.js'
export default function Card({ title }: { title: string }) {
return (
<div className="card">
<Badge />
Card content
</div>
)
}
OML and SurrealDB
OmlNode is plain JSON, so it maps directly to SurrealDB records. The type discriminant doubles as a natural table name — oml_node:⟨id⟩ — and child relationships map to graph edges:
CREATE oml_node:card_root CONTENT { type: 'element', tag: 'div', props: { className: 'card' } };
CREATE oml_node:card_text CONTENT { type: '#text', value: 'Hello' };
RELATE oml_node:card_root ->child-> oml_node:card_text;
Blueprints are stored as top-level records, with their return tree either embedded (for simple components) or fully normalized with graph edges (for components that need cross-project querying). The imports map becomes a set of ->references-> edges in the graph.
File paths make natural record IDs within a project — SurrealDB handles special characters in IDs with its escaped syntax: blueprint:⟨src/components/Button.oml.json⟩.
Type reference
import type {
OmlNode,
OmlElement,
OmlComponent,
OmlFragment,
OmlText,
OmlRaw,
OmlProps,
OmlBlueprint,
OmlPropSchema,
OmlPropType,
} from 'davaux'
| Type | Description |
|---|---|
OmlNode | Union of all node types |
OmlElement | HTML element node |
OmlComponent | Function component boundary node |
OmlFragment | Fragment node |
OmlText | Text node |
OmlRaw | Raw HTML passthrough node — value emitted unescaped |
OmlProps | Record<string, unknown> alias |
OmlBlueprint | Full component definition for JSON storage |
OmlPropSchema | Type, default, required, description for one prop |
OmlPropType | 'string' | 'number' | 'boolean' | 'function' | 'node' | 'array' |
Router integration
Any Davaux page handler can return an OmlNode instead of a string. The router detects this automatically and calls renderToHtml before sending the response — no change to the route file convention required.
The standard approach is definePage with jsxImportSource: "davaux/oml" set in tsconfig.json — JSX then produces OmlNode values directly:
// src/routes/index.page.tsx
import { definePage } from 'davaux'
export default definePage(async (ctx) => {
const name = ctx.query.get('name') ?? 'World'
return <div class="greeting">Hello, {name}!</div>
})
PageHandler already accepts string | OmlNode, so definePage covers both runtimes transparently.
defineOmlPage is a narrower alias that constrains the return type to OmlNode only — useful if you want TypeScript to enforce that a handler never returns a plain string:
import { defineOmlPage } from 'davaux'
// or: import type { OmlPageHandler } from 'davaux'
export default defineOmlPage(async (ctx) => {
// TypeScript error if you return a string here
return <div>Hello</div>
})
At runtime defineOmlPage is identical to definePage — it is a typed identity function only.
Caching in production
In production (isDev = false), the router stores the OmlNode result in an in-process cache after the first render. Subsequent requests for the same route and URL reuse the cached node and call renderToHtml — skipping the page function entirely.
The cache key includes both the pathname and the query string:
filePath::pathname+search
// e.g. ".../routes/index.page.ts::/about?tab=faq"
This means routes that read ctx.query inside the handler produce separate cache entries per unique query string — the example above caches /?name=Alice and /?name=Bob independently.
Dynamic data and caching
Because the page function is skipped on cache hits, any database query or external fetch inside the handler only runs once per cache key — the first request. Every subsequent visitor receives the same frozen OmlNode tree until the process restarts.
export default definePage(async (ctx) => {
const posts = await db.posts.findAll() // only runs on the first request in production
return <ul>{posts.map(p => <li>{p.title}</li>)}</ul>
})
OML page caching is therefore best suited to content that is static between deploys — docs, marketing pages, navigation shells. For data that must be fresh on every request, choose one of these patterns instead:
| Pattern | When to use |
|---|---|
Standard runtime — return a string from definePage | Fully dynamic pages; the router only caches OmlNode returns |
| Islands | Cache the static shell with OML; put user-specific or real-time data in a client island that fetches on hydration |
Fragment TTL — defineFragment with ttl | Data that can be slightly stale; re-fetches automatically on an interval |
Webhook invalidation — clearAllFragments() | CMS or database-write-driven invalidation; call from a POST endpoint after a content update |
There is currently no per-page cache TTL or manual page-level invalidation — those controls are on defineFragment. For a page that mixes a cacheable shell with per-request data (e.g. a logged-in dashboard), the recommended shape is an OML layout for the shell and islands or an API route for the dynamic content.
Layouts are not cached
The cache covers only the page handler's output. Layouts always run on every request — even when the page body is served from cache. The sequence is:
- Check cache → return cached page HTML, or run the page handler and cache its result
- Always run each applicable layout, passing the (possibly cached) page HTML as
children
This means a database query inside a layout handler runs on every request regardless of page caching. For layout-level data fetching, prefer defineFragment with a TTL so the query is cached independently:
// src/fragments/current-user.ts
import { defineFragment } from 'davaux'
export const CurrentUser = defineFragment(
async (props: { userId: string }) => {
const user = await db.users.find(props.userId)
return <span>{user.name}</span>
},
{ ttl: 30_000 },
)
// src/routes/_layout.tsx
import { defineLayout } from 'davaux'
import { CurrentUser } from '../fragments/current-user.js'
export default defineLayout(async ({ children, ctx }) => {
const userId = ctx.cookies.get('userId') ?? ''
return (
<html>
<body>
<nav>{await CurrentUser({ userId })}</nav>
<main>{children}</main>
</body>
</html>
)
})
Dev mode
In dev mode (isDev = true), OML pages are never cached — the module is re-imported on every request (with a cache-busting timestamp) and the page function always runs. This ensures edits are reflected immediately without a restart.
Fragment caching
The page-level omlCache caches an entire route's output as one unit. Fragment caching goes one level deeper — individual components can cache their own output independently, so a nav bar or sidebar renders once and is reused across every page that includes it, even when the surrounding page is dynamic.
Use defineFragment to create a cached component:
// src/fragments/nav.tsx
/** @jsxImportSource davaux/oml */
import { defineFragment } from 'davaux'
export const Nav = defineFragment(async () => {
// runs once — subsequent calls return the cached OmlNode
return (
<nav>
<a href="/">Home</a>
<a href="/docs">Docs</a>
</nav>
)
})
Use it in any OML page:
/** @jsxImportSource davaux/oml */
import { definePage } from 'davaux'
import { Nav } from '../fragments/nav.js'
export default definePage(async (ctx) => {
return (
<main>
{await Nav()}
<p>Content</p>
</main>
)
})
Caching by props
When a fragment depends on props, provide a function that accepts them. The cache is keyed per unique prop combination using JSON.stringify by default:
/** @jsxImportSource davaux/oml */
const UserBadge = defineFragment(async (props: { userId: string }) => {
const user = await db.users.find(props.userId)
return <span class="badge">{user.name}</span>
})
// Each userId gets its own cache entry
await UserBadge({ userId: 'alice' })
await UserBadge({ userId: 'bob' })
Provide a custom key function when props contain fields that shouldn't affect caching:
const Item = defineFragment(
async (props: { id: string; requestId: string }) => { ... },
{ key: (p) => p.id }, // requestId is ignored for cache purposes
)
TTL
Set a ttl (milliseconds) to re-run the function after a fixed interval:
const Trending = defineFragment(
async () => { /* fetch trending posts */ },
{ ttl: 60_000 }, // refresh every minute
)
Manual invalidation
clearAllFragments invalidates every fragment cache in the process — useful for webhook-driven cache busting after a CMS update:
import { clearAllFragments } from 'davaux'
// In a POST /api/revalidate handler:
clearAllFragments()
Lifecycle
Fragment caches are module-level — they persist for the lifetime of the Node process. In dev mode, the process restarts on file changes, which clears all caches automatically. In production, caches live until the process exits, a TTL expires, or clearAllFragments is called.
Dev inspector overlay
In dev mode, every OML page request stores its rendered OmlNode tree server-side. Enable the dev tools via davaux.config.ts:
export default defineConfig({
editor: { enabled: true },
})
A floating badge then appears in the corner of every page. Click it (or press Ctrl+Shift+O) to open the inspector panel, which shows the live component tree for the current URL. The badge initially shows your configured label (default Inspector), and updates to OML or DOM once the panel loads, indicating whether the page used the OML runtime or the standard string renderer.
The inspector tree uses colour to distinguish node types:
- Blue — HTML elements (
<div>,<nav>, etc.) with their key props - Green — Component boundaries (
◈ Card,◈ Nav) with props - Gray — Fragments and
nullnodes - Orange — Raw HTML passthrough nodes — island stubs and string children from standard-runtime components
Click any node with children to collapse or expand it. The inspector state persists across page reloads via sessionStorage, so it stays open during livereload-triggered refreshes.
The badge also exposes an Edit link that opens the full visual editor (Ctrl+Shift+E). See the Visual Editor page for full documentation on editing components, props, styles, and source files from the browser.
The inspector has no effect in production builds.