UNPKG

@furystack/shades-common-components

Version:

Common UI components for FuryStack Shades

274 lines 10.2 kB
/** * Zero-dependency Markdown parser that converts a Markdown string into an AST. */ const HORIZONTAL_RULE_RE = /^(\*{3,}|-{3,}|_{3,})\s*$/; const HEADING_RE = /^(#{1,6})\s+(.*)/; const FENCED_CODE_OPEN_RE = /^```(\w*)\s*$/; const FENCED_CODE_CLOSE_RE = /^```\s*$/; const UNORDERED_LIST_RE = /^(\s*)[-*]\s+(.*)/; const ORDERED_LIST_RE = /^(\s*)\d+\.\s+(.*)/; const BLOCKQUOTE_RE = /^>\s?(.*)/; const CHECKBOX_UNCHECKED_RE = /^\[[ ]\]\s+(.*)/; const CHECKBOX_CHECKED_RE = /^\[[xX]\]\s+(.*)/; /** * Parse inline Markdown formatting into an array of InlineNodes. * * Known limitations: * - Backslash escapes (e.g. `\*`) are not supported; special characters are always interpreted. * - Underscore `_` markers are not restricted to word boundaries, so identifiers like * `some_variable_name` may produce false italic/bold matches. */ export const parseInline = (text) => { const nodes = []; let pos = 0; while (pos < text.length) { // Inline code if (text[pos] === '`') { const closeIdx = text.indexOf('`', pos + 1); if (closeIdx !== -1) { nodes.push({ type: 'code', content: text.slice(pos + 1, closeIdx) }); pos = closeIdx + 1; continue; } } // Image ![alt](src) if (text[pos] === '!' && text[pos + 1] === '[') { const altClose = text.indexOf(']', pos + 2); if (altClose !== -1 && text[altClose + 1] === '(') { const srcClose = text.indexOf(')', altClose + 2); if (srcClose !== -1) { const alt = text.slice(pos + 2, altClose); const src = text.slice(altClose + 2, srcClose); nodes.push({ type: 'image', src, alt }); pos = srcClose + 1; continue; } } } // Link [text](href) if (text[pos] === '[') { const textClose = text.indexOf(']', pos + 1); if (textClose !== -1 && text[textClose + 1] === '(') { const hrefClose = text.indexOf(')', textClose + 2); if (hrefClose !== -1) { const linkText = text.slice(pos + 1, textClose); const href = text.slice(textClose + 2, hrefClose); nodes.push({ type: 'link', href, children: parseInline(linkText) }); pos = hrefClose + 1; continue; } } } // Bold+Italic (***text***) or Bold (**text**) or Italic (*text*) if (text[pos] === '*' || text[pos] === '_') { const marker = text[pos]; // Count consecutive markers let markerCount = 0; while (pos + markerCount < text.length && text[pos + markerCount] === marker) { markerCount++; } if (markerCount >= 3) { const closeIdx = text.indexOf(marker.repeat(3), pos + 3); if (closeIdx !== -1) { const inner = text.slice(pos + 3, closeIdx); nodes.push({ type: 'bold', children: [{ type: 'italic', children: parseInline(inner) }] }); pos = closeIdx + 3; continue; } } if (markerCount >= 2) { const closeIdx = text.indexOf(marker.repeat(2), pos + 2); if (closeIdx !== -1) { const inner = text.slice(pos + 2, closeIdx); nodes.push({ type: 'bold', children: parseInline(inner) }); pos = closeIdx + 2; continue; } } if (markerCount >= 1) { const closeIdx = text.indexOf(marker, pos + 1); if (closeIdx !== -1) { const inner = text.slice(pos + 1, closeIdx); nodes.push({ type: 'italic', children: parseInline(inner) }); pos = closeIdx + 1; continue; } } } // Plain text — consume until the next special character let end = pos + 1; while (end < text.length && !['`', '!', '[', '*', '_'].includes(text[end])) { end++; } const content = text.slice(pos, end); const lastNode = nodes[nodes.length - 1]; if (lastNode?.type === 'text') { lastNode.content += content; } else { nodes.push({ type: 'text', content }); } pos = end; } return nodes; }; /** * Parse a Markdown string into an array of block-level MarkdownNodes. */ export const parseMarkdown = (source) => { const lines = source.split('\n'); const nodes = []; let i = 0; while (i < lines.length) { const line = lines[i]; // Blank lines — skip if (line.trim() === '') { i++; continue; } // Fenced code block const codeMatch = FENCED_CODE_OPEN_RE.exec(line); if (codeMatch) { const language = codeMatch[1] || undefined; const codeLines = []; i++; while (i < lines.length && !FENCED_CODE_CLOSE_RE.test(lines[i])) { codeLines.push(lines[i]); i++; } nodes.push({ type: 'codeBlock', language, content: codeLines.join('\n') }); i++; // skip closing ``` continue; } // Horizontal rule if (HORIZONTAL_RULE_RE.test(line)) { nodes.push({ type: 'horizontalRule' }); i++; continue; } // Heading const headingMatch = HEADING_RE.exec(line); if (headingMatch) { const level = headingMatch[1].length; nodes.push({ type: 'heading', level, children: parseInline(headingMatch[2]) }); i++; continue; } // Blockquote const bqMatch = BLOCKQUOTE_RE.exec(line); if (bqMatch) { const bqLines = []; while (i < lines.length) { const bqLineMatch = BLOCKQUOTE_RE.exec(lines[i]); if (bqLineMatch) { bqLines.push(bqLineMatch[1]); i++; } else { break; } } nodes.push({ type: 'blockquote', children: parseMarkdown(bqLines.join('\n')) }); continue; } // Unordered list const ulMatch = UNORDERED_LIST_RE.exec(line); if (ulMatch) { const items = []; while (i < lines.length) { const itemMatch = UNORDERED_LIST_RE.exec(lines[i]); if (!itemMatch) break; const itemText = itemMatch[2]; const checkedMatch = CHECKBOX_CHECKED_RE.exec(itemText); const uncheckedMatch = CHECKBOX_UNCHECKED_RE.exec(itemText); if (checkedMatch) { items.push({ children: parseInline(checkedMatch[1]), checkbox: 'checked', sourceLineIndex: i, }); } else if (uncheckedMatch) { items.push({ children: parseInline(uncheckedMatch[1]), checkbox: 'unchecked', sourceLineIndex: i, }); } else { items.push({ children: parseInline(itemText), sourceLineIndex: i, }); } i++; } nodes.push({ type: 'list', ordered: false, items }); continue; } // Ordered list const olMatch = ORDERED_LIST_RE.exec(line); if (olMatch) { const items = []; while (i < lines.length) { const itemMatch = ORDERED_LIST_RE.exec(lines[i]); if (!itemMatch) break; items.push({ children: parseInline(itemMatch[2]), sourceLineIndex: i, }); i++; } nodes.push({ type: 'list', ordered: true, items }); continue; } // Paragraph — collect consecutive non-blank, non-block-start lines const paraLines = []; while (i < lines.length) { const pLine = lines[i]; if (pLine.trim() === '') break; if (HEADING_RE.test(pLine)) break; if (FENCED_CODE_OPEN_RE.test(pLine)) break; if (HORIZONTAL_RULE_RE.test(pLine)) break; if (BLOCKQUOTE_RE.test(pLine)) break; if (UNORDERED_LIST_RE.test(pLine)) break; if (ORDERED_LIST_RE.test(pLine)) break; paraLines.push(pLine); i++; } if (paraLines.length > 0) { nodes.push({ type: 'paragraph', children: parseInline(paraLines.join(' ')) }); } } return nodes; }; const TOGGLE_UNCHECKED_RE = /^(\s*[-*]\s+)\[ \]/; const TOGGLE_CHECKED_RE = /^(\s*[-*]\s+)\[[xX]\]/; /** * Toggle a checkbox at the given source line index in the raw Markdown string. * Only matches checkboxes in unordered list items (`- [ ]` or `* [x]`). * Returns the updated string. */ export const toggleCheckbox = (source, sourceLineIndex) => { const lines = source.split('\n'); if (sourceLineIndex < 0 || sourceLineIndex >= lines.length) return source; const line = lines[sourceLineIndex]; if (TOGGLE_UNCHECKED_RE.test(line)) { lines[sourceLineIndex] = line.replace(TOGGLE_UNCHECKED_RE, '$1[x]'); } else if (TOGGLE_CHECKED_RE.test(line)) { lines[sourceLineIndex] = line.replace(TOGGLE_CHECKED_RE, '$1[ ]'); } return lines.join('\n'); }; //# sourceMappingURL=markdown-parser.js.map