Files
at-mintel/packages/payload-ai/src/utils/lexicalParser.ts

641 lines
21 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* Converts a Markdown+JSX string into a Lexical AST node array.
* Handles all registered Payload blocks and standard markdown formatting.
*/
function propValue(chunk: string, prop: string): string {
// Match prop="value" or prop='value' or prop={value}
const match =
chunk.match(new RegExp(`${prop}=["']([^"']+)["']`)) ||
chunk.match(new RegExp(`${prop}=\\{([^}]+)\\}`));
return match ? match[1] : "";
}
function innerContent(chunk: string, tag: string): string {
const match = chunk.match(
new RegExp(`<${tag}(?:\\s[^>]*)?>([\\s\\S]*?)<\\/${tag}>`),
);
return match ? match[1].trim() : "";
}
function blockNode(blockType: string, fields: Record<string, any>) {
return {
type: "block",
format: "",
version: 2,
fields: { blockType, ...fields },
};
}
export function parseMarkdownToLexical(markdown: string): any[] {
const textNode = (text: string) => ({
type: "paragraph",
format: "",
indent: 0,
version: 1,
children: [{ mode: "normal", type: "text", text, version: 1 }],
});
const nodes: any[] = [];
// Strip frontmatter
let content = markdown;
const fm = content.match(/^---\s*\n[\s\S]*?\n---/);
if (fm) content = content.replace(fm[0], "").trim();
// Pre-process: reassemble multi-line JSX tags that got split by double-newline chunking.
// This handles tags like <IconList>\n\n<IconListItem ... />\n\n</IconList>
content = reassembleMultiLineJSX(content);
const rawChunks = content.split(/\n\s*\n/);
for (let chunk of rawChunks) {
chunk = chunk.trim();
if (!chunk) continue;
// === Self-closing tags (no children) ===
// ArticleMeme / MemeCard
if (chunk.includes("<ArticleMeme") || chunk.includes("<MemeCard")) {
nodes.push(
blockNode("memeCard", {
template: propValue(chunk, "template"),
captions: propValue(chunk, "captions"),
}),
);
continue;
}
// BoldNumber
if (chunk.includes("<BoldNumber")) {
nodes.push(
blockNode("boldNumber", {
value: propValue(chunk, "value"),
label: propValue(chunk, "label"),
source: propValue(chunk, "source"),
sourceUrl: propValue(chunk, "sourceUrl"),
}),
);
continue;
}
// WebVitalsScore
if (chunk.includes("<WebVitalsScore")) {
nodes.push(
blockNode("webVitalsScore", {
lcp: parseFloat(propValue(chunk, "lcp")) || 0,
inp: parseFloat(propValue(chunk, "inp")) || 0,
cls: parseFloat(propValue(chunk, "cls")) || 0,
description: propValue(chunk, "description"),
}),
);
continue;
}
// LeadMagnet
if (chunk.includes("<LeadMagnet")) {
nodes.push(
blockNode("leadMagnet", {
title: propValue(chunk, "title"),
description: propValue(chunk, "description"),
buttonText: propValue(chunk, "buttonText") || "Jetzt anfragen",
href: propValue(chunk, "href") || "/contact",
variant: propValue(chunk, "variant") || "standard",
}),
);
continue;
}
// ComparisonRow
if (chunk.includes("<ComparisonRow")) {
nodes.push(
blockNode("comparisonRow", {
description: propValue(chunk, "description"),
negativeLabel: propValue(chunk, "negativeLabel"),
negativeText: propValue(chunk, "negativeText"),
positiveLabel: propValue(chunk, "positiveLabel"),
positiveText: propValue(chunk, "positiveText"),
reverse: chunk.includes("reverse={true}"),
}),
);
continue;
}
// StatsDisplay
if (chunk.includes("<StatsDisplay")) {
nodes.push(
blockNode("statsDisplay", {
label: propValue(chunk, "label"),
value: propValue(chunk, "value"),
subtext: propValue(chunk, "subtext"),
}),
);
continue;
}
// MetricBar
if (chunk.includes("<MetricBar")) {
nodes.push(
blockNode("metricBar", {
label: propValue(chunk, "label"),
value: parseFloat(propValue(chunk, "value")) || 0,
max: parseFloat(propValue(chunk, "max")) || 100,
unit: propValue(chunk, "unit") || "%",
}),
);
continue;
}
// ExternalLink
if (chunk.includes("<ExternalLink")) {
nodes.push(
blockNode("externalLink", {
href: propValue(chunk, "href"),
label:
propValue(chunk, "label") || innerContent(chunk, "ExternalLink"),
}),
);
continue;
}
// TrackedLink
if (chunk.includes("<TrackedLink")) {
nodes.push(
blockNode("trackedLink", {
href: propValue(chunk, "href"),
label:
propValue(chunk, "label") || innerContent(chunk, "TrackedLink"),
eventName: propValue(chunk, "eventName"),
}),
);
continue;
}
// YouTube
if (chunk.includes("<YouTubeEmbed")) {
nodes.push(
blockNode("youTubeEmbed", {
videoId: propValue(chunk, "videoId"),
}),
);
continue;
}
// LinkedIn
if (chunk.includes("<LinkedInEmbed")) {
nodes.push(
blockNode("linkedInEmbed", {
url: propValue(chunk, "url"),
}),
);
continue;
}
// Twitter
if (chunk.includes("<TwitterEmbed")) {
nodes.push(
blockNode("twitterEmbed", {
url: propValue(chunk, "url"),
}),
);
continue;
}
// Interactive (self-closing, defaults only)
if (chunk.includes("<RevenueLossCalculator")) {
nodes.push(
blockNode("revenueLossCalculator", {
title: propValue(chunk, "title") || "Performance Revenue Simulator",
}),
);
continue;
}
if (chunk.includes("<PerformanceChart")) {
nodes.push(
blockNode("performanceChart", {
title: propValue(chunk, "title") || "Website Performance",
}),
);
continue;
}
if (chunk.includes("<PerformanceROICalculator")) {
nodes.push(
blockNode("performanceROICalculator", {
baseConversionRate:
parseFloat(propValue(chunk, "baseConversionRate")) || 2.5,
monthlyVisitors:
parseInt(propValue(chunk, "monthlyVisitors")) || 50000,
}),
);
continue;
}
if (chunk.includes("<LoadTimeSimulator")) {
nodes.push(
blockNode("loadTimeSimulator", {
initialLoadTime:
parseFloat(propValue(chunk, "initialLoadTime")) || 3.5,
}),
);
continue;
}
if (chunk.includes("<ArchitectureBuilder")) {
nodes.push(
blockNode("architectureBuilder", {
preset: propValue(chunk, "preset") || "standard",
}),
);
continue;
}
if (chunk.includes("<DigitalAssetVisualizer")) {
nodes.push(
blockNode("digitalAssetVisualizer", {
assetId: propValue(chunk, "assetId"),
}),
);
continue;
}
// === Tags with inner content ===
// TLDR
if (chunk.includes("<TLDR>")) {
const inner = innerContent(chunk, "TLDR");
if (inner) {
nodes.push(blockNode("mintelTldr", { content: inner }));
continue;
}
}
// Paragraph (handles <Paragraph>, <Paragraph ...attrs>)
if (/<Paragraph[\s>]/.test(chunk)) {
const inner = innerContent(chunk, "Paragraph");
if (inner) {
nodes.push(blockNode("mintelP", { text: inner }));
continue;
}
}
// H2 (handles <H2>, <H2 id="...">)
if (/<H2[\s>]/.test(chunk)) {
const inner = innerContent(chunk, "H2");
if (inner) {
nodes.push(
blockNode("mintelHeading", {
text: inner,
seoLevel: "h2",
displayLevel: "h2",
}),
);
continue;
}
}
// H3 (handles <H3>, <H3 id="...">)
if (/<H3[\s>]/.test(chunk)) {
const inner = innerContent(chunk, "H3");
if (inner) {
nodes.push(
blockNode("mintelHeading", {
text: inner,
seoLevel: "h3",
displayLevel: "h3",
}),
);
continue;
}
}
// Marker (inline highlight, usually inside Paragraph store as standalone block)
if (chunk.includes("<Marker>") && !chunk.includes("<Paragraph")) {
const inner = innerContent(chunk, "Marker");
if (inner) {
nodes.push(blockNode("marker", { text: inner }));
continue;
}
}
// LeadParagraph
if (chunk.includes("<LeadParagraph>")) {
const inner = innerContent(chunk, "LeadParagraph");
if (inner) {
nodes.push(blockNode("leadParagraph", { text: inner }));
continue;
}
}
// ArticleBlockquote
if (chunk.includes("<ArticleBlockquote")) {
nodes.push(
blockNode("articleBlockquote", {
quote: innerContent(chunk, "ArticleBlockquote"),
author: propValue(chunk, "author"),
role: propValue(chunk, "role"),
}),
);
continue;
}
// ArticleQuote
if (chunk.includes("<ArticleQuote")) {
nodes.push(
blockNode("articleQuote", {
quote:
innerContent(chunk, "ArticleQuote") || propValue(chunk, "quote"),
author: propValue(chunk, "author"),
role: propValue(chunk, "role"),
source: propValue(chunk, "source"),
}),
);
continue;
}
// Mermaid
if (chunk.includes("<Mermaid")) {
nodes.push(
blockNode("mermaid", {
id: propValue(chunk, "id") || `chart-${Date.now()}`,
title: propValue(chunk, "title"),
showShare: chunk.includes("showShare={true}"),
chartDefinition: innerContent(chunk, "Mermaid"),
}),
);
continue;
}
// Diagram variants (prefer inner definition, fall back to raw chunk text)
if (chunk.includes("<DiagramFlow")) {
nodes.push(
blockNode("diagramFlow", {
definition:
innerContent(chunk, "DiagramFlow") ||
propValue(chunk, "definition") ||
chunk,
}),
);
continue;
}
if (chunk.includes("<DiagramSequence")) {
nodes.push(
blockNode("diagramSequence", {
definition:
innerContent(chunk, "DiagramSequence") ||
propValue(chunk, "definition") ||
chunk,
}),
);
continue;
}
if (chunk.includes("<DiagramGantt")) {
nodes.push(
blockNode("diagramGantt", {
definition:
innerContent(chunk, "DiagramGantt") ||
propValue(chunk, "definition") ||
chunk,
}),
);
continue;
}
if (chunk.includes("<DiagramPie")) {
nodes.push(
blockNode("diagramPie", {
definition:
innerContent(chunk, "DiagramPie") ||
propValue(chunk, "definition") ||
chunk,
}),
);
continue;
}
if (chunk.includes("<DiagramState")) {
nodes.push(
blockNode("diagramState", {
definition:
innerContent(chunk, "DiagramState") ||
propValue(chunk, "definition") ||
chunk,
}),
);
continue;
}
if (chunk.includes("<DiagramTimeline")) {
nodes.push(
blockNode("diagramTimeline", {
definition:
innerContent(chunk, "DiagramTimeline") ||
propValue(chunk, "definition") ||
chunk,
}),
);
continue;
}
// Section (wrapping container unwrap and parse inner content as top-level blocks)
if (chunk.includes("<Section")) {
const inner = innerContent(chunk, "Section");
if (inner) {
const innerNodes = parseMarkdownToLexical(inner);
nodes.push(...innerNodes);
}
continue;
}
// FAQSection (wrapping container)
if (chunk.includes("<FAQSection")) {
// FAQSection contains nested H3/Paragraph pairs.
// We extract them as individual blocks instead.
const faqContent = innerContent(chunk, "FAQSection");
if (faqContent) {
// Parse nested content recursively
const innerNodes = parseMarkdownToLexical(faqContent);
nodes.push(...innerNodes);
}
continue;
}
// IconList with IconListItems
if (chunk.includes("<IconList")) {
const items: any[] = [];
// Self-closing: <IconListItem icon="Check" title="..." description="..." />
const itemMatches = chunk.matchAll(/<IconListItem\s+([^>]*?)\/>/g);
for (const m of itemMatches) {
const attrs = m[1];
const title = (attrs.match(/title=["']([^"']+)["']/) || [])[1] || "";
const desc =
(attrs.match(/description=["']([^"']+)["']/) || [])[1] || "";
items.push({
icon: (attrs.match(/icon=["']([^"']+)["']/) || [])[1] || "Check",
title: title || "•",
description: desc,
});
}
// Content-wrapped: <IconListItem check>HTML content</IconListItem>
const itemMatches2 = chunk.matchAll(
/<IconListItem([^>]*)>([\s\S]*?)<\/IconListItem>/g,
);
for (const m of itemMatches2) {
const attrs = m[1] || "";
const innerHtml = m[2].trim();
// Use title attr if present, otherwise use inner HTML (stripped of tags) as title
const titleAttr = (attrs.match(/title=["']([^"']+)["']/) || [])[1];
const strippedInner = innerHtml.replace(/<[^>]+>/g, "").trim();
items.push({
icon: (attrs.match(/icon=["']([^"']+)["']/) || [])[1] || "Check",
title: titleAttr || strippedInner || "•",
description: "",
});
}
if (items.length > 0) {
nodes.push(blockNode("iconList", { items }));
continue;
}
}
// StatsGrid
if (chunk.includes("<StatsGrid")) {
const stats: any[] = [];
const statMatches = chunk.matchAll(/<StatItem\s+([^>]*?)\/>/g);
for (const m of statMatches) {
const attrs = m[1];
stats.push({
label: (attrs.match(/label=["']([^"']+)["']/) || [])[1] || "",
value: (attrs.match(/value=["']([^"']+)["']/) || [])[1] || "",
});
}
// Also try inline props pattern
if (stats.length === 0) {
const innerStats = innerContent(chunk, "StatsGrid");
if (innerStats) {
// fallback: store the raw content
nodes.push(blockNode("statsGrid", { stats: [] }));
continue;
}
}
nodes.push(blockNode("statsGrid", { stats }));
continue;
}
// PremiumComparisonChart
if (chunk.includes("<PremiumComparisonChart")) {
nodes.push(
blockNode("premiumComparisonChart", {
title: propValue(chunk, "title"),
}),
);
continue;
}
// WaterfallChart
if (chunk.includes("<WaterfallChart")) {
nodes.push(
blockNode("waterfallChart", {
title: propValue(chunk, "title"),
}),
);
continue;
}
// Reveal (animation wrapper just pass through)
if (chunk.includes("<Reveal")) {
const inner = innerContent(chunk, "Reveal");
if (inner) {
// Parse inner content as regular nodes
const innerNodes = parseMarkdownToLexical(inner);
nodes.push(...innerNodes);
continue;
}
}
// Standalone IconListItem (outside IconList context)
if (chunk.includes("<IconListItem")) {
// Skip these should be inside an IconList
continue;
}
// Skip wrapper divs (like <div className="my-8">)
if (/^<div\s/.test(chunk) || chunk === "</div>") {
continue;
}
// === Standard Markdown ===
// CarouselBlock
if (chunk.includes("<Carousel")) {
const slides: any[] = [];
const slideMatches = chunk.matchAll(/<Slide\s+([^>]*?)\/>/g);
for (const m of slideMatches) {
const attrs = m[1];
slides.push({
image: (attrs.match(/image=["']([^"']+)["']/) || [])[1] || "",
caption: (attrs.match(/caption=["']([^"']+)["']/) || [])[1] || "",
});
}
if (slides.length > 0) {
nodes.push(blockNode("carousel", { slides }));
continue;
}
}
// Headings
const headingMatch = chunk.match(/^(#{1,6})\s+(.*)/);
if (headingMatch) {
nodes.push({
type: "heading",
tag: `h${headingMatch[1].length}`,
format: "",
indent: 0,
version: 1,
direction: "ltr",
children: [
{ mode: "normal", type: "text", text: headingMatch[2], version: 1 },
],
});
continue;
}
// Default: plain text paragraph
nodes.push(textNode(chunk));
}
return nodes;
}
/**
* Reassembles multi-line JSX tags that span across double-newline boundaries.
* E.g. <IconList>\n\n<IconListItem.../>\n\n</IconList> becomes a single chunk.
*/
function reassembleMultiLineJSX(content: string): string {
// Tags that wrap other content across paragraph breaks
const wrapperTags = [
"IconList",
"StatsGrid",
"FAQSection",
"Section",
"Reveal",
"Carousel",
];
for (const tag of wrapperTags) {
const openRegex = new RegExp(`<${tag}[^>]*>`, "g");
let match;
while ((match = openRegex.exec(content)) !== null) {
const openPos = match.index;
const closeTag = `</${tag}>`;
const closePos = content.indexOf(closeTag, openPos);
if (closePos === -1) continue;
const fullEnd = closePos + closeTag.length;
const fullBlock = content.substring(openPos, fullEnd);
// Replace double newlines inside this block with single newlines
// so it stays as one chunk during splitting
const collapsed = fullBlock.replace(/\n\s*\n/g, "\n");
content =
content.substring(0, openPos) + collapsed + content.substring(fullEnd);
// Adjust regex position
openRegex.lastIndex = openPos + collapsed.length;
}
}
return content;
}