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:
2026-09-17 09:22:14 -05:00
parent 81939d8ca3
commit 8e2f5e3395
2 changed files with 479 additions and 251 deletions
@@ -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>
+458 -230
View File
@@ -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]); nodes.push(
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 { } else {
nodes.push( nodes.push(match[0]);
<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]) { } else if (match[7] !== undefined) {
const { url, trailing } = splitTrailing(match[3]); nodes.push(anchor(key, match[7], match[7], "break-all"));
nodes.push( } else if (match[8] !== undefined) {
<a const { url, trailing } = splitTrailing(match[8]);
key={`${keyPrefix}-${counter++}`} nodes.push(anchor(key, url, url, "break-all"));
href={url}
target="_blank"
rel="noreferrer"
className="break-all text-ctp-blue hover:underline"
>
{url}
</a>,
);
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,14 +601,20 @@ 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`);
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( extras.set(
token, token,
tag === "section" ? ( expanded || !label ? (
<section key={key} className="flex flex-col gap-2"> <section key={key} className="flex flex-col gap-2">
{title ? ( {label ? (
<h4 className="text-sm font-semibold text-ctp-subtext1"> <h4 className="text-sm font-semibold text-ctp-subtext1">
{stripTags(title)} {label}
</h4> </h4>
) : null} ) : null}
{content} {content}
@@ -408,7 +622,7 @@ function extractSections(
) : ( ) : (
<details key={key} className="rounded border border-ctp-surface1"> <details key={key} className="rounded border border-ctp-surface1">
<summary className="cursor-pointer px-2 py-1 text-sm text-ctp-subtext1"> <summary className="cursor-pointer px-2 py-1 text-sm text-ctp-subtext1">
{title ? stripTags(title) : "Expand"} {label}
</summary> </summary>
<div className="flex flex-col gap-2 border-t border-ctp-surface1 p-2"> <div className="flex flex-col gap-2 border-t border-ctp-surface1 p-2">
{content} {content}
@@ -416,6 +630,21 @@ function extractSections(
</details> </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}`}>
{renderInline(
line.replace(/^[*+-]\s+/, ""),
`${keyPrefix}-${blockIndex}-${lineIndex}`,
extras, extras,
baseUrl,
)} )}
</li> </div>
))}
</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>
);
} }