workflow(sfc-playground): support multiple files
This commit is contained in:
parent
2e3984fd5b
commit
d1bf35c8b8
@ -11,6 +11,7 @@ export { parse as babelParse } from '@babel/parser'
|
||||
export { walkIdentifiers } from './compileScript'
|
||||
import MagicString from 'magic-string'
|
||||
export { MagicString }
|
||||
export { walk } from 'estree-walker'
|
||||
|
||||
// Types
|
||||
export {
|
||||
|
@ -29,6 +29,8 @@ body {
|
||||
background-color: #f8f8f8;
|
||||
--nav-height: 50px;
|
||||
--font-code: 'Source Code Pro', monospace;
|
||||
--color-branding: #3ca877;
|
||||
--color-branding-dark: #416f9c;
|
||||
}
|
||||
|
||||
.wrapper {
|
||||
|
@ -38,6 +38,8 @@ function formatMessage(err: string | Error): string {
|
||||
border-radius: 6px;
|
||||
font-family: var(--font-code);
|
||||
white-space: pre-wrap;
|
||||
max-height: calc(100% - 50px);
|
||||
overflow-y: scroll;
|
||||
}
|
||||
|
||||
.msg.err {
|
||||
|
@ -1,17 +1,40 @@
|
||||
<template>
|
||||
<CodeMirror @change="onChange" :value="initialCode" />
|
||||
<FileSelector/>
|
||||
<div class="editor-container">
|
||||
<CodeMirror @change="onChange" :value="activeCode" :mode="activeMode" />
|
||||
<Message :err="store.errors[0]" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import FileSelector from './FileSelector.vue'
|
||||
import CodeMirror from '../codemirror/CodeMirror.vue'
|
||||
import Message from '../Message.vue'
|
||||
import { store } from '../store'
|
||||
import { debounce } from '../utils'
|
||||
import { ref, watch, computed } from 'vue'
|
||||
|
||||
const onChange = debounce((code: string) => {
|
||||
store.code = code
|
||||
store.activeFile.code = code
|
||||
}, 250)
|
||||
|
||||
const initialCode = store.code
|
||||
</script>
|
||||
const activeCode = ref(store.activeFile.code)
|
||||
const activeMode = computed(
|
||||
() => (store.activeFilename.endsWith('.js') ? 'javascript' : 'htmlmixed')
|
||||
)
|
||||
|
||||
watch(
|
||||
() => store.activeFilename,
|
||||
() => {
|
||||
activeCode.value = store.activeFile.code
|
||||
}
|
||||
)
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.editor-container {
|
||||
height: calc(100% - 35px);
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
}
|
||||
</style>
|
||||
|
118
packages/sfc-playground/src/editor/FileSelector.vue
Normal file
118
packages/sfc-playground/src/editor/FileSelector.vue
Normal file
@ -0,0 +1,118 @@
|
||||
<template>
|
||||
<div class="file-selector">
|
||||
<div
|
||||
v-for="(file, i) in Object.keys(store.files)"
|
||||
class="file"
|
||||
:class="{ active: store.activeFilename === file }"
|
||||
@click="setActive(file)">
|
||||
<span class="label">{{ file }}</span>
|
||||
<span v-if="i > 0" class="remove" @click.stop="deleteFile(file)">
|
||||
<svg width="12" height="12" viewBox="0 0 24 24" class="svelte-cghqrp"><line stroke="#999" x1="18" y1="6" x2="6" y2="18"></line><line stroke="#999" x1="6" y1="6" x2="18" y2="18"></line></svg>
|
||||
</span>
|
||||
</div>
|
||||
<div v-if="pending" class="file" >
|
||||
<input
|
||||
v-model="pendingFilename"
|
||||
spellcheck="false"
|
||||
@keyup.enter="doneAddFile"
|
||||
@keyup.esc="cancelAddFile"
|
||||
@vnodeMounted="focus">
|
||||
</div>
|
||||
<button class="add" @click="startAddFile">+</button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { store, addFile, deleteFile, setActive } from '../store'
|
||||
import { ref } from 'vue'
|
||||
import type { VNode } from 'vue'
|
||||
|
||||
const pending = ref(false)
|
||||
const pendingFilename = ref('Comp.vue')
|
||||
|
||||
function startAddFile() {
|
||||
pending.value = true
|
||||
}
|
||||
|
||||
function cancelAddFile() {
|
||||
pending.value = false
|
||||
}
|
||||
|
||||
function focus({ el }: VNode) {
|
||||
(el as HTMLInputElement).focus()
|
||||
}
|
||||
|
||||
function doneAddFile() {
|
||||
const filename = pendingFilename.value
|
||||
|
||||
if (!filename.endsWith('.vue') && !filename.endsWith('.js')) {
|
||||
store.errors = [`Playground only supports .vue or .js files.`]
|
||||
return
|
||||
}
|
||||
|
||||
if (filename in store.files) {
|
||||
store.errors = [`File "${filename}" already exists.`]
|
||||
return
|
||||
}
|
||||
|
||||
store.errors = []
|
||||
pending.value = false
|
||||
addFile(filename)
|
||||
pendingFilename.value = 'Comp.vue'
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.file-selector {
|
||||
box-sizing: border-box;
|
||||
border-bottom: 1px solid #ddd;
|
||||
background-color: white;
|
||||
}
|
||||
.file {
|
||||
display: inline-block;
|
||||
font-size: 13px;
|
||||
font-family: var(--font-code);
|
||||
cursor: pointer;
|
||||
color: #999;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
.file.active {
|
||||
color: var(--color-branding);
|
||||
border-bottom: 3px solid var(--color-branding);
|
||||
cursor: text;
|
||||
}
|
||||
.file span {
|
||||
display: inline-block;
|
||||
padding: 8px 10px 6px;
|
||||
}
|
||||
.file input {
|
||||
width: 80px;
|
||||
outline: none;
|
||||
border: 1px solid #ccc;
|
||||
border-radius: 3px;
|
||||
padding: 4px 6px;
|
||||
margin-left: 6px;
|
||||
}
|
||||
.file .remove {
|
||||
display: inline-block;
|
||||
vertical-align: middle;
|
||||
line-height: 12px;
|
||||
cursor: pointer;
|
||||
padding-left: 0;
|
||||
}
|
||||
.add {
|
||||
margin: 0;
|
||||
font-size: 20px;
|
||||
font-family: var(--font-code);
|
||||
color: #999;
|
||||
border: none;
|
||||
outline: none;
|
||||
background-color: transparent;
|
||||
cursor: pointer;
|
||||
vertical-align: middle;
|
||||
margin-left: 6px;
|
||||
}
|
||||
.add:hover {
|
||||
color: var(--color-branding);
|
||||
}
|
||||
</style>
|
@ -4,12 +4,12 @@
|
||||
</div>
|
||||
|
||||
<div class="output-container">
|
||||
<Preview v-if="mode === 'preview'" :code="store.compiled.executed" />
|
||||
<Preview v-if="mode === 'preview'" />
|
||||
<CodeMirror
|
||||
v-else
|
||||
readonly
|
||||
:mode="mode === 'css' ? 'css' : 'javascript'"
|
||||
:value="store.compiled[mode]"
|
||||
:value="store.activeFile.compiled[mode]"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
@ -20,9 +20,9 @@ import CodeMirror from '../codemirror/CodeMirror.vue'
|
||||
import { store } from '../store'
|
||||
import { ref } from 'vue'
|
||||
|
||||
type Modes = 'preview' | 'executed' | 'js' | 'css' | 'template'
|
||||
type Modes = 'preview' | 'js' | 'css'
|
||||
|
||||
const modes: Modes[] = ['preview', 'js', 'css', 'template', 'executed']
|
||||
const modes: Modes[] = ['preview', 'js', 'css']
|
||||
const mode = ref<Modes>('preview')
|
||||
</script>
|
||||
|
||||
@ -35,14 +35,15 @@ const mode = ref<Modes>('preview')
|
||||
.tab-buttons {
|
||||
box-sizing: border-box;
|
||||
border-bottom: 1px solid #ddd;
|
||||
background-color: white;
|
||||
}
|
||||
.tab-buttons button {
|
||||
margin: 0;
|
||||
font-size: 13px;
|
||||
font-family: 'Source Code Pro', monospace;
|
||||
font-family: var(--font-code);
|
||||
border: none;
|
||||
outline: none;
|
||||
background-color: #f8f8f8;
|
||||
background-color: transparent;
|
||||
padding: 8px 16px 6px;
|
||||
text-transform: uppercase;
|
||||
cursor: pointer;
|
||||
@ -51,7 +52,7 @@ const mode = ref<Modes>('preview')
|
||||
}
|
||||
|
||||
button.active {
|
||||
color: #42b983;
|
||||
border-bottom: 3px solid #42b983;
|
||||
color: var(--color-branding-dark);
|
||||
border-bottom: 3px solid var(--color-branding-dark);
|
||||
}
|
||||
</style>
|
||||
|
@ -11,12 +11,11 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import Message from '../Message.vue'
|
||||
import { ref, onMounted, onUnmounted, watchEffect, defineProps } from 'vue'
|
||||
import { ref, onMounted, onUnmounted, watchEffect } from 'vue'
|
||||
import srcdoc from './srcdoc.html?raw'
|
||||
import { PreviewProxy } from './PreviewProxy'
|
||||
import { sandboxVueURL } from '../store'
|
||||
|
||||
const props = defineProps<{ code: string }>()
|
||||
import { MAIN_FILE, SANDBOX_VUE_URL } from '../store'
|
||||
import { compileModulesForPreview } from './moduleCompiler'
|
||||
|
||||
const iframe = ref()
|
||||
const runtimeError = ref()
|
||||
@ -25,32 +24,35 @@ const runtimeWarning = ref()
|
||||
let proxy: PreviewProxy
|
||||
|
||||
async function updatePreview() {
|
||||
if (!props.code?.trim()) {
|
||||
return
|
||||
}
|
||||
try {
|
||||
proxy.eval(`
|
||||
${props.code}
|
||||
|
||||
if (window.vueApp) {
|
||||
window.vueApp.unmount()
|
||||
}
|
||||
const container = document.getElementById('app')
|
||||
container.innerHTML = ''
|
||||
|
||||
import { createApp as _createApp } from "${sandboxVueURL}"
|
||||
const app = window.vueApp = _createApp(__comp)
|
||||
|
||||
app.config.errorHandler = e => console.error(e)
|
||||
|
||||
app.mount(container)
|
||||
`)
|
||||
} catch (e) {
|
||||
runtimeError.value = e.message
|
||||
return
|
||||
}
|
||||
runtimeError.value = null
|
||||
runtimeWarning.value = null
|
||||
try {
|
||||
const modules = compileModulesForPreview()
|
||||
console.log(`successfully compiled ${modules.length} modules.`)
|
||||
// reset modules
|
||||
await proxy.eval(`
|
||||
window.__modules__ = {}
|
||||
window.__css__ = ''
|
||||
`)
|
||||
// evaluate modules
|
||||
for (const mod of modules) {
|
||||
await proxy.eval(mod)
|
||||
}
|
||||
// reboot
|
||||
await proxy.eval(`
|
||||
import { createApp as _createApp } from "${SANDBOX_VUE_URL}"
|
||||
if (window.__app__) {
|
||||
window.__app__.unmount()
|
||||
document.getElementById('app').innerHTML = ''
|
||||
}
|
||||
document.getElementById('__sfc-styles').innerHTML = window.__css__
|
||||
const app = window.__app__ = _createApp(__modules__["${MAIN_FILE}"].default)
|
||||
app.config.errorHandler = e => console.error(e)
|
||||
app.mount('#app')
|
||||
`)
|
||||
} catch (e) {
|
||||
runtimeError.value = e.stack
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
@ -59,7 +61,6 @@ onMounted(() => {
|
||||
// pending_imports = progress;
|
||||
},
|
||||
on_error: (event: any) => {
|
||||
// push_logs({ level: 'error', args: [event.value] });
|
||||
runtimeError.value = event.value
|
||||
},
|
||||
on_unhandled_rejection: (event: any) => {
|
||||
@ -69,10 +70,17 @@ onMounted(() => {
|
||||
},
|
||||
on_console: (log: any) => {
|
||||
if (log.level === 'error') {
|
||||
runtimeError.value = log.args.join('')
|
||||
if (log.args[0] instanceof Error) {
|
||||
runtimeError.value = log.args[0].stack
|
||||
} else {
|
||||
runtimeError.value = log.args
|
||||
}
|
||||
} else if (log.level === 'warn') {
|
||||
if (log.args[0].toString().includes('[Vue warn]')) {
|
||||
runtimeWarning.value = log.args.join('').replace(/\[Vue warn\]:/, '').trim()
|
||||
runtimeWarning.value = log.args
|
||||
.join('')
|
||||
.replace(/\[Vue warn\]:/, '')
|
||||
.trim()
|
||||
}
|
||||
}
|
||||
},
|
||||
@ -88,9 +96,9 @@ onMounted(() => {
|
||||
})
|
||||
|
||||
iframe.value.addEventListener('load', () => {
|
||||
proxy.handle_links();
|
||||
proxy.handle_links()
|
||||
watchEffect(updatePreview)
|
||||
});
|
||||
})
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
|
207
packages/sfc-playground/src/output/moduleCompiler.ts
Normal file
207
packages/sfc-playground/src/output/moduleCompiler.ts
Normal file
@ -0,0 +1,207 @@
|
||||
import { store, MAIN_FILE, SANDBOX_VUE_URL, File } from '../store'
|
||||
import { babelParse, MagicString, walk } from '@vue/compiler-sfc'
|
||||
import { babelParserDefaultPlugins } from '@vue/shared'
|
||||
import { Identifier, Node } from '@babel/types'
|
||||
|
||||
export function compileModulesForPreview() {
|
||||
return processFile(store.files[MAIN_FILE]).reverse()
|
||||
}
|
||||
|
||||
function processFile(file: File, seen = new Set<File>()) {
|
||||
if (seen.has(file)) {
|
||||
return []
|
||||
}
|
||||
seen.add(file)
|
||||
|
||||
const { js, css } = file.compiled
|
||||
const ast = babelParse(js, {
|
||||
sourceFilename: file.filename,
|
||||
sourceType: 'module',
|
||||
plugins: [...babelParserDefaultPlugins]
|
||||
}).program.body
|
||||
|
||||
const importedFiles = new Set<string>()
|
||||
const importToIdMap = new Map<string, string>()
|
||||
|
||||
const s = new MagicString(js)
|
||||
|
||||
function registerImport(source: string) {
|
||||
const filename = source.replace(/^\.\/+/, '')
|
||||
if (!(filename in store.files)) {
|
||||
throw new Error(`File "${filename}" does not exist.`)
|
||||
}
|
||||
if (importedFiles.has(filename)) {
|
||||
return importToIdMap.get(filename)
|
||||
}
|
||||
importedFiles.add(filename)
|
||||
const id = `__import_${importedFiles.size}__`
|
||||
importToIdMap.set(filename, id)
|
||||
s.prepend(`const ${id} = __modules__[${JSON.stringify(filename)}]\n`)
|
||||
return id
|
||||
}
|
||||
|
||||
s.prepend(
|
||||
`const mod = __modules__[${JSON.stringify(
|
||||
file.filename
|
||||
)}] = Object.create(null)\n\n`
|
||||
)
|
||||
|
||||
for (const node of ast) {
|
||||
if (node.type === 'ImportDeclaration') {
|
||||
const source = node.source.value
|
||||
if (source === 'vue') {
|
||||
// rewrite Vue imports
|
||||
s.overwrite(
|
||||
node.source.start!,
|
||||
node.source.end!,
|
||||
`"${SANDBOX_VUE_URL}"`
|
||||
)
|
||||
} else if (source.startsWith('./')) {
|
||||
// rewrite the import to retrieve the import from global registry
|
||||
s.remove(node.start!, node.end!)
|
||||
|
||||
const id = registerImport(source)
|
||||
|
||||
for (const spec of node.specifiers) {
|
||||
if (spec.type === 'ImportDefaultSpecifier') {
|
||||
s.prependRight(
|
||||
node.start!,
|
||||
`const ${spec.local.name} = ${id}.default\n`
|
||||
)
|
||||
} else if (spec.type === 'ImportSpecifier') {
|
||||
s.prependRight(
|
||||
node.start!,
|
||||
`const ${spec.local.name} = ${id}.${
|
||||
(spec.imported as Identifier).name
|
||||
}\n`
|
||||
)
|
||||
} else {
|
||||
// namespace import
|
||||
s.prependRight(node.start!, `const ${spec.local.name} = ${id}`)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (node.type === 'ExportDefaultDeclaration') {
|
||||
// export default -> mod.default = ...
|
||||
s.overwrite(node.start!, node.declaration.start!, 'mod.default = ')
|
||||
}
|
||||
|
||||
if (node.type === 'ExportNamedDeclaration') {
|
||||
if (node.source) {
|
||||
// export { foo } from '...' -> mode.foo = __import_x__.foo
|
||||
const id = registerImport(node.source.value)
|
||||
let code = ``
|
||||
for (const spec of node.specifiers) {
|
||||
if (spec.type === 'ExportSpecifier') {
|
||||
code += `mod.${(spec.exported as Identifier).name} = ${id}.${
|
||||
spec.local.name
|
||||
}\n`
|
||||
}
|
||||
}
|
||||
s.overwrite(node.start!, node.end!, code)
|
||||
} else if (node.declaration) {
|
||||
if (
|
||||
node.declaration.type === 'FunctionDeclaration' ||
|
||||
node.declaration.type === 'ClassDeclaration'
|
||||
) {
|
||||
// export function foo() {}
|
||||
const name = node.declaration.id!.name
|
||||
s.appendLeft(node.end!, `\nmod.${name} = ${name}\n`)
|
||||
} else if (node.declaration.type === 'VariableDeclaration') {
|
||||
// export const foo = 1, bar = 2
|
||||
for (const decl of node.declaration.declarations) {
|
||||
for (const { name } of extractIdentifiers(decl.id)) {
|
||||
s.appendLeft(node.end!, `\nmod.${name} = ${name}`)
|
||||
}
|
||||
}
|
||||
}
|
||||
s.remove(node.start!, node.declaration.start!)
|
||||
} else {
|
||||
let code = ``
|
||||
for (const spec of node.specifiers) {
|
||||
if (spec.type === 'ExportSpecifier') {
|
||||
code += `mod.${(spec.exported as Identifier).name} = ${
|
||||
spec.local.name
|
||||
}\n`
|
||||
}
|
||||
}
|
||||
s.overwrite(node.start!, node.end!, code)
|
||||
}
|
||||
}
|
||||
|
||||
if (node.type === 'ExportAllDeclaration') {
|
||||
const id = registerImport(node.source.value)
|
||||
s.overwrite(node.start!, node.end!, `Object.assign(mod, ${id})`)
|
||||
}
|
||||
}
|
||||
|
||||
// dynamic import
|
||||
walk(ast as any, {
|
||||
enter(node) {
|
||||
if (node.type === 'ImportExpression') {
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
// append CSS injection code
|
||||
if (css) {
|
||||
s.append(`\nwindow.__css__ += ${JSON.stringify(css)}`)
|
||||
}
|
||||
|
||||
const processed = [s.toString()]
|
||||
if (importedFiles.size) {
|
||||
for (const imported of importedFiles) {
|
||||
processed.push(...processFile(store.files[imported], seen))
|
||||
}
|
||||
}
|
||||
|
||||
// return a list of files to further process
|
||||
return processed
|
||||
}
|
||||
|
||||
function extractIdentifiers(
|
||||
param: Node,
|
||||
nodes: Identifier[] = []
|
||||
): Identifier[] {
|
||||
switch (param.type) {
|
||||
case 'Identifier':
|
||||
nodes.push(param)
|
||||
break
|
||||
|
||||
case 'MemberExpression':
|
||||
let object: any = param
|
||||
while (object.type === 'MemberExpression') {
|
||||
object = object.object
|
||||
}
|
||||
nodes.push(object)
|
||||
break
|
||||
|
||||
case 'ObjectPattern':
|
||||
param.properties.forEach(prop => {
|
||||
if (prop.type === 'RestElement') {
|
||||
extractIdentifiers(prop.argument, nodes)
|
||||
} else {
|
||||
extractIdentifiers(prop.value, nodes)
|
||||
}
|
||||
})
|
||||
break
|
||||
|
||||
case 'ArrayPattern':
|
||||
param.elements.forEach(element => {
|
||||
if (element) extractIdentifiers(element, nodes)
|
||||
})
|
||||
break
|
||||
|
||||
case 'RestElement':
|
||||
extractIdentifiers(param.argument, nodes)
|
||||
break
|
||||
|
||||
case 'AssignmentPattern':
|
||||
extractIdentifiers(param.left, nodes)
|
||||
break
|
||||
}
|
||||
|
||||
return nodes
|
||||
}
|
@ -8,76 +8,75 @@
|
||||
}
|
||||
</style>
|
||||
<style id="__sfc-styles"></style>
|
||||
<script>
|
||||
(function(){
|
||||
let scriptEl
|
||||
<script type="module">
|
||||
let scriptEl
|
||||
|
||||
function handle_message(ev) {
|
||||
let { action, cmd_id } = ev.data;
|
||||
const send_message = (payload) => parent.postMessage( { ...payload }, ev.origin);
|
||||
const send_reply = (payload) => send_message({ ...payload, cmd_id });
|
||||
const send_ok = () => send_reply({ action: 'cmd_ok' });
|
||||
const send_error = (message, stack) => send_reply({ action: 'cmd_error', message, stack });
|
||||
function handle_message(ev) {
|
||||
let { action, cmd_id } = ev.data;
|
||||
const send_message = (payload) => parent.postMessage( { ...payload }, ev.origin);
|
||||
const send_reply = (payload) => send_message({ ...payload, cmd_id });
|
||||
const send_ok = window.send_ok = () => send_reply({ action: 'cmd_ok' });
|
||||
const send_error = (message, stack) => send_reply({ action: 'cmd_error', message, stack });
|
||||
|
||||
if (action === 'eval') {
|
||||
try {
|
||||
if (scriptEl) {
|
||||
document.head.removeChild(scriptEl)
|
||||
}
|
||||
scriptEl = document.createElement('script')
|
||||
scriptEl.setAttribute('type', 'module')
|
||||
scriptEl.innerHTML = ev.data.args.script
|
||||
document.head.appendChild(scriptEl)
|
||||
send_ok();
|
||||
} catch (e) {
|
||||
send_error(e.message, e.stack);
|
||||
if (action === 'eval') {
|
||||
try {
|
||||
if (scriptEl) {
|
||||
document.head.removeChild(scriptEl)
|
||||
}
|
||||
scriptEl = document.createElement('script')
|
||||
scriptEl.setAttribute('type', 'module')
|
||||
// send ok in the module script to ensure sequential evaluation
|
||||
// of multiple proxy.eval() calls
|
||||
scriptEl.innerHTML = ev.data.args.script + `\nwindow.send_ok()`
|
||||
document.head.appendChild(scriptEl)
|
||||
} catch (e) {
|
||||
send_error(e.message, e.stack);
|
||||
}
|
||||
}
|
||||
|
||||
if (action === 'catch_clicks') {
|
||||
try {
|
||||
const top_origin = ev.origin;
|
||||
document.body.addEventListener('click', event => {
|
||||
if (event.which !== 1) return;
|
||||
if (event.metaKey || event.ctrlKey || event.shiftKey) return;
|
||||
if (event.defaultPrevented) return;
|
||||
if (action === 'catch_clicks') {
|
||||
try {
|
||||
const top_origin = ev.origin;
|
||||
document.body.addEventListener('click', event => {
|
||||
if (event.which !== 1) return;
|
||||
if (event.metaKey || event.ctrlKey || event.shiftKey) return;
|
||||
if (event.defaultPrevented) return;
|
||||
|
||||
// ensure target is a link
|
||||
let el = event.target;
|
||||
while (el && el.nodeName !== 'A') el = el.parentNode;
|
||||
if (!el || el.nodeName !== 'A') return;
|
||||
// ensure target is a link
|
||||
let el = event.target;
|
||||
while (el && el.nodeName !== 'A') el = el.parentNode;
|
||||
if (!el || el.nodeName !== 'A') return;
|
||||
|
||||
if (el.hasAttribute('download') || el.getAttribute('rel') === 'external' || el.target) return;
|
||||
if (el.hasAttribute('download') || el.getAttribute('rel') === 'external' || el.target) return;
|
||||
|
||||
event.preventDefault();
|
||||
event.preventDefault();
|
||||
|
||||
if (el.href.startsWith(top_origin)) {
|
||||
const url = new URL(el.href);
|
||||
if (url.hash[0] === '#') {
|
||||
window.location.hash = url.hash;
|
||||
return;
|
||||
}
|
||||
if (el.href.startsWith(top_origin)) {
|
||||
const url = new URL(el.href);
|
||||
if (url.hash[0] === '#') {
|
||||
window.location.hash = url.hash;
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
window.open(el.href, '_blank');
|
||||
});
|
||||
send_ok();
|
||||
} catch(e) {
|
||||
send_error(e.message, e.stack);
|
||||
}
|
||||
window.open(el.href, '_blank');
|
||||
});
|
||||
send_ok();
|
||||
} catch(e) {
|
||||
send_error(e.message, e.stack);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
window.addEventListener('message', handle_message, false);
|
||||
window.addEventListener('message', handle_message, false);
|
||||
|
||||
window.onerror = function (msg, url, lineNo, columnNo, error) {
|
||||
parent.postMessage({ action: 'error', value: error }, '*');
|
||||
}
|
||||
window.onerror = function (msg, url, lineNo, columnNo, error) {
|
||||
parent.postMessage({ action: 'error', value: error }, '*');
|
||||
}
|
||||
|
||||
window.addEventListener("unhandledrejection", event => {
|
||||
parent.postMessage({ action: 'unhandledrejection', value: event.reason }, '*');
|
||||
});
|
||||
}).call(this);
|
||||
window.addEventListener("unhandledrejection", event => {
|
||||
parent.postMessage({ action: 'unhandledrejection', value: event.reason }, '*');
|
||||
});
|
||||
|
||||
let previous = { level: null, args: null };
|
||||
|
||||
|
@ -4,14 +4,12 @@ import {
|
||||
compileTemplate,
|
||||
compileStyleAsync,
|
||||
compileScript,
|
||||
rewriteDefault,
|
||||
CompilerError
|
||||
rewriteDefault
|
||||
} from '@vue/compiler-sfc'
|
||||
|
||||
const storeKey = 'sfc-code'
|
||||
const saved =
|
||||
localStorage.getItem(storeKey) ||
|
||||
`
|
||||
const STORAGE_KEY = 'vue-sfc-playground'
|
||||
|
||||
const welcomeCode = `
|
||||
<template>
|
||||
<h1>{{ msg }}</h1>
|
||||
</template>
|
||||
@ -19,42 +17,93 @@ const saved =
|
||||
<script setup>
|
||||
const msg = 'Hello World!'
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
h1 {
|
||||
color: #42b983;
|
||||
}
|
||||
</style>
|
||||
`.trim()
|
||||
|
||||
export const MAIN_FILE = 'App.vue'
|
||||
export const COMP_IDENTIFIER = `__sfc__`
|
||||
|
||||
// @ts-ignore
|
||||
export const sandboxVueURL = import.meta.env.PROD
|
||||
export const SANDBOX_VUE_URL = import.meta.env.PROD
|
||||
? '/vue.runtime.esm-browser.js' // to be copied on build
|
||||
: '/src/vue-dev-proxy'
|
||||
|
||||
export const store = reactive({
|
||||
code: saved,
|
||||
compiled: {
|
||||
executed: '',
|
||||
export class File {
|
||||
filename: string
|
||||
code: string
|
||||
compiled = {
|
||||
js: '',
|
||||
css: '',
|
||||
template: ''
|
||||
css: ''
|
||||
}
|
||||
|
||||
constructor(filename: string, code = '') {
|
||||
this.filename = filename
|
||||
this.code = code
|
||||
}
|
||||
}
|
||||
|
||||
interface Store {
|
||||
files: Record<string, File>
|
||||
activeFilename: string
|
||||
readonly activeFile: File
|
||||
errors: (string | Error)[]
|
||||
}
|
||||
|
||||
const savedFiles = localStorage.getItem(STORAGE_KEY)
|
||||
const files = savedFiles
|
||||
? JSON.parse(savedFiles)
|
||||
: {
|
||||
'App.vue': new File(MAIN_FILE, welcomeCode)
|
||||
}
|
||||
|
||||
export const store: Store = reactive({
|
||||
files,
|
||||
activeFilename: MAIN_FILE,
|
||||
get activeFile() {
|
||||
return store.files[store.activeFilename]
|
||||
},
|
||||
errors: [] as (string | CompilerError | SyntaxError)[]
|
||||
errors: []
|
||||
})
|
||||
|
||||
const filename = 'Playground.vue'
|
||||
const id = 'scope-id'
|
||||
const compIdentifier = `__comp`
|
||||
for (const file in store.files) {
|
||||
if (file !== MAIN_FILE) {
|
||||
compileFile(store.files[file])
|
||||
}
|
||||
}
|
||||
|
||||
watchEffect(async () => {
|
||||
const { code, compiled } = store
|
||||
watchEffect(() => compileFile(store.activeFile))
|
||||
watchEffect(() => {
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(store.files))
|
||||
})
|
||||
|
||||
export function setActive(filename: string) {
|
||||
store.activeFilename = filename
|
||||
}
|
||||
|
||||
export function addFile(filename: string) {
|
||||
store.files[filename] = new File(filename)
|
||||
setActive(filename)
|
||||
}
|
||||
|
||||
export function deleteFile(filename: string) {
|
||||
if (confirm(`Are you sure you want to delete ${filename}?`)) {
|
||||
if (store.activeFilename === filename) {
|
||||
store.activeFilename = MAIN_FILE
|
||||
}
|
||||
delete store.files[filename]
|
||||
}
|
||||
}
|
||||
|
||||
async function compileFile({ filename, code, compiled }: File) {
|
||||
if (!code.trim()) {
|
||||
return
|
||||
}
|
||||
|
||||
localStorage.setItem(storeKey, code)
|
||||
if (filename.endsWith('.js')) {
|
||||
compiled.js = code
|
||||
return
|
||||
}
|
||||
|
||||
const id = await hashId(filename)
|
||||
const { errors, descriptor } = parse(code, { filename, sourceMap: true })
|
||||
if (errors.length) {
|
||||
store.errors = errors
|
||||
@ -84,20 +133,14 @@ watchEffect(async () => {
|
||||
refSugar: true,
|
||||
inlineTemplate: true
|
||||
})
|
||||
compiled.js = compiledScript.content.trim()
|
||||
finalCode +=
|
||||
`\n` +
|
||||
rewriteDefault(
|
||||
rewriteVueImports(compiledScript.content),
|
||||
compIdentifier
|
||||
)
|
||||
`\n` + rewriteDefault(compiledScript.content, COMP_IDENTIFIER)
|
||||
} catch (e) {
|
||||
store.errors = [e]
|
||||
return
|
||||
}
|
||||
} else {
|
||||
compiled.js = ''
|
||||
finalCode += `\nconst ${compIdentifier} = {}`
|
||||
finalCode += `\nconst ${COMP_IDENTIFIER} = {}`
|
||||
}
|
||||
|
||||
// template
|
||||
@ -115,25 +158,25 @@ watchEffect(async () => {
|
||||
return
|
||||
}
|
||||
|
||||
compiled.template = templateResult.code.trim()
|
||||
finalCode +=
|
||||
`\n` +
|
||||
rewriteVueImports(templateResult.code).replace(
|
||||
templateResult.code.replace(
|
||||
/\nexport (function|const) render/,
|
||||
'$1 render'
|
||||
)
|
||||
finalCode += `\n${compIdentifier}.render = render`
|
||||
} else {
|
||||
compiled.template = descriptor.scriptSetup
|
||||
? '/* inlined in JS (script setup) */'
|
||||
: '/* no template present */'
|
||||
finalCode += `\n${COMP_IDENTIFIER}.render = render`
|
||||
}
|
||||
if (hasScoped) {
|
||||
finalCode += `\n${compIdentifier}.__scopeId = ${JSON.stringify(
|
||||
finalCode += `\n${COMP_IDENTIFIER}.__scopeId = ${JSON.stringify(
|
||||
`data-v-${id}`
|
||||
)}`
|
||||
}
|
||||
|
||||
if (finalCode) {
|
||||
finalCode += `\nexport default ${COMP_IDENTIFIER}`
|
||||
compiled.js = finalCode.trimStart()
|
||||
}
|
||||
|
||||
// styles
|
||||
let css = ''
|
||||
for (const style of descriptor.styles) {
|
||||
@ -162,25 +205,18 @@ watchEffect(async () => {
|
||||
}
|
||||
if (css) {
|
||||
compiled.css = css.trim()
|
||||
finalCode += `\ndocument.getElementById('__sfc-styles').innerHTML = ${JSON.stringify(
|
||||
css
|
||||
)}`
|
||||
} else {
|
||||
compiled.css = ''
|
||||
compiled.css = '/* No <style> tags present */'
|
||||
}
|
||||
|
||||
// clear errors
|
||||
store.errors = []
|
||||
if (finalCode) {
|
||||
compiled.executed =
|
||||
`/* Exact code being executed in the preview iframe (different from production bundler output) */\n` +
|
||||
finalCode
|
||||
}
|
||||
})
|
||||
|
||||
// TODO use proper parser
|
||||
function rewriteVueImports(code: string): string {
|
||||
return code.replace(
|
||||
/\b(import \{.*?\}\s+from\s+)(?:"vue"|'vue')/g,
|
||||
`$1"${sandboxVueURL}"`
|
||||
)
|
||||
}
|
||||
|
||||
async function hashId(filename: string) {
|
||||
const msgUint8 = new TextEncoder().encode(filename) // encode as (utf-8) Uint8Array
|
||||
const hashBuffer = await crypto.subtle.digest('SHA-256', msgUint8) // hash the message
|
||||
const hashArray = Array.from(new Uint8Array(hashBuffer)) // convert buffer to byte array
|
||||
const hashHex = hashArray.map(b => b.toString(16).padStart(2, '0')).join('') // convert bytes to hex string
|
||||
return hashHex.slice(0, 8)
|
||||
}
|
||||
|
Loading…
Reference in New Issue
Block a user