├── .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 |
4 |
5 |
6 |
7 |
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 |
8 | {{ msg }}
9 |
10 |
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 |
18 |
25 |
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 |
9 |
10 |
HTML
11 |
{{ html }}
12 |
Context
13 |
{{ context }}
14 |
15 |
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 |
25 |
34 |
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 |
42 |
48 |
49 |
--------------------------------------------------------------------------------
/src/editor/ToggleButton.vue:
--------------------------------------------------------------------------------
1 |
5 |
6 |
7 |
13 |
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 |
37 |
38 |
39 |
44 |
45 |
46 |
47 |
52 |
57 |
58 |
59 |
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 |
34 |
35 |
40 |
{{ formatMessage(err || warn!) }}
41 |
42 |
43 |
44 |
45 |
46 |
129 |
--------------------------------------------------------------------------------
/src/codemirror/CodeMirror.vue:
--------------------------------------------------------------------------------
1 |
2 |
8 |
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 |
60 |
61 |
69 |
70 |
71 |
86 |
87 |
88 |
91 |
92 |
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 |
33 |
34 |
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 |
50 |
51 |
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 |
107 |
108 |
109 | ```
110 |
111 | Use only the Preview without the editor
112 |
113 | ```vue
114 |
130 |
131 |
132 |
133 |
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 |
102 |
103 |
104 |
105 |
106 |
107 |
108 |
116 |
117 |
118 |
119 |
120 |
121 |
158 |
--------------------------------------------------------------------------------
/src/monaco/Monaco.vue:
--------------------------------------------------------------------------------
1 |
173 |
174 |
175 |
181 |
182 |
183 |
191 |
--------------------------------------------------------------------------------
/src/SplitPane.vue:
--------------------------------------------------------------------------------
1 |
61 |
62 |
63 |
76 |
83 |
87 |
88 | {{ `${state.viewWidth}px x ${state.viewHeight}px` }}
89 |
90 |
91 |
92 |
93 |
100 |
101 |
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 |
115 |
121 |
122 | 0 && editFileName(file)"
128 | >
129 | {{ stripSrcPrefix(file) }}
130 |
131 |
135 |
136 |
137 |
142 | {{ pendingFilename }}
143 |
151 |
152 |
153 |
154 |
155 |
156 |
162 | tsconfig.json
163 |
164 |
170 | Import Map
171 |
172 |
173 |
174 |
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 = /
349 |
350 |
351 |
357 |
358 |
362 |
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 |