From 23c5be6ecd68a8d74e59dd81e0055350f714ee75 Mon Sep 17 00:00:00 2001 From: Eyejoker Date: Tue, 28 Apr 2026 13:15:59 +0900 Subject: [PATCH] Extract dashboard parsed body renderer Extract the dashboard markdown renderer into ParsedBody.tsx and remove stale inline parser helpers from App.tsx. --- apps/dashboard/src/App.tsx | 357 +----------------------------- apps/dashboard/src/ParsedBody.tsx | 37 ++++ 2 files changed, 38 insertions(+), 356 deletions(-) create mode 100644 apps/dashboard/src/ParsedBody.tsx diff --git a/apps/dashboard/src/App.tsx b/apps/dashboard/src/App.tsx index 0af0d83..74e7bb3 100644 --- a/apps/dashboard/src/App.tsx +++ b/apps/dashboard/src/App.tsx @@ -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(/(? - 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({v}); - return; - } - if (s.kind === 'inline-code') { - usedRef.used += s.value.length; - out.push( - - {s.value} - , - ); - return; - } - if (s.kind === 'bold') { - usedRef.used += s.value.length; - out.push({s.value}); - return; - } - if (s.kind === 'italic') { - usedRef.used += s.value.length; - out.push({s.value}); - return; - } - if (s.kind === 'strike') { - usedRef.used += s.value.length; - out.push({s.value}); - return; - } - if (s.kind === 'url') { - usedRef.used += s.label.length; - out.push( - - {s.label} - , - ); - return; - } - if (s.kind === 'mention') { - usedRef.used += s.value.length; - out.push( - - {s.value} - , - ); - return; - } - if (s.kind === 'path') { - usedRef.used += s.value.length; - out.push( - - {s.value} - , - ); - return; - } - if (s.kind === 'marker') { - usedRef.used += s.value.length; - out.push( - - {s.value} - , - ); - 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 ; - } - - return ( -
- {cleaned} -
- ); -} - function senderRoleClass(value: string | null | undefined): string { const v = (value ?? '').toLowerCase(); if (v.includes('오너') || v.includes('owner')) return 'role-owner'; diff --git a/apps/dashboard/src/ParsedBody.tsx b/apps/dashboard/src/ParsedBody.tsx new file mode 100644 index 0000000..580ac35 --- /dev/null +++ b/apps/dashboard/src/ParsedBody.tsx @@ -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 ; + } + + return ( +
+ {cleaned} +
+ ); +}