diff options
Diffstat (limited to 'makima/frontend/src/lib/markdown.ts')
| -rw-r--r-- | makima/frontend/src/lib/markdown.ts | 228 |
1 files changed, 228 insertions, 0 deletions
diff --git a/makima/frontend/src/lib/markdown.ts b/makima/frontend/src/lib/markdown.ts new file mode 100644 index 0000000..b6e860a --- /dev/null +++ b/makima/frontend/src/lib/markdown.ts @@ -0,0 +1,228 @@ +/** + * 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 `${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  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<boolean> { + 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; + } +} |
