Files
NexusOS/interface/web/src/Markdown.jsx
T

209 lines
6.0 KiB
React

import { useState } from "react";
// Parse content into an array of {type, value, lang, streaming} blocks.
// Handles:
// - ```lang\ncode``` (well-formed)
// - ```code``` (no language, no newline — model formatting bug)
// - ```lang\ncode (unclosed — streaming in progress)
function parseBlocks(content) {
const blocks = [];
let i = 0;
while (i < content.length) {
const fenceStart = content.indexOf("```", i);
if (fenceStart === -1) {
if (i < content.length) blocks.push({ type: "text", value: content.slice(i) });
break;
}
if (fenceStart > i) blocks.push({ type: "text", value: content.slice(i, fenceStart) });
let j = fenceStart + 3;
let lang = "";
// Language specifier is valid only when word-chars are followed by a newline.
// If there's no newline (e.g. ```pythonprint(...)) treat everything as code.
const langMatch = content.slice(j).match(/^(\w+)(\r?\n)/);
if (langMatch) {
lang = langMatch[1];
j += langMatch[0].length;
} else if (/^\r?\n/.test(content.slice(j))) {
j += content[j] === "\r" ? 2 : 1;
}
// else: no newline — leave j as-is, lang stays ""
const closeIdx = content.indexOf("```", j);
if (closeIdx === -1) {
blocks.push({ type: "code", lang, value: content.slice(j), streaming: true });
i = content.length;
} else {
blocks.push({ type: "code", lang, value: content.slice(j, closeIdx).replace(/\n$/, ""), streaming: false });
i = closeIdx + 3;
}
}
return blocks;
}
export function Markdown({ content }) {
if (!content) return null;
const blocks = parseBlocks(content);
return (
<div style={{ lineHeight: "1.6" }}>
{blocks.map((block, i) =>
block.type === "code"
? <CodeBlock key={i} lang={block.lang} value={block.value} streaming={block.streaming} />
: <TextBlock key={i} text={block.value} />
)}
</div>
);
}
function CodeBlock({ lang, value, streaming }) {
const [copied, setCopied] = useState(false);
const copy = () => {
navigator.clipboard.writeText(value.trimEnd()).then(() => {
setCopied(true);
setTimeout(() => setCopied(false), 1500);
});
};
return (
<div style={{
background: "#0d0d0d",
border: "1px solid #2a2a2a",
borderRadius: "6px",
margin: "0.5rem 0",
overflow: "hidden",
}}>
<div style={{
display: "flex",
justifyContent: "space-between",
alignItems: "center",
padding: "0.3rem 0.75rem",
background: "#161616",
borderBottom: "1px solid #2a2a2a",
}}>
<span style={{ fontSize: "0.75rem", color: "#666", fontFamily: "monospace" }}>
{lang || "code"}
{streaming && <span style={{ color: "#444", marginLeft: "0.5rem" }}></span>}
</span>
{!streaming && (
<button onClick={copy} style={{
background: "transparent",
border: "none",
color: copied ? "#4caf50" : "#555",
cursor: "pointer",
fontSize: "0.75rem",
padding: "0.1rem 0.3rem",
}}>
{copied ? "Copied!" : "Copy"}
</button>
)}
</div>
<pre style={{
padding: "0.75rem 1rem",
overflowX: "auto",
fontSize: "0.85rem",
lineHeight: "1.5",
margin: 0,
fontFamily: "monospace",
}}>
<code>{value.trimEnd()}</code>
</pre>
</div>
);
}
function TextBlock({ text }) {
const lines = text.split("\n");
const elements = [];
let i = 0;
while (i < lines.length) {
const line = lines[i];
const hMatch = line.match(/^(#{1,3})\s+(.+)/);
if (hMatch) {
const level = hMatch[1].length;
const sizes = { 1: "1.2rem", 2: "1.05rem", 3: "0.95rem" };
elements.push(
<div key={i} style={{ fontWeight: "700", fontSize: sizes[level], margin: "0.6rem 0 0.2rem", color: "#fff" }}>
{inlineMarkdown(hMatch[2])}
</div>
);
i++; continue;
}
if (/^[-*]\s+/.test(line)) {
const items = [];
while (i < lines.length && /^[-*]\s+/.test(lines[i])) {
items.push(<li key={i}>{inlineMarkdown(lines[i].replace(/^[-*]\s+/, ""))}</li>);
i++;
}
elements.push(<ul key={`ul-${i}`} style={{ paddingLeft: "1.25rem", margin: "0.25rem 0" }}>{items}</ul>);
continue;
}
if (/^\d+\.\s+/.test(line)) {
const items = [];
while (i < lines.length && /^\d+\.\s+/.test(lines[i])) {
items.push(<li key={i}>{inlineMarkdown(lines[i].replace(/^\d+\.\s+/, ""))}</li>);
i++;
}
elements.push(<ol key={`ol-${i}`} style={{ paddingLeft: "1.25rem", margin: "0.25rem 0" }}>{items}</ol>);
continue;
}
if (/^---+$/.test(line.trim())) {
elements.push(<hr key={i} style={{ border: "none", borderTop: "1px solid #333", margin: "0.5rem 0" }} />);
i++; continue;
}
if (line.trim() === "") {
elements.push(<div key={i} style={{ height: "0.4rem" }} />);
i++; continue;
}
elements.push(<div key={i}>{inlineMarkdown(line)}</div>);
i++;
}
return <>{elements}</>;
}
function inlineMarkdown(text) {
const parts = [];
const re = /(\*\*(.+?)\*\*|\*(.+?)\*|`([^`]+)`)/g;
let last = 0;
let m;
while ((m = re.exec(text)) !== null) {
if (m.index > last) parts.push(text.slice(last, m.index));
if (m[2] !== undefined) {
parts.push(<strong key={m.index}>{m[2]}</strong>);
} else if (m[3] !== undefined) {
parts.push(<em key={m.index}>{m[3]}</em>);
} else if (m[4] !== undefined) {
parts.push(
<code key={m.index} style={{
background: "#1e1e1e",
border: "1px solid #333",
borderRadius: "3px",
padding: "0.1rem 0.35rem",
fontSize: "0.85em",
fontFamily: "monospace",
}}>
{m[4]}
</code>
);
}
last = m.index + m[0].length;
}
if (last < text.length) parts.push(text.slice(last));
return parts.length === 1 && typeof parts[0] === "string" ? parts[0] : parts;
}