From f430a6876694efd45506eeed1edea3dd5d4392c1 Mon Sep 17 00:00:00 2001 From: Aresdgi <115067719+Aresdgi@users.noreply.github.com> Date: Thu, 30 Apr 2026 14:05:00 +0200 Subject: [PATCH] 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 --- apps/daemon/src/artifact-manifest.ts | 37 ++++ apps/daemon/tests/artifact-manifest.test.ts | 32 +++- apps/web/src/artifacts/manifest.test.ts | 42 +++++ apps/web/src/artifacts/manifest.ts | 10 ++ apps/web/src/artifacts/markdown.test.ts | 71 ++++++++ apps/web/src/artifacts/markdown.ts | 162 ++++++++++++++++++ .../src/artifacts/renderer-registry.test.ts | 123 +++++++++++++ apps/web/src/artifacts/renderer-registry.ts | 37 ++++ apps/web/src/artifacts/types.ts | 5 + apps/web/src/components/FileViewer.tsx | 111 +++++++++++- apps/web/src/i18n/locales/en.ts | 4 + apps/web/src/i18n/locales/pt-BR.ts | 4 + apps/web/src/i18n/locales/zh-CN.ts | 4 + apps/web/src/i18n/locales/zh-TW.ts | 4 + apps/web/src/i18n/types.ts | 4 + apps/web/src/index.css | 57 ++++++ packages/contracts/src/api/artifacts.ts | 7 + 17 files changed, 712 insertions(+), 2 deletions(-) create mode 100644 apps/web/src/artifacts/markdown.test.ts create mode 100644 apps/web/src/artifacts/markdown.ts create mode 100644 apps/web/src/artifacts/renderer-registry.test.ts diff --git a/apps/daemon/src/artifact-manifest.ts b/apps/daemon/src/artifact-manifest.ts index e21b0d850..e765183ac 100644 --- a/apps/daemon/src/artifact-manifest.ts +++ b/apps/daemon/src/artifact-manifest.ts @@ -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; } diff --git a/apps/daemon/tests/artifact-manifest.test.ts b/apps/daemon/tests/artifact-manifest.test.ts index 6f88e63aa..7159642d1 100644 --- a/apps/daemon/tests/artifact-manifest.test.ts +++ b/apps/daemon/tests/artifact-manifest.test.ts @@ -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']); + }); }); diff --git a/apps/web/src/artifacts/manifest.test.ts b/apps/web/src/artifacts/manifest.test.ts index 487f0b626..fd4030b4d 100644 --- a/apps/web/src/artifacts/manifest.test.ts +++ b/apps/web/src/artifacts/manifest.test.ts @@ -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'); diff --git a/apps/web/src/artifacts/manifest.ts b/apps/web/src/artifacts/manifest.ts index bcdecabb7..2aa058959 100644 --- a/apps/web/src/artifacts/manifest.ts +++ b/apps/web/src/artifacts/manifest.ts @@ -3,6 +3,7 @@ import type { ArtifactKind, ArtifactManifest, ArtifactRendererId, + ArtifactStatus, } from './types'; const MANIFEST_VERSION = 1; @@ -38,6 +39,7 @@ const ALLOWED_EXPORTS: ReadonlySet = new Set([ 'svg', 'txt', ]); +const ALLOWED_STATUS: ReadonlySet = 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, }; diff --git a/apps/web/src/artifacts/markdown.test.ts b/apps/web/src/artifacts/markdown.test.ts new file mode 100644 index 000000000..80371354e --- /dev/null +++ b/apps/web/src/artifacts/markdown.test.ts @@ -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('

Title

'); + expect(out).toContain('

Paragraph with bold and italic and code.

'); + expect(out).toContain(''); + expect(out).toContain('
  1. first
  2. second
'); + expect(out).toContain('
note line
'); + expect(out).toContain('
const x = 1 < 2;
'); + }); + + it('escapes raw html', () => { + const out = renderMarkdownToSafeHtml(''); + expect(out).toContain('<script>alert(1)</script>'); + expect(out).not.toContain('](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('