]> git.ipfire.org Git - thirdparty/vuejs/core.git/commitdiff
workflow(sfc-playground): support multiple files
authorEvan You <yyx990803@gmail.com>
Sun, 28 Mar 2021 22:41:33 +0000 (18:41 -0400)
committerEvan You <yyx990803@gmail.com>
Sun, 28 Mar 2021 22:41:33 +0000 (18:41 -0400)
packages/compiler-sfc/src/index.ts
packages/sfc-playground/src/App.vue
packages/sfc-playground/src/Message.vue
packages/sfc-playground/src/editor/Editor.vue
packages/sfc-playground/src/editor/FileSelector.vue [new file with mode: 0644]
packages/sfc-playground/src/output/Output.vue
packages/sfc-playground/src/output/Preview.vue
packages/sfc-playground/src/output/moduleCompiler.ts [new file with mode: 0644]
packages/sfc-playground/src/output/srcdoc.html
packages/sfc-playground/src/store.ts

index a38d968a39dc1fb9e1e3a3c66107ea0beb692e02..9acc94466d4e4512a88a3e6031ee4384ce9d84ea 100644 (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 {
index e032f703adbc71239e920ea7f25166129c0a01b7..f39ec3ab1fc4278e4685bfd2ac711849119805fb 100644 (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 {
index e2512b181168c8a58cdc8fd00ab7278e39b49231..7cc23995554ebe20118099a13ec2a0ca8c24c50f 100644 (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 {
index 8be5d0fc5f17f01e549cf8609e969afb3714306b..86f4f67342562a50967f84d1f2c29a7a9285eace 100644 (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>
\ No newline at end of file
+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>
diff --git a/packages/sfc-playground/src/editor/FileSelector.vue b/packages/sfc-playground/src/editor/FileSelector.vue
new file mode 100644 (file)
index 0000000..23c6d4f
--- /dev/null
@@ -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>
index 5116ea2f3fc83671ad7ff62990264995a459bac2..0027cdaed049bdb9465ba50b4e37094f4e572e8a 100644 (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>
index 8ef3b1014419104660f8baef87a5cbf25cf2c8d2..ef76c23f3112cfa1492c85d00e83cb1e49537462 100644 (file)
 
 <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
-  }
+  runtimeError.value = null
+  runtimeWarning.value = null
   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)
-  `)
+    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.message
-    return
+    runtimeError.value = e.stack
   }
-  runtimeError.value = null
-  runtimeWarning.value = null
 }
 
 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(() => {
diff --git a/packages/sfc-playground/src/output/moduleCompiler.ts b/packages/sfc-playground/src/output/moduleCompiler.ts
new file mode 100644 (file)
index 0000000..82ffd22
--- /dev/null
@@ -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
+}
index 7ef13dce4c7cf19c0cbf83d29ab1d9a5479ecff0..31e033542f2a027960d013c8c293afc086ab6629 100644 (file)
@@ -8,76 +8,75 @@
                        }
                </style>
                <style id="__sfc-styles"></style>
-               <script>
-                       (function(){
-                               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 });
-
-                                       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);
+               <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 = 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')
+                                               // 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;
-
-                                                               // 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;
-
-                                                               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 (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;
+
+                                                       if (el.hasAttribute('download') || el.getAttribute('rel') === 'external' || el.target) return;
+
+                                                       event.preventDefault();
+
+                                                       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 };
 
index d3a892acbb45d3afdc50b9ca9098c61a41007ae3..0eb121b109c6266ceedea3634ca083172d181d6c 100644 (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: []
+})
+
+for (const file in store.files) {
+  if (file !== MAIN_FILE) {
+    compileFile(store.files[file])
+  }
+}
+
+watchEffect(() => compileFile(store.activeFile))
+watchEffect(() => {
+  localStorage.setItem(STORAGE_KEY, JSON.stringify(store.files))
 })
 
-const filename = 'Playground.vue'
-const id = 'scope-id'
-const compIdentifier = `__comp`
+export function setActive(filename: string) {
+  store.activeFilename = filename
+}
+
+export function addFile(filename: string) {
+  store.files[filename] = new File(filename)
+  setActive(filename)
+}
 
-watchEffect(async () => {
-  const { code, compiled } = store
+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)
 }