diff --git a/frontend/src/features/online/OnlineDetailPage.tsx b/frontend/src/features/online/OnlineDetailPage.tsx index 9a997bb..7796e00 100644 --- a/frontend/src/features/online/OnlineDetailPage.tsx +++ b/frontend/src/features/online/OnlineDetailPage.tsx @@ -308,7 +308,7 @@ export default function OnlineDetailPage() { Description
- {renderDText(post.description)} + {renderDText(post.description, credentials?.base_url)}
diff --git a/frontend/src/lib/dtext.tsx b/frontend/src/lib/dtext.tsx index 6f9df16..0a13448 100644 --- a/frontend/src/lib/dtext.tsx +++ b/frontend/src/lib/dtext.tsx @@ -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 = { + 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 = { + 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 ( + + {label} + + ); +} + +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 ( + + {label} + + ); + } + 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( - - {match[1]} - , - ); - 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( - - {url} - , + 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(); + } 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, + 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(
); } 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, + baseUrl: string, ): ReactNode { switch (tag) { case "b": return ( - {renderInline(inner, keyPrefix, extras)} + + {renderInline(inner, keyPrefix, extras, baseUrl)} + ); case "i": - return {renderInline(inner, keyPrefix, extras)}; + return ( + {renderInline(inner, keyPrefix, extras, baseUrl)} + ); case "u": return ( - {renderInline(inner, keyPrefix, extras)} + {renderInline(inner, keyPrefix, extras, baseUrl)} ); case "s": return ( - {renderInline(inner, keyPrefix, extras)} + {renderInline(inner, keyPrefix, extras, baseUrl)} ); case "sup": - return {renderInline(inner, keyPrefix, extras)}; + return ( + {renderInline(inner, keyPrefix, extras, baseUrl)} + ); case "sub": - return {renderInline(inner, keyPrefix, extras)}; + return ( + {renderInline(inner, keyPrefix, extras, baseUrl)} + ); case "code": return ( - {renderInline(inner, keyPrefix, extras)} + {renderInline(inner, keyPrefix, extras, baseUrl)} ); - case "quote": + case "quote": { + const color = resolveColor(attr); return (
- {attr ? ( - - {stripTags(attr)}: - - ) : null} - {renderInline(inner, keyPrefix, extras)} + {renderInline(inner, keyPrefix, extras, baseUrl)}
); + } case "color": { - const color = safeColor(attr); - return color ? ( - - {renderInline(inner, keyPrefix, extras)} + const color = resolveColor(attr); + return ( + + {renderInline(inner, keyPrefix, extras, baseUrl)} - ) : ( - {renderInline(inner, keyPrefix, extras)} ); } case "url": { - const href = safeUrl(attr) ?? safeUrl(stripTags(inner)); - if (!href) return {stripTags(inner)}; - return ( - - {attr ? renderInline(inner, keyPrefix, extras) : href} - - ); + const href = absoluteUrl(attr ?? inner, baseUrl); + if (!href) return {inner}; + 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 ( - - {title} - + {wikiLink(attr ?? inner, baseUrl, keyPrefix)} ); - } - case "thumb": - case "img": { - const href = safeUrl(attr) ?? safeUrl(stripTags(inner)); - if (!href) return null; - return ( - - [image] - - ); - } default: return null; } @@ -286,6 +314,7 @@ function renderInline( text: string, keyPrefix: string, extras: Map, + 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( + + {rest.slice(0, close)} + , + ); + 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[], + 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, + baseUrl: string, +): ReactNode { + const ListTag = ordered ? "ol" : "ul"; + return ( + + {nodes.map((node, 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} +
  • + ))} +
    + ); +} + +function renderTable( + kind: "table" | "ltable", + inner: string, + key: string, + extras: Map, + 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 ( +
    + + + {rows.map((row, rowIndex) => ( + + {row.cells.map((cell, cellIndex) => + row.header ? ( + + ) : ( + + ), + )} + + ))} + +
    + {renderInline( + cell, + `${key}-${rowIndex}-${cellIndex}`, + extras, + baseUrl, + )} + + {renderInline( + cell, + `${key}-${rowIndex}-${cellIndex}`, + extras, + baseUrl, + )} +
    +
    + ); +} + +function extractBlocks( text: string, keyPrefix: string, + baseUrl: string, ): { processed: string; extras: Map } { const extras = new Map(); 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" ? ( -
    - {title ? ( -

    - {stripTags(title)} -

    - ) : null} - {content} -
    - ) : ( -
    - - {title ? stripTags(title) : "Expand"} - -
    - {content} -
    -
    - ), - ); + + 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 ? ( +
    + {label ? ( +

    + {label} +

    + ) : null} + {content} +
    + ) : ( +
    + + {label} + +
    + {content} +
    +
    + ), + ); + } else { + extras.set( + token, +
    + + {label || "Expand"} + +
    + {content} +
    +
    , + ); + } + } + 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 ? ( -
    {node}
    - ) : null; + return node ?
    {node}
    : 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 ( - {renderInline(heading[2], `${keyPrefix}-h-${blockIndex}`, extras)} + {renderInline(heading[2], `${key}-h`, extras, baseUrl)} ); } 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 ( -
      - {lines.map((line, lineIndex) => ( -
    • - {renderInline( - line.replace(/^[*+-]\s+/, ""), - `${keyPrefix}-${blockIndex}-${lineIndex}`, - extras, - )} -
    • - ))} -
    - ); - } - if (lines.length > 0 && lines.every((line) => /^#\s+/.test(line))) { - return ( -
      - {lines.map((line, lineIndex) => ( -
    1. - {renderInline( - line.replace(/^#\s+/, ""), - `${keyPrefix}-${blockIndex}-${lineIndex}`, - extras, - )} -
    2. - ))} -
    +
    + {renderListNodes( + tree, + items[0]?.ordered ?? false, + `${key}-list`, + extras, + baseUrl, + )} +
    ); } return ( -
    - {renderInline(trimmed, `${keyPrefix}-p-${blockIndex}`, extras)} +
    + {renderInline(trimmed, `${key}-p`, extras, baseUrl)}
    ); }) .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
    {parseBlocks(text, "dtext")}
    ; + return ( +
    {parseBlocks(text, "dtext", baseUrl)}
    + ); }