/** * 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) { 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 \n\n\n\n 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("")) { const inner = innerContent(chunk, "TLDR"); if (inner) { nodes.push(blockNode("mintelTldr", { content: inner })); continue; } } // Paragraph (handles , ) if (/]/.test(chunk)) { const inner = innerContent(chunk, "Paragraph"); if (inner) { nodes.push(blockNode("mintelP", { text: inner })); continue; } } // H2 (handles

,

) if (/]/.test(chunk)) { const inner = innerContent(chunk, "H2"); if (inner) { nodes.push( blockNode("mintelHeading", { text: inner, seoLevel: "h2", displayLevel: "h2", }), ); continue; } } // H3 (handles

,

) if (/]/.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("") && !chunk.includes("")) { const inner = innerContent(chunk, "LeadParagraph"); if (inner) { nodes.push(blockNode("leadParagraph", { text: inner })); continue; } } // ArticleBlockquote if (chunk.includes(" const itemMatches = chunk.matchAll(/]*?)\/>/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: HTML content const itemMatches2 = chunk.matchAll( /]*)>([\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("]*?)\/>/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(") if (/^") { continue; } // === Standard Markdown === // CarouselBlock if (chunk.includes("]*?)\/>/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. \n\n\n\n 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 = ``; 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; }