Render DText to the official spec
Checked against https://e621.net/help/dtext and filled the gaps: - backtick inline code spans and backslash-backtick escaping - [color=...] accepts tag category names (artist, copyright, species, ...) alongside CSS colour names and 3/6/8-digit hex - links: <url> brackets, "title":[url], "title":/relative paths, wiki links with custom titles and #anchors, {{tag search}} and entity references (post/topic/pool/set/comment/... #id) - [quote=red] colours the bar instead of being read as an author - nested lists via repeated * / # markers - [section,expanded=Title] renders expanded - [table] thead/tr/th/td tables and [ltable] pipe tables - [#anchor] targets and [[#anchor]] in-page links - headings always start their own block
This commit is contained in:
@@ -308,7 +308,7 @@ export default function OnlineDetailPage() {
|
|||||||
Description
|
Description
|
||||||
</summary>
|
</summary>
|
||||||
<div className="mt-2 text-sm leading-relaxed text-ctp-subtext0">
|
<div className="mt-2 text-sm leading-relaxed text-ctp-subtext0">
|
||||||
{renderDText(post.description)}
|
{renderDText(post.description, credentials?.base_url)}
|
||||||
</div>
|
</div>
|
||||||
</details>
|
</details>
|
||||||
</section>
|
</section>
|
||||||
|
|||||||
+478
-250
@@ -1,133 +1,173 @@
|
|||||||
import type { ReactNode } from "react";
|
import type { ReactNode } from "react";
|
||||||
|
|
||||||
import { Spoiler } from "@/components/Spoiler";
|
import { Spoiler } from "@/components/Spoiler";
|
||||||
import { cn } from "@/lib/cn";
|
|
||||||
|
|
||||||
const INLINE_TAGS = new Set([
|
export const DEFAULT_E621_BASE_URL = "https://e621.net";
|
||||||
"b",
|
|
||||||
"i",
|
|
||||||
"u",
|
|
||||||
"s",
|
|
||||||
"sup",
|
|
||||||
"sub",
|
|
||||||
"code",
|
|
||||||
"spoiler",
|
|
||||||
"quote",
|
|
||||||
"color",
|
|
||||||
"url",
|
|
||||||
"wiki",
|
|
||||||
"thumb",
|
|
||||||
"img",
|
|
||||||
]);
|
|
||||||
|
|
||||||
const COLOR_NAMES = new Set([
|
const TAXONOMY_COLORS: Record<string, string> = {
|
||||||
"red",
|
artist: "#f9e2af",
|
||||||
"green",
|
copyright: "#cba6f7",
|
||||||
"blue",
|
character: "#a6e3a1",
|
||||||
"yellow",
|
species: "#fab387",
|
||||||
"orange",
|
general: "#89b4fa",
|
||||||
"purple",
|
meta: "#9399b2",
|
||||||
"pink",
|
lore: "#94e2d5",
|
||||||
"brown",
|
invalid: "#f38ba8",
|
||||||
"black",
|
contributor: "#b4befe",
|
||||||
"white",
|
};
|
||||||
"gray",
|
|
||||||
"grey",
|
|
||||||
"cyan",
|
|
||||||
"magenta",
|
|
||||||
"lime",
|
|
||||||
"olive",
|
|
||||||
"navy",
|
|
||||||
"teal",
|
|
||||||
"maroon",
|
|
||||||
"silver",
|
|
||||||
"gold",
|
|
||||||
"aqua",
|
|
||||||
"fuchsia",
|
|
||||||
]);
|
|
||||||
|
|
||||||
function safeColor(value: string | undefined): string | undefined {
|
function resolveColor(value: string | undefined): string | undefined {
|
||||||
if (!value) return undefined;
|
if (!value) return undefined;
|
||||||
const trimmed = value.trim().toLowerCase();
|
const trimmed = value.trim().toLowerCase();
|
||||||
if (/^#[0-9a-f]{3,8}$/.test(trimmed)) return trimmed;
|
if (TAXONOMY_COLORS[trimmed]) return TAXONOMY_COLORS[trimmed];
|
||||||
if (COLOR_NAMES.has(trimmed)) return trimmed;
|
if (/^#[0-9a-f]{3}([0-9a-f]{3})?([0-9a-f]{2})?$/.test(trimmed)) return trimmed;
|
||||||
|
// Any other bare word is passed through; the browser ignores invalid CSS colors.
|
||||||
|
if (/^[a-z]+$/.test(trimmed)) return trimmed;
|
||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
function safeUrl(value: string | undefined): string | undefined {
|
function absoluteUrl(value: string, baseUrl: string): string | undefined {
|
||||||
if (!value) return undefined;
|
|
||||||
const trimmed = value.trim();
|
const trimmed = value.trim();
|
||||||
if (/^https?:\/\//i.test(trimmed)) return trimmed;
|
if (/^https?:\/\//i.test(trimmed)) return trimmed;
|
||||||
|
if (trimmed.startsWith("/")) return `${baseUrl.replace(/\/+$/, "")}${trimmed}`;
|
||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
function stripTags(value: string): string {
|
|
||||||
return value.replace(/\[[^\]]*\]/g, "").trim();
|
|
||||||
}
|
|
||||||
|
|
||||||
function findOpenTag(
|
|
||||||
text: string,
|
|
||||||
tag: string,
|
|
||||||
from: number,
|
|
||||||
): { index: number; length: number } | null {
|
|
||||||
const regex = new RegExp(`\\[${tag}(?:=[^\\]]*)?\\]`, "g");
|
|
||||||
regex.lastIndex = from;
|
|
||||||
const match = regex.exec(text);
|
|
||||||
return match ? { index: match.index, length: match[0].length } : null;
|
|
||||||
}
|
|
||||||
|
|
||||||
function splitTrailing(url: string): { url: string; trailing: string } {
|
function splitTrailing(url: string): { url: string; trailing: string } {
|
||||||
const match = /[.,;:!?)\]]+$/.exec(url);
|
const match = /[.,;:!?)\]]+$/.exec(url);
|
||||||
if (!match) return { url, trailing: "" };
|
if (!match) return { url, trailing: "" };
|
||||||
return { url: url.slice(0, url.length - match[0].length), trailing: match[0] };
|
return { url: url.slice(0, url.length - match[0].length), trailing: match[0] };
|
||||||
}
|
}
|
||||||
|
|
||||||
function linkify(text: string, keyPrefix: string): ReactNode[] {
|
const ENTITY_PATHS: Record<string, string> = {
|
||||||
|
post: "/posts/{id}",
|
||||||
|
"post changes": "/post_versions?search[post_id]={id}",
|
||||||
|
topic: "/forum_topics/{id}",
|
||||||
|
forum: "/forum_posts/{id}",
|
||||||
|
comment: "/comments/{id}",
|
||||||
|
blip: "/blips/{id}",
|
||||||
|
pool: "/pools/{id}",
|
||||||
|
set: "/post_sets/{id}",
|
||||||
|
takedown: "/takedowns/{id}",
|
||||||
|
record: "/user_feedbacks/{id}",
|
||||||
|
ticket: "/tickets/{id}",
|
||||||
|
thumb: "/posts/{id}",
|
||||||
|
};
|
||||||
|
|
||||||
|
const LINK_RE = new RegExp(
|
||||||
|
[
|
||||||
|
'"([^"]+)":\\[([^\\]]+)\\]',
|
||||||
|
'"([^"]+)":(\\/[^\\s]*)',
|
||||||
|
'"([^"]+)":(\\S+)',
|
||||||
|
"<(https?://[^\\s>]+)>",
|
||||||
|
"(https?://[^\\s<]+)",
|
||||||
|
"\\[\\[([^\\]]+)\\]\\]",
|
||||||
|
"\\{\\{([^}]+)\\}\\}",
|
||||||
|
"\\[#([A-Za-z0-9_]+)\\]",
|
||||||
|
"\\b(post changes|post|topic|forum|comment|blip|pool|set|takedown|record|ticket|thumb) #(\\d+)\\b",
|
||||||
|
].join("|"),
|
||||||
|
"g",
|
||||||
|
);
|
||||||
|
|
||||||
|
function anchor(
|
||||||
|
key: string,
|
||||||
|
href: string,
|
||||||
|
label: ReactNode,
|
||||||
|
extraClass?: string,
|
||||||
|
) {
|
||||||
|
return (
|
||||||
|
<a
|
||||||
|
key={key}
|
||||||
|
href={href}
|
||||||
|
target="_blank"
|
||||||
|
rel="noreferrer"
|
||||||
|
className={`text-ctp-blue hover:underline ${extraClass ?? ""}`}
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</a>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function wikiLink(raw: string, baseUrl: string, key: string): ReactNode {
|
||||||
|
const [targetPart, titlePart] = raw.split("|").map((part) => part.trim());
|
||||||
|
const target = targetPart ?? "";
|
||||||
|
const label = titlePart || target;
|
||||||
|
if (target.startsWith("#")) {
|
||||||
|
return (
|
||||||
|
<a key={key} href={target} className="text-ctp-blue hover:underline">
|
||||||
|
{label}
|
||||||
|
</a>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const [page, anchorId] = target.split("#");
|
||||||
|
const title = (page ?? "").trim().replace(/\s+/g, "_");
|
||||||
|
const href = `${baseUrl}/wiki_pages/show_or_new?title=${encodeURIComponent(title)}${
|
||||||
|
anchorId ? `#${anchorId}` : ""
|
||||||
|
}`;
|
||||||
|
return anchor(key, href, label);
|
||||||
|
}
|
||||||
|
|
||||||
|
function linkify(text: string, keyPrefix: string, baseUrl: string): ReactNode[] {
|
||||||
const nodes: ReactNode[] = [];
|
const nodes: ReactNode[] = [];
|
||||||
const regex = /"([^"]+)":(\S+)|(https?:\/\/\S+)/g;
|
|
||||||
let lastIndex = 0;
|
let lastIndex = 0;
|
||||||
let match: RegExpExecArray | null;
|
|
||||||
let counter = 0;
|
let counter = 0;
|
||||||
|
let match: RegExpExecArray | null;
|
||||||
|
const regex = new RegExp(LINK_RE.source, "g");
|
||||||
|
|
||||||
while ((match = regex.exec(text)) !== null) {
|
while ((match = regex.exec(text)) !== null) {
|
||||||
if (match.index > lastIndex) {
|
if (match.index > lastIndex) {
|
||||||
nodes.push(text.slice(lastIndex, match.index));
|
nodes.push(text.slice(lastIndex, match.index));
|
||||||
}
|
}
|
||||||
if (match[1] && match[2]) {
|
const key = `${keyPrefix}-${counter++}`;
|
||||||
const { url, trailing } = splitTrailing(match[2]);
|
|
||||||
const href = safeUrl(url);
|
if (match[1] !== undefined && match[2] !== undefined) {
|
||||||
if (!href) {
|
const href = absoluteUrl(match[2], baseUrl);
|
||||||
nodes.push(match[0]);
|
|
||||||
} else {
|
|
||||||
nodes.push(
|
|
||||||
<a
|
|
||||||
key={`${keyPrefix}-${counter++}`}
|
|
||||||
href={href}
|
|
||||||
target="_blank"
|
|
||||||
rel="noreferrer"
|
|
||||||
className="text-ctp-blue hover:underline"
|
|
||||||
>
|
|
||||||
{match[1]}
|
|
||||||
</a>,
|
|
||||||
);
|
|
||||||
if (trailing) nodes.push(trailing);
|
|
||||||
}
|
|
||||||
} else if (match[3]) {
|
|
||||||
const { url, trailing } = splitTrailing(match[3]);
|
|
||||||
nodes.push(
|
nodes.push(
|
||||||
<a
|
href ? anchor(key, href, match[1]) : `${match[1]}:${match[2]}`,
|
||||||
key={`${keyPrefix}-${counter++}`}
|
|
||||||
href={url}
|
|
||||||
target="_blank"
|
|
||||||
rel="noreferrer"
|
|
||||||
className="break-all text-ctp-blue hover:underline"
|
|
||||||
>
|
|
||||||
{url}
|
|
||||||
</a>,
|
|
||||||
);
|
);
|
||||||
|
} else if (match[3] !== undefined && match[4] !== undefined) {
|
||||||
|
const href = absoluteUrl(match[4], baseUrl);
|
||||||
|
nodes.push(
|
||||||
|
href ? anchor(key, href, match[3]) : `${match[3]}:${match[4]}`,
|
||||||
|
);
|
||||||
|
} else if (match[5] !== undefined && match[6] !== undefined) {
|
||||||
|
const { url, trailing } = splitTrailing(match[6]);
|
||||||
|
const href = absoluteUrl(url, baseUrl);
|
||||||
|
if (href) {
|
||||||
|
nodes.push(anchor(key, href, match[5]));
|
||||||
|
if (trailing) nodes.push(trailing);
|
||||||
|
} else {
|
||||||
|
nodes.push(match[0]);
|
||||||
|
}
|
||||||
|
} else if (match[7] !== undefined) {
|
||||||
|
nodes.push(anchor(key, match[7], match[7], "break-all"));
|
||||||
|
} else if (match[8] !== undefined) {
|
||||||
|
const { url, trailing } = splitTrailing(match[8]);
|
||||||
|
nodes.push(anchor(key, url, url, "break-all"));
|
||||||
if (trailing) nodes.push(trailing);
|
if (trailing) nodes.push(trailing);
|
||||||
|
} else if (match[9] !== undefined) {
|
||||||
|
nodes.push(wikiLink(match[9], baseUrl, key));
|
||||||
|
} else if (match[10] !== undefined) {
|
||||||
|
const query = match[10].trim();
|
||||||
|
nodes.push(
|
||||||
|
anchor(
|
||||||
|
key,
|
||||||
|
`${baseUrl}/posts?tags=${encodeURIComponent(query)}`,
|
||||||
|
match[10],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
} else if (match[11] !== undefined) {
|
||||||
|
nodes.push(<span key={key} id={match[11]} />);
|
||||||
|
} else if (match[12] !== undefined && match[13] !== undefined) {
|
||||||
|
const path = ENTITY_PATHS[match[12]]?.replace("{id}", match[13]);
|
||||||
|
const label = `${match[12]} #${match[13]}`;
|
||||||
|
nodes.push(
|
||||||
|
path ? anchor(key, `${baseUrl}${path}`, label) : label,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
lastIndex = regex.lastIndex;
|
lastIndex = regex.lastIndex;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (lastIndex < text.length) {
|
if (lastIndex < text.length) {
|
||||||
nodes.push(text.slice(lastIndex));
|
nodes.push(text.slice(lastIndex));
|
||||||
}
|
}
|
||||||
@@ -138,9 +178,11 @@ function renderText(
|
|||||||
text: string,
|
text: string,
|
||||||
keyPrefix: string,
|
keyPrefix: string,
|
||||||
extras: Map<string, ReactNode>,
|
extras: Map<string, ReactNode>,
|
||||||
|
baseUrl: string,
|
||||||
): ReactNode[] {
|
): ReactNode[] {
|
||||||
|
const unescaped = text.replace(/\\`/g, "`");
|
||||||
const nodes: ReactNode[] = [];
|
const nodes: ReactNode[] = [];
|
||||||
const pieces = text.split(/(\uE000\d+\uE001)/);
|
const pieces = unescaped.split(/(\uE000\d+\uE001)/);
|
||||||
pieces.forEach((piece, pieceIndex) => {
|
pieces.forEach((piece, pieceIndex) => {
|
||||||
if (!piece) return;
|
if (!piece) return;
|
||||||
const tokenMatch = /^\uE000(\d+)\uE001$/.exec(piece);
|
const tokenMatch = /^\uE000(\d+)\uE001$/.exec(piece);
|
||||||
@@ -155,43 +197,70 @@ function renderText(
|
|||||||
nodes.push(<br key={`${keyPrefix}-br-${pieceIndex}-${lineIndex}`} />);
|
nodes.push(<br key={`${keyPrefix}-br-${pieceIndex}-${lineIndex}`} />);
|
||||||
}
|
}
|
||||||
nodes.push(
|
nodes.push(
|
||||||
...linkify(line, `${keyPrefix}-l-${pieceIndex}-${lineIndex}`),
|
...linkify(line, `${keyPrefix}-l-${pieceIndex}-${lineIndex}`, baseUrl),
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
return nodes;
|
return nodes;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function findOpenTag(
|
||||||
|
text: string,
|
||||||
|
tag: string,
|
||||||
|
from: number,
|
||||||
|
): { index: number; length: number } | null {
|
||||||
|
const regex = new RegExp(`\\[${tag}(?:=[^\\]]*)?\\]`, "g");
|
||||||
|
regex.lastIndex = from;
|
||||||
|
const match = regex.exec(text);
|
||||||
|
return match ? { index: match.index, length: match[0].length } : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function findBacktick(text: string, from: number): number {
|
||||||
|
for (let i = from; i < text.length; i += 1) {
|
||||||
|
if (text[i] === "`" && text[i - 1] !== "\\") return i;
|
||||||
|
}
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
|
||||||
function renderTag(
|
function renderTag(
|
||||||
tag: string,
|
tag: string,
|
||||||
attr: string | undefined,
|
attr: string | undefined,
|
||||||
inner: string,
|
inner: string,
|
||||||
keyPrefix: string,
|
keyPrefix: string,
|
||||||
extras: Map<string, ReactNode>,
|
extras: Map<string, ReactNode>,
|
||||||
|
baseUrl: string,
|
||||||
): ReactNode {
|
): ReactNode {
|
||||||
switch (tag) {
|
switch (tag) {
|
||||||
case "b":
|
case "b":
|
||||||
return (
|
return (
|
||||||
<strong key={keyPrefix}>{renderInline(inner, keyPrefix, extras)}</strong>
|
<strong key={keyPrefix}>
|
||||||
|
{renderInline(inner, keyPrefix, extras, baseUrl)}
|
||||||
|
</strong>
|
||||||
);
|
);
|
||||||
case "i":
|
case "i":
|
||||||
return <em key={keyPrefix}>{renderInline(inner, keyPrefix, extras)}</em>;
|
return (
|
||||||
|
<em key={keyPrefix}>{renderInline(inner, keyPrefix, extras, baseUrl)}</em>
|
||||||
|
);
|
||||||
case "u":
|
case "u":
|
||||||
return (
|
return (
|
||||||
<span key={keyPrefix} className="underline">
|
<span key={keyPrefix} className="underline">
|
||||||
{renderInline(inner, keyPrefix, extras)}
|
{renderInline(inner, keyPrefix, extras, baseUrl)}
|
||||||
</span>
|
</span>
|
||||||
);
|
);
|
||||||
case "s":
|
case "s":
|
||||||
return (
|
return (
|
||||||
<span key={keyPrefix} className="line-through">
|
<span key={keyPrefix} className="line-through">
|
||||||
{renderInline(inner, keyPrefix, extras)}
|
{renderInline(inner, keyPrefix, extras, baseUrl)}
|
||||||
</span>
|
</span>
|
||||||
);
|
);
|
||||||
case "sup":
|
case "sup":
|
||||||
return <sup key={keyPrefix}>{renderInline(inner, keyPrefix, extras)}</sup>;
|
return (
|
||||||
|
<sup key={keyPrefix}>{renderInline(inner, keyPrefix, extras, baseUrl)}</sup>
|
||||||
|
);
|
||||||
case "sub":
|
case "sub":
|
||||||
return <sub key={keyPrefix}>{renderInline(inner, keyPrefix, extras)}</sub>;
|
return (
|
||||||
|
<sub key={keyPrefix}>{renderInline(inner, keyPrefix, extras, baseUrl)}</sub>
|
||||||
|
);
|
||||||
case "code":
|
case "code":
|
||||||
return (
|
return (
|
||||||
<code
|
<code
|
||||||
@@ -204,79 +273,38 @@ function renderTag(
|
|||||||
case "spoiler":
|
case "spoiler":
|
||||||
return (
|
return (
|
||||||
<Spoiler key={keyPrefix}>
|
<Spoiler key={keyPrefix}>
|
||||||
{renderInline(inner, keyPrefix, extras)}
|
{renderInline(inner, keyPrefix, extras, baseUrl)}
|
||||||
</Spoiler>
|
</Spoiler>
|
||||||
);
|
);
|
||||||
case "quote":
|
case "quote": {
|
||||||
|
const color = resolveColor(attr);
|
||||||
return (
|
return (
|
||||||
<blockquote
|
<blockquote
|
||||||
key={keyPrefix}
|
key={keyPrefix}
|
||||||
|
style={color ? { borderColor: color } : undefined}
|
||||||
className="border-l-2 border-ctp-surface1 pl-3 text-ctp-subtext0"
|
className="border-l-2 border-ctp-surface1 pl-3 text-ctp-subtext0"
|
||||||
>
|
>
|
||||||
{attr ? (
|
{renderInline(inner, keyPrefix, extras, baseUrl)}
|
||||||
<span className="mr-1 font-medium text-ctp-overlay0">
|
|
||||||
{stripTags(attr)}:
|
|
||||||
</span>
|
|
||||||
) : null}
|
|
||||||
{renderInline(inner, keyPrefix, extras)}
|
|
||||||
</blockquote>
|
</blockquote>
|
||||||
);
|
);
|
||||||
|
}
|
||||||
case "color": {
|
case "color": {
|
||||||
const color = safeColor(attr);
|
const color = resolveColor(attr);
|
||||||
return color ? (
|
return (
|
||||||
<span key={keyPrefix} style={{ color }}>
|
<span key={keyPrefix} style={color ? { color } : undefined}>
|
||||||
{renderInline(inner, keyPrefix, extras)}
|
{renderInline(inner, keyPrefix, extras, baseUrl)}
|
||||||
</span>
|
</span>
|
||||||
) : (
|
|
||||||
<span key={keyPrefix}>{renderInline(inner, keyPrefix, extras)}</span>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
case "url": {
|
case "url": {
|
||||||
const href = safeUrl(attr) ?? safeUrl(stripTags(inner));
|
const href = absoluteUrl(attr ?? inner, baseUrl);
|
||||||
if (!href) return <span key={keyPrefix}>{stripTags(inner)}</span>;
|
if (!href) return <span key={keyPrefix}>{inner}</span>;
|
||||||
return (
|
return anchor(keyPrefix, href, attr ? renderInline(inner, keyPrefix, extras, baseUrl) : href, "break-all");
|
||||||
<a
|
|
||||||
key={keyPrefix}
|
|
||||||
href={href}
|
|
||||||
target="_blank"
|
|
||||||
rel="noreferrer"
|
|
||||||
className="break-all text-ctp-blue hover:underline"
|
|
||||||
>
|
|
||||||
{attr ? renderInline(inner, keyPrefix, extras) : href}
|
|
||||||
</a>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
case "wiki": {
|
case "wiki":
|
||||||
const title = stripTags(attr ?? inner);
|
|
||||||
if (!title) return null;
|
|
||||||
return (
|
return (
|
||||||
<a
|
<span key={keyPrefix}>{wikiLink(attr ?? inner, baseUrl, keyPrefix)}</span>
|
||||||
key={keyPrefix}
|
|
||||||
href={`https://e621.net/wiki_pages?title=${encodeURIComponent(title)}`}
|
|
||||||
target="_blank"
|
|
||||||
rel="noreferrer"
|
|
||||||
className="text-ctp-blue hover:underline"
|
|
||||||
>
|
|
||||||
{title}
|
|
||||||
</a>
|
|
||||||
);
|
);
|
||||||
}
|
|
||||||
case "thumb":
|
|
||||||
case "img": {
|
|
||||||
const href = safeUrl(attr) ?? safeUrl(stripTags(inner));
|
|
||||||
if (!href) return null;
|
|
||||||
return (
|
|
||||||
<a
|
|
||||||
key={keyPrefix}
|
|
||||||
href={href}
|
|
||||||
target="_blank"
|
|
||||||
rel="noreferrer"
|
|
||||||
className="text-ctp-blue hover:underline"
|
|
||||||
>
|
|
||||||
[image]
|
|
||||||
</a>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
default:
|
default:
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
@@ -286,6 +314,7 @@ function renderInline(
|
|||||||
text: string,
|
text: string,
|
||||||
keyPrefix: string,
|
keyPrefix: string,
|
||||||
extras: Map<string, ReactNode>,
|
extras: Map<string, ReactNode>,
|
||||||
|
baseUrl: string,
|
||||||
): ReactNode[] {
|
): ReactNode[] {
|
||||||
const nodes: ReactNode[] = [];
|
const nodes: ReactNode[] = [];
|
||||||
let cursor = 0;
|
let cursor = 0;
|
||||||
@@ -293,28 +322,48 @@ function renderInline(
|
|||||||
|
|
||||||
while (cursor < text.length) {
|
while (cursor < text.length) {
|
||||||
const slice = text.slice(cursor);
|
const slice = text.slice(cursor);
|
||||||
const match = /\[([a-z_]+)(?:=([^\]]*))?\]/.exec(slice);
|
const tagMatch = /\[([a-z_]+)(?:=([^\]]*))?\]/.exec(slice);
|
||||||
if (!match) {
|
const tagIndex = tagMatch ? tagMatch.index : -1;
|
||||||
nodes.push(...renderText(slice, `${keyPrefix}-${counter++}`, extras));
|
const tickIndex = findBacktick(slice, 0);
|
||||||
break;
|
|
||||||
}
|
if (tickIndex !== -1 && (tagIndex === -1 || tickIndex < tagIndex)) {
|
||||||
const start = cursor + match.index;
|
|
||||||
const tag = match[1].toLowerCase();
|
|
||||||
if (!INLINE_TAGS.has(tag)) {
|
|
||||||
nodes.push(
|
nodes.push(
|
||||||
...renderText(
|
...renderText(
|
||||||
text.slice(cursor, start + match[0].length),
|
text.slice(cursor, cursor + tickIndex),
|
||||||
`${keyPrefix}-${counter++}`,
|
`${keyPrefix}-${counter++}`,
|
||||||
extras,
|
extras,
|
||||||
|
baseUrl,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
cursor = start + match[0].length;
|
const rest = text.slice(cursor + tickIndex + 1);
|
||||||
|
const close = findBacktick(rest, 0);
|
||||||
|
if (close === -1) {
|
||||||
|
nodes.push("`");
|
||||||
|
cursor += tickIndex + 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
nodes.push(
|
||||||
|
<code
|
||||||
|
key={`${keyPrefix}-code-${counter++}`}
|
||||||
|
className="rounded bg-ctp-surface0 px-1 py-0.5 font-mono text-[0.85em]"
|
||||||
|
>
|
||||||
|
{rest.slice(0, close)}
|
||||||
|
</code>,
|
||||||
|
);
|
||||||
|
cursor += tickIndex + 1 + close + 1;
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!tagMatch || tagIndex === -1) {
|
||||||
|
nodes.push(...renderText(slice, `${keyPrefix}-${counter++}`, extras, baseUrl));
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
const start = cursor + tagIndex;
|
||||||
|
const tag = tagMatch[1].toLowerCase();
|
||||||
const closeTag = `[/${tag}]`;
|
const closeTag = `[/${tag}]`;
|
||||||
let depth = 1;
|
let depth = 1;
|
||||||
let scan = start + match[0].length;
|
let scan = start + tagMatch[0].length;
|
||||||
while (depth > 0) {
|
while (depth > 0) {
|
||||||
const nextOpen = findOpenTag(text, tag, scan);
|
const nextOpen = findOpenTag(text, tag, scan);
|
||||||
const nextClose = text.indexOf(closeTag, scan);
|
const nextClose = text.indexOf(closeTag, scan);
|
||||||
@@ -331,19 +380,29 @@ function renderInline(
|
|||||||
if (depth !== 0) {
|
if (depth !== 0) {
|
||||||
nodes.push(
|
nodes.push(
|
||||||
...renderText(
|
...renderText(
|
||||||
text.slice(cursor, start + match[0].length),
|
text.slice(cursor, start + tagMatch[0].length),
|
||||||
`${keyPrefix}-${counter++}`,
|
`${keyPrefix}-${counter++}`,
|
||||||
extras,
|
extras,
|
||||||
|
baseUrl,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
cursor = start + match[0].length;
|
cursor = start + tagMatch[0].length;
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
const inner = text.slice(start + match[0].length, scan - closeTag.length);
|
const inner = text.slice(start + tagMatch[0].length, scan - closeTag.length);
|
||||||
nodes.push(...renderText(text.slice(cursor, start), `${keyPrefix}-t${counter}`, extras));
|
|
||||||
nodes.push(
|
nodes.push(
|
||||||
renderTag(tag, match[2], inner, `${keyPrefix}-${tag}-${counter++}`, extras),
|
...renderText(text.slice(cursor, start), `${keyPrefix}-t${counter}`, extras, baseUrl),
|
||||||
|
);
|
||||||
|
nodes.push(
|
||||||
|
renderTag(
|
||||||
|
tag,
|
||||||
|
tagMatch[2],
|
||||||
|
inner,
|
||||||
|
`${keyPrefix}-${tag}-${counter++}`,
|
||||||
|
extras,
|
||||||
|
baseUrl,
|
||||||
|
),
|
||||||
);
|
);
|
||||||
cursor = scan;
|
cursor = scan;
|
||||||
}
|
}
|
||||||
@@ -351,9 +410,155 @@ function renderInline(
|
|||||||
return nodes;
|
return nodes;
|
||||||
}
|
}
|
||||||
|
|
||||||
function extractSections(
|
interface ListNode {
|
||||||
|
level: number;
|
||||||
|
ordered: boolean;
|
||||||
|
content: string;
|
||||||
|
children: ListNode[];
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildListTree(
|
||||||
|
items: Omit<ListNode, "children">[],
|
||||||
|
index: { value: number },
|
||||||
|
level: number,
|
||||||
|
): ListNode[] {
|
||||||
|
const nodes: ListNode[] = [];
|
||||||
|
while (index.value < items.length && items[index.value].level >= level) {
|
||||||
|
const item = items[index.value];
|
||||||
|
if (item.level > level) break;
|
||||||
|
index.value += 1;
|
||||||
|
const children =
|
||||||
|
index.value < items.length && items[index.value].level > level
|
||||||
|
? buildListTree(items, index, items[index.value].level)
|
||||||
|
: [];
|
||||||
|
nodes.push({ ...item, children });
|
||||||
|
}
|
||||||
|
return nodes;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderListNodes(
|
||||||
|
nodes: ListNode[],
|
||||||
|
ordered: boolean,
|
||||||
|
keyPrefix: string,
|
||||||
|
extras: Map<string, ReactNode>,
|
||||||
|
baseUrl: string,
|
||||||
|
): ReactNode {
|
||||||
|
const ListTag = ordered ? "ol" : "ul";
|
||||||
|
return (
|
||||||
|
<ListTag
|
||||||
|
className={
|
||||||
|
ordered
|
||||||
|
? "ml-4 list-decimal space-y-1"
|
||||||
|
: "ml-4 list-disc space-y-1"
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{nodes.map((node, index) => (
|
||||||
|
<li key={`${keyPrefix}-${index}`}>
|
||||||
|
{renderInline(
|
||||||
|
node.content,
|
||||||
|
`${keyPrefix}-${index}`,
|
||||||
|
extras,
|
||||||
|
baseUrl,
|
||||||
|
)}
|
||||||
|
{node.children.length > 0
|
||||||
|
? renderListNodes(
|
||||||
|
node.children,
|
||||||
|
node.children[0].ordered,
|
||||||
|
`${keyPrefix}-${index}-c`,
|
||||||
|
extras,
|
||||||
|
baseUrl,
|
||||||
|
)
|
||||||
|
: null}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ListTag>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderTable(
|
||||||
|
kind: "table" | "ltable",
|
||||||
|
inner: string,
|
||||||
|
key: string,
|
||||||
|
extras: Map<string, ReactNode>,
|
||||||
|
baseUrl: string,
|
||||||
|
): ReactNode {
|
||||||
|
const rows: { header: boolean; cells: string[] }[] = [];
|
||||||
|
|
||||||
|
if (kind === "ltable") {
|
||||||
|
const lines = inner
|
||||||
|
.split("\n")
|
||||||
|
.map((line) => line.trim())
|
||||||
|
.filter(Boolean);
|
||||||
|
lines.forEach((line, index) => {
|
||||||
|
rows.push({
|
||||||
|
header: index === 0,
|
||||||
|
cells: line.split("|").map((cell) => cell.trim()),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
const rowRegex = /\[tr\]([\s\S]*?)\[\/tr\]/g;
|
||||||
|
let rowMatch: RegExpExecArray | null;
|
||||||
|
while ((rowMatch = rowRegex.exec(inner)) !== null) {
|
||||||
|
const cells: string[] = [];
|
||||||
|
let header = false;
|
||||||
|
const cellRegex = /\[(th|td)\]([\s\S]*?)\[\/\1\]/g;
|
||||||
|
let cellMatch: RegExpExecArray | null;
|
||||||
|
while ((cellMatch = cellRegex.exec(rowMatch[1])) !== null) {
|
||||||
|
if (cellMatch[1] === "th") header = true;
|
||||||
|
cells.push(cellMatch[2].trim());
|
||||||
|
}
|
||||||
|
if (cells.length > 0) rows.push({ header, cells });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div key={key} className="overflow-x-auto">
|
||||||
|
<table className="min-w-full border-collapse text-sm">
|
||||||
|
<tbody>
|
||||||
|
{rows.map((row, rowIndex) => (
|
||||||
|
<tr
|
||||||
|
key={`${key}-r${rowIndex}`}
|
||||||
|
className="border-b border-ctp-surface0 last:border-0"
|
||||||
|
>
|
||||||
|
{row.cells.map((cell, cellIndex) =>
|
||||||
|
row.header ? (
|
||||||
|
<th
|
||||||
|
key={`${key}-c${rowIndex}-${cellIndex}`}
|
||||||
|
className="px-2 py-1 text-left font-semibold text-ctp-subtext1"
|
||||||
|
>
|
||||||
|
{renderInline(
|
||||||
|
cell,
|
||||||
|
`${key}-${rowIndex}-${cellIndex}`,
|
||||||
|
extras,
|
||||||
|
baseUrl,
|
||||||
|
)}
|
||||||
|
</th>
|
||||||
|
) : (
|
||||||
|
<td
|
||||||
|
key={`${key}-c${rowIndex}-${cellIndex}`}
|
||||||
|
className="px-2 py-1 text-ctp-subtext0"
|
||||||
|
>
|
||||||
|
{renderInline(
|
||||||
|
cell,
|
||||||
|
`${key}-${rowIndex}-${cellIndex}`,
|
||||||
|
extras,
|
||||||
|
baseUrl,
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
),
|
||||||
|
)}
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function extractBlocks(
|
||||||
text: string,
|
text: string,
|
||||||
keyPrefix: string,
|
keyPrefix: string,
|
||||||
|
baseUrl: string,
|
||||||
): { processed: string; extras: Map<string, ReactNode> } {
|
): { processed: string; extras: Map<string, ReactNode> } {
|
||||||
const extras = new Map<string, ReactNode>();
|
const extras = new Map<string, ReactNode>();
|
||||||
let result = "";
|
let result = "";
|
||||||
@@ -362,14 +567,17 @@ function extractSections(
|
|||||||
|
|
||||||
while (cursor < text.length) {
|
while (cursor < text.length) {
|
||||||
const slice = text.slice(cursor);
|
const slice = text.slice(cursor);
|
||||||
const match = /\[(section|expand)(?:=([^\]]*))?\]/.exec(slice);
|
const match = /\[(section|expand|table|ltable)(?:,expanded)?(?:=([^\]]*))?\]/.exec(
|
||||||
|
slice,
|
||||||
|
);
|
||||||
if (!match) {
|
if (!match) {
|
||||||
result += slice;
|
result += slice;
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
const start = cursor + match.index;
|
const start = cursor + match.index;
|
||||||
const tag = match[1];
|
const tag = match[1];
|
||||||
const title = match[2];
|
const title = match[2] ?? (match[0].includes(",expanded") ? "" : undefined);
|
||||||
|
const expanded = match[0].includes(",expanded");
|
||||||
const closeTag = `[/${tag}]`;
|
const closeTag = `[/${tag}]`;
|
||||||
let depth = 1;
|
let depth = 1;
|
||||||
let scan = start + match[0].length;
|
let scan = start + match[0].length;
|
||||||
@@ -393,29 +601,50 @@ function extractSections(
|
|||||||
const inner = text.slice(start + match[0].length, scan - closeTag.length);
|
const inner = text.slice(start + match[0].length, scan - closeTag.length);
|
||||||
const token = `\uE000${counter}\uE001`;
|
const token = `\uE000${counter}\uE001`;
|
||||||
const key = `${keyPrefix}-${tag}-${counter}`;
|
const key = `${keyPrefix}-${tag}-${counter}`;
|
||||||
const content = parseBlocks(inner, `${key}-body`);
|
|
||||||
extras.set(
|
if (tag === "table" || tag === "ltable") {
|
||||||
token,
|
extras.set(token, renderTable(tag, inner, key, extras, baseUrl));
|
||||||
tag === "section" ? (
|
} else {
|
||||||
<section key={key} className="flex flex-col gap-2">
|
const content = parseBlocks(inner, `${key}-body`, baseUrl);
|
||||||
{title ? (
|
const label = title ? title.trim() : undefined;
|
||||||
<h4 className="text-sm font-semibold text-ctp-subtext1">
|
if (tag === "section") {
|
||||||
{stripTags(title)}
|
extras.set(
|
||||||
</h4>
|
token,
|
||||||
) : null}
|
expanded || !label ? (
|
||||||
{content}
|
<section key={key} className="flex flex-col gap-2">
|
||||||
</section>
|
{label ? (
|
||||||
) : (
|
<h4 className="text-sm font-semibold text-ctp-subtext1">
|
||||||
<details key={key} className="rounded border border-ctp-surface1">
|
{label}
|
||||||
<summary className="cursor-pointer px-2 py-1 text-sm text-ctp-subtext1">
|
</h4>
|
||||||
{title ? stripTags(title) : "Expand"}
|
) : null}
|
||||||
</summary>
|
{content}
|
||||||
<div className="flex flex-col gap-2 border-t border-ctp-surface1 p-2">
|
</section>
|
||||||
{content}
|
) : (
|
||||||
</div>
|
<details key={key} className="rounded border border-ctp-surface1">
|
||||||
</details>
|
<summary className="cursor-pointer px-2 py-1 text-sm text-ctp-subtext1">
|
||||||
),
|
{label}
|
||||||
);
|
</summary>
|
||||||
|
<div className="flex flex-col gap-2 border-t border-ctp-surface1 p-2">
|
||||||
|
{content}
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
|
),
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
extras.set(
|
||||||
|
token,
|
||||||
|
<details key={key} open={expanded} className="rounded border border-ctp-surface1">
|
||||||
|
<summary className="cursor-pointer px-2 py-1 text-sm text-ctp-subtext1">
|
||||||
|
{label || "Expand"}
|
||||||
|
</summary>
|
||||||
|
<div className="flex flex-col gap-2 border-t border-ctp-surface1 p-2">
|
||||||
|
{content}
|
||||||
|
</div>
|
||||||
|
</details>,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
result += `${text.slice(cursor, start)}${token}`;
|
result += `${text.slice(cursor, start)}${token}`;
|
||||||
counter += 1;
|
counter += 1;
|
||||||
cursor = scan;
|
cursor = scan;
|
||||||
@@ -424,20 +653,26 @@ function extractSections(
|
|||||||
return { processed: result, extras };
|
return { processed: result, extras };
|
||||||
}
|
}
|
||||||
|
|
||||||
function parseBlocks(text: string, keyPrefix: string): ReactNode[] {
|
function parseBlocks(
|
||||||
const { processed, extras } = extractSections(text, keyPrefix);
|
text: string,
|
||||||
const blocks = processed.split(/\n{2,}/);
|
keyPrefix: string,
|
||||||
|
baseUrl: string,
|
||||||
|
): ReactNode[] {
|
||||||
|
const { processed, extras } = extractBlocks(text, keyPrefix, baseUrl);
|
||||||
|
// Headers are block-level; make sure a heading always starts its own block.
|
||||||
|
const blocks = processed
|
||||||
|
.replace(/^(h[1-6]\.\s)/gm, "\n\n$1")
|
||||||
|
.split(/\n{2,}/);
|
||||||
|
|
||||||
return blocks
|
return blocks
|
||||||
.map((block, blockIndex) => {
|
.map((block, blockIndex) => {
|
||||||
const trimmed = block.trim();
|
const trimmed = block.trim();
|
||||||
if (!trimmed) return null;
|
if (!trimmed) return null;
|
||||||
|
const key = `${keyPrefix}-${blockIndex}`;
|
||||||
|
|
||||||
if (/^\uE000\d+\uE001$/.test(trimmed)) {
|
if (/^\uE000\d+\uE001$/.test(trimmed)) {
|
||||||
const node = extras.get(trimmed);
|
const node = extras.get(trimmed);
|
||||||
return node ? (
|
return node ? <div key={key}>{node}</div> : null;
|
||||||
<div key={`${keyPrefix}-${blockIndex}`}>{node}</div>
|
|
||||||
) : null;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const heading = /^(h[1-6])\.\s+(.*)$/.exec(trimmed);
|
const heading = /^(h[1-6])\.\s+(.*)$/.exec(trimmed);
|
||||||
@@ -447,65 +682,58 @@ function parseBlocks(text: string, keyPrefix: string): ReactNode[] {
|
|||||||
level <= "h2" ? "h3" : level === "h3" ? "h4" : "h5";
|
level <= "h2" ? "h3" : level === "h3" ? "h4" : "h5";
|
||||||
return (
|
return (
|
||||||
<HeadingTag
|
<HeadingTag
|
||||||
key={`${keyPrefix}-${blockIndex}`}
|
key={key}
|
||||||
className={cn(
|
className={
|
||||||
"font-semibold text-ctp-subtext1",
|
HeadingTag === "h3"
|
||||||
HeadingTag === "h3" ? "text-base" : "text-sm",
|
? "text-base font-semibold text-ctp-subtext1"
|
||||||
)}
|
: "text-sm font-semibold text-ctp-subtext1"
|
||||||
|
}
|
||||||
>
|
>
|
||||||
{renderInline(heading[2], `${keyPrefix}-h-${blockIndex}`, extras)}
|
{renderInline(heading[2], `${key}-h`, extras, baseUrl)}
|
||||||
</HeadingTag>
|
</HeadingTag>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const lines = trimmed.split("\n");
|
const lines = trimmed.split("\n");
|
||||||
if (lines.length > 0 && lines.every((line) => /^[*+-]\s+/.test(line))) {
|
if (lines.length > 0 && lines.every((line) => /^[*#]+\s+/.test(line))) {
|
||||||
|
const items = lines.map((line) => {
|
||||||
|
const itemMatch = /^([*#]+)\s+(.*)$/.exec(line.trim());
|
||||||
|
const prefix = itemMatch?.[1] ?? "*";
|
||||||
|
return {
|
||||||
|
level: prefix.length,
|
||||||
|
ordered: prefix.endsWith("#"),
|
||||||
|
content: itemMatch?.[2] ?? line,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
const tree = buildListTree(items, { value: 0 }, 1);
|
||||||
return (
|
return (
|
||||||
<ul
|
<div key={key}>
|
||||||
key={`${keyPrefix}-${blockIndex}`}
|
{renderListNodes(
|
||||||
className="ml-4 list-disc space-y-1"
|
tree,
|
||||||
>
|
items[0]?.ordered ?? false,
|
||||||
{lines.map((line, lineIndex) => (
|
`${key}-list`,
|
||||||
<li key={`${keyPrefix}-${blockIndex}-${lineIndex}`}>
|
extras,
|
||||||
{renderInline(
|
baseUrl,
|
||||||
line.replace(/^[*+-]\s+/, ""),
|
)}
|
||||||
`${keyPrefix}-${blockIndex}-${lineIndex}`,
|
</div>
|
||||||
extras,
|
|
||||||
)}
|
|
||||||
</li>
|
|
||||||
))}
|
|
||||||
</ul>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
if (lines.length > 0 && lines.every((line) => /^#\s+/.test(line))) {
|
|
||||||
return (
|
|
||||||
<ol
|
|
||||||
key={`${keyPrefix}-${blockIndex}`}
|
|
||||||
className="ml-4 list-decimal space-y-1"
|
|
||||||
>
|
|
||||||
{lines.map((line, lineIndex) => (
|
|
||||||
<li key={`${keyPrefix}-${blockIndex}-${lineIndex}`}>
|
|
||||||
{renderInline(
|
|
||||||
line.replace(/^#\s+/, ""),
|
|
||||||
`${keyPrefix}-${blockIndex}-${lineIndex}`,
|
|
||||||
extras,
|
|
||||||
)}
|
|
||||||
</li>
|
|
||||||
))}
|
|
||||||
</ol>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div key={`${keyPrefix}-${blockIndex}`} className="leading-relaxed">
|
<div key={key} className="leading-relaxed">
|
||||||
{renderInline(trimmed, `${keyPrefix}-p-${blockIndex}`, extras)}
|
{renderInline(trimmed, `${key}-p`, extras, baseUrl)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
})
|
})
|
||||||
.filter(Boolean);
|
.filter(Boolean);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function renderDText(text: string): ReactNode {
|
export function renderDText(
|
||||||
|
text: string,
|
||||||
|
baseUrl: string = DEFAULT_E621_BASE_URL,
|
||||||
|
): ReactNode {
|
||||||
if (!text.trim()) return null;
|
if (!text.trim()) return null;
|
||||||
return <div className="flex flex-col gap-2">{parseBlocks(text, "dtext")}</div>;
|
return (
|
||||||
|
<div className="flex flex-col gap-2">{parseBlocks(text, "dtext", baseUrl)}</div>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user