/** * Markdown conversion utilities for BodyElement arrays. * * Provides bidirectional conversion between structured BodyElement[] and markdown strings. */ import { BodyElement } from "./api"; /** * Convert an array of BodyElements to a markdown string. * * Handles: * - Headings: # through ###### based on level * - Paragraphs: plain text with blank lines between * - Code blocks: ```language\ncontent\n``` * - Lists: ordered (1. 2. 3.) and unordered (- - -) * - Charts: rendered as fenced JSON * - Images: rendered as markdown image syntax */ export function bodyToMarkdown(elements: BodyElement[]): string { return elements .map((elem) => { switch (elem.type) { case "heading": { const hashes = "#".repeat(Math.min(elem.level, 6)); return `${hashes} ${elem.text}`; } case "paragraph": return elem.text; case "code": { const lang = elem.language || ""; return `\`\`\`${lang}\n${elem.content}\n\`\`\``; } case "list": { return elem.items .map((item, i) => (elem.ordered ? `${i + 1}. ${item}` : `- ${item}`)) .join("\n"); } case "chart": { const titleStr = elem.title ? ` - ${elem.title}` : ""; const dataStr = JSON.stringify(elem.data, null, 2); return `\`\`\`chart:${elem.chartType}${titleStr}\n${dataStr}\n\`\`\``; } case "image": { const alt = elem.alt || "image"; const caption = elem.caption ? `\n*${elem.caption}*` : ""; return `![${alt}](${elem.src})${caption}`; } case "markdown": // Markdown elements output their content directly return elem.content; default: return ""; } }) .filter((s) => s !== "") .join("\n\n"); } /** * Parse a markdown string into an array of BodyElements. * * Handles: * - Headings: lines starting with # through ###### * - Code blocks: ```language ... ``` * - Ordered lists: lines starting with 1. 2. etc. * - Unordered lists: lines starting with - or * or + * - Paragraphs: all other non-empty lines */ export function markdownToBody(markdown: string): BodyElement[] { const elements: BodyElement[] = []; const lines = markdown.split("\n"); let currentParagraph: string[] = []; let inCodeBlock = false; let codeBlockLanguage: string | undefined; let codeBlockContent: string[] = []; let currentList: { ordered: boolean; items: string[] } | null = null; const flushParagraph = () => { if (currentParagraph.length > 0) { const text = currentParagraph.join("\n").trim(); if (text) { elements.push({ type: "paragraph", text }); } currentParagraph = []; } }; const flushCodeBlock = () => { if (codeBlockContent.length > 0 || inCodeBlock) { elements.push({ type: "code", language: codeBlockLanguage || undefined, content: codeBlockContent.join("\n"), }); codeBlockContent = []; codeBlockLanguage = undefined; } }; const flushList = () => { if (currentList && currentList.items.length > 0) { elements.push({ type: "list", ordered: currentList.ordered, items: currentList.items, }); currentList = null; } }; // Convert image syntax ![alt](url) to link syntax [alt](url) or [image](url) const convertImagesToLinks = (text: string): string => { return text.replace(/!\[([^\]]*)\]\(([^)]+)\)/g, (_, alt, url) => { const linkText = alt || "image"; return `[${linkText}](${url})`; }); }; for (const rawLine of lines) { // Check for code block fence (``` or ~~~) const codeFenceMatch = rawLine.match(/^(`{3,}|~{3,})(\w*)?$/); if (codeFenceMatch) { if (!inCodeBlock) { // Starting a code block flushParagraph(); flushList(); inCodeBlock = true; codeBlockLanguage = codeFenceMatch[2] || undefined; codeBlockContent = []; } else { // Ending a code block flushCodeBlock(); inCodeBlock = false; } continue; } // If inside a code block, add line as-is if (inCodeBlock) { codeBlockContent.push(rawLine); continue; } // Convert images to links in all lines const line = convertImagesToLinks(rawLine); // Check for headings const headingMatch = line.match(/^(#{1,6})\s+(.+)$/); if (headingMatch) { flushParagraph(); flushList(); const level = headingMatch[1].length; const text = headingMatch[2].trim(); elements.push({ type: "heading", level, text }); continue; } // Check for unordered list items (-, *, +) const unorderedMatch = line.match(/^[\s]*[-*+]\s+(.+)$/); if (unorderedMatch) { flushParagraph(); const itemText = unorderedMatch[1].trim(); if (currentList && currentList.ordered) { // Switch from ordered to unordered flushList(); } if (!currentList) { currentList = { ordered: false, items: [] }; } currentList.items.push(itemText); continue; } // Check for ordered list items (1. 2. etc) const orderedMatch = line.match(/^[\s]*\d+\.\s+(.+)$/); if (orderedMatch) { flushParagraph(); const itemText = orderedMatch[1].trim(); if (currentList && !currentList.ordered) { // Switch from unordered to ordered flushList(); } if (!currentList) { currentList = { ordered: true, items: [] }; } currentList.items.push(itemText); continue; } // Empty line - flush everything if (line.trim() === "") { flushParagraph(); flushList(); continue; } // Regular text - flush list first, then add to paragraph flushList(); currentParagraph.push(line); } // Flush any remaining content if (inCodeBlock) { flushCodeBlock(); } flushParagraph(); flushList(); return elements; } /** * Copy markdown to clipboard. * Returns true if successful, false otherwise. */ export async function copyMarkdownToClipboard( elements: BodyElement[] ): Promise { try { const markdown = bodyToMarkdown(elements); await navigator.clipboard.writeText(markdown); return true; } catch (error) { console.error("Failed to copy markdown to clipboard:", error); return false; } }