Eleventy Adapter
The Eleventy adapter (@refrakt-md/eleventy) integrates refrakt.md with Eleventy (11ty) v3. It is the simplest adapter — no Vite, no bundler, just Eleventy's template engine and data cascade. Content is loaded at build time, transformed to HTML strings, and injected into Nunjucks (or Liquid) templates.
Installation
npm install @refrakt-md/eleventy @refrakt-md/content @refrakt-md/runes @refrakt-md/transform @refrakt-md/types @refrakt-md/lumina @markdoc/markdoc
Configuration
Set target to "eleventy" in refrakt.config.json:
{
"contentDir": "./content",
"theme": "@refrakt-md/lumina",
"target": "eleventy",
"routeRules": [
{ "pattern": "**", "layout": "default" }
]
}
EleventyTheme Interface
Like the HTML adapter, the Eleventy adapter uses a theme interface with no component registry — all runes render through the identity transform and renderToHtml():
interface EleventyTheme {
manifest: ThemeManifest;
layouts: Record<string, LayoutConfig>;
}
Interactive runes get their behavior from @refrakt-md/behaviors via client-side initialization in the template.
Project Structure
A typical Eleventy + refrakt project looks like this:
my-site/
├── content/ # Markdoc content (separate from Eleventy templates)
│ ├── index.md
│ └── docs/
│ └── getting-started.md
├── _data/
│ └── refrakt.js # Global data file — loads and transforms content
├── _includes/
│ └── base.njk # Base Nunjucks template
├── pages.njk # Pagination template — one page per content item
├── eleventy.config.js # Eleventy configuration
├── refrakt.config.json # refrakt configuration
└── package.json
Keep the content directory separate from Eleventy's template input directory. Eleventy should not try to process .md files in content/ as its own Markdown — refrakt handles all Markdown processing through Markdoc.
Plugin Setup
Register the refrakt plugin in your Eleventy configuration file. The plugin configures passthrough file copy for theme CSS:
import { refraktPlugin } from '@refrakt-md/eleventy';
export default function (eleventyConfig) {
eleventyConfig.addPlugin(refraktPlugin, {
cssFiles: ['node_modules/@refrakt-md/lumina/index.css'],
cssPrefix: '/css',
});
// Ignore the content directory — refrakt processes it, not Eleventy
eleventyConfig.ignores.add('content/**');
return {
dir: {
input: '.',
includes: '_includes',
data: '_data',
output: '_site',
},
};
}
RefraktEleventyOptions
| Option | Type | Default | Description |
|---|---|---|---|
configPath | string | './refrakt.config.json' | Path to the refrakt config file |
cssFiles | string[] | — | CSS file paths to passthrough copy (typically from node_modules) |
cssPrefix | string | '/css' | URL prefix for copied CSS files in the output |
behaviorFile | string | — | Path to the behaviors JS bundle for passthrough copy |
jsPrefix | string | '/js' | URL prefix for copied JS files in the output |
Global Data File
The createDataFile function produces an Eleventy global data file that loads all refrakt content, applies the identity and layout transforms, and returns an array of page objects with pre-rendered HTML.
Create _data/refrakt.js. Read the site config so the four SEO-enrichment fields (siteName, baseUrl, defaultImage, logo) flow into every page's pre-built meta tags:
import { createDataFile } from '@refrakt-md/eleventy';
import { loadRefraktConfig, resolveSite } from '@refrakt-md/transform/node';
import manifest from '@refrakt-md/lumina/manifest';
import { layouts } from '@refrakt-md/lumina/layouts';
import { resolve } from 'node:path';
const config = loadRefraktConfig(resolve('refrakt.config.json'));
const { site } = resolveSite(config);
export default createDataFile({
theme: { manifest, layouts },
contentDir: site.contentDir,
seo: {
siteName: site.siteName,
baseUrl: site.baseUrl,
defaultImage: site.defaultImage,
logo: site.logo,
},
});
createDataFile Options
| Option | Type | Default | Description |
|---|---|---|---|
theme | EleventyTheme | — | Theme definition (required) |
contentDir | string | './content' | Path to the content directory |
basePath | string | '/' | Base URL path for all generated pages |
plugins | Plugin[] | — | Plugins to include in the content pipeline |
seo | SeoToHtmlOptions | — | Site-level SEO fields (siteName, baseUrl, defaultImage, logo) threaded into every page's emitted meta tags. Surfaces og:site_name, absolute canonical URLs, image fallback, and WebSite + Organization JSON-LD entries when supplied. |
security | SecurityPolicy | 'trusted' | Security policy for untrusted author content. Pass 'strict' to sanitise scripts in author markdown for hosted-product use. |
variables | Record<string, unknown> | — | Markdoc variables available in content via {% $name %} syntax. Real JavaScript values, not source-text expressions. |
EleventyPageData
Each item in the returned array has this shape:
interface EleventyPageData {
url: string; // e.g. '/docs/getting-started/'
title: string; // Page title from frontmatter
html: string; // Pre-rendered HTML (identity + layout transform)
seo: {
title: string; // Resolved page title
description: string; // Meta description
metaTags: string; // Pre-built <meta> tags (OG, Twitter)
jsonLd: string; // Pre-built <script type="application/ld+json"> tags
};
frontmatter: Record<string, unknown>;
contextJson: string; // Pre-serialized JSON for #rf-context (pages + currentUrl)
hasInteractiveRunes: boolean; // Whether this page needs behavior JS
}
Base Template
Use a Nunjucks template that outputs the pre-rendered HTML with the | safe filter (to prevent HTML escaping):
{# _includes/base.njk #}
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
{% if page.seo.title %}<title>{{ page.seo.title }}</title>{% endif %}
{{ page.seo.metaTags | safe }}
{{ page.seo.jsonLd | safe }}
<link rel="stylesheet" href="/css/index.css">
</head>
<body>
{{ page.html | safe }}
<script type="application/json" id="rf-context">{{ page.contextJson | safe }}</script>
{% if page.hasInteractiveRunes %}
<script type="module">
import { registerElements, RfContext, initRuneBehaviors, initLayoutBehaviors } from '/js/behaviors.js';
const contextEl = document.getElementById('rf-context');
if (contextEl) {
try {
const ctx = JSON.parse(contextEl.textContent || '{}');
RfContext.pages = ctx.pages;
RfContext.currentUrl = ctx.currentUrl;
} catch {}
}
registerElements();
initRuneBehaviors();
initLayoutBehaviors();
</script>
{% endif %}
</body>
</html>
The contextJson field contains the pre-serialized pages list and current URL. The #rf-context script element makes this data available to behaviors like navigation, search, and version-switcher. The behavior script is conditionally included based on hasInteractiveRunes — pages with only static runes ship zero JavaScript.
Always use | safe when outputting page.html, page.seo.metaTags, page.seo.jsonLd, and page.contextJson. Without it, Nunjucks escapes the HTML entities and the output renders as visible markup instead of formatted content.
A reference template is included in the package at @refrakt-md/eleventy/templates/base.njk.
Pagination
Use Eleventy's pagination to generate one HTML page per content item from the global data:
---js
{
pagination: {
data: "refrakt",
size: 1,
alias: "page"
},
permalink: "{{ page.url }}",
layout: "base.njk"
}
---
Save this as pages.njk at the root of your input directory. Eleventy iterates over the refrakt data array and generates a page for each item, using the url from the content as the output permalink.
CSS Setup
Theme CSS needs to be copied into the output directory. The plugin's cssFiles option handles this via Eleventy's passthrough file copy:
eleventyConfig.addPlugin(refraktPlugin, {
cssFiles: ['node_modules/@refrakt-md/lumina/index.css'],
cssPrefix: '/css',
});
This copies index.css to _site/css/index.css. Reference it in your template with:
<link rel="stylesheet" href="/css/index.css">
For plugin CSS, add their stylesheets to the cssFiles array:
cssFiles: [
'node_modules/@refrakt-md/lumina/index.css',
'node_modules/@refrakt-md/marketing/styles/index.css',
],
Site-level token overrides
Any theme.tokens, theme.modes, theme.presets, or site.tints you declare in refrakt.config.json becomes a :root { --rf-* } stylesheet via the writeSiteTokensCss helper. Eleventy doesn't run on Vite, so there's no virtual module — you generate the file at config-load time and passthrough-copy it like any other static asset:
import { refraktPlugin, writeSiteTokensCss } from '@refrakt-md/eleventy';
import { resolve } from 'node:path';
// Compose site-tokens CSS once at config-load time.
await writeSiteTokensCss(
resolve('refrakt.config.json'),
resolve('src/_generated/site-tokens.css'),
);
export default function (eleventyConfig) {
eleventyConfig.addPlugin(refraktPlugin, {
cssFiles: ['node_modules/@refrakt-md/lumina/index.css'],
cssPrefix: '/css',
});
eleventyConfig.addPassthroughCopy({
'src/_generated/site-tokens.css': '/css/site-tokens.css',
});
return { /* ... */ };
}
Reference the generated stylesheet in your base template after the theme barrel CSS so site-level --rf-* overrides resolve last:
<link rel="stylesheet" href="/css/index.css">
<link rel="stylesheet" href="/css/site-tokens.css">
Empty config (no overrides) still produces a (zero-byte) file, so the <link> never 404s. See the design tokens contract and the scoped tint projection pages for the full token surface.
Behavior Initialization
Interactive runes (tabs, accordion, datatable, etc.) need client-side JavaScript from @refrakt-md/behaviors. Use the plugin's behaviorFile option to copy the behaviors bundle to your output:
eleventyConfig.addPlugin(refraktPlugin, {
cssFiles: ['node_modules/@refrakt-md/lumina/index.css'],
behaviorFile: 'node_modules/@refrakt-md/behaviors/dist/index.js',
jsPrefix: '/js',
});
Or use Eleventy's passthrough copy directly:
eleventyConfig.addPassthroughCopy({
'node_modules/@refrakt-md/behaviors/dist/index.js': 'js/behaviors.js',
});
hasInteractiveRunes
The hasInteractiveRunes() utility checks whether a rendered tree contains runes that need client-side behavior initialization. Each EleventyPageData item includes a pre-computed hasInteractiveRunes boolean, which the base template uses to conditionally include the behavior script — pages with only static runes ship zero JavaScript.
You can also use hasInteractiveRunes directly in custom templates or build scripts:
import { hasInteractiveRunes } from '@refrakt-md/eleventy';
if (hasInteractiveRunes(page.renderable)) {
// Include behavior script
}
@refrakt-md/behaviors is optional. Without it, the page renders correctly but interactive runes will not have JavaScript enhancement.
ESM Compatibility
Eleventy 3.0 is ESM-native. The @refrakt-md/eleventy package, data files, and configuration files all use ES module syntax (import/export). Ensure your package.json has "type": "module".
Differences from Other Adapters
| Feature | Eleventy | SvelteKit | HTML |
|---|---|---|---|
| Build tool | Eleventy CLI | Vite | Custom script |
| Template language | Nunjucks/Liquid | Svelte | None (API) |
| Dev server | --serve (live reload) | vite dev (HMR) | Manual |
| Component overrides | No | Yes (Svelte) | No |
| Data loading | Global data file | Vite plugin + virtual modules | Direct API call |
| Output | Static HTML | SSR + SPA | Static HTML |
| Client routing | No (full page loads) | Yes (SvelteKit router) | No |
The Eleventy adapter sits between the HTML adapter and the SvelteKit adapter in complexity. It provides Eleventy's template system and data cascade without requiring a bundler, while the HTML adapter gives you a raw API and the SvelteKit adapter gives you a full application framework.
Dependencies
| Package | Required | Purpose |
|---|---|---|
@refrakt-md/content | Yes | Content loading, routing, layout cascade, cross-page pipeline |
@refrakt-md/transform | Yes | Identity transform engine, layout transform, renderToHtml |
@refrakt-md/behaviors | Yes | Client-side progressive enhancement + hasInteractiveRunes detection |
@refrakt-md/types | Yes | Shared TypeScript interfaces |
@11ty/eleventy | Peer | Eleventy v3 (ESM) |
Theme Integration
Lumina provides a dedicated Eleventy adapter export:
import manifest from '@refrakt-md/lumina/manifest';
import { layouts } from '@refrakt-md/lumina/layouts';
const theme = { manifest, layouts };
This export bundles the theme manifest and layout configurations (default, docs, blog-article) so you can pass it directly to createDataFile. Custom themes can implement the EleventyTheme interface by providing a manifest and layout map.