Extract dashboard parsed body renderer
Extract the dashboard markdown renderer into ParsedBody.tsx and remove stale inline parser helpers from App.tsx.
This commit is contained in:
@@ -1,6 +1,4 @@
|
||||
import { useEffect, useMemo, useRef, useState, type ReactNode } from 'react';
|
||||
import ReactMarkdown from 'react-markdown';
|
||||
import remarkGfm from 'remark-gfm';
|
||||
import { Send } from 'lucide-react';
|
||||
|
||||
import {
|
||||
@@ -62,6 +60,7 @@ import {
|
||||
isInternalProtocolPayload,
|
||||
isWatcherRoomMessage,
|
||||
} from './roomThread';
|
||||
import { ParsedBody } from './ParsedBody';
|
||||
import './styles.css';
|
||||
|
||||
interface DashboardState {
|
||||
@@ -163,360 +162,6 @@ function humanizeError(raw: string, t: Messages): string {
|
||||
return raw || t.error.unknown;
|
||||
}
|
||||
|
||||
type ParsedSegment =
|
||||
| { kind: 'text'; value: string }
|
||||
| { kind: 'code'; lang: string; value: string }
|
||||
| { kind: 'inline-code'; value: string }
|
||||
| { kind: 'bold'; value: string }
|
||||
| { kind: 'italic'; value: string }
|
||||
| { kind: 'strike'; value: string }
|
||||
| { kind: 'url'; href: string; label: string }
|
||||
| { kind: 'mention'; value: string }
|
||||
| { kind: 'marker'; tone: 'done' | 'fail' | 'info'; value: string }
|
||||
| { kind: 'path'; value: string };
|
||||
|
||||
const URL_RE = /\bhttps?:\/\/[^\s)<>]+/g;
|
||||
const MENTION_RE = /(^|\s)(@[A-Za-z0-9_-]+)/g;
|
||||
const MARKER_RE =
|
||||
/\b(TASK_DONE|TASK_FAILED|TASK_FAIL|TASK_OK|DONE|FAILED|FAIL|OK|ERROR|WARNING|RETRY|STATUS|PASS|PASSED)\b/g;
|
||||
const PATH_RE = /(?:^|\s)((?:\/|\.\/|\.\.\/)[^\s)<>]+)/g;
|
||||
|
||||
function tokenizeInline(input: string): ParsedSegment[] {
|
||||
if (!input) return [];
|
||||
const segments: ParsedSegment[] = [];
|
||||
const matches: Array<{
|
||||
start: number;
|
||||
end: number;
|
||||
seg: ParsedSegment;
|
||||
priority: number;
|
||||
}> = [];
|
||||
for (const m of input.matchAll(/`([^`\n]+)`/g)) {
|
||||
matches.push({
|
||||
start: m.index ?? 0,
|
||||
end: (m.index ?? 0) + m[0].length,
|
||||
seg: { kind: 'inline-code', value: m[1] },
|
||||
priority: 0,
|
||||
});
|
||||
}
|
||||
for (const m of input.matchAll(/\*\*([^*\n]+)\*\*/g)) {
|
||||
matches.push({
|
||||
start: m.index ?? 0,
|
||||
end: (m.index ?? 0) + m[0].length,
|
||||
seg: { kind: 'bold', value: m[1] },
|
||||
priority: 0,
|
||||
});
|
||||
}
|
||||
for (const m of input.matchAll(/(?<![*\w])\*([^*\n]+?)\*(?!\w)/g)) {
|
||||
matches.push({
|
||||
start: m.index ?? 0,
|
||||
end: (m.index ?? 0) + m[0].length,
|
||||
seg: { kind: 'italic', value: m[1] },
|
||||
priority: 0,
|
||||
});
|
||||
}
|
||||
for (const m of input.matchAll(/~~([^~\n]+)~~/g)) {
|
||||
matches.push({
|
||||
start: m.index ?? 0,
|
||||
end: (m.index ?? 0) + m[0].length,
|
||||
seg: { kind: 'strike', value: m[1] },
|
||||
priority: 0,
|
||||
});
|
||||
}
|
||||
for (const m of input.matchAll(URL_RE)) {
|
||||
matches.push({
|
||||
start: m.index ?? 0,
|
||||
end: (m.index ?? 0) + m[0].length,
|
||||
seg: { kind: 'url', href: m[0], label: m[0].replace(/^https?:\/\//, '') },
|
||||
priority: 1,
|
||||
});
|
||||
}
|
||||
for (const m of input.matchAll(MENTION_RE)) {
|
||||
const idx = (m.index ?? 0) + m[1].length;
|
||||
matches.push({
|
||||
start: idx,
|
||||
end: idx + m[2].length,
|
||||
seg: { kind: 'mention', value: m[2] },
|
||||
priority: 2,
|
||||
});
|
||||
}
|
||||
for (const m of input.matchAll(PATH_RE)) {
|
||||
const lead = m[0].startsWith(' ') ? 1 : 0;
|
||||
const idx = (m.index ?? 0) + lead;
|
||||
matches.push({
|
||||
start: idx,
|
||||
end: idx + m[1].length,
|
||||
seg: { kind: 'path', value: m[1] },
|
||||
priority: 3,
|
||||
});
|
||||
}
|
||||
for (const m of input.matchAll(MARKER_RE)) {
|
||||
const v = m[1];
|
||||
const tone: 'done' | 'fail' | 'info' = /FAIL|ERROR/.test(v)
|
||||
? 'fail'
|
||||
: /DONE|OK|PASS/.test(v)
|
||||
? 'done'
|
||||
: 'info';
|
||||
matches.push({
|
||||
start: m.index ?? 0,
|
||||
end: (m.index ?? 0) + v.length,
|
||||
seg: { kind: 'marker', tone, value: v },
|
||||
priority: 4,
|
||||
});
|
||||
}
|
||||
matches.sort((a, b) =>
|
||||
a.start === b.start ? a.priority - b.priority : a.start - b.start,
|
||||
);
|
||||
let cursor = 0;
|
||||
const used: Array<[number, number]> = [];
|
||||
for (const match of matches) {
|
||||
if (match.start < cursor) continue;
|
||||
const overlaps = used.some(
|
||||
([s, e]) => !(match.end <= s || match.start >= e),
|
||||
);
|
||||
if (overlaps) continue;
|
||||
if (match.start > cursor) {
|
||||
segments.push({ kind: 'text', value: input.slice(cursor, match.start) });
|
||||
}
|
||||
segments.push(match.seg);
|
||||
used.push([match.start, match.end]);
|
||||
cursor = match.end;
|
||||
}
|
||||
if (cursor < input.length) {
|
||||
segments.push({ kind: 'text', value: input.slice(cursor) });
|
||||
}
|
||||
return segments;
|
||||
}
|
||||
|
||||
type ParsedBlock =
|
||||
| { kind: 'paragraph'; segments: ParsedSegment[] }
|
||||
| { kind: 'code'; lang: string; value: string }
|
||||
| { kind: 'heading'; level: 1 | 2 | 3 | 4; segments: ParsedSegment[] }
|
||||
| { kind: 'list'; items: ParsedSegment[][] }
|
||||
| { kind: 'olist'; items: ParsedSegment[][]; start: number }
|
||||
| { kind: 'quote'; segments: ParsedSegment[] };
|
||||
|
||||
function parseTextBlocks(raw: string): ParsedBlock[] {
|
||||
const blocks: ParsedBlock[] = [];
|
||||
const lines = raw.split(/\n/);
|
||||
let i = 0;
|
||||
while (i < lines.length) {
|
||||
const line = lines[i];
|
||||
const trimmed = line.trim();
|
||||
if (!trimmed) {
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
const heading = trimmed.match(/^(#{1,4})\s+(.*)$/);
|
||||
if (heading) {
|
||||
const level = Math.min(heading[1].length, 4) as 1 | 2 | 3 | 4;
|
||||
blocks.push({
|
||||
kind: 'heading',
|
||||
level,
|
||||
segments: tokenizeInline(heading[2]),
|
||||
});
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
if (/^>\s+/.test(trimmed)) {
|
||||
const quoteLines: string[] = [];
|
||||
while (i < lines.length && /^\s*>\s?/.test(lines[i])) {
|
||||
quoteLines.push(lines[i].replace(/^\s*>\s?/, ''));
|
||||
i++;
|
||||
}
|
||||
blocks.push({
|
||||
kind: 'quote',
|
||||
segments: tokenizeInline(quoteLines.join('\n')),
|
||||
});
|
||||
continue;
|
||||
}
|
||||
if (/^[-*]\s+/.test(trimmed)) {
|
||||
const items: ParsedSegment[][] = [];
|
||||
while (i < lines.length && /^\s*[-*]\s+/.test(lines[i])) {
|
||||
const itemText = lines[i].replace(/^\s*[-*]\s+/, '');
|
||||
items.push(tokenizeInline(itemText));
|
||||
i++;
|
||||
}
|
||||
blocks.push({ kind: 'list', items });
|
||||
continue;
|
||||
}
|
||||
const olistMatch = trimmed.match(/^(\d+)\.\s+/);
|
||||
if (olistMatch) {
|
||||
const items: ParsedSegment[][] = [];
|
||||
const start = Number(olistMatch[1]);
|
||||
while (i < lines.length && /^\s*\d+\.\s+/.test(lines[i])) {
|
||||
const itemText = lines[i].replace(/^\s*\d+\.\s+/, '');
|
||||
items.push(tokenizeInline(itemText));
|
||||
i++;
|
||||
}
|
||||
blocks.push({ kind: 'olist', items, start });
|
||||
continue;
|
||||
}
|
||||
// Plain paragraph: gather consecutive non-empty lines
|
||||
const paraLines: string[] = [];
|
||||
while (
|
||||
i < lines.length &&
|
||||
lines[i].trim() &&
|
||||
!/^(#{1,4})\s+/.test(lines[i].trim()) &&
|
||||
!/^[-*]\s+/.test(lines[i].trim()) &&
|
||||
!/^\d+\.\s+/.test(lines[i].trim()) &&
|
||||
!/^>\s+/.test(lines[i].trim())
|
||||
) {
|
||||
paraLines.push(lines[i]);
|
||||
i++;
|
||||
}
|
||||
if (paraLines.length > 0) {
|
||||
blocks.push({
|
||||
kind: 'paragraph',
|
||||
segments: tokenizeInline(paraLines.join('\n')),
|
||||
});
|
||||
}
|
||||
}
|
||||
return blocks;
|
||||
}
|
||||
|
||||
function parseMessageBody(raw: string | null | undefined): ParsedBlock[] {
|
||||
if (!raw) return [];
|
||||
const cleaned = raw
|
||||
.replace(/<\/?internal[^>]*>/gi, '')
|
||||
.replace(/<\/?intern\.{3}/gi, '')
|
||||
.trim();
|
||||
if (!cleaned) return [];
|
||||
const blocks: ParsedBlock[] = [];
|
||||
const fenceRe = /```([A-Za-z0-9_+-]*)\n?([\s\S]*?)```/g;
|
||||
let cursor = 0;
|
||||
let m: RegExpExecArray | null;
|
||||
while ((m = fenceRe.exec(cleaned)) !== null) {
|
||||
if (m.index > cursor) {
|
||||
const pre = cleaned.slice(cursor, m.index).trim();
|
||||
if (pre) blocks.push(...parseTextBlocks(pre));
|
||||
}
|
||||
blocks.push({ kind: 'code', lang: m[1] || '', value: m[2] });
|
||||
cursor = m.index + m[0].length;
|
||||
}
|
||||
if (cursor < cleaned.length) {
|
||||
const tail = cleaned.slice(cursor).trim();
|
||||
if (tail) blocks.push(...parseTextBlocks(tail));
|
||||
}
|
||||
return blocks;
|
||||
}
|
||||
|
||||
function renderInlineSegments(
|
||||
segments: ParsedSegment[],
|
||||
truncate: number | undefined,
|
||||
usedRef: { used: number },
|
||||
): ReactNode[] {
|
||||
const out: ReactNode[] = [];
|
||||
segments.forEach((s, j) => {
|
||||
if (truncate && usedRef.used >= truncate) return;
|
||||
const remaining = truncate ? truncate - usedRef.used : Infinity;
|
||||
if (s.kind === 'text') {
|
||||
const v =
|
||||
s.value.length > remaining
|
||||
? `${s.value.slice(0, remaining)}…`
|
||||
: s.value;
|
||||
usedRef.used += v.length;
|
||||
out.push(<span key={j}>{v}</span>);
|
||||
return;
|
||||
}
|
||||
if (s.kind === 'inline-code') {
|
||||
usedRef.used += s.value.length;
|
||||
out.push(
|
||||
<code className="parsed-icode" key={j}>
|
||||
{s.value}
|
||||
</code>,
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (s.kind === 'bold') {
|
||||
usedRef.used += s.value.length;
|
||||
out.push(<strong key={j}>{s.value}</strong>);
|
||||
return;
|
||||
}
|
||||
if (s.kind === 'italic') {
|
||||
usedRef.used += s.value.length;
|
||||
out.push(<em key={j}>{s.value}</em>);
|
||||
return;
|
||||
}
|
||||
if (s.kind === 'strike') {
|
||||
usedRef.used += s.value.length;
|
||||
out.push(<s key={j}>{s.value}</s>);
|
||||
return;
|
||||
}
|
||||
if (s.kind === 'url') {
|
||||
usedRef.used += s.label.length;
|
||||
out.push(
|
||||
<a
|
||||
className="parsed-url"
|
||||
href={s.href}
|
||||
key={j}
|
||||
rel="noreferrer noopener"
|
||||
target="_blank"
|
||||
>
|
||||
{s.label}
|
||||
</a>,
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (s.kind === 'mention') {
|
||||
usedRef.used += s.value.length;
|
||||
out.push(
|
||||
<span className="parsed-mention" key={j}>
|
||||
{s.value}
|
||||
</span>,
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (s.kind === 'path') {
|
||||
usedRef.used += s.value.length;
|
||||
out.push(
|
||||
<code className="parsed-path" key={j}>
|
||||
{s.value}
|
||||
</code>,
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (s.kind === 'marker') {
|
||||
usedRef.used += s.value.length;
|
||||
out.push(
|
||||
<span className={`parsed-marker parsed-marker-${s.tone}`} key={j}>
|
||||
{s.value}
|
||||
</span>,
|
||||
);
|
||||
return;
|
||||
}
|
||||
});
|
||||
return out;
|
||||
}
|
||||
|
||||
function ParsedBody({
|
||||
text,
|
||||
truncate,
|
||||
}: {
|
||||
text: string | null | undefined;
|
||||
truncate?: number;
|
||||
}): ReactNode {
|
||||
const cleaned = useMemo(() => {
|
||||
if (!text) return '';
|
||||
let out = text
|
||||
.replace(SECRET_ASSIGNMENT_RE, (_m, key) => `${key}=***`)
|
||||
.replace(SECRET_VALUE_RE, '***');
|
||||
if (truncate && out.length > truncate) {
|
||||
out = out.slice(0, truncate) + '…';
|
||||
}
|
||||
return out;
|
||||
}, [text, truncate]);
|
||||
|
||||
if (!cleaned.trim()) {
|
||||
return <span className="parsed-empty">—</span>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="parsed-body">
|
||||
<ReactMarkdown remarkPlugins={[remarkGfm]}>{cleaned}</ReactMarkdown>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function senderRoleClass(value: string | null | undefined): string {
|
||||
const v = (value ?? '').toLowerCase();
|
||||
if (v.includes('오너') || v.includes('owner')) return 'role-owner';
|
||||
|
||||
37
apps/dashboard/src/ParsedBody.tsx
Normal file
37
apps/dashboard/src/ParsedBody.tsx
Normal file
@@ -0,0 +1,37 @@
|
||||
import { useMemo, type ReactNode } from 'react';
|
||||
import ReactMarkdown from 'react-markdown';
|
||||
import remarkGfm from 'remark-gfm';
|
||||
|
||||
const SECRET_ASSIGNMENT_RE =
|
||||
/\b([A-Z0-9_]*(?:TOKEN|SECRET|PASSWORD|API_KEY|AUTH|PRIVATE_KEY)[A-Z0-9_]*)\s*=\s*([^\s"'`]+)/gi;
|
||||
const SECRET_VALUE_RE =
|
||||
/\b(?:sk-[A-Za-z0-9_-]{12,}|gh[pousr]_[A-Za-z0-9_]{12,}|xox[baprs]-[A-Za-z0-9-]{12,}|eyJ[A-Za-z0-9_-]{20,}\.[A-Za-z0-9_-]{20,}\.[A-Za-z0-9_-]{10,})\b/g;
|
||||
|
||||
export function ParsedBody({
|
||||
text,
|
||||
truncate,
|
||||
}: {
|
||||
text: string | null | undefined;
|
||||
truncate?: number;
|
||||
}): ReactNode {
|
||||
const cleaned = useMemo(() => {
|
||||
if (!text) return '';
|
||||
let out = text
|
||||
.replace(SECRET_ASSIGNMENT_RE, (_m, key) => `${key}=***`)
|
||||
.replace(SECRET_VALUE_RE, '***');
|
||||
if (truncate && out.length > truncate) {
|
||||
out = out.slice(0, truncate) + '…';
|
||||
}
|
||||
return out;
|
||||
}, [text, truncate]);
|
||||
|
||||
if (!cleaned.trim()) {
|
||||
return <span className="parsed-empty">—</span>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="parsed-body">
|
||||
<ReactMarkdown remarkPlugins={[remarkGfm]}>{cleaned}</ReactMarkdown>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user