2024-03-14 02:39:49 -06:00

294 lines
7.4 KiB
TypeScript

"use client";
import { Accordion, AccordionContent } from "@/lib/accordion";
import { Poppable } from "@/lib/poppables/components/poppable";
import { createElements } from "@/lib/tcmd";
import Link from "next/link";
import React, {
FC,
Fragment,
Suspense,
use,
useEffect,
useMemo,
useState,
} from "react";
import { sanitize } from "isomorphic-dompurify";
import { MDSkeletonLoader } from "../loader";
export const TTCMD: FC<
{ body: string; escapeTOC?: (tokens: Token[]) => boolean }
> = ({ body, escapeTOC = () => false }) => {
const elements = useMemo(() => createElements(body), [body]);
const [toc, start, end] = useMemo(() => {
const tocHead = elements.findIndex((t) =>
t.content.includes("Table of Contents")
);
if (tocHead > -1) {
const hr = elements.slice(tocHead).findIndex((t) => t.type === "hr");
if (hr > -1) {
const end = hr + 1;
return [elements.slice(tocHead, end), tocHead, end - tocHead];
}
}
return [[], 0, 0];
}, [elements]);
// const hasEscapedTOC = useMemo(() => toc && escapeTOC(toc), [escapeTOC, toc])
const [hasEscapedTOC, setHasEscapedTOC] = useState<boolean>();
useEffect(() => {
setHasEscapedTOC(escapeTOC(toc));
}, [escapeTOC, toc]);
return (
<Suspense fallback={<MDSkeletonLoader />}>
{
/* <button
className="btn-primary"
onClick={() =>
navigator.clipboard.writeText(JSON.stringify(elements, null, 2))}
>
copy ast
</button> */
}
{hasEscapedTOC !== undefined &&
(
<TTCMDRenderer
tokens={hasEscapedTOC
? [...elements].toSpliced(start, end)
: elements}
/>
)}
</Suspense>
);
};
export const TTCMDRenderer: FC<{ tokens: Token[] }> = ({ tokens }) => {
const tada = useMemo(
() => (
<>
{renderer(tokens)}
</>
),
[tokens],
);
if (!tokens.length) {
const audio = new Audio(
"https://assets.mixkit.co/active_storage/sfx/221/221-preview.mp3",
);
audio.onload = () => {
audio.play();
};
}
return (
<div className="text-lg">
{tada}
</div>
);
};
function renderer(tokens: Token[]) {
const usedIds: string[] = [];
return tokens.map((t) => (
<div className="p" key={t.uuid}>{render(t, usedIds)}</div>
));
}
function render(token: Token, usedIds: string[]) {
switch (token.type) {
case "heading":
return (
<div
id={generateId(token.raw, usedIds)}
data-strength={token.metadata.strength}
className={`
font-bold
data-[strength="1"]:text-4xl
data-[strength="2"]:text-3xl
data-[strength="3"]:text-2xl
`}
>
{token.content}
</div>
);
case "grid":
return (
<div
style={{
"--grid-cols": token.metadata.columns,
} as React.CSSProperties}
className="grid grid-cols-dynamic gap-x-8 gap-y-6 mb-6"
>
{token.children?.map((c, i) => (
<div key={c.uuid}>
{render(c, usedIds)}
</div>
))}
</div>
);
case "code":
return (
<pre className="whitespace-pre-wrap bg-black/20 p-2 rounded-md">
{token.content}
</pre>
);
case "card":
return (
<div className="card mb-6">
{token.children?.map((e) => (
<Fragment key={e.uuid}>
{render(e, usedIds)}
</Fragment>
))}
</div>
);
case "anchor":
return (
<Link
className={token.metadata.classes ||
"dark:text-primary-600 underline dark:no-underline"}
href={token.metadata.href}
>
{token.content}
</Link>
);
case "image": {
token.metadata.src = token.metadata.src as string;
if (token.metadata.src.startsWith("<svg")) {
return (
<div
dangerouslySetInnerHTML={{
__html: sanitize(token.metadata.src, {
USE_PROFILES: { svg: true },
}),
}}
>
</div>
);
}
// eslint-disable-next-line @next/next/no-img-element
return <img src={token.metadata.src} alt={token.content} />;
}
case "inline-code":
return (
<span className="p-1 rounded-md font-mono bg-black/20 border border-mixed-100/20 mx-2">
{token.content}
</span>
);
case "popover":
return (
<Poppable
content={token.children?.map((c) => render(c, usedIds)) ||
token.content}
preferredAlign="centered"
preferredEdge="bottom"
className="cursor-pointer mx-2"
>
<span className="border-b-2 border-dotted border-white">
{token.metadata.title}
</span>
</Poppable>
);
case "text":
return (
<span className="whitespace-pre-wrap">
{token.content.replaceAll("\\n", "\n")}
</span>
);
case "p":
return (
<div className="p">
{token.children?.map((e, i) => (
<Fragment key={e.uuid}>
{render(e, usedIds)}
</Fragment>
))}
</div>
);
case "accordion":
return (
<div className="bg-black/20 p-1 rounded-md">
<Accordion
title={token.metadata.title || "Expand"}
>
<AccordionContent>
{token.children?.map((e, i) => (
<Fragment key={e.uuid}>
{render(e, usedIds)}
</Fragment>
))}
</AccordionContent>
</Accordion>
</div>
);
case "bold":
return (
<span className="font-bold">
{token.content}
</span>
);
case "italic":
return (
<span className="italic">
{token.content}
</span>
);
case "list":
const items = token.children || [];
return (
<>
<ul
data-depth={(Number(token.metadata.initialDepth) / 2) % 3}
className="data-[depth='2']:list-[circle] data-[depth='1']:list-[square] list-disc ml-6"
>
{items.map((c) => {
return (
<li
key={c.uuid}
data-depth={token.metadata.initialDepth}
>
{c.children?.map((c) => render(c, usedIds))}
</li>
);
})}
</ul>
</>
);
case "list-item":
// This probably doesn't need to exist, but I'm leaving it anyway
return (
<li
data-depth={token.metadata.initialDepth}
className="ml-2"
>
{token.children?.map((c) => render(c, usedIds))}
</li>
);
case "hr":
return <div className="w-full border-b border-mixed-500 my-3"></div>;
default:
return (
<div className="p bg-red-600 text-white">
Block or paragraph missing implementation: {token.type}
</div>
);
}
}
function generateId(t: string, usedIds: string[]) {
let id = t.toLowerCase().replace(/[^a-z\s]/ig, "").trim().replaceAll(
" ",
"-",
);
let idNum = 1;
while (usedIds.includes(id)) {
id = id.replace(/-[0-9]+$/g, "");
id += "-" + idNum;
idNum++;
}
return id;
}