workflow(sfc-playground): support multiple files

This commit is contained in:
Evan You 2021-03-28 18:41:33 -04:00
parent 2e3984fd5b
commit d1bf35c8b8
10 changed files with 555 additions and 158 deletions

View File

@ -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 {

View File

@ -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 {

View File

@ -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 {

View File

@ -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>

View 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>

View File

@ -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>

View File

@ -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(() => {

View 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
}

View File

@ -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 };

View File

@ -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)
}