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
|
||||
</summary>
|
||||
<div className="mt-2 text-sm leading-relaxed text-ctp-subtext0">
|
||||
{renderDText(post.description)}
|
||||
{renderDText(post.description, credentials?.base_url)}
|
||||
</div>
|
||||
</details>
|
||||
</section>
|
||||
|
||||
+478
-250
@@ -1,133 +1,173 @@
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
import { Spoiler } from "@/components/Spoiler";
|
||||
import { cn } from "@/lib/cn";
|
||||
|
||||
const INLINE_TAGS = new Set([
|
||||
"b",
|
||||
"i",
|
||||
"u",
|
||||
"s",
|
||||
"sup",
|
||||
"sub",
|
||||
"code",
|
||||
"spoiler",
|
||||
"quote",
|
||||
"color",
|
||||
"url",
|
||||
"wiki",
|
||||
"thumb",
|
||||
"img",
|
||||
]);
|
||||
export const DEFAULT_E621_BASE_URL = "https://e621.net";
|
||||
|
||||
const COLOR_NAMES = new Set([
|
||||
"red",
|
||||
"green",
|
||||
"blue",
|
||||
"yellow",
|
||||
"orange",
|
||||
"purple",
|
||||
"pink",
|
||||
"brown",
|
||||
"black",
|
||||
"white",
|
||||
"gray",
|
||||
"grey",
|
||||
"cyan",
|
||||
"magenta",
|
||||
"lime",
|
||||
"olive",
|
||||
"navy",
|
||||
"teal",
|
||||
"maroon",
|
||||
"silver",
|
||||
"gold",
|
||||
"aqua",
|
||||
"fuchsia",
|
||||
]);
|
||||
const TAXONOMY_COLORS: Record<string, string> = {
|
||||
artist: "#f9e2af",
|
||||
copyright: "#cba6f7",
|
||||
character: "#a6e3a1",
|
||||
species: "#fab387",
|
||||
general: "#89b4fa",
|
||||
meta: "#9399b2",
|
||||
lore: "#94e2d5",
|
||||
invalid: "#f38ba8",
|
||||
contributor: "#b4befe",
|
||||
};
|
||||
|
||||
function safeColor(value: string | undefined): string | undefined {
|
||||
function resolveColor(value: string | undefined): string | undefined {
|
||||
if (!value) return undefined;
|
||||
const trimmed = value.trim().toLowerCase();
|
||||
if (/^#[0-9a-f]{3,8}$/.test(trimmed)) return trimmed;
|
||||
if (COLOR_NAMES.has(trimmed)) return trimmed;
|
||||
if (TAXONOMY_COLORS[trimmed]) return TAXONOMY_COLORS[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;
|
||||
}
|
||||
|
||||
function safeUrl(value: string | undefined): string | undefined {
|
||||
if (!value) return undefined;
|
||||
function absoluteUrl(value: string, baseUrl: string): string | undefined {
|
||||
const trimmed = value.trim();
|
||||
if (/^https?:\/\//i.test(trimmed)) return trimmed;
|
||||
if (trimmed.startsWith("/")) return `${baseUrl.replace(/\/+$/, "")}${trimmed}`;
|
||||
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 } {
|
||||
const match = /[.,;:!?)\]]+$/.exec(url);
|
||||
if (!match) return { url, trailing: "" };
|
||||
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 regex = /"([^"]+)":(\S+)|(https?:\/\/\S+)/g;
|
||||
let lastIndex = 0;
|
||||
let match: RegExpExecArray | null;
|
||||
let counter = 0;
|
||||
let match: RegExpExecArray | null;
|
||||
const regex = new RegExp(LINK_RE.source, "g");
|
||||
|
||||
while ((match = regex.exec(text)) !== null) {
|
||||
if (match.index > lastIndex) {
|
||||
nodes.push(text.slice(lastIndex, match.index));
|
||||
}
|
||||
if (match[1] && match[2]) {
|
||||
const { url, trailing } = splitTrailing(match[2]);
|
||||
const href = safeUrl(url);
|
||||
if (!href) {
|
||||
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]);
|
||||
const key = `${keyPrefix}-${counter++}`;
|
||||
|
||||
if (match[1] !== undefined && match[2] !== undefined) {
|
||||
const href = absoluteUrl(match[2], baseUrl);
|
||||
nodes.push(
|
||||
<a
|
||||
key={`${keyPrefix}-${counter++}`}
|
||||
href={url}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="break-all text-ctp-blue hover:underline"
|
||||
>
|
||||
{url}
|
||||
</a>,
|
||||
href ? anchor(key, href, match[1]) : `${match[1]}:${match[2]}`,
|
||||
);
|
||||
} 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);
|
||||
} 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;
|
||||
}
|
||||
|
||||
if (lastIndex < text.length) {
|
||||
nodes.push(text.slice(lastIndex));
|
||||
}
|
||||
@@ -138,9 +178,11 @@ function renderText(
|
||||
text: string,
|
||||
keyPrefix: string,
|
||||
extras: Map<string, ReactNode>,
|
||||
baseUrl: string,
|
||||
): ReactNode[] {
|
||||
const unescaped = text.replace(/\\`/g, "`");
|
||||
const nodes: ReactNode[] = [];
|
||||
const pieces = text.split(/(\uE000\d+\uE001)/);
|
||||
const pieces = unescaped.split(/(\uE000\d+\uE001)/);
|
||||
pieces.forEach((piece, pieceIndex) => {
|
||||
if (!piece) return;
|
||||
const tokenMatch = /^\uE000(\d+)\uE001$/.exec(piece);
|
||||
@@ -155,43 +197,70 @@ function renderText(
|
||||
nodes.push(<br key={`${keyPrefix}-br-${pieceIndex}-${lineIndex}`} />);
|
||||
}
|
||||
nodes.push(
|
||||
...linkify(line, `${keyPrefix}-l-${pieceIndex}-${lineIndex}`),
|
||||
...linkify(line, `${keyPrefix}-l-${pieceIndex}-${lineIndex}`, baseUrl),
|
||||
);
|
||||
});
|
||||
});
|
||||
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(
|
||||
tag: string,
|
||||
attr: string | undefined,
|
||||
inner: string,
|
||||
keyPrefix: string,
|
||||
extras: Map<string, ReactNode>,
|
||||
baseUrl: string,
|
||||
): ReactNode {
|
||||
switch (tag) {
|
||||
case "b":
|
||||
return (
|
||||
<strong key={keyPrefix}>{renderInline(inner, keyPrefix, extras)}</strong>
|
||||
<strong key={keyPrefix}>
|
||||
{renderInline(inner, keyPrefix, extras, baseUrl)}
|
||||
</strong>
|
||||
);
|
||||
case "i":
|
||||
return <em key={keyPrefix}>{renderInline(inner, keyPrefix, extras)}</em>;
|
||||
return (
|
||||
<em key={keyPrefix}>{renderInline(inner, keyPrefix, extras, baseUrl)}</em>
|
||||
);
|
||||
case "u":
|
||||
return (
|
||||
<span key={keyPrefix} className="underline">
|
||||
{renderInline(inner, keyPrefix, extras)}
|
||||
{renderInline(inner, keyPrefix, extras, baseUrl)}
|
||||
</span>
|
||||
);
|
||||
case "s":
|
||||
return (
|
||||
<span key={keyPrefix} className="line-through">
|
||||
{renderInline(inner, keyPrefix, extras)}
|
||||
{renderInline(inner, keyPrefix, extras, baseUrl)}
|
||||
</span>
|
||||
);
|
||||
case "sup":
|
||||
return <sup key={keyPrefix}>{renderInline(inner, keyPrefix, extras)}</sup>;
|
||||
return (
|
||||
<sup key={keyPrefix}>{renderInline(inner, keyPrefix, extras, baseUrl)}</sup>
|
||||
);
|
||||
case "sub":
|
||||
return <sub key={keyPrefix}>{renderInline(inner, keyPrefix, extras)}</sub>;
|
||||
return (
|
||||
<sub key={keyPrefix}>{renderInline(inner, keyPrefix, extras, baseUrl)}</sub>
|
||||
);
|
||||
case "code":
|
||||
return (
|
||||
<code
|
||||
@@ -204,79 +273,38 @@ function renderTag(
|
||||
case "spoiler":
|
||||
return (
|
||||
<Spoiler key={keyPrefix}>
|
||||
{renderInline(inner, keyPrefix, extras)}
|
||||
{renderInline(inner, keyPrefix, extras, baseUrl)}
|
||||
</Spoiler>
|
||||
);
|
||||
case "quote":
|
||||
case "quote": {
|
||||
const color = resolveColor(attr);
|
||||
return (
|
||||
<blockquote
|
||||
key={keyPrefix}
|
||||
style={color ? { borderColor: color } : undefined}
|
||||
className="border-l-2 border-ctp-surface1 pl-3 text-ctp-subtext0"
|
||||
>
|
||||
{attr ? (
|
||||
<span className="mr-1 font-medium text-ctp-overlay0">
|
||||
{stripTags(attr)}:
|
||||
</span>
|
||||
) : null}
|
||||
{renderInline(inner, keyPrefix, extras)}
|
||||
{renderInline(inner, keyPrefix, extras, baseUrl)}
|
||||
</blockquote>
|
||||
);
|
||||
}
|
||||
case "color": {
|
||||
const color = safeColor(attr);
|
||||
return color ? (
|
||||
<span key={keyPrefix} style={{ color }}>
|
||||
{renderInline(inner, keyPrefix, extras)}
|
||||
const color = resolveColor(attr);
|
||||
return (
|
||||
<span key={keyPrefix} style={color ? { color } : undefined}>
|
||||
{renderInline(inner, keyPrefix, extras, baseUrl)}
|
||||
</span>
|
||||
) : (
|
||||
<span key={keyPrefix}>{renderInline(inner, keyPrefix, extras)}</span>
|
||||
);
|
||||
}
|
||||
case "url": {
|
||||
const href = safeUrl(attr) ?? safeUrl(stripTags(inner));
|
||||
if (!href) return <span key={keyPrefix}>{stripTags(inner)}</span>;
|
||||
return (
|
||||
<a
|
||||
key={keyPrefix}
|
||||
href={href}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="break-all text-ctp-blue hover:underline"
|
||||
>
|
||||
{attr ? renderInline(inner, keyPrefix, extras) : href}
|
||||
</a>
|
||||
);
|
||||
const href = absoluteUrl(attr ?? inner, baseUrl);
|
||||
if (!href) return <span key={keyPrefix}>{inner}</span>;
|
||||
return anchor(keyPrefix, href, attr ? renderInline(inner, keyPrefix, extras, baseUrl) : href, "break-all");
|
||||
}
|
||||
case "wiki": {
|
||||
const title = stripTags(attr ?? inner);
|
||||
if (!title) return null;
|
||||
case "wiki":
|
||||
return (
|
||||
<a
|
||||
key={keyPrefix}
|
||||
href={`https://e621.net/wiki_pages?title=${encodeURIComponent(title)}`}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="text-ctp-blue hover:underline"
|
||||
>
|
||||
{title}
|
||||
</a>
|
||||
<span key={keyPrefix}>{wikiLink(attr ?? inner, baseUrl, keyPrefix)}</span>
|
||||
);
|
||||
}
|
||||
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:
|
||||
return null;
|
||||
}
|
||||
@@ -286,6 +314,7 @@ function renderInline(
|
||||
text: string,
|
||||
keyPrefix: string,
|
||||
extras: Map<string, ReactNode>,
|
||||
baseUrl: string,
|
||||
): ReactNode[] {
|
||||
const nodes: ReactNode[] = [];
|
||||
let cursor = 0;
|
||||
@@ -293,28 +322,48 @@ function renderInline(
|
||||
|
||||
while (cursor < text.length) {
|
||||
const slice = text.slice(cursor);
|
||||
const match = /\[([a-z_]+)(?:=([^\]]*))?\]/.exec(slice);
|
||||
if (!match) {
|
||||
nodes.push(...renderText(slice, `${keyPrefix}-${counter++}`, extras));
|
||||
break;
|
||||
}
|
||||
const start = cursor + match.index;
|
||||
const tag = match[1].toLowerCase();
|
||||
if (!INLINE_TAGS.has(tag)) {
|
||||
const tagMatch = /\[([a-z_]+)(?:=([^\]]*))?\]/.exec(slice);
|
||||
const tagIndex = tagMatch ? tagMatch.index : -1;
|
||||
const tickIndex = findBacktick(slice, 0);
|
||||
|
||||
if (tickIndex !== -1 && (tagIndex === -1 || tickIndex < tagIndex)) {
|
||||
nodes.push(
|
||||
...renderText(
|
||||
text.slice(cursor, start + match[0].length),
|
||||
text.slice(cursor, cursor + tickIndex),
|
||||
`${keyPrefix}-${counter++}`,
|
||||
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;
|
||||
}
|
||||
|
||||
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}]`;
|
||||
let depth = 1;
|
||||
let scan = start + match[0].length;
|
||||
let scan = start + tagMatch[0].length;
|
||||
while (depth > 0) {
|
||||
const nextOpen = findOpenTag(text, tag, scan);
|
||||
const nextClose = text.indexOf(closeTag, scan);
|
||||
@@ -331,19 +380,29 @@ function renderInline(
|
||||
if (depth !== 0) {
|
||||
nodes.push(
|
||||
...renderText(
|
||||
text.slice(cursor, start + match[0].length),
|
||||
text.slice(cursor, start + tagMatch[0].length),
|
||||
`${keyPrefix}-${counter++}`,
|
||||
extras,
|
||||
baseUrl,
|
||||
),
|
||||
);
|
||||
cursor = start + match[0].length;
|
||||
cursor = start + tagMatch[0].length;
|
||||
continue;
|
||||
}
|
||||
|
||||
const inner = text.slice(start + match[0].length, scan - closeTag.length);
|
||||
nodes.push(...renderText(text.slice(cursor, start), `${keyPrefix}-t${counter}`, extras));
|
||||
const inner = text.slice(start + tagMatch[0].length, scan - closeTag.length);
|
||||
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;
|
||||
}
|
||||
@@ -351,9 +410,155 @@ function renderInline(
|
||||
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,
|
||||
keyPrefix: string,
|
||||
baseUrl: string,
|
||||
): { processed: string; extras: Map<string, ReactNode> } {
|
||||
const extras = new Map<string, ReactNode>();
|
||||
let result = "";
|
||||
@@ -362,14 +567,17 @@ function extractSections(
|
||||
|
||||
while (cursor < text.length) {
|
||||
const slice = text.slice(cursor);
|
||||
const match = /\[(section|expand)(?:=([^\]]*))?\]/.exec(slice);
|
||||
const match = /\[(section|expand|table|ltable)(?:,expanded)?(?:=([^\]]*))?\]/.exec(
|
||||
slice,
|
||||
);
|
||||
if (!match) {
|
||||
result += slice;
|
||||
break;
|
||||
}
|
||||
const start = cursor + match.index;
|
||||
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}]`;
|
||||
let depth = 1;
|
||||
let scan = start + match[0].length;
|
||||
@@ -393,29 +601,50 @@ function extractSections(
|
||||
const inner = text.slice(start + match[0].length, scan - closeTag.length);
|
||||
const token = `\uE000${counter}\uE001`;
|
||||
const key = `${keyPrefix}-${tag}-${counter}`;
|
||||
const content = parseBlocks(inner, `${key}-body`);
|
||||
extras.set(
|
||||
token,
|
||||
tag === "section" ? (
|
||||
<section key={key} className="flex flex-col gap-2">
|
||||
{title ? (
|
||||
<h4 className="text-sm font-semibold text-ctp-subtext1">
|
||||
{stripTags(title)}
|
||||
</h4>
|
||||
) : null}
|
||||
{content}
|
||||
</section>
|
||||
) : (
|
||||
<details key={key} className="rounded border border-ctp-surface1">
|
||||
<summary className="cursor-pointer px-2 py-1 text-sm text-ctp-subtext1">
|
||||
{title ? stripTags(title) : "Expand"}
|
||||
</summary>
|
||||
<div className="flex flex-col gap-2 border-t border-ctp-surface1 p-2">
|
||||
{content}
|
||||
</div>
|
||||
</details>
|
||||
),
|
||||
);
|
||||
|
||||
if (tag === "table" || tag === "ltable") {
|
||||
extras.set(token, renderTable(tag, inner, key, extras, baseUrl));
|
||||
} else {
|
||||
const content = parseBlocks(inner, `${key}-body`, baseUrl);
|
||||
const label = title ? title.trim() : undefined;
|
||||
if (tag === "section") {
|
||||
extras.set(
|
||||
token,
|
||||
expanded || !label ? (
|
||||
<section key={key} className="flex flex-col gap-2">
|
||||
{label ? (
|
||||
<h4 className="text-sm font-semibold text-ctp-subtext1">
|
||||
{label}
|
||||
</h4>
|
||||
) : null}
|
||||
{content}
|
||||
</section>
|
||||
) : (
|
||||
<details key={key} className="rounded border border-ctp-surface1">
|
||||
<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}`;
|
||||
counter += 1;
|
||||
cursor = scan;
|
||||
@@ -424,20 +653,26 @@ function extractSections(
|
||||
return { processed: result, extras };
|
||||
}
|
||||
|
||||
function parseBlocks(text: string, keyPrefix: string): ReactNode[] {
|
||||
const { processed, extras } = extractSections(text, keyPrefix);
|
||||
const blocks = processed.split(/\n{2,}/);
|
||||
function parseBlocks(
|
||||
text: string,
|
||||
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
|
||||
.map((block, blockIndex) => {
|
||||
const trimmed = block.trim();
|
||||
if (!trimmed) return null;
|
||||
const key = `${keyPrefix}-${blockIndex}`;
|
||||
|
||||
if (/^\uE000\d+\uE001$/.test(trimmed)) {
|
||||
const node = extras.get(trimmed);
|
||||
return node ? (
|
||||
<div key={`${keyPrefix}-${blockIndex}`}>{node}</div>
|
||||
) : null;
|
||||
return node ? <div key={key}>{node}</div> : null;
|
||||
}
|
||||
|
||||
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";
|
||||
return (
|
||||
<HeadingTag
|
||||
key={`${keyPrefix}-${blockIndex}`}
|
||||
className={cn(
|
||||
"font-semibold text-ctp-subtext1",
|
||||
HeadingTag === "h3" ? "text-base" : "text-sm",
|
||||
)}
|
||||
key={key}
|
||||
className={
|
||||
HeadingTag === "h3"
|
||||
? "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>
|
||||
);
|
||||
}
|
||||
|
||||
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 (
|
||||
<ul
|
||||
key={`${keyPrefix}-${blockIndex}`}
|
||||
className="ml-4 list-disc space-y-1"
|
||||
>
|
||||
{lines.map((line, lineIndex) => (
|
||||
<li key={`${keyPrefix}-${blockIndex}-${lineIndex}`}>
|
||||
{renderInline(
|
||||
line.replace(/^[*+-]\s+/, ""),
|
||||
`${keyPrefix}-${blockIndex}-${lineIndex}`,
|
||||
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>
|
||||
<div key={key}>
|
||||
{renderListNodes(
|
||||
tree,
|
||||
items[0]?.ordered ?? false,
|
||||
`${key}-list`,
|
||||
extras,
|
||||
baseUrl,
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div key={`${keyPrefix}-${blockIndex}`} className="leading-relaxed">
|
||||
{renderInline(trimmed, `${keyPrefix}-p-${blockIndex}`, extras)}
|
||||
<div key={key} className="leading-relaxed">
|
||||
{renderInline(trimmed, `${key}-p`, extras, baseUrl)}
|
||||
</div>
|
||||
);
|
||||
})
|
||||
.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;
|
||||
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