Build-time validation, derivation, and pruning for MDX props
A compile-time Unified/Recma plugin that moves MDX prop processing from runtime to build time—validating, transforming, and pruning props during static bundling.
- 🛡️ Build-Time Validation: Enforce schemas via Standard Schema V1 (Zod, Valibot, ArkType) at compile time; no validation code ships to the client.
- ⚡ Pre-Computed Derivation: Complex calculations happen at build time; results are baked in as literals.
- ✂️ Dead Prop Elimination: Source-only props are stripped from the emitted code, reducing bundle size.
- ✨ Zero Client-Side Cost: No plugin code, validators, or derivation logic executes in the browser.
- 🔒 Type-Safe Registry: Full TypeScript inference with strict contracts on prop shapes and derived values.
By default, MDX components receive props as-is and handle them at runtime—validation, derivation, and cleanup all happen in the browser. This plugin provides an escape hatch to compile time: move that work to the build step and ship only the results.
| Aspect | Runtime (Default) | Build Time (This Plugin) |
|---|---|---|
| Validation | Schemas execute on every render | Schemas execute once at build; zero validation code shipped |
| Derivation | Calculations run in the browser | Values pre-computed; results baked as static literals |
| Data cleanup | Source props travel to client | Internal props stripped; smaller bundles |
| Type safety | Runtime checks or manual | Schema-guaranteed; full TypeScript inference |
Not all props are statically extractable. Runtime expressions (variables, function calls, JSX) are preserved verbatim and passed through to the component. The plugin processes what it can statically; the rest remains dynamic.
At build time, the plugin extracts statically determinable data and runs it through three phases:
| Phase | Input | Output | Purpose |
|---|---|---|---|
| 1. Validation | Raw props from MDX | Validated/transformed props | Enforce contracts using Zod/Valibot/ArkType |
| 2. Derivation | Validated props | Props + computed values | Pre-calculate derived data (normalization, layouts) |
| 3. Pruning | Full prop set | Cleaned prop set | Strip source-only data before emission |
The result: your runtime components receive plain, static objects with all processing already complete.
npm install recma-static-refiner
# or
pnpm add recma-static-refinerIf you want the shortest path to adoption:
- Define your component rules: Step 1: Define Rules
- Register the plugin in your MDX pipeline: Step 2: Register Plugin
- Verify output expectations: Example: MDX Source to Compiled Output
This plugin relies on two companion libraries:
estree-util-to-static-valuefor safe static ESTree value extraction.object-graph-deltafor structural diffing of extracted/derived props.
Transform a string prop to a number:
const rules = defineRuleRegistry({
Counter: defineRule<{ initial: number }>()({
schema: z.object({ initial: z.coerce.number() })
})
});<Counter initial="5" />Output: <Counter initial={5} />
For simple cases like this, you don't need this plugin—write
<Counter initial={5} />directly instead.
Where this plugin becomes essential: processing meta props from CodeHike or remark plugins, where values are extracted as strings:
<PostList>
# !!posts
!author "42"
!createdAt "2020-01-01T10:00:00Z"
!contentType "article"
## !content
### !text
Hello world
</PostList>type PostListProps = {
posts: {
author: number;
createdAt: Date;
contentType: string;
}[];
};
const rules = defineRuleRegistry({
PostList: defineRule<PostListProps>()({
schema: z.object({
posts: z.array(
z.object({
author: z.coerce.number(), // "42" → 42
createdAt: z.iso.datetime(), // string → Date
contentType: z.string()
})
)
}),
derive: (input, set) => {
set({ postCount: input.posts.length }); // Pre-computed at build
}
})
});This plugin requires two configuration steps: define your component rules, then register the plugin with your MDX compiler.
Create a rule registry that maps component names to their validation, derivation, and pruning configuration:
import { defineRuleRegistry, defineRule } from 'recma-static-refiner';
import { z } from 'zod';
// 1. Define your component's props interface
type CustomComponentProps = {
title: string;
count: number;
_sourceId: string;
doubledCount?: number; // Set by derive
};
// 2. Create a validation schema using Standard Schema V1 (Zod, Valibot, or ArkType)
// Explicit transform: MDX passes props as strings, schema converts to expected types
const CountSchema = z.codec(
z.union([z.string(), z.number()]), // input: accept string or number
z.number(), // output: always number
{
decode: raw => {
// "42" → 42
return typeof raw === 'string' ? parseInt(raw, 10) : raw;
},
encode: val => val
}
);
const CustomComponentSchema = z.object({
title: z.string(),
count: CountSchema,
_sourceId: z.string()
});
// 3. Build your rule registry
export const staticRefinerRules = defineRuleRegistry({
CustomComponent: defineRule<CustomComponentProps>()({
// Schema validates and transforms props at build time
schema: CustomComponentSchema,
// Derive computes new props based on the upstream input
derive: (derivationInput, set) => {
// derivationInput is InferOutput<S, Props>:
// - With schema: SchemaValidatedProps<S> (validated/transformed output)
// - Without schema: PassthroughProps<Props> (direct pass-through)
//
// Schema transformed "42" → 42, so derivationInput.count is number
// Compute derived value using the validated number
set({
doubledCount: derivationInput.count * 2
// TypeScript ensures only CustomComponentProps keys are allowed here
});
},
// PruneKeys removes props after derivation (no longer needed at runtime)
pruneKeys: ['title', 'count']
}),
// Add more component rules as needed
AnotherComponent: defineRule<AnotherComponentProps>()({
schema: AnotherComponentSchema
// Schema-only rule: validates but doesn't derive or prune
})
});Pass your rule registry to the plugin in your MDX compilation configuration:
import { compile } from '@mdx-js/mdx';
import { recmaStaticRefiner } from 'recma-static-refiner';
import { staticRefinerRules } from './your-rules-file';
const mdxOptions = {
recmaPlugins: [
// Register recmaStaticRefiner with your rules
[recmaStaticRefiner, { rules: staticRefinerRules }]
]
};
const compiled = await compile(mdxSource, mdxOptions);// next.config.js
import { recmaStaticRefiner } from 'recma-static-refiner';
import { staticRefinerRules } from './your-rules-file';
/** @type {import('next').NextConfig} */
const nextConfig = {
pageExtensions: ['js', 'jsx', 'md', 'mdx', 'ts', 'tsx']
};
const withMdx = require('@next/mdx')({
options: {
recmaPlugins: [[recmaStaticRefiner, { rules: staticRefinerRules }]]
}
});
export default withMdx(nextConfig);Given this MDX:
<CustomComponent title="Hello World" count="42" _sourceId="internal-123" />With the rule defined above, the plugin will:
- Validate: Ensure
titleis a string and transformcountto a number - Derive: Compute
doubledCountascount * 2 - Prune: Remove
title,count, and_sourceIdfrom the runtime output - Output: The compiled component receives only
{ doubledCount: 84 }
| Option | Type | Default | Description |
|---|---|---|---|
rules |
RuleMap |
Required | Registry mapping component names to their validation, derivation, and pruning rules |
applyTransforms |
boolean |
true |
Whether to write validated values back to the AST. true updates the AST (e.g., "50" → 50); false validates without modifying (dry-run mode) |
preservedKeys |
readonly string[] |
['children'] |
Props to preserve as dynamic expressions. These are not resolved to static data and skip transformation |
Each rule can configure three pipeline phases. All features are optional, but at least one must be defined per rule:
| Feature | Purpose | When to Use |
|---|---|---|
schema |
Validates and transforms props using Standard Schema | Ensure props conform to expected types |
derive |
Computes derived props from upstream input | Build computed state from validated props |
pruneKeys |
Removes source-only props from runtime output | Strip internal data used only during build |
The derive function receives derivationInput: InferOutput<S, Props>. The type resolves based on schema presence:
InferOutput<S, Props> resolves to:
┌─────────────────────────────────────┐
│ S extends StandardSchemaV1 ? │
│ ├── SchemaValidatedProps<S> │ ← With Schema (strict)
│ └── PassthroughProps<Props> │ ← Without Schema (partial)
└─────────────────────────────────────┘
| Aspect | With Schema | Without Schema |
|---|---|---|
| Resolved Type | SchemaValidatedProps<S> |
PassthroughProps<Props> |
| Runtime Value | Schema's decode output |
Props as provided at instantiation |
| Completeness | Guaranteed (schema enforces shape) | Props as provided |
| Transformation | Applied ("42" → 42) |
None (raw values) |
| Type Safety | Schema output shape | Props interface, all optional |
With Schema:
// derivationInput: SchemaValidatedProps<typeof CustomComponentSchema>
derive: (derivationInput, set) => {
// Schema's decode already ran: "42" → 42
// TypeScript knows derivationInput.count is number
set({ computed: derivationInput.count * 2 });
};Without Schema (Passthrough Mode):
// Without Schema: Direct props pass-through
// derivationInput: PassthroughProps<Props>
derive: (derivationInput, set) => {
// Access props directly as provided at instantiation
set({
summary: `${derivationInput.title} (${derivationInput.count} items)`
});
};
⚠️ Placeholder required:derivecan only set props that exist in the AST (leaf-only patching). Add placeholder props in your MDX before setting them:<CustomComponent doubledCount={null} />
Rules can mix features to suit your needs:
// Validation + Derivation + Pruning
FullComponent: defineRule<Props>()({
schema: MySchema,
derive: (input, set) => set({ computed: transform(input) }),
pruneKeys: ['sourceData'],
}),
// Validation only
ValidatedComponent: defineRule<Props>()({
schema: MySchema,
}),
// Derivation only (passthrough mode)
ComputedComponent: defineRule<Props>()({
derive: (input, set) => set({ computed: calculate(input) }),
}),
// Pruning only
CleanComponent: defineRule<Props>()({
pruneKeys: ['internalId', 'debug'],
}),| Combination | When to Use |
|---|---|
| Full (validate + derive + prune) | Maximum control—transform inputs, compute derived state, strip internal data |
| Validate only | Type safety and transformation without derivation or cleanup |
| Derive only | Trusted inputs that need computed values based on static props |
| Prune only | Clean up props from external sources without validation or derivation |
The plugin throws build-time errors for any validation or patch failure. It enforces a zero-tolerance policy: any unapplied patch aborts compilation.
| Failure Type | Cause | Resolution |
|---|---|---|
| Schema validation | Prop doesn't match schema | Fix the invalid value in MDX |
| Derive patch | Target prop missing from AST | Add placeholder in MDX: prop={null} |
| Structural patch | Dynamic keys, spreads, or preserved subtrees | Requires component architecture changes |
Build errors include phase annotations and summaries. Derive failures provide actionable hints; structural failures indicate non-recoverable AST constraints.
| Capability | Supported | Notes |
|---|---|---|
| Static literal props | ✅ Yes | Strings, numbers, booleans, null |
| Static arrays/objects | ✅ Yes | Without spreads or computed keys |
| Schema validation | ✅ Yes | Zod, Valibot, ArkType at build time |
| Prop transformation | ✅ Yes | "42" → 42 via schema |
| Derived prop injection | ✅ Yes | Computed at build, emitted as literals |
| Prop removal | ✅ Yes | Source-only keys stripped from output |
| Dynamic expressions | Passed through unchanged (see preservedKeys) |
|
| Runtime values | ❌ No | Variables, function calls, member access not resolved |
See src/architecture.ts for:
- Expression extraction and preservation
- AST patching constraints