feat(renderer): sandboxed code runner for HTML/JS/CSS (M10.11)

- CodeRunner.vue: sandboxed iframe with srcdoc, allow-scripts only
- Console capture via postMessage (log + error)
- Run button, clear output, code preview
- Extracts runnable code blocks from markdown

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Dorian
2026-03-04 00:02:36 +00:00
co-authored by Claude Opus 4.6
parent 7f67f0863d
commit 7fa06ce500
3 changed files with 181 additions and 0 deletions
@@ -214,6 +214,15 @@
/>
</div>
<div v-if="runnableCodeBlocks.length > 0" class="mt-3 space-y-2" @click.stop>
<CodeRunner
v-for="(block, i) in runnableCodeBlocks"
:key="`code-${i}`"
:code="block.code"
:language="block.language"
/>
</div>
<div v-if="inlineNewsLinks.length > 0" class="mt-3 space-y-1" @click.stop>
<NewsCard
v-for="(link, i) in inlineNewsLinks"
@@ -362,7 +371,9 @@ import RecipeCard from '@/components/renderers/RecipeCard.vue'
import EventCard from '@/components/renderers/EventCard.vue'
import InteractiveTable from '@/components/renderers/InteractiveTable.vue'
import TimelineRenderer from '@/components/renderers/TimelineRenderer.vue'
import CodeRunner from '@/components/renderers/CodeRunner.vue'
import { extractTables } from '@/composables/useTableExtractor'
import { extractRunnableCodeBlocks } from '@/composables/useCodeBlockExtractor'
const props = withDefaults(
defineProps<{
@@ -470,6 +481,11 @@ const inlineEvents = computed(() => {
return extractEvents(props.message.content)
})
const runnableCodeBlocks = computed(() => {
if (isUser.value) return []
return extractRunnableCodeBlocks(props.message.content)
})
const isCodeResponse = computed(() =>
!isUser.value && props.triggeringQuery.trim().toLowerCase() === '/code'
)
@@ -0,0 +1,133 @@
<template>
<div class="code-runner rounded-xl bg-white/5 border border-white/10 overflow-hidden">
<!-- Toolbar -->
<div class="flex items-center gap-2 px-3 py-2 border-b border-white/5">
<span class="text-[10px] text-white/30 uppercase tracking-wider">{{ language }}</span>
<div class="flex-1" />
<button
class="text-[10px] px-2.5 py-1 rounded bg-accent/15 text-accent/80 hover:bg-accent/25 transition-colors"
@click="runCode"
>
Run
</button>
<button
v-if="consoleOutput.length > 0"
class="text-[10px] px-2 py-1 rounded bg-white/5 text-white/40 hover:text-white/60 hover:bg-white/10 transition-colors"
@click="clearOutput"
>
Clear
</button>
</div>
<!-- Code display -->
<pre class="px-3 py-2 text-xs text-white/70 overflow-x-auto max-h-48 bg-black/20"><code>{{ code }}</code></pre>
<!-- Output iframe (hidden, used for execution) -->
<iframe
v-if="showIframe"
ref="iframeRef"
:srcdoc="srcdoc"
sandbox="allow-scripts"
class="w-full border-t border-white/5"
:class="isHtml ? 'h-48' : 'h-0 invisible'"
title="Code output"
/>
<!-- Console output -->
<div
v-if="consoleOutput.length > 0"
class="border-t border-white/5 bg-black/30 px-3 py-2 max-h-32 overflow-y-auto"
>
<p class="text-[9px] text-white/20 uppercase tracking-wider mb-1">Console</p>
<div
v-for="(entry, i) in consoleOutput"
:key="i"
class="text-[11px] font-mono leading-relaxed"
:class="entry.type === 'error' ? 'text-red-400/80' : 'text-white/60'"
>
{{ entry.text }}
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed, onMounted, onBeforeUnmount } from 'vue'
const props = defineProps<{
code: string
language: string
}>()
interface ConsoleEntry {
type: 'log' | 'error'
text: string
}
const consoleOutput = ref<ConsoleEntry[]>([])
const showIframe = ref(false)
const iframeRef = ref<HTMLIFrameElement | null>(null)
const isHtml = computed(() => props.language === 'html')
const srcdoc = computed(() => {
if (isHtml.value) {
// For HTML, wrap with console capture
return `<!DOCTYPE html>
<html><head><style>body{background:#0a0a0a;color:#e0e0e0;font-family:system-ui;margin:8px}</style>
<script>
const _post = (type, args) => parent.postMessage({type:'code-runner-console',level:type,text:args.map(a=>typeof a==='object'?JSON.stringify(a):String(a)).join(' ')},'*');
console.log = (...a) => _post('log', a);
console.error = (...a) => _post('error', a);
window.onerror = (m) => _post('error', [m]);
<\/script></head><body>${props.code}</body></html>`
}
// For JS/CSS, wrap in HTML
if (props.language === 'javascript' || props.language === 'js') {
return `<!DOCTYPE html><html><head>
<script>
const _post = (type, args) => parent.postMessage({type:'code-runner-console',level:type,text:args.map(a=>typeof a==='object'?JSON.stringify(a):String(a)).join(' ')},'*');
console.log = (...a) => _post('log', a);
console.error = (...a) => _post('error', a);
window.onerror = (m) => _post('error', [m]);
<\/script></head><body><script>${props.code}<\/script></body></html>`
}
if (props.language === 'css') {
return `<!DOCTYPE html><html><head><style>${props.code}</style></head><body style="background:#0a0a0a"><div style="padding:16px;color:#e0e0e0;font-family:system-ui">CSS Preview</div></body></html>`
}
return ''
})
function runCode() {
consoleOutput.value = []
showIframe.value = false
// Force re-mount iframe
requestAnimationFrame(() => {
showIframe.value = true
})
}
function clearOutput() {
consoleOutput.value = []
}
function handleMessage(e: MessageEvent) {
if (e.data?.type === 'code-runner-console') {
consoleOutput.value.push({
type: e.data.level === 'error' ? 'error' : 'log',
text: e.data.text,
})
}
}
onMounted(() => {
window.addEventListener('message', handleMessage)
})
onBeforeUnmount(() => {
window.removeEventListener('message', handleMessage)
})
</script>
@@ -0,0 +1,32 @@
/**
* Extract runnable code blocks (HTML, JS, CSS) from markdown text.
*/
export interface CodeBlock {
language: string
code: string
}
const RUNNABLE_LANGS = new Set(['html', 'javascript', 'js', 'css'])
const FENCED_CODE_RE = /```(html|javascript|js|css)\n([\s\S]*?)```/gi
export function extractRunnableCodeBlocks(text: string): CodeBlock[] {
const results: CodeBlock[] = []
let m: RegExpExecArray | null
const re = new RegExp(FENCED_CODE_RE.source, FENCED_CODE_RE.flags)
while ((m = re.exec(text)) !== null) {
const language = m[1].toLowerCase()
const code = m[2].trim()
if (RUNNABLE_LANGS.has(language) && code.length > 0) {
results.push({ language, code })
}
}
return results
}
export function hasRunnableCode(text: string): boolean {
return FENCED_CODE_RE.test(text)
}