├── .node-version ├── ssr-stub.js ├── .prettierignore ├── .prettierrc ├── src ├── vue-dev-proxy.ts ├── template │ ├── new-sfc.vue │ └── welcome.vue ├── vue-server-renderer-dev-proxy.ts ├── env.d.ts ├── jsx.ts ├── index.ts ├── monaco │ ├── utils.ts │ ├── highlight.ts │ ├── Monaco.vue │ ├── env.ts │ ├── vue.worker.ts │ └── language-configs.ts ├── core.ts ├── editor │ ├── MonacoEditor.vue │ ├── CodeMirrorEditor.vue │ ├── ToggleButton.vue │ ├── EditorContainer.vue │ └── FileSelector.vue ├── output │ ├── SsrOutput.vue │ ├── Preview.vue │ ├── PreviewProxy.ts │ ├── Output.vue │ ├── moduleCompiler.ts │ ├── Sandbox.vue │ └── srcdoc.html ├── types.ts ├── codemirror │ ├── codemirror.ts │ ├── CodeMirror.vue │ └── codemirror.css ├── utils.ts ├── import-map.ts ├── Message.vue ├── sourcemap.ts ├── Repl.vue ├── SplitPane.vue ├── transform.ts └── store.ts ├── .git-blame-ignore-revs ├── .gitignore ├── .github ├── renovate.json5 └── workflows │ ├── release-continuous.yml │ └── release.yml ├── index.html ├── tsconfig.json ├── vite.preview.config.ts ├── index-dist.html ├── LICENSE ├── eslint.config.js ├── vite.config.ts ├── test └── main.ts ├── package.json └── README.md /.node-version: -------------------------------------------------------------------------------- 1 | lts/* 2 | -------------------------------------------------------------------------------- /ssr-stub.js: -------------------------------------------------------------------------------- 1 | module.exports = {} 2 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | dist 2 | CHANGELOG.md 3 | pnpm-lock.yaml 4 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "semi": false, 3 | "singleQuote": true 4 | } 5 | -------------------------------------------------------------------------------- /src/vue-dev-proxy.ts: -------------------------------------------------------------------------------- 1 | // serve vue to the iframe sandbox during dev. 2 | export * from 'vue' 3 | -------------------------------------------------------------------------------- /.git-blame-ignore-revs: -------------------------------------------------------------------------------- 1 | # style: add trailing comma 2 | 497f07527b162f42123ead110031f265981f4d4d 3 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | .DS_Store 3 | dist 4 | TODOs.md 5 | 6 | # jetbrains files 7 | .idea 8 | -------------------------------------------------------------------------------- /src/template/new-sfc.vue: -------------------------------------------------------------------------------- 1 | 2 | 3 | 8 | -------------------------------------------------------------------------------- /src/vue-server-renderer-dev-proxy.ts: -------------------------------------------------------------------------------- 1 | // serve server renderer to the iframe sandbox during dev. 2 | export * from 'vue/server-renderer' 3 | -------------------------------------------------------------------------------- /src/env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | 3 | declare module '*.vue' { 4 | import type { ComponentOptions } from 'vue' 5 | const comp: ComponentOptions 6 | export default comp 7 | } 8 | -------------------------------------------------------------------------------- /src/jsx.ts: -------------------------------------------------------------------------------- 1 | import { transform } from '@babel/standalone' 2 | import jsx from '@vue/babel-plugin-jsx' 3 | 4 | export function transformJSX(src: string) { 5 | return transform(src, { 6 | plugins: [jsx], 7 | }).code! 8 | } 9 | -------------------------------------------------------------------------------- /src/template/welcome.vue: -------------------------------------------------------------------------------- 1 | 6 | 7 | 11 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | export { default as Repl, type Props as ReplProps } from './Repl.vue' 2 | export { default as Preview } from './output/Preview.vue' 3 | export { default as Sandbox, type SandboxProps } from './output/Sandbox.vue' 4 | export type { OutputModes } from './types' 5 | export * from './core' 6 | -------------------------------------------------------------------------------- /src/monaco/utils.ts: -------------------------------------------------------------------------------- 1 | import { type Uri, editor } from 'monaco-editor-core' 2 | 3 | export function getOrCreateModel( 4 | uri: Uri, 5 | lang: string | undefined, 6 | value: string, 7 | ) { 8 | const model = editor.getModel(uri) 9 | if (model) { 10 | model.setValue(value) 11 | return model 12 | } 13 | return editor.createModel(value, lang, uri) 14 | } 15 | -------------------------------------------------------------------------------- /src/core.ts: -------------------------------------------------------------------------------- 1 | export { 2 | useStore, 3 | File, 4 | type SFCOptions, 5 | type StoreState, 6 | type Store, 7 | type ReplStore, 8 | } from './store' 9 | export { useVueImportMap, mergeImportMap, type ImportMap } from './import-map' 10 | export { compileFile } from './transform' 11 | export { version as languageToolsVersion } from '@vue/language-service/package.json' 12 | -------------------------------------------------------------------------------- /src/editor/MonacoEditor.vue: -------------------------------------------------------------------------------- 1 | 16 | 17 | 26 | -------------------------------------------------------------------------------- /.github/renovate.json5: -------------------------------------------------------------------------------- 1 | { 2 | $schema: 'https://docs.renovatebot.com/renovate-schema.json', 3 | extends: [ 4 | 'config:base', 5 | 'schedule:weekly', 6 | 'group:allNonMajor', 7 | ':semanticCommitTypeAll(chore)', 8 | ], 9 | labels: ['dependencies'], 10 | rangeStrategy: 'bump', 11 | packageRules: [ 12 | { 13 | depTypeList: ['peerDependencies'], 14 | enabled: false, 15 | }, 16 | { 17 | matchPackageNames: ['codemirror'], 18 | matchUpdateTypes: ['major'], 19 | enabled: false, 20 | }, 21 | ], 22 | postUpdateOptions: ['pnpmDedupe'], 23 | } 24 | -------------------------------------------------------------------------------- /index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | Vue SFC Playground 8 | 17 | 18 | 19 | 20 | 21 |
22 | 23 | 24 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "outDir": "dist", 4 | "sourceMap": false, 5 | "target": "esnext", 6 | "useDefineForClassFields": false, 7 | "module": "esnext", 8 | "moduleResolution": "bundler", 9 | "allowJs": false, 10 | "strict": true, 11 | "noUnusedLocals": true, 12 | "experimentalDecorators": true, 13 | "resolveJsonModule": true, 14 | "esModuleInterop": true, 15 | "removeComments": false, 16 | "lib": ["esnext", "dom"], 17 | "jsx": "preserve", 18 | "rootDir": ".", 19 | "skipLibCheck": true 20 | }, 21 | "include": ["src", "test", "vite.config.ts"], 22 | "exclude": ["src/template"] 23 | } 24 | -------------------------------------------------------------------------------- /.github/workflows/release-continuous.yml: -------------------------------------------------------------------------------- 1 | name: Publish Any Commit 2 | on: [push, pull_request] 3 | 4 | jobs: 5 | build: 6 | runs-on: ubuntu-latest 7 | 8 | steps: 9 | - name: Checkout code 10 | uses: actions/checkout@v5 11 | 12 | - name: Install pnpm 13 | uses: pnpm/action-setup@v4 14 | 15 | - name: Install Node.js 16 | uses: actions/setup-node@v5 17 | with: 18 | node-version-file: '.node-version' 19 | cache: 'pnpm' 20 | 21 | - name: Install dependencies 22 | run: pnpm install 23 | 24 | - name: Build 25 | run: pnpm build 26 | 27 | - run: pnpx pkg-pr-new publish --compact 28 | -------------------------------------------------------------------------------- /src/output/SsrOutput.vue: -------------------------------------------------------------------------------- 1 | 7 | 8 | 16 | 17 | 33 | -------------------------------------------------------------------------------- /src/types.ts: -------------------------------------------------------------------------------- 1 | import type { Component, ComputedRef, InjectionKey, ToRefs } from 'vue' 2 | import { Props } from './Repl.vue' 3 | 4 | export type EditorMode = 'js' | 'css' | 'ssr' 5 | export interface EditorProps { 6 | value: string 7 | filename: string 8 | readonly?: boolean 9 | mode?: EditorMode 10 | } 11 | export interface EditorEmits { 12 | (e: 'change', code: string): void 13 | } 14 | export type EditorComponentType = Component 15 | 16 | export type OutputModes = 'preview' | 'ssr output' | EditorMode 17 | 18 | export const injectKeyProps: InjectionKey< 19 | ToRefs> 20 | > = Symbol('props') 21 | export const injectKeyPreviewRef: InjectionKey< 22 | ComputedRef 23 | > = Symbol('preview-ref') 24 | -------------------------------------------------------------------------------- /vite.preview.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from 'vite' 2 | import vue from '@vitejs/plugin-vue' 3 | import replace from '@rollup/plugin-replace' 4 | 5 | export default defineConfig({ 6 | plugins: [vue()], 7 | resolve: { 8 | alias: { 9 | '@vue/compiler-dom': '@vue/compiler-dom/dist/compiler-dom.cjs.js', 10 | '@vue/compiler-core': '@vue/compiler-core/dist/compiler-core.cjs.js', 11 | }, 12 | }, 13 | build: { 14 | commonjsOptions: { 15 | ignore: ['typescript'], 16 | }, 17 | }, 18 | worker: { 19 | format: 'es', 20 | plugins: () => [ 21 | replace({ 22 | preventAssignment: true, 23 | values: { 24 | 'process.env.NODE_ENV': JSON.stringify('production'), 25 | }, 26 | }), 27 | ], 28 | }, 29 | }) 30 | -------------------------------------------------------------------------------- /index-dist.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | Vue SFC Playground 8 | 17 | 18 | 26 | 27 | 28 |
29 | 30 | 31 | -------------------------------------------------------------------------------- /src/codemirror/codemirror.ts: -------------------------------------------------------------------------------- 1 | import CodeMirror from 'codemirror' 2 | import 'codemirror/addon/dialog/dialog.css' 3 | import './codemirror.css' 4 | 5 | // modes 6 | import 'codemirror/mode/javascript/javascript.js' 7 | import 'codemirror/mode/css/css.js' 8 | import 'codemirror/mode/htmlmixed/htmlmixed.js' 9 | 10 | // addons 11 | import 'codemirror/addon/edit/closebrackets.js' 12 | import 'codemirror/addon/edit/closetag.js' 13 | import 'codemirror/addon/comment/comment.js' 14 | import 'codemirror/addon/fold/foldcode.js' 15 | import 'codemirror/addon/fold/foldgutter.js' 16 | import 'codemirror/addon/fold/brace-fold.js' 17 | import 'codemirror/addon/fold/indent-fold.js' 18 | import 'codemirror/addon/fold/comment-fold.js' 19 | import 'codemirror/addon/search/search.js' 20 | import 'codemirror/addon/search/searchcursor.js' 21 | import 'codemirror/addon/dialog/dialog.js' 22 | 23 | // keymap 24 | import 'codemirror/keymap/sublime.js' 25 | 26 | export default CodeMirror 27 | -------------------------------------------------------------------------------- /src/output/Preview.vue: -------------------------------------------------------------------------------- 1 | 23 | 24 | 35 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Release 2 | 3 | on: 4 | push: 5 | tags: 6 | - 'v*' 7 | 8 | jobs: 9 | release: 10 | runs-on: ubuntu-latest 11 | permissions: 12 | contents: write 13 | id-token: write 14 | 15 | steps: 16 | - uses: actions/checkout@v5 17 | with: 18 | fetch-depth: 0 19 | 20 | - name: Install pnpm 21 | uses: pnpm/action-setup@v4 22 | 23 | - name: Install Node.js 24 | uses: actions/setup-node@v5 25 | with: 26 | node-version-file: '.node-version' 27 | cache: pnpm 28 | registry-url: 'https://registry.npmjs.org' 29 | 30 | - run: npx changelogithub 31 | continue-on-error: true 32 | env: 33 | GITHUB_TOKEN: ${{secrets.GITHUB_TOKEN}} 34 | 35 | - name: Install Dependencies 36 | run: pnpm i 37 | 38 | - name: Publish to NPM 39 | run: npm i -g npm && pnpm -r publish --access public --no-git-checks 40 | -------------------------------------------------------------------------------- /src/monaco/highlight.ts: -------------------------------------------------------------------------------- 1 | import * as monaco from 'monaco-editor-core' 2 | import { createHighlighterCoreSync } from 'shiki/core' 3 | import { createJavaScriptRegexEngine } from 'shiki/engine-javascript.mjs' 4 | import { shikiToMonaco } from '@shikijs/monaco' 5 | 6 | import langVue from 'shiki/langs/vue.mjs' 7 | import langTsx from 'shiki/langs/tsx.mjs' 8 | import langJsx from 'shiki/langs/jsx.mjs' 9 | import themeDark from 'shiki/themes/dark-plus.mjs' 10 | import themeLight from 'shiki/themes/light-plus.mjs' 11 | 12 | let registered = false 13 | export function registerHighlighter() { 14 | if (!registered) { 15 | const highlighter = createHighlighterCoreSync({ 16 | themes: [themeDark, themeLight], 17 | langs: [langVue, langTsx, langJsx], 18 | engine: createJavaScriptRegexEngine(), 19 | }) 20 | monaco.languages.register({ id: 'vue' }) 21 | shikiToMonaco(highlighter, monaco) 22 | registered = true 23 | } 24 | 25 | return { 26 | light: themeLight.name!, 27 | dark: themeDark.name!, 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /src/utils.ts: -------------------------------------------------------------------------------- 1 | import { strFromU8, strToU8, unzlibSync, zlibSync } from 'fflate' 2 | 3 | export function debounce(fn: Function, n = 100) { 4 | let handle: any 5 | return (...args: any[]) => { 6 | if (handle) clearTimeout(handle) 7 | handle = setTimeout(() => { 8 | fn(...args) 9 | }, n) 10 | } 11 | } 12 | 13 | export function utoa(data: string): string { 14 | const buffer = strToU8(data) 15 | const zipped = zlibSync(buffer, { level: 9 }) 16 | const binary = strFromU8(zipped, true) 17 | return btoa(binary) 18 | } 19 | 20 | export function atou(base64: string): string { 21 | const binary = atob(base64) 22 | 23 | // zlib header (x78), level 9 (xDA) 24 | if (binary.startsWith('\x78\xDA')) { 25 | const buffer = strToU8(binary, true) 26 | const unzipped = unzlibSync(buffer) 27 | return strFromU8(unzipped) 28 | } 29 | 30 | // old unicode hacks for backward compatibility 31 | // https://base64.guru/developers/javascript/examples/unicode-strings 32 | return decodeURIComponent(escape(binary)) 33 | } 34 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2021-present, Yuxi (Evan) You 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in 13 | all copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 21 | THE SOFTWARE. 22 | -------------------------------------------------------------------------------- /src/editor/CodeMirrorEditor.vue: -------------------------------------------------------------------------------- 1 | 40 | 41 | 49 | -------------------------------------------------------------------------------- /src/editor/ToggleButton.vue: -------------------------------------------------------------------------------- 1 | 5 | 6 | 14 | 15 | 53 | -------------------------------------------------------------------------------- /eslint.config.js: -------------------------------------------------------------------------------- 1 | import eslint from '@eslint/js' 2 | import tseslint from 'typescript-eslint' 3 | import pluginVue from 'eslint-plugin-vue' 4 | 5 | export default tseslint.config( 6 | { ignores: ['**/node_modules', '**/dist'] }, 7 | eslint.configs.recommended, 8 | tseslint.configs.base, 9 | ...pluginVue.configs['flat/recommended'], 10 | { 11 | files: ['**/*.vue'], 12 | languageOptions: { 13 | parserOptions: { 14 | parser: '@typescript-eslint/parser', 15 | }, 16 | }, 17 | }, 18 | { 19 | rules: { 20 | 'no-debugger': 'error', 21 | 'no-console': ['error', { allow: ['warn', 'error', 'info', 'clear'] }], 22 | 'no-unused-vars': 'off', 23 | 'no-undef': 'off', 24 | 'prefer-const': 'error', 25 | 'sort-imports': ['error', { ignoreDeclarationSort: true }], 26 | 'no-duplicate-imports': 'error', 27 | // This rule enforces the preference for using '@ts-expect-error' comments in TypeScript 28 | // code to indicate intentional type errors, improving code clarity and maintainability. 29 | '@typescript-eslint/prefer-ts-expect-error': 'error', 30 | // Enforce the use of 'import type' for importing types 31 | '@typescript-eslint/consistent-type-imports': [ 32 | 'error', 33 | { 34 | fixStyle: 'inline-type-imports', 35 | disallowTypeAnnotations: false, 36 | }, 37 | ], 38 | // Enforce the use of top-level import type qualifier when an import only has specifiers with inline type qualifiers 39 | '@typescript-eslint/no-import-type-side-effects': 'error', 40 | 'vue/max-attributes-per-line': 'off', 41 | 'vue/singleline-html-element-content-newline': 'off', 42 | 'vue/multi-word-component-names': 'off', 43 | 'vue/html-self-closing': [ 44 | 'error', 45 | { 46 | html: { component: 'always', normal: 'always', void: 'any' }, 47 | math: 'always', 48 | svg: 'always', 49 | }, 50 | ], 51 | }, 52 | }, 53 | ) 54 | -------------------------------------------------------------------------------- /vite.config.ts: -------------------------------------------------------------------------------- 1 | import { type Plugin, mergeConfig } from 'vite' 2 | import dts from 'vite-plugin-dts' 3 | import base from './vite.preview.config' 4 | import fs from 'node:fs' 5 | import path from 'node:path' 6 | 7 | const genStub: Plugin = { 8 | name: 'gen-stub', 9 | apply: 'build', 10 | generateBundle() { 11 | this.emitFile({ 12 | type: 'asset', 13 | fileName: 'ssr-stub.js', 14 | source: `module.exports = {}`, 15 | }) 16 | }, 17 | } 18 | 19 | /** 20 | * Patch generated entries and import their corresponding CSS files. 21 | * Also normalize MonacoEditor.css 22 | */ 23 | const patchCssFiles: Plugin = { 24 | name: 'patch-css', 25 | apply: 'build', 26 | writeBundle() { 27 | // inject css imports to the files 28 | const outDir = path.resolve('dist') 29 | ;['vue-repl', 'monaco-editor', 'codemirror-editor'].forEach((file) => { 30 | const filePath = path.resolve(outDir, file + '.js') 31 | const content = fs.readFileSync(filePath, 'utf-8') 32 | fs.writeFileSync(filePath, `import './${file}.css'\n${content}`) 33 | }) 34 | }, 35 | } 36 | 37 | export default mergeConfig(base, { 38 | plugins: [ 39 | dts({ 40 | rollupTypes: true, 41 | }), 42 | genStub, 43 | patchCssFiles, 44 | ], 45 | optimizeDeps: { 46 | // avoid late discovered deps 47 | include: [ 48 | 'typescript', 49 | 'monaco-editor-core/esm/vs/editor/editor.worker', 50 | 'vue/server-renderer', 51 | ], 52 | }, 53 | base: './', 54 | build: { 55 | target: 'esnext', 56 | minify: false, 57 | lib: { 58 | entry: { 59 | 'vue-repl': './src/index.ts', 60 | core: './src/core.ts', 61 | 'monaco-editor': './src/editor/MonacoEditor.vue', 62 | 'codemirror-editor': './src/editor/CodeMirrorEditor.vue', 63 | }, 64 | formats: ['es'], 65 | fileName: () => '[name].js', 66 | }, 67 | cssCodeSplit: true, 68 | rollupOptions: { 69 | output: { 70 | chunkFileNames: 'chunks/[name]-[hash].js', 71 | }, 72 | external: ['vue', 'vue/compiler-sfc'], 73 | }, 74 | }, 75 | }) 76 | -------------------------------------------------------------------------------- /src/editor/EditorContainer.vue: -------------------------------------------------------------------------------- 1 | 35 | 36 | 60 | 61 | 82 | -------------------------------------------------------------------------------- /src/import-map.ts: -------------------------------------------------------------------------------- 1 | import { computed, version as currentVersion, ref } from 'vue' 2 | 3 | export function getVersions(version: string): number[] { 4 | return version.split('.').map((v) => parseInt(v, 10)) 5 | } 6 | 7 | export function isVaporSupported(version: string): boolean{ 8 | const [major, minor] = getVersions(version) 9 | // vapor mode is supported in v3.6+ 10 | return major > 3 || (major === 3 && minor >= 6) 11 | } 12 | 13 | export function useVueImportMap( 14 | defaults: { 15 | runtimeDev?: string | (() => string) 16 | runtimeProd?: string | (() => string) 17 | serverRenderer?: string | (() => string) 18 | vueVersion?: string | null 19 | } = {}, 20 | ) { 21 | function normalizeDefaults(defaults?: string | (() => string)) { 22 | if (!defaults) return 23 | return typeof defaults === 'string' ? defaults : defaults() 24 | } 25 | 26 | const productionMode = ref(false) 27 | const vueVersion = ref(defaults.vueVersion || null) 28 | 29 | function getVueURL() { 30 | const version = vueVersion.value || currentVersion 31 | return isVaporSupported(version) 32 | ? `https://cdn.jsdelivr.net/npm/vue@${version}/dist/vue.runtime-with-vapor.esm-browser${productionMode.value ? `.prod` : ``}.js` 33 | : `https://cdn.jsdelivr.net/npm/@vue/runtime-dom@${version}/dist/runtime-dom.esm-browser${productionMode.value ? `.prod` : ``}.js` 34 | } 35 | 36 | const importMap = computed(() => { 37 | const vue = 38 | (!vueVersion.value && 39 | normalizeDefaults( 40 | productionMode.value ? defaults.runtimeProd : defaults.runtimeDev, 41 | )) || 42 | getVueURL() 43 | 44 | const serverRenderer = 45 | (!vueVersion.value && normalizeDefaults(defaults.serverRenderer)) || 46 | `https://cdn.jsdelivr.net/npm/@vue/server-renderer@${ 47 | vueVersion.value || currentVersion 48 | }/dist/server-renderer.esm-browser.js` 49 | return { 50 | imports: { 51 | vue, 52 | 'vue/server-renderer': serverRenderer, 53 | }, 54 | } 55 | }) 56 | 57 | return { 58 | productionMode, 59 | importMap, 60 | vueVersion, 61 | defaultVersion: currentVersion, 62 | } 63 | } 64 | 65 | export interface ImportMap { 66 | imports?: Record 67 | scopes?: Record> 68 | } 69 | 70 | export function mergeImportMap(a: ImportMap, b: ImportMap): ImportMap { 71 | return { 72 | imports: { ...a.imports, ...b.imports }, 73 | scopes: { ...a.scopes, ...b.scopes }, 74 | } 75 | } 76 | -------------------------------------------------------------------------------- /test/main.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/prefer-ts-expect-error */ 2 | import { createApp, h, ref, watchEffect } from 'vue' 3 | import { type OutputModes, Repl, useStore, useVueImportMap } from '../src' 4 | // @ts-ignore 5 | import MonacoEditor from '../src/editor/MonacoEditor.vue' 6 | // @ts-ignore 7 | import CodeMirrorEditor from '../src/editor/CodeMirrorEditor.vue' 8 | 9 | const window = globalThis.window as any 10 | window.process = { env: {} } 11 | 12 | const App = { 13 | setup() { 14 | const query = new URLSearchParams(location.search) 15 | const { importMap: builtinImportMap, vueVersion } = useVueImportMap({ 16 | runtimeDev: import.meta.env.PROD 17 | ? undefined 18 | : `${location.origin}/src/vue-dev-proxy`, 19 | serverRenderer: import.meta.env.PROD 20 | ? undefined 21 | : `${location.origin}/src/vue-server-renderer-dev-proxy`, 22 | }) 23 | const store = (window.store = useStore( 24 | { 25 | builtinImportMap, 26 | vueVersion, 27 | showOutput: ref(query.has('so')), 28 | outputMode: ref((query.get('om') as OutputModes) || 'preview'), 29 | }, 30 | location.hash, 31 | )) 32 | console.info(store) 33 | 34 | watchEffect(() => history.replaceState({}, '', store.serialize())) 35 | 36 | // setTimeout(() => { 37 | // store.setFiles( 38 | // { 39 | // 'src/index.html': '

yo

', 40 | // 'src/main.js': 'document.body.innerHTML = "

hello

"', 41 | // 'src/foo.js': 'document.body.innerHTML = "

hello

"', 42 | // 'src/bar.js': 'document.body.innerHTML = "

hello

"', 43 | // 'src/baz.js': 'document.body.innerHTML = "

hello

"', 44 | // }, 45 | // 'src/index.html', 46 | // ) 47 | // }, 1000) 48 | 49 | // store.vueVersion = '3.4.1' 50 | const theme = ref<'light' | 'dark'>('dark') 51 | window.theme = theme 52 | const previewTheme = ref(false) 53 | window.previewTheme = previewTheme 54 | 55 | return () => 56 | h(Repl, { 57 | store, 58 | theme: theme.value, 59 | previewTheme: previewTheme.value, 60 | editor: MonacoEditor, 61 | showOpenSourceMap: true, 62 | // layout: 'vertical', 63 | ssr: true, 64 | showSsrOutput: true, 65 | sfcOptions: { 66 | script: { 67 | // inlineTemplate: false 68 | }, 69 | }, 70 | // showCompileOutput: false, 71 | // showImportMap: false 72 | editorOptions: { 73 | autoSaveText: '💾', 74 | monacoOptions: { 75 | // wordWrap: 'on', 76 | }, 77 | }, 78 | // autoSave: false, 79 | }) 80 | }, 81 | } 82 | 83 | createApp(App).mount('#app') 84 | -------------------------------------------------------------------------------- /src/Message.vue: -------------------------------------------------------------------------------- 1 | 32 | 33 | 45 | 46 | 129 | -------------------------------------------------------------------------------- /src/codemirror/CodeMirror.vue: -------------------------------------------------------------------------------- 1 | 9 | 10 | 103 | 104 | 118 | -------------------------------------------------------------------------------- /src/output/PreviewProxy.ts: -------------------------------------------------------------------------------- 1 | // ReplProxy and srcdoc implementation from Svelte REPL 2 | // MIT License https://github.com/sveltejs/svelte-repl/blob/master/LICENSE 3 | 4 | let uid = 1 5 | 6 | export class PreviewProxy { 7 | iframe: HTMLIFrameElement 8 | handlers: Record 9 | pending_cmds: Map< 10 | number, 11 | { resolve: (value: unknown) => void; reject: (reason?: any) => void } 12 | > 13 | handle_event: (e: any) => void 14 | 15 | constructor(iframe: HTMLIFrameElement, handlers: Record) { 16 | this.iframe = iframe 17 | this.handlers = handlers 18 | 19 | this.pending_cmds = new Map() 20 | 21 | this.handle_event = (e) => this.handle_repl_message(e) 22 | window.addEventListener('message', this.handle_event, false) 23 | } 24 | 25 | destroy() { 26 | window.removeEventListener('message', this.handle_event) 27 | } 28 | 29 | iframe_command(action: string, args: any) { 30 | return new Promise((resolve, reject) => { 31 | const cmd_id = uid++ 32 | 33 | this.pending_cmds.set(cmd_id, { resolve, reject }) 34 | 35 | this.iframe.contentWindow!.postMessage({ action, cmd_id, args }, '*') 36 | }) 37 | } 38 | 39 | handle_command_message(cmd_data: any) { 40 | let action = cmd_data.action 41 | let id = cmd_data.cmd_id 42 | let handler = this.pending_cmds.get(id) 43 | 44 | if (handler) { 45 | this.pending_cmds.delete(id) 46 | if (action === 'cmd_error') { 47 | let { message, stack } = cmd_data 48 | let e = new Error(message) 49 | e.stack = stack 50 | handler.reject(e) 51 | } 52 | 53 | if (action === 'cmd_ok') { 54 | handler.resolve(cmd_data.args) 55 | } 56 | } else if (action !== 'cmd_error' && action !== 'cmd_ok') { 57 | console.error('command not found', id, cmd_data, [ 58 | ...this.pending_cmds.keys(), 59 | ]) 60 | } 61 | } 62 | 63 | handle_repl_message(event: any) { 64 | if (event.source !== this.iframe.contentWindow) return 65 | 66 | const { action, args } = event.data 67 | 68 | switch (action) { 69 | case 'cmd_error': 70 | case 'cmd_ok': 71 | return this.handle_command_message(event.data) 72 | case 'fetch_progress': 73 | return this.handlers.on_fetch_progress(args.remaining) 74 | case 'error': 75 | return this.handlers.on_error(event.data) 76 | case 'unhandledrejection': 77 | return this.handlers.on_unhandled_rejection(event.data) 78 | case 'console': 79 | return this.handlers.on_console(event.data) 80 | case 'console_group': 81 | return this.handlers.on_console_group(event.data) 82 | case 'console_group_collapsed': 83 | return this.handlers.on_console_group_collapsed(event.data) 84 | case 'console_group_end': 85 | return this.handlers.on_console_group_end(event.data) 86 | } 87 | } 88 | 89 | eval(script: string | string[]) { 90 | return this.iframe_command('eval', { script }) 91 | } 92 | 93 | handle_links() { 94 | return this.iframe_command('catch_clicks', {}) 95 | } 96 | } 97 | -------------------------------------------------------------------------------- /src/output/Output.vue: -------------------------------------------------------------------------------- 1 | 58 | 59 | 93 | 94 | 131 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@vue/repl", 3 | "version": "4.7.1", 4 | "description": "Vue component for editing Vue components", 5 | "packageManager": "pnpm@10.14.0", 6 | "type": "module", 7 | "main": "dist/ssr-stub.js", 8 | "module": "dist/vue-repl.js", 9 | "files": [ 10 | "dist" 11 | ], 12 | "types": "dist/vue-repl.d.ts", 13 | "exports": { 14 | ".": { 15 | "types": "./dist/vue-repl.d.ts", 16 | "import": "./dist/vue-repl.js", 17 | "require": "./dist/ssr-stub.js" 18 | }, 19 | "./monaco-editor": { 20 | "types": "./dist/monaco-editor.d.ts", 21 | "import": "./dist/monaco-editor.js", 22 | "require": null 23 | }, 24 | "./codemirror-editor": { 25 | "types": "./dist/codemirror-editor.d.ts", 26 | "import": "./dist/codemirror-editor.js", 27 | "require": null 28 | }, 29 | "./core": { 30 | "types": "./dist/core.d.ts", 31 | "import": "./dist/core.js", 32 | "require": null 33 | }, 34 | "./package.json": "./package.json", 35 | "./style.css": "./dist/vue-repl.css", 36 | "./dist/style.css": "./dist/vue-repl.css" 37 | }, 38 | "typesVersions": { 39 | "*": { 40 | "*": [ 41 | "./dist/*", 42 | "./*" 43 | ] 44 | } 45 | }, 46 | "publishConfig": { 47 | "tag": "latest" 48 | }, 49 | "scripts": { 50 | "dev": "vite", 51 | "build": "vite build", 52 | "build-preview": "vite build -c vite.preview.config.ts", 53 | "format": "prettier --write .", 54 | "lint": "eslint .", 55 | "typecheck": "vue-tsc --noEmit", 56 | "release": "bumpp --all", 57 | "version": "conventional-changelog -p angular -i CHANGELOG.md -s -r 0", 58 | "prepublishOnly": "npm run build" 59 | }, 60 | "simple-git-hooks": { 61 | "pre-commit": "pnpm exec lint-staged --concurrent false" 62 | }, 63 | "lint-staged": { 64 | "*": [ 65 | "prettier --write --cache --ignore-unknown" 66 | ] 67 | }, 68 | "repository": { 69 | "type": "git", 70 | "url": "git+https://github.com/vuejs/repl.git" 71 | }, 72 | "author": "Evan You", 73 | "license": "MIT", 74 | "bugs": { 75 | "url": "https://github.com/vuejs/repl/issues" 76 | }, 77 | "homepage": "https://github.com/vuejs/repl#readme", 78 | "devDependencies": { 79 | "@babel/standalone": "^7.28.2", 80 | "@babel/types": "^7.28.2", 81 | "@eslint/js": "^9.32.0", 82 | "@jridgewell/gen-mapping": "^0.3.12", 83 | "@jridgewell/trace-mapping": "^0.3.29", 84 | "@rollup/plugin-replace": "^6.0.2", 85 | "@shikijs/monaco": "^3.9.2", 86 | "@types/babel__standalone": "^7.1.9", 87 | "@types/codemirror": "^5.60.16", 88 | "@types/hash-sum": "^1.0.2", 89 | "@types/node": "^24.2.0", 90 | "@vitejs/plugin-vue": "^6.0.1", 91 | "@volar/jsdelivr": "2.4.23", 92 | "@volar/monaco": "2.4.23", 93 | "@volar/typescript": "2.4.23", 94 | "@vue/babel-plugin-jsx": "^1.4.0", 95 | "@vue/language-core": "3.0.8", 96 | "@vue/language-service": "3.0.8", 97 | "@vue/typescript-plugin": "3.0.8", 98 | "assert": "^2.1.0", 99 | "bumpp": "^10.2.2", 100 | "codemirror": "^5.65.18", 101 | "conventional-changelog-cli": "^5.0.0", 102 | "eslint": "^9.32.0", 103 | "eslint-plugin-vue": "^10.4.0", 104 | "fflate": "^0.8.2", 105 | "hash-sum": "^2.0.0", 106 | "lint-staged": "^16.1.4", 107 | "monaco-editor-core": "^0.52.2", 108 | "prettier": "^3.6.2", 109 | "shiki": "^3.9.2", 110 | "simple-git-hooks": "^2.13.1", 111 | "source-map-js": "^1.2.1", 112 | "sucrase": "^3.35.0", 113 | "typescript": "^5.9.2", 114 | "typescript-eslint": "^8.39.0", 115 | "vite": "^7.0.6", 116 | "vite-plugin-dts": "^4.5.4", 117 | "vscode-uri": "^3.1.0", 118 | "volar-service-typescript": "0.0.65", 119 | "vue": "^3.5.18", 120 | "vue-tsc": "3.0.8" 121 | } 122 | } 123 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # @vue/repl 2 | 3 | Vue SFC REPL as a Vue 3 component. 4 | 5 | ## Basic Usage 6 | 7 | **Note: `@vue/repl` >= 2 now supports Monaco Editor, but also requires explicitly passing in the editor to be used for tree-shaking.** 8 | 9 | ```ts 10 | // vite.config.ts 11 | import { defineConfig } from 'vite' 12 | export default defineConfig({ 13 | optimizeDeps: { 14 | exclude: ['@vue/repl'], 15 | }, 16 | // ... 17 | }) 18 | ``` 19 | 20 | ### With CodeMirror Editor 21 | 22 | Basic editing experience with no intellisense. Lighter weight, fewer network requests, better for embedding use cases. 23 | 24 | ```vue 25 | 31 | 32 | 35 | ``` 36 | 37 | ### With Monaco Editor 38 | 39 | With Volar support, autocomplete, type inference, and semantic highlighting. Heavier bundle, loads dts files from CDN, better for standalone use cases. 40 | 41 | ```vue 42 | 48 | 49 | 52 | ``` 53 | 54 | ## Advanced Usage 55 | 56 | Customize the behavior of the REPL by manually initializing the store. 57 | 58 | See [v4 Migration Guide](https://github.com/vuejs/repl/releases/tag/v4.0.0) 59 | 60 | ```vue 61 | 105 | 106 | 109 | ``` 110 | 111 | Use only the Preview without the editor 112 | 113 | ```vue 114 | 130 | 131 | 134 | ``` 135 | -------------------------------------------------------------------------------- /src/sourcemap.ts: -------------------------------------------------------------------------------- 1 | import type { RawSourceMap } from 'source-map-js' 2 | import type { EncodedSourceMap as TraceEncodedSourceMap } from '@jridgewell/trace-mapping' 3 | import { TraceMap, eachMapping } from '@jridgewell/trace-mapping' 4 | import type { EncodedSourceMap as GenEncodedSourceMap } from '@jridgewell/gen-mapping' 5 | import { addMapping, fromMap, toEncodedMap } from '@jridgewell/gen-mapping' 6 | 7 | // trim analyzed bindings comment 8 | export function trimAnalyzedBindings(scriptCode: string) { 9 | return scriptCode.replace(/\/\*[\s\S]*?\*\/\n/, '').trim() 10 | } 11 | /** 12 | * The merge logic of sourcemap is consistent with the logic in vite-plugin-vue 13 | */ 14 | export function getSourceMap( 15 | filename: string, 16 | scriptCode: string, 17 | scriptMap: any, 18 | templateMap: any, 19 | ): RawSourceMap { 20 | let resolvedMap: RawSourceMap | undefined = undefined 21 | if (templateMap) { 22 | // if the template is inlined into the main module (indicated by the presence 23 | // of templateMap), we need to concatenate the two source maps. 24 | const from = scriptMap ?? { 25 | file: filename, 26 | sourceRoot: '', 27 | version: 3, 28 | sources: [], 29 | sourcesContent: [], 30 | names: [], 31 | mappings: '', 32 | } 33 | const gen = fromMap( 34 | // version property of result.map is declared as string 35 | // but actually it is `3` 36 | from as Omit as TraceEncodedSourceMap, 37 | ) 38 | const tracer = new TraceMap( 39 | // same above 40 | templateMap as Omit as TraceEncodedSourceMap, 41 | ) 42 | const offset = 43 | (trimAnalyzedBindings(scriptCode).match(/\r?\n/g)?.length ?? 0) 44 | eachMapping(tracer, (m) => { 45 | if (m.source == null) return 46 | addMapping(gen, { 47 | source: m.source, 48 | original: { line: m.originalLine, column: m.originalColumn }, 49 | generated: { 50 | line: m.generatedLine + offset, 51 | column: m.generatedColumn, 52 | }, 53 | }) 54 | }) 55 | 56 | // same above 57 | resolvedMap = toEncodedMap(gen) as Omit< 58 | GenEncodedSourceMap, 59 | 'version' 60 | > as RawSourceMap 61 | // if this is a template only update, we will be reusing a cached version 62 | // of the main module compile result, which has outdated sourcesContent. 63 | resolvedMap.sourcesContent = templateMap.sourcesContent 64 | } else { 65 | resolvedMap = scriptMap 66 | } 67 | 68 | return resolvedMap! 69 | } 70 | 71 | /* 72 | * Slightly modified version of https://github.com/AriPerkkio/vite-plugin-source-map-visualizer/blob/main/src/generate-link.ts 73 | */ 74 | export function toVisualizer(code: string, sourceMap: RawSourceMap) { 75 | const map = JSON.stringify(sourceMap) 76 | const encoder = new TextEncoder() 77 | 78 | // Convert the strings to Uint8Array 79 | const codeArray = encoder.encode(code) 80 | const mapArray = encoder.encode(map) 81 | 82 | // Create Uint8Array for the lengths 83 | const codeLengthArray = encoder.encode(codeArray.length.toString()) 84 | const mapLengthArray = encoder.encode(mapArray.length.toString()) 85 | 86 | // Combine the lengths and the data 87 | const combinedArray = new Uint8Array( 88 | codeLengthArray.length + 89 | 1 + 90 | codeArray.length + 91 | mapLengthArray.length + 92 | 1 + 93 | mapArray.length, 94 | ) 95 | 96 | combinedArray.set(codeLengthArray) 97 | combinedArray.set([0], codeLengthArray.length) 98 | combinedArray.set(codeArray, codeLengthArray.length + 1) 99 | combinedArray.set( 100 | mapLengthArray, 101 | codeLengthArray.length + 1 + codeArray.length, 102 | ) 103 | combinedArray.set( 104 | [0], 105 | codeLengthArray.length + 1 + codeArray.length + mapLengthArray.length, 106 | ) 107 | combinedArray.set( 108 | mapArray, 109 | codeLengthArray.length + 1 + codeArray.length + mapLengthArray.length + 1, 110 | ) 111 | 112 | // Convert the Uint8Array to a binary string 113 | let binary = '' 114 | const len = combinedArray.byteLength 115 | for (let i = 0; i < len; i++) binary += String.fromCharCode(combinedArray[i]) 116 | 117 | // Convert the binary string to a base64 string and return it 118 | return `https://evanw.github.io/source-map-visualization#${btoa(binary)}` 119 | } 120 | -------------------------------------------------------------------------------- /src/Repl.vue: -------------------------------------------------------------------------------- 1 | 100 | 101 | 120 | 121 | 158 | -------------------------------------------------------------------------------- /src/monaco/Monaco.vue: -------------------------------------------------------------------------------- 1 | 173 | 174 | 182 | 183 | 191 | -------------------------------------------------------------------------------- /src/SplitPane.vue: -------------------------------------------------------------------------------- 1 | 61 | 62 | 102 | 103 | 221 | -------------------------------------------------------------------------------- /src/monaco/env.ts: -------------------------------------------------------------------------------- 1 | import * as volar from '@volar/monaco' 2 | import { Uri, editor, languages } from 'monaco-editor-core' 3 | import editorWorker from 'monaco-editor-core/esm/vs/editor/editor.worker?worker' 4 | import { watchEffect } from 'vue' 5 | import type { Store } from '../store' 6 | import { getOrCreateModel } from './utils' 7 | import type { CreateData } from './vue.worker' 8 | import vueWorker from './vue.worker?worker' 9 | import * as languageConfigs from './language-configs' 10 | import type { WorkerLanguageService } from '@volar/monaco/worker' 11 | import { debounce } from '../utils' 12 | 13 | let initted = false 14 | export function initMonaco(store: Store) { 15 | if (initted) return 16 | loadMonacoEnv(store) 17 | 18 | watchEffect(() => { 19 | // create a model for each file in the store 20 | for (const filename in store.files) { 21 | const file = store.files[filename] 22 | if (editor.getModel(Uri.parse(`file:///${filename}`))) continue 23 | getOrCreateModel( 24 | Uri.parse(`file:///${filename}`), 25 | file.language, 26 | file.code, 27 | ) 28 | } 29 | 30 | // dispose of any models that are not in the store 31 | for (const model of editor.getModels()) { 32 | const uri = model.uri.toString() 33 | if (store.files[uri.substring('file:///'.length)]) continue 34 | 35 | if (uri.startsWith('file:///node_modules')) continue 36 | if (uri.startsWith('inmemory://')) continue 37 | 38 | model.dispose() 39 | } 40 | }) 41 | 42 | initted = true 43 | } 44 | 45 | export class WorkerHost { 46 | onFetchCdnFile(uri: string, text: string) { 47 | getOrCreateModel(Uri.parse(uri), undefined, text) 48 | } 49 | } 50 | 51 | let disposeVue: undefined | (() => void) 52 | export async function reloadLanguageTools(store: Store) { 53 | disposeVue?.() 54 | 55 | let dependencies: Record = { 56 | ...store.dependencyVersion, 57 | } 58 | 59 | if (store.vueVersion) { 60 | dependencies = { 61 | ...dependencies, 62 | vue: store.vueVersion, 63 | '@vue/compiler-core': store.vueVersion, 64 | '@vue/compiler-dom': store.vueVersion, 65 | '@vue/compiler-sfc': store.vueVersion, 66 | '@vue/compiler-ssr': store.vueVersion, 67 | '@vue/reactivity': store.vueVersion, 68 | '@vue/runtime-core': store.vueVersion, 69 | '@vue/runtime-dom': store.vueVersion, 70 | '@vue/shared': store.vueVersion, 71 | } 72 | } 73 | 74 | if (store.typescriptVersion) { 75 | dependencies = { 76 | ...dependencies, 77 | typescript: store.typescriptVersion, 78 | } 79 | } 80 | 81 | const worker = editor.createWebWorker({ 82 | moduleId: 'vs/language/vue/vueWorker', 83 | label: 'vue', 84 | host: new WorkerHost(), 85 | createData: { 86 | tsconfig: store.getTsConfig?.() || {}, 87 | dependencies, 88 | } satisfies CreateData, 89 | }) 90 | const languageId = ['vue', 'javascript', 'typescript'] 91 | const getSyncUris = () => 92 | Object.keys(store.files).map((filename) => Uri.parse(`file:///${filename}`)) 93 | 94 | const { dispose: disposeMarkers } = volar.activateMarkers( 95 | worker, 96 | languageId, 97 | 'vue', 98 | getSyncUris, 99 | editor, 100 | ) 101 | const { dispose: disposeAutoInsertion } = volar.activateAutoInsertion( 102 | worker, 103 | languageId, 104 | getSyncUris, 105 | editor, 106 | ) 107 | const { dispose: disposeProvides } = await volar.registerProviders( 108 | worker, 109 | languageId, 110 | getSyncUris, 111 | languages, 112 | ) 113 | 114 | disposeVue = () => { 115 | disposeMarkers() 116 | disposeAutoInsertion() 117 | disposeProvides() 118 | } 119 | } 120 | 121 | export interface WorkerMessage { 122 | event: 'init' 123 | tsVersion: string 124 | tsLocale?: string 125 | } 126 | 127 | export function loadMonacoEnv(store: Store) { 128 | ;(self as any).MonacoEnvironment = { 129 | async getWorker(_: any, label: string) { 130 | if (label === 'vue') { 131 | const worker = new vueWorker() 132 | const init = new Promise((resolve) => { 133 | worker.addEventListener('message', (data) => { 134 | if (data.data === 'inited') { 135 | resolve() 136 | } 137 | }) 138 | worker.postMessage({ 139 | event: 'init', 140 | tsVersion: store.typescriptVersion, 141 | tsLocale: store.locale, 142 | } satisfies WorkerMessage) 143 | }) 144 | await init 145 | return worker 146 | } 147 | return new editorWorker() 148 | }, 149 | } 150 | languages.register({ id: 'vue', extensions: ['.vue'] }) 151 | languages.register({ id: 'javascript', extensions: ['.js'] }) 152 | languages.register({ id: 'typescript', extensions: ['.ts'] }) 153 | languages.register({ id: 'css', extensions: ['.css'] }) 154 | languages.setLanguageConfiguration('vue', languageConfigs.vue) 155 | languages.setLanguageConfiguration('javascript', languageConfigs.js) 156 | languages.setLanguageConfiguration('typescript', languageConfigs.ts) 157 | languages.setLanguageConfiguration('css', languageConfigs.css) 158 | 159 | let languageToolsPromise: Promise | undefined 160 | store.reloadLanguageTools = debounce(async () => { 161 | ;(languageToolsPromise ||= reloadLanguageTools(store)).finally(() => { 162 | languageToolsPromise = undefined 163 | }) 164 | }, 250) 165 | languages.onLanguage('vue', () => store.reloadLanguageTools!()) 166 | 167 | // Support for go to definition 168 | editor.registerEditorOpener({ 169 | openCodeEditor(_, resource) { 170 | if (resource.toString().startsWith('file:///node_modules')) { 171 | return true 172 | } 173 | 174 | const path = resource.path 175 | if (/^\//.test(path)) { 176 | const fileName = path.replace('/', '') 177 | if (fileName !== store.activeFile.filename) { 178 | store.setActive(fileName) 179 | return true 180 | } 181 | } 182 | 183 | return false 184 | }, 185 | }) 186 | } 187 | -------------------------------------------------------------------------------- /src/editor/FileSelector.vue: -------------------------------------------------------------------------------- 1 | 113 | 114 | 175 | 176 | 294 | -------------------------------------------------------------------------------- /src/output/moduleCompiler.ts: -------------------------------------------------------------------------------- 1 | import type { File, Store } from '../store' 2 | import { 3 | MagicString, 4 | babelParse, 5 | extractIdentifiers, 6 | isInDestructureAssignment, 7 | isStaticProperty, 8 | walk, 9 | walkIdentifiers, 10 | } from 'vue/compiler-sfc' 11 | import type { ExportSpecifier, Identifier, Node } from '@babel/types' 12 | 13 | export function compileModulesForPreview(store: Store, isSSR = false) { 14 | const seen = new Set() 15 | const processed: string[] = [] 16 | processFile(store, store.files[store.mainFile], processed, seen, isSSR) 17 | 18 | if (!isSSR) { 19 | // also add css files that are not imported 20 | for (const filename in store.files) { 21 | if (filename.endsWith('.css')) { 22 | const file = store.files[filename] 23 | if (!seen.has(file)) { 24 | processed.push( 25 | `\nwindow.__css__.push(${JSON.stringify(file.compiled.css)})`, 26 | ) 27 | } 28 | } 29 | } 30 | } 31 | 32 | return processed 33 | } 34 | 35 | const modulesKey = `__modules__` 36 | const exportKey = `__export__` 37 | const dynamicImportKey = `__dynamic_import__` 38 | const moduleKey = `__module__` 39 | 40 | // similar logic with Vite's SSR transform, except this is targeting the browser 41 | function processFile( 42 | store: Store, 43 | file: File, 44 | processed: string[], 45 | seen: Set, 46 | isSSR: boolean, 47 | ) { 48 | if (seen.has(file)) { 49 | return [] 50 | } 51 | seen.add(file) 52 | 53 | if (!isSSR && file.filename.endsWith('.html')) { 54 | return processHtmlFile(store, file.code, file.filename, processed, seen) 55 | } 56 | 57 | let { 58 | code: js, 59 | importedFiles, 60 | hasDynamicImport, 61 | } = processModule( 62 | store, 63 | isSSR ? file.compiled.ssr : file.compiled.js, 64 | file.filename, 65 | ) 66 | processChildFiles( 67 | store, 68 | importedFiles, 69 | hasDynamicImport, 70 | processed, 71 | seen, 72 | isSSR, 73 | ) 74 | // append css 75 | if (file.compiled.css && !isSSR) { 76 | js += `\nwindow.__css__.push(${JSON.stringify(file.compiled.css)})` 77 | } 78 | 79 | // push self 80 | processed.push(js) 81 | } 82 | 83 | function processChildFiles( 84 | store: Store, 85 | importedFiles: Set, 86 | hasDynamicImport: boolean, 87 | processed: string[], 88 | seen: Set, 89 | isSSR: boolean, 90 | ) { 91 | if (hasDynamicImport) { 92 | // process all files 93 | for (const file of Object.values(store.files)) { 94 | if (seen.has(file)) continue 95 | processFile(store, file, processed, seen, isSSR) 96 | } 97 | } else if (importedFiles.size > 0) { 98 | // crawl child imports 99 | for (const imported of importedFiles) { 100 | processFile(store, store.files[imported], processed, seen, isSSR) 101 | } 102 | } 103 | } 104 | 105 | function processModule(store: Store, src: string, filename: string) { 106 | const s = new MagicString(src) 107 | 108 | const ast = babelParse(src, { 109 | sourceFilename: filename, 110 | sourceType: 'module', 111 | }).program.body 112 | 113 | const idToImportMap = new Map() 114 | const declaredConst = new Set() 115 | const importedFiles = new Set() 116 | const importToIdMap = new Map() 117 | 118 | function resolveImport(raw: string): string | undefined { 119 | const files = store.files 120 | let resolved = raw 121 | const file = 122 | files[resolved] || 123 | files[(resolved = raw + '.ts')] || 124 | files[(resolved = raw + '.js')] 125 | return file ? resolved : undefined 126 | } 127 | 128 | function defineImport(node: Node, source: string) { 129 | const filename = resolveImport(source.replace(/^\.\/+/, 'src/')) 130 | if (!filename) { 131 | throw new Error(`File "${source}" does not exist.`) 132 | } 133 | if (importedFiles.has(filename)) { 134 | return importToIdMap.get(filename)! 135 | } 136 | importedFiles.add(filename) 137 | const id = `__import_${importedFiles.size}__` 138 | importToIdMap.set(filename, id) 139 | s.appendLeft( 140 | node.start!, 141 | `const ${id} = ${modulesKey}[${JSON.stringify(filename)}]\n`, 142 | ) 143 | return id 144 | } 145 | 146 | function defineExport(name: string, local = name) { 147 | s.append(`\n${exportKey}(${moduleKey}, "${name}", () => ${local})`) 148 | } 149 | 150 | // 0. instantiate module 151 | s.prepend( 152 | `const ${moduleKey} = ${modulesKey}[${JSON.stringify( 153 | filename, 154 | )}] = { [Symbol.toStringTag]: "Module" }\n\n`, 155 | ) 156 | 157 | // 1. check all import statements and record id -> importName map 158 | for (const node of ast) { 159 | // import foo from 'foo' --> foo -> __import_foo__.default 160 | // import { baz } from 'foo' --> baz -> __import_foo__.baz 161 | // import * as ok from 'foo' --> ok -> __import_foo__ 162 | if (node.type === 'ImportDeclaration') { 163 | const source = node.source.value 164 | if (source.startsWith('./')) { 165 | const importId = defineImport(node, node.source.value) 166 | for (const spec of node.specifiers) { 167 | if (spec.type === 'ImportSpecifier') { 168 | idToImportMap.set( 169 | spec.local.name, 170 | `${importId}.${(spec.imported as Identifier).name}`, 171 | ) 172 | } else if (spec.type === 'ImportDefaultSpecifier') { 173 | idToImportMap.set(spec.local.name, `${importId}.default`) 174 | } else { 175 | // namespace specifier 176 | idToImportMap.set(spec.local.name, importId) 177 | } 178 | } 179 | s.remove(node.start!, node.end!) 180 | } 181 | } 182 | } 183 | 184 | // 2. check all export statements and define exports 185 | for (const node of ast) { 186 | // named exports 187 | if (node.type === 'ExportNamedDeclaration') { 188 | if (node.declaration) { 189 | if ( 190 | node.declaration.type === 'FunctionDeclaration' || 191 | node.declaration.type === 'ClassDeclaration' 192 | ) { 193 | // export function foo() {} 194 | defineExport(node.declaration.id!.name) 195 | } else if (node.declaration.type === 'VariableDeclaration') { 196 | // export const foo = 1, bar = 2 197 | for (const decl of node.declaration.declarations) { 198 | for (const id of extractIdentifiers(decl.id)) { 199 | defineExport(id.name) 200 | } 201 | } 202 | } 203 | s.remove(node.start!, node.declaration.start!) 204 | } else if (node.source && node.source.value.startsWith('./')) { 205 | // export { foo, bar } from './foo' 206 | const importId = defineImport(node, node.source.value) 207 | for (const spec of node.specifiers) { 208 | defineExport( 209 | (spec.exported as Identifier).name, 210 | `${importId}.${(spec as ExportSpecifier).local.name}`, 211 | ) 212 | } 213 | s.remove(node.start!, node.end!) 214 | } else { 215 | // export { foo, bar } 216 | for (const spec of node.specifiers) { 217 | const local = (spec as ExportSpecifier).local.name 218 | const binding = idToImportMap.get(local) 219 | defineExport((spec.exported as Identifier).name, binding || local) 220 | } 221 | s.remove(node.start!, node.end!) 222 | } 223 | } 224 | 225 | // default export 226 | if (node.type === 'ExportDefaultDeclaration') { 227 | if ('id' in node.declaration && node.declaration.id) { 228 | // named hoistable/class exports 229 | // export default function foo() {} 230 | // export default class A {} 231 | const { name } = node.declaration.id 232 | s.remove(node.start!, node.start! + 15) 233 | s.append(`\n${exportKey}(${moduleKey}, "default", () => ${name})`) 234 | } else { 235 | // anonymous default exports 236 | s.overwrite(node.start!, node.start! + 14, `${moduleKey}.default =`) 237 | } 238 | } 239 | 240 | // export * from './foo' 241 | if (node.type === 'ExportAllDeclaration') { 242 | const importId = defineImport(node, node.source.value) 243 | s.remove(node.start!, node.end!) 244 | s.append(`\nfor (const key in ${importId}) { 245 | if (key !== 'default') { 246 | ${exportKey}(${moduleKey}, key, () => ${importId}[key]) 247 | } 248 | }`) 249 | } 250 | } 251 | 252 | // 3. convert references to import bindings 253 | for (const node of ast) { 254 | if (node.type === 'ImportDeclaration') continue 255 | walkIdentifiers(node, (id, parent, parentStack) => { 256 | const binding = idToImportMap.get(id.name) 257 | if (!binding) { 258 | return 259 | } 260 | if (parent && isStaticProperty(parent) && parent.shorthand) { 261 | // let binding used in a property shorthand 262 | // { foo } -> { foo: __import_x__.foo } 263 | // skip for destructure patterns 264 | if ( 265 | !(parent as any).inPattern || 266 | isInDestructureAssignment(parent, parentStack) 267 | ) { 268 | s.appendLeft(id.end!, `: ${binding}`) 269 | } 270 | } else if ( 271 | parent && 272 | parent.type === 'ClassDeclaration' && 273 | id === parent.superClass 274 | ) { 275 | if (!declaredConst.has(id.name)) { 276 | declaredConst.add(id.name) 277 | // locate the top-most node containing the class declaration 278 | const topNode = parentStack[1] 279 | s.prependRight(topNode.start!, `const ${id.name} = ${binding};\n`) 280 | } 281 | } else { 282 | s.overwrite(id.start!, id.end!, binding) 283 | } 284 | }) 285 | } 286 | 287 | // 4. convert dynamic imports 288 | let hasDynamicImport = false 289 | walk(ast, { 290 | enter(node: Node, parent: Node) { 291 | if (node.type === 'Import' && parent.type === 'CallExpression') { 292 | const arg = parent.arguments[0] 293 | if (arg.type === 'StringLiteral' && arg.value.startsWith('./')) { 294 | hasDynamicImport = true 295 | s.overwrite(node.start!, node.start! + 6, dynamicImportKey) 296 | s.overwrite( 297 | arg.start!, 298 | arg.end!, 299 | JSON.stringify(arg.value.replace(/^\.\/+/, 'src/')), 300 | ) 301 | } 302 | } 303 | }, 304 | }) 305 | 306 | return { 307 | code: s.toString(), 308 | importedFiles, 309 | hasDynamicImport, 310 | } 311 | } 312 | 313 | const scriptRE = /]*>|>)([^]*?)<\/script>/gi 314 | const scriptModuleRE = 315 | /]*type\s*=\s*(?:"module"|'module')[^>]*>([^]*?)<\/script>/gi 316 | 317 | function processHtmlFile( 318 | store: Store, 319 | src: string, 320 | filename: string, 321 | processed: string[], 322 | seen: Set, 323 | ) { 324 | const deps: string[] = [] 325 | let jsCode = '' 326 | const html = src 327 | .replace(scriptModuleRE, (_, content) => { 328 | const { code, importedFiles, hasDynamicImport } = processModule( 329 | store, 330 | content, 331 | filename, 332 | ) 333 | processChildFiles( 334 | store, 335 | importedFiles, 336 | hasDynamicImport, 337 | deps, 338 | seen, 339 | false, 340 | ) 341 | jsCode += '\n' + code 342 | return '' 343 | }) 344 | .replace(scriptRE, (_, content) => { 345 | jsCode += '\n' + content 346 | return '' 347 | }) 348 | processed.push(`document.body.innerHTML = ${JSON.stringify(html)}`) 349 | processed.push(...deps) 350 | processed.push(jsCode) 351 | } 352 | -------------------------------------------------------------------------------- /src/monaco/vue.worker.ts: -------------------------------------------------------------------------------- 1 | import { createNpmFileSystem } from '@volar/jsdelivr' 2 | import { 3 | type LanguageServiceEnvironment, 4 | createTypeScriptWorkerLanguageService, 5 | Language, 6 | } from '@volar/monaco/worker' 7 | import { 8 | type VueCompilerOptions, 9 | VueVirtualCode, 10 | createVueLanguagePlugin, 11 | getDefaultCompilerOptions, 12 | generateGlobalTypes, 13 | getGlobalTypesFileName, 14 | } from '@vue/language-core' 15 | import { 16 | LanguageService, 17 | createVueLanguageServicePlugins, 18 | } from '@vue/language-service' 19 | import type * as monaco from 'monaco-editor-core' 20 | // @ts-expect-error 21 | import * as worker from 'monaco-editor-core/esm/vs/editor/editor.worker' 22 | import { create as createTypeScriptDirectiveCommentPlugin } from 'volar-service-typescript/lib/plugins/directiveComment' 23 | import { create as createTypeScriptSemanticPlugin } from 'volar-service-typescript/lib/plugins/semantic' 24 | import { URI } from 'vscode-uri' 25 | import type { WorkerHost, WorkerMessage } from './env' 26 | 27 | import { createVueLanguageServiceProxy } from '@vue/typescript-plugin/lib/common' 28 | import { getComponentDirectives } from '@vue/typescript-plugin/lib/requests/getComponentDirectives' 29 | import { getComponentEvents } from '@vue/typescript-plugin/lib/requests/getComponentEvents' 30 | import { getComponentNames } from '@vue/typescript-plugin/lib/requests/getComponentNames' 31 | import { getComponentProps } from '@vue/typescript-plugin/lib/requests/getComponentProps' 32 | import { getComponentSlots } from '@vue/typescript-plugin/lib/requests/getComponentSlots' 33 | import { getElementAttrs } from '@vue/typescript-plugin/lib/requests/getElementAttrs' 34 | import { getElementNames } from '@vue/typescript-plugin/lib/requests/getElementNames' 35 | import { isRefAtPosition } from '@vue/typescript-plugin/lib/requests/isRefAtPosition' 36 | 37 | export interface CreateData { 38 | tsconfig: { 39 | compilerOptions?: import('typescript').CompilerOptions 40 | vueCompilerOptions?: Partial 41 | } 42 | dependencies: Record 43 | } 44 | 45 | const asFileName = (uri: URI) => uri.path 46 | const asUri = (fileName: string): URI => URI.file(fileName) 47 | 48 | let ts: typeof import('typescript') 49 | let locale: string | undefined 50 | 51 | self.onmessage = async (msg: MessageEvent) => { 52 | if (msg.data?.event === 'init') { 53 | locale = msg.data.tsLocale 54 | ts = await importTsFromCdn(msg.data.tsVersion) 55 | self.postMessage('inited') 56 | return 57 | } 58 | 59 | worker.initialize( 60 | ( 61 | ctx: monaco.worker.IWorkerContext, 62 | { tsconfig, dependencies }: CreateData, 63 | ) => { 64 | const env: LanguageServiceEnvironment = { 65 | workspaceFolders: [URI.file('/')], 66 | locale, 67 | fs: createNpmFileSystem( 68 | (uri) => { 69 | if (uri.scheme === 'file') { 70 | if (uri.path === '/node_modules') { 71 | return '' 72 | } else if (uri.path.startsWith('/node_modules/')) { 73 | return uri.path.slice('/node_modules/'.length) 74 | } 75 | } 76 | }, 77 | (pkgName) => dependencies[pkgName], 78 | (path, content) => { 79 | ctx.host.onFetchCdnFile( 80 | asUri('/node_modules/' + path).toString(), 81 | content, 82 | ) 83 | }, 84 | ), 85 | } 86 | 87 | const { options: compilerOptions } = ts.convertCompilerOptionsFromJson( 88 | tsconfig?.compilerOptions || {}, 89 | '', 90 | ) 91 | const vueCompilerOptions: VueCompilerOptions = { 92 | ...getDefaultCompilerOptions(), 93 | ...tsconfig.vueCompilerOptions, 94 | } 95 | setupGlobalTypes(vueCompilerOptions, env) 96 | 97 | const workerService = createTypeScriptWorkerLanguageService({ 98 | typescript: ts, 99 | compilerOptions, 100 | workerContext: ctx, 101 | env, 102 | uriConverter: { 103 | asFileName, 104 | asUri, 105 | }, 106 | languagePlugins: [ 107 | createVueLanguagePlugin( 108 | ts, 109 | compilerOptions, 110 | vueCompilerOptions, 111 | asFileName, 112 | ), 113 | ], 114 | languageServicePlugins: [ 115 | ...getTsLanguageServicePlugins(), 116 | ...getVueLanguageServicePlugins(), 117 | ], 118 | }) 119 | 120 | return workerService 121 | 122 | function setupGlobalTypes( 123 | options: VueCompilerOptions, 124 | env: LanguageServiceEnvironment, 125 | ) { 126 | const globalTypes = generateGlobalTypes(options) 127 | const globalTypesPath = 128 | '/node_modules/' + getGlobalTypesFileName(options) 129 | options.globalTypesPath = () => globalTypesPath 130 | const { stat, readFile } = env.fs! 131 | const ctime = Date.now() 132 | env.fs!.stat = async (uri) => { 133 | if (uri.path === globalTypesPath) { 134 | return { 135 | type: 1, 136 | ctime: ctime, 137 | mtime: ctime, 138 | size: globalTypes.length, 139 | } 140 | } 141 | return stat(uri) 142 | } 143 | env.fs!.readFile = async (uri) => { 144 | if (uri.path === globalTypesPath) { 145 | return globalTypes 146 | } 147 | return readFile(uri) 148 | } 149 | } 150 | 151 | function getTsLanguageServicePlugins() { 152 | const semanticPlugin = createTypeScriptSemanticPlugin(ts) 153 | const { create } = semanticPlugin 154 | semanticPlugin.create = (context) => { 155 | const created = create(context) 156 | const ls = created.provide[ 157 | 'typescript/languageService' 158 | ]() as import('typescript').LanguageService 159 | const proxy = createVueLanguageServiceProxy( 160 | ts, 161 | new Proxy( 162 | {}, 163 | { 164 | get(_target, prop, receiver) { 165 | return Reflect.get(context.language, prop, receiver) 166 | }, 167 | }, 168 | ) as unknown as Language, 169 | ls, 170 | vueCompilerOptions, 171 | asUri, 172 | ) 173 | ls.getCompletionsAtPosition = proxy.getCompletionsAtPosition 174 | ls.getCompletionEntryDetails = proxy.getCompletionEntryDetails 175 | ls.getCodeFixesAtPosition = proxy.getCodeFixesAtPosition 176 | ls.getDefinitionAndBoundSpan = proxy.getDefinitionAndBoundSpan 177 | ls.getQuickInfoAtPosition = proxy.getQuickInfoAtPosition 178 | return created 179 | } 180 | return [semanticPlugin, createTypeScriptDirectiveCommentPlugin()] 181 | } 182 | 183 | function getVueLanguageServicePlugins() { 184 | const plugins = createVueLanguageServicePlugins(ts, { 185 | getComponentDirectives(fileName) { 186 | return getComponentDirectives(ts, getProgram(), fileName) 187 | }, 188 | getComponentEvents(fileName, tag) { 189 | return getComponentEvents(ts, getProgram(), fileName, tag) 190 | }, 191 | getComponentNames(fileName) { 192 | return getComponentNames(ts, getProgram(), fileName) 193 | }, 194 | getComponentProps(fileName, tag) { 195 | return getComponentProps(ts, getProgram(), fileName, tag) 196 | }, 197 | getComponentSlots(fileName) { 198 | const { virtualCode } = getVirtualCode(fileName) 199 | return getComponentSlots(ts, getProgram(), virtualCode) 200 | }, 201 | getElementAttrs(fileName, tag) { 202 | return getElementAttrs(ts, getProgram(), fileName, tag) 203 | }, 204 | getElementNames(fileName) { 205 | return getElementNames(ts, getProgram(), fileName) 206 | }, 207 | isRefAtPosition(fileName, position) { 208 | const { sourceScript, virtualCode } = getVirtualCode(fileName) 209 | return isRefAtPosition( 210 | ts, 211 | getLanguageService().context.language, 212 | getProgram(), 213 | sourceScript, 214 | virtualCode, 215 | position, 216 | ) 217 | }, 218 | async getQuickInfoAtPosition(fileName, position) { 219 | const uri = asUri(fileName) 220 | const sourceScript = 221 | getLanguageService().context.language.scripts.get(uri) 222 | if (!sourceScript) { 223 | return 224 | } 225 | const hover = await getLanguageService().getHover(uri, position) 226 | let text = '' 227 | if (typeof hover?.contents === 'string') { 228 | text = hover.contents 229 | } else if (Array.isArray(hover?.contents)) { 230 | text = hover.contents 231 | .map((c) => (typeof c === 'string' ? c : c.value)) 232 | .join('\n') 233 | } else if (hover) { 234 | text = hover.contents.value 235 | } 236 | text = text.replace(/```typescript/g, '') 237 | text = text.replace(/```/g, '') 238 | text = text.replace(/---/g, '') 239 | text = text.trim() 240 | while (true) { 241 | const newText = text.replace(/\n\n/g, '\n') 242 | if (newText === text) { 243 | break 244 | } 245 | text = newText 246 | } 247 | text = text.replace(/\n/g, ' | ') 248 | return text 249 | }, 250 | collectExtractProps() { 251 | throw new Error('Not implemented') 252 | }, 253 | getImportPathForFile() { 254 | throw new Error('Not implemented') 255 | }, 256 | getDocumentHighlights() { 257 | throw new Error('Not implemented') 258 | }, 259 | getEncodedSemanticClassifications() { 260 | throw new Error('Not implemented') 261 | }, 262 | getReactiveReferences() { 263 | throw new Error('Not implemented') 264 | }, 265 | }) 266 | const ignoreVueServicePlugins = new Set([ 267 | 'vue-extract-file', 268 | 'vue-document-drop', 269 | 'vue-document-highlights', 270 | 'typescript-semantic-tokens', 271 | ]) 272 | return plugins.filter( 273 | (plugin) => !ignoreVueServicePlugins.has(plugin.name!), 274 | ) 275 | 276 | function getVirtualCode(fileName: string) { 277 | const uri = asUri(fileName) 278 | const sourceScript = 279 | getLanguageService().context.language.scripts.get(uri) 280 | if (!sourceScript) { 281 | throw new Error('No source script found for file: ' + fileName) 282 | } 283 | const virtualCode = sourceScript.generated?.root 284 | if (!(virtualCode instanceof VueVirtualCode)) { 285 | throw new Error('No virtual code found for file: ' + fileName) 286 | } 287 | return { 288 | sourceScript, 289 | virtualCode, 290 | } 291 | } 292 | 293 | function getProgram() { 294 | const tsService: import('typescript').LanguageService = 295 | getLanguageService().context.inject('typescript/languageService') 296 | return tsService.getProgram()! 297 | } 298 | 299 | function getLanguageService() { 300 | return (workerService as any).languageService as LanguageService 301 | } 302 | } 303 | }, 304 | ) 305 | } 306 | 307 | async function importTsFromCdn(tsVersion: string) { 308 | const _module = globalThis.module 309 | ;(globalThis as any).module = { exports: {} } 310 | const tsUrl = `https://cdn.jsdelivr.net/npm/typescript@${tsVersion}/lib/typescript.js` 311 | await import(/* @vite-ignore */ tsUrl) 312 | const ts = globalThis.module.exports 313 | globalThis.module = _module 314 | return ts as typeof import('typescript') 315 | } 316 | -------------------------------------------------------------------------------- /src/output/Sandbox.vue: -------------------------------------------------------------------------------- 1 | 349 | 350 | 363 | 364 | 376 | -------------------------------------------------------------------------------- /src/transform.ts: -------------------------------------------------------------------------------- 1 | import type { File, Store } from './store' 2 | import type { 3 | BindingMetadata, 4 | CompilerOptions, 5 | SFCDescriptor, 6 | } from 'vue/compiler-sfc' 7 | import { type Transform, transform } from 'sucrase' 8 | import hashId from 'hash-sum' 9 | import { getSourceMap, toVisualizer, trimAnalyzedBindings } from './sourcemap' 10 | 11 | export const COMP_IDENTIFIER = `__sfc__` 12 | 13 | const REGEX_JS = /\.[jt]sx?$/ 14 | function testTs(filename: string | undefined | null) { 15 | return !!(filename && /(\.|\b)tsx?$/.test(filename)) 16 | } 17 | function testJsx(filename: string | undefined | null) { 18 | return !!(filename && /(\.|\b)[jt]sx$/.test(filename)) 19 | } 20 | 21 | function transformTS(src: string, isJSX?: boolean) { 22 | return transform(src, { 23 | transforms: ['typescript', ...(isJSX ? (['jsx'] as Transform[]) : [])], 24 | jsxRuntime: 'preserve', 25 | }).code 26 | } 27 | 28 | export async function compileFile( 29 | store: Store, 30 | { filename, code, compiled }: File, 31 | ): Promise<(string | Error)[]> { 32 | if (!code.trim()) { 33 | return [] 34 | } 35 | 36 | if (filename.endsWith('.css')) { 37 | compiled.css = code 38 | return [] 39 | } 40 | 41 | if (REGEX_JS.test(filename)) { 42 | const isJSX = testJsx(filename) 43 | if (testTs(filename)) { 44 | code = transformTS(code, isJSX) 45 | } 46 | if (isJSX) { 47 | code = await import('./jsx').then(({ transformJSX }) => 48 | transformJSX(code), 49 | ) 50 | } 51 | compiled.js = compiled.ssr = code 52 | return [] 53 | } 54 | 55 | if (filename.endsWith('.json')) { 56 | let parsed 57 | try { 58 | parsed = JSON.parse(code) 59 | } catch (err: any) { 60 | console.error(`Error parsing ${filename}`, err.message) 61 | return [err.message] 62 | } 63 | compiled.js = compiled.ssr = `export default ${JSON.stringify(parsed)}` 64 | return [] 65 | } 66 | 67 | if (!filename.endsWith('.vue')) { 68 | return [] 69 | } 70 | 71 | const id = hashId(filename) 72 | const { errors, descriptor } = store.compiler.parse(code, { 73 | filename, 74 | sourceMap: true, 75 | templateParseOptions: store.sfcOptions?.template?.compilerOptions, 76 | }) 77 | if (errors.length) { 78 | return errors 79 | } 80 | 81 | const styleLangs = descriptor.styles.map((s) => s.lang).filter(Boolean) 82 | const templateLang = descriptor.template?.lang 83 | if (styleLangs.length && templateLang) { 84 | return [ 85 | `lang="${styleLangs.join( 86 | ',', 87 | )}" pre-processors for 13 | 14 | 367 | 368 | 369 | 373 | 376 | 377 | 378 | 379 | 380 | 381 | -------------------------------------------------------------------------------- /src/monaco/language-configs.ts: -------------------------------------------------------------------------------- 1 | import { languages } from 'monaco-editor-core' 2 | 3 | // export const html: languages.LanguageConfiguration = { 4 | // comments: { 5 | // blockComment: [''], 6 | // }, 7 | // brackets: [ 8 | // [''], 9 | // ['{', '}'], 10 | // ['(', ')'], 11 | // ], 12 | // autoClosingPairs: [ 13 | // { open: '{', close: '}' }, 14 | // { open: '[', close: ']' }, 15 | // { open: '(', close: ')' }, 16 | // { open: "'", close: "'" }, 17 | // { open: '"', close: '"' }, 18 | // { open: '', notIn: ['comment', 'string'] }, 19 | // ], 20 | // surroundingPairs: [ 21 | // { open: "'", close: "'" }, 22 | // { open: '"', close: '"' }, 23 | // { open: '{', close: '}' }, 24 | // { open: '[', close: ']' }, 25 | // { open: '(', close: ')' }, 26 | // { open: '<', close: '>' }, 27 | // ], 28 | // colorizedBracketPairs: [], 29 | // folding: { 30 | // markers: { 31 | // start: /^\s*/, 32 | // end: /^\s*/, 33 | // }, 34 | // }, 35 | // wordPattern: new RegExp( 36 | // '(-?\\d*\\.\\d\\w*)|([^\\`\\~\\!\\@\\$\\^\\&\\*\\(\\)\\=\\+\\[\\{\\]\\}\\\\\\|\\;\\:\\\'\\"\\,\\.\\<\\>\\/\\s]+)', 37 | // ), 38 | // onEnterRules: [ 39 | // { 40 | // beforeText: new RegExp( 41 | // '<(?!(?:area|base|br|col|embed|hr|img|input|keygen|link|menuitem|meta|param|source|track|wbr))([_:\\w][_:\\w-.\\d]*)(?:(?:[^\'"/>]|"[^"]*"|\'[^\']*\')*?(?!\\/)>)[^<]*$', 42 | // 'i', 43 | // ), 44 | // afterText: new RegExp('^<\\/([_:\\w][_:\\w-.\\d]*)\\s*>', 'i'), 45 | // action: { 46 | // indentAction: languages.IndentAction.IndentOutdent, 47 | // }, 48 | // }, 49 | // { 50 | // beforeText: new RegExp( 51 | // '<(?!(?:area|base|br|col|embed|hr|img|input|keygen|link|menuitem|meta|param|source|track|wbr))([_:\\w][_:\\w-.\\d]*)(?:(?:[^\'"/>]|"[^"]*"|\'[^\']*\')*?(?!\\/)>)[^<]*$', 52 | // 'i', 53 | // ), 54 | // action: { 55 | // indentAction: languages.IndentAction.Indent, 56 | // }, 57 | // }, 58 | // ], 59 | // indentationRules: { 60 | // increaseIndentPattern: new RegExp( 61 | // '<(?!\\?|(?:area|base|br|col|frame|hr|html|img|input|keygen|link|menuitem|meta|param|source|track|wbr)\\b|[^>]*\\/>)([-_\\.A-Za-z0-9]+)(?=\\s|>)\\b[^>]*>(?!.*<\\/\\1>)|)|\\{[^}"\']*$', 62 | // ), 63 | // decreaseIndentPattern: new RegExp( 64 | // '^\\s*(<\\/(?!html)[-_\\.A-Za-z0-9]+\\b[^>]*>|-->|\\})', 65 | // ), 66 | // }, 67 | // } 68 | 69 | export const css: languages.LanguageConfiguration = { 70 | comments: { 71 | blockComment: ['/*', '*/'], 72 | }, 73 | brackets: [ 74 | ['{', '}'], 75 | ['[', ']'], 76 | ['(', ')'], 77 | ], 78 | autoClosingPairs: [ 79 | { open: '{', close: '}', notIn: ['string', 'comment'] }, 80 | { open: '[', close: ']', notIn: ['string', 'comment'] }, 81 | { open: '(', close: ')', notIn: ['string', 'comment'] }, 82 | { open: '"', close: '"', notIn: ['string', 'comment'] }, 83 | { open: "'", close: "'", notIn: ['string', 'comment'] }, 84 | ], 85 | surroundingPairs: [ 86 | { 87 | open: "'", 88 | close: "'", 89 | }, 90 | { 91 | open: '"', 92 | close: '"', 93 | }, 94 | { 95 | open: '{', 96 | close: '}', 97 | }, 98 | { 99 | open: '[', 100 | close: ']', 101 | }, 102 | { 103 | open: '(', 104 | close: ')', 105 | }, 106 | ], 107 | folding: { 108 | markers: { 109 | start: new RegExp('^\\s*\\/\\*\\s*#region\\b\\s*(.*?)\\s*\\*\\/'), 110 | end: new RegExp('^\\s*\\/\\*\\s*#endregion\\b.*\\*\\/'), 111 | }, 112 | }, 113 | indentationRules: { 114 | increaseIndentPattern: new RegExp('(^.*\\{[^}]*$)'), 115 | decreaseIndentPattern: new RegExp('^\\s*\\}'), 116 | }, 117 | wordPattern: new RegExp( 118 | '(#?-?\\d*\\.\\d\\w*%?)|(::?[\\w-]*(?=[^,{;]*[,{]))|(([@#.!])?[\\w-?]+%?|[@#!.])', 119 | ), 120 | } 121 | 122 | export const vue: languages.LanguageConfiguration = { 123 | comments: { 124 | blockComment: [''], 125 | }, 126 | brackets: [ 127 | [''], 128 | ['<', '>'], 129 | ['{', '}'], 130 | ['(', ')'], 131 | ], 132 | autoClosingPairs: [ 133 | { 134 | open: '{', 135 | close: '}', 136 | }, 137 | { 138 | open: '[', 139 | close: ']', 140 | }, 141 | { 142 | open: '(', 143 | close: ')', 144 | }, 145 | { 146 | open: "'", 147 | close: "'", 148 | }, 149 | { 150 | open: '"', 151 | close: '"', 152 | }, 153 | { 154 | open: '', 156 | notIn: ['comment', 'string'], 157 | }, 158 | { 159 | open: '`', 160 | close: '`', 161 | notIn: ['string', 'comment'], 162 | }, 163 | { 164 | open: '/**', 165 | close: ' */', 166 | notIn: ['string'], 167 | }, 168 | ], 169 | autoCloseBefore: ';:.,=}])><`\'" \n\t', 170 | surroundingPairs: [ 171 | { 172 | open: "'", 173 | close: "'", 174 | }, 175 | { 176 | open: '"', 177 | close: '"', 178 | }, 179 | { 180 | open: '{', 181 | close: '}', 182 | }, 183 | { 184 | open: '[', 185 | close: ']', 186 | }, 187 | { 188 | open: '(', 189 | close: ')', 190 | }, 191 | { 192 | open: '<', 193 | close: '>', 194 | }, 195 | { 196 | open: '`', 197 | close: '`', 198 | }, 199 | ], 200 | colorizedBracketPairs: [], 201 | folding: { 202 | markers: { 203 | start: /^\s*/, 204 | end: /^\s*/, 205 | }, 206 | }, 207 | wordPattern: 208 | /(-?\d*\.\d\w*)|([^\`\@\~\!\%\^\&\*\(\)\-\=\+\[\{\]\}\\\|\;\:\'\"\,\.\<\>/\?\s]+)/, 209 | onEnterRules: [ 210 | { 211 | beforeText: 212 | /<(?!(?:area|base|br|col|embed|hr|img|input|keygen|link|menuitem|meta|param|source|track|wbr|script|style))([_:\w][_:\w-.\d]*)(?:(?:[^'"/>]|"[^"]*"|'[^']*')*?(?!\/)>)[^<]*$/i, 213 | afterText: /^<\/([_:\w][_:\w-.\d]*)\s*>/i, 214 | action: { 215 | indentAction: languages.IndentAction.IndentOutdent, 216 | }, 217 | }, 218 | { 219 | beforeText: 220 | /<(?!(?:area|base|br|col|embed|hr|img|input|keygen|link|menuitem|meta|param|source|track|wbr|script|style))([_:\w][_:\w-.\d]*)(?:(?:[^'"/>]|"[^"]*"|'[^']*')*?(?!\/)>)[^<]*$/i, 221 | action: { 222 | indentAction: languages.IndentAction.Indent, 223 | }, 224 | }, 225 | ], 226 | indentationRules: { 227 | increaseIndentPattern: 228 | /<(?!\?|(?:area|base|br|col|frame|hr|html|img|input|keygen|link|menuitem|meta|param|source|track|wbr|script|style)\b|[^>]*\/>)([-_\.A-Za-z0-9]+)(?=\s|>)\b[^>]*>(?!\s*\()(?!.*<\/\1>)|)|\{[^}"']*$/i, 229 | decreaseIndentPattern: /^\s*(<\/(?!html)[-_\.A-Za-z0-9]+\b[^>]*>|-->|\})/, 230 | }, 231 | } 232 | 233 | export const js: languages.LanguageConfiguration = { 234 | comments: { 235 | lineComment: '//', 236 | blockComment: ['/*', '*/'], 237 | }, 238 | brackets: [ 239 | ['${', '}'], 240 | ['{', '}'], 241 | ['[', ']'], 242 | ['(', ')'], 243 | ], 244 | autoClosingPairs: [ 245 | { 246 | open: '{', 247 | close: '}', 248 | }, 249 | { 250 | open: '[', 251 | close: ']', 252 | }, 253 | { 254 | open: '(', 255 | close: ')', 256 | }, 257 | { 258 | open: "'", 259 | close: "'", 260 | notIn: ['string', 'comment'], 261 | }, 262 | { 263 | open: '"', 264 | close: '"', 265 | notIn: ['string'], 266 | }, 267 | { 268 | open: '`', 269 | close: '`', 270 | notIn: ['string', 'comment'], 271 | }, 272 | { 273 | open: '/**', 274 | close: ' */', 275 | notIn: ['string'], 276 | }, 277 | ], 278 | surroundingPairs: [ 279 | { 280 | open: "'", 281 | close: "'", 282 | }, 283 | { 284 | open: '"', 285 | close: '"', 286 | }, 287 | { 288 | open: '{', 289 | close: '}', 290 | }, 291 | { 292 | open: '[', 293 | close: ']', 294 | }, 295 | { 296 | open: '(', 297 | close: ')', 298 | }, 299 | { 300 | open: '<', 301 | close: '>', 302 | }, 303 | { 304 | open: '`', 305 | close: '`', 306 | }, 307 | ], 308 | autoCloseBefore: ';:.,=}])>` \n\t', 309 | folding: { 310 | markers: { 311 | start: /^\s*\/\/\s*#?region\b/, 312 | end: /^\s*\/\/\s*#?endregion\b/, 313 | }, 314 | }, 315 | wordPattern: 316 | /(-?\d*\.\d\w*)|([^\`\~\@\!\%\^\&\*\(\)\-\=\+\[\{\]\}\\\|\;\:\'\"\,\.\<\>/\?\s]+)/, 317 | indentationRules: { 318 | decreaseIndentPattern: /^((?!.*?\/\*).*\*\/)?\s*[\}\]].*$/, 319 | increaseIndentPattern: 320 | /^((?!\/\/).)*(\{([^}"'`/]*|(\t|[ ])*\/\/.*)|\([^)"'`/]*|\[[^\]"'`/]*)$/, 321 | unIndentedLinePattern: 322 | /^(\t|[ ])*[ ]\*[^/]*\*\/\s*$|^(\t|[ ])*[ ]\*\/\s*$|^(\t|[ ])*[ ]\*([ ]([^\*]|\*(?!\/))*)?$/, 323 | }, 324 | onEnterRules: [ 325 | { 326 | beforeText: /^\s*\/\*\*(?!\/)([^\*]|\*(?!\/))*$/, 327 | afterText: /^\s*\*\/$/, 328 | action: { 329 | indentAction: languages.IndentAction.IndentOutdent, 330 | appendText: ' * ', 331 | }, 332 | }, 333 | { 334 | beforeText: /^\s*\/\*\*(?!\/)([^\*]|\*(?!\/))*$/, 335 | action: { 336 | indentAction: languages.IndentAction.None, 337 | appendText: ' * ', 338 | }, 339 | }, 340 | { 341 | beforeText: /^(\t|[ ])*[ ]\*([ ]([^\*]|\*(?!\/))*)?$/, 342 | previousLineText: /(?=^(\s*(\/\*\*|\*)).*)(?=(?!(\s*\*\/)))/, 343 | action: { 344 | indentAction: languages.IndentAction.None, 345 | appendText: '* ', 346 | }, 347 | }, 348 | { 349 | beforeText: /^(\t|[ ])*[ ]\*\/\s*$/, 350 | action: { 351 | indentAction: languages.IndentAction.None, 352 | removeText: 1, 353 | }, 354 | }, 355 | { 356 | beforeText: /^(\t|[ ])*[ ]\*[^/]*\*\/\s*$/, 357 | action: { 358 | indentAction: languages.IndentAction.None, 359 | removeText: 1, 360 | }, 361 | }, 362 | { 363 | beforeText: /^\s*(\bcase\s.+:|\bdefault:)$/, 364 | afterText: /^(?!\s*(\bcase\b|\bdefault\b))/, 365 | action: { 366 | indentAction: languages.IndentAction.Indent, 367 | }, 368 | }, 369 | { 370 | previousLineText: /^\s*(((else ?)?if|for|while)\s*\(.*\)\s*|else\s*)$/, 371 | beforeText: /^\s+([^{i\s]|i(?!f\b))/, 372 | action: { 373 | indentAction: languages.IndentAction.Outdent, 374 | }, 375 | }, 376 | ], 377 | } 378 | 379 | export const ts: languages.LanguageConfiguration = { 380 | comments: { 381 | lineComment: '//', 382 | blockComment: ['/*', '*/'], 383 | }, 384 | brackets: [ 385 | ['${', '}'], 386 | ['{', '}'], 387 | ['[', ']'], 388 | ['(', ')'], 389 | ], 390 | autoClosingPairs: [ 391 | { 392 | open: '{', 393 | close: '}', 394 | }, 395 | { 396 | open: '[', 397 | close: ']', 398 | }, 399 | { 400 | open: '(', 401 | close: ')', 402 | }, 403 | { 404 | open: "'", 405 | close: "'", 406 | notIn: ['string', 'comment'], 407 | }, 408 | { 409 | open: '"', 410 | close: '"', 411 | notIn: ['string'], 412 | }, 413 | { 414 | open: '`', 415 | close: '`', 416 | notIn: ['string', 'comment'], 417 | }, 418 | { 419 | open: '/**', 420 | close: ' */', 421 | notIn: ['string'], 422 | }, 423 | ], 424 | surroundingPairs: [ 425 | { 426 | open: "'", 427 | close: "'", 428 | }, 429 | { 430 | open: '"', 431 | close: '"', 432 | }, 433 | { 434 | open: '{', 435 | close: '}', 436 | }, 437 | { 438 | open: '[', 439 | close: ']', 440 | }, 441 | { 442 | open: '(', 443 | close: ')', 444 | }, 445 | { 446 | open: '<', 447 | close: '>', 448 | }, 449 | { 450 | open: '`', 451 | close: '`', 452 | }, 453 | ], 454 | colorizedBracketPairs: [ 455 | ['(', ')'], 456 | ['[', ']'], 457 | ['{', '}'], 458 | ['<', '>'], 459 | ], 460 | autoCloseBefore: ';:.,=}])>` \n\t', 461 | folding: { 462 | markers: { 463 | start: /^\s*\/\/\s*#?region\b/, 464 | end: /^\s*\/\/\s*#?endregion\b/, 465 | }, 466 | }, 467 | wordPattern: 468 | /(-?\d*\.\d\w*)|([^\`\~\@\!\%\^\&\*\(\)\-\=\+\[\{\]\}\\\|\;\:\'\"\,\.\<\>/\?\s]+)/, 469 | indentationRules: { 470 | decreaseIndentPattern: /^((?!.*?\/\*).*\*\/)?\s*[\}\]].*$/, 471 | increaseIndentPattern: 472 | /^((?!\/\/).)*(\{([^}"'`/]*|(\t|[ ])*\/\/.*)|\([^)"'`/]*|\[[^\]"'`/]*)$/, 473 | unIndentedLinePattern: 474 | /^(\t|[ ])*[ ]\*[^/]*\*\/\s*$|^(\t|[ ])*[ ]\*\/\s*$|^(\t|[ ])*[ ]\*([ ]([^\*]|\*(?!\/))*)?$/, 475 | }, 476 | onEnterRules: [ 477 | { 478 | beforeText: /^\s*\/\*\*(?!\/)([^\*]|\*(?!\/))*$/, 479 | afterText: /^\s*\*\/$/, 480 | action: { 481 | indentAction: languages.IndentAction.IndentOutdent, 482 | appendText: ' * ', 483 | }, 484 | }, 485 | { 486 | beforeText: /^\s*\/\*\*(?!\/)([^\*]|\*(?!\/))*$/, 487 | action: { 488 | indentAction: languages.IndentAction.None, 489 | appendText: ' * ', 490 | }, 491 | }, 492 | { 493 | beforeText: /^(\t|[ ])*[ ]\*([ ]([^\*]|\*(?!\/))*)?$/, 494 | previousLineText: /(?=^(\s*(\/\*\*|\*)).*)(?=(?!(\s*\*\/)))/, 495 | action: { 496 | indentAction: languages.IndentAction.None, 497 | appendText: '* ', 498 | }, 499 | }, 500 | { 501 | beforeText: /^(\t|[ ])*[ ]\*\/\s*$/, 502 | action: { 503 | indentAction: languages.IndentAction.None, 504 | removeText: 1, 505 | }, 506 | }, 507 | { 508 | beforeText: /^(\t|[ ])*[ ]\*[^/]*\*\/\s*$/, 509 | action: { 510 | indentAction: languages.IndentAction.None, 511 | removeText: 1, 512 | }, 513 | }, 514 | { 515 | beforeText: /^\s*(\bcase\s.+:|\bdefault:)$/, 516 | afterText: /^(?!\s*(\bcase\b|\bdefault\b))/, 517 | action: { 518 | indentAction: languages.IndentAction.Indent, 519 | }, 520 | }, 521 | { 522 | previousLineText: /^\s*(((else ?)?if|for|while)\s*\(.*\)\s*|else\s*)$/, 523 | beforeText: /^\s+([^{i\s]|i(?!f\b))/, 524 | action: { 525 | indentAction: languages.IndentAction.Outdent, 526 | }, 527 | }, 528 | ], 529 | } 530 | -------------------------------------------------------------------------------- /src/store.ts: -------------------------------------------------------------------------------- 1 | import { 2 | type ToRefs, 3 | type UnwrapRef, 4 | computed, 5 | reactive, 6 | ref, 7 | shallowRef, 8 | watch, 9 | watchEffect, 10 | } from 'vue' 11 | import * as defaultCompiler from 'vue/compiler-sfc' 12 | import { compileFile } from './transform' 13 | import { atou, utoa } from './utils' 14 | import type { 15 | SFCAsyncStyleCompileOptions, 16 | SFCScriptCompileOptions, 17 | SFCTemplateCompileOptions, 18 | } from 'vue/compiler-sfc' 19 | import type { OutputModes } from './types' 20 | import type { editor } from 'monaco-editor-core' 21 | import { type ImportMap, mergeImportMap, useVueImportMap } from './import-map' 22 | 23 | import welcomeSFCCode from './template/welcome.vue?raw' 24 | import newSFCCode from './template/new-sfc.vue?raw' 25 | 26 | export const importMapFile = 'import-map.json' 27 | export const tsconfigFile = 'tsconfig.json' 28 | 29 | export function useStore( 30 | { 31 | files = ref(Object.create(null)), 32 | activeFilename = undefined!, // set later 33 | mainFile = ref('src/App.vue'), 34 | template = ref({ 35 | welcomeSFC: welcomeSFCCode, 36 | newSFC: newSFCCode, 37 | }), 38 | builtinImportMap = undefined!, // set later 39 | 40 | errors = ref([]), 41 | showOutput = ref(false), 42 | outputMode = ref('preview'), 43 | sfcOptions = ref({}), 44 | compiler = shallowRef(defaultCompiler), 45 | vueVersion = ref(null), 46 | 47 | locale = ref(), 48 | typescriptVersion = ref('latest'), 49 | dependencyVersion = ref(Object.create(null)), 50 | reloadLanguageTools = ref(), 51 | }: Partial = {}, 52 | serializedState?: string, 53 | ): ReplStore { 54 | if (!builtinImportMap) { 55 | ;({ importMap: builtinImportMap, vueVersion } = useVueImportMap({ 56 | vueVersion: vueVersion.value, 57 | })) 58 | } 59 | const loading = ref(false) 60 | 61 | function applyBuiltinImportMap() { 62 | const importMap = mergeImportMap(builtinImportMap.value, getImportMap()) 63 | setImportMap(importMap) 64 | } 65 | 66 | function init() { 67 | watchEffect(() => { 68 | compileFile(store, activeFile.value).then((errs) => (errors.value = errs)) 69 | }) 70 | 71 | watch( 72 | () => [ 73 | files.value[tsconfigFile]?.code, 74 | typescriptVersion.value, 75 | locale.value, 76 | dependencyVersion.value, 77 | vueVersion.value, 78 | ], 79 | () => reloadLanguageTools.value?.(), 80 | { deep: true }, 81 | ) 82 | 83 | watch( 84 | builtinImportMap, 85 | () => { 86 | setImportMap(mergeImportMap(getImportMap(), builtinImportMap.value)) 87 | }, 88 | { deep: true }, 89 | ) 90 | 91 | watch( 92 | vueVersion, 93 | async (version) => { 94 | if (version) { 95 | const compilerUrl = `https://cdn.jsdelivr.net/npm/@vue/compiler-sfc@${version}/dist/compiler-sfc.esm-browser.js` 96 | loading.value = true 97 | compiler.value = await import(/* @vite-ignore */ compilerUrl).finally( 98 | () => (loading.value = false), 99 | ) 100 | console.info(`[@vue/repl] Now using Vue version: ${version}`) 101 | } else { 102 | // reset to default 103 | compiler.value = defaultCompiler 104 | console.info(`[@vue/repl] Now using default Vue version`) 105 | } 106 | }, 107 | { immediate: true }, 108 | ) 109 | 110 | // Recompile all Vue SFC files when the compiler changes. 111 | // This ensures that when switching Vue versions (e.g., from <3.6 to >=3.6), 112 | // all vue sfc files are recompiled with the new compiler to correctly handle 113 | // vapor components. 114 | watch(compiler, (_, oldCompiler) => { 115 | if (!oldCompiler) return 116 | for (const file of Object.values(files.value)) { 117 | if (file.filename.endsWith('.vue')) { 118 | compileFile(store, file).then((errs) => errors.value.push(...errs)) 119 | } 120 | } 121 | }) 122 | 123 | watch( 124 | sfcOptions, 125 | () => { 126 | sfcOptions.value.script ||= {} 127 | sfcOptions.value.script.fs = { 128 | fileExists(file: string) { 129 | if (file.startsWith('/')) file = file.slice(1) 130 | return !!store.files[file] 131 | }, 132 | readFile(file: string) { 133 | if (file.startsWith('/')) file = file.slice(1) 134 | return store.files[file].code 135 | }, 136 | } 137 | }, 138 | { immediate: true }, 139 | ) 140 | 141 | // init tsconfig 142 | if (!files.value[tsconfigFile]) { 143 | files.value[tsconfigFile] = new File( 144 | tsconfigFile, 145 | JSON.stringify(tsconfig, undefined, 2), 146 | ) 147 | } 148 | 149 | // compile rest of the files 150 | errors.value = [] 151 | for (const [filename, file] of Object.entries(files.value)) { 152 | if (filename !== mainFile.value) { 153 | compileFile(store, file).then((errs) => errors.value.push(...errs)) 154 | } 155 | } 156 | } 157 | 158 | function setImportMap(map: ImportMap, merge = false) { 159 | if (merge) { 160 | map = mergeImportMap(getImportMap(), map) 161 | } 162 | 163 | if (map.imports) 164 | for (const [key, value] of Object.entries(map.imports)) { 165 | if (value) { 166 | map.imports![key] = fixURL(value) 167 | } 168 | } 169 | 170 | const code = JSON.stringify(map, undefined, 2) 171 | if (files.value[importMapFile]) { 172 | files.value[importMapFile].code = code 173 | } else { 174 | files.value[importMapFile] = new File(importMapFile, code) 175 | } 176 | } 177 | 178 | const setActive: Store['setActive'] = (filename) => { 179 | activeFilename.value = filename 180 | } 181 | const addFile: Store['addFile'] = (fileOrFilename) => { 182 | let file: File 183 | if (typeof fileOrFilename === 'string') { 184 | file = new File( 185 | fileOrFilename, 186 | fileOrFilename.endsWith('.vue') ? template.value.newSFC : '', 187 | ) 188 | } else { 189 | file = fileOrFilename 190 | } 191 | files.value[file.filename] = file 192 | if (!file.hidden) setActive(file.filename) 193 | } 194 | const deleteFile: Store['deleteFile'] = (filename) => { 195 | if ( 196 | !confirm(`Are you sure you want to delete ${stripSrcPrefix(filename)}?`) 197 | ) { 198 | return 199 | } 200 | 201 | if (activeFilename.value === filename) { 202 | activeFilename.value = mainFile.value 203 | } 204 | delete files.value[filename] 205 | } 206 | const renameFile: Store['renameFile'] = (oldFilename, newFilename) => { 207 | const file = files.value[oldFilename] 208 | 209 | if (!file) { 210 | errors.value = [`Could not rename "${oldFilename}", file not found`] 211 | return 212 | } 213 | 214 | if (!newFilename || oldFilename === newFilename) { 215 | errors.value = [`Cannot rename "${oldFilename}" to "${newFilename}"`] 216 | return 217 | } 218 | 219 | file.filename = newFilename 220 | const newFiles: Record = {} 221 | 222 | // Preserve iteration order for files 223 | for (const [name, file] of Object.entries(files.value)) { 224 | if (name === oldFilename) { 225 | newFiles[newFilename] = file 226 | } else { 227 | newFiles[name] = file 228 | } 229 | } 230 | 231 | files.value = newFiles 232 | 233 | if (mainFile.value === oldFilename) { 234 | mainFile.value = newFilename 235 | } 236 | if (activeFilename.value === oldFilename) { 237 | activeFilename.value = newFilename 238 | } else { 239 | compileFile(store, file).then((errs) => (errors.value = errs)) 240 | } 241 | } 242 | const getImportMap: Store['getImportMap'] = () => { 243 | try { 244 | return JSON.parse(files.value[importMapFile].code) 245 | } catch (e) { 246 | errors.value = [ 247 | `Syntax error in ${importMapFile}: ${(e as Error).message}`, 248 | ] 249 | return {} 250 | } 251 | } 252 | const getTsConfig: Store['getTsConfig'] = () => { 253 | try { 254 | return JSON.parse(files.value[tsconfigFile].code) 255 | } catch { 256 | return {} 257 | } 258 | } 259 | const serialize: ReplStore['serialize'] = () => { 260 | const files = getFiles() 261 | const importMap = files[importMapFile] 262 | if (importMap) { 263 | const parsed = JSON.parse(importMap) 264 | const builtin = builtinImportMap.value.imports || {} 265 | 266 | if (parsed.imports) { 267 | for (const [key, value] of Object.entries(parsed.imports)) { 268 | if (builtin[key] === value) { 269 | delete parsed.imports[key] 270 | } 271 | } 272 | if (parsed.imports && !Object.keys(parsed.imports).length) { 273 | delete parsed.imports 274 | } 275 | } 276 | if (parsed.scopes && !Object.keys(parsed.scopes).length) { 277 | delete parsed.scopes 278 | } 279 | if (Object.keys(parsed).length) { 280 | files[importMapFile] = JSON.stringify(parsed, null, 2) 281 | } else { 282 | delete files[importMapFile] 283 | } 284 | } 285 | if (vueVersion.value) files._version = vueVersion.value 286 | if (typescriptVersion.value !== 'latest' || files._tsVersion) { 287 | files._tsVersion = typescriptVersion.value 288 | } 289 | return '#' + utoa(JSON.stringify(files)) 290 | } 291 | const deserialize: ReplStore['deserialize'] = ( 292 | serializedState: string, 293 | checkBuiltinImportMap = true, 294 | ) => { 295 | if (serializedState.startsWith('#')) 296 | serializedState = serializedState.slice(1) 297 | let saved: any 298 | try { 299 | saved = JSON.parse(atou(serializedState)) 300 | } catch (err) { 301 | console.error(err) 302 | alert('Failed to load code from URL.') 303 | return setDefaultFile() 304 | } 305 | for (const filename in saved) { 306 | if (filename === '_version') { 307 | vueVersion.value = saved[filename] 308 | } else if (filename === '_tsVersion') { 309 | typescriptVersion.value = saved[filename] 310 | } else { 311 | setFile(files.value, filename, saved[filename]) 312 | } 313 | } 314 | if (checkBuiltinImportMap) { 315 | applyBuiltinImportMap() 316 | } 317 | } 318 | const getFiles: ReplStore['getFiles'] = () => { 319 | const exported: Record = {} 320 | for (const [filename, file] of Object.entries(files.value)) { 321 | const normalized = stripSrcPrefix(filename) 322 | exported[normalized] = file.code 323 | } 324 | return exported 325 | } 326 | const setFiles: ReplStore['setFiles'] = async ( 327 | newFiles, 328 | mainFile = store.mainFile, 329 | ) => { 330 | const files: Record = Object.create(null) 331 | 332 | mainFile = addSrcPrefix(mainFile) 333 | if (!newFiles[mainFile]) { 334 | setFile(files, mainFile, template.value.welcomeSFC || welcomeSFCCode) 335 | } 336 | for (const [filename, file] of Object.entries(newFiles)) { 337 | setFile(files, filename, file) 338 | } 339 | 340 | const errors = [] 341 | for (const file of Object.values(files)) { 342 | errors.push(...(await compileFile(store, file))) 343 | } 344 | 345 | store.mainFile = mainFile 346 | store.files = files 347 | store.errors = errors 348 | applyBuiltinImportMap() 349 | setActive(store.mainFile) 350 | } 351 | const setDefaultFile = (): void => { 352 | setFile( 353 | files.value, 354 | mainFile.value, 355 | template.value.welcomeSFC || welcomeSFCCode, 356 | ) 357 | } 358 | 359 | if (serializedState) { 360 | deserialize(serializedState, false) 361 | } else { 362 | setDefaultFile() 363 | } 364 | if (!files.value[mainFile.value]) { 365 | mainFile.value = Object.keys(files.value)[0] 366 | } 367 | activeFilename ||= ref(mainFile.value) 368 | const activeFile = computed(() => files.value[activeFilename.value]) 369 | 370 | applyBuiltinImportMap() 371 | 372 | const store: ReplStore = reactive({ 373 | files, 374 | activeFile, 375 | activeFilename, 376 | mainFile, 377 | template, 378 | builtinImportMap, 379 | 380 | errors, 381 | showOutput, 382 | outputMode, 383 | sfcOptions, 384 | ssrOutput: { html: '', context: '' }, 385 | compiler, 386 | loading, 387 | vueVersion, 388 | 389 | locale, 390 | typescriptVersion, 391 | dependencyVersion, 392 | reloadLanguageTools, 393 | 394 | init, 395 | setActive, 396 | addFile, 397 | deleteFile, 398 | renameFile, 399 | getImportMap, 400 | setImportMap, 401 | getTsConfig, 402 | serialize, 403 | deserialize, 404 | getFiles, 405 | setFiles, 406 | }) 407 | return store 408 | } 409 | 410 | const tsconfig = { 411 | compilerOptions: { 412 | allowJs: true, 413 | checkJs: true, 414 | jsx: 'Preserve', 415 | target: 'ESNext', 416 | module: 'ESNext', 417 | moduleResolution: 'Bundler', 418 | allowImportingTsExtensions: true, 419 | }, 420 | vueCompilerOptions: { 421 | target: 3.4, 422 | }, 423 | } 424 | 425 | export interface SFCOptions { 426 | script?: Partial 427 | style?: Partial 428 | template?: Partial 429 | } 430 | 431 | export type StoreState = ToRefs<{ 432 | files: Record 433 | activeFilename: string 434 | mainFile: string 435 | template: { 436 | welcomeSFC?: string 437 | newSFC?: string 438 | } 439 | builtinImportMap: ImportMap 440 | 441 | // output 442 | errors: (string | Error)[] 443 | showOutput: boolean 444 | outputMode: OutputModes 445 | sfcOptions: SFCOptions 446 | ssrOutput: { 447 | html: string 448 | context: unknown 449 | } 450 | /** `@vue/compiler-sfc` */ 451 | compiler: typeof defaultCompiler 452 | /* only apply for compiler-sfc */ 453 | vueVersion: string | null 454 | 455 | // volar-related 456 | locale: string | undefined 457 | typescriptVersion: string 458 | /** \{ dependencyName: version \} */ 459 | dependencyVersion: Record 460 | reloadLanguageTools?: (() => void) | undefined 461 | }> 462 | 463 | export interface ReplStore extends UnwrapRef { 464 | activeFile: File 465 | /** Loading compiler */ 466 | loading: boolean 467 | init(): void 468 | setActive(filename: string): void 469 | addFile(filename: string | File): void 470 | deleteFile(filename: string): void 471 | renameFile(oldFilename: string, newFilename: string): void 472 | getImportMap(): ImportMap 473 | setImportMap(map: ImportMap, merge?: boolean): void 474 | getTsConfig(): Record 475 | serialize(): string 476 | /** 477 | * Deserializes the given string to restore the REPL store state. 478 | * @param serializedState - The serialized state string. 479 | * @param checkBuiltinImportMap - Whether to check the built-in import map. Default to true 480 | */ 481 | deserialize(serializedState: string, checkBuiltinImportMap?: boolean): void 482 | getFiles(): Record 483 | setFiles(newFiles: Record, mainFile?: string): Promise 484 | } 485 | 486 | export type Store = Pick< 487 | ReplStore, 488 | | 'files' 489 | | 'activeFile' 490 | | 'mainFile' 491 | | 'errors' 492 | | 'showOutput' 493 | | 'outputMode' 494 | | 'sfcOptions' 495 | | 'ssrOutput' 496 | | 'compiler' 497 | | 'vueVersion' 498 | | 'locale' 499 | | 'typescriptVersion' 500 | | 'dependencyVersion' 501 | | 'reloadLanguageTools' 502 | | 'init' 503 | | 'setActive' 504 | | 'addFile' 505 | | 'deleteFile' 506 | | 'renameFile' 507 | | 'getImportMap' 508 | | 'getTsConfig' 509 | > 510 | 511 | export class File { 512 | compiled = { 513 | js: '', 514 | css: '', 515 | ssr: '', 516 | clientMap: '', 517 | ssrMap: '', 518 | } 519 | editorViewState: editor.ICodeEditorViewState | null = null 520 | 521 | constructor( 522 | public filename: string, 523 | public code = '', 524 | public hidden = false, 525 | ) {} 526 | 527 | get language() { 528 | if (this.filename.endsWith('.vue')) { 529 | return 'vue' 530 | } 531 | if (this.filename.endsWith('.html')) { 532 | return 'html' 533 | } 534 | if (this.filename.endsWith('.css')) { 535 | return 'css' 536 | } 537 | if (this.filename.endsWith('.ts')) { 538 | return 'typescript' 539 | } 540 | return 'javascript' 541 | } 542 | } 543 | 544 | function addSrcPrefix(file: string) { 545 | return file === importMapFile || 546 | file === tsconfigFile || 547 | file.startsWith('src/') 548 | ? file 549 | : `src/${file}` 550 | } 551 | 552 | export function stripSrcPrefix(file: string) { 553 | return file.replace(/^src\//, '') 554 | } 555 | 556 | function fixURL(url: string) { 557 | return url.replace('https://sfc.vuejs', 'https://play.vuejs') 558 | } 559 | 560 | function setFile( 561 | files: Record, 562 | filename: string, 563 | content: string, 564 | ) { 565 | const normalized = addSrcPrefix(filename) 566 | files[normalized] = new File(normalized, content) 567 | } 568 | --------------------------------------------------------------------------------