| name | mdx-to-lexical |
| description | Convert Astro MDX content to Payload Lexical JSON format, including frontmatter parsing, component extraction, inline block resolution, and relationship linking. Use when converting MDX files, transforming markdown content, processing Astro components, generating Lexical JSON, migrating rich text from Keystatic to Payload, resolving data instance references, or mapping MDX components to Payload blocks. |
| allowed-tools | Read, Write, Bash |
MDX to Lexical Conversion Skill
Convert Astro MDX files to Payload Lexical JSON format with full component mapping and inline block resolution.
What This Skill Does
This skill handles the complete conversion pipeline from MDX source files to valid Payload Lexical JSON:
- Parse MDX files with YAML frontmatter
- Convert Markdown AST to Lexical nodes
- Extract inline components (phone numbers, dynamic data)
- Map components to Payload inline blocks
- Resolve data instance slugs to database IDs
- Validate Lexical JSON structure
When to Use This Skill
Use this skill when:
- Converting MDX files to Lexical JSON
- Migrating content from Astro/Keystatic to Payload
- Transforming Markdown to Lexical format
- Processing Astro components
- Resolving inline block references
- Validating Lexical JSON structure
Input/Output Contract
Input
interface ConversionInput {
mdxFilePath: string // Path to .mdx file
frontmatter: Record<string, any> // Parsed YAML frontmatter
content: string // MDX content string
dataInstanceMap: Map<string, string> // slug → ID mapping
}
Output
interface ConversionOutput {
lexicalJSON: SerializedEditorState // Valid Lexical JSON
inlineBlocks: InlineBlockReference[] // Extracted components
errors: ValidationError[] // Conversion errors (if any)
warnings: string[] // Non-critical issues
}
Conversion Pipeline
Phase 1: Parse MDX
Tools: unified, remark-parse, remark-mdx
Process:
import { unified } from 'unified'
import remarkParse from 'remark-parse'
import remarkMdx from 'remark-mdx'
const ast = unified()
.use(remarkParse)
.use(remarkMdx)
.parse(content)
Output: MDX AST (Abstract Syntax Tree)
Validation:
- No parse errors
- All frontmatter extracted
- Components identified
Phase 2: Convert Markdown Nodes
Node Mappings:
| Markdown Node | Lexical Node |
|---|---|
| heading | { type: 'heading', tag: 'h1-h6', children: [...] } |
| paragraph | { type: 'paragraph', children: [...] } |
| text | { type: 'text', text: '...', format: 0 } |
| strong | text with format: 1 (bold) |
| emphasis | text with format: 2 (italic) |
| link | { type: 'link', url: '...', children: [...] } |
| list | { type: 'list', listType: 'bullet/number', children: [...] } |
| listItem | { type: 'listitem', children: [...] } |
Code Example:
function convertNode(mdNode: MdastNode): LexicalNode {
switch (mdNode.type) {
case 'heading':
return {
type: 'heading',
tag: `h${mdNode.depth}`,
children: mdNode.children.map(convertNode),
direction: 'ltr',
format: '',
indent: 0,
version: 1
}
case 'paragraph':
return {
type: 'paragraph',
children: mdNode.children.map(convertNode),
direction: 'ltr',
format: '',
indent: 0,
version: 1
}
case 'text':
return {
type: 'text',
text: mdNode.value,
format: 0,
mode: 'normal',
style: '',
detail: 0,
version: 1
}
// ... more node types
}
}
Validation:
- All Markdown nodes converted
- Node structure valid
- No missing children
Phase 3: Extract Inline Components
Component Detection:
Look for JSX-style components in MDX:
Call us at <FourChangePhoneNumber /> for assistance.
The lowest rate is <LowestRateDisplay rateType="12month" />.
As of <CurrentYearDirect />, we serve Texas customers.
Extraction Logic:
function extractInlineComponents(paragraph: MdastParagraph): {
nodes: LexicalNode[]
components: ComponentReference[]
} {
const nodes: LexicalNode[] = []
const components: ComponentReference[] = []
for (const child of paragraph.children) {
if (child.type === 'mdxJsxTextElement') {
// Found inline component
const componentName = child.name
const props = extractProps(child.attributes)
components.push({
name: componentName,
props,
slug: mapComponentToSlug(componentName)
})
// Replace with inline block placeholder
nodes.push({
type: 'inlineBlock',
fields: {
blockType: mapComponentToBlockType(componentName),
dataInstanceSlug: mapComponentToSlug(componentName)
}
})
} else {
// Regular text node
nodes.push(convertNode(child))
}
}
return { nodes, components }
}
Component Mapping Table:
| Astro Component | Block Type | Data Instance Slug |
|---|---|---|
<FourChangePhoneNumber /> |
providerPhone |
4change-phone |
<AmigoPhoneNumber /> |
providerPhone |
amigo-phone |
<LowestRateDisplay /> |
lowestRate |
lowest-rate-display |
<CurrentYearDirect /> |
currentYear |
current-year-direct |
<ComparepowerReviewCount /> |
reviewCount |
comparepower-review-count |
<AvgTexasResidentialRate /> |
avgRate |
avg-texas-residential-rate |
Validation:
- All components detected
- Components mapped to block types
- Slugs generated correctly
Phase 4: Resolve Data Instance References
Problem: Inline blocks reference RichTextDataInstances by slug, but Payload needs IDs.
Solution: Query database for slug → ID mapping
Code Example:
async function resolveDataInstanceSlugs(
lexicalJSON: SerializedEditorState,
payload: Payload
): Promise<SerializedEditorState> {
// Collect all slugs
const slugs = new Set<string>()
traverseNodes(lexicalJSON.root, (node) => {
if (node.type === 'inlineBlock') {
slugs.add(node.fields.dataInstanceSlug)
}
})
// Query database for IDs
const { docs } = await payload.find({
collection: 'richTextDataInstances',
where: {
slug: { in: Array.from(slugs) }
},
limit: 1000
})
// Create slug → ID map
const slugToId = new Map(
docs.map(doc => [doc.slug, doc.id])
)
// Replace slugs with IDs
traverseNodes(lexicalJSON.root, (node) => {
if (node.type === 'inlineBlock') {
const slug = node.fields.dataInstanceSlug
const id = slugToId.get(slug)
if (!id) {
throw new Error(`Data instance not found: ${slug}`)
}
// Replace slug with relationship ID
node.fields.dataInstance = id
delete node.fields.dataInstanceSlug
}
})
return lexicalJSON
}
Validation:
- All slugs resolved to IDs
- No orphaned references
- Relationships valid
Phase 5: Validate Lexical JSON
Validation Rules:
- Root Structure:
{
root: {
type: 'root',
format: '',
indent: 0,
version: 1,
children: [...], // Array of nodes
direction: 'ltr'
}
}
- Required Fields:
- All nodes must have
typefield - All nodes must have
version: 1 - All nodes must have
children(except text nodes)
- Text Format Bitmask:
0= normal1= bold2= italic4= strikethrough8= underline
- Heading Tags:
- Must be one of: h1, h2, h3, h4, h5, h6
- Link URLs:
- Must be valid URLs or internal references
- Format:
https://example.comor/internal/path
Validation Code:
function validateLexicalJSON(json: SerializedEditorState): ValidationResult {
const errors: string[] = []
// Check root structure
if (!json.root) {
errors.push('Missing root node')
}
if (json.root.type !== 'root') {
errors.push('Root must have type: "root"')
}
// Traverse all nodes
traverseNodes(json.root, (node, path) => {
if (!node.type) {
errors.push(`Missing type at ${path}`)
}
if (!node.version) {
errors.push(`Missing version at ${path}`)
}
if (node.type === 'heading' && !['h1', 'h2', 'h3', 'h4', 'h5', 'h6'].includes(node.tag)) {
errors.push(`Invalid heading tag at ${path}: ${node.tag}`)
}
if (node.type === 'inlineBlock') {
if (!node.fields.dataInstance) {
errors.push(`Unresolved inline block at ${path}`)
}
}
})
return {
valid: errors.length === 0,
errors
}
}
Validation:
- Root structure valid
- All nodes have required fields
- Heading tags valid
- Links valid
- Inline blocks resolved
Common Edge Cases
Edge Case 1: Nested Components
Problem: Component inside component
<CallToAction>
Call <FourChangePhoneNumber /> today!
</CallToAction>
Solution: Flatten to paragraph with inline block
// Convert to:
{
type: 'paragraph',
children: [
{ type: 'text', text: 'Call ' },
{ type: 'inlineBlock', fields: { ... } },
{ type: 'text', text: ' today!' }
]
}
Edge Case 2: Component with Props
Problem: Component has attributes
<LowestRateDisplay rateType="12month" city="Houston" />
Solution: Store props in inline block fields
{
type: 'inlineBlock',
fields: {
blockType: 'lowestRate',
dataInstance: '...',
props: {
rateType: '12month',
city: 'Houston'
}
}
}
Edge Case 3: Missing Data Instance
Problem: Component references non-existent data instance
Solution: Create placeholder or fail gracefully
if (!dataInstanceId) {
warnings.push(`Missing data instance: ${slug}`)
// Option 1: Skip component (convert to text)
// Option 2: Create placeholder
// Option 3: Fail migration for this file
}
Edge Case 4: HTML in Markdown
Problem: Raw HTML in MDX
Click <a href="https://example.com">here</a> for details.
Solution: Convert HTML to Lexical nodes
// Convert <a> to Lexical link:
{
type: 'link',
url: 'https://example.com',
children: [
{ type: 'text', text: 'here' }
]
}
Example Conversion
Input MDX
---
title: 4Change Energy Review
slug: 4change-energy
status: published
publish_date: 2023-01-15
---
# 4Change Energy Review
4Change Energy is a **top-rated** provider in Texas. Call <FourChangePhoneNumber /> for more information.
## Plans & Rates
The current lowest rate is <LowestRateDisplay rateType="12month" />.
- Fixed-rate plans
- Variable-rate plans
- Green energy options
[Compare Rates](/compare-rates)
Output Lexical JSON
{
"root": {
"type": "root",
"format": "",
"indent": 0,
"version": 1,
"children": [
{
"type": "heading",
"tag": "h1",
"children": [
{
"type": "text",
"text": "4Change Energy Review",
"format": 0
}
],
"direction": "ltr",
"format": "",
"indent": 0,
"version": 1
},
{
"type": "paragraph",
"children": [
{
"type": "text",
"text": "4Change Energy is a ",
"format": 0
},
{
"type": "text",
"text": "top-rated",
"format": 1
},
{
"type": "text",
"text": " provider in Texas. Call ",
"format": 0
},
{
"type": "inlineBlock",
"fields": {
"blockType": "providerPhone",
"dataInstance": "676a1b2c3d4e5f6g7h8i9j0k"
}
},
{
"type": "text",
"text": " for more information.",
"format": 0
}
],
"direction": "ltr",
"format": "",
"indent": 0,
"version": 1
},
{
"type": "heading",
"tag": "h2",
"children": [
{
"type": "text",
"text": "Plans & Rates",
"format": 0
}
],
"direction": "ltr",
"format": "",
"indent": 0,
"version": 1
},
{
"type": "paragraph",
"children": [
{
"type": "text",
"text": "The current lowest rate is ",
"format": 0
},
{
"type": "inlineBlock",
"fields": {
"blockType": "lowestRate",
"dataInstance": "123a4b5c6d7e8f9g0h1i2j3k"
}
},
{
"type": "text",
"text": ".",
"format": 0
}
],
"direction": "ltr",
"format": "",
"indent": 0,
"version": 1
},
{
"type": "list",
"listType": "bullet",
"children": [
{
"type": "listitem",
"children": [
{
"type": "text",
"text": "Fixed-rate plans",
"format": 0
}
]
},
{
"type": "listitem",
"children": [
{
"type": "text",
"text": "Variable-rate plans",
"format": 0
}
]
},
{
"type": "listitem",
"children": [
{
"type": "text",
"text": "Green energy options",
"format": 0
}
]
}
],
"direction": "ltr",
"format": "",
"indent": 0,
"version": 1
},
{
"type": "paragraph",
"children": [
{
"type": "link",
"url": "/compare-rates",
"children": [
{
"type": "text",
"text": "Compare Rates",
"format": 0
}
],
"direction": "ltr",
"format": "",
"indent": 0,
"version": 1
}
],
"direction": "ltr",
"format": "",
"indent": 0,
"version": 1
}
],
"direction": "ltr"
}
}
Scripts Reference
scripts/convert-mdx.ts
Purpose: Main conversion script
Usage:
pnpm tsx .claude/skills/mdx-to-lexical/scripts/convert-mdx.ts \
--input /path/to/file.mdx \
--output /path/to/output.json
Output: Lexical JSON file
scripts/validate-lexical.ts
Purpose: Validate Lexical JSON structure
Usage:
pnpm tsx .claude/skills/mdx-to-lexical/scripts/validate-lexical.ts \
--input /path/to/lexical.json
Output: Validation report with errors/warnings
Validation Checklist
Before marking conversion complete, verify:
- MDX file parsed without syntax errors
- All frontmatter fields extracted
- Lexical JSON has valid root structure
- All heading nodes have valid tags (h1-h6)
- All text nodes have format bitmask
- All inline components converted to inline blocks
- All data instance slugs resolved to IDs
- No orphaned references
- All links are valid URLs or internal paths
- Lexical JSON validates against schema
- Test render in Payload admin UI
Troubleshooting
Issue: Parse Error
Symptom: MDX parsing fails with syntax error
Causes:
- Invalid YAML frontmatter
- Unclosed JSX tags
- Malformed Markdown syntax
Fix:
- Check YAML frontmatter syntax
- Validate JSX component tags
- Test with simpler MDX content
- Review error message for line number
Issue: Missing Inline Block
Symptom: Component not converted to inline block
Causes:
- Component not in mapping table
- Component name mismatch
- Nested component (not supported)
Fix:
- Add component to mapping table
- Check exact component name
- Flatten nested components
Issue: Unresolved Reference
Symptom: "Data instance not found" error
Causes:
- Missing RichTextDataInstance in database
- Incorrect slug mapping
- Slug typo
Fix:
- Create missing data instance
- Verify slug mapping in database
- Check for typos in slug
Issue: Invalid Lexical JSON
Symptom: Validation fails
Causes:
- Missing required fields
- Invalid node structure
- Wrong field types
Fix:
- Run validation script for detailed errors
- Check node structure against Lexical spec
- Verify all required fields present
Best Practices
- Test with One File First: Validate conversion on single file before batch processing
- Keep Component Mapping Updated: Add new components as discovered
- Validate Early: Check Lexical JSON structure immediately after conversion
- Log Warnings: Track non-critical issues for review
- Preserve Source: Keep original MDX files for reference
- Version Lexical JSON: Use
version: 1consistently
Resources
- Lexical Documentation: https://lexical.dev/docs/intro
- Payload Lexical Editor: https://payloadcms.com/docs/rich-text/lexical
- MDX Specification: https://mdxjs.com/docs/
- Unified/Remark: https://unifiedjs.com/
Created: 2025-10-24 Version: 1.0 Use: Ask "Convert MDX file to Lexical JSON"