Plan
ID:SPEC-013Status:accepted

Multi-Framework Support: Layout Transform Architecture

Problem

Refrakt currently targets SvelteKit only. Adding Astro support (and eventually others) is straightforward for the rendering pipeline — most of it is already framework-agnostic. The hard problem is layouts. Today, layouts are full Svelte components (DefaultLayout, DocsLayout, BlogLayout) that mix structural placement, injected UI chrome, derived content, interactive behavior, and SPA concerns. They can't be reused across frameworks, and maintaining parallel implementations per framework doesn't scale.

Key Insight

The identity transform engine already solves this exact problem for runes: it takes a declarative config and produces a complete, enhanced tag tree with BEM classes, structural elements, icons, and data attributes — all framework-agnostic. Behaviors handle interactivity via DOM queries.

Layouts are structurally the same problem. They can go through the same pipeline.

Proposed Extension: Layout Transform

A new transform step that sits between the identity transform and the renderer:

Markdown Parse Schema Transform Serialize
 Identity Transform (runes)
 Layout Transform (new!)     ← wraps page in layout structure
 Renderer

The layout transform takes the transformed renderable + regions + page metadata and produces a single SerializedTag tree with everything in place. The renderer just walks the tree — no layout awareness needed.

Decomposition of Existing Layouts

Every line across all three layouts falls into five categories:

1. Structural Placement (engine already does this for runes)

"If region X exists, wrap it in <tag class='rf-layout__region'>."

Same concept as the engine's structure config. The engine just needs to learn about regions as a slot concept.

2. Injected UI Chrome (engine already does this for runes)

Mobile menu button, hamburger icon, close button, panel wrappers — static SVG + HTML injected into the structure. Identical to how the engine injects headers, icons, and badges via StructureEntry.

3. Derived Content (new — but small scope)

Five identified cases (two in current layouts, three common across docs/blog sites):

  • Breadcrumbs (DocsLayout): walks the nav region tree to build slug → groupTitle map, renders category › page. Pure function on nav tree + current URL.
  • Table of contents (DocsLayout): filters page headings (h2/h3), renders anchor links with scroll-spy. Pure function on headings array. Conditionally visible (2+ headings, frontmatter opt-out).
  • Prev/next navigation: walks nav tree to find current page's neighbors, emits previous/next links. Universal in docs sites (Docusaurus, VitePress, GitBook, ReadTheDocs).
  • Related pages: filters pages list by shared tags, emits a sidebar list. Common in knowledge bases and blog sidebars.
  • Reading time: counts words in content tree, emits "5 min read" span. Common in blog themes.

All can be computed at transform time as pre-built SerializedTag trees and injected as structural elements. The content system already has the page list, nav tree, and headings.

Note: Blog post listings (index page with post cards) are a rune-system concern, not a layout concern. A {% blog-index %} rune queries content at build time and renders through the standard rune pipeline. See "Boundary: Computed Content vs Runes" below.

4. Interactive Behavior (solved by @refrakt-md/behaviors)

Mobile menu toggle, panel open/close, body scroll lock, escape key dismiss — same pattern as tabsBehavior, accordionBehavior, etc. Discover elements by data-* attributes, wire up event listeners, return cleanup function.

New behaviors needed:

  • mobile-menu — toggle panel visibility, body scroll lock, escape dismiss
  • mobile-nav-panel — secondary panel for docs nav, same pattern
  • scrollspy — highlight active heading in TOC Already implemented in packages/behaviors/src/behaviors/scrollspy.ts — pure DOM IntersectionObserver, discovers [data-scrollspy] containers, sets data-active on active <li>. Registered in initRuneBehaviors() alongside copyBehavior.

5. SPA Concerns (framework-specific, thin)

  • {#key page.url} for full DOM recreation on navigate (SvelteKit only)
  • Close-on-navigate effects (SvelteKit only, irrelevant for Astro MPA)

These stay in the framework adapter's thin wrapper.

Layout Config Interface

interface LayoutConfig {
  /** BEM block name for the layout */
  block: string;

  /** Root HTML element */
  tag?: string; // defaults to 'div'

  /** Structural slots — where regions and content go */
  slots: Record<string, LayoutSlot>;

  /** Static chrome injected into the structure (buttons, icons, panels, page metadata) */
  chrome?: Record<string, LayoutStructureEntry>;

  /** Computed content — built from page data at transform time */
  computed?: Record<string, ComputedContent>;

  /** Layout behaviors to attach via @refrakt-md/behaviors */
  behaviors?: string[];
}

interface LayoutSlot {
  /** HTML wrapper tag */
  tag: string;
  /** CSS class(es) */
  class?: string;
  /** What fills this slot:
   *  - 'region:<name>' — contents of a named region
   *  - 'content' — the main page renderable
   *  - 'computed:<name>' — output of a computed content builder
   *  - 'clone:region:<name>' — cloned copy of a region (for mobile panels)
   *  - 'chrome:<name>' — output of a named chrome entry */
  source?: string;
  /** Only render this slot if the source region/computed exists */
  conditional?: boolean;
  /** Only render this slot if a frontmatter key is truthy (or not explicitly false) */
  frontmatterCondition?: string;
  /** Wrapper element for inner content */
  wrapper?: { tag: string; class: string; conditionalModifier?: { computed: string; modifier: string } };
  /** Static children or nested structure (when present, `source` on parent is omitted) */
  children?: (string | LayoutSlot | LayoutStructureEntry)[];
  /** Conditional BEM modifier class based on region existence */
  conditionalModifier?: { region: string; modifier: string };
}

interface ComputedContent {
  /** Type of computed content */
  type: 'breadcrumb' | 'toc' | 'prev-next' | 'related-pages' | 'reading-time';
  /** Data source: region name, 'pages', 'headings', 'content', etc. */
  source: string;
  /** Type-specific options */
  options?: Record<string, any>;
  /** When to show this computed content (if omitted, always shown) */
  visibility?: {
    /** Minimum count of source items needed */
    minCount?: number;
    /** Frontmatter key that can disable it (value=false hides) */
    frontmatterToggle?: string;
  };
}

/** Extended StructureEntry for layout chrome — adds page data access and iteration */
interface LayoutStructureEntry extends StructureEntry {
  /** Inject text from page-level data: 'title', 'url', 'frontmatter.date', etc. */
  pageText?: string;
  /** Only inject this element if the referenced page field is truthy */
  pageCondition?: string;
  /** Date formatting when pageText resolves to a date string */
  dateFormat?: Intl.DateTimeFormatOptions;
  /** Repeat this element for each item in a page data array (e.g. 'frontmatter.tags') */
  iterate?: { source: string };
  /** Set attributes from page data (parallel to StructureEntry's fromModifier) */
  attrs?: Record<string, string | { fromModifier: string } | { fromPageData: string }>;
}

Concrete Example: DocsLayout as Config

const docsLayout: LayoutConfig = {
  block: 'docs',
  slots: {
    header: {
      tag: 'header',
      class: 'rf-docs-header',
      source: 'region:header',
      conditional: true,
      wrapper: { tag: 'div', class: 'rf-docs-header__inner' },
      // mobile menu button injected via chrome
    },
    'mobile-panel': {
      tag: 'div',
      class: 'rf-mobile-panel',
      source: 'clone:region:header', // duplicates header content into panel
      conditional: true, // only if header region exists
      children: [
        // close button, title — via chrome entries
      ],
    },
    toolbar: {
      tag: 'div',
      class: 'rf-docs-toolbar',
      source: 'computed:breadcrumb',
      conditional: true, // only if nav region exists
      // hamburger button via chrome
    },
    'mobile-nav-panel': {
      tag: 'div',
      class: 'rf-mobile-panel rf-mobile-panel--nav',
      source: 'clone:region:nav',
      conditional: true,
    },
    sidebar: {
      tag: 'aside',
      class: 'rf-docs-sidebar',
      source: 'region:nav',
      conditional: true,
    },
    content: {
      tag: 'main',
      class: 'rf-docs-content',
      conditionalModifier: { region: 'nav', modifier: 'has-nav' },
      wrapper: {
        tag: 'div',
        class: 'rf-docs-content__inner',
        conditionalModifier: { computed: 'toc', modifier: 'has-toc' },
      },
      children: [
        { tag: 'div', class: 'rf-docs-content__body', source: 'content' },
        { tag: 'aside', class: 'rf-docs-toc', source: 'computed:toc', conditional: true },
      ],
    },
    'prev-next': {
      tag: 'nav',
      class: 'rf-docs-prev-next',
      source: 'computed:prevNext',
      conditional: true,
    },
  },
  computed: {
    breadcrumb: {
      type: 'breadcrumb',
      source: 'region:nav',
      // walks nav tree to build slug→groupTitle map
      // emits: <div class="rf-docs-toolbar__breadcrumb">
      //          <span class="rf-docs-breadcrumb-category">Group</span>
      //          <span class="rf-docs-breadcrumb-sep">›</span>
      //          <span class="rf-docs-breadcrumb-page">Page Title</span>
      //        </div>
    },
    toc: {
      type: 'toc',
      source: 'headings',
      options: { levels: [2, 3] },
      visibility: { minCount: 2, frontmatterToggle: 'toc' },
      // emits: <nav class="rf-on-this-page" data-scrollspy>
      //          <p class="rf-on-this-page__title">On this page</p>
      //          <ul class="rf-on-this-page__list">
      //            <li class="rf-on-this-page__item" data-level="2">
      //              <a href="#id">Heading text</a>
      //            </li>
      //          </ul>
      //        </nav>
      // The data-scrollspy attribute enables scrollspyBehavior (already in @refrakt-md/behaviors)
      // to discover the TOC and highlight the active heading as the user scrolls.
    },
    prevNext: {
      type: 'prev-next',
      source: 'region:nav',
      // walks nav tree to find current page, emits:
      // <a data-name="prev" href="/prev-url">
      //   <span data-name="label">Previous</span>
      //   <span data-name="title">Page Title</span>
      // </a>
      // <a data-name="next" href="/next-url">
      //   <span data-name="label">Next</span>
      //   <span data-name="title">Page Title</span>
      // </a>
    },
  },
  chrome: {
    'mobile-menu-btn': {
      tag: 'button',
      ref: 'mobile-menu-btn',
      attrs: { 'aria-label': 'Open menu', class: 'rf-mobile-menu-btn' },
      children: [
        // SVG dots icon
      ],
    },
    'mobile-panel-close': {
      tag: 'button',
      ref: 'mobile-panel-close',
      attrs: { 'aria-label': 'Close menu', class: 'rf-mobile-panel__close' },
      children: [
        // SVG X icon
      ],
    },
    'toolbar-hamburger': {
      tag: 'button',
      ref: 'toolbar-hamburger',
      attrs: { 'aria-label': 'Toggle navigation', class: 'rf-docs-toolbar__hamburger' },
      children: [
        // SVG hamburger icon
      ],
    },
  },
  behaviors: ['mobile-menu', 'mobile-nav-panel'],
};

Layout Transform Function

function layoutTransform(
  config: LayoutConfig,
  page: {
    renderable: RendererNode;
    regions: Record<string, { name: string; mode: string; content: RendererNode[] }>;
    title: string;
    url: string;
    pages: PageEntry[];
    frontmatter: Record<string, unknown>;
    headings?: Array<{ level: number; text: string; id: string }>;
  }
): SerializedTag {
  // 1. Build computed content (breadcrumbs, TOC, prev/next, etc.)
  const computed = buildComputedContent(config.computed, page);

  // 2. Resolve each slot — skip conditional slots with missing regions
  const children = resolveSlots(config.slots, page, computed, config.chrome);

  // 3. Wrap in root element with layout BEM class + data-layout attribute
  return makeTag(config.tag ?? 'div', {
    class: `${prefix}-layout ${prefix}-layout--${config.block}`,
    'data-layout': config.block,
    // behaviors discover via [data-layout]
  }, children);
}

What Framework Adapters Become

Svelte Adapter (~25 lines)

<script>
  import { Renderer } from '@refrakt-md/svelte';
  import { layoutTransform } from '@refrakt-md/transform';
  import { initBehaviors } from '@refrakt-md/behaviors';

  let { theme, page } = $props();

  const tree = $derived(layoutTransform(
    theme.layouts[matchRouteRule(page.url, theme.manifest.routeRules ?? [])],
    page
  ));

  $effect(() => {
    void page.url;
    return initBehaviors();
  });
</script>

<svelte:head><!-- SEO tags --></svelte:head>

{#key page.url}
  <Renderer node={tree} />
{/key}

Astro Adapter (~20 lines)

---
import Renderer from '@refrakt-md/astro/Renderer.astro';
import { layoutTransform } from '@refrakt-md/transform';

const { page, layoutConfig } = Astro.props;
const tree = layoutTransform(layoutConfig, page);
---

<html>
<head><!-- SEO tags --></head>
<body>
  <Renderer node={tree} />
  <script>
    import { initBehaviors } from '@refrakt-md/behaviors';
    initBehaviors();
  </script>
</body>
</html>

What Remains Framework-Specific

ConcernSvelteAstro
RendererRenderer.svelte (recursive <svelte:element>)Renderer.astro (recursive Astro.self)
<head> management<svelte:head>Astro <head> in base layout
Behavior init$effectinitBehaviors()<script>initBehaviors()
SPA concerns{#key page.url}, close-on-navigateN/A (MPA = fresh page)
Interactive runesSvelte components (direct)Svelte islands (client:visible)
Page data loading+page.server.tsgetStaticPaths() or content collections

Implementation Plan

Phase 1: Layout Transform (framework-agnostic) — Complete (cdbaef2)

Built the core layout transform engine and all supporting infrastructure:

  • Types (packages/transform/src/types.ts): LayoutConfig, LayoutSlot, ComputedContent, LayoutStructureEntry, LayoutPageData interfaces
  • Engine (packages/transform/src/layout.ts): layoutTransform() with slot resolution, chrome building (with pageText/pageCondition/iterate/dateFormat/svg), frontmatter conditions, conditional modifiers, conditionalRegion
  • Computed builders (packages/transform/src/computed.ts):
    • buildBreadcrumb() — nav tree → slug/groupTitle map → breadcrumb trail
    • buildToc() — headings → anchor links with data-scrollspy
    • buildPrevNext() — nav tree → previous/next page links
  • Layout behaviors (packages/behaviors/src/):
    • mobileMenuBehavior — panel toggling via [data-open] attribute model
    • initLayoutBehaviors() — discovers [data-layout-behaviors] elements
  • Tests: 30 layout transform tests covering slots, chrome, computed content, behaviors, conditional rendering, and edge cases

Phase 2: Migrate Svelte Adapter — Complete (d8812f3)

Integrated the layout transform into the Svelte rendering pipeline:

  • Svelte types (packages/svelte/src/theme.ts): SvelteTheme union type with layouts: Record<string, Component<any> | LayoutConfig> + isLayoutConfig() discriminator
  • Dual-mode rendering (packages/svelte/src/ThemeShell.svelte): detects LayoutConfig vs Svelte component at render time, calls layoutTransform() for config-based layouts
  • Layout configs (packages/theme-base/src/layouts.ts): defaultLayout, docsLayout, blogArticleLayout — three declarative configs replacing the three Svelte layout components
  • Mobile CSS (packages/lumina/styles/layouts/mobile.css): [data-open] visibility model for panels, body scroll lock coordination
  • OnThisPage styles (packages/lumina/styles/layouts/on-this-page.css): extracted from Svelte component to CSS (works with both component and layout transform output)
  • Renderer (packages/svelte/src/Renderer.svelte): added data-raw-html support for injecting SVG strings without escaping
  • Site config (site/svelte.config.js): handleMissingId: 'warn' for pre-existing heading ID mismatches in rune-restructured content

Runtime fixes during integration:

  • structuredClone for clone:region: sources (replaces JSON.parse/stringify, handles potential circular references in deeply nested content trees)
  • Shallow copy [...region.content] in resolveSource for region: sources (prevents mutation of original region data when slots add children, which caused circular references)
  • conditionalRegion on outer header slots (checks region existence without adding content as children, preventing duplicated content when inner child also sources the same region)

Phase 3: Astro Integration

  1. Create packages/astro/ with Renderer.astro (recursive tree walker)
  2. Create Astro Vite plugin (reuse virtual module logic from sveltekit package)
  3. Create packages/lumina/astro/ adapter (tokens, registry)
  4. Wire interactive Svelte components as client:visible islands
  5. Build example Astro site

Design Considerations

Blog Layout Dual-Mode

The blog has two distinct page types: index (post list) and article (individual post).

Blog index pages use a {% blog-index %} rune as their primary content. The rune queries content at build time, filters/sorts posts, and renders post cards through the standard rune pipeline (schema → identity transform → CSS). This can be placed manually in markdown or generated by a virtual route system. The layout for a blog index page is structurally simple — it doesn't need to know about post listing because that's handled by the rune in the content.

Blog article pages use a blog-article layout config with frontmatter-sourced chrome (title, date, author, tags) — this is legitimately layout-level structure since it's metadata display that wraps the article content.

Two layouts selected by route rules:

{ "pattern": "blog", "layout": "blog-index" },
{ "pattern": "blog/*", "layout": "blog-article" }

The blog-index layout may be as simple as a default layout (the rune handles the content). The blog-article layout has the frontmatter chrome shown in the example below.

Region Cloning for Mobile Panels

Mobile panels duplicate region content (header content appears in both the header bar and the mobile panel). The layout transform needs to deep-clone SerializedTag trees. This is straightforward since they're plain objects, but worth noting as a design choice — the same content renders in two places.

Interaction Between Layout Behaviors and Rune Behaviors

Layout behaviors (mobile-menu) and rune behaviors (tabs, accordion) both call initBehaviors(). They should share the same discovery mechanism:

  • Rune behaviors: [data-rune="tabgroup"]
  • Layout behaviors: [data-layout="docs"] or [data-layout-behavior="mobile-menu"]

Both return cleanup functions, both are initialized by the same initBehaviors() entry point.

TOC as Computed Content (validated by implementation)

The existing OnThisPage.svelte component in lumina is a pure function from headings → HTML. Under the layout transform model, the toc computed content builder produces this as a SerializedTag tree instead, which the Renderer walks like any other markup. This eliminates the Svelte component entirely — the scoped styles move to packages/lumina/styles/ as regular CSS (consistent with how all other rune styles work).

Key details:

  • Builder emits data-scrollspy on the <nav> so scrollspyBehavior discovers it automatically
  • Visibility is resolved at transform time: check heading count against visibility.minCount and frontmatter against visibility.frontmatterToggle — if conditions fail, omit the computed node and the --has-toc modifier on the wrapper
  • CSS handles responsive hiding (@media (max-width: 1100px)) — not a transform concern
  • The __inner flex wrapper pattern (content body + computed sidebar) may generalize to other layouts wanting an adjacent sidebar (e.g. "related articles")

Boundary: Computed Content vs Runes

ComputedContent builders produce layout-level navigation aids and metadata displays derived from page structure (headings, nav tree, page list). They are structural chrome that wraps or accompanies the page content — breadcrumbs, TOC, prev/next links, reading time.

Content-level queries that produce the primary substance of a page — blog post listings, tag indexes, search results — belong in the rune system and flow through the standard transform pipeline as page content. A {% blog-index %} rune queries content at build time, renders post cards through schema → identity transform → CSS, and can be placed in any markdown file or generated by the virtual route system.

The test: if removing the element leaves the page feeling empty, it's content (rune). If removing it leaves the page content intact but harder to navigate, it's chrome (computed).

Escape Hatch

Like RuneConfig.postTransform, LayoutConfig should support a postTransform hook for cases that can't be expressed declaratively. This keeps the system extensible without abandoning the declarative model.

Additional Layout Examples

Blog Article Layout (with frontmatter-sourced chrome)

Demonstrates LayoutStructureEntry with pageText, pageCondition, iterate, and dateFormat:

const blogArticleLayout: LayoutConfig = {
  block: 'blog-article',
  slots: {
    header: { tag: 'header', class: 'rf-blog-header', source: 'region:header',
              conditional: true, wrapper: { tag: 'div', class: 'rf-blog-header__inner' } },
    content: {
      tag: 'article', class: 'rf-blog-article',
      children: [
        { tag: 'header', class: 'rf-blog-article__header', source: 'chrome:article-header' },
        { tag: 'div', class: 'rf-blog-article__body', source: 'content' },
      ],
    },
    sidebar: { tag: 'aside', class: 'rf-blog-sidebar', source: 'region:sidebar',
               conditional: true, frontmatterCondition: 'sidebar' },
    footer: { tag: 'footer', class: 'rf-blog-footer', source: 'region:footer',
              conditional: true },
  },
  chrome: {
    'article-header': {
      tag: 'header', ref: 'article-header',
      children: [
        { tag: 'h1', ref: 'title', pageText: 'title' },
        { tag: 'div', ref: 'meta', pageCondition: 'frontmatter.date', children: [
          { tag: 'time', ref: 'date', pageText: 'frontmatter.date',
            dateFormat: { year: 'numeric', month: 'long', day: 'numeric' },
            attrs: { datetime: { fromPageData: 'frontmatter.date' } } },
          { tag: 'span', ref: 'author', pageText: 'frontmatter.author',
            pageCondition: 'frontmatter.author' },
        ]},
        { tag: 'div', ref: 'tags', pageCondition: 'frontmatter.tags', children: [
          { tag: 'span', ref: 'tag', iterate: { source: 'frontmatter.tags' } },
        ]},
      ],
    },
  },
  behaviors: ['mobile-menu'],
};

Tutorial Layout (Docusaurus/VitePress-style)

Same as DocsLayout but with prev/next navigation, demonstrating computed content reuse:

const tutorialLayout: LayoutConfig = {
  block: 'tutorial',
  slots: {
    header: { tag: 'header', source: 'region:header', conditional: true },
    sidebar: { tag: 'aside', source: 'region:nav', conditional: true },
    content: {
      tag: 'main',
      conditionalModifier: { region: 'nav', modifier: 'has-nav' },
      wrapper: { tag: 'div', class: 'rf-tutorial-content__inner',
                 conditionalModifier: { computed: 'toc', modifier: 'has-toc' } },
      children: [
        { tag: 'div', source: 'content' },
        { tag: 'aside', source: 'computed:toc', conditional: true },
      ],
    },
    'prev-next': { tag: 'nav', source: 'computed:prevNext', conditional: true },
  },
  computed: {
    toc: { type: 'toc', source: 'headings', options: { levels: [2, 3] },
           visibility: { minCount: 2, frontmatterToggle: 'toc' } },
    prevNext: { type: 'prev-next', source: 'region:nav' },
  },
  behaviors: ['mobile-menu', 'mobile-nav-panel', 'scrollspy'],
};

Pattern Coverage Assessment

Expressible with current interfaces

PatternExample sitesHow
Docs with sidebar + TOCStripe, Tailwind, Next.jsDocsLayout config (above)
Simple content pageLanding pages, marketingDefaultLayout config
Knowledge base / wikiNotion-styleDefault + breadcrumb computed
ChangelogRelease notesDefault layout, content via runes

Expressible with new computed content types (no interface changes)

PatternComputedContent typeSource
Prev/next navigationprev-nextregion:nav — walks nav tree
Related pages sidebarrelated-pagespages — matches by tags
Reading time estimatereading-timecontent — word count

Requires LayoutStructureEntry extensions

PatternWhat's neededExtension
Blog article header (title, date, author)Inject text from frontmatterpageText, pageCondition
Tags/categories pill listRepeat element per array itemiterate
Date formatting ("February 26, 2026")Format date stringsdateFormat
Conditional author bioShow only if frontmatter field existspageCondition

Requires LayoutSlot extension

PatternWhat's neededExtension
Optional sidebar via frontmattersidebar: false in frontmatter hides slotfrontmatterCondition

Out of scope (rune/component/behavior territory)

PatternWhyHow to support
Blog post listingPrimary page content, not layout chrome{% blog-index %} rune
Tag/category index pagesContent query, not layout structure{% blog-index tags="..." %} rune or generated route
Search interfaceLive query + results overlay{% search %} rune + component
Dark mode togglelocalStorage + DOM class toggleBehavior or component in header region
Version selector dropdownInteractive dropdown + redirectComponent in header region
Collapsible nav sectionsExpand/collapse on clickBehavior on Nav rune
Language switcherSync all code blocks on pageBehavior coordinating TabGroups

Priority Order for New Capabilities

  1. Prev/next navigation (ComputedContent) — universal docs expectation, pure function
  2. Frontmatter-sourced chrome (LayoutStructureEntry) — unlocks blog article layout as declarative config; pageText, pageCondition, dateFormat
  3. Iterable chrome (LayoutStructureEntry) — tags, categories, author lists; iterate
  4. Frontmatter slot conditions (LayoutSlot) — optional sidebars; frontmatterCondition
  5. Related pages (ComputedContent) — knowledge base / blog sidebar engagement
  6. Reading time (ComputedContent) — blog article headers