> Build-time validation, derivation, and pruning for MDX props
npm install recma-static-refiner> 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.
``bash`
npm install recma-static-refineror
pnpm add recma-static-refiner
Transform a string prop to a number:
`typescript`
const rules = defineRuleRegistry({
Counter: defineRule<{ initial: number }>()({
schema: z.object({ initial: z.coerce.number() })
})
});
`mdx`
Output:
> For simple cases like this, you don't need this pluginβwrite directly instead.
Where this plugin becomes essential: processing meta props from CodeHike or remark plugins, where values are extracted as strings:
`mdx`
# !!posts
!author "42"
!createdAt "2020-01-01T10:00:00Z"
!contentType "article"
## !content
### !text
Hello world
`typescript
type PostListProps = {
posts: {
author: number;
createdAt: Date;
contentType: string;
}[];
};
const rules = defineRuleRegistry({
PostList: defineRule
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
}
})
});
`
> Important: This plugin requires two configuration steps: First 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:
`typescript
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
// 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:
// - With schema: SchemaValidatedProps (validated/transformed output)
// - Without schema: PassthroughProps
//
// 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
schema: AnotherComponentSchema
// Schema-only rule: validates but doesn't derive or prune
})
});
`
Pass your rule registry to the plugin in your MDX compilation configuration:
#### Option A: Using @mdx-js/mdx directly
`typescript
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);
`
#### Option B: Using Next.js with @next/mdx
`typescript
// 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:
`mdx`
With the rule defined above, the plugin will:
1. Validate: Ensure title is a string and transform count to a numberdoubledCount
2. Derive: Compute as count * 2title
3. Prune: Remove , count, and _sourceId from the runtime output{ doubledCount: 84 }
4. Output: The compiled component receives only
| 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. The type resolves based on schema presence:
`
InferOutput resolves to:
βββββββββββββββββββββββββββββββββββββββ
β S extends StandardSchemaV1 ? β
β βββ SchemaValidatedProps β β With Schema (strict)
β βββ PassthroughProps
βββββββββββββββββββββββββββββββββββββββ
`
| Aspect | With Schema | Without Schema |
| ------------------ | ---------------------------------- | ---------------------------------- |
| Resolved Type | SchemaValidatedProps | PassthroughProps |decode
| Runtime Value | Schema's output | Props as provided at instantiation |"42"
| Completeness | Guaranteed (schema enforces shape) | Props as provided |
| Transformation | Applied ( β 42) | None (raw values) |
| Type Safety | Schema output shape | Props interface, all optional |
With Schema:
`typescript`
// derivationInput: SchemaValidatedProps
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):
`typescript${derivationInput.title} (${derivationInput.count} items)
// Without Schema: Direct props pass-through
// derivationInput: PassthroughProps
derive: (derivationInput, set) => {
// Access props directly as provided at instantiation
set({
summary: `
});
};
> β οΈ Placeholder required: derive can only set props that exist in the AST (leaf-only patching). Add placeholder props in your MDX before setting them:`
>
> mdx`
>
>
Rules can mix features to suit your needs:
`typescript
// Validation + Derivation + Pruning
FullComponent: defineRule
schema: MySchema,
derive: (input, set) => set({ computed: transform(input) }),
pruneKeys: ['sourceData'],
}),
// Validation only
ValidatedComponent: defineRule
schema: MySchema,
}),
// Derivation only (passthrough mode)
ComputedComponent: defineRule
derive: (input, set) => set({ computed: calculate(input) }),
}),
// Pruning only
CleanComponent: defineRule
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 |preservedKeys
| Derived prop injection | β
Yes | Computed at build, emitted as literals |
| Prop removal | β
Yes | Source-only keys stripped from output |
| Dynamic expressions | β οΈ Preserved | Passed through unchanged (see ) |
| Runtime values | β No | Variables, function calls, member access not resolved |
See src/architecture.ts` for:
- Expression extraction and preservation
- AST patching constraints