import React, { Fragment } from 'react'; /** * Tiny self-contained Markdown-to-React renderer. * * Built specifically for the AI Visibility article output produced by the * agent backend (Gemini-generated long-form posts). Supports the subset * we actually see in practice - headings, paragraphs, ordered / unordered * lists, blockquotes, fenced code blocks, inline ``code``, **bold**, * *italic*, links and images. No third-party markdown library so bundle * weight stays minimal. * * Output is structured React elements - no ``dangerouslySetInnerHTML``. */ /** One entry in the TOC derived from article markdown. */ export interface TocEntry { level: number; text: string; slug: string; } export const slugifyHeading = (text: string): string => { const normalised = text.normalize('NFD').replace(/[̀-ͯ]/g, '').toLowerCase(); const slug = normalised.replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, ''); return slug || 'section'; }; export const extractHeadings = (source: string): TocEntry[] => { if (!source || typeof source !== 'string') return []; const out: TocEntry[] = []; const seen = new Map(); for (const line of source.replace(/\r\n/g, '\n').split('\n')) { const heading = /^(#{1,6})\s+(.*)$/.exec(line); if (!heading) continue; const level = heading[1].length; const text = heading[2] .replace(/`([^`]+)`/g, '$1') .replace(/\*\*([^*]+)\*\*/g, '$1') .replace(/\*([^*]+)\*/g, '$1') .replace(/\[([^\]]+)\]\(([^)]+)\)/g, '$1') .trim(); if (!text) continue; const base = slugifyHeading(text); const count = (seen.get(base) ?? 0) + 1; seen.set(base, count); const slug = count > 1 ? `${base}-${count}` : base; out.push({ level, text, slug }); } return out; }; interface MarkdownViewProps { source: string; } const renderInline = (text: string, keyPrefix: string): React.ReactNode[] => { const tokens: { regex: RegExp; render: (m: RegExpExecArray, key: string) => React.ReactNode; }[] = [ { regex: /!\[([^\]]*)\]\(([^)]+)\)/, render: (m, key) => ( {m[1]} ), }, { regex: /\[([^\]]+)\]\(([^)]+)\)/, render: (m, key) => ( {m[1]} ), }, { regex: /`([^`]+)`/, render: (m, key) => ( {m[1]} ), }, { regex: /\*\*([^*]+)\*\*/, render: (m, key) => {m[1]}, }, { regex: /\*([^*]+)\*/, render: (m, key) => {m[1]}, }, ]; const walk = (input: string, depth: number): React.ReactNode[] => { if (!input) return []; let earliest: { token: (typeof tokens)[0]; match: RegExpExecArray } | null = null; for (const token of tokens) { const match = token.regex.exec(input); if (match && (!earliest || match.index < earliest.match.index)) { earliest = { token, match }; } } if (!earliest) return [input]; const before = input.slice(0, earliest.match.index); const after = input.slice(earliest.match.index + earliest.match[0].length); const node = earliest.token.render( earliest.match, `${keyPrefix}-${depth}-${earliest.match.index}` ); return [...(before ? [before] : []), node, ...walk(after, depth + 1)]; }; return walk(text, 0); }; const HEADING_STYLES: Record = { 1: { fontSize: 32, fontWeight: 700, margin: '24px 0 16px', lineHeight: 1.2 }, 2: { fontSize: 24, fontWeight: 700, margin: '32px 0 12px', lineHeight: 1.25 }, 3: { fontSize: 20, fontWeight: 600, margin: '24px 0 10px', lineHeight: 1.3 }, 4: { fontSize: 18, fontWeight: 600, margin: '20px 0 8px' }, 5: { fontSize: 16, fontWeight: 600, margin: '16px 0 6px' }, 6: { fontSize: 14, fontWeight: 600, margin: '14px 0 6px' }, }; const MarkdownView = ({ source }: MarkdownViewProps): JSX.Element => { if (!source || typeof source !== 'string') { return <>; } const blocks: React.ReactNode[] = []; const lines = source.replace(/\r\n/g, '\n').split('\n'); const seenSlugs = new Map(); let i = 0; let blockKey = 0; while (i < lines.length) { const line = lines[i]; if (/^```/.test(line)) { const collected: string[] = []; i++; while (i < lines.length && !/^```/.test(lines[i])) { collected.push(lines[i]); i++; } i++; blocks.push(
          {collected.join('\n')}
        
); continue; } if (/^\s*---+\s*$/.test(line)) { blocks.push(
); i++; continue; } const heading = /^(#{1,6})\s+(.*)$/.exec(line); if (heading) { const level = heading[1].length; const text = heading[2].trim(); const base = slugifyHeading( text .replace(/`([^`]+)`/g, '$1') .replace(/\*\*([^*]+)\*\*/g, '$1') .replace(/\*([^*]+)\*/g, '$1') .replace(/\[([^\]]+)\]\(([^)]+)\)/g, '$1') ); const count = (seenSlugs.get(base) ?? 0) + 1; seenSlugs.set(base, count); const slug = count > 1 ? `${base}-${count}` : base; const Tag = `h${level}` as keyof JSX.IntrinsicElements; blocks.push( {renderInline(text, `h${blockKey}`)} ); i++; continue; } if (/^>\s?/.test(line)) { const collected: string[] = []; while (i < lines.length && /^>\s?/.test(lines[i])) { collected.push(lines[i].replace(/^>\s?/, '')); i++; } blocks.push(
{collected.map((text, idx) => (

{renderInline(text, `bq${blockKey}-${idx}`)}

))}
); continue; } if (/^\d+\.\s+/.test(line)) { const items: string[] = []; while (i < lines.length && /^\d+\.\s+/.test(lines[i])) { items.push(lines[i].replace(/^\d+\.\s+/, '')); i++; } blocks.push(
    {items.map((item, idx) => (
  1. {renderInline(item, `ol${blockKey}-${idx}`)}
  2. ))}
); continue; } if (/^[-*+]\s+/.test(line)) { const items: string[] = []; while (i < lines.length && /^[-*+]\s+/.test(lines[i])) { items.push(lines[i].replace(/^[-*+]\s+/, '')); i++; } blocks.push(
    {items.map((item, idx) => (
  • {renderInline(item, `ul${blockKey}-${idx}`)}
  • ))}
); continue; } if (line.trim() === '') { i++; continue; } const para: string[] = [line]; i++; while ( i < lines.length && lines[i].trim() !== '' && !/^(#{1,6})\s+/.test(lines[i]) && !/^>\s?/.test(lines[i]) && !/^\d+\.\s+/.test(lines[i]) && !/^[-*+]\s+/.test(lines[i]) && !/^```/.test(lines[i]) && !/^\s*---+\s*$/.test(lines[i]) ) { para.push(lines[i]); i++; } const paraKey = `b-${blockKey++}`; blocks.push(

{para .flatMap((segment, idx) => [ ...(idx > 0 ? [
] : []), ...renderInline(segment, `${paraKey}-${idx}`), ]) .map((node, idx) => ( {node} ))}

); } return
{blocks}
; }; /** * Escape the five HTML special characters so user-supplied markdown text * can never inject raw markup into the serialized output. */ const escapeHtml = (input: string): string => input .replace(/&/g, '&') .replace(//g, '>') .replace(/"/g, '"') .replace(/'/g, '''); const TABLE_LINE = /^\s*\|.*\|\s*$/; const TABLE_SEPARATOR = /^\s*\|?\s*:?-+:?\s*(\|\s*:?-+:?\s*)+\|?\s*$/; type TableAlign = 'left' | 'right' | 'center' | null; const splitTableRow = (line: string): string[] => { let s = line.trim(); if (s.startsWith('|')) s = s.slice(1); if (s.endsWith('|')) s = s.slice(0, -1); return s.split('|').map(c => c.trim()); }; const columnAlignments = (sepLine: string): TableAlign[] => splitTableRow(sepLine).map(cell => { const left = cell.startsWith(':'); const right = cell.endsWith(':'); if (left && right) return 'center'; if (right) return 'right'; if (left) return 'left'; return null; }); const inlineToHtml = (text: string): string => { const tokens: { regex: RegExp; render: (m: RegExpExecArray) => string; }[] = [ { regex: /!\[([^\]]*)\]\(([^)]+)\)/, render: m => `${escapeHtml(m[1])}`, }, { regex: /\[([^\]]+)\]\(([^)]+)\)/, render: m => `${inlineToHtml(m[1])}`, }, { regex: /`([^`]+)`/, render: m => `${escapeHtml(m[1])}`, }, { regex: /\*\*([^*]+)\*\*/, render: m => `${inlineToHtml(m[1])}`, }, { regex: /\*([^*]+)\*/, render: m => `${inlineToHtml(m[1])}`, }, ]; const walk = (input: string): string => { if (!input) return ''; let earliest: { token: (typeof tokens)[0]; match: RegExpExecArray } | null = null; for (const token of tokens) { const match = token.regex.exec(input); if (match && (!earliest || match.index < earliest.match.index)) { earliest = { token, match }; } } if (!earliest) return escapeHtml(input); const before = input.slice(0, earliest.match.index); const after = input.slice(earliest.match.index + earliest.match[0].length); return ( escapeHtml(before) + earliest.token.render(earliest.match) + walk(after) ); }; return walk(text); }; /** * Serialize markdown to a clean HTML string suitable for pasting into a * WordPress / Gutenberg post body or any CMS that accepts safe inline * HTML. Output is a sequence of stacked block elements (h1..h6, p, * ul/ol, blockquote, pre/code, hr, table) - no outer html/body wrapper, * no scripts. Heading ids match the slugs produced by * {@link slugifyHeading} so anchor links round-trip with the in-app * preview. */ export const markdownToHtml = (source: string): string => { if (!source || typeof source !== 'string') return ''; const out: string[] = []; const lines = source.replace(/\r\n/g, '\n').split('\n'); const seenSlugs = new Map(); let i = 0; while (i < lines.length) { const line = lines[i]; if (/^```/.test(line)) { const collected: string[] = []; i++; while (i < lines.length && !/^```/.test(lines[i])) { collected.push(lines[i]); i++; } i++; out.push(`
${escapeHtml(collected.join('\n'))}
`); continue; } if (/^\s*---+\s*$/.test(line)) { out.push('
'); i++; continue; } const heading = /^(#{1,6})\s+(.*)$/.exec(line); if (heading) { const level = heading[1].length; const text = heading[2].trim(); const base = slugifyHeading( text .replace(/`([^`]+)`/g, '$1') .replace(/\*\*([^*]+)\*\*/g, '$1') .replace(/\*([^*]+)\*/g, '$1') .replace(/\[([^\]]+)\]\(([^)]+)\)/g, '$1') ); const count = (seenSlugs.get(base) ?? 0) + 1; seenSlugs.set(base, count); const slug = count > 1 ? `${base}-${count}` : base; out.push( `${inlineToHtml(text)}` ); i++; continue; } if (/^>\s?/.test(line)) { const collected: string[] = []; while (i < lines.length && /^>\s?/.test(lines[i])) { collected.push(lines[i].replace(/^>\s?/, '')); i++; } const inner = collected.map(t => `

${inlineToHtml(t)}

`).join(''); out.push(`
${inner}
`); continue; } if (/^\d+\.\s+/.test(line)) { const items: string[] = []; while (i < lines.length && /^\d+\.\s+/.test(lines[i])) { items.push(lines[i].replace(/^\d+\.\s+/, '')); i++; } out.push( `
    ${items.map(t => `
  1. ${inlineToHtml(t)}
  2. `).join('')}
` ); continue; } if (/^[-*+]\s+/.test(line)) { const items: string[] = []; while (i < lines.length && /^[-*+]\s+/.test(lines[i])) { items.push(lines[i].replace(/^[-*+]\s+/, '')); i++; } out.push( `
    ${items.map(t => `
  • ${inlineToHtml(t)}
  • `).join('')}
` ); continue; } if ( TABLE_LINE.test(line) && i + 1 < lines.length && TABLE_SEPARATOR.test(lines[i + 1]) ) { const headers = splitTableRow(line); const aligns = columnAlignments(lines[i + 1]); i += 2; const bodyRows: string[][] = []; while (i < lines.length && TABLE_LINE.test(lines[i])) { bodyRows.push(splitTableRow(lines[i])); i++; } const styleFor = (a: TableAlign): string => a ? ` style="text-align:${a}"` : ''; const head = headers .map((h, idx) => `${inlineToHtml(h)}`) .join(''); const body = bodyRows .map( row => `${row .map( (c, cIdx) => `${inlineToHtml(c)}` ) .join('')}` ) .join(''); out.push( `${head}${body}
` ); continue; } if (line.trim() === '') { i++; continue; } const para: string[] = [line]; i++; while ( i < lines.length && lines[i].trim() !== '' && !/^(#{1,6})\s+/.test(lines[i]) && !/^>\s?/.test(lines[i]) && !/^\d+\.\s+/.test(lines[i]) && !/^[-*+]\s+/.test(lines[i]) && !/^```/.test(lines[i]) && !/^\s*---+\s*$/.test(lines[i]) && !( TABLE_LINE.test(lines[i]) && i + 1 < lines.length && TABLE_SEPARATOR.test(lines[i + 1]) ) ) { para.push(lines[i]); i++; } out.push(`

${para.map(inlineToHtml).join('
')}

`); } return out.join('\n'); }; /** * Render a markdown source into a plain-text string suitable for the * clipboard's ``text/plain`` slot. Reuses {@link markdownToHtml} so any future * formatting tweak only has to land in one place, then collapses the resulting * tags into characters. Block elements (``

``, ``

  • ``, ````, * ``
    ``) become single newlines so a paste into Word / Notepad / Gmail * keeps its paragraph breaks instead of collapsing into one run-on line. * * @param {string} source - Markdown source text. * @returns {string} Plain-text rendering with paragraph breaks preserved. */ export const markdownToPlainText = (source: string): string => { if (!source || typeof source !== 'string') return ''; if (typeof window === 'undefined' || typeof DOMParser === 'undefined') { return markdownToHtml(source) .replace(//gi, '\n') .replace(/<\/(p|div|li|h[1-6])>/gi, '\n') .replace(/<[^>]+>/g, '') .replace(/ /g, ' ') .replace(/&/g, '&') .replace(/</g, '<') .replace(/>/g, '>') .replace(/"/g, '"') .replace(/\n{3,}/g, '\n\n') .trim(); } const htmlWithBreaks = markdownToHtml(source) .replace(//gi, '\n') .replace(/<\/(p|div|li|h[1-6])>/gi, '$&\n'); const parsed = new DOMParser().parseFromString(htmlWithBreaks, 'text/html'); const text = parsed.body.textContent || ''; return text.replace(/\n{3,}/g, '\n\n').trim(); }; export default MarkdownView;