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:
Eyejoker
2026-04-28 13:15:59 +09:00
committed by GitHub
parent 880e1bfe24
commit 23c5be6ecd
2 changed files with 38 additions and 356 deletions

View File

@@ -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';

View 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>
);
}