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.json for package configuration
  • Injects theme CSS automatically (from the configured theme field)
  • Configures SSR noExternal for 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 in refrakt.config.json is all you need
  • The manifest is loaded via createRequire().resolve() + readFileSync because it's a JSON file (dynamic import() without a type attribute fails in Node ESM for JSON)
  • Community packages listed in config.packages are loaded, merged, and their RunePackage objects are passed to loadContent() so pipeline hooks (register, aggregate, post-process) run correctly
  • getHighlightTransform() lazily initializes @refrakt-md/highlight for 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:

  1. If the node has a data-rune attribute matching a key in components, the registered .astro component renders it
  2. If the node's HTML tag name matches a key in elements, the element override renders it
  3. 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

ConcernSvelteKitAstro
RenderingRecursive Svelte RendererrenderToHtml() or recursive RfRenderer
Component registrySvelte components for custom runes.astro components via RfRenderer
Behavior cleanupSPA lifecycle (navigate, destroy)MPA — no cleanup needed
CSSVirtual module with tree-shakingDirect import
Content loading+page.server.ts with load()getStaticPaths()
HMRFull-reload on content changeVite 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.