Framework-native component interface for rune overrides
Context
When a theme or package author writes a Svelte (or future Astro/React) component to override a rune's identity transform, they receive a single tag prop containing the full serialized tag object. To access rune properties (like prepTime or difficulty) or named content regions (like ingredients or media), they must use refrakt-internal helper functions:
import { readMeta, findByDataName, nonMetaChildren } from '@refrakt-md/transform';
const prepTime = readMeta(tag, 'prepTime');
const ingredients = findByDataName(tag, 'ingredients');
const content = nonMetaChildren(tag);
This has several problems:
Unintuitive for framework developers. A Svelte or Astro developer expects props and slots, not a tag object they must dissect with unfamiliar helpers.
Leaks internal representation. The meta tag convention (properties encoded as child
<meta>tags withpropertyattributes, named regions identified bydata-name) is a transport mechanism between the schema transform and the renderer. Component authors shouldn't need to know about it.Not portable across frameworks. Every framework adapter would need to document the same helper-based extraction pattern, and component authors would need to learn it regardless of their framework background.
Grows worse with multi-framework support. As we add Astro (and potentially React), each framework's component authors face the same friction. The problem multiplies rather than being solved once.
Current pipeline position
The renderer is the boundary between refrakt's internal tree representation and framework-native components. It already dispatches on typeof/data-rune to select components. It is the natural place to translate the internal representation into framework-native interfaces.
What the renderer currently does
{#if Component}
<Component tag={node}>
{#each node.children as child}
<Renderer node={child} overrides={merged} />
{/each}
</Component>
{/if}
All children — including meta tags (properties) and named ref elements — are rendered uniformly into the default slot.
Proposal
Two complementary changes to how renderers pass data to component overrides:
1. Properties as props
The renderer extracts children with a property attribute, reads their content value, and passes them as named props to the component. Meta tags are filtered out of the rendered children.
A recipe component would receive:
<script>
let { prepTime, cookTime, servings, difficulty, children } = $props();
</script>
In Astro:
---
const { prepTime, cookTime, servings, difficulty } = Astro.props;
---
<slot />
Extraction rule: For each child where attributes.property is set, extract attributes.content as a string prop keyed by the property name. Remove these children from the rendered child list.
2. Named refs as slots/snippets
The renderer extracts children with a data-name attribute and provides them as named slots (Svelte 5 snippets, Astro named slots, React render props).
A recipe component in Svelte 5:
<script>
let { prepTime, difficulty, headline, ingredients, steps, media, children } = $props();
</script>
<article class="my-recipe">
<div class="hero">
{@render media?.()}
{@render headline?.()}
</div>
<div class="body">
{@render ingredients?.()}
{@render steps?.()}
</div>
</article>
In Astro:
---
const { prepTime, difficulty } = Astro.props;
---
<article class="my-recipe">
<div class="hero">
<slot name="media" />
<slot name="headline" />
</div>
<div class="body">
<slot name="ingredients" />
<slot name="steps" />
</div>
</article>
Extraction rule: For each child where attributes['data-name'] is set, provide the pre-rendered subtree as a named slot/snippet keyed by the data-name value. Remove these children from the default slot.
Important: Named slots contain identity-transformed content (BEM classes applied, structure injected). Component authors receive styled, structured content — not raw AST nodes. They control placement, not internal rendering. This is a feature: the identity transform handles the tedious structural work, and the component handles layout.
What remains in the default slot
After extracting properties and named refs, the default slot contains only "anonymous" content children — children that are neither properties nor named regions. For many runes this will be empty (all content is in named refs). For simpler runes it may contain the primary content body.
Open Questions
Naming collisions.Resolved. Top-level ref names and property names must be unique within the same rune. A flat namespace is sufficient — properties and slots share it. This is enforceable at schema build time:createComponentRenderablealready receives both thepropertiesandrefsobjects, so a duplicate key across the two can be a static error. Separate namespaces (e.g.,props.xvsslots.x) add API complexity without practical benefit given the low collision risk.Nested refs.Resolved. Only top-level refs become slots. Nested refs (children withdata-nameinside another ref) remain internal to the parent slot's pre-rendered content. Rationale:- Naming scope. Nested refs often have generic names (
title,label,value,icon) that are contextual to their parent. Flattening them to top-level slots would create ambiguity — e.g.,Eventhaslabelandvaluenested inside multipledetailrefs. Top-level-only avoids the scoping problem entirely. - Right level of control. A component override's purpose is to place major content regions, not restructure their internals. The identity transform handles internal structure (BEM classes, nested elements), and that arrives pre-rendered inside the slot.
- Escape hatch. For the rare case where a component needs finer-grained access than slot-level placement, Option 4 (hybrid) provides the
tagprop as a fallback. The component can then use existing helpers to dig into nested structure.
Audit of current rune configs confirms this is safe: the vast majority of runes have unique top-level ref names. The only rune with nested naming collisions (
Eventwithlabel/valueinsidedetail) would not be affected since those nested refs stay inside thedetailslot.- Naming scope. Nested refs often have generic names (
Opt-in vs default.Resolved. The new interface is the default for all component overrides, with the originaltagprop passed alongside as a companion (Option 4 — hybrid). Existing components that destructuretagcontinue to work unchanged — they simply ignore the new props and slots. New components use props and slots and ignoretag. No migration flag, no opt-in mechanism. The renderer always performs extraction; what the component consumes is up to it.BEM classes on slot content.Resolved. Slots always arrive identity-transformed. No opt-out mechanism. Component overrides control placement of content regions, not their internal rendering. CSS overrides are sufficient for restyling internals (BEM selectors like.rf-recipe__ingredientremain valid targets). Providing raw content would require the renderer to maintain two versions of each subtree, adding real complexity for a niche case. Components needing fundamentally different internal structure should pair with a schema override, not just a renderer override.Svelte 5 snippet mechanics.Deferred to implementation. The ADR specifies the contract — components receive named renderables — not the framework-specific mechanism. Each framework adapter implements extraction using its native construct (createRawSnippetin Svelte 5, named slots in Astro, render props or children-as-object in React). Pinning the mechanism here would couple the ADR to framework internals that may evolve independently.Typed component interfaces.Resolved. Approach B (generic interface with renderable type parameter) is the primary path. Rune packages export a generic interface parameterized over the renderable type, keeping packages framework-agnostic:- Package exports. Each rune package exports prop interfaces alongside schemas (e.g.,
import type { RecipeProps } from '@refrakt-md/learning'). Types are generated as part of the package build from schema metadata. - CLI generation.
refrakt inspect recipe --typesemits a TypeScript interface with scalar props typed from attributes andSnippettypes for each named ref. - Vite virtual modules. The SvelteKit plugin already knows which packages are loaded — it could generate virtual type modules (like it does for content modules) so components get autocompletion and type errors without explicit imports.
The package export approach is the simplest starting point. However, the renderable type for slots is framework-specific (
Snippetin Svelte 5,astroHTML.JSX.Elementin Astro,ReactNodein React), so rune packages — which are framework-agnostic — cannot ship a complete typed interface directly.Approach A: Split scalar props from slot names. The rune package exports a framework-agnostic contract — scalar property types and slot names as separate constructs. Each framework adapter applies its own renderable type:
// From the rune package (framework-agnostic) interface RecipeProperties { prepTime?: string; cookTime?: string; servings?: string; difficulty?: 'easy' | 'medium' | 'hard'; } type RecipeSlotNames = 'headline' | 'ingredients' | 'steps' | 'tips' | 'media';// In a Svelte component import type { Snippet } from 'svelte'; import type { RecipeProperties, RecipeSlotNames } from '@refrakt-md/learning'; type RecipeProps = RecipeProperties & Record<RecipeSlotNames, Snippet | undefined> & { children?: Snippet };Approach B: Generic interface with a renderable type parameter. The rune package exports a single generic interface parameterized over the renderable type:
// From the rune package interface RecipeProps<R = unknown> { prepTime?: string; cookTime?: string; servings?: string; difficulty?: 'easy' | 'medium' | 'hard'; headline?: R; ingredients?: R; steps?: R; tips?: R; media?: R; children?: R; }// In a Svelte component import type { Snippet } from 'svelte'; import type { RecipeProps } from '@refrakt-md/learning'; let { prepTime, headline, ingredients, ...rest }: RecipeProps<Snippet> = $props();Approach C: Framework adapter generates concrete types. The rune package exports only the contract metadata (property names/types, slot names). The framework adapter — or the Vite plugin — generates fully concrete types using the framework's native renderable type. This keeps rune packages entirely free of type-level framework coupling.
Approach B is the most ergonomic for component authors (one import, one generic parameter). Approach A offers the cleanest separation but requires the component author to assemble the full type. Approach C (adapter-generated types from the Vite plugin) is the most decoupled but adds tooling complexity — it can be layered on top of Approach B later when multiple framework adapters exist and the generation story is proven.
- Package exports. Each rune package exports prop interfaces alongside schemas (e.g.,
Decision
Option 4: Hybrid — framework-native by default, tag prop as escape hatch. The renderer extracts property meta tags as props and top-level refs as named slots, passing them alongside the original tag object. Types are provided via generic interfaces (Approach B) exported from rune packages.
Rationale
The framework-native interface (props + slots) is the right default because it matches what component authors in every framework expect. Passing tag alongside preserves backwards compatibility and provides an escape hatch for advanced cases that need full tree access. Together these eliminate the migration burden — existing components keep working, new components get the clean interface.
Always delivering identity-transformed content in slots is the right trade-off because component overrides exist to control placement, not to reimplement internal rendering. The identity transform already handles BEM classes, structural elements, and nested refs — duplicating that in components would be wasted effort.
Deferring snippet/slot mechanics to each framework adapter keeps this decision stable across framework evolution. The contract (named renderables as props) is durable; the mechanism (createRawSnippet, <slot name>, render props) is framework-specific and may change.
Generic type parameters (Approach B) strike the right balance between ergonomics and framework independence. RecipeProps<Snippet> is a single import and one type parameter — minimal ceremony while keeping the rune package free of framework types.
Consequences
Each framework renderer gains an extraction phase. Before dispatching to a component, the renderer partitions children into properties, named refs, and anonymous content. This is ~20-30 lines of extraction logic per renderer. The extraction logic itself is framework-agnostic and can live in
packages/transformas a shared utility; only the slot-passing mechanism is framework-specific.Adapters and renderers are separate packages. Framework adapters (
packages/astro,packages/nuxt,packages/next,packages/eleventy,packages/sveltekit) handle routing, build config, and SSR integration. Renderers (packages/svelte, and futurepackages/react,packages/vue) handle component dispatch and ADR-008 extraction. These are orthogonal concerns:Adapter UI Framework Renderer Package @refrakt-md/sveltekitSvelte @refrakt-md/svelte@refrakt-md/nextReact @refrakt-md/react(new)@refrakt-md/nuxtVue @refrakt-md/vue(new)@refrakt-md/astroAstro-native + islands see below @refrakt-md/eleventynone (HTML only) n/a Adapters that currently use
renderToHtml()(all except SvelteKit) get component override support by depending on the appropriate renderer package. Eleventy has no component model and relies entirely on the identity transform.Astro is a special case. Astro is both an adapter (routing,
astro:config:setup) and has its own component format with native named slots (<slot name="ingredients" />). This means:.astrocomponent overrides — extraction and slot passing happen natively inpackages/astrousing Astro's built-in named slot mechanism. This is the natural choice for static rune overrides.- Island component overrides — for interactive runes in Astro islands, the island's renderer (
@refrakt-md/react,@refrakt-md/svelte, or@refrakt-md/vue) handles extraction using its own slot mechanism.
Existing Svelte component overrides continue to work. The
tagprop is always passed alongside extracted props and slots. Components can migrate gradually fromtag-based access to props/slots.Component authoring documentation simplifies. Instead of documenting helper functions, we document "your component receives these props and these slots" — which is what framework developers already understand.
refrakt inspectgains a component interface view. The inspect tool could show "this rune provides props: prepTime, difficulty; slots: headline, ingredients, steps, media" — making the component contract discoverable.Cross-framework component parity. A recipe component in Svelte and one in Astro would have the same logical interface (same prop names, same slot names), differing only in framework syntax.
References
- SPEC-030 — Framework Adapter System (adapter architecture for Astro, Nuxt, Next.js, Eleventy)
- SPEC-033 — Structure Slots and Declarative Flexibility (the structural model that produces refs)
- ADR-006 — Post-identity-transform hook (related pipeline architecture)
packages/svelte/src/Renderer.svelte— current renderer implementationpackages/transform/src/helpers.ts— current helper functions for tag extractionpackages/runes/src/lib/component.ts—createComponentRenderable(sets property attributes)
Relationships
Related
- ADR-009accepteddecisionFramework-agnostic theme packages
- SPEC-033acceptedspecStructure Slots and Declarative Flexibility
- ADR-006proposeddecisionPost-identity-transform hook for rune packages
- WORK-125doneworkAdd Astro native component override support with ADR-008 named slots
- WORK-123doneworkCreate @refrakt-md/react renderer with ADR-008 component interface
- WORK-124doneworkCreate @refrakt-md/vue renderer with ADR-008 component interface
- WORK-120doneworkExport generic type interfaces for rune component overrides
- WORK-118doneworkValidate property and ref name uniqueness in createComponentRenderable