AdaptersEleventy Adapter

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
note

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

OptionTypeDefaultDescription
configPathstring'./refrakt.config.json'Path to the refrakt config file
cssFilesstring[]CSS file paths to passthrough copy (typically from node_modules)
cssPrefixstring'/css'URL prefix for copied CSS files in the output
behaviorFilestringPath to the behaviors JS bundle for passthrough copy
jsPrefixstring'/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

OptionTypeDefaultDescription
themeEleventyThemeTheme definition (required)
contentDirstring'./content'Path to the content directory
basePathstring'/'Base URL path for all generated pages
pluginsPlugin[]Plugins to include in the content pipeline
seoSeoToHtmlOptionsSite-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.
securitySecurityPolicy'trusted'Security policy for untrusted author content. Pass 'strict' to sanitise scripts in author markdown for hosted-product use.
variablesRecord<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.

warning

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
}
note

@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

FeatureEleventySvelteKitHTML
Build toolEleventy CLIViteCustom script
Template languageNunjucks/LiquidSvelteNone (API)
Dev server--serve (live reload)vite dev (HMR)Manual
Component overridesNoYes (Svelte)No
Data loadingGlobal data fileVite plugin + virtual modulesDirect API call
OutputStatic HTMLSSR + SPAStatic HTML
Client routingNo (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

PackageRequiredPurpose
@refrakt-md/contentYesContent loading, routing, layout cascade, cross-page pipeline
@refrakt-md/transformYesIdentity transform engine, layout transform, renderToHtml
@refrakt-md/behaviorsYesClient-side progressive enhancement + hasInteractiveRunes detection
@refrakt-md/typesYesShared TypeScript interfaces
@11ty/eleventyPeerEleventy 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.