/**
* 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;
}
}