summaryrefslogtreecommitdiff
path: root/makima/frontend/src/lib/markdown.ts
blob: b6e860aca478e344ab4dece1f16729fdbe33adf7 (plain) (blame)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
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;
  }
}