summaryrefslogblamecommitdiff
path: root/makima/frontend/src/lib/markdown.ts
blob: b6e860aca478e344ab4dece1f16729fdbe33adf7 (plain) (tree)



































































































































































































































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