mirror of
https://github.com/nexu-io/open-design.git
synced 2026-05-31 19:04:39 +07:00
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:
parent
132adac3bb
commit
f430a68766
17 changed files with 712 additions and 2 deletions
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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']);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
|
|
|
|||
71
apps/web/src/artifacts/markdown.test.ts
Normal file
71
apps/web/src/artifacts/markdown.test.ts
Normal 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 < 2;</code></pre>');
|
||||
});
|
||||
|
||||
it('escapes raw html', () => {
|
||||
const out = renderMarkdownToSafeHtml('<script>alert(1)</script>');
|
||||
expect(out).toContain('<script>alert(1)</script>');
|
||||
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('<img src=x onerror=alert(1)>');
|
||||
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 ');
|
||||
});
|
||||
});
|
||||
162
apps/web/src/artifacts/markdown.ts
Normal file
162
apps/web/src/artifacts/markdown.ts
Normal file
|
|
@ -0,0 +1,162 @@
|
|||
function escapeHtml(value: string): string {
|
||||
return value
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''');
|
||||
}
|
||||
|
||||
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(/&/g, '&').replace(/"/g, '"').replace(/'/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');
|
||||
}
|
||||
123
apps/web/src/artifacts/renderer-registry.test.ts
Normal file
123
apps/web/src/artifacts/renderer-registry.test.ts
Normal 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('<script>alert(1)</script>');
|
||||
expect(out).toContain('href="https://example.com/a_b_c"');
|
||||
expect(out).not.toContain('<script>');
|
||||
});
|
||||
});
|
||||
|
|
@ -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,
|
||||
]);
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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': '复制',
|
||||
|
|
|
|||
|
|
@ -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': '複製',
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
Loading…
Reference in a new issue