lazyBoy/apps/web/src/markdown.tsx

189 lines
6.3 KiB
TypeScript

import { Children, cloneElement, createElement, isValidElement, memo, useCallback, useMemo, useRef, useState, type ComponentPropsWithoutRef, type ReactNode } from "react";
import ReactMarkdown, { type Components } from "react-markdown";
import remarkBreaks from "remark-breaks";
import remarkGfm from "remark-gfm";
import { t } from "./i18n";
import { mentionSpans, type MentionAgent } from "./mentions";
import "./markdown.css";
const protocolPattern = /^([a-z][a-z\d+.-]*):/i;
const safeProtocols = new Set(["http", "https", "mailto", "tel"]);
export function sanitizeMarkdownUrl(url: string): string | undefined {
const value = url.trim();
const protocol = value.match(protocolPattern)?.[1]?.toLowerCase();
if (protocol) return safeProtocols.has(protocol) ? value : undefined;
if (value.startsWith("#") && !value.toLowerCase().startsWith("#javascript")) return value;
return undefined;
}
function CopyIcon() {
return (
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" aria-hidden="true">
<rect x="9" y="9" width="12" height="12" rx="2" stroke="currentColor" strokeWidth="2" strokeLinejoin="round"/>
<path d="M5 15H4a1 1 0 0 1-1-1V4a1 1 0 0 1 1-1h10a1 1 0 0 1 1 1v1" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"/>
</svg>
);
}
function CheckIcon() {
return (
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" aria-hidden="true">
<path d="M4 12.5 9.5 18 20 6" stroke="currentColor" strokeWidth="2.25" strokeLinecap="round" strokeLinejoin="round"/>
</svg>
);
}
function useCopiedFlag() {
const [copied, setCopied] = useState(false);
const timerRef = useRef<number>(undefined);
const markCopied = useCallback(() => {
setCopied(true);
window.clearTimeout(timerRef.current);
timerRef.current = window.setTimeout(() => setCopied(false), 1500);
}, []);
return {copied, markCopied};
}
export function copyText(text: string): Promise<boolean> {
if (!navigator.clipboard) return Promise.resolve(false);
return navigator.clipboard.writeText(text).then(() => true).catch(() => false);
}
function CodeBlock(props: ComponentPropsWithoutRef<"pre">) {
const preRef = useRef<HTMLPreElement>(null);
const {copied, markCopied} = useCopiedFlag();
const handleCopy = useCallback(() => {
void copyText(preRef.current?.textContent ?? "").then((ok) => { if (ok) markCopied(); });
}, [markCopied]);
return (
<div className="md-pre-wrap">
<pre {...props} ref={preRef}/>
<button type="button" className="md-copy" onClick={handleCopy} aria-label={copied ? t("copiedCode") : t("copyCode")}>
{copied ? <CheckIcon/> : <CopyIcon/>}
</button>
</div>
);
}
const components: Components = {
a({node: _node, ...props}) {
return <a {...props} target="_blank" rel="noreferrer noopener"/>;
},
img({node: _node, ...props}) {
return <img {...props} alt={props.alt ?? ""} loading="lazy"/>;
},
pre({node: _node, ...props}) {
return <CodeBlock {...props}/>;
},
};
export function CopyMessageButton({text}:{text:string}) {
const {copied, markCopied} = useCopiedFlag();
if (!text.trim()) return null;
return (
<button
type="button"
className={`copy-msg ${copied ? "copied" : ""}`}
title={copied ? t("copiedMessage") : t("copyMessage")}
aria-label={copied ? t("copiedMessage") : t("copyMessage")}
onClick={() => { void copyText(text).then((ok) => { if (ok) markCopied(); }); }}
>
{copied ? <CheckIcon/> : <CopyIcon/>}
<span>{copied ? t("copiedMessage") : t("copyMessage")}</span>
</button>
);
}
export function MentionText({
text,
agents,
onMention,
}:{
text:string;
agents:MentionAgent[];
onMention?:(id:string)=>void;
}) {
const spans=mentionSpans(text,agents);
if(!spans.length)return null;
if(spans.length===1&&spans[0].kind==="text")return text;
return <>{spans.map((span,index)=>{
if(span.kind==="text")return <span key={index}>{span.text}</span>;
if(span.kind==="everyone")return <span key={index} className="mention-link is-everyone">{span.text}</span>;
const name=span.text.slice(1);
return <button
key={index}
type="button"
className="mention-link"
title={t("openAgentChat",{name})}
aria-label={t("openAgentChat",{name})}
onClick={event=>{event.preventDefault();event.stopPropagation();onMention?.(span.id);}}
>{span.text}</button>;
})}</>;
}
const SKIP_TAGS=new Set(["code","pre","a"]);
function linkifyMentions(node:ReactNode,agents:MentionAgent[],onMention?:(id:string)=>void):ReactNode {
if(node==null||typeof node==="boolean")return node;
if(typeof node==="number")return node;
if(typeof node==="string")return <MentionText text={node} agents={agents} onMention={onMention}/>;
if(Array.isArray(node))return Children.map(node,child=>linkifyMentions(child,agents,onMention));
if(isValidElement(node)){
if(typeof node.type!=="string")return node;
if(SKIP_TAGS.has(node.type))return node;
const kids=(node.props as {children?:ReactNode}).children;
if(kids==null)return node;
return cloneElement(node,undefined,linkifyMentions(kids,agents,onMention));
}
return node;
}
function mentionComponents(agents:MentionAgent[],onMention?:(id:string)=>void):Components {
const wrap=(tag:"p"|"li"|"td"|"th"|"h1"|"h2"|"h3"|"h4"|"h5"|"h6"|"blockquote")=>
function MentionTag({node:_node,children,...props}:{node?:unknown;children?:ReactNode}){
return createElement(tag,props,linkifyMentions(children,agents,onMention));
};
return {
...components,
p:wrap("p"),
li:wrap("li"),
td:wrap("td"),
th:wrap("th"),
h1:wrap("h1"),
h2:wrap("h2"),
h3:wrap("h3"),
h4:wrap("h4"),
h5:wrap("h5"),
h6:wrap("h6"),
blockquote:wrap("blockquote"),
};
}
export const ChatMarkdown = memo(function ChatMarkdown({
children,
agents=[],
onMention,
}:{
children:string;
agents?:MentionAgent[];
onMention?:(id:string)=>void;
}) {
const mdComponents=useMemo(
()=>agents.length?mentionComponents(agents,onMention):components,
[agents,onMention],
);
return (
<div className="md-body">
<ReactMarkdown
components={mdComponents}
remarkPlugins={[remarkGfm, remarkBreaks]}
skipHtml
urlTransform={(url) => sanitizeMarkdownUrl(url) ?? ""}
>
{children}
</ReactMarkdown>
</div>
);
});