Astro Adapter
The Astro adapter connects refrakt.md to Astro via a single package:
@refrakt-md/astro— Astro integration, BaseLayout component, rendering utilities, SEO helpers, and behavior initialization
Astro is an MPA-first framework, making it a natural fit for refrakt.md's content-first approach. All runes render through renderToHtml() with zero client-side JavaScript by default — behavior scripts are only included on pages that use interactive runes.
Installation
npm install @refrakt-md/astro @refrakt-md/content @refrakt-md/runes @refrakt-md/transform @refrakt-md/types @refrakt-md/behaviors @refrakt-md/highlight @markdoc/markdoc
Install your theme (Lumina is the default):
npm install @refrakt-md/lumina
Configuration
Create a refrakt.config.json in your project root with target set to "astro":
{
"contentDir": "./content",
"theme": "@refrakt-md/lumina",
"target": "astro",
"routeRules": [
{ "pattern": "docs/**", "layout": "docs" },
{ "pattern": "**", "layout": "default" }
]
}
Astro Integration
Add the refrakt integration to your astro.config.mjs:
import { defineConfig } from 'astro/config';
import { refrakt } from '@refrakt-md/astro';
export default defineConfig({
integrations: [refrakt()],
});
The integration:
- Reads
refrakt.config.jsonfor package configuration - Injects theme CSS automatically (from the configured
themefield) - Configures SSR
noExternalfor refrakt packages (ensures they're bundled correctly) - Watches the content directory for changes in dev mode
- Serves
virtual:refrakt/site-tokens.csscarrying any site-level token / preset / mode / tint overrides declared inrefrakt.config.json(see Site-level token overrides below)
Options
refrakt({
configPath: './refrakt.config.json', // default
site: 'main', // optional; required for multi-site configs
security: 'strict', // optional; default 'trusted'
variables: { version: '1.0.0' }, // optional; available as `{% $version %}`
})
| Option | Type | Description |
|---|---|---|
configPath | string | Path to refrakt.config.json. Default: './refrakt.config.json' |
site | string | Which site to use from a multi-site config |
security | SecurityPolicy | Security policy for untrusted author content. Default: 'trusted' (no sanitisation). Pass 'strict' for hosted-product use. |
variables | Record<string, unknown> | Markdoc variables available in content via {% $name %} syntax. Values are real JavaScript values consumed at runtime (different from the SvelteKit plugin's source-text-expression shape). |
Site-level token overrides
Any theme.tokens, theme.modes, theme.presets, or site.tints you declare in refrakt.config.json is automatically picked up by the Astro adapter — no manual CSS authoring required. The integration computes the override CSS once at build time and ships it as virtual:refrakt/site-tokens.css, imported after the theme package's base CSS so the --rf-* cascade resolves to your overrides last.
{
"sites": {
"main": {
"theme": {
"package": "@refrakt-md/lumina",
"presets": ["@refrakt-md/lumina/presets/nord"],
"tokens": {
"color": { "text": "#1a1a1a" }
},
"modes": {
"dark": { "color": { "text": "#f5f5f5" } }
}
},
"tints": {
"nord-scoped": {
"extends": "@refrakt-md/lumina/presets/nord"
}
}
}
}
}
The above produces:
:root { --rf-color-text: #1a1a1a; ... }(active preset + inline override)[data-color-scheme="dark"] { --rf-color-text: #f5f5f5; ... }(mode overlay)[data-tint="nord-scoped"] { --rf-syntax-keyword: ...; ... }(scoped tint projection)
See the design tokens contract and the scoped tint projection pages for the full token surface.
AstroTheme Interface
The Astro adapter uses the AstroTheme interface for theme objects:
interface AstroTheme {
manifest: ThemeManifest;
layouts: Record<string, LayoutConfig>;
}
By default all runes render through the identity transform and renderToHtml(). Interactive runes are enhanced client-side by @refrakt-md/behaviors. For runes that need custom rendering, use RfRenderer with component overrides — see Component Overrides below.
Project Structure
src/
├── setup.ts # Theme + transform initialization (reads refrakt.config.json)
├── pages/
│ └── [...slug].astro # Catch-all route for content pages
├── layouts/ # (optional) custom Astro layouts
content/
├── docs/
│ ├── _layout.md # Layout cascade for docs section
│ └── getting-started.md
├── _layout.md # Root layout
└── index.md
astro.config.mjs
refrakt.config.json
Setup Module
The src/setup.ts module reads refrakt.config.json and initializes the theme, transform pipeline, and content loader. It dynamically imports the theme's manifest and layouts based on the theme field in your config — so the page template never hardcodes a specific theme package:
import { loadContent } from '@refrakt-md/content';
import { assembleThemeConfig, createTransform } from '@refrakt-md/transform';
import { loadPlugin, mergePlugins, runes as coreRunes } from '@refrakt-md/runes';
import type { RefraktConfig } from '@refrakt-md/types';
import type { Schema } from '@markdoc/markdoc';
import { readFileSync } from 'node:fs';
import * as path from 'node:path';
const config: RefraktConfig = JSON.parse(
readFileSync(path.resolve('refrakt.config.json'), 'utf-8')
);
const contentDir = path.resolve(config.contentDir);
const routeRules = config.routeRules ?? [{ pattern: '**', layout: 'default' }];
let _transform: ((tree: any) => any) | null = null;
let _hl: { (tree: any): any; css: string } | null = null;
let _theme: { manifest: any; layouts: any } | null = null;
let _communityTags: Record<string, Schema> | undefined;
let _packages: any[] | undefined;
async function init() {
if (_transform) return;
const [themeModule, layoutsModule] = await Promise.all([
import(config.theme + '/transform'),
import(config.theme + '/layouts'),
]);
// Manifest is a JSON file — resolve its path and read directly
const { createRequire: cr } = await import('node:module');
const manifestPath = cr(import.meta.url).resolve(config.theme + '/manifest');
const manifest = JSON.parse(readFileSync(manifestPath, 'utf-8'));
_theme = {
manifest: { ...manifest, routeRules },
layouts: layoutsModule.layouts,
};
const themeConfig = themeModule.themeConfig ?? themeModule.luminaConfig ?? themeModule.default;
let transformConfig = themeConfig;
const packageNames = config.plugins ?? [];
if (packageNames.length > 0) {
const loaded = await Promise.all(
packageNames.map((name: string) => loadPlugin(name))
);
const coreRuneNames = new Set(Object.keys(coreRunes));
const merged = mergePlugins(loaded, coreRuneNames, config.runes?.prefer);
_communityTags = Object.keys(merged.tags).length > 0 ? merged.tags : undefined;
_packages = loaded.map((l: any) => l.pkg);
const { config: assembledConfig } = assembleThemeConfig({
coreConfig: themeConfig,
packageRunes: merged.themeRunes,
packageIcons: merged.themeIcons,
packageBackgrounds: merged.themeBackgrounds,
extensions: merged.extensions as any,
provenance: merged.provenance,
});
transformConfig = assembledConfig;
}
_transform = createTransform(transformConfig);
}
export async function getTransform() { await init(); return _transform!; }
export async function getTheme() { await init(); return _theme!; }
export async function getHighlightTransform() {
if (_hl) return _hl;
const { createHighlightTransform } = await import('@refrakt-md/highlight');
_hl = await createHighlightTransform((config as any).highlight);
return _hl;
}
export async function getSite() {
await init();
return loadContent(contentDir, '/', {}, _communityTags, _packages);
}
The key details:
config.theme(e.g."@refrakt-md/lumina") drives the dynamic imports — switching themes inrefrakt.config.jsonis all you need- The manifest is loaded via
createRequire().resolve()+readFileSyncbecause it's a JSON file (dynamicimport()without a type attribute fails in Node ESM for JSON) - Community packages listed in
config.pluginsare loaded, merged, and theirPluginobjects are passed toloadContent()so pipeline hooks (register, aggregate, post-process) run correctly getHighlightTransform()lazily initializes@refrakt-md/highlightfor syntax highlighting — the highlight transform runs after the identity transform
Content Loading
Use Astro's getStaticPaths() to generate pages from your content directory. The setup module handles config loading, plugin merging, theme assembly, and caching automatically:
---
import { getTransform, getSite, getTheme, getHighlightTransform } from '../setup';
import { renderPage, buildSeoHead } from '@refrakt-md/astro';
import type { RendererNode } from '@refrakt-md/types';
export async function getStaticPaths() {
const [transform, site, hl] = await Promise.all([getTransform(), getSite(), getHighlightTransform()]);
return site.pages
.filter((p) => !p.route.draft)
.map((page) => {
const renderable = hl(transform(page.renderable)) as RendererNode;
const regions = {};
for (const [name, region] of page.layout.regions.entries()) {
regions[name] = {
name: region.name,
mode: region.mode,
content: region.content.map((c) => hl(transform(c)) as RendererNode),
};
}
const pages = site.pages
.filter((p) => !p.route.draft)
.map((p) => ({
url: p.route.url,
title: p.frontmatter.title ?? '',
draft: false,
}));
const slug = page.route.url === '/' ? undefined : page.route.url.slice(1);
return {
params: { slug },
props: {
page: {
renderable,
regions,
title: page.frontmatter.title ?? '',
url: page.route.url,
pages,
frontmatter: page.frontmatter,
headings: page.headings,
},
seo: page.seo,
highlightCss: hl.css,
},
};
});
}
const { page, seo, highlightCss } = Astro.props;
const theme = await getTheme();
const html = renderPage({ theme, page });
const head = buildSeoHead({ title: page.title, frontmatter: page.frontmatter, seo });
const needsBehaviors = html.includes('data-layout-behaviors') || html.includes('data-rune=');
const contextData = JSON.stringify({ pages: page.pages, currentUrl: page.url });
---
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
{head.title && <title>{head.title}</title>}
<Fragment set:html={head.metaTags} />
<Fragment set:html={head.jsonLd} />
{highlightCss && <style set:html={highlightCss} />}
</head>
<body>
<Fragment set:html={html} />
<script type="application/json" id="rf-context" set:html={contextData} />
{needsBehaviors && (
<script>
import { registerElements, RfContext, initRuneBehaviors, initLayoutBehaviors } from '@refrakt-md/behaviors';
function init() {
const el = document.getElementById('rf-context');
if (el) {
try {
const ctx = JSON.parse(el.textContent || '{}');
RfContext.pages = ctx.pages;
RfContext.currentUrl = ctx.currentUrl;
} catch {}
}
registerElements();
initRuneBehaviors();
initLayoutBehaviors();
}
init();
document.addEventListener('astro:page-load', () => init());
</script>
)}
</body>
</html>
BaseLayout Component
For convenience, @refrakt-md/astro provides a BaseLayout.astro component that handles layout selection, rendering, SEO injection, and conditional behavior loading:
---
import BaseLayout from '@refrakt-md/astro/BaseLayout.astro';
import { getSite, getTransform, getTheme } from '../setup';
export async function getStaticPaths() {
// ... same content loading as above
}
const { page, seo } = Astro.props;
const theme = await getTheme();
---
<BaseLayout {theme} {page} {seo} />
The BaseLayout component:
- Selects the layout via
matchRouteRule()using your route rules - Runs
layoutTransform()to produce the full page tree (sidebar, TOC, breadcrumbs) - Renders via
renderToHtml()+set:html - Injects SEO meta tags (Open Graph, JSON-LD) into
<head> - Conditionally includes the behavior script only on pages with interactive runes
Slots
BaseLayout accepts named slots for customization:
<BaseLayout {theme} {page} {seo}>
<link slot="head" rel="icon" href="/favicon.svg" />
<script slot="body-end" src="/analytics.js" />
</BaseLayout>
CSS Injection
The refrakt() integration automatically injects CSS from the theme specified in refrakt.config.json. No manual CSS imports are needed in your page templates — changing the theme field in your config is sufficient.
Syntax Highlighting
The setup module exposes getHighlightTransform() which lazily initializes @refrakt-md/highlight. The highlight transform runs after the identity transform and produces CSS that must be injected into <head>:
// In getStaticPaths():
const [transform, site, hl] = await Promise.all([getTransform(), getSite(), getHighlightTransform()]);
// Apply both transforms — identity first, then highlight:
const renderable = hl(transform(page.renderable));
// Pass the generated CSS as a prop:
return { params: { slug }, props: { /* ... */ highlightCss: hl.css } };
In the page template, inject the CSS with a <style> tag:
{highlightCss && <style set:html={highlightCss} />}
Install the highlight package:
npm install @refrakt-md/highlight
SEO
The buildSeoHead() helper transforms page SEO data into HTML meta tag strings. Pass per-page input (title, frontmatter, seo) plus the four site-level fields read from refrakt.config.json (siteName, baseUrl, defaultImage, logo) and the helper emits a complete OG + JSON-LD bundle:
import { buildSeoHead } from '@refrakt-md/astro';
import { loadRefraktConfig, resolveSite } from '@refrakt-md/transform/node';
const { site } = resolveSite(loadRefraktConfig('refrakt.config.json'));
const head = buildSeoHead({
title: page.title,
frontmatter: page.frontmatter,
seo: page.seo,
// Site-level fields surface og:site_name, absolute canonical URLs,
// image fallback, and WebSite + Organization JSON-LD entries.
siteName: site.siteName,
baseUrl: site.baseUrl,
defaultImage: site.defaultImage,
logo: site.logo,
});
// head.title — page title string
// head.metaTags — OG, description, twitter, og:site_name meta tags
// head.jsonLd — page JSON-LD + WebSite + Organization script tags
When the site-level fields are omitted the output stays minimal — only per-page meta tags are emitted, matching the pre-v0.14.4 behaviour. The four fields live at the top level of SiteConfig:
{
"sites": {
"main": {
"contentDir": "./content",
"theme": "@refrakt-md/lumina",
"siteName": "Refrakt",
"baseUrl": "https://refrakt.md",
"defaultImage": "/og-image.png",
"logo": "/favicon-192.png"
}
}
}
Behavior Initialization
Behaviors are interactive enhancements for runes like tabs, accordions, and data tables. The Astro adapter ships zero JavaScript for pages that only use static runes.
Conditional Loading
After rendering the page HTML, check whether it contains interactive runes or layout behaviors:
const html = renderPage({ theme, page });
const needsBehaviors = html.includes('data-layout-behaviors') || html.includes('data-rune=');
View Transitions
When using Astro View Transitions, behaviors must re-initialize after each navigation. The behavior script listens to the astro:page-load event:
document.addEventListener('astro:page-load', () => {
registerElements();
initRuneBehaviors();
initLayoutBehaviors();
});
This is handled automatically by the BaseLayout component.
Component Overrides
While most runes need only the identity transform, you can register native .astro components for runes that need custom rendering. Use RfRenderer instead of renderPage() to enable component dispatch:
---
import RfRenderer from '@refrakt-md/astro/RfRenderer.astro';
import Table from '@refrakt-md/astro/elements/Table.astro';
import Pre from '@refrakt-md/astro/elements/Pre.astro';
import MyRecipe from '../components/MyRecipe.astro';
const components = { recipe: MyRecipe };
const elements = { table: Table, pre: Pre };
// ... getStaticPaths() as before
const { page } = Astro.props;
---
<RfRenderer node={page.renderable} components={components} elements={elements} />
How it works
RfRenderer recursively walks the renderable tree. For each tag node:
- If the node has a
data-runeattribute matching a key incomponents, the registered.astrocomponent renders it - If the node's HTML tag name matches a key in
elements, the element override renders it - Otherwise, the node renders as plain HTML via
renderToHtml()
Component props
Component overrides receive:
- Extracted properties as named props (e.g.,
prepTime,difficulty) - Named refs as named Astro slots (e.g.,
<slot name="headline" />) - Anonymous content as the default slot
tag— the original tag object for escape-hatch access
---
// components/MyRecipe.astro
const { prepTime, difficulty, tag } = Astro.props;
---
<div class="my-recipe" data-difficulty={difficulty}>
<header>
<slot name="headline" />
{prepTime && <span class="prep-time">{prepTime}</span>}
</header>
<div class="body">
<slot />
</div>
</div>
Theme Integration
When creating a theme for Astro, export an AstroTheme object from the ./astro subpath:
// astro/index.ts
import type { AstroTheme } from '@refrakt-md/astro';
import { defaultLayout, docsLayout } from '@refrakt-md/transform';
export const theme: AstroTheme = {
manifest: { /* ... */ },
layouts: {
default: defaultLayout,
docs: docsLayout,
},
};
Differences from SvelteKit
| Concern | SvelteKit | Astro |
|---|---|---|
| Rendering | Recursive Svelte Renderer | renderToHtml() or recursive RfRenderer |
| Component registry | Svelte components for custom runes | .astro components via RfRenderer |
| Behavior cleanup | SPA lifecycle (navigate, destroy) | MPA — no cleanup needed |
| CSS | Virtual module with tree-shaking | Direct import |
| Content loading | +page.server.ts with load() | getStaticPaths() |
| HMR | Full-reload on content change | Vite watcher via integration |
Compatibility Notes
@astrojs/markdoc coexistence: This adapter replaces @astrojs/markdoc — it does not supplement it. Refrakt needs the full schema transform pipeline (rune models, content models, meta tag injection) which cannot be expressed as simple Markdoc tag registrations. For a lighter integration that preserves Astro's content collections, use @refrakt-md/vite instead.
Astro content collections: This adapter uses loadContent() + getStaticPaths(), bypassing Astro's native content collections. The refrakt content pipeline provides richer cross-page features (entity registry, aggregation, layout cascade) than content collections alone.