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

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