+
()
-const { store } = inject(injectKeyProps)!
+const { store, showConsole } = inject(injectKeyProps)!
const previewRef = useTemplateRef('preview')
const modes = computed(() => {
const outputModes: OutputModes[] = ['preview']
@@ -51,6 +57,9 @@ function openSourceMap() {
function reload() {
previewRef.value?.reload()
+ store.value.clearConsole?.()
+
+
}
defineExpose({ reload, previewRef })
@@ -69,7 +78,15 @@ defineExpose({ reload, previewRef })
-
+
+
+
+
+
+
+
+
+
diff --git a/packages/vue-repl/src/output/Sandbox.vue b/packages/vue-repl/src/output/Sandbox.vue
index c62f480..8c969f0 100644
--- a/packages/vue-repl/src/output/Sandbox.vue
+++ b/packages/vue-repl/src/output/Sandbox.vue
@@ -15,7 +15,7 @@ import srcdoc from './srcdoc.html?raw'
import { PreviewProxy } from './PreviewProxy'
import { compileModulesForPreview } from './moduleCompiler'
import type { Store } from '../store'
-import { injectKeyProps } from '../types'
+import { type LogLevel, type SandboxEmits, injectKeyProps } from '../types'
import { getVersions, isVaporSupported } from '../import-map'
export interface SandboxProps {
@@ -47,6 +47,10 @@ const props = withDefaults(defineProps
(), {
previewOptions: () => ({}),
autoStoreInit: true,
})
+
+const emit = defineEmits()
+
+
const { store, theme, clearConsole, previewOptions } = toRefs(props)
const keyProps = inject(injectKeyProps)
@@ -132,6 +136,9 @@ function createSandbox() {
sandbox.srcdoc = sandboxSrc
containerRef.value?.appendChild(sandbox)
+ const doLog = (logLevel: LogLevel, data?: any) =>
+ emit('log', { logLevel, data })
+
proxy = new PreviewProxy(sandbox, {
on_fetch_progress: (progress: any) => {
// pending_imports = progress;
@@ -157,33 +164,33 @@ function createSandbox() {
}
runtimeError.value = 'Uncaught (in promise): ' + error.message
},
- on_console: (log: any) => {
- if (log.duplicate) {
- return
- }
+ on_console: (log: any) => {
+ const maybeMsg = log.args[0]
if (log.level === 'error') {
- if (log.args[0] instanceof Error) {
- runtimeError.value = log.args[0].message
- } else {
- runtimeError.value = log.args[0]
- }
- } else if (log.level === 'warn') {
- if (log.args[0].toString().includes('[Vue warn]')) {
- runtimeWarning.value = log.args
- .join('')
- .replace(/\[Vue warn\]:/, '')
- .trim()
+ if (maybeMsg instanceof Error) {
+ runtimeError.value = maybeMsg.message
+ } else if (!maybeMsg?.startsWith('[vue-repl]')) {
+ runtimeError.value = maybeMsg
}
+ } else if (
+ log.level === 'warn' &&
+ maybeMsg.toString().includes('[Vue warn]')
+ ) {
+ runtimeWarning.value = log.args
+ .join('')
+ .replace(/\[Vue warn\]:/, '')
+ .trim()
}
+ doLog(log.level || 'log', log.args)
},
on_console_group: (action: any) => {
- // group_logs(action.label, false);
+ doLog('group', action.label)
},
on_console_group_end: () => {
- // ungroup_logs();
+ doLog('groupEnd')
},
on_console_group_collapsed: (action: any) => {
- // group_logs(action.label, true);
+ doLog('groupCollapsed', action.label)
},
})
@@ -348,12 +355,7 @@ defineExpose({ reload, container: containerRef })
-
+
+import { inject, nextTick, onMounted, ref, useTemplateRef, watch } from 'vue'
+import LunaConsole from 'luna-console'
+import { type LogPayload, injectKeyProps } from '../types'
+import 'luna-object-viewer/luna-object-viewer.css'
+import 'luna-dom-viewer/luna-dom-viewer.css'
+import 'luna-data-grid/luna-data-grid.css'
+import 'luna-console/luna-console.css'
+import toEl from 'licia/toEl'
+
+
+const { store, theme } = inject(injectKeyProps)!
+const lunaRef = useTemplateRef('luna-ref')
+const lunaConsole = ref()
+
+onMounted(() => {
+ if (!lunaRef.value) return
+ lunaConsole.value = new LunaConsole(lunaRef.value, {
+ theme: theme.value || 'light',
+ })
+ store.value.executeLog = ({ logLevel, data = [] }: LogPayload) => {
+ const deserialized = data.map(deserializeMessage)
+ ;(lunaConsole.value?.[logLevel] as any)?.(...deserialized)
+ // Ensure theme is applied after log (for late-rendered viewers)
+ updateLunaThemeClass(theme.value || 'light')
+ }
+ store.value.clearConsole = clearLunaConsole
+})
+
+// Watch for theme changes and update LunaConsole theme reactively
+watch(
+ () => theme.value,
+ (newTheme) => {
+ if (lunaConsole.value) {
+ lunaConsole.value.setOption('theme', newTheme || 'light')
+ // Update theme class on root and sub-viewers
+ updateLunaThemeClass(newTheme || 'light')
+ }
+ },
+ { immediate: true }
+)
+
+async function updateLunaThemeClass(theme: string) {
+ // Helper to update theme classes
+ function applyTheme() {
+ const root = lunaRef.value
+ if (!root) return
+ root.classList.remove('luna-console-theme-dark', 'luna-console-theme-light')
+ root.classList.add(`luna-console-theme-${theme}`)
+
+ const viewerTypes = ['object-viewer', 'dom-viewer', 'data-grid']
+ for (const type of viewerTypes) {
+ // Update theme class on all theme-marked elements
+ const themeEls = root.querySelectorAll(`[class*="luna-${type}-theme-"]`)
+ themeEls.forEach(el => {
+ el.classList.remove(`luna-${type}-theme-dark`, `luna-${type}-theme-light`)
+ el.classList.add(`luna-${type}-theme-${theme}`)
+ })
+ }
+ }
+ // Run immediately
+ applyTheme()
+ // Run again after DOM updates using Vue's nextTick
+ await nextTick()
+ applyTheme()
+}
+
+function clearLunaConsole() {
+ lunaConsole.value?.clear(true)
+}
+
+// maybe make this configurable, but don't do it by default
+// watch(() => store.value.activeFile.code, clearLunaConsole)
+
+/**
+ * Lossless deserialization:
+ * - Recreates primitives as-is
+ * - Returns enriched objects for complex types (with __type metadata)
+ * - Keeps outerHTML, function source, accessor info, etc.
+ */
+function deserializeMessage(arg: any): any {
+ if (arg === null || arg === undefined) return arg
+ if (typeof arg !== 'object' || !arg.__type) return arg
+
+ switch (arg.__type) {
+ case 'undefined':
+ return undefined
+
+ case 'function': {
+ // Approximated with a Function constructor (non-invocable stub)
+ const fn = new Function(`/* [Function ${arg.name}] */`)
+ ; (fn as any).__isStub = true
+ ; (fn as any).__preview = `[Function ${arg.name}]${arg.isNative ? ' [native]' : ''}`
+ return fn
+ }
+
+ case 'symbol':
+ return Symbol(arg.description ?? '')
+
+ case 'bigint':
+ try {
+ return BigInt(arg.value)
+ } catch {
+ return Object(`BigInt(${arg.value})`) // fallback object wrapper
+ }
+
+ case 'date':
+ return arg.invalid ? new Date('Invalid Date') : new Date(arg.value).toString()
+
+ case 'regexp':
+ return new RegExp(arg.source, arg.flags)
+
+ case 'error': {
+ const e = new Error(arg.message)
+ e.name = arg.name
+ ; (e as any).stack = arg.stack
+ return e
+ }
+
+ case 'dom': {
+ if (arg.outerHTML) {
+ const el = toEl(arg.outerHTML)
+ Object.defineProperty(el, '__preview', {
+ value: arg.outerHTML.replace(/\n/g, ''), // one-liner preview
+ enumerable: false,
+ })
+ return el
+ }
+ if (arg.nodeType === 3 && arg.textContent) {
+ return document.createTextNode(arg.textContent)
+ }
+ return `[DOM ${arg.tagName || 'Unknown'}]`
+ }
+
+ case 'vue_component':
+ return `[VueComponent ${arg.name}]`
+
+ case 'array': {
+ const items = arg.items?.map(deserializeMessage) ?? []
+ if (arg.truncated) {
+ items.push(`...and ${arg.truncated} more items`)
+ }
+ return items
+ }
+
+ case 'object': {
+ const out: Record = {}
+ for (const key in arg.properties) {
+ const prop = arg.properties[key]
+ if (prop && 'value' in prop) {
+ out[key] = deserializeMessage(prop.value)
+ }
+ }
+ if (arg.truncatedProperties) {
+ out['__truncated__'] = `${arg.truncatedProperties} more properties`
+ }
+ // Method, not property
+ Object.defineProperty(out, 'toString', {
+ value: () => `[Object ${arg.constructor}]`,
+ enumerable: false,
+ })
+ return out
+ }
+
+ case 'circular':
+ return `[Circular ${arg.path ?? ''}]`
+
+ case 'accessor':
+ return `[Getter/Setter]`
+
+ default:
+ return `[Unrecognized ${arg.__type}]`
+ }
+}
+
+
+
+
+
+ Console
+
+
+
+
+
+
+
diff --git a/packages/vue-repl/src/output/srcdoc.html b/packages/vue-repl/src/output/srcdoc.html
index 75fa51b..4ce4690 100644
--- a/packages/vue-repl/src/output/srcdoc.html
+++ b/packages/vue-repl/src/output/srcdoc.html
@@ -6,8 +6,9 @@
color-scheme: dark;
}
body {
- font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto,
- Oxygen, Ubuntu, Cantarell, 'Open Sans', 'Helvetica Neue', sans-serif;
+ font-family:
+ -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu,
+ Cantarell, 'Open Sans', 'Helvetica Neue', sans-serif;
}
@@ -30,6 +31,233 @@
return Promise.resolve(window.__modules__[key])
}
+ // Enhanced serialization for Luna Console
+ function serializeForPostMessage(data, maxDepth = 10, currentDepth = 0) {
+ if (currentDepth >= maxDepth) return '[Max Depth Reached]'
+ if (data === null) return null
+ if (data === undefined) return { __type: 'undefined' }
+
+ const dataType = typeof data
+
+ // Handle primitives
+ if (dataType === 'boolean' || dataType === 'number' || dataType === 'string') {
+ return data
+ }
+
+ // Handle functions
+ if (dataType === 'function') {
+ return {
+ __type: 'function',
+ name: data.name || 'anonymous',
+ source: data.toString().substring(0, 500),
+ isNative: data.toString().includes('[native code]')
+ }
+ }
+
+ // Handle symbols
+ if (dataType === 'symbol') {
+ return {
+ __type: 'symbol',
+ description: data.description,
+ toString: data.toString()
+ }
+ }
+
+ // Handle bigint
+ if (dataType === 'bigint') {
+ return {
+ __type: 'bigint',
+ value: data.toString()
+ }
+ }
+
+ // For objects, we need to be more careful
+ if (dataType === 'object') {
+ // Use WeakSet to track circular references within this serialization call
+ const seen = serializeForPostMessage._seen || (serializeForPostMessage._seen = new WeakSet())
+
+ if (seen.has(data)) {
+ return { __type: 'circular', toString: Object.prototype.toString.call(data) }
+ }
+ seen.add(data)
+
+ try {
+ // Handle dates
+ if (data instanceof Date) {
+ return {
+ __type: 'date',
+ value: data.toISOString(),
+ invalid: isNaN(data.getTime())
+ }
+ }
+
+ // Handle regexps
+ if (data instanceof RegExp) {
+ return {
+ __type: 'regexp',
+ source: data.source,
+ flags: data.flags
+ }
+ }
+
+ // Handle errors
+ if (data instanceof Error) {
+ return {
+ __type: 'error',
+ name: data.name,
+ message: data.message,
+ stack: data.stack
+ }
+ }
+
+ // Handle DOM nodes
+ if (data.nodeType) {
+ const result = {
+ __type: 'dom',
+ nodeType: data.nodeType,
+ nodeName: data.nodeName
+ }
+
+ if (data.nodeType === 1) { // Element node
+ result.tagName = data.tagName
+ result.className = data.className
+ result.id = data.id
+ result.outerHTML = data.outerHTML?.substring(0, 200)
+ result.attributes = Array.from(data.attributes || []).map(attr => ({
+ name: attr.name,
+ value: attr.value
+ }))
+ } else if (data.nodeType === 3) { // Text node
+ result.textContent = data.textContent
+ }
+
+ return result
+ }
+
+ // Handle Vue component proxies
+ if (isComponentProxy(data)) {
+ return {
+ __type: 'vue_component',
+ name: data.$options?.name || data.$.type?.name || 'Anonymous',
+ props: serializeForPostMessage(data.$props, maxDepth, currentDepth + 1),
+ data: serializeForPostMessage(data.$data, maxDepth, currentDepth + 1)
+ }
+ }
+
+ // Handle arrays
+ if (Array.isArray(data)) {
+ const result = {
+ __type: 'array',
+ length: data.length,
+ items: []
+ }
+
+ // Only serialize first 100 items for performance
+ const itemsToSerialize = Math.min(data.length, 100)
+ for (let i = 0; i < itemsToSerialize; i++) {
+ try {
+ result.items[i] = serializeForPostMessage(data[i], maxDepth, currentDepth + 1)
+ } catch (e) {
+ result.items[i] = { __type: 'error', message: 'Serialization failed' }
+ }
+ }
+
+ if (data.length > 100) {
+ result.truncated = data.length - 100
+ }
+
+ return result
+ }
+
+ // Handle plain objects and other object types
+ const result = {
+ __type: 'object',
+ constructor: data.constructor?.name,
+ toString: Object.prototype.toString.call(data),
+ properties: {}
+ }
+
+ // Get all property names (including non-enumerable)
+ const allKeys = new Set([
+ ...Object.keys(data),
+ ...Object.getOwnPropertyNames(data)
+ ])
+
+ let propertyCount = 0
+ const maxProperties = 50 // Limit properties for performance
+
+ for (const key of allKeys) {
+ if (propertyCount >= maxProperties) {
+ result.truncatedProperties = allKeys.size - maxProperties
+ break
+ }
+
+ try {
+ const descriptor = Object.getOwnPropertyDescriptor(data, key)
+ if (descriptor) {
+ const prop = {
+ enumerable: descriptor.enumerable,
+ configurable: descriptor.configurable,
+ writable: descriptor.writable
+ }
+
+ if (descriptor.get || descriptor.set) {
+ prop.getter = !!descriptor.get
+ prop.setter = !!descriptor.set
+ prop.value = { __type: 'accessor' }
+ } else {
+ prop.value = serializeForPostMessage(descriptor.value, maxDepth, currentDepth + 1)
+ }
+
+ result.properties[key] = prop
+ }
+ } catch (e) {
+ result.properties[key] = {
+ value: { __type: 'error', message: 'Property access failed' },
+ enumerable: false
+ }
+ }
+ propertyCount++
+ }
+
+ return result
+
+ } finally {
+ seen.delete(data)
+ }
+ }
+
+ return { __type: 'unknown', typeof: dataType }
+ }
+
+ // Helper function to detect Vue component proxy
+ function isComponentProxy(value) {
+ return (
+ value &&
+ typeof value === 'object' &&
+ value.__v_skip === true &&
+ typeof value.$nextTick === 'function' &&
+ value.$ &&
+ value._
+ )
+ }
+
+ // Fallback toString function for unsupported types
+ function toString(value) {
+ if (value instanceof Error) {
+ return value.message
+ }
+ for (const fn of [
+ String,
+ (v) => Object.prototype.toString.call(v),
+ (v) => typeof v,
+ ]) {
+ try {
+ return fn(value)
+ } catch (err) {}
+ }
+ }
+
async function handle_message(ev) {
let { action, cmd_id } = ev.data
const send_message = (payload) =>
@@ -122,7 +350,7 @@
window.onerror = function (msg, url, lineNo, columnNo, error) {
// ignore errors from import map polyfill - these are necessary for
// it to detect browser support
- if (msg.includes('module specifier “vue”')) {
+ if (msg.includes('module specifier "vue"')) {
// firefox only error, ignore
return false
}
@@ -158,49 +386,83 @@
}
})
- let previous = { level: null, args: null }
-
- ;['clear', 'log', 'info', 'dir', 'warn', 'error', 'table'].forEach(
- (level) => {
- const original = console[level]
- console[level] = (...args) => {
- const msg = args[0]
- if (typeof msg === 'string') {
- if (
- msg.includes('You are running a development build of Vue') ||
- msg.includes('You are running the esm-bundler build of Vue')
- ) {
- return
- }
- }
-
- original(...args)
-
- const stringifiedArgs = stringify(args)
+ const errorColorStr = 'color: #fb2c36'
+ const errorMsg = 'please open the devtool to see correctly this log'
+
+ // Enhanced console methods with proper serialization
+ ;[
+ 'clear',
+ 'log',
+ 'info',
+ 'dir',
+ 'warn',
+ 'error',
+ 'table',
+ 'debug',
+ 'time',
+ 'timeLog',
+ 'timeEnd',
+ 'assert',
+ 'count',
+ 'countReset',
+ ].forEach((level) => {
+ const original = console[level]
+ console[level] = (...args) => {
+ const msg = args[0]
+ if (typeof msg === 'string') {
if (
- previous.level === level &&
- previous.args &&
- previous.args === stringifiedArgs
+ msg.includes('You are running a development build of Vue') ||
+ msg.includes('You are running the esm-bundler build of Vue')
) {
- parent.postMessage(
- { action: 'console', level, duplicate: true },
- '*',
- )
- } else {
- previous = { level, args: stringifiedArgs }
-
- try {
- parent.postMessage({ action: 'console', level, args }, '*')
- } catch (err) {
- parent.postMessage(
- { action: 'console', level, args: args.map(toString) },
- '*',
- )
- }
+ return
}
}
- },
- )
+ original(...args)
+
+ // Enhanced serialization for Luna Console
+ try {
+ // Clear the WeakSet for each console call to avoid memory leaks
+ serializeForPostMessage._seen = new WeakSet()
+
+ const serializedArgs = args.map(arg => serializeForPostMessage(arg))
+
+ parent.postMessage({
+ action: 'console',
+ level,
+ args: serializedArgs,
+ timestamp: Date.now()
+ }, '*')
+
+ } catch (error) {
+ // Fallback to original behavior if serialization completely fails
+ console.warn('Serialization failed:', error)
+ try {
+ const fallbackArgs = args.map(toString)
+ parent.postMessage({
+ action: 'console',
+ level,
+ args: fallbackArgs.length ? fallbackArgs : [
+ `[vue-repl]: %c Serialization failed, ${errorMsg}`,
+ errorColorStr,
+ ],
+ timestamp: Date.now()
+ }, '*')
+ } catch (fallbackError) {
+ parent.postMessage({
+ action: 'console',
+ level: 'error',
+ args: [
+ `[vue-repl]: %c Console message could not be displayed, ${errorMsg}`,
+ errorColorStr,
+ ],
+ timestamp: Date.now()
+ }, '*')
+ }
+ }
+ }
+ })
+
+ // Console group methods
;[
{ method: 'group', action: 'console_group' },
{ method: 'groupEnd', action: 'console_group_end' },
@@ -208,160 +470,33 @@
].forEach((group_action) => {
const original = console[group_action.method]
console[group_action.method] = (label) => {
- parent.postMessage({ action: group_action.action, label }, '*')
+ parent.postMessage({
+ action: group_action.action,
+ label: label ? serializeForPostMessage(label) : undefined,
+ timestamp: Date.now()
+ }, '*')
original(label)
}
})
-
- const timers = new Map()
- const original_time = console.time
- const original_timelog = console.timeLog
- const original_timeend = console.timeEnd
-
- console.time = (label = 'default') => {
- original_time(label)
- timers.set(label, performance.now())
- }
- console.timeLog = (label = 'default') => {
- original_timelog(label)
- const now = performance.now()
- if (timers.has(label)) {
- parent.postMessage(
- {
- action: 'console',
- level: 'system-log',
- args: [`${label}: ${now - timers.get(label)}ms`],
- },
- '*',
- )
- } else {
- parent.postMessage(
- {
- action: 'console',
- level: 'system-warn',
- args: [`Timer '${label}' does not exist`],
- },
- '*',
- )
- }
- }
- console.timeEnd = (label = 'default') => {
- original_timeend(label)
- const now = performance.now()
- if (timers.has(label)) {
- parent.postMessage(
- {
- action: 'console',
- level: 'system-log',
- args: [`${label}: ${now - timers.get(label)}ms`],
- },
- '*',
- )
- } else {
- parent.postMessage(
- {
- action: 'console',
- level: 'system-warn',
- args: [`Timer '${label}' does not exist`],
- },
- '*',
- )
- }
- timers.delete(label)
- }
-
- const original_assert = console.assert
- console.assert = (condition, ...args) => {
- if (condition) {
- const stack = new Error().stack
- parent.postMessage(
- { action: 'console', level: 'assert', args, stack },
- '*',
- )
- }
- original_assert(condition, ...args)
- }
-
- const counter = new Map()
- const original_count = console.count
- const original_countreset = console.countReset
-
- console.count = (label = 'default') => {
- counter.set(label, (counter.get(label) || 0) + 1)
- parent.postMessage(
- {
- action: 'console',
- level: 'system-log',
- args: `${label}: ${counter.get(label)}`,
- },
- '*',
- )
- original_count(label)
- }
-
- console.countReset = (label = 'default') => {
- if (counter.has(label)) {
- counter.set(label, 0)
- } else {
- parent.postMessage(
- {
- action: 'console',
- level: 'system-warn',
- args: `Count for '${label}' does not exist`,
- },
- '*',
- )
+
+ // Unsupported console methods
+ ;['profile', 'profileEnd', 'trace', 'dirXml'].forEach((level) => {
+ const original = console[level]
+ console[level] = (...args) => {
+ original(...args)
+ const fallbackArgs = [
+ `[vue-repl]: %c Cannot handle "${level}" log, ${errorMsg}.`,
+ errorColorStr,
+ ]
+ parent.postMessage({
+ action: 'console',
+ level: 'log',
+ args: fallbackArgs,
+ timestamp: Date.now()
+ }, '*')
}
- original_countreset(label)
- }
-
- const original_trace = console.trace
-
- console.trace = (...args) => {
- const stack = new Error().stack
- parent.postMessage(
- { action: 'console', level: 'trace', args, stack },
- '*',
- )
- original_trace(...args)
- }
-
- function toString(value) {
- if (value instanceof Error) {
- return value.message
- }
- for (const fn of [
- String,
- (v) => Object.prototype.toString.call(v),
- (v) => typeof v,
- ]) {
- try {
- return fn(value)
- } catch (err) {}
- }
- }
-
- function isComponentProxy(value) {
- return (
- value &&
- typeof value === 'object' &&
- value.__v_skip === true &&
- typeof value.$nextTick === 'function' &&
- value.$ &&
- value._
- )
- }
-
- function stringify(args) {
- try {
- return JSON.stringify(args, (key, value) => {
- return isComponentProxy(value) ? '{component proxy}' : value
- })
- } catch (error) {
- return null
- }
- }
+ })
})()
@@ -377,4 +512,4 @@
-