Resolvers: The Component Registry
Think of resolvers as a registry that tells v-craft everything about your components. While blueprints define how components appear in the editor sidebar, resolvers define the actual component behavior, editable properties, event handlers, and rules.
What Are Resolvers?
Resolvers are like component registries that tell the editor:
- What components actually are
- What properties can be edited
- What events can be handled
- What drag-and-drop rules apply
- What slots are available
🔑 Key Concept: Resolver Map A resolver map is a JavaScript object where keys are component names and values contain all the metadata about each component. This metadata includes:
componentName- The actual Vue component namepropsSchema- FormKit schema for editable propertieseventsSchema- FormKit schema for event handlersdefaultProps- Default property valuesrules- Drag-and-drop behavior rulesslots- Available slot names
🎯 Built on FormKit Resolvers use FormKit schemas for defining editable properties and events. This means you get access to FormKit's powerful form system, validation, and field types.
The Simplest Resolver Ever
Let's start with the most basic resolver possible:
const myResolver = {
SimpleText: {
componentName: "SimpleText",
propsSchema: [
{
$formkit: "text",
name: "text",
label: "Text Content",
}
],
defaultProps: {
text: "Hello World!"
}
}
}That's it! This resolver tells v-craft:
- There's a component called "SimpleText"
- It has one editable property called "text"
- The default value is "Hello World!"
Creating Your First Resolver
Step 1: Define the Resolver Map
Create a file called resolvers.ts:
import type { CraftNodeResolverMap } from '@versa-stack/v-craft'
export const myResolvers: CraftNodeResolverMap<any> = {
HeroSection: {
componentName: "HeroSection",
propsSchema: [
{
$formkit: "text",
name: "title",
label: "Hero Title",
validation: "required",
},
{
$formkit: "textarea",
name: "subtitle",
label: "Subtitle",
},
{
$formkit: "color",
name: "backgroundColor",
label: "Background Color",
value: "#6366f1",
}
],
eventsSchema: {
$el: "div",
children: [
{
$formkit: "textarea",
name: "onClick",
label: "onClick Handler",
},
],
},
defaultProps: {
title: "Welcome to Our Site",
subtitle: "We make amazing things",
backgroundColor: "#6366f1",
}
},
InfoCard: {
componentName: "InfoCard",
propsSchema: [
{
$formkit: "text",
name: "title",
label: "Card Title",
},
{
$formkit: "textarea",
name: "description",
label: "Description",
},
{
$formkit: "url",
name: "imageUrl",
label: "Image URL",
}
],
defaultProps: {
title: "Card Title",
description: "This is a description",
imageUrl: "https://via.placeholder.com/300x200",
}
}
}Step 2: Use Your Resolvers in the Editor
import { CraftNodeResolver } from '@versa-stack/v-craft'
import { myResolvers } from './resolvers'
const resolver = new CraftNodeResolver(myResolvers)
const editorConfig = {
resolver
}Resolver Structure Explained
Let's break down every part of a resolver:
const myResolver = {
// 1. COMPONENT NAME - Must match your Vue component
componentName: "MyVueComponent",
// 2. PROPS SCHEMA - FormKit schema for editable properties
propsSchema: [
{
$formkit: "text",
name: "title",
label: "Title",
validation: "required",
},
{
$formkit: "number",
name: "fontSize",
label: "Font Size",
value: 16,
}
],
// 3. EVENTS SCHEMA - FormKit schema for event handlers
eventsSchema: {
$el: "div",
children: [
{
$formkit: "textarea",
name: "onClick",
label: "onClick Handler",
},
],
},
// 4. DEFAULT PROPS - Default property values
defaultProps: {
title: "Default Title",
fontSize: 16,
},
// 5. RULES - Drag-and-drop behavior rules
rules: {
canAccept: (node) => true,
canDrag: (node) => true,
},
// 6. SLOTS - Available slot names
slots: ["default", "header", "footer"]
}Resolver Examples for Every Situation
1. Simple Text Component
const TextBlock = {
componentName: "TextBlock",
propsSchema: [
{
$formkit: "text",
name: "text",
label: "Text Content",
},
{
$formkit: "number",
name: "fontSize",
label: "Font Size (px)",
value: 16,
},
{
$formkit: "color",
name: "color",
label: "Text Color",
value: "#333333",
},
{
$formkit: "select",
name: "align",
label: "Text Alignment",
options: [
{ value: "left", label: "Left" },
{ value: "center", label: "Center" },
{ value: "right", label: "Right" },
],
value: "left",
}
],
defaultProps: {
text: "Type your text here...",
fontSize: 16,
color: "#333333",
align: "left"
}
}2. Button Component
const Button = {
componentName: "ActionButton",
propsSchema: [
{
$formkit: "text",
name: "text",
label: "Button Text",
validation: "required",
},
{
$formkit: "color",
name: "backgroundColor",
label: "Background Color",
value: "#007bff",
},
{
$formkit: "color",
name: "textColor",
label: "Text Color",
value: "#ffffff",
},
{
$formkit: "number",
name: "borderRadius",
label: "Border Radius (px)",
value: 4,
}
],
eventsSchema: {
$el: "div",
children: [
{
$formkit: "textarea",
name: "onClick",
label: "onClick Handler",
},
],
},
defaultProps: {
text: "Click Me",
backgroundColor: "#007bff",
textColor: "#ffffff",
borderRadius: 4,
}
}3. Image Component
const Image = {
componentName: "DisplayImage",
propsSchema: [
{
$formkit: "url",
name: "src",
label: "Image URL",
validation: "required|url",
},
{
$formkit: "text",
name: "alt",
label: "Alt Text",
},
{
$formkit: "number",
name: "width",
label: "Width (px)",
value: 400,
},
{
$formkit: "number",
name: "height",
label: "Height (px)",
value: 300,
},
{
$formkit: "number",
name: "borderRadius",
label: "Border Radius (px)",
value: 0,
}
],
defaultProps: {
src: "https://via.placeholder.com/400x300",
alt: "Description of image",
width: 400,
height: 300,
borderRadius: 0,
}
}4. Container Component with Slots
const Container = {
componentName: "ContainerBox",
propsSchema: [
{
$formkit: "color",
name: "bgColor",
label: "Background Color",
value: "#f8f9fa",
},
{
$formkit: "number",
name: "padding",
label: "Padding (px)",
value: 20,
},
{
$formkit: "number",
name: "borderRadius",
label: "Border Radius (px)",
value: 8,
}
],
defaultProps: {
bgColor: "#f8f9fa",
padding: 20,
borderRadius: 8,
},
slots: ["default"]
}5. Component with Drag-and-Drop Rules
const DraggableSection = {
componentName: "Section",
propsSchema: [
{
$formkit: "text",
name: "title",
label: "Section Title",
}
],
defaultProps: {
title: "My Section",
},
rules: {
canAccept: (node) => {
// Only accept text and button components
return node.componentName === "TextBlock" || node.componentName === "ActionButton"
},
canDrag: (node) => {
// Allow dragging unless it's the root node
return node.parentUuid !== null
}
}
}Async Component Resolution
The component field in a resolver map entry accepts either a sync Vue component or an async factory function (() => Promise<Component>). This enables lazy-loading components on demand — useful for code-splitting and optimizing bundle size.
The component field is read by all rendering paths — CraftNodeViewer, CraftNodeEditor, and CraftCanvas — via the shared useResolveCraftNode composable. This means async loading works for every node type, not just container nodes.
Usage
import type { CraftNodeResolverMap } from "@versa-stack/v-craft";
const resolverMap: CraftNodeResolverMap<any> = {
HeroSection: {
componentName: "HeroSection",
component: () => import("./components/HeroSection.vue"),
},
};The factory is detected automatically and wrapped with Vue's defineAsyncComponent. No extra configuration is needed.
Sync vs Async
import HeroSection from "./components/HeroSection.vue";
import type { CraftNodeResolverMap } from "@versa-stack/v-craft";
const resolverMap: CraftNodeResolverMap<any> = {
HeroSection: {
componentName: "HeroSection",
component: HeroSection, // sync — imported at bundle time
},
LazyCard: {
componentName: "LazyCard",
component: () => import("./components/LazyCard.vue"), // async — loaded on demand
},
PlainDiv: {
componentName: "div", // no component — falls back to HTML tag
},
};| Scenario | component value |
|---|---|
| Standard Vue app | Sync import: component: MyComponent |
| Code-split / lazy loading | Async factory: component: () => import('./MyComponent.vue') |
| HTML element or globally registered | Omit component, use componentName only |
Complete Example
The following shows a full setup with both sync and async components in the same resolver map:
components/HeroSection.vue
<template>
<section :style="{ backgroundColor: bgColor, padding: '60px 20px', textAlign: 'center' }">
<h1>{{ title }}</h1>
<p>{{ subtitle }}</p>
</section>
</template>
<script setup lang="ts">
defineProps<{
title: string;
subtitle: string;
bgColor: string;
}>();
</script>components/LazyCard.vue
<template>
<div class="card" :style="{ padding: '20px', border: '1px solid #eee', borderRadius: '8px' }">
<h3>{{ heading }}</h3>
<slot />
</div>
</template>
<script setup lang="ts">
defineProps<{ heading: string }>();
</script>resolvers.ts
import HeroSection from "./components/HeroSection.vue";
import type { CraftNodeResolverMap } from "@versa-stack/v-craft";
export const resolverMap: CraftNodeResolverMap<any> = {
HeroSection: {
componentName: "HeroSection",
component: HeroSection, // sync
defaultProps: { title: "Hello", subtitle: "", bgColor: "#f0f0f0" },
propsSchema: [
{ $formkit: "text", name: "title", label: "Title" },
{ $formkit: "text", name: "subtitle", label: "Subtitle" },
{ $formkit: "color", name: "bgColor", label: "Background" },
],
},
LazyCard: {
componentName: "LazyCard",
component: () => import("./components/LazyCard.vue"), // async — loaded on demand
defaultProps: { heading: "Card" },
propsSchema: [
{ $formkit: "text", name: "heading", label: "Heading" },
],
slots: ["default"],
},
};App.vue
<template>
<CraftStaticRenderer :nodes="nodes" :resolverMap="resolverMap" />
</template>
<script setup lang="ts">
import { CraftStaticRenderer } from "@versa-stack/v-craft";
import { resolverMap } from "./resolvers";
const nodes = [
{
uuid: "1",
componentName: "HeroSection",
props: { title: "Welcome", subtitle: "Built with v-craft", bgColor: "#6366f1" },
slots: {},
},
{
uuid: "2",
componentName: "LazyCard",
props: { heading: "Features" },
slots: {
default: [
{
uuid: "3",
componentName: "HeroSection",
props: { title: "Fast", subtitle: "Lazy loaded", bgColor: "#fff" },
slots: {},
},
],
},
},
];
</script>LazyCard is only fetched from the server when the renderer first encounters it. HeroSection is bundled synchronously. Both are configured identically from the resolver's perspective.
Using the CraftNodeResolver Class
The CraftNodeResolver class provides methods to work with your resolver map:
import { CraftNodeResolver } from '@versa-stack/v-craft'
import { myResolvers } from './resolvers'
const resolver = new CraftNodeResolver(myResolvers)
// Resolve a component by name
const componentInfo = resolver.resolve("HeroSection")
// Get default props for a CraftNode
const defaults = resolver.getDefaultProps(myCraftNode)
// Get the props schema for a CraftNode
const schema = resolver.getSchema(myCraftNode)
// Get the events schema for a CraftNode
const eventsSchema = resolver.getEventsSchema(myCraftNode)
// Get drag-and-drop rules for a CraftNode
const rules = resolver.getRules(myCraftNode)Component Resolution Hook
For frameworks that have their own component resolution systems (auto-imports, custom resolvers, etc.), you can use the onResolveComponent hook to override v-craft's default component resolution. This allows frameworks to handle component resolution their way while maintaining a graceful fallback to v-craft's default behavior.
Why Use the Hook?
The hook allows you to:
- Use framework-specific component resolution
- Avoid static imports that might break SSR builds
- Maintain backward compatibility with existing extensions
- Provide progressive enhancement - apps opt-in without breaking changes
Basic Usage
import { CraftNodeResolver } from '@versa-stack/v-craft'
import { resolveComponent } from 'vue'
const resolver = new CraftNodeResolver({
UFooter: {
componentName: "UFooter",
// No component field - hook will handle resolution
}
})
resolver.onResolveComponent((craftNode, defaultResolver) => {
const component = resolveComponent(craftNode.componentName)
if (!component || typeof component === 'string') {
const fallback = defaultResolver(craftNode.componentName)
return fallback
}
return component
})The hook receives:
craftNode: The full craft node, which includes the component name and can be used to check if the component is wrapped in a CanvasdefaultResolver: A function that uses v-craft's default resolution (fromresolverMap)
Handling Canvas Components: When a component is wrapped in a Canvas, the actual component name is in craftNode.props.componentName instead of craftNode.componentName. The resolver automatically handles this internally, so you don't need to check for Canvas nodes in your hook - just use craftNode.componentName and the resolver will extract the correct name:
resolver.onResolveComponent((craftNode, defaultResolver) => {
// The resolver automatically handles Canvas nodes internally
const component = resolveComponent(craftNode.componentName)
if (!component || typeof component === 'string') {
return defaultResolver(craftNode.componentName)
}
return component
})Return the resolved component, or call defaultResolver(craftNode.componentName) to fall back to v-craft's default resolution.
Hook Signature
type ResolveComponentHook = (
craftNode: CraftNode,
defaultResolver: (name: string) => Component | undefined
) => Component | undefined- craftNode: The full craft node being resolved
- defaultResolver: Fallback function that uses v-craft's default resolution (from
resolverMap) - Returns: A Vue component or
undefinedto trigger fallback
Providing Resolver to CraftEditor
To use your resolver with the onResolveComponent hook in CraftEditor, provide it via the config:
const config = {
blueprintsLibrary: mergedBlueprints,
resolver: resolver, // Provide the resolver instance (not resolverMap)
}
<CraftEditor :config="config">
<!-- ... -->
</CraftEditor>CraftEditor will use the provided resolver instance instead of creating a new one from resolverMap. This preserves any hooks you've registered.
Providing Resolver to CraftStaticRenderer
CraftStaticRenderer also supports providing a resolver instance:
<CraftStaticRenderer
:nodes="nodes"
:resolver="resolver"
:nodeDataMap="nodeDataMap"
/>When resolver is provided, CraftStaticRenderer uses it directly. Otherwise, it creates a new resolver from resolverMap. This allows you to use the onResolveComponent hook in static rendering scenarios as well.
Backward Compatibility
The hook is completely optional. If you don't call onResolveComponent, v-craft uses its default resolution unchanged. Existing extensions with static component imports continue to work without modification.
Mixed Resolution Strategy
You can combine framework resolution with static imports for components that aren't auto-imported:
import MyCustomComponent from './components/MyCustomComponent.vue'
const resolver = new CraftNodeResolver({
AppHero: {
componentName: "AppHero", // Framework auto-imported
},
MyCustomComponent: {
componentName: "MyCustomComponent",
component: MyCustomComponent, // Static import
}
})
resolver.onResolveComponent((name, defaultResolver) => {
if (name === "AppHero") {
return resolveComponent(name)
}
// Use default resolver for everything else
return defaultResolver(name)
})How Resolvers and Blueprints Work Together
The Two-Layer System
Your implementation uses a two-layer system where blueprints and resolvers work together:
- Blueprints define what appears in the editor sidebar
- Resolvers define the actual components and their editable properties
Real Example: HTML DIV Container
Looking at your actual code, here's how a <div> becomes a container:
Layer 1: Resolver (defines the actual component)
// resolvermap.ts - defines what a <div> is
const resolveHtmlElements = (elements: string[]) => {
const mapped: Record<string, any> = {};
elements.forEach((element) => {
mapped[element] = {
componentName: element,
eventsSchema: {
$el: "div",
children: [
{
$formkit: "textarea",
name: "click",
label: "onClick",
},
],
},
propsSchema: [
{
$formkit: "text",
label: "CSS Class(es)",
name: "class",
},
],
};
});
return mapped;
};Layer 2: Blueprint (defines what users see in editor)
// blueprints.ts - creates the draggable blueprint
const createHtmlElementBlueprints = () => {
const resolverMap: CraftNodeResolverMap<any> = htmlResolvers;
const blueprints: Blueprints<any> = {};
Object.entries(resolverMap).forEach(([key, value]) => {
blueprints[key] = {
label: `HTML <${value.componentName}>`,
componentName: "CraftCanvas",
props: {
...value.defaultProps,
componentName: value.componentName,
},
slots: {},
};
});
return blueprints;
};Why This Separation Works
- Resolvers define the actual component behavior and properties
- Blueprints define how components appear in the editor
- This allows the same component to have different editor representations
- Container behavior is added by CraftCanvas, not the component itself
FormKit Schema Reference
All resolver schemas use FormKit schemas. Here are common patterns:
Text Input
{
$formkit: "text",
name: "title",
label: "Title",
validation: "required",
placeholder: "Enter title...",
}Textarea
{
$formkit: "textarea",
name: "description",
label: "Description",
rows: 4,
}Number
{
$formkit: "number",
name: "fontSize",
label: "Font Size",
value: 16,
min: 12,
max: 72,
}Color
{
$formkit: "color",
name: "backgroundColor",
label: "Background Color",
value: "#ffffff",
}Select
{
$formkit: "select",
name: "align",
label: "Alignment",
options: [
{ value: "left", label: "Left" },
{ value: "center", label: "Center" },
{ value: "right", label: "Right" },
],
value: "left",
}URL
{
$formkit: "url",
name: "imageUrl",
label: "Image URL",
validation: "required|url",
}📚 See Also:
- FormKit Input Documentation - Complete list of all available input types
- FormKit Validation - Advanced validation options
- FormKit Conditional Logic - Show/hide fields based on other values
Resolver Best Practices
1. Always Provide Default Props
// ❌ Bad - no defaults
const BadResolver = {
componentName: "MyComponent",
propsSchema: [...]
}
// ✅ Good - always provide defaults
const GoodResolver = {
componentName: "MyComponent",
propsSchema: [...],
defaultProps: {
title: "Default Title",
color: "#333333"
}
}2. Use Validation Where Appropriate
// ❌ Bad - no validation
{
$formkit: "text",
name: "email",
label: "Email",
}
// ✅ Good - with validation
{
$formkit: "text",
name: "email",
label: "Email",
validation: "required|email",
}3. Group Related Properties
// ✅ Group related properties in your schema
propsSchema: [
// Content properties
{ $formkit: "text", name: "title", label: "Title" },
{ $formkit: "textarea", name: "description", label: "Description" },
// Styling properties
{ $formkit: "color", name: "backgroundColor", label: "Background" },
{ $formkit: "color", name: "textColor", label: "Text Color" },
]4. Define Slots for Multi-Slot Components
// ✅ Define all available slots
{
componentName: "MyContainer",
slots: ["header", "body", "footer"]
}5. Use Rules for Complex Drag-and-Drop Behavior
// ✅ Define rules to control drag-and-drop
rules: {
canAccept: (node) => {
// Custom logic for what can be dropped
return true
},
canDrag: (node) => {
// Custom logic for what can be dragged
return true
}
}Common Resolver Mistakes (And How to Fix Them)
Mistake 1: Wrong Component Name
❌ Wrong:
componentName: "MyButton" // But your Vue file is Button.vue✅ Correct:
componentName: "Button" // Must match your Vue component nameMistake 2: Missing Default Props
❌ Wrong:
{
componentName: "MyComponent",
propsSchema: [...]
// No defaultProps!
}✅ Correct:
{
componentName: "MyComponent",
propsSchema: [...],
defaultProps: {
title: "Default Title",
color: "#333333"
}
}Mistake 3: Incorrect FormKit Schema Syntax
❌ Wrong:
propsSchema: [
{
type: "text", // Should be $formkit
name: "title",
label: "Title"
}
]✅ Correct:
propsSchema: [
{
$formkit: "text", // Correct FormKit syntax
name: "title",
label: "Title"
}
]Mistake 4: Not Defining Slots for Container Components
❌ Wrong:
{
componentName: "MyContainer",
propsSchema: [...],
// No slots definition!
}✅ Correct:
{
componentName: "MyContainer",
propsSchema: [...],
slots: ["default", "header", "footer"]
}Testing Your Resolvers
Quick Test Method
import { CraftNodeResolver } from '@versa-stack/v-craft'
import { myResolvers } from './resolvers'
const resolver = new CraftNodeResolver(myResolvers)
// Test resolution
console.log('Testing HeroSection resolver:', {
componentName: resolver.resolve("HeroSection")?.componentName,
hasPropsSchema: !!resolver.resolve("HeroSection")?.propsSchema,
hasDefaultProps: !!resolver.resolve("HeroSection")?.defaultProps,
hasEventsSchema: !!resolver.resolve("HeroSection")?.eventsSchema,
})Next Steps
Now that you understand resolvers:
- Learn about Blueprints to see how resolvers connect to the editor UI
- Create Data Wrappers for dynamic content