|
| 1 | +"""MarkdownRenderer – lightweight markdown renderer for LLM chat responses.""" |
| 2 | + |
| 3 | +cl import from "react" { useState } |
| 4 | +cl import from "lucide-react" { Copy, Check } |
| 5 | + |
| 6 | + |
| 7 | +def CodeBlock(props: Any) -> JsxElement { |
| 8 | + language = props.language or ""; |
| 9 | + code = props.code or ""; |
| 10 | + has copied: bool = False; |
| 11 | + |
| 12 | + def handleCopy -> None { |
| 13 | + navigator.clipboard.writeText(code).then(lambda -> Any { |
| 14 | + copied = True; |
| 15 | + setTimeout(lambda -> None { copied = False; }, 2000); |
| 16 | + return None; |
| 17 | + }); |
| 18 | + } |
| 19 | + |
| 20 | + return <div className="my-3 rounded-lg border border-border overflow-hidden"> |
| 21 | + <div className="flex items-center justify-between px-3 py-1.5 bg-muted/50 border-b border-border"> |
| 22 | + <span className="text-xs font-mono text-muted-foreground/70">{language or "code"}</span> |
| 23 | + <button |
| 24 | + onClick={handleCopy} |
| 25 | + className="flex items-center gap-1 text-xs text-muted-foreground hover:text-foreground transition-colors cursor-pointer" |
| 26 | + > |
| 27 | + {copied and <Check size={11} className="text-success"/> or <Copy size={11}/>} |
| 28 | + <span className="ml-0.5">{copied and "Copied!" or "Copy"}</span> |
| 29 | + </button> |
| 30 | + </div> |
| 31 | + <pre className="px-4 py-3 overflow-x-auto bg-muted/20 m-0"> |
| 32 | + <code className="text-xs font-mono text-foreground leading-relaxed">{code}</code> |
| 33 | + </pre> |
| 34 | + </div>; |
| 35 | +} |
| 36 | + |
| 37 | + |
| 38 | +# Convert inline markdown (**bold**, `code`) to HTML string |
| 39 | +def mdInlineHtml(text: Any) -> Any { |
| 40 | + s = text; |
| 41 | + bParts = s.split("**"); |
| 42 | + s = bParts.map(lambda(p: Any, i: Any) -> Any { |
| 43 | + return i % 2 == 1 and "<strong class='font-semibold text-foreground'>" + p + "</strong>" or p; |
| 44 | + }).join(""); |
| 45 | + cParts = s.split("`"); |
| 46 | + s = cParts.map(lambda(p: Any, i: Any) -> Any { |
| 47 | + return i % 2 == 1 and "<code style='font-family:monospace;font-size:0.8em;background:rgba(128,128,128,0.2);padding:0.1em 0.4em;border-radius:3px'>" + p + "</code>" or p; |
| 48 | + }).join(""); |
| 49 | + return s; |
| 50 | +} |
| 51 | + |
| 52 | + |
| 53 | +# Parse a text segment (between code fences) into typed blocks |
| 54 | +def mdTextToBlocks(text: Any) -> Any { |
| 55 | + blocks = []; |
| 56 | + listItems = []; |
| 57 | + listOrdered = False; |
| 58 | + |
| 59 | + for line in text.split(String.fromCharCode(10)) { |
| 60 | + s = line.trim(); |
| 61 | + if s == "" { |
| 62 | + if listItems.length > 0 { |
| 63 | + blocks.push({"type": "list", "ordered": listOrdered, "items": listItems.slice(0)}); |
| 64 | + listItems.length = 0; |
| 65 | + } |
| 66 | + } elif s.startsWith("### ") { |
| 67 | + if listItems.length > 0 { blocks.push({"type": "list", "ordered": listOrdered, "items": listItems.slice(0)}); listItems.length = 0; } |
| 68 | + blocks.push({"type": "h3", "text": s.substring(4)}); |
| 69 | + } elif s.startsWith("## ") { |
| 70 | + if listItems.length > 0 { blocks.push({"type": "list", "ordered": listOrdered, "items": listItems.slice(0)}); listItems.length = 0; } |
| 71 | + blocks.push({"type": "h2", "text": s.substring(3)}); |
| 72 | + } elif s.startsWith("# ") { |
| 73 | + if listItems.length > 0 { blocks.push({"type": "list", "ordered": listOrdered, "items": listItems.slice(0)}); listItems.length = 0; } |
| 74 | + blocks.push({"type": "h1", "text": s.substring(2)}); |
| 75 | + } elif s.startsWith("- ") or s.startsWith("* ") { |
| 76 | + if listItems.length > 0 and listOrdered { blocks.push({"type": "list", "ordered": True, "items": listItems.slice(0)}); listItems.length = 0; } |
| 77 | + listOrdered = False; |
| 78 | + listItems.push(s.substring(2)); |
| 79 | + } elif not isNaN(parseInt(s)) and s.indexOf(". ") > 0 { |
| 80 | + if listItems.length > 0 and not listOrdered { blocks.push({"type": "list", "ordered": False, "items": listItems.slice(0)}); listItems.length = 0; } |
| 81 | + listOrdered = True; |
| 82 | + dotIdx = s.indexOf(". "); |
| 83 | + listItems.push(s.substring(dotIdx + 2)); |
| 84 | + } else { |
| 85 | + if listItems.length > 0 { blocks.push({"type": "list", "ordered": listOrdered, "items": listItems.slice(0)}); listItems.length = 0; } |
| 86 | + blocks.push({"type": "p", "text": s}); |
| 87 | + } |
| 88 | + } |
| 89 | + if listItems.length > 0 { |
| 90 | + blocks.push({"type": "list", "ordered": listOrdered, "items": listItems.slice(0)}); |
| 91 | + } |
| 92 | + return blocks; |
| 93 | +} |
| 94 | + |
| 95 | + |
| 96 | +def:pub MarkdownRenderer(props: Any) -> JsxElement { |
| 97 | + content = props.content or ""; |
| 98 | + |
| 99 | + # Split on ``` fences → alternate text / code |
| 100 | + parts = content.split("```"); |
| 101 | + rendered = []; |
| 102 | + segIdx = 0; |
| 103 | + |
| 104 | + for part in parts { |
| 105 | + if segIdx % 2 == 1 { |
| 106 | + lines = part.split(String.fromCharCode(10)); |
| 107 | + lang = lines[0].trim(); |
| 108 | + code = lines.slice(1).join(String.fromCharCode(10)).trimEnd(); |
| 109 | + rendered.push(<CodeBlock key={segIdx} language={lang} code={code}/>); |
| 110 | + } else { |
| 111 | + for block in mdTextToBlocks(part) { |
| 112 | + blockKey = f"{segIdx}-{rendered.length}"; |
| 113 | + if block.type == "h1" { |
| 114 | + rendered.push(<h1 key={blockKey} className="text-base font-bold mt-3 mb-1 text-foreground" dangerouslySetInnerHTML={{"__html": mdInlineHtml(block.text)}}/>); |
| 115 | + } elif block.type == "h2" { |
| 116 | + rendered.push(<h2 key={blockKey} className="text-sm font-bold mt-2.5 mb-1 text-foreground" dangerouslySetInnerHTML={{"__html": mdInlineHtml(block.text)}}/>); |
| 117 | + } elif block.type == "h3" { |
| 118 | + rendered.push(<h3 key={blockKey} className="text-sm font-semibold mt-2 mb-0.5 text-foreground" dangerouslySetInnerHTML={{"__html": mdInlineHtml(block.text)}}/>); |
| 119 | + } elif block.type == "list" { |
| 120 | + items = block.items.map(lambda(item: Any, ii: Any) -> Any { |
| 121 | + return <li key={ii} className="leading-relaxed" dangerouslySetInnerHTML={{"__html": mdInlineHtml(item)}}/>; |
| 122 | + }); |
| 123 | + if block.ordered { |
| 124 | + rendered.push(<ol key={blockKey} className="text-sm list-decimal pl-5 my-1 space-y-0.5">{items}</ol>); |
| 125 | + } else { |
| 126 | + rendered.push(<ul key={blockKey} className="text-sm list-disc pl-5 my-1 space-y-0.5">{items}</ul>); |
| 127 | + } |
| 128 | + } else { |
| 129 | + rendered.push(<p key={blockKey} className="text-sm leading-relaxed" dangerouslySetInnerHTML={{"__html": mdInlineHtml(block.text)}}/>); |
| 130 | + } |
| 131 | + } |
| 132 | + } |
| 133 | + segIdx += 1; |
| 134 | + } |
| 135 | + |
| 136 | + return <div className="text-sm text-foreground space-y-1.5">{rendered}</div>; |
| 137 | +} |
0 commit comments