diff --git a/package.json b/package.json index ab1f9233..fe2957dc 100644 --- a/package.json +++ b/package.json @@ -48,6 +48,7 @@ }, "scripts": { "dev": "vite", + "dev:playground": "vite -c playground/vite.config.ts", "build": "vite build", "build-preview": "vite build -c vite.preview.config.ts", "format": "prettier --write .", diff --git a/playground/README.md b/playground/README.md new file mode 100644 index 00000000..57fb29ea --- /dev/null +++ b/playground/README.md @@ -0,0 +1,84 @@ +# Vue REPL Playground + +Optimize `test/main` to support dynamically adding scenario validations for the REPL. Use `npm run dev:playground` to try it out. + +## Playground Architecture + +### Directory Structure + +``` +playground/ +├── index.html # HTML entry file with scenario switcher +├── vite.config.ts # Standalone Vite config +├── vite-plugin-scenario.js # Parse scenarios directory and generate REPL configuration +├── scenarios/ # Scenario directory, add dynamically +│ ├── basic/ # Basic example +│ ├── customMain/ # Custom main entry +│ ├── html/ # html as entry example +│ ├── pinia/ # Pinia state management example +│ └── vueRouter/ # Vue Router example +│ └── vueUse/ # Vue Use example +└── src/ + └── App.vue # Main application component +``` + +### How It Works + +The playground uses a directory-based scenario system, where each scenario is an independent folder under `scenarios/`. Core features include: + +- **Virtual Module System**: A Vite plugin scans the scenario directory and generates a virtual module `virtual:playground-files` +- **Dynamic Scenario Loading**: Users can switch scenarios via the UI, which automatically loads the corresponding configuration + +### Scenario Structure + +Each scenario directory typically contains the following files: + +``` +scenarios/example-scenario/ +├── App.vue # Main component +├── main.ts # Entry file +├── import-map.json # Dependency mapping +├── tsconfig.json # TypeScript config +└── _meta.js # Metadata config for REPL settings +``` + +The `_meta.js` file exports the scenario configuration: + +```javascript +export default { + mainFile: 'main.ts', // Specify the main entry file +} +``` + +## Usage Example + +### Start the Playground + +```bash +# Enter the project directory +cd vue-repl + +# Install dependencies +npm install + +# Start the development server +npm run dev:playground +``` + +Visit the displayed local address (usually http://localhost:5174/) to use the playground. + +### Add a New Scenario + +1. Create a new folder under the `scenarios/` directory, e.g. `myScenario` +2. Add the required files: + + ``` + myScenario/ + ├── App.vue # Main component + ├── main.ts # Entry file (default entry) + ├── import-map.json # Dependency config + ├── tsconfig.json # TypeScript config + └── _meta.js # Config with mainFile: 'main.ts' + ``` + +3. Refresh the browser, and the new scenario will automatically appear in the dropdown menu. diff --git a/playground/index.html b/playground/index.html new file mode 100644 index 00000000..90135e61 --- /dev/null +++ b/playground/index.html @@ -0,0 +1,18 @@ + + + + + + + Playground + + + + +
+ + diff --git a/playground/main.ts b/playground/main.ts new file mode 100644 index 00000000..5677bf0d --- /dev/null +++ b/playground/main.ts @@ -0,0 +1,5 @@ +import { createApp } from 'vue' +// @ts-ignore +import App from './src/App.vue' + +createApp(App).mount('#app') diff --git a/playground/scenarios/basic/App.vue b/playground/scenarios/basic/App.vue new file mode 100644 index 00000000..69eafb79 --- /dev/null +++ b/playground/scenarios/basic/App.vue @@ -0,0 +1,15 @@ + + + diff --git a/playground/scenarios/basic/_meta.js b/playground/scenarios/basic/_meta.js new file mode 100644 index 00000000..67c0b6c7 --- /dev/null +++ b/playground/scenarios/basic/_meta.js @@ -0,0 +1,6 @@ +export default { + mainFile: 'App.vue', + ReplOptions: { + theme: 'dark', + }, +} diff --git a/playground/scenarios/customMain/App.vue b/playground/scenarios/customMain/App.vue new file mode 100644 index 00000000..00debe0f --- /dev/null +++ b/playground/scenarios/customMain/App.vue @@ -0,0 +1,6 @@ + diff --git a/playground/scenarios/customMain/_meta.js b/playground/scenarios/customMain/_meta.js new file mode 100644 index 00000000..ce23dfb4 --- /dev/null +++ b/playground/scenarios/customMain/_meta.js @@ -0,0 +1,3 @@ +export default { + mainFile: 'main.ts', +} diff --git a/playground/scenarios/customMain/import-map.json b/playground/scenarios/customMain/import-map.json new file mode 100644 index 00000000..60857ee5 --- /dev/null +++ b/playground/scenarios/customMain/import-map.json @@ -0,0 +1,5 @@ +{ + "imports": { + "vue": "https://unpkg.com/vue@3/dist/vue.esm-browser.js" + } +} diff --git a/playground/scenarios/customMain/main.ts b/playground/scenarios/customMain/main.ts new file mode 100644 index 00000000..0b99e5a6 --- /dev/null +++ b/playground/scenarios/customMain/main.ts @@ -0,0 +1,6 @@ +import { createApp } from 'vue' +import App from './App.vue' + +const app = createApp(App) +app.config.globalProperties.$hi = () => alert('hi Vue') +app.mount('#app') diff --git a/playground/scenarios/customMain/tsconfig.json b/playground/scenarios/customMain/tsconfig.json new file mode 100644 index 00000000..3d685f65 --- /dev/null +++ b/playground/scenarios/customMain/tsconfig.json @@ -0,0 +1,9 @@ +{ + "compilerOptions": { + "target": "esnext", + "module": "esnext", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true + } +} diff --git a/playground/scenarios/html/_meta.js b/playground/scenarios/html/_meta.js new file mode 100644 index 00000000..4a59d442 --- /dev/null +++ b/playground/scenarios/html/_meta.js @@ -0,0 +1,6 @@ +export default { + mainFile: 'index.html', + ReplOptions: { + theme: 'dark', + }, +} diff --git a/playground/scenarios/html/import-map.json b/playground/scenarios/html/import-map.json new file mode 100644 index 00000000..60857ee5 --- /dev/null +++ b/playground/scenarios/html/import-map.json @@ -0,0 +1,5 @@ +{ + "imports": { + "vue": "https://unpkg.com/vue@3/dist/vue.esm-browser.js" + } +} diff --git a/playground/scenarios/html/index.html b/playground/scenarios/html/index.html new file mode 100644 index 00000000..a3ca398d --- /dev/null +++ b/playground/scenarios/html/index.html @@ -0,0 +1,41 @@ + + +
+
+ + +
+ +
diff --git a/playground/scenarios/pinia/App.vue b/playground/scenarios/pinia/App.vue new file mode 100644 index 00000000..62bbea57 --- /dev/null +++ b/playground/scenarios/pinia/App.vue @@ -0,0 +1,17 @@ + + + diff --git a/playground/scenarios/pinia/_meta.js b/playground/scenarios/pinia/_meta.js new file mode 100644 index 00000000..ce23dfb4 --- /dev/null +++ b/playground/scenarios/pinia/_meta.js @@ -0,0 +1,3 @@ +export default { + mainFile: 'main.ts', +} diff --git a/playground/scenarios/pinia/import-map.json b/playground/scenarios/pinia/import-map.json new file mode 100644 index 00000000..86ce97d1 --- /dev/null +++ b/playground/scenarios/pinia/import-map.json @@ -0,0 +1,8 @@ +{ + "imports": { + "vue": "https://unpkg.com/vue@3/dist/vue.esm-browser.js", + "pinia": "https://unpkg.com/pinia@2/dist/pinia.esm-browser.js", + "vue-demi": "https://cdn.jsdelivr.net/npm/vue-demi@0.14.7/lib/v3/index.mjs", + "@vue/devtools-api": "https://cdn.jsdelivr.net/npm/@vue/devtools-api@6.6.1/lib/esm/index.js" + } +} diff --git a/playground/scenarios/pinia/main.ts b/playground/scenarios/pinia/main.ts new file mode 100644 index 00000000..ce885f3c --- /dev/null +++ b/playground/scenarios/pinia/main.ts @@ -0,0 +1,8 @@ +import { createApp } from 'vue' +import { createPinia } from 'pinia' +import App from './App.vue' + +const pinia = createPinia() +const app = createApp(App) +app.use(pinia) +app.mount('#app') diff --git a/playground/scenarios/pinia/store.ts b/playground/scenarios/pinia/store.ts new file mode 100644 index 00000000..278cca74 --- /dev/null +++ b/playground/scenarios/pinia/store.ts @@ -0,0 +1,12 @@ +import { defineStore } from 'pinia' + +export const useMainStore = defineStore('main', { + state: () => ({ + message: 'Hello from Pinia!', + }), + actions: { + updateMessage(newMessage: string) { + this.message = newMessage + }, + }, +}) diff --git a/playground/scenarios/pinia/tsconfig.json b/playground/scenarios/pinia/tsconfig.json new file mode 100644 index 00000000..3d685f65 --- /dev/null +++ b/playground/scenarios/pinia/tsconfig.json @@ -0,0 +1,9 @@ +{ + "compilerOptions": { + "target": "esnext", + "module": "esnext", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true + } +} diff --git a/playground/scenarios/vueRouter/About.vue b/playground/scenarios/vueRouter/About.vue new file mode 100644 index 00000000..cb1e23cf --- /dev/null +++ b/playground/scenarios/vueRouter/About.vue @@ -0,0 +1,17 @@ + + + diff --git a/playground/scenarios/vueRouter/App.vue b/playground/scenarios/vueRouter/App.vue new file mode 100644 index 00000000..84b1033b --- /dev/null +++ b/playground/scenarios/vueRouter/App.vue @@ -0,0 +1,15 @@ + + + diff --git a/playground/scenarios/vueRouter/Home.vue b/playground/scenarios/vueRouter/Home.vue new file mode 100644 index 00000000..98a25376 --- /dev/null +++ b/playground/scenarios/vueRouter/Home.vue @@ -0,0 +1,5 @@ + diff --git a/playground/scenarios/vueRouter/_meta.js b/playground/scenarios/vueRouter/_meta.js new file mode 100644 index 00000000..ce23dfb4 --- /dev/null +++ b/playground/scenarios/vueRouter/_meta.js @@ -0,0 +1,3 @@ +export default { + mainFile: 'main.ts', +} diff --git a/playground/scenarios/vueRouter/import-map.json b/playground/scenarios/vueRouter/import-map.json new file mode 100644 index 00000000..e9f5c7a7 --- /dev/null +++ b/playground/scenarios/vueRouter/import-map.json @@ -0,0 +1,9 @@ +{ + "imports": { + "vue": "https://unpkg.com/vue@3/dist/vue.esm-browser.js", + "vue/server-renderer": "http://localhost:5173/src/vue-server-renderer-dev-proxy", + "vue-router": "https://unpkg.com/vue-router@4/dist/vue-router.esm-browser.js", + "@vue/devtools-api": "https://cdn.jsdelivr.net/npm/@vue/devtools-api@6.6.1/lib/esm/index.js" + }, + "scopes": {} +} diff --git a/playground/scenarios/vueRouter/main.ts b/playground/scenarios/vueRouter/main.ts new file mode 100644 index 00000000..efa63ce9 --- /dev/null +++ b/playground/scenarios/vueRouter/main.ts @@ -0,0 +1,25 @@ +import { createApp } from 'vue' +import { createRouter, createMemoryHistory } from 'vue-router' +import App from './App.vue' +import Home from './Home.vue' +import About from './About.vue' + +const routes = [ + { path: '/', component: Home }, + { path: '/about', component: About }, +] + +// Use createMemoryHistory instead of createWebHistory in the sandbox environment +const router = createRouter({ + history: createMemoryHistory(), + routes, +}) + +// Add router error handling +router.onError((error) => { + console.error('Vue Router error:', error) +}) + +const app = createApp(App) +app.use(router) +app.mount('#app') diff --git a/playground/scenarios/vueRouter/tsconfig.json b/playground/scenarios/vueRouter/tsconfig.json new file mode 100644 index 00000000..3d685f65 --- /dev/null +++ b/playground/scenarios/vueRouter/tsconfig.json @@ -0,0 +1,9 @@ +{ + "compilerOptions": { + "target": "esnext", + "module": "esnext", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true + } +} diff --git a/playground/scenarios/vueUse/App.vue b/playground/scenarios/vueUse/App.vue new file mode 100644 index 00000000..2e118b58 --- /dev/null +++ b/playground/scenarios/vueUse/App.vue @@ -0,0 +1,25 @@ + + + + + diff --git a/playground/scenarios/vueUse/Coordinate.vue b/playground/scenarios/vueUse/Coordinate.vue new file mode 100644 index 00000000..a2dc7b59 --- /dev/null +++ b/playground/scenarios/vueUse/Coordinate.vue @@ -0,0 +1,35 @@ + + + + + diff --git a/playground/scenarios/vueUse/_meta.js b/playground/scenarios/vueUse/_meta.js new file mode 100644 index 00000000..39b877c3 --- /dev/null +++ b/playground/scenarios/vueUse/_meta.js @@ -0,0 +1,3 @@ +export default { + mainFile: 'App.vue', +} diff --git a/playground/scenarios/vueUse/import-map.json b/playground/scenarios/vueUse/import-map.json new file mode 100644 index 00000000..8938cd22 --- /dev/null +++ b/playground/scenarios/vueUse/import-map.json @@ -0,0 +1,8 @@ +{ + "imports": { + "vue": "https://unpkg.com/vue@3/dist/vue.esm-browser.js", + "@vueuse/core": "https://unpkg.com/@vueuse/core@10.1.0/index.mjs", + "@vueuse/shared": "https://unpkg.com/@vueuse/shared@10.1.0/index.mjs", + "vue-demi": "https://cdn.jsdelivr.net/npm/vue-demi@0.14.7/lib/v3/index.mjs" + } +} diff --git a/playground/src/App.vue b/playground/src/App.vue new file mode 100644 index 00000000..69f41acf --- /dev/null +++ b/playground/src/App.vue @@ -0,0 +1,56 @@ + + + + + diff --git a/playground/src/ScenarioSelector.vue b/playground/src/ScenarioSelector.vue new file mode 100644 index 00000000..dc9e36d9 --- /dev/null +++ b/playground/src/ScenarioSelector.vue @@ -0,0 +1,49 @@ + + + + + diff --git a/playground/vite-plugin-scenario.js b/playground/vite-plugin-scenario.js new file mode 100644 index 00000000..b3d473a9 --- /dev/null +++ b/playground/vite-plugin-scenario.js @@ -0,0 +1,254 @@ +/** + * Vite Scenario Plugin + * + * This plugin provides file system-based scenario configuration management for the Vue REPL Playground. + * It dynamically scans the scenarios directory and generates configuration objects for REPL usage. + * + * Features: + * - Automatically scans scenario directories under scenarios + * - Supports hot updates: regenerates config when scenario files change + * - Uses _meta.js for scenario metadata + * + * Usage: + * 1. Create a scenario subdirectory under `scenarios` + * 2. Add necessary files in each scenario directory (App.vue, main.ts, etc.) + * 3. Add a _meta.js file to configure scenario metadata + */ +import fs from 'fs' +import path from 'path' +import { fileURLToPath } from 'url' + +// Get current file directory +const __dirname = path.dirname(fileURLToPath(import.meta.url)) + +/** + * Scenario plugin + * Provides the virtual module 'virtual:playground-files' for runtime dynamic scenario config generation + */ +export default function scenarioPlugin() { + let server + const scenariosPath = path.resolve(__dirname, 'scenarios') + const VIRTUAL_MODULE_ID = 'virtual:playground-files' + const RESOLVED_VIRTUAL_MODULE_ID = '\0' + VIRTUAL_MODULE_ID + + return { + name: 'vite-plugin-scenario', + + configureServer(_server) { + server = _server + + // Watch for changes in the scenarios directory + const watcher = server.watcher + watcher.add(scenariosPath) + + // On file changes in scenarios, reload the virtual module + watcher.on('add', handleFileChange) + watcher.on('change', handleFileChange) + watcher.on('unlink', handleFileChange) + + function handleFileChange(file) { + // Check if the changed file is under scenarios + if (file.startsWith(scenariosPath)) { + console.log( + `[vite-plugin-scenario] Scenario file changed: ${path.relative(scenariosPath, file)}`, + ) + // Notify client that the module needs to update + server.moduleGraph + .getModuleById(RESOLVED_VIRTUAL_MODULE_ID) + ?.importers.forEach((importer) => { + server.moduleGraph.invalidateModule(importer) + server.ws.send({ + type: 'update', + updates: [ + { + type: 'js-update', + path: importer.url, + acceptedPath: importer.url, + }, + ], + }) + }) + + // Invalidate the module directly to ensure regeneration on next request + server.moduleGraph.invalidateModule( + server.moduleGraph.getModuleById(RESOLVED_VIRTUAL_MODULE_ID), + ) + } + } + }, + + resolveId(id) { + if (id === VIRTUAL_MODULE_ID) { + return RESOLVED_VIRTUAL_MODULE_ID + } + }, + + async load(id) { + if (id === RESOLVED_VIRTUAL_MODULE_ID) { + try { + // Scan scenario directory and generate config + const config = await generateConfig(scenariosPath) + + // Ensure at least one scenario exists + if (Object.keys(config).length === 0) { + console.warn( + '[vite-plugin-scenario] Warning: No scenarios found, providing default scenario', + ) + // Provide a default scenario to prevent frontend errors + return `export default { + "demo1": { + "files": { + "App.vue": "", + "main.ts": "import { createApp } from 'vue';\\nimport App from './App.vue';\\n\\nconst app = createApp(App);\\napp.mount('#app');" + }, + "mainFile": "main.ts" + } + }` + } + + return `export default ${JSON.stringify(config, null, 2)}` + } catch (error) { + console.error( + '[vite-plugin-scenario] Error generating config:', + error, + ) + // Return a basic config to avoid frontend errors + return `export default { + "error": { + "files": { + "App.vue": "", + "main.ts": "import { createApp } from 'vue';\\nimport App from './App.vue';\\n\\nconst app = createApp(App);\\napp.mount('#app');" + }, + "mainFile": "main.ts" + } + }` + } + } + }, + } +} + +/** + * Scan scenario directory and generate config object with parallel processing + * @param {string} scenariosPath - Path to scenarios directory + * @returns {Promise} - Config object + */ +async function generateConfig(scenariosPath) { + const config = {} + + try { + // Check if scenarios directory exists + try { + await fs.promises.access(scenariosPath) + } catch (err) { + console.warn( + `[vite-plugin-scenario] Scenarios directory does not exist: ${scenariosPath}, creating it`, + ) + // Create scenarios directory + await fs.promises.mkdir(scenariosPath, { recursive: true }) + console.log( + `[vite-plugin-scenario] Scenarios directory created: ${scenariosPath}`, + ) + return config // Return empty config + } + + const dirs = await fs.promises.readdir(scenariosPath) + + // Process all scenario directories in parallel + const scenarioPromises = dirs.map(async (dir) => { + const scenarioPath = path.join(scenariosPath, dir) + const stat = await fs.promises.stat(scenarioPath) + + if (!stat.isDirectory()) return null + + // Read all files in the scenario + const allFiles = await fs.promises.readdir(scenarioPath) + let meta = { mainFile: 'main.ts' } // Default metadata + + // Handle metadata file first to get metadata before other files + const metaFile = allFiles.find((file) => file === '_meta.js') + if (metaFile) { + const metaFilePath = path.join(scenarioPath, metaFile) + try { + // Use dynamic import to properly load the ES module + // This is safer and more reliable than regex extraction + // Convert to absolute path URL for dynamic import + // Add timestamp to URL to bypass module cache + const fileUrl = `file://${path.resolve(metaFilePath)}?t=${Date.now()}` + + // Dynamically import the metadata module + const metaModule = await import(fileUrl) + + if (metaModule.default) { + // Deep merge the metadata with defaults + // This ensures all ReplOptions properties are properly merged + meta = { ...(meta || {}), ...(metaModule.default || {}) } + console.log( + `[vite-plugin-scenario] Loaded scenario metadata: ${dir}`, + meta, + ) + } + } catch (error) { + console.error( + `[vite-plugin-scenario] Unable to load metadata file: ${metaFilePath}`, + error, + ) + } + } + + // Read all scenario files in parallel + const filePromises = allFiles + .filter((file) => !file.startsWith('.') && file !== '_meta.js') + .map(async (file) => { + const filePath = path.join(scenarioPath, file) + try { + const content = await fs.promises.readFile(filePath, 'utf-8') + return [file, content] // Return as key-value pair + } catch (error) { + console.error( + `[vite-plugin-scenario] Error reading file: ${filePath}`, + error, + ) + return [file, ''] // Return empty content on error + } + }) + + // Wait for all file reading promises to complete + const fileEntries = await Promise.all(filePromises) + const files = Object.fromEntries(fileEntries) + + // Build complete scenario configuration + // Structure it to match what REPL expects: files object + options + const scenarioConfig = { + // Files must be in this format for REPL + files, + // all meta config support extend + ...meta, + } + + // Return scenario name and its configuration + return [dir, scenarioConfig] + }) + + // Wait for all scenario processing to complete + const scenarioResults = await Promise.all(scenarioPromises) + + // Filter out null results (non-directories) and build config object + scenarioResults + .filter((result) => result !== null) + .forEach(([scenarioName, scenarioConfig]) => { + config[scenarioName] = scenarioConfig + }) + + console.log( + `[vite-plugin-scenario] Config generated, scenarios: ${Object.keys(config).join(', ')}`, + ) + return config + } catch (error) { + console.error( + '[vite-plugin-scenario] Error generating scenario config:', + error, + ) + return {} + } +} diff --git a/playground/vite.config.ts b/playground/vite.config.ts new file mode 100644 index 00000000..54202ea1 --- /dev/null +++ b/playground/vite.config.ts @@ -0,0 +1,34 @@ +import { defineConfig } from 'vite' +import vue from '@vitejs/plugin-vue' +import replace from '@rollup/plugin-replace' +import path from 'node:path' +import scenarioPlugin from './vite-plugin-scenario' + +export default defineConfig({ + plugins: [vue(), scenarioPlugin()], + resolve: { + alias: { + '@vue/compiler-dom': '@vue/compiler-dom/dist/compiler-dom.cjs.js', + '@vue/compiler-core': '@vue/compiler-core/dist/compiler-core.cjs.js', + }, + }, + server: { + port: 5174, // Use a different port to avoid conflicts with the main project + open: true, // Automatically open the browser + }, + // Remove build config because playground is for development and demo only + worker: { + format: 'es', + plugins: () => [ + replace({ + preventAssignment: true, + values: { + 'process.env.NODE_ENV': JSON.stringify('production'), + }, + }), + ], + }, + // Set project root directory to the playground directory + root: path.resolve(__dirname), + base: './', // Use relative path +}) diff --git a/vite.config.ts b/vite.config.ts index 1b007b87..1a31c12a 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -49,6 +49,8 @@ export default mergeConfig(base, { 'monaco-editor-core/esm/vs/editor/editor.worker', 'vue/server-renderer', ], + // ignore playground just for dev + exclude: ['playground'], }, base: './', build: {