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
Options
refrakt({
configPath: './refrakt.config.json', // default
})
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 { loadRunePackage, mergePackages, 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.packages ?? [];
if (packageNames.length > 0) {
const loaded = await Promise.all(
packageNames.map((name: string) => loadRunePackage(name))
);
const coreRuneNames = new Set(Object.keys(coreRunes));
const merged = mergePackages(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.packagesare loaded, merged, and theirRunePackageobjects 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, community package 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:
import { buildSeoHead } from '@refrakt-md/astro';
const head = buildSeoHead({
title: page.title,
frontmatter: page.frontmatter,
seo: page.seo,
});
// head.title — page title string
// head.metaTags — OG, description, twitter card meta tags
// head.jsonLd — JSON-LD structured data script tags
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.