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 { useEffect, useMemo, useRef, useState, type ReactNode } from 'react';
|
||||||
import ReactMarkdown from 'react-markdown';
|
|
||||||
import remarkGfm from 'remark-gfm';
|
|
||||||
import { Send } from 'lucide-react';
|
import { Send } from 'lucide-react';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
@@ -62,6 +60,7 @@ import {
|
|||||||
isInternalProtocolPayload,
|
isInternalProtocolPayload,
|
||||||
isWatcherRoomMessage,
|
isWatcherRoomMessage,
|
||||||
} from './roomThread';
|
} from './roomThread';
|
||||||
|
import { ParsedBody } from './ParsedBody';
|
||||||
import './styles.css';
|
import './styles.css';
|
||||||
|
|
||||||
interface DashboardState {
|
interface DashboardState {
|
||||||
@@ -163,360 +162,6 @@ function humanizeError(raw: string, t: Messages): string {
|
|||||||
return raw || t.error.unknown;
|
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 {
|
function senderRoleClass(value: string | null | undefined): string {
|
||||||
const v = (value ?? '').toLowerCase();
|
const v = (value ?? '').toLowerCase();
|
||||||
if (v.includes('오너') || v.includes('owner')) return 'role-owner';
|
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