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 OmlNode tree 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.json files that round-trip cleanly to and from .tsx source
  • SurrealDBOmlNode is 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
TypeDescription
elementAn HTML element — tag, props, children
#componentA function component call — preserves name and props for the inspector
#fragmentA JSX Fragment — children with no wrapper element
#textA plain text node
#rawRaw HTML passthrough — value is emitted unescaped (island server stubs, layout children)
nullEmpty / 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'
TypeDescription
OmlNodeUnion of all node types
OmlElementHTML element node
OmlComponentFunction component boundary node
OmlFragmentFragment node
OmlTextText node
OmlRawRaw HTML passthrough node — value emitted unescaped
OmlPropsRecord<string, unknown> alias
OmlBlueprintFull component definition for JSON storage
OmlPropSchemaType, 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:

PatternWhen to use
Standard runtime — return a string from definePageFully dynamic pages; the router only caches OmlNode returns
IslandsCache the static shell with OML; put user-specific or real-time data in a client island that fetches on hydration
Fragment TTLdefineFragment with ttlData that can be slightly stale; re-fetches automatically on an interval
Webhook invalidationclearAllFragments()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:

  1. Check cache → return cached page HTML, or run the page handler and cache its result
  2. 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 null nodes
  • 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.