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
- {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) => (
- -
- {renderInline(
- line.replace(/^#\s+/, ""),
- `${keyPrefix}-${blockIndex}-${lineIndex}`,
- extras,
- )}
-
- ))}
-
+
+ {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)}
+ );
}