summaryrefslogtreecommitdiff
path: root/makima/frontend/src/lib/markdown.ts
diff options
context:
space:
mode:
authorsoryu <soryu@soryu.co>2026-01-11 05:52:14 +0000
committersoryu <soryu@soryu.co>2026-01-15 00:21:16 +0000
commit87044a747b47bd83249d61a45842c7f7b2eae56d (patch)
treeef2000ce79ffcc2723ef841acef5aa1deb1d5378 /makima/frontend/src/lib/markdown.ts
parent077820c4167c168072d217a1b01df840463a12a8 (diff)
downloadsoryu-87044a747b47bd83249d61a45842c7f7b2eae56d.tar.gz
soryu-87044a747b47bd83249d61a45842c7f7b2eae56d.zip
Contract system
Diffstat (limited to 'makima/frontend/src/lib/markdown.ts')
-rw-r--r--makima/frontend/src/lib/markdown.ts228
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 `![${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;
+ }
+}