feat: add markdown and svg artifact renderers (#73)

* feat: add markdown and svg artifact renderers

* fix: harden markdown preview rendering

* fix: address markdown renderer review follow-ups
This commit is contained in:
Aresdgi 2026-04-30 14:05:00 +02:00 committed by GitHub
parent 132adac3bb
commit f430a68766
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
17 changed files with 712 additions and 2 deletions

View file

@ -35,6 +35,7 @@ const ALLOWED_RENDERERS = new Set([
]);
const ALLOWED_EXPORTS = new Set(['html', 'pdf', 'zip', 'pptx', 'jsx', 'md', 'svg', 'txt']);
const ALLOWED_STATUS = new Set(['streaming', 'complete', 'error']);
function isPlainObject(value) {
if (!value || typeof value !== 'object' || Array.isArray(value)) return false;
@ -98,6 +99,15 @@ export function validateArtifactManifestInput(manifest, entry) {
}
}
if (manifest.status !== undefined) {
if (typeof manifest.status !== 'string') {
return { ok: false, error: 'artifactManifest.status must be a string' };
}
if (!ALLOWED_STATUS.has(manifest.status)) {
return { ok: false, error: 'artifactManifest.status is not allowed' };
}
}
if (manifest.supportingFiles !== undefined) {
if (!Array.isArray(manifest.supportingFiles)) {
return { ok: false, error: 'artifactManifest.supportingFiles must be an array' };
@ -176,6 +186,7 @@ export function sanitizeManifest(manifest, entry) {
title: manifest.title || entry,
entry,
renderer: manifest.renderer,
status: ALLOWED_STATUS.has(manifest.status) ? manifest.status : 'complete',
exports: manifest.exports,
supportingFiles: Array.isArray(manifest.supportingFiles)
? manifest.supportingFiles.map((x) => x.replace(/\\/g, '/'))
@ -214,9 +225,35 @@ export function inferLegacyManifest(entry) {
title: entry,
entry,
renderer: isDeck ? 'deck-html' : 'html',
status: 'complete',
exports: isDeck ? ['html', 'pdf', 'pptx', 'zip'] : ['html', 'pdf', 'zip'],
metadata: { inferred: true },
};
}
if (ext === '.md') {
return {
version: MANIFEST_VERSION,
kind: 'markdown-document',
title: entry,
entry,
renderer: 'markdown',
status: 'complete',
exports: ['md', 'html', 'pdf', 'zip'],
metadata: { inferred: true },
};
}
if (ext === '.svg') {
return {
version: MANIFEST_VERSION,
kind: 'svg',
title: entry,
entry,
renderer: 'svg',
status: 'complete',
exports: ['svg', 'zip'],
metadata: { inferred: true },
};
}
return null;
}

View file

@ -1,6 +1,6 @@
import { describe, expect, it } from 'vitest';
import { validateArtifactManifestInput } from '../src/artifact-manifest.js';
import { inferLegacyManifest, validateArtifactManifestInput } from '../src/artifact-manifest.js';
function validBase() {
return {
@ -45,4 +45,34 @@ describe('validateArtifactManifestInput', () => {
);
expect(res.ok).toBe(false);
});
it('defaults status to complete when missing', () => {
const res = validateArtifactManifestInput(validBase(), 'index.html');
expect(res.ok).toBe(true);
if (res.ok) expect(res.value?.status).toBe('complete');
});
it('preserves valid status values', () => {
const res = validateArtifactManifestInput({ ...validBase(), status: 'streaming' }, 'index.html');
expect(res.ok).toBe(true);
if (res.ok) expect(res.value?.status).toBe('streaming');
});
});
describe('inferLegacyManifest', () => {
it('infers markdown manifest for .md files', () => {
const out = inferLegacyManifest('README.md');
expect(out?.kind).toBe('markdown-document');
expect(out?.renderer).toBe('markdown');
expect(out?.status).toBe('complete');
expect(out?.exports).toEqual(['md', 'html', 'pdf', 'zip']);
});
it('infers svg manifest for .svg files', () => {
const out = inferLegacyManifest('logo.svg');
expect(out?.kind).toBe('svg');
expect(out?.renderer).toBe('svg');
expect(out?.status).toBe('complete');
expect(out?.exports).toEqual(['svg', 'zip']);
});
});

View file

@ -27,9 +27,50 @@ describe('parseArtifactManifest', () => {
});
expect(parseArtifactManifest(raw)).toBeNull();
});
it('defaults status to complete when missing', () => {
const raw = JSON.stringify({
version: 1,
kind: 'html',
title: 'x',
entry: 'index.html',
renderer: 'html',
exports: ['html'],
});
const out = parseArtifactManifest(raw);
expect(out?.status).toBe('complete');
});
it('preserves valid status when provided', () => {
const raw = JSON.stringify({
version: 1,
kind: 'html',
title: 'x',
entry: 'index.html',
renderer: 'html',
status: 'streaming',
exports: ['html'],
});
const out = parseArtifactManifest(raw);
expect(out?.status).toBe('streaming');
});
});
describe('inferLegacyManifest', () => {
it('infers markdown manifests for .md files', () => {
const out = inferLegacyManifest({ entry: 'README.md' });
expect(out?.kind).toBe('markdown-document');
expect(out?.renderer).toBe('markdown');
expect(out?.status).toBe('complete');
});
it('infers svg manifests for .svg files', () => {
const out = inferLegacyManifest({ entry: 'logo.svg' });
expect(out?.kind).toBe('svg');
expect(out?.renderer).toBe('svg');
expect(out?.status).toBe('complete');
});
it('returns null for non-artifact file types', () => {
expect(inferLegacyManifest({ entry: 'photo.png' })).toBeNull();
expect(inferLegacyManifest({ entry: 'archive.bin' })).toBeNull();
@ -56,6 +97,7 @@ describe('createHtmlArtifactManifest', () => {
expect(out.version).toBe(1);
expect(out.kind).toBe('html');
expect(out.renderer).toBe('html');
expect(out.status).toBe('complete');
expect(out.exports).toEqual(['html', 'pdf', 'zip']);
expect(out.entry).toBe('index.html');
expect(out.title).toBe('Landing');

View file

@ -3,6 +3,7 @@ import type {
ArtifactKind,
ArtifactManifest,
ArtifactRendererId,
ArtifactStatus,
} from './types';
const MANIFEST_VERSION = 1;
@ -38,6 +39,7 @@ const ALLOWED_EXPORTS: ReadonlySet<ArtifactExportKind> = new Set([
'svg',
'txt',
]);
const ALLOWED_STATUS: ReadonlySet<ArtifactStatus> = new Set(['streaming', 'complete', 'error']);
function normalizeExt(name: string): string {
const i = name.lastIndexOf('.');
@ -81,6 +83,7 @@ export function createHtmlArtifactManifest(input: {
title: input.title,
entry: input.entry,
renderer: 'html',
status: 'complete',
exports: ['html', 'pdf', 'zip'],
createdAt: now,
updatedAt: now,
@ -106,6 +109,9 @@ export function parseArtifactManifest(raw: string): ArtifactManifest | null {
}
if (!ALLOWED_KINDS.has(parsed.kind as ArtifactKind)) return null;
if (!ALLOWED_RENDERERS.has(parsed.renderer as ArtifactRendererId)) return null;
if (parsed.status !== undefined && !ALLOWED_STATUS.has(parsed.status as ArtifactStatus)) {
return null;
}
if (parsed.exports.length === 0) return null;
if (parsed.exports.some((value) => !ALLOWED_EXPORTS.has(value as ArtifactExportKind))) return null;
return {
@ -114,6 +120,9 @@ export function parseArtifactManifest(raw: string): ArtifactManifest | null {
title: parsed.title,
entry: parsed.entry,
renderer: parsed.renderer as ArtifactRendererId,
status: ALLOWED_STATUS.has(parsed.status as ArtifactStatus)
? (parsed.status as ArtifactStatus)
: 'complete',
exports: parsed.exports as ArtifactExportKind[],
supportingFiles: Array.isArray(parsed.supportingFiles)
? parsed.supportingFiles.filter((x): x is string => typeof x === 'string')
@ -167,6 +176,7 @@ export function inferLegacyManifest(input: {
title: input.title || input.entry,
entry: input.entry,
renderer,
status: 'complete',
exports: exportsForKind(resolvedKind),
metadata: input.metadata,
};

View file

@ -0,0 +1,71 @@
import { describe, expect, it } from 'vitest';
import { renderMarkdownToSafeHtml } from './markdown';
describe('renderMarkdownToSafeHtml', () => {
it('renders common markdown blocks', () => {
const md = [
'# Title',
'',
'Paragraph with **bold** and *italic* and `code`.',
'',
'- one',
'- two',
'',
'1. first',
'2. second',
'',
'> note line',
'',
'```',
'const x = 1 < 2;',
'```',
].join('\n');
const out = renderMarkdownToSafeHtml(md);
expect(out).toContain('<h1>Title</h1>');
expect(out).toContain('<p>Paragraph with <strong>bold</strong> and <em>italic</em> and <code>code</code>.</p>');
expect(out).toContain('<ul><li>one</li><li>two</li></ul>');
expect(out).toContain('<ol><li>first</li><li>second</li></ol>');
expect(out).toContain('<blockquote>note line</blockquote>');
expect(out).toContain('<pre><code>const x = 1 &lt; 2;</code></pre>');
});
it('escapes raw html', () => {
const out = renderMarkdownToSafeHtml('<script>alert(1)</script>');
expect(out).toContain('&lt;script&gt;alert(1)&lt;/script&gt;');
expect(out).not.toContain('<script>');
});
it('renders safe links with target attributes', () => {
const out = renderMarkdownToSafeHtml('[Open](https://example.com)');
expect(out).toContain('<a href="https://example.com" rel="noreferrer noopener" target="_blank">Open</a>');
});
it('keeps underscores inside href intact', () => {
const out = renderMarkdownToSafeHtml('[x](https://example.com/a_b_c)');
expect(out).toContain('<a href="https://example.com/a_b_c" rel="noreferrer noopener" target="_blank">x</a>');
expect(out).not.toContain('<em>b</em>');
});
it('escapes raw html inside link text', () => {
const out = renderMarkdownToSafeHtml('[<img src=x onerror=alert(1)>](https://example.com)');
expect(out).toContain('&lt;img src=x onerror=alert(1)&gt;');
expect(out).not.toContain('<img ');
});
it('keeps markdown emphasis markers literal inside inline code', () => {
const out = renderMarkdownToSafeHtml('Use `**literal**` and `_literal_` as code.');
expect(out).toContain('<code>**literal**</code>');
expect(out).toContain('<code>_literal_</code>');
expect(out).not.toContain('<code><strong>literal</strong></code>');
expect(out).not.toContain('<code><em>literal</em></code>');
});
it('does not render unsafe link protocols', () => {
const out = renderMarkdownToSafeHtml('[Bad](javascript:alert(1))');
expect(out).toContain('<p>Bad)</p>');
expect(out).not.toContain('javascript:');
expect(out).not.toContain('<a ');
});
});

View file

@ -0,0 +1,162 @@
function escapeHtml(value: string): string {
return value
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
}
const LINK_TOKEN_PREFIX = 'ODMDLINKTOKEN';
const CODE_TOKEN_PREFIX = 'ODMDCODETOKEN';
function formatInline(raw: string): string {
const linkTokens = new Map<string, string>();
const codeTokens = new Map<string, string>();
let linkTokenIndex = 0;
let codeTokenIndex = 0;
const withCodeTokens = raw.replace(/`([^`]+)`/g, (_m, code: string) => {
const token = `${CODE_TOKEN_PREFIX}${codeTokenIndex++}X`;
codeTokens.set(token, `<code>${escapeHtml(code)}</code>`);
return token;
});
const withLinkTokens = withCodeTokens.replace(/\[([^\]]+)\]\(([^)\s]+)\)/g, (_m, text: string, href: string) => {
const normalizedHref = normalizeSafeHref(href);
const safeText = escapeHtml(text);
if (!normalizedHref) return safeText;
const safeHref = escapeHtml(normalizedHref);
const rel = safeHref.startsWith('#') ? '' : ' rel="noreferrer noopener" target="_blank"';
const token = `${LINK_TOKEN_PREFIX}${linkTokenIndex++}X`;
linkTokens.set(token, `<a href="${safeHref}"${rel}>${safeText}</a>`);
return token;
});
let out = escapeHtml(withLinkTokens);
out = out.replace(/\*\*([^*]+)\*\*/g, '<strong>$1</strong>');
out = out.replace(/__([^_]+)__/g, '<strong>$1</strong>');
out = out.replace(/\*([^*]+)\*/g, '<em>$1</em>');
out = out.replace(/_([^_]+)_/g, '<em>$1</em>');
out = out.replace(/ODMDCODETOKEN\d+X/g, (token) => codeTokens.get(token) ?? token);
out = out.replace(/ODMDLINKTOKEN\d+X/g, (token) => linkTokens.get(token) ?? token);
return out;
}
function normalizeSafeHref(href: string): string | null {
const decoded = href.replace(/&amp;/g, '&').replace(/&quot;/g, '"').replace(/&#39;/g, "'");
if (
decoded.startsWith('#') ||
decoded.startsWith('/') ||
decoded.startsWith('./') ||
decoded.startsWith('../') ||
/^https?:\/\//i.test(decoded) ||
/^mailto:/i.test(decoded)
) {
return decoded;
}
return null;
}
function headingLevel(line: string): number {
const m = /^(#{1,6})\s+/.exec(line);
return m?.[1]?.length ?? 0;
}
export function renderMarkdownToSafeHtml(markdown: string): string {
// Intentionally small markdown subset for conservative preview rendering.
// Supported: headings, paragraphs, blockquotes, ul/ol lists, fenced code,
// inline code, bold/italic, and links.
// Not supported on purpose: full CommonMark edge cases (nested lists,
// escaped markdown syntax, raw HTML blocks, tables, etc.).
const lines = markdown.replace(/\r\n/g, '\n').split('\n');
const out: string[] = [];
let i = 0;
while (i < lines.length) {
const line = lines[i];
if (line === undefined) break;
if (/^\s*$/.test(line)) {
i += 1;
continue;
}
if (/^```/.test(line)) {
i += 1;
const code: string[] = [];
while (i < lines.length) {
const codeLine = lines[i];
if (codeLine === undefined || /^```/.test(codeLine)) break;
code.push(codeLine);
i += 1;
}
if (i < lines.length) i += 1;
out.push(`<pre><code>${escapeHtml(code.join('\n'))}</code></pre>`);
continue;
}
const h = headingLevel(line);
if (h > 0) {
out.push(`<h${h}>${formatInline(line.replace(/^#{1,6}\s+/, ''))}</h${h}>`);
i += 1;
continue;
}
if (/^>\s?/.test(line)) {
const block: string[] = [];
while (i < lines.length) {
const blockLine = lines[i];
if (blockLine === undefined || !/^>\s?/.test(blockLine)) break;
block.push(blockLine.replace(/^>\s?/, ''));
i += 1;
}
out.push(`<blockquote>${formatInline(block.join(' '))}</blockquote>`);
continue;
}
if (/^\s*[-*]\s+/.test(line)) {
const items: string[] = [];
while (i < lines.length) {
const itemLine = lines[i];
if (itemLine === undefined || !/^\s*[-*]\s+/.test(itemLine)) break;
items.push(`<li>${formatInline(itemLine.replace(/^\s*[-*]\s+/, ''))}</li>`);
i += 1;
}
out.push(`<ul>${items.join('')}</ul>`);
continue;
}
if (/^\s*\d+\.\s+/.test(line)) {
const items: string[] = [];
while (i < lines.length) {
const itemLine = lines[i];
if (itemLine === undefined || !/^\s*\d+\.\s+/.test(itemLine)) break;
items.push(`<li>${formatInline(itemLine.replace(/^\s*\d+\.\s+/, ''))}</li>`);
i += 1;
}
out.push(`<ol>${items.join('')}</ol>`);
continue;
}
const para: string[] = [];
while (i < lines.length) {
const paraLine = lines[i];
if (paraLine === undefined || /^\s*$/.test(paraLine)) break;
if (
/^```/.test(paraLine) ||
headingLevel(paraLine) > 0 ||
/^>\s?/.test(paraLine) ||
/^\s*[-*]\s+/.test(paraLine) ||
/^\s*\d+\.\s+/.test(paraLine)
) {
break;
}
para.push(paraLine);
i += 1;
}
out.push(`<p>${formatInline(para.join(' '))}</p>`);
}
return out.join('\n');
}

View file

@ -0,0 +1,123 @@
import { describe, expect, it } from 'vitest';
import {
DeckHtmlRenderer,
HtmlRenderer,
MarkdownRenderer,
RendererRegistry,
SvgRenderer,
} from './renderer-registry';
import { renderMarkdownToSafeHtml } from './markdown';
import type { ProjectFile } from '../types';
function baseFile(overrides: Partial<ProjectFile>): ProjectFile {
return {
name: 'artifact.html',
path: 'artifact.html',
type: 'file',
size: 1,
mtime: Date.now(),
kind: 'html',
mime: 'text/html; charset=utf-8',
...overrides,
};
}
describe('RendererRegistry', () => {
const registry = new RendererRegistry([
DeckHtmlRenderer,
HtmlRenderer,
MarkdownRenderer,
SvgRenderer,
]);
it('resolves markdown renderer from explicit manifest', () => {
const file = baseFile({
name: 'notes.md',
kind: 'text',
mime: 'text/markdown; charset=utf-8',
artifactManifest: {
version: 1,
kind: 'markdown-document',
title: 'Notes',
entry: 'notes.md',
renderer: 'markdown',
exports: ['md', 'html'],
},
});
const match = registry.resolve({ file, isDeckHint: false });
expect(match?.renderer.id).toBe('markdown');
expect(match?.manifest.renderer).toBe('markdown');
});
it('falls back to inferred markdown manifest for .md files', () => {
const file = baseFile({
name: 'README.md',
kind: 'text',
mime: 'text/markdown; charset=utf-8',
artifactManifest: undefined,
});
const match = registry.resolve({ file, isDeckHint: false });
expect(match?.renderer.id).toBe('markdown');
expect(match?.manifest.kind).toBe('markdown-document');
});
it('resolves svg renderer from explicit manifest', () => {
const file = baseFile({
name: 'diagram.svg',
kind: 'sketch',
mime: 'image/svg+xml',
artifactManifest: {
version: 1,
kind: 'svg',
title: 'Diagram',
entry: 'diagram.svg',
renderer: 'svg',
exports: ['svg'],
},
});
const match = registry.resolve({ file, isDeckHint: false });
expect(match?.renderer.id).toBe('svg');
expect(match?.manifest.renderer).toBe('svg');
});
it('falls back to inferred svg manifest for .svg files', () => {
const file = baseFile({
name: 'logo.svg',
kind: 'sketch',
mime: 'image/svg+xml',
artifactManifest: undefined,
});
const match = registry.resolve({ file, isDeckHint: false });
expect(match?.renderer.id).toBe('svg');
expect(match?.manifest.kind).toBe('svg');
});
it('keeps unknown files on old fallback path', () => {
const file = baseFile({
name: 'archive.bin',
kind: 'binary',
mime: 'application/octet-stream',
artifactManifest: undefined,
});
expect(registry.resolve({ file, isDeckHint: false })).toBeNull();
});
it('exposes conservative streaming contract values', () => {
expect(HtmlRenderer.supportsStreaming).toBe(false);
expect(DeckHtmlRenderer.supportsStreaming).toBe(false);
expect(MarkdownRenderer.supportsStreaming).toBe(true);
expect(MarkdownRenderer.renderPartial).toBe(renderMarkdownToSafeHtml);
expect(SvgRenderer.supportsStreaming).toBe(true);
expect(SvgRenderer.renderPartial).toBeUndefined();
});
it('keeps markdown partial renderer output safe', () => {
const out = MarkdownRenderer.renderPartial?.('[<script>alert(1)</script>](https://example.com/a_b_c)') ?? '';
expect(out).toContain('&lt;script&gt;alert(1)&lt;/script&gt;');
expect(out).toContain('href="https://example.com/a_b_c"');
expect(out).not.toContain('<script>');
});
});

View file

@ -1,4 +1,5 @@
import { inferLegacyManifest } from './manifest';
import { renderMarkdownToSafeHtml } from './markdown';
import type { ArtifactManifest, ArtifactRendererId } from './types';
import type { ProjectFile } from '../types';
@ -9,6 +10,15 @@ export interface ArtifactRendererContext {
export interface ArtifactRenderer {
id: ArtifactRendererId;
/**
* Whether this renderer can receive partial content during streaming.
* - true + renderPartial defined renderer produces useful intermediate output
* - true without renderPartial renderer tolerates partial content but
* should be considered visually meaningful only when status === "complete"
* - false consumer should show skeleton/loading state until status === "complete"
*/
supportsStreaming: boolean;
renderPartial?: (content: string) => string;
canRender: (ctx: ArtifactRendererContext) => boolean;
}
@ -23,6 +33,7 @@ function resolveManifest(file: ProjectFile): ArtifactManifest | null {
export const HtmlRenderer: ArtifactRenderer = {
id: 'html',
supportsStreaming: false,
canRender: ({ file, isDeckHint }) => {
const manifest = resolveManifest(file);
if (!manifest) return false;
@ -34,6 +45,7 @@ export const HtmlRenderer: ArtifactRenderer = {
export const DeckHtmlRenderer: ArtifactRenderer = {
id: 'deck-html',
supportsStreaming: false,
canRender: ({ file, isDeckHint }) => {
const manifest = resolveManifest(file);
if (!manifest) return false;
@ -42,6 +54,29 @@ export const DeckHtmlRenderer: ArtifactRenderer = {
},
};
export const MarkdownRenderer: ArtifactRenderer = {
id: 'markdown',
supportsStreaming: true,
renderPartial: renderMarkdownToSafeHtml,
canRender: ({ file }) => {
const manifest = resolveManifest(file);
if (!manifest) return false;
if (manifest.renderer === 'markdown' || manifest.kind === 'markdown-document') return true;
return file.kind === 'text' && /\.md$/i.test(file.name);
},
};
export const SvgRenderer: ArtifactRenderer = {
id: 'svg',
supportsStreaming: true,
canRender: ({ file }) => {
const manifest = resolveManifest(file);
if (!manifest) return false;
if (manifest.renderer === 'svg' || manifest.kind === 'svg') return true;
return (file.kind === 'image' || file.kind === 'sketch') && /\.svg$/i.test(file.name);
},
};
export class RendererRegistry {
constructor(private readonly renderers: ArtifactRenderer[]) {}
@ -57,4 +92,6 @@ export class RendererRegistry {
export const artifactRendererRegistry = new RendererRegistry([
DeckHtmlRenderer,
HtmlRenderer,
MarkdownRenderer,
SvgRenderer,
]);

View file

@ -30,12 +30,17 @@ export type ArtifactExportKind =
| 'svg'
| 'txt';
export type ArtifactStatus = 'streaming' | 'complete' | 'error';
export interface ArtifactManifest {
version: 1;
kind: ArtifactKind;
title: string;
entry: string;
renderer: ArtifactRendererId;
// Optional for backward compatibility with older manifests.
// Frontend + daemon normalize missing status to "complete".
status?: ArtifactStatus;
exports: ArtifactExportKind[];
/**
* Reserved for future multi-file artifact packaging.

View file

@ -1,5 +1,6 @@
import { useEffect, useMemo, useRef, useState } from 'react';
import { artifactRendererRegistry } from '../artifacts/renderer-registry';
import { MarkdownRenderer, artifactRendererRegistry } from '../artifacts/renderer-registry';
import { renderMarkdownToSafeHtml } from '../artifacts/markdown';
import { useT } from '../i18n';
import type { Dict } from '../i18n/types';
import {
@ -51,6 +52,12 @@ export function FileViewer({
/>
);
}
if (rendererMatch?.renderer.id === 'markdown') {
return <MarkdownViewer projectId={projectId} file={file} />;
}
if (rendererMatch?.renderer.id === 'svg') {
return <ImageViewer projectId={projectId} file={file} />;
}
if (file.kind === 'image') {
return <ImageViewer projectId={projectId} file={file} />;
}
@ -878,6 +885,108 @@ function TextViewer({
);
}
function MarkdownViewer({
projectId,
file,
}: {
projectId: string;
file: ProjectFile;
}) {
const t = useT();
const [text, setText] = useState<string | null>(null);
const [reloadKey, setReloadKey] = useState(0);
const [copied, setCopied] = useState(false);
const status = file.artifactManifest?.status ?? 'complete';
const isStreaming = status === 'streaming';
const isError = status === 'error';
useEffect(() => {
setText(null);
let cancelled = false;
void fetchProjectFileText(projectId, file.name).then((next) => {
if (!cancelled) setText(next ?? '');
});
return () => {
cancelled = true;
};
}, [projectId, file.name, file.mtime, reloadKey]);
async function copy() {
if (text == null) return;
try {
await navigator.clipboard.writeText(text);
setCopied(true);
window.setTimeout(() => setCopied(false), 1500);
} catch {
const ta = document.createElement('textarea');
ta.value = text;
ta.style.position = 'fixed';
ta.style.opacity = '0';
document.body.appendChild(ta);
ta.select();
try {
document.execCommand('copy');
setCopied(true);
window.setTimeout(() => setCopied(false), 1500);
} finally {
document.body.removeChild(ta);
}
}
}
const html = useMemo(() => {
if (text === null) return null;
const renderPartial = MarkdownRenderer.renderPartial ?? renderMarkdownToSafeHtml;
return renderPartial(text);
}, [text]);
return (
<div className="viewer text-viewer">
<div className="viewer-toolbar">
<div className="viewer-toolbar-left">
{isStreaming ? <span className="viewer-meta">{t('fileViewer.markdownStreamingMeta')}</span> : null}
{isError ? <span className="viewer-meta">{t('fileViewer.markdownErrorMeta')}</span> : null}
</div>
<div className="viewer-toolbar-actions">
<button
type="button"
className="viewer-action"
onClick={() => setReloadKey((n) => n + 1)}
title={t('fileViewer.reloadDisk')}
>
<Icon name="reload" size={13} />
<span>{t('fileViewer.reload')}</span>
</button>
<button
type="button"
className="viewer-action"
onClick={() => void copy()}
title={t('fileViewer.copyTitle')}
>
<Icon name={copied ? 'check' : 'copy'} size={13} />
<span>{copied ? t('fileViewer.copied') : t('fileViewer.copy')}</span>
</button>
</div>
</div>
<div className="viewer-body">
{html === null ? (
<div className="viewer-empty">{t('fileViewer.loading')}</div>
) : (
<>
{isStreaming ? <div className="markdown-status">{t('fileViewer.markdownStreamingStatus')}</div> : null}
{isError ? <div className="markdown-status markdown-status-error">{t('fileViewer.markdownErrorStatus')}</div> : null}
{/* Safe by contract: renderMarkdownToSafeHtml escapes raw HTML and rejects unsafe link protocols. */}
<article
className="markdown-rendered"
dangerouslySetInnerHTML={{ __html: html }}
/>
</>
)}
</div>
</div>
);
}
function CodeWithLines({ text }: { text: string }) {
const lines = text.split('\n');
// Trailing newline produces a phantom empty line — keep gutter aligned.

View file

@ -374,6 +374,10 @@ export const en: Dict = {
'fileViewer.open': 'Open',
'fileViewer.imageMeta': 'Image · {size}',
'fileViewer.sketchMeta': 'Sketch · {size}',
'fileViewer.markdownStreamingMeta': 'Streaming preview…',
'fileViewer.markdownErrorMeta': 'Preview may be incomplete (generation error).',
'fileViewer.markdownStreamingStatus': 'Streaming… showing partial markdown.',
'fileViewer.markdownErrorStatus': 'Generation error. Showing last available content.',
'fileViewer.reload': 'Reload',
'fileViewer.reloadDisk': 'Reload from disk',
'fileViewer.copy': 'Copy',

View file

@ -374,6 +374,10 @@ export const ptBR: Dict = {
'fileViewer.open': 'Abrir',
'fileViewer.imageMeta': 'Imagem · {size}',
'fileViewer.sketchMeta': 'Esboço · {size}',
'fileViewer.markdownStreamingMeta': 'Prévia em streaming…',
'fileViewer.markdownErrorMeta': 'A prévia pode estar incompleta (erro de geração).',
'fileViewer.markdownStreamingStatus': 'Streaming… mostrando markdown parcial.',
'fileViewer.markdownErrorStatus': 'Erro de geração. Mostrando o último conteúdo disponível.',
'fileViewer.reload': 'Recarregar',
'fileViewer.reloadDisk': 'Recarregar do disco',
'fileViewer.copy': 'Copiar',

View file

@ -365,6 +365,10 @@ export const zhCN: Dict = {
'fileViewer.open': '打开',
'fileViewer.imageMeta': '图片 · {size}',
'fileViewer.sketchMeta': '草图 · {size}',
'fileViewer.markdownStreamingMeta': '正在流式预览…',
'fileViewer.markdownErrorMeta': '预览可能不完整(生成错误)。',
'fileViewer.markdownStreamingStatus': '正在流式生成…显示部分 Markdown。',
'fileViewer.markdownErrorStatus': '生成错误。正在显示最后可用内容。',
'fileViewer.reload': '重新加载',
'fileViewer.reloadDisk': '从磁盘重新加载',
'fileViewer.copy': '复制',

View file

@ -365,6 +365,10 @@ export const zhTW: Dict = {
'fileViewer.open': '開啟',
'fileViewer.imageMeta': '圖片 · {size}',
'fileViewer.sketchMeta': '草圖 · {size}',
'fileViewer.markdownStreamingMeta': '正在串流預覽…',
'fileViewer.markdownErrorMeta': '預覽可能不完整(產生錯誤)。',
'fileViewer.markdownStreamingStatus': '正在串流產生…顯示部分 Markdown。',
'fileViewer.markdownErrorStatus': '產生錯誤。正在顯示最後可用內容。',
'fileViewer.reload': '重新載入',
'fileViewer.reloadDisk': '從磁碟重新載入',
'fileViewer.copy': '複製',

View file

@ -384,6 +384,10 @@ export interface Dict {
'fileViewer.open': string;
'fileViewer.imageMeta': string;
'fileViewer.sketchMeta': string;
'fileViewer.markdownStreamingMeta': string;
'fileViewer.markdownErrorMeta': string;
'fileViewer.markdownStreamingStatus': string;
'fileViewer.markdownErrorStatus': string;
'fileViewer.reload': string;
'fileViewer.reloadDisk': string;
'fileViewer.copy': string;

View file

@ -2761,6 +2761,63 @@ code {
line-height: 1.65;
white-space: pre-wrap;
}
.markdown-rendered {
max-width: 900px;
margin: 0 auto;
padding: 24px 28px 40px;
color: var(--text);
line-height: 1.65;
white-space: normal;
}
.markdown-status {
margin: 12px auto 0;
max-width: 900px;
padding: 8px 10px;
border: 1px solid var(--border-soft);
border-radius: 8px;
background: var(--bg-panel);
color: var(--text-muted);
font-size: 12px;
}
.markdown-status-error {
border-color: color-mix(in oklab, var(--danger, #d04b4b) 45%, var(--border-soft));
color: var(--danger, #d04b4b);
}
.markdown-rendered h1,
.markdown-rendered h2,
.markdown-rendered h3,
.markdown-rendered h4,
.markdown-rendered h5,
.markdown-rendered h6 {
margin: 20px 0 10px;
line-height: 1.25;
}
.markdown-rendered p { margin: 10px 0; }
.markdown-rendered ul,
.markdown-rendered ol {
margin: 10px 0;
padding-left: 24px;
}
.markdown-rendered blockquote {
margin: 12px 0;
padding: 8px 12px;
border-left: 3px solid var(--border);
color: var(--text-muted);
background: var(--bg-panel);
}
.markdown-rendered pre {
margin: 12px 0;
background: var(--bg-panel);
border: 1px solid var(--border-soft);
border-radius: 8px;
padding: 12px;
overflow: auto;
}
.markdown-rendered code {
font-family: var(--mono);
font-size: 12px;
}
.markdown-rendered a { color: var(--accent); }
.image-body {
display: flex;
align-items: center;

View file

@ -24,12 +24,19 @@ export type ArtifactRendererId =
export type ArtifactExportKind = 'html' | 'pdf' | 'zip' | 'pptx' | 'jsx' | 'md' | 'svg' | 'txt';
export type ArtifactStatus = 'streaming' | 'complete' | 'error';
export interface ArtifactManifest {
version: 1;
kind: ArtifactKind;
title: string;
entry: string;
renderer: ArtifactRendererId;
/**
* Optional for backward compatibility with pre-streaming artifacts.
* Daemon/web manifest normalization defaults missing values to "complete".
*/
status?: ArtifactStatus;
exports: ArtifactExportKind[];
supportingFiles?: string[];
createdAt?: string;