tabletop-commander/lib/tcmd/TokenIdentifiers.tsx
2024-08-15 04:11:57 -06:00

945 lines
22 KiB
TypeScript

import { sanitize } from "isomorphic-dompurify";
import Link from "next/link";
import React, { Fragment } from "react";
import { Poppable } from "../poppables/components/poppable";
import { Accordion, AccordionContent } from "../accordion";
import { OnDemandResolver, Resolver } from "./Resolver";
export const TokenRenderers = new Map<string, TokenRenderer<any>>();
export function buildIdentifierMap(): [
TokenIdentifierMap,
IdentifierRegistration
] {
const TokenIdentifiers = new Map<string, TokenIdentifier<any>>();
function registerIdentifier<M>(
type: string,
match: RegExp,
parseFunction: (s: string, rx: RegExp) => IdentifiedToken<M>,
renderFunction: TokenRenderer<M>
): void;
function registerIdentifier<M>(
type: string,
match: RegExp,
parseFunction: (s: string, rx: RegExp) => IdentifiedToken<M>,
renderFunction: TokenRenderer<M>,
openTagRx: RegExp,
closeTagRx: RegExp
): void;
function registerIdentifier<M = Record<string, string>>(
type: string,
match: RegExp,
parseFunction: (s: string, rx: RegExp) => IdentifiedToken<M>,
renderFunction: TokenRenderer<M>,
openTagRx?: RegExp,
closeTagRx?: RegExp
) {
TokenIdentifiers.set(type, {
rx: match,
parse(s) {
const identifiedToken = parseFunction(s, this.rx);
const token: TokenAttributes = {
render: renderFunction,
type,
};
return { ...token, ...identifiedToken } as Token<M>;
},
search:
openTagRx && closeTagRx
? (s, start, end) => {
return search(
s,
start,
end,
new RegExp(openTagRx, "g"),
new RegExp(closeTagRx, "g")
);
}
: undefined,
});
TokenRenderers.set(type, renderFunction);
}
return [TokenIdentifiers, registerIdentifier];
}
export const buildOnlyDefaultElements = () => {
const [TokenIdentifiers, registerIdentifier] = buildIdentifierMap();
TokenRenderers.set("text", (t: Token<any>) => {
return (
<span className="whitespace-pre-wrap">
{t.content.replaceAll(/\\n ?/g, "\n")}
</span>
);
});
const usedIds: string[] = [];
const rendersContentOnly = true;
const rendersChildrenOnly = true;
// grid
registerIdentifier<{ columns: string }>(
"grid",
/(?<!\/)(?:\[\])+\n+((?:.|\n)*?)\n+\/\[\]/g,
(s) => {
const rx = /((?:\[\])+)\n+([\s\S]*)\n+\/\[\]/;
const [_, columns, content] = s.match(rx) || [
"",
"..",
"Unable to parse grid",
];
return {
content,
raw: s,
metadata: {
columns: (columns.length / 2).toString(),
},
uuid: crypto.randomUUID(),
rendersChildrenOnly,
};
},
(token) => {
const { children, metadata } = token;
return (
<div
style={
{
"--grid-cols": metadata.columns,
} as React.CSSProperties
}
className="grid grid-cols-dynamic gap-x-8 mb-6"
>
{children?.map((c) => {
const Comp = c.metadata.span ? Fragment : "div";
return (
<Comp className="p" key={c.uuid}>
{c.render(c)}
</Comp>
);
})}
</div>
);
},
/(?<![\/\?])(?:\[\])+/g,
/\/\[\]/g
);
// card
registerIdentifier(
"card",
/\[{2}[\s\S]*?\n+\]{2}/g,
(s) => {
const rx = /\[{2}((?:!?)(?:[0-9]?))\s*?\n+([\s\S]*)\n+\]{2}/;
const match = s.match(rx);
const [_, isBlockOrSpan, content] = match || ["", "", s];
const isBlock = isBlockOrSpan.includes("!");
const span = Number(isBlockOrSpan.replace("!", ""));
return {
content: content.trim(),
raw: s,
metadata: {
isBlock,
span,
},
uuid: crypto.randomUUID(),
rendersChildrenOnly,
};
},
(token) => {
const { children, metadata } = token;
return (
<div
data-block={!!metadata.isBlock}
style={
{
"--v-span": metadata.span || 1,
} as React.CSSProperties
}
className="data-[block=false]:card data-[block=false]:mb-6 col-span-2"
>
{children?.map((e) => (
<Fragment key={e.uuid}>{e.render(e)}</Fragment>
))}
</div>
);
},
/\[\[/g,
/\]\]/g
);
// fenced code block
registerIdentifier(
"code",
/`{3}\n+((?:.|\n)*?)\n+`{3}/g,
(s, rx) => {
return {
content: s.match(new RegExp(rx, ""))?.at(1) || "Unable to parse code",
raw: s,
metadata: {},
uuid: crypto.randomUUID(),
rendersContentOnly,
};
},
(token) => {
return (
<pre className="whitespace-pre-wrap bg-black/20 p-2 rounded-md">
{token.content}
</pre>
);
}
);
// list
registerIdentifier(
"list",
/(?<=\n\n?|^) *-\s([\s\S]*?)(?=\n\n|$)/g,
(s, rx) => {
return {
content: s.match(new RegExp(rx, ""))?.at(0) || "Unable to parse list",
raw: s,
metadata: {
initialDepth:
s.replace("\n", "").split("-").at(0)?.length.toString() || "1",
},
uuid: crypto.randomUUID(),
rendersChildrenOnly,
};
},
(token) => {
const { children, metadata } = token;
return (
<>
<ul
data-depth={(Number(metadata.initialDepth) / 2) % 3}
className="data-[depth='2']:list-[circle] data-[depth='1']:list-[square] list-disc ml-6"
>
{children?.map((c) => {
return (
<li key={c.uuid} data-depth={metadata.initialDepth}>
{c.children?.map((c: Token<any>) => (
<Fragment key={c.uuid}>{c.render(c)}</Fragment>
))}
</li>
);
})}
</ul>
</>
);
}
);
// ordered-list
registerIdentifier(
"ordered-list",
/(?<=\n\n|^)\s*\d+\.\s([\s\S]*?)(?=\n\n|$)/g,
(s, rx) => {
return {
content:
s.match(new RegExp(rx, ""))?.at(0) || "Unable to parse ordered list",
raw: s,
metadata: {
// initialDepth:
// s.replace("\n", "").split(/\d+\./).at(0)?.length.toString() || "1",
},
uuid: crypto.randomUUID(),
rendersChildrenOnly,
};
},
(token) => {
const { children } = token;
return (
<>
<ol
// data-depth={(Number(metadata.initialDepth) / 2) % 3}
className="ml-6 list-decimal"
>
{children?.map((c) => {
return (
<li key={c.uuid}>
{c.children?.map((c: Token<any>) => (
<Fragment key={c.uuid}>{c.render(c)}</Fragment>
))}
</li>
);
})}
</ol>
</>
);
}
);
// ordered list-item
// list-item
registerIdentifier(
"list-item",
/(?<=^|\n) *(?:-|\d+\.)\s(.*?)(?=\n|$)/g,
(s, rx) => {
return {
content:
s.match(new RegExp(rx, ""))?.at(1) || "Unable to parse list-item",
raw: s,
metadata: {
initialDepth:
s.replace("\n", "").split("-").at(0)?.length.toString() || "1",
},
uuid: crypto.randomUUID(),
};
},
(token) => {
const { children, metadata } = token;
return (
<li data-depth={metadata.initialDepth} className="ml-2">
{children?.map((c) => (
<Fragment key={c.uuid}>{c.render(c)}</Fragment>
))}
</li>
);
}
);
// heading
registerIdentifier(
"heading",
/^#+\s(.*?)$/gm,
(s, rx) => {
const content =
s.match(new RegExp(rx, ""))?.at(1) || "Unable to parse heading";
return {
content: content,
raw: s,
metadata: {
strength: s.match(/#/g)?.length.toString() || "1",
id: generateId(content, usedIds),
},
uuid: crypto.randomUUID(),
rendersContentOnly,
};
},
(token) => {
return (
<div
id={token.metadata.id}
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>
);
}
);
// image
registerIdentifier(
"image",
/\!\[(.*?)\]\((.*?)\)/g,
(s, rx) => {
const [_, title, src] = s.match(new RegExp(rx, ""))!;
return {
// content: inline,
content: title.trim(),
raw: s,
metadata: {
src,
},
uuid: crypto.randomUUID(),
rendersContentOnly,
};
},
(token) => {
const { metadata } = token;
metadata.src = metadata.src as string;
if (metadata.src.startsWith("<svg")) {
return (
<div
dangerouslySetInnerHTML={{
__html: sanitize(metadata.src, {
USE_PROFILES: { svg: true },
}),
}}
></div>
);
}
// eslint-disable-next-line @next/next/no-img-element
return <img src={metadata.src} alt={token.content} />;
}
);
// anchor
registerIdentifier(
"anchor",
/(?<![\!^])\[(.*?)\]\((.*?)\)/g,
(s, rx) => {
let preset,
[_, title, href] = s.match(new RegExp(rx, ""))!;
const match = title.match(/~{2}(cta|button)?(.*)/);
if (match) {
[_, preset, title] = match;
}
const classes = {
button: "btn-primary inline-block",
cta: "btn-secondary inline-block uppercase",
};
return {
content: title.trim(),
raw: s,
metadata: {
href,
classes: classes[preset as keyof typeof classes],
},
uuid: crypto.randomUUID(),
rendersContentOnly,
};
},
(token) => {
const { metadata } = token;
return (
<Link
className={
metadata.classes ||
"dark:text-primary-600 underline dark:no-underline"
}
href={metadata.href}
>
{token.content}
</Link>
);
}
);
// inline-code
registerIdentifier(
"inline-code",
/(?<=\s|^)`(.*?)`(?=[\s,.!?)]|$)/gi,
(s, rx) => {
return {
content:
s.match(new RegExp(rx, "i"))?.at(1) || "Unable to parse inline-code",
raw: s,
metadata: {},
uuid: crypto.randomUUID(),
rendersContentOnly,
};
},
(token) => {
return (
<span className="p-1 rounded-md font-mono bg-black/20 border border-mixed-100/20 mx-1">
{token.content}
</span>
);
}
);
// bold
registerIdentifier(
"bold",
/\*{2}(.*?)\*{2}/g,
(s, rx) => {
return {
content: s.match(new RegExp(rx, "i"))?.at(1) || "Unable to parse bold",
raw: s,
metadata: {},
uuid: crypto.randomUUID(),
rendersContentOnly,
};
},
(token) => {
return <span className="font-bold">{token.content}</span>;
}
);
// italic
registerIdentifier(
"italic",
/(?<!\*)\*([^\*]+?)\*(?!\*)/g,
(s, rx) => {
return {
content:
s.match(new RegExp(rx, "i"))?.at(1) || "Unable to parse italic",
raw: s,
metadata: {},
uuid: crypto.randomUUID(),
rendersContentOnly,
};
},
(token) => {
return <span className="italic">{token.content}</span>;
}
);
// popover
registerIdentifier(
"popover",
/\^\[(.*?)\]\<<(.*?)\>>/g,
(s, rx) => {
const [_, title, content] = s.match(new RegExp(rx, ""))!;
return {
content,
raw: s,
metadata: { title },
uuid: crypto.randomUUID(),
rendersContentOnly,
};
},
(token) => {
const { children, metadata, uuid } = token;
return (
<Poppable
content={
children?.map((c) => (
<Fragment key={uuid}>{c.render(c)}</Fragment>
)) || token.content
}
preferredAlign="centered"
preferredEdge="bottom"
className="cursor-pointer mx-2"
>
<span className="border-b-2 border-dotted border-white">
{metadata.title}
</span>
</Poppable>
);
}
);
// accordion
registerIdentifier(
"accordion",
/\[accordion(\s.*?)?]\n+((?:.|\n)*?)\n+\[\/accordion\]/g,
(s, rx) => {
const [_, title, content] = s.match(new RegExp(rx, ""))!;
return {
content,
raw: s,
metadata: { title },
uuid: crypto.randomUUID(),
};
},
(token) => {
const { children, metadata } = token;
return (
<div className="bg-black/20 p-1 accordion">
<Accordion title={metadata.title || "Expand"}>
<AccordionContent>
{children?.map((e) => (
<Fragment key={e.uuid}>{e.render(e)}</Fragment>
))}
</AccordionContent>
</Accordion>
</div>
);
}
);
// paragraph
registerIdentifier(
"p",
/(?<=\n\n)([\s\S]*?)(?=\n\n)/g,
(s) => {
return {
content: s.replace("\n", " "),
raw: s,
metadata: {},
uuid: crypto.randomUUID(),
};
},
(token) => {
const { children } = token;
return (
<div className="p">
{children?.map((e) => {
return <Fragment key={e.uuid}>{e.render(e)}</Fragment>;
})}
</div>
);
}
);
// horizontal rule
registerIdentifier(
"hr",
/^-{3,}$/gm,
(s) => {
return {
content: s,
raw: s,
metadata: {},
uuid: crypto.randomUUID(),
rendersContentOnly,
};
},
() => {
return <div className="w-full border-b border-mixed-500 my-3"></div>;
}
);
// comment
registerIdentifier(
"comment",
/<!--[\s\S]+?-->/g,
(s) => {
return {
content: "",
metadata: { comment: s },
raw: "",
uuid: crypto.randomUUID(),
rendersContentOnly,
};
},
() => {
return <></>;
}
);
// frontmatter
registerIdentifier(
"frontmatter",
/^---([\s\S]*?)---/g,
(s, rx) => {
return {
content: "",
metadata: {
frontmatterString: s.match(rx)?.at(0) || "",
},
raw: "",
uuid: "frontmatter",
};
},
(token) => {
return <>{token.raw}</>;
}
);
// table
registerIdentifier(
"table",
/(?<=\n|^)\| [\s\S]*? \|(?=(\n|$)(?!\|))/g,
(s) => {
const splitMarker = "{{^^}}";
const original = s;
let columnPattern: string[] = [];
let fullWidth = false;
s = s.replace(/^\| (<-)?[-|\s^]+(->)? \|$/gm, (e) => {
if (!columnPattern.length) {
columnPattern = e.split("|").filter((e) => e);
}
if (e.match(/^\| <-.*?-> \|/gm)) {
fullWidth = true;
}
return splitMarker;
});
const rowSections = s.split(splitMarker).map((s) =>
s
.split("\n")
.filter((r) => !!r)
.map((r) =>
r
.split("|")
.map((c) => c.trim())
.filter((c) => !!c)
)
);
let headerRows: string[][] = [],
bodyRows: string[][] = [],
footerRows: string[][] = [];
switch (rowSections.length) {
case 1:
bodyRows = rowSections[0];
break;
case 2:
headerRows = rowSections[0];
bodyRows = rowSections[1];
break;
case 3:
headerRows = rowSections[0];
bodyRows = rowSections[1];
footerRows = rowSections[3];
break;
}
const maxColumns = Math.max(
...[...headerRows, ...bodyRows, ...footerRows].map((r) => r.length)
);
return {
content: original,
raw: original,
metadata: {
headerRows: headerRows,
bodyRows: bodyRows,
footerRows: footerRows,
columns: maxColumns,
columnPattern,
fullWidth,
},
uuid: crypto.randomUUID(),
};
},
(t) => {
const {
headerRows,
bodyRows,
footerRows,
columns,
columnPattern,
fullWidth,
} = t.metadata;
return (
<table
data-full-width={fullWidth}
className="md-table data-[full-width=true]:w-full"
>
{!!headerRows && (
<thead>
{headerRows.map((r, i) => (
<tr key={r.join() + i}>
{r.concat(Array(columns - r.length).fill("")).map((c) => {
const child = t.children?.find((child) => child.raw === c);
return (
<th key={r.join() + i + c}>
{child?.render(child) || c}
</th>
);
})}
</tr>
))}
</thead>
)}
{!!bodyRows && (
<tbody>
{bodyRows.map((r, i) => (
<tr key={r.join() + i}>
{r.concat(Array(columns - r.length).fill("")).map((c, i) => {
const child = t.children?.find((child) => child.raw === c);
return (
<td
key={r.join() + i + c}
className="data-[center=true]:text-center"
data-center={
!!(
columnPattern?.at(i) &&
columnPattern.at(i)?.includes("^")
)
}
>
{child?.render(child) || c}
</td>
);
})}
</tr>
))}
</tbody>
)}
{!!footerRows && (
<tfoot>
{footerRows.map((r, i) => (
<tr key={r.join() + i}>
{r.concat(Array(columns - r.length).fill("")).map((c) => {
const child = t.children?.find((child) => child.raw === c);
return (
<td key={r.join() + i + c}>
{child?.render(child) || c}
</td>
);
})}
</tr>
))}
</tfoot>
)}
</table>
);
}
);
// resolver
registerIdentifier(
"resolver",
/\?\?<<(.*?)>>/g,
(s) => {
const inp = s.match(/(?<=<<)(.*?)(?=>>)/)![0];
if (inp == undefined)
return {
content: "Error parsing resolver: " + s,
metadata: {},
raw: "ERROR",
uuid: crypto.randomUUID(),
};
return {
content: inp,
metadata: {},
raw: s,
uuid: crypto.randomUUID(),
};
},
(t) => {
if (t.content.startsWith("Error"))
return <span className="red-500">{t.content}</span>;
return <Resolver resolver={t.content} />;
}
);
// on-demand resolver
registerIdentifier(
"on-demand resolver",
/\?\?\[.*?\](\(.*?\))?<<(.*?)>>/g,
(s) => {
const inp = s.match(/(?<=<<)(.*?)(?=>>)/)![0];
const template = s.match(/(?<=\?\?\[)(.*?)(?=\])/)![0];
const title = s.match(/(?<=\]\()(.*?)(?=\))/)?.at(0);
if (inp == undefined)
return {
content: "Error parsing resolver: " + s,
metadata: {
title: "",
template: "",
},
raw: "ERROR",
uuid: crypto.randomUUID(),
};
return {
content: inp,
metadata: {
title,
template,
},
raw: s,
uuid: crypto.randomUUID(),
};
},
(t) => {
if (t.content.startsWith("Error"))
return <span className="red-500">{t.content}</span>;
return (
<OnDemandResolver
resolver={t.content}
template={t.metadata.template}
title={t.metadata.title}
/>
);
}
);
return TokenIdentifiers;
};
function findMatchingClosedParenthesis(
str: string,
openRegex: RegExp,
closedRegex: RegExp
): number | null {
let openings = 0;
let closings = 0;
openRegex = new RegExp(openRegex, "g");
closedRegex = new RegExp(closedRegex, "g");
let lastOpeningSuccessIndex = 0;
let lastClosingSuccessIndex = 0;
do {
const openingMatch = openRegex.exec(str);
const closingMatch = closedRegex.exec(str);
if (openingMatch && !closingMatch) {
throw Error("Things have gone horribly wrong");
}
// if ((!openingMatch && closingMatch) || (!openingMatch && !closingMatch)) break;
if (
openingMatch &&
closingMatch &&
openingMatch.index < closingMatch.index
) {
openings++;
lastOpeningSuccessIndex = openingMatch.index + openingMatch[0].length;
closedRegex.lastIndex = lastClosingSuccessIndex;
} else if (
(!openingMatch && closingMatch) ||
(openingMatch && closingMatch && openingMatch.index > closingMatch.index)
) {
closings++;
lastClosingSuccessIndex = closingMatch.index + closingMatch[0].length;
openRegex.lastIndex = lastOpeningSuccessIndex;
} else {
return closingMatch?.index ?? null;
}
} while (openings > closings);
return closedRegex.lastIndex;
}
interface SearchResult {
start: number;
end: number;
text: string;
lastIndex: number;
}
function search(
s: string,
start: number,
end: number,
openRx: RegExp,
closeRx: RegExp
): SearchResult {
const oldEnd = end;
const newEnd = findMatchingClosedParenthesis(
s,
// s.substring(0, end - start),
openRx,
closeRx
);
if (newEnd === null)
throw Error("There was an issue finding a closing tag for ");
end = newEnd + start;
return {
start,
end,
text: s.substring(0, newEnd),
lastIndex: oldEnd === end ? end : start + s.match(openRx)![0].length,
};
}
// Finds a unique id for things like headings
function generateId(t: string, usedIds: string[]) {
let id = t
.toLowerCase()
.replace(/[^a-z\s]/gi, "")
.trim()
.replaceAll(" ", "-");
let idNum = 1;
while (usedIds.includes(id)) {
id = id.replace(/-[0-9]+$/g, "");
id += "-" + idNum;
idNum++;
}
return id;
}