"use client"; import { PortableText, PortableTextProps } from "@portabletext/react"; import Image from "next/image"; import Link from "next/link"; import React, { useMemo } from "react"; import { YouTubeEmbed } from "@next/third-parties/google"; import { Highlight, themes } from "prism-react-renderer"; import { CopyButton } from "@/components/ui/copy-button"; import { CheckCircle2, Quote } from "lucide-react"; import { urlFor } from "@/sanity/lib/image"; import CalloutBox from "@/components/ui/callout-box"; import { Accordion, AccordionContent, AccordionItem, AccordionTrigger, } from "@/components/ui/accordion"; import { cn } from "@/lib/utils"; // Helper to format inline markdown like **bold**, *italic*, [link](href) function formatInlineText(text: string): React.ReactNode { if (typeof text !== "string" || !text) return text; let clean = text .replace(/^>\s*\*{1,3}/, "") .replace(/\*{1,3}$/, "") .replace(/^→\s*/, "") .replace(/^\*{2}→\s*/, "**"); const regex = /(\[.*?\]\(.*?\)|\*\*.*?\*\*|\*.*?\*|`.*?`)/g; const parts = clean.split(regex); if (parts.length === 1) return clean; return parts.map((part, i) => { if (!part) return null; const linkMatch = part.match(/^\[(.*?)\]\((.*?)\)$/); if (linkMatch) { const [, label, href] = linkMatch; const isExt = href.startsWith("http") || href.startsWith("mailto"); return ( {label} ); } const boldMatch = part.match(/^\*\*(.*?)\*\*$/); if (boldMatch) { return ( {boldMatch[1]} ); } const italicMatch = part.match(/^\*(.*?)\*$/); if (italicMatch) { return ( {italicMatch[1]} ); } const codeMatch = part.match(/^`(.*?)`$/); if (codeMatch) { return ( {codeMatch[1]} ); } return part; }); } function formatChildren(children: React.ReactNode): React.ReactNode { if (typeof children === "string") { return formatInlineText(children); } if (Array.isArray(children)) { return React.Children.map(children, (child) => { if (typeof child === "string") { return formatInlineText(child); } return child; }); } return children; } // Preprocess Portable Text blocks to handle Notion callouts, Scenarios, frontmatter cleanup, and clean structure function preprocessPortableText(blocks: any[]): any[] { if (!Array.isArray(blocks)) return blocks; const result: any[] = []; let i = 0; while (i < blocks.length) { const block = blocks[i]; const text = block.children?.map((c: any) => c.text).join("") || ""; const trimmed = text.trim(); // 0. Skip frontmatter metadata blocks leftover from markdown import if ( i < 12 && /^(title|slug|customPath|pageId|createdTime|lastEditedTime|cover):/i.test( trimmed ) ) { i++; continue; } // 0b. Skip redundant duplicate title H1/H2 at the very beginning of the article if ( i < 5 && (block.style === "h1" || block.style === "h2") && (/vietnam luxury wedding cost/i.test(trimmed) || /ngân sách tiệc cưới cao cấp/i.test(trimmed)) ) { i++; continue; } // 1. Detect Notion Callout pattern (
or > 💡 **Callout:**) if ( text.includes("
") || text.includes("Callout:") || text.startsWith("> 💡") ) { let title = "Vietnam Wedding Guide's Advice"; const contentParts: string[] = []; let j = i; let foundEnd = false; while (j < blocks.length && j < i + 15) { const bText = blocks[j].children?.map((c: any) => c.text).join("") || ""; const summaryMatch = bText.match( /\s*\**\s*(.*?)\s*\**\s*<\/summary>/i ); if (summaryMatch) { title = summaryMatch[1].replace(/[*#]/g, "").trim(); } else if ( !bText.includes("
") && !bText.includes("
") && !bText.includes("Callout:") && !bText.startsWith("> 💡") ) { if (bText.trim()) { contentParts.push(bText.trim()); } } if (bText.includes("
")) { foundEnd = true; j++; break; } j++; } if (foundEnd || contentParts.length > 0) { result.push({ _key: block._key || `callout-${i}`, _type: "callout", tone: "gold", title, content: contentParts.join("\n\n"), }); i = j; continue; } } // 2. Detect Scenario 1, Scenario 2, Scenario 3 pattern (English & Vietnamese) const isScenarioStart = /Scenario\s*1/i.test(text) || /Kịch bản\s*1/i.test(text) || text.includes("Nếu hai bạn là người trực tiếp") || text.includes("Nếu hai bạn"); if (isScenarioStart) { const scenarios: Array<{ title: string; upside: string; downside: string; }> = []; let j = i; let curTitle = ""; let curUpside = ""; let curDownside = ""; while (j < blocks.length && j < i + 25) { const bText = blocks[j].children?.map((c: any) => c.text).join("") || ""; // Check if this line is a scenario header const scenMatch = bText.match( /(?:Scenario\s*(\d+)[:\s]+|Kịch bản\s*(\d+)[:\s]+|→\s*(?:Nếu\s+|Scenario\s*\d+:?\s*))(.*?)(?:\*\*|$)/i ); const isHeader = scenMatch || bText.includes("→ Nếu") || (blocks[j].style === "h4" && (bText.includes("Scenario") || bText.includes("Nếu"))); if (isHeader) { if (curTitle) { scenarios.push({ title: curTitle, upside: curUpside, downside: curDownside, }); curUpside = ""; curDownside = ""; } if (scenMatch && scenMatch[1]) { curTitle = `Scenario ${scenMatch[1]}: ${scenMatch[3] ?.replace(/[*#→_]/g, "") .trim()}`; } else { curTitle = bText.replace(/[*#→_]/g, "").trim(); } } else if ( bText.includes("Upside:") || bText.includes("Reality:") || bText.includes("Lợi ích:") || bText.includes("Thực tế:") ) { curUpside = bText .replace(/.*?(Upside|Reality|Lợi ích|Thực tế):\s*/i, "") .replace(/[*]/g, "") .trim(); } else if ( bText.includes("Challenge:") || bText.includes("Trade-off:") || bText.includes("Rationale:") || bText.includes("Thách thức:") || bText.includes("Đánh đổi:") || bText.includes("Lý do:") ) { curDownside = bText .replace( /.*?(Challenge|Trade-off|Rationale|Thách thức|Đánh đổi|Lý do):\s*/i, "" ) .replace(/[*]/g, "") .trim(); } else if ( scenarios.length >= 2 && (blocks[j].style === "h2" || blocks[j].style === "h3" || (!bText.includes("Scenario") && !bText.includes("Nếu") && !bText.includes("Upside") && !bText.includes("Lợi ích") && !bText.includes("Trade-off") && !bText.includes("Thách thức") && !bText.includes("Challenge") && !bText.includes("Reality") && !bText.includes("Thực tế") && !bText.includes("Rationale") && !bText.includes("Lý do"))) ) { break; } j++; } if (curTitle) { scenarios.push({ title: curTitle, upside: curUpside, downside: curDownside, }); } if (scenarios.length >= 2) { result.push({ _key: block._key || `scenarios-${i}`, _type: "scenariosGrid", items: scenarios, }); i = j; continue; } } // 3. Skip standalone
or
leftover tags if ( trimmed === "
" || trimmed === "
" || trimmed === "> 💡 **Callout:**" ) { i++; continue; } // 4. Standalone quotes starting with `>` or `> ***` if (trimmed.startsWith(">")) { const cleanQuote = trimmed .replace(/^>\s*/, "") .replace(/^\*+|\*+$/g, "") .trim(); result.push({ _key: block._key || `quote-${i}`, _type: "quote", content: cleanQuote, }); i++; continue; } result.push(block); i++; } return result; } const portableTextComponents: PortableTextProps["components"] = { types: { tableBlock: ({ value }) => { const rows = value?.rows; if (!rows || rows.length === 0) return null; const [headerRow, ...bodyRows] = rows; return (
{headerRow.cells?.map((cell: string, idx: number) => ( ))} {bodyRows.map((row: any, rIdx: number) => ( {row.cells?.map((cell: string, cIdx: number) => ( ))} ))}
{formatInlineText(cell)}
{formatInlineText(cell)}
); }, faqAccordion: ({ value }) => { const items = value?.items; if (!items || items.length === 0) return null; return (
{items.map((item: any, idx: number) => ( {item.question} {formatInlineText(item.answer)} ))}
); }, callout: ({ value }) => { return (
{typeof value.content === "string" ? value.content.split("\n\n").map((p: string, idx: number) => (

{formatInlineText(p)}

)) : value.content}
); }, quote: ({ value }) => { return (

{formatInlineText(value.content)}

); }, scenariosGrid: ({ value }) => { return (
{value.items?.map((scen: any, idx: number) => { const isVietnamese = /[\u00C0-\u1EF9]/.test( scen.title || scen.upside || "" ); const upsideLabel = isVietnamese ? "Lợi ích:" : "Advantage:"; const downsideLabel = isVietnamese ? "Thách thức / Rủi ro:" : "Trade-off / Risk:"; return (
Scenario 0{idx + 1}

{scen.title.replace( /^(?:Scenario\s*\d+:?|Kịch bản\s*\d+:?)\s*/i, "" )}

{scen.upside && (

{upsideLabel} {" "} {formatInlineText(scen.upside)}

)} {scen.downside && (

{downsideLabel} {" "} {formatInlineText(scen.downside)}

)}
); })}
); }, gallery: ({ value }) => { const { images, layout, caption } = value; const colsClass = layout === "3col" ? "grid-cols-1 sm:grid-cols-2 lg:grid-cols-3" : "grid-cols-1 sm:grid-cols-2"; return (
{images?.map((img: any, i: number) => (
{img.alt
))}
{caption && (
{caption}
)}
); }, image: ({ value }) => { const { url, metadata } = value.asset; const { lqip, dimensions } = metadata; return ( {value.alt ); }, youtube: ({ value }) => { const { videoId } = value; return (
); }, code: ({ value }) => { return (
{value.filename || ""}
{({ style, tokens, getLineProps, getTokenProps }) => (
                {tokens.map((line, i) => (
                  
{line.map((token, key) => ( ))}
))}
)}
); }, }, block: { normal: ({ children }) => { const rawText = Array.isArray(children) ? children.map((c) => (typeof c === "string" ? c : "")).join("") : typeof children === "string" ? children : ""; // If this paragraph looks like an imported bullet point (e.g. "1. **Bold**" or "* **Bold**") const bulletMatch = rawText.match(/^(\d+\.\s+|[*•-]\s+)(.*)$/); if (bulletMatch) { return (
{formatInlineText(bulletMatch[2])}
); } return (

{formatChildren(children)}

); }, h1: ({ children }) => (

{formatChildren(children)}

), h2: ({ children }) => { const rawText = Array.isArray(children) ? children.map((c) => (typeof c === "string" ? c : "")).join("") : typeof children === "string" ? children : ""; const id = rawText .toLowerCase() .replace(/[^a-z0-9]+/g, "-") .replace(/^-|-$/g, ""); return (

{formatChildren(children)}

); }, h3: ({ children }) => { const rawText = Array.isArray(children) ? children.map((c) => (typeof c === "string" ? c : "")).join("") : typeof children === "string" ? children : ""; const id = rawText .toLowerCase() .replace(/[^a-z0-9]+/g, "-") .replace(/^-|-$/g, ""); return (

{formatChildren(children)}

); }, h4: ({ children }) => (

{formatChildren(children)}

), h5: ({ children }) => (
{formatChildren(children)}
), blockquote: ({ children }) => (
{formatChildren(children)}
), }, marks: { link: ({ value, children }) => { const isExternal = (value?.href || "").startsWith("http") || (value?.href || "").startsWith("https") || (value?.href || "").startsWith("mailto"); const target = isExternal ? "_blank" : undefined; return ( {children} ); }, strong: ({ children }) => ( {children} ), em: ({ children }) => ( {children} ), }, list: { bullet: ({ children }) => ( ), number: ({ children }) => (
    {children}
), }, listItem: { bullet: ({ children }) => (
  • {formatChildren(children)}
  • ), number: ({ children }) => (
  • {formatChildren(children)}
  • ), }, }; const PortableTextRenderer = ({ value, }: { value: PortableTextProps["value"]; }) => { const processedValue = useMemo(() => { return preprocessPortableText(value as any[]); }, [value]); return ; }; export default PortableTextRenderer;