Regor
Regor is the component framework PureStack is built on. It runs in two places:
Regor renders your MDX pages and their components to static HTML. Visitors receive finished markup that needs no JavaScript to display.
Regor mounts interactive apps on your pages. They use the same built-in components, theme styles, and TypeScript as your pages.
A PureStack page can load any browser library, including React or Vue, because PureStack bundles any TypeScript you give it. Regor is the natural choice because it reuses everything that is already there. The component catalog works in your apps without adapters, the component styles are already in the page's stylesheet, and your own components are ordinary TypeScript functions and classes that need no compiler plugin.
How a page is rendered
Every page goes through the same four steps:
PureStack converts the Markdown and keeps component markup, such as <Panel> or <Btn>, exactly as you wrote it.
Regor mounts the page in a lightweight DOM with every registered component and the page's data. It expands components, evaluates expressions, and applies conditions and loops.
Event handlers and reactive state stay behind. Only the markup is published, so the page displays without JavaScript.
For each RegorApp or PageScript on the page, PureStack bundles its TypeScript entry point, which runs when the page loads.
| At build time | In the browser | |
|---|---|---|
| Where it runs | Your build, once per page | The visitor's browser |
| What it produces | Static HTML | A live, interactive interface |
Event handlers, r-model, timers | Not kept | Run normally |
Page data (tsSsgContext) | Available | Not available |
| Components | All built-in and registered components | The component families you register |
Regor at build time
Use components in content
Write components in an .mdx, .rmdx, or .md page as you would write HTML elements:
<Panel tone="info" bodyClass="p-3">
<h2 class="mt-0">Before you begin</h2>
<p>Keep your site configuration beside the content folder.</p>
<BtnLink href="/guides/purestack-cli/" tone="info" variant="link">Set up the site</BtnLink>
</Panel> A plain attribute passes a string. An attribute that starts with a colon is a Regor expression, so use it for numbers, booleans, arrays, and objects:
<Btn tone="neutral" icon="tabler:settings" :iconOnly="true" ariaLabel="Open settings"/>
<DoughnutChart
title="Ready tasks by team"
:segments="[
{ label: 'Product', value: 5 },
{ label: 'Design', value: 4 }
]"/> Expressions and page data
Text in double braces is a Regor expression, anywhere in a page. Inside markup, expressions can read tsSsgContext, which holds the page and site data that PureStack provides during the build:
| Field | Contents |
|---|---|
tsSsgContext.pageInfo.frontmatter | The page's frontmatter, including any custom fields you add |
tsSsgContext.pageInfo.urlPath | The page's URL, such as /guides/regor/ |
tsSsgContext.pageInfo.relPath | The source file path inside the content folder |
tsSsgContext.site | The resolved site configuration |
tsSsgContext.navigation, tsSsgContext.outline | The page's navigation and heading outline |
tsSsgContext.locale, tsSsgContext.locales | The page's language and the site's languages |
This page lists three highlights in its frontmatter. The panel below reads them during the build:
From this page's frontmatter
Regor- One component model for pages and apps
- Plain TypeScript, no compiler plugin
- Built-in components and theme styles in both places
---
title: Regor
highlights:
- One component model for pages and apps
- Plain TypeScript, no compiler plugin
- Built-in components and theme styles in both places
---
<Panel tone="accent" variant="surface" bodyClass="p-3">
<strong>{{ tsSsgContext.pageInfo.frontmatter.title }}</strong>
<ul>
<li r-for="item in tsSsgContext.pageInfo.frontmatter.highlights">{{ item }}</li>
</ul>
</Panel> r-if, r-else-if, and r-else choose what to render. r-for repeats an element for each item. Loop over data from frontmatter, the page context, or component state.
r-pre to the element. What does not run at build time
The build publishes markup, not behavior. @click handlers, r-model, reactive updates, and timers in page markup do nothing on the published page. Interactive components on content pages, such as Tabs or Modal, include a small script for their basic behavior. For anything else that responds to the visitor, mount a browser app.
Write build-time components
A component is a template plus an optional context. This one composes built-in components and accepts props and a slot:
import { defineComponent, html, type RefOrValue } from 'regor'
export interface ReleaseNote {
version?: RefOrValue<string>
date?: RefOrValue<string>
href?: RefOrValue<string>
}
const releaseNoteTemplate = html`<Panel tone="neutral" variant="flat" bodyClass="p-3">
<p class="text-eyebrow mt-0">{{ date }}</p>
<h3 class="mt-0">Version {{ version }}</h3>
<slot></slot>
<BtnLink r-if="href" :href="href" tone="accent" variant="link">Read the full notes</BtnLink>
</Panel>`
export function defineReleaseNoteComponents() {
return {
releaseNote: defineComponent<ReleaseNote>(releaseNoteTemplate, {
props: ['version', 'date', 'href'],
}),
}
} Register it in a build script that calls buildSite. Components you pass in components are added to the built-in ones:
import { buildSite } from '@purestack/ts-ssg'
import { defineReleaseNoteComponents } from './components/releaseNote'
await buildSite({
siteConfig: { contentDir: './content' },
options: {
components: { ...defineReleaseNoteComponents() },
},
}) ReleaseNote is exported here. The VS Code extension finds components through their exported interfaces. Without the export, it can't complete the component's props or jump from a tag in your markup to the component's source. Then use it in any page. Content between the tags fills the <slot>:
<ReleaseNote version="1.2" date="October 2026" href="/releases/1-2/">
Faster rebuilds and a new blue theme.
</ReleaseNote> Tag names match registered keys regardless of case, so <ReleaseNote> finds releaseNote. The kebab-case form <release-note> works too. Components can also read page data. Pass the component head to resolveTsSsgContext from @purestack/ts-common, as the built-in navigation and page-link components do.
Regor in the browser
Mount an app
Place a RegorApp in a page and point it at a TypeScript file next to the page. It renders an app element with the id you give it and loads the script. The script calls createApp with the matching selector:
<RegorApp id="launch-checklist" src="./regor-launch-checklist.ts"/> import {
defineBadgeComponents,
defineButtonComponents,
defineFlexComponents,
defineFormComponents,
defineIconComponents,
definePanelComponents,
} from '@purestack/ts-components'
import { tabler_rocket } from '@purestack/ts-svg-icons'
import {
batch,
type ComputedRef,
computed,
createApp,
defineComponent,
html,
type Ref,
ref,
} from 'regor'
export interface Task {
id: string
title: string
done: Ref<boolean>
}
export interface TaskRow {
task: Task
}
const taskRow = defineComponent<TaskRow>(
html`<FormCheck :id="'launch-' + task.id" :label="task.title" :checked="task.done" />`,
{ props: ['task'] },
)
export interface LaunchChecklist {
tasks: Task[]
completed: ComputedRef<number>
ready: ComputedRef<boolean>
launched: Ref<boolean>
launch: () => void
reset: () => void
}
const launchChecklistTemplate = html`<Panel tone="neutral" variant="surfaceAlt" bodyClass="p-3">
<Flex direction="column">
<Flex justify="between" align="center" wrap="true">
<strong>Launch checklist</strong>
<Badge :tone="ready ? 'success' : 'warning'" variant="surface">
{{ completed }} of {{ tasks.length }} done
</Badge>
</Flex>
<TaskRow r-for="task in tasks" :task="task" />
<Flex wrap="true">
<Btn tone="accent" icon="tabler:rocket" :disabled="!ready || launched" @click="launch">Launch</Btn>
<Btn tone="neutral" variant="surface" @click="reset">Start over</Btn>
</Flex>
<FormStatus r-if="launched" role="status">Launched. Every check passed.</FormStatus>
</Flex>
</Panel>`
function createLaunchChecklist(): LaunchChecklist {
const tasks: Task[] = [
{ id: 'docs', title: 'Docs reviewed', done: ref(true) },
{ id: 'tests', title: 'Tests passing', done: ref(false) },
{ id: 'notes', title: 'Release notes written', done: ref(false) },
]
const launched = ref(false)
const completed = computed(() => tasks.filter((task) => task.done()).length)
const ready = computed(() => completed() === tasks.length)
return {
tasks,
completed,
ready,
launched,
launch: () => launched(true),
reset: () =>
batch(() => {
for (const task of tasks) task.done(false)
launched(false)
}),
}
}
createApp(
{
components: {
LaunchChecklist: defineComponent<LaunchChecklist>(
launchChecklistTemplate,
{ context: createLaunchChecklist },
),
TaskRow: taskRow,
...defineBadgeComponents(),
...defineButtonComponents(),
...defineFlexComponents(),
...defineFormComponents(),
...defineIconComponents((name) =>
name === 'tabler:rocket' ? tabler_rocket : '',
),
...definePanelComponents(),
},
},
{ selector: 'app#launch-checklist', template: html`<LaunchChecklist />` },
) The app is an ordinary module. You can split it into several files, import npm packages, and share code with your build-time components. PureStack bundles it with esbuild when it builds the page.
Register only what you use
Browser apps do not get the build's component list. Each app registers the component families it renders, such as defineButtonComponents() or defineFormComponents(). Bundlers include only the families you import. You don't need to load any CSS, because the build already put every component's styles in the site stylesheet.
Template syntax
Browser templates use the same syntax as page markup:
| Syntax | Meaning |
|---|---|
{{ expression }} | Insert text |
:name="expression" | Bind an attribute or component prop |
.name="expression" | Set a DOM property |
@event="handler" | Listen to a DOM event or a component event |
r-if, r-else-if, r-else | Render conditionally |
r-show | Hide without removing the element |
r-for="item in items" | Repeat for each item |
r-model="value" | Two-way binding on native form fields |
:class, :style | Bind classes and inline styles |
<slot>, <template #name> | Pass content into a component |
:is="name" | Render the component named by an expression |
:context="{ … }" | Pass an object of inputs to a component |
In TypeScript you call a ref to read it, as in completed(). In a template, write the name without the call, as in {{ completed }} or :disabled="!ready". Regor reads the value and updates the element when it changes. This also works through nested refs: a template writes editor.selectedHost.hostname where TypeScript needs editor().selectedHost().hostname().
Reactive state
State lives in refs. Call a ref to read it and call it with a value to write it:
const count = ref(0)
count() // read: 0
count(1) // write | API | Use it for |
|---|---|
ref(value) | State whose nested fields are reactive too. Converts the object you pass in place, so its fields become refs. |
cref(value) | The same deep state as ref, built from a copy. The original object stays plain. Costs more than ref. |
sref(value) | A reactive container whose contents stay plain. Replace the value to notify, or change an array, Map, or Set in place. Assigning a field of a plain object does not notify. |
computed(() => …) | A value derived from other refs. Keep it free of side effects. |
observe(source, callback) | A side effect when one known ref changes |
watchEffect(() => …) | A side effect that reruns when any ref it reads changes |
batch(() => …) | Several changes that should notify observers once |
flatten(value) | Plain data from reactive content, for example before sending it to an API |
Give refs a concrete type, such as ref<User>(user), so TypeScript can describe the reactive shape. Model missing data with null, not undefined. Effects created inside a component are cleaned up when the component is removed.
Props, events, and lifecycle
A component lists the props it accepts in props. Regor copies them into the component's context, so the template can use them directly. A component without a context, such as ReleaseNote above, needs nothing more.
When the context needs a prop, read it from head.props. Type the prop as RefOrValue<T> when a parent can pass either a literal or a ref, and read it with unref inside a computed, so the value stays current when the parent's ref changes:
import {
type ComputedRef,
computed,
defineComponent,
html,
type RefOrValue,
unref,
} from 'regor'
export interface Greeting {
name?: RefOrValue<string>
message: ComputedRef<string>
}
const greeting = defineComponent<Greeting>(html`<p>{{ message }}</p>`, {
props: ['name'],
context: (head) => ({
message: computed(() => `Hello, ${unref(head.props.name) || 'there'}`),
}),
}) When a parent binds a ref to a prop that the component also holds as a ref, Regor links the two in both directions. That is how PureStack form components edit your state: <FormInputField :model="profile.email"> reads and writes profile.email, and <FormCheck :checked="task.done"> toggles task.done.
To report something to the parent, emit an event from the component's context. The parent listens with @ and receives a CustomEvent whose detail holds the data:
context: (head) => ({
save: () => head.emit('saved', { id: 42 }),
}) <EditorForm @saved="onSaved"></EditorForm> For setup and cleanup, give the context mounted and unmounted methods, or call onMounted and onUnmounted while the component's context is created.
Build larger apps
Regor has no app store or router that you must adopt. Larger apps are built from plain classes, and a few Regor features connect them.
Use classes as contexts. A class instance can be a component context. Its fields are the state, and its arrow-function methods are the handlers:
import { ContextRegistry, defineComponent, html, ref, sref } from 'regor'
export class Workspace {
readonly registry = new ContextRegistry()
readonly busy = ref(false)
readonly tabs = sref<string[]>([])
openTab = (name: string) => this.tabs([...this.tabs(), name])
} Reach a parent's context with requireContext. A child component asks for an ancestor's context by class. That gives it shared state and actions without passing props through every level:
export class Toolbar {
constructor(private readonly workspace: Workspace) {}
get busy() {
return this.workspace.busy
}
newTab = () => this.workspace.openTab('Untitled')
}
const toolbar = defineComponent<Toolbar>(
html`<Btn icon="tabler:plus" :disabled="busy" @click="newTab">New tab</Btn>`,
{
context: (head) => {
const workspace = head.requireContext(Workspace)
const context = new Toolbar(workspace)
workspace.registry.register(context)
return context
},
},
) findContext does the same lookup but returns undefined instead of throwing when no ancestor matches.
Find sibling contexts with a registry. When components register themselves in a ContextRegistry, any part of the app can call registry.require(Toolbar) to reach another part, such as refreshing a table after a toolbar action.
Register components where they are used. A context can carry its own components object. Those components are available to that component's template, so each feature brings the set it needs.
Render components from data. r-for over a ref of objects can create whole components at runtime. For example, opening an editor can add a tab that holds a new editor component. To switch which component fills a region, bind its name with :is, as in <section :is="currentView"></section>. Some built-in components also take a component by its registered name, such as the rowComponent of VirtualList.
Share services. ToastStore and useModalStore() from @purestack/ts-components give the whole app one place to show notifications and dialogs.
These patterns scale from a single widget to a multi-page admin tool with tables, editors, and forms. Each piece is still a typed class or function that you can read and test like any other TypeScript.
Use other libraries
A PageScript loads any TypeScript entry point. Use it for small enhancements to the page's HTML, or to mount a component from another framework. PureStack bundles the script and its npm dependencies, so you can use React, Vue, a charting library, or no library at all.
Choose where code runs
| You want to… | Use |
|---|---|
| Write content with structured layout | Built-in components in MDX |
| Repeat a section design across pages | A build-time component |
| Show page data, such as frontmatter fields | An expression or loop in MDX |
| Add an interactive tool or form | A RegorApp |
| Enhance existing HTML with a few lines of code | A PageScript |
| Embed a widget built with another framework | A PageScript |