import type { ReactNode } from 'react'; import { useEffect, useMemo, useRef, useState } from 'react'; import { useTranslation } from 'react-i18next'; import i18n from '../../i18n'; import { Marked, Renderer } from 'marked'; import DOMPurify from 'dompurify'; import mermaid from 'mermaid'; import hljs from 'highlight.js'; import { updateLocalFileContent } from '../../api'; import { EmbedBlock } from '../embed/EmbedBlock'; import { OUTPUT_PATH_REGEX, linkifyOutputPathsInEscapedHtml } from '../../lib/output-path-detect'; import { useBackdropClose } from '../../lib/useBackdropClose'; mermaid.initialize({ startOnLoad: false, theme: 'default' }); // --- MDXG outline helpers --- interface OutlineEntry { depth: 1 | 2 | 3; text: string; slug: string; } function slugify(text: string): string { const cleaned = text .replace(/<[^>]+>/g, '') .toLowerCase() .trim() .replace(/[\s_]+/g, '-') .replace(/[^\w\-぀-ヿ一-鿿]/g, '') .replace(/-+/g, '-') .replace(/^-+|-+$/g, ''); return cleaned || 'section'; } function buildSlugger() { const counts = new Map(); return (text: string): string => { const base = slugify(text); const n = counts.get(base) ?? 0; counts.set(base, n + 1); return n === 0 ? base : `${base}-${n}`; }; } function extractOutline(content: string, parser: Marked): OutlineEntry[] { const tokens = parser.lexer(content); const headings: OutlineEntry[] = []; const slugger = buildSlugger(); for (const t of tokens) { if (t.type === 'heading') { const depth = (t as { depth?: number }).depth; const text = (t as { text?: string }).text ?? ''; if (depth === 1 || depth === 2 || depth === 3) { headings.push({ depth, text, slug: slugger(text) }); } } } return headings; } interface FilePreviewProps { name: string; content: string; imageSrc: string; /** Markdown 内の相対パス画像を解決するためのベース URL (省略可) */ markdownImageBaseUrl?: string; onClose: () => void; taskId?: number; section?: string; filePath?: string; editable?: boolean; } // --- CSV --- function renderCsv(csv: string) { const rows = csv.trim().split(/\r?\n/).map(r => r.split(',')); if (rows.length === 0) return null; return (
{rows.slice(0, 120).map((r, i) => ( {r.slice(0, 20).map((c, j) => ( ))} ))}
{c}
); } // --- Markdown (marked) --- function escapeHtml(s: string): string { return s.replace(/&/g, '&').replace(//g, '>'); } function buildMdRenderer(opts: { imageBaseUrl?: string; slugger?: (text: string) => string }): Renderer { const { imageBaseUrl, slugger } = opts; const renderer = new Renderer(); renderer.link = function ({ href, title, text }: { href: string; title?: string | null; text: string }) { const titleAttr = title ? ` title="${title}"` : ''; // Markdown link whose destination is an `output/...` workspace // path: route the click through OutputPreviewProvider's // delegation instead of opening a new tab. Visible text is the // markdown label. const isOutputHref = OUTPUT_PATH_REGEX.test(href); OUTPUT_PATH_REGEX.lastIndex = 0; if (isOutputHref) { return `${text}`; } return `${text}`; }; // Bare `output/...` paths in paragraph / list / blockquote text. // Marked passes pre-escaped HTML strings here, so the linkifier // runs on safe content. // // CRITICAL: when the text token has nested inline children (strong, // em, codespan, link), `tokens` is set and we MUST defer to // parser.parseInline. Otherwise the inline formatting is silently // dropped — `- **bold** \`code\`` would render as literal `**bold** // \`code\`` instead of formatted. renderer.text = function ({ tokens, text }: { tokens?: unknown[]; text: string }) { if (tokens && tokens.length > 0) { const self = this as unknown as { parser: { parseInline(tokens: unknown[]): string } }; return self.parser.parseInline(tokens); } return linkifyOutputPathsInEscapedHtml(text); }; // Inline `output/foo.md` in single-backtick spans. Fenced code // blocks go through `renderer.code` (below) which deliberately // doesn't linkify — fenced code is usually a copy-paste sample, // not a real reference. renderer.codespan = function ({ text }: { text: string }) { return `${linkifyOutputPathsInEscapedHtml(text)}`; }; renderer.code = function ({ text, lang }: { text: string; lang?: string }) { if (lang === 'mermaid') { return `
${escapeHtml(text)}
`; } let highlighted: string; try { if (lang && hljs.getLanguage(lang)) { highlighted = hljs.highlight(text, { language: lang, ignoreIllegals: true }).value; } else { highlighted = hljs.highlightAuto(text).value; } } catch { highlighted = escapeHtml(text); } const langLabel = lang ? `${escapeHtml(lang)}` : ''; const langClass = lang ? `language-${escapeHtml(lang)}` : ''; return `
${langLabel}${highlighted}
`; }; if (slugger) { renderer.heading = function ({ tokens, depth, text }: { tokens: unknown[]; depth: number; text: string }) { const self = this as unknown as { parser: { parseInline(tokens: unknown[]): string } }; const inner = self.parser.parseInline(tokens); if (depth > 3) { return `${inner}`; } const slug = slugger(text); return `${inner}`; }; } if (imageBaseUrl) { renderer.image = function ({ href, title, text }: { href: string; title?: string | null; text: string }) { let resolvedHref = href; if (href && !href.startsWith('http://') && !href.startsWith('https://') && !href.startsWith('data:')) { const cleanPath = href.replace(/^\.\//, ''); resolvedHref = `${imageBaseUrl}${encodeURIComponent(cleanPath)}`; } const titleAttr = title ? ` title="${title}"` : ''; return `${text}`; }; } return renderer; } /** embed マーカーでコンテンツを分割する */ const EMBED_SPLIT_RE = /\[\[embed:([\w-]+)\]\]/g; interface ContentSegment { type: 'markdown' | 'embed'; value: string; // markdown: テキスト, embed: refId } function splitContentByEmbeds(content: string): ContentSegment[] { const segments: ContentSegment[] = []; let lastIndex = 0; let match: RegExpExecArray | null; while ((match = EMBED_SPLIT_RE.exec(content)) !== null) { if (match.index > lastIndex) { segments.push({ type: 'markdown', value: content.slice(lastIndex, match.index) }); } segments.push({ type: 'embed', value: match[1] }); lastIndex = match.index + match[0].length; } if (lastIndex < content.length) { segments.push({ type: 'markdown', value: content.slice(lastIndex) }); } return segments; } /** 単一の Markdown 断片をレンダリングする内部コンポーネント */ function MarkdownSegment({ html }: { html: string }): JSX.Element { const ref = useRef(null); useEffect(() => { if (ref.current) { mermaid.run({ nodes: ref.current.querySelectorAll('.mermaid') }).catch(() => {}); } }, [html]); return
; } const DOMPURIFY_CONFIG = { // `data-output-path`, `role`, `tabindex` added for the output-path // linkifier — defaults already permit `data-*`, but being explicit // guards against future config tightening, same as MarkdownText. ADD_ATTR: ['data-copy', 'aria-hidden', 'aria-label', 'id', 'target', 'rel', 'data-output-path', 'role', 'tabindex'] as string[], }; interface MarkdownPreviewProps { content: string; imageBaseUrl?: string; taskId?: number; /** true で目次サイドバー + リーダースタイル (MDXG) を有効化。チャット吹き出し等では false 推奨。 */ showOutline?: boolean; } export function MarkdownPreview({ content, imageBaseUrl, taskId, showOutline = false }: MarkdownPreviewProps): JSX.Element { const { t } = useTranslation('files'); const truncated = content.slice(0, 100000); const containerRef = useRef(null); const [activeSlug, setActiveSlug] = useState(''); // 目次抽出 (showOutline=true のときのみ) const outline = useMemo(() => { if (!showOutline) return []; try { const parser = new Marked({ gfm: true }); return extractOutline(truncated, parser); } catch { return []; } }, [truncated, showOutline]); // HTML 生成 const segments = useMemo(() => { EMBED_SPLIT_RE.lastIndex = 0; const slugger = showOutline ? buildSlugger() : undefined; const renderer = buildMdRenderer({ imageBaseUrl, slugger }); const parser = new Marked({ gfm: true, renderer }); const hasEmbed = taskId != null && EMBED_SPLIT_RE.test(truncated); EMBED_SPLIT_RE.lastIndex = 0; if (!hasEmbed) { const html = DOMPurify.sanitize(parser.parse(truncated, { async: false }) as string, DOMPURIFY_CONFIG); return [{ type: 'markdown' as const, html }]; } return splitContentByEmbeds(truncated).map(seg => { if (seg.type === 'embed') return { type: 'embed' as const, refId: seg.value }; const html = DOMPurify.sanitize(parser.parse(seg.value, { async: false }) as string, DOMPURIFY_CONFIG); return { type: 'markdown' as const, html }; }); }, [truncated, imageBaseUrl, taskId, showOutline]); // コピーボタン + アンカーリンクのイベント delegation useEffect(() => { const root = containerRef.current; if (!root) return; const handler = (e: MouseEvent) => { const target = e.target as HTMLElement; const btn = target.closest('button.mdxg-copy'); if (btn) { const pre = btn.closest('pre'); const code = pre?.querySelector('code'); if (code) { navigator.clipboard.writeText(code.textContent ?? '').then(() => { btn.classList.add('copied'); const original = btn.textContent; btn.textContent = '✓'; setTimeout(() => { btn.classList.remove('copied'); btn.textContent = original ?? 'copy'; }, 1200); }).catch(() => {}); } return; } const anchor = target.closest('a.mdxg-anchor'); if (anchor) { e.preventDefault(); const id = anchor.getAttribute('href')?.slice(1); if (id) { root.querySelector(`#${CSS.escape(id)}`)?.scrollIntoView({ behavior: 'smooth', block: 'start' }); } } }; root.addEventListener('click', handler); return () => root.removeEventListener('click', handler); }, [segments]); // scroll-spy: 表示中の見出しを active に useEffect(() => { if (!showOutline || outline.length === 0) return; const root = containerRef.current; if (!root) return; const targets = Array.from(root.querySelectorAll('h1[id], h2[id], h3[id]')); if (targets.length === 0) return; const observer = new IntersectionObserver( entries => { const visible = entries.filter(e => e.isIntersecting); if (visible.length > 0) { visible.sort((a, b) => a.boundingClientRect.top - b.boundingClientRect.top); setActiveSlug(visible[0].target.id); } }, { rootMargin: '0px 0px -70% 0px', threshold: 0 } ); targets.forEach(t => observer.observe(t)); if (!activeSlug && targets[0]) setActiveSlug(targets[0].id); return () => observer.disconnect(); }, [segments, showOutline, outline.length]); const handleOutlineClick = (slug: string) => (e: React.MouseEvent) => { e.preventDefault(); const root = containerRef.current; root?.querySelector(`#${CSS.escape(slug)}`)?.scrollIntoView({ behavior: 'smooth', block: 'start' }); }; const content_el = (
{segments.map((seg, i) => { if (seg.type === 'embed') { return ; } return ; })}
); if (!showOutline || outline.length < 2) { return content_el; } return (
{content_el}
); } // --- Print / PDF helpers --- const PRINT_STYLE = ` body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "Hiragino Kaku Gothic ProN", "Yu Gothic", Meiryo, sans-serif; color: #1e293b; line-height: 1.7; max-width: 760px; margin: 32px auto; padding: 0 24px; font-size: 14px; -webkit-print-color-adjust: exact; print-color-adjust: exact; } h1, h2, h3, h4, h5, h6 { color: #0f172a; font-weight: 600; margin-top: 1.6em; margin-bottom: 0.5em; line-height: 1.3; } h1 { font-size: 1.9em; border-bottom: 1px solid #e2e8f0; padding-bottom: 0.3em; } h2 { font-size: 1.5em; border-bottom: 1px solid #e2e8f0; padding-bottom: 0.2em; } h3 { font-size: 1.25em; } h4 { font-size: 1.05em; } p, ul, ol, blockquote { margin: 0.7em 0; } ul, ol { padding-left: 1.5em; } li { margin: 0.2em 0; } a { color: #2563eb; text-decoration: none; } a:hover { text-decoration: underline; } a.mdxg-anchor { display: none; } a.output-path-link { color: #2563eb; cursor: text; } code { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; background: #f1f5f9; padding: 0.1em 0.35em; border-radius: 4px; font-size: 0.9em; } pre { background: #f8fafc; color: #0f172a; padding: 14px 16px; border-radius: 6px; overflow-x: auto; font-size: 0.85em; border: 1px solid #e2e8f0; position: relative; white-space: pre-wrap; word-break: break-word; } pre code { background: transparent; color: inherit; padding: 0; font-size: 1em; } pre .mdxg-copy, pre .mdxg-lang { display: none; } table { border-collapse: collapse; width: 100%; margin: 1em 0; font-size: 0.9em; } th, td { border: 1px solid #e2e8f0; padding: 6px 10px; text-align: left; vertical-align: top; } th { background: #f8fafc; font-weight: 600; } blockquote { border-left: 3px solid #cbd5e1; margin: 1em 0; padding: 0.3em 1em; color: #475569; background: #f8fafc; } img { max-width: 100%; height: auto; display: block; margin: 0.5em 0; } hr { border: 0; border-top: 1px solid #e2e8f0; margin: 1.5em 0; } .mermaid-rendered { margin: 1em 0; text-align: center; } .mermaid-rendered svg { max-width: 100%; height: auto; } .embed-placeholder { border: 1px dashed #cbd5e1; background: #f8fafc; color: #64748b; padding: 8px 12px; border-radius: 6px; margin: 0.7em 0; font-size: 0.85em; } /* hljs minimal light theme */ .hljs-comment, .hljs-quote { color: #6a737d; font-style: italic; } .hljs-keyword, .hljs-selector-tag, .hljs-literal, .hljs-section, .hljs-link { color: #d73a49; } .hljs-function .hljs-keyword { color: #d73a49; } .hljs-subst { color: #24292e; } .hljs-string, .hljs-attr, .hljs-template-tag, .hljs-template-variable { color: #032f62; } .hljs-title, .hljs-name, .hljs-type, .hljs-attribute, .hljs-symbol, .hljs-bullet, .hljs-addition, .hljs-variable, .hljs-template-tag, .hljs-template-variable { color: #6f42c1; } .hljs-number, .hljs-meta { color: #005cc5; } .hljs-built_in, .hljs-builtin-name, .hljs-class .hljs-title { color: #e36209; } .hljs-deletion { color: #b31d28; background: #ffeef0; } .hljs-regexp, .hljs-link { color: #032f62; } @media print { body { margin: 0; padding: 12mm; max-width: 100%; } a { color: inherit; text-decoration: none; } a.output-path-link { color: inherit; } pre { background: #f8fafc !important; border: 1px solid #e2e8f0; } h1, h2, h3, h4, h5, h6 { page-break-after: avoid; break-after: avoid; } pre, table, blockquote, .mermaid-rendered, img { page-break-inside: avoid; break-inside: avoid; } } `; const EMBED_MARKER_RE = /\[\[embed:([\w-]+)\]\]/g; async function buildPrintHtml(content: string, opts: { title: string; imageBaseUrl?: string }): Promise { const truncated = content.slice(0, 100000); const slugger = buildSlugger(); const absoluteImageBaseUrl = opts.imageBaseUrl ? (opts.imageBaseUrl.startsWith('http') ? opts.imageBaseUrl : `${window.location.origin}${opts.imageBaseUrl}`) : undefined; const renderer = buildMdRenderer({ imageBaseUrl: absoluteImageBaseUrl, slugger }); const parser = new Marked({ gfm: true, renderer }); const renderedHtml = DOMPurify.sanitize(parser.parse(truncated, { async: false }) as string, DOMPURIFY_CONFIG); // Replace [[embed:xxx]] markers with placeholders BEFORE parsing into DOM // (marked passes them through as literal text inside paragraphs). EMBED_MARKER_RE.lastIndex = 0; const withEmbedPlaceholders = renderedHtml.replace( EMBED_MARKER_RE, (_, refId) => `
📎 Embedded: ${escapeHtml(String(refId))}
`, ); // Parse to a temporary DOM so we can swap mermaid
 blocks for rendered SVG.
  const doc = new DOMParser().parseFromString(`${withEmbedPlaceholders}`, 'text/html');
  const mermaidBlocks = Array.from(doc.body.querySelectorAll('pre.mermaid'));
  for (let i = 0; i < mermaidBlocks.length; i++) {
    const block = mermaidBlocks[i];
    const source = (block.textContent ?? '').trim();
    if (!source) continue;
    try {
      const id = `mermaid-print-${Date.now()}-${i}`;
      const { svg } = await mermaid.render(id, source);
      const wrapper = doc.createElement('div');
      wrapper.className = 'mermaid-rendered';
      wrapper.innerHTML = svg;
      block.replaceWith(wrapper);
    } catch {
      // Leave the original 
 in place if render fails.
    }
  }

  const bodyHtml = doc.body.innerHTML;
  const safeTitle = escapeHtml(opts.title);

  return `



${safeTitle}



${bodyHtml}


`;
}

// --- JSONL ---
function badgeClass(color: 'green' | 'orange' | 'red' | 'gray'): string {
  if (color === 'green')  return 'bg-green-100 text-green-800 dark:bg-green-500/15 dark:text-green-300';
  if (color === 'orange') return 'bg-amber-100 text-amber-800 dark:bg-amber-500/15 dark:text-amber-300';
  if (color === 'red')    return 'bg-red-100 text-red-800 dark:bg-red-500/15 dark:text-red-300';
  return 'bg-slate-100 text-slate-600';
}

function outcomeColor(value: string): 'green' | 'orange' | 'red' | 'gray' {
  if (value === 'success') return 'green';
  if (value === 'ssrf_blocked' || value === 'pdf_blocked' || value === 'binary_blocked') return 'orange';
  if (value === 'error' || value === 'http_error' || value === 'invalid_url') return 'red';
  return 'gray';
}

function formatCell(key: string, value: unknown): ReactNode {
  if (value === null || value === undefined) return ;

  if (key === 'url' && typeof value === 'string') {
    return (
      
        {value.length > 60 ? `${value.slice(0, 60)}…` : value}
      
    );
  }

  if (key === 'timestamp' && typeof value === 'string') {
    try {
      return new Date(value).toLocaleTimeString('ja-JP', { hour: '2-digit', minute: '2-digit', second: '2-digit' });
    } catch { return String(value); }
  }

  if (key === 'outcome' && typeof value === 'string') {
    const color = outcomeColor(value);
    return {value};
  }

  if (key === 'status') {
    if (typeof value === 'string') {
      const color: 'green' | 'red' | 'gray' = value === 'success' ? 'green' : value === 'error' ? 'red' : 'gray';
      return {value};
    }
    if (typeof value === 'number') {
      const color: 'green' | 'red' | 'gray' = value >= 200 && value < 300 ? 'green' : value >= 400 ? 'red' : 'gray';
      return {String(value)};
    }
  }

  if (key === 'exitCode') {
    const color: 'green' | 'red' = value === 0 ? 'green' : 'red';
    return {String(value)};
  }

  if (Array.isArray(value)) return value.join(' ');

  const str = typeof value === 'string' ? value : JSON.stringify(value);
  return str.length > 80 ? `${str.slice(0, 80)}…` : str;
}

function renderJsonl(content: string): JSX.Element {
  const lines = content.trim().split('\n').filter(Boolean).slice(0, 1000);
  const records: Record[] = [];
  for (const line of lines) {
    try { records.push(JSON.parse(line) as Record); }
    catch { /* skip invalid lines */ }
  }

  if (records.length === 0) {
    return 

{i18n.t('files:records.empty')}

; } const columns = [...new Set(records.flatMap(r => Object.keys(r)))]; return (
{columns.map(col => ( ))} {records.map((record, i) => ( {columns.map(col => ( ))} ))}
{col}
{formatCell(col, record[col])}
); } // --- FilePreview --- export function FilePreview({ name, content, imageSrc, markdownImageBaseUrl, onClose, taskId, section, filePath, editable }: FilePreviewProps) { const { t } = useTranslation('files'); const [mode, setMode] = useState<'view' | 'edit'>('view'); const [editContent, setEditContent] = useState(content); const [saving, setSaving] = useState(false); const [error, setError] = useState(''); const [currentContent, setCurrentContent] = useState(content); const [printing, setPrinting] = useState(false); const backdrop = useBackdropClose(onClose); const canEdit = editable && taskId != null && section && filePath; const isMarkdownFile = /\.(md|markdown)$/i.test(name); const handlePrint = async () => { if (printing) return; setPrinting(true); setError(''); try { const html = await buildPrintHtml(currentContent, { title: name, imageBaseUrl: markdownImageBaseUrl }); const win = window.open('', '_blank'); if (!win) { setError(t('print.popupBlocked')); return; } win.document.open(); win.document.write(html); win.document.close(); } catch (err) { setError(err instanceof Error ? err.message : t('print.prepFailed')); } finally { setPrinting(false); } }; const handleSave = async () => { if (!taskId || !section || !filePath) return; setSaving(true); setError(''); try { await updateLocalFileContent(taskId, section, filePath, editContent); setCurrentContent(editContent); setMode('view'); } catch (err) { setError(err instanceof Error ? err.message : 'Failed to save'); } finally { setSaving(false); } }; const body = (() => { if (mode === 'edit') { return (