INIT
This commit is contained in:
commit
5245c0a856
42 files changed
+11202
No files matched your search
@@ -0,0 +1,138 @@
|
||||
import { useState, type ReactNode } from "react";
|
||||
import { downloadImage } from "./download";
|
||||
import { mediaName, mediaUrl } from "./media";
|
||||
import { useStore } from "../state/store";
|
||||
import { DownloadIcon, ToolIcon } from "../components/Icons";
|
||||
|
||||
// A deliberately small, dependency-free markdown renderer. It escapes all text
|
||||
// (no raw HTML) and supports the subset that matters for chat: fenced code
|
||||
// blocks, inline code, images (including data: URLs), links, bold, italic, and
|
||||
// autolinks.
|
||||
|
||||
const INLINE =
|
||||
/(`[^`]+`)|(!\[[^\]]*\]\([^)]*\))|(\[[^\]]*\]\([^)]*\))|(\*\*[^*]+\*\*)|(\*[^*\n]+\*)|(https?:\/\/[^\s<)]+)|(data:image\/[^\s)]+)|(MEDIA:\S+)/g;
|
||||
|
||||
/** Splits `alt](url` (the inside of `` / `[text](url)`) into parts. */
|
||||
function splitLink(inner: string): { label: string; url: string } {
|
||||
const idx = inner.indexOf("](");
|
||||
if (idx === -1) return { label: inner, url: "" };
|
||||
return { label: inner.slice(0, idx), url: inner.slice(idx + 2) };
|
||||
}
|
||||
|
||||
/** Some servers wrap long data URLs across lines; strip whitespace so the
|
||||
* browser can decode them. */
|
||||
function cleanUrl(url: string): string {
|
||||
return url.startsWith("data:") ? url.replace(/\s+/g, "") : url;
|
||||
}
|
||||
|
||||
/** A `MEDIA:<path>` reference: an image if a media base URL is configured,
|
||||
* otherwise an attachment card. */
|
||||
function MediaRef({ path }: { path: string }) {
|
||||
const baseUrl = useStore((s) => s.config.mediaBaseUrl);
|
||||
const prefix = useStore((s) => s.config.mediaPathPrefix);
|
||||
const [failed, setFailed] = useState(false);
|
||||
const url = mediaUrl(path, baseUrl, prefix);
|
||||
const name = mediaName(path);
|
||||
|
||||
if (url && !failed) {
|
||||
return (
|
||||
<span className="md-figure">
|
||||
<img src={url} alt={name} onError={() => setFailed(true)} />
|
||||
<button type="button" className="md-download" onClick={() => downloadImage(url, name)}>
|
||||
<DownloadIcon width={15} height={15} />
|
||||
Download
|
||||
</button>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<span className="media-ref" title={path}>
|
||||
<ToolIcon width={15} height={15} />
|
||||
<span className="nm">{name}</span>
|
||||
<span className="path">{path}</span>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
/** An inline image with a download button beneath it. */
|
||||
function ImageFigure({ url, alt }: { url: string; alt: string }) {
|
||||
const src = cleanUrl(url);
|
||||
return (
|
||||
<span className="md-figure">
|
||||
<img src={src} alt={alt} />
|
||||
<button type="button" className="md-download" onClick={() => downloadImage(src, alt)}>
|
||||
<DownloadIcon width={15} height={15} />
|
||||
Download
|
||||
</button>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function renderInline(text: string, keyPrefix: string): ReactNode[] {
|
||||
const nodes: ReactNode[] = [];
|
||||
let last = 0;
|
||||
let i = 0;
|
||||
let m: RegExpExecArray | null;
|
||||
INLINE.lastIndex = 0;
|
||||
while ((m = INLINE.exec(text)) !== null) {
|
||||
if (m.index > last) nodes.push(text.slice(last, m.index));
|
||||
const token = m[0];
|
||||
const key = `${keyPrefix}-${i++}`;
|
||||
|
||||
if (m[1]) {
|
||||
nodes.push(<code key={key}>{token.slice(1, -1)}</code>);
|
||||
} else if (m[2]) {
|
||||
const { label, url } = splitLink(token.slice(2, -1));
|
||||
nodes.push(<ImageFigure key={key} url={url} alt={label} />);
|
||||
} else if (m[3]) {
|
||||
const { label, url } = splitLink(token.slice(1, -1));
|
||||
nodes.push(
|
||||
<a key={key} href={url} target="_blank" rel="noreferrer noopener">
|
||||
{label}
|
||||
</a>,
|
||||
);
|
||||
} else if (m[4]) {
|
||||
nodes.push(<strong key={key}>{token.slice(2, -2)}</strong>);
|
||||
} else if (m[5]) {
|
||||
nodes.push(<em key={key}>{token.slice(1, -1)}</em>);
|
||||
} else if (m[6]) {
|
||||
nodes.push(
|
||||
<a key={key} href={token} target="_blank" rel="noreferrer noopener">
|
||||
{token}
|
||||
</a>,
|
||||
);
|
||||
} else if (m[7]) {
|
||||
nodes.push(<ImageFigure key={key} url={token} alt="image" />);
|
||||
} else if (m[8]) {
|
||||
nodes.push(<MediaRef key={key} path={token.slice("MEDIA:".length)} />);
|
||||
}
|
||||
last = m.index + token.length;
|
||||
}
|
||||
if (last < text.length) nodes.push(text.slice(last));
|
||||
return nodes;
|
||||
}
|
||||
|
||||
export function renderMarkdown(text: string): ReactNode {
|
||||
const parts: ReactNode[] = [];
|
||||
const fence = /```([^\n`]*)\n?([\s\S]*?)```/g;
|
||||
let last = 0;
|
||||
let m: RegExpExecArray | null;
|
||||
let i = 0;
|
||||
while ((m = fence.exec(text)) !== null) {
|
||||
if (m.index > last) {
|
||||
parts.push(<span key={`t${i}`}>{renderInline(text.slice(last, m.index), `t${i}`)}</span>);
|
||||
}
|
||||
parts.push(
|
||||
<pre key={`c${i}`}>
|
||||
<code>{m[2].replace(/\n$/, "")}</code>
|
||||
</pre>,
|
||||
);
|
||||
last = m.index + m[0].length;
|
||||
i++;
|
||||
}
|
||||
if (last < text.length) {
|
||||
parts.push(<span key={`t${i}`}>{renderInline(text.slice(last), `t${i}`)}</span>);
|
||||
}
|
||||
return parts;
|
||||
}
|
||||
Reference in new issue
Block a user