├── .github └── workflows │ └── test.yml ├── .gitignore ├── LICENSE.txt ├── README.md ├── assets ├── README.md ├── double-workflow-explanation.png ├── introduction-thumbnail.png └── logo-transparent.png ├── dev-types.d.ts ├── package-lock.json ├── package.json ├── postBuildCleanup.js ├── src ├── bundler.ts ├── bundler │ ├── plugins.ts │ └── transform │ │ ├── apiMap.ts │ │ ├── phpParser.ts │ │ ├── typescriptGenerator.ts │ │ └── typescriptUpdater.ts ├── double │ ├── api.ts │ ├── apiMap.ts │ ├── bundler.ts │ ├── deepUnref.ts │ ├── helpers.ts │ ├── install.ts │ └── useDouble.ts ├── index.ts ├── nuxt.ts ├── nuxt │ └── nuxtModule.ts ├── pinia.ts └── pinia │ └── doublePiniaStore.ts ├── test └── bundler │ └── phpParser.test.ts ├── tools └── phpstorm-watcher.xml ├── tsconfig-base.json ├── tsconfig-cjs.json ├── tsconfig.json ├── tsup.config.ts └── vitest.config.ts /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: Test 2 | 3 | on: 4 | push: 5 | branches: [ main ] 6 | pull_request: 7 | branches: [ main ] 8 | 9 | jobs: 10 | build: 11 | 12 | runs-on: ubuntu-latest 13 | 14 | strategy: 15 | matrix: 16 | node-version: [14.x, 16.x, 18.x] 17 | # See supported Node.js release schedule at https://nodejs.org/en/about/releases/ 18 | 19 | steps: 20 | - uses: actions/checkout@v3 21 | - name: Use Node.js ${{ matrix.node-version }} 22 | uses: actions/setup-node@v3 23 | with: 24 | node-version: ${{ matrix.node-version }} 25 | cache: 'npm' 26 | - run: npm ci 27 | - run: npm test 28 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | .parcel-cache 3 | node_modules 4 | dist 5 | /pinia.js 6 | /pinia.d.ts -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2021-present, Paul Mohr (Sopamo GmbH) 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. -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |
2 | 3 |
4 |
5 | 6 | ![double logo](./assets/logo-transparent.png) 7 | 8 |

9 | The missing link between Laravel and Vue.
10 | Status: alpha
11 | RC planned for Q4 / 2022 12 |
13 |
14 | Try the demo project 15 |

16 |
17 | 18 |
19 | 20 | ## About Double 21 | 22 | Double drastically simplifies writing Vue applications with a Laravel backend. It does so, by removing all of the API boilerplate code you have to write. 23 | 24 | ![workflow comparison](./assets/double-workflow-explanation.png) 25 | 26 | 27 | ### How does it work? 28 | Traditionally, your API code lives in `App\Http\Controllers\UsersController`. You also have to create an entry in your routes file, and write some frontend boilerplate code to call that API. 29 | 30 | When using Double, you place your API code next to your vue store / component files. For example you would have your vue store in `stores/users.ts` and your API code for that store in `stores/users.php`. 31 | 32 | This let's Double automatically associate your API code with your frontend code. By creating *closeness* in the file system, you don't need to manually connect your server-side code with your frontend. 33 | 34 | 🚀 Double also analyzes your PHP code and intelligently creates TypeScript definitions! 35 | 36 | ### Why? 37 | 38 | * Double removes the need for any API boilerplate code 39 | * Double automatically gives you TypeScript types for your backend API 40 | * Double integrates with pinia 41 | 42 | 43 |
44 | 45 | [![Watch the introduction video](./assets/introduction-thumbnail.png)](https://youtu.be/Amd0ynVh5Ik) 46 | [Watch the introduction video](https://youtu.be/Amd0ynVh5Ik) 47 | 48 |
49 | 50 | 51 | ## Code says a thousand words 52 | The following two files are everything you need for having a vue component which displays all users of your app: 53 | 54 | */double/src/components/users.php* 55 | ```php 56 | 1, 67 | 'username' => 'Quentin' 68 | ] 69 | ]; 70 | } 71 | }; 72 | ?> 73 | ``` 74 | */double/src/components/users.vue* 75 | ```vue 76 | 86 | 87 | 100 | ``` 101 | 102 | Yep, that's it! No need to write any API boilerplate code. All methods from the PHP file are intelligently mapped to your frontend code. Read on to discover more complex usage examples. 103 | 104 | ## Demo 105 | Before you start to use double in your own project, you can try out the demo project: 106 | 107 | [Open demo repository](https://github.com/Sopamo/double-demo) 108 | 109 | The double demo is a dockerized Laravel application (using Laravel sail), so you can start to play with double in a few minutes. 110 | 111 |
112 |
113 |
114 | 115 | ## Installation 116 | 117 | This is an example of how you may give instructions on setting up your project locally. 118 | To get a local copy up and running follow these simple example steps. 119 | 120 | 121 | 1. Setup a [new Laravel project](https://laravel.com/docs/9.x/installation), or use an existing one 122 | 123 | 124 | ### **Vue setup** 125 | 1. Setup a vue project in the `double` subfolder 126 | 1. [Install](https://cli.vuejs.org/guide/installation.html) the vue cli 127 | 2. Go to your laravel installation 128 | 3. Create a new vue project `vue create double`. Make sure to select "Manually select features" and then check "Typescript" and "vue3". 129 | 3. Setup double in the new vue project 130 | 1. `cd double` 131 | 2. `npm install double-vue` 132 | 3. In src/main.ts add the following lines before the `createApp(App)` call: 133 | ```js 134 | import { installDouble } from 'double-vue' 135 | 136 | installDouble('http://localhost/api/double', 'webpack') 137 | ``` 138 | Make sure to replace `localhost` with the domain that your laravel project is running at 139 | 4. Add this `vue.config.js` file to the root of the double folder: 140 | ```js 141 | const { defineConfig } = require('@vue/cli-service') 142 | const { doubleWebpackPlugin } = require('double-vue/bundler') 143 | const path = require("path") 144 | 145 | module.exports = defineConfig({ 146 | transpileDependencies: true, 147 | configureWebpack: { 148 | plugins: [ 149 | doubleWebpackPlugin() 150 | ] 151 | } 152 | }) 153 | ``` 154 | 5. `npm run serve` 155 | 156 | ### **Laravel setup** 157 | 1. Go back to your laravel installation 158 | 2. `composer require sopamo/double-laravel` 159 | 3. `php artisan vendor:publish --provider="Sopamo\Double\DoubleServiceProvider"` 160 | 161 | 162 | ### **Use double** 163 | 1. Create the two example files from above (users.php and users.vue) in the `double/src/components` folder to get a working example of double. 164 | 2. Use your new users.vue component by embedding it with a `` component like so: 165 | ``` 166 | 167 | 168 | 169 | ``` 170 | ### **Questions?** 171 | If any of the steps above are unclear, you can have a look at the [demo project](https://github.com/Sopamo/double-demo) or open a [discussion](https://github.com/Sopamo/double-vue/discussions) 172 | 173 |

(back to top)

174 | 175 | 176 | ## Usage 177 | 178 | ### PHP naming conventions 179 | The code you write in the PHP files next to your frontend files always have to return a single class: 180 | 181 | ```php 182 | username = $request->input('username'); 195 | $user->save(); 196 | } 197 | }; 198 | ?> 199 | ``` 200 | 201 | Define the data that you want to receive as *state* with methods starting with `get`, followed by an uppercase letter. 202 | This data will automatically be fetched when Double initializes. 203 | 204 | All other public methods are available as actions: 205 | 206 | ```js 207 | import { useDouble } from 'double-vue' 208 | 209 | const double = useDouble('/src/pages/users') 210 | 211 | // This will contain the return value of the getUsers method 212 | console.log(double.users) 213 | 214 | // This will call the storeUser method 215 | double.storeUser({username: 'Bob Marley'}) 216 | ``` 217 | 218 | ### Refreshing the state 219 | Sometimes you want to update the Double state with the latest data from the server. Use the `refresh` method for that: 220 | 221 | ```js 222 | double.refresh() 223 | ``` 224 | 225 | ### Parameters for state methods 226 | Sometimes you want to configure state fetching methods dynamically, for example if you don't want to return all users, but only users which match a given query: 227 | 228 | ```js 229 | import { useDouble } from 'double-vue' 230 | 231 | const userQuery = ref('Bob') 232 | 233 | const double = useDouble('/src/pages/users', { 234 | getUsers: { 235 | search: userQuery 236 | } 237 | }) 238 | 239 | // This will automatically trigger a refresh, then loading all users which match the query "Johnny" 240 | userQuery.value = 'Johnny' 241 | ``` 242 | 243 | You can use the normal Laravel methods to access the search parameter: 244 | ```php 245 | $request->input('search') // Contains "Bob" 246 | ``` 247 | 248 | ### Sending custom headers 249 | If you are not using cookie-based authentication, you will want to set an authorization header in the requests that Double sends: 250 | 251 | ```js 252 | import { setCustomHeader } from 'double-vue' 253 | 254 | setCustomHeader('Authorization', 'Bearer ' + yourToken) 255 | ``` 256 | 257 | 258 |

(back to top)

259 | 260 | 261 | 262 | ## Roadmap 263 | 264 | - [ ] PHPStorm setup instructions 265 | - [x] Create a screencast 266 | - [x] Finalize readme 267 | - [x] Add support to configure the data requests in pinia 268 | - [x] Finalize the example project 269 | - [x] Unify double between regular usage and pinia 270 | - [x] Fix HMR breaking in sample project 271 | - [x] Add support for the refresh method outside of pinia 272 | - [x] Error handling 273 | - [x] Ignore private / protected php methods 274 | 275 |

(back to top)

276 | 277 | 278 | 279 | ## Contributing 280 | 281 | Contributions are what make the open source community such an amazing place to learn, inspire, and create. Any contributions you make are **greatly appreciated**. 282 | 283 | If you have a suggestion on how to improve Double, please fork the repo and create a pull request. You can also simply open an issue with any questions or bugs you find. 284 | Don't forget to give the project a star! Thanks again! 285 | 286 |

(back to top)

287 | 288 | 289 | 290 | ## License 291 | 292 | Distributed under the MIT License. See `LICENSE.txt` for more information. 293 | 294 |

(back to top)

295 | -------------------------------------------------------------------------------- /assets/README.md: -------------------------------------------------------------------------------- 1 | # Information for double assets 2 | 3 | Edit the workflow explanation on: 4 | 5 | https://lucid.app/lucidchart/bb38bbc7-66e2-4411-8865-05054fa920c0/edit?page=0_0# -------------------------------------------------------------------------------- /assets/double-workflow-explanation.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Sopamo/double-vue/a5bc427374b028a72b5c5cce0cfc597da8fc47ae/assets/double-workflow-explanation.png -------------------------------------------------------------------------------- /assets/introduction-thumbnail.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Sopamo/double-vue/a5bc427374b028a72b5c5cce0cfc597da8fc47ae/assets/introduction-thumbnail.png -------------------------------------------------------------------------------- /assets/logo-transparent.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Sopamo/double-vue/a5bc427374b028a72b5c5cce0cfc597da8fc47ae/assets/logo-transparent.png -------------------------------------------------------------------------------- /dev-types.d.ts: -------------------------------------------------------------------------------- 1 | export type doubleTypes = Record -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "double-vue", 3 | "version": "0.3.4", 4 | "description": "The vue part of double", 5 | "type": "module", 6 | "scripts": { 7 | "build": "tsup && node ./postBuildCleanup.js", 8 | "test": "vitest", 9 | "coverage": "vitest run --coverage" 10 | }, 11 | "source": "src/index.ts", 12 | "types": "dist/index.d.ts", 13 | "main": "./dist/index.js", 14 | "exports": { 15 | ".": "./dist/index.js", 16 | "./pinia": "./pinia.js", 17 | "./bundler": "./dist/bundler/index.cjs", 18 | "./nuxt": "./dist/nuxt/index.cjs" 19 | }, 20 | "files": [ 21 | "dist/**/*", 22 | "pinia.js", 23 | "pinia.d.ts", 24 | "package.json", 25 | "tsconfig.json" 26 | ], 27 | "repository": { 28 | "type": "git", 29 | "url": "git+https://github.com/Sopamo/double-vue.git" 30 | }, 31 | "keywords": [ 32 | "double", 33 | "vue", 34 | "laravel", 35 | "api", 36 | "vite", 37 | "webpack", 38 | "nuxt", 39 | "pinia" 40 | ], 41 | "author": "Paul Mohr", 42 | "license": "MIT", 43 | "bugs": { 44 | "url": "https://github.com/Sopamo/double-vue/issues" 45 | }, 46 | "homepage": "https://github.com/Sopamo/double-vue#readme", 47 | "dependencies": { 48 | "php-parser": "^3.0.3", 49 | "unplugin": "^0.3.1" 50 | }, 51 | "peerDependencies": { 52 | "@nuxt/kit-edge": "^3.0.0-27373619.4728fd5", 53 | "pinia": "^2.0.9", 54 | "vue": "^3.0.0" 55 | }, 56 | "peerDependenciesMeta": { 57 | "@nuxt/kit-edge": { 58 | "optional": true 59 | }, 60 | "pinia": { 61 | "optional": true 62 | } 63 | }, 64 | "devDependencies": { 65 | "@nuxt/kit-edge": "^3.0.0-27373619.4728fd5", 66 | "@types/node": "^18.0.0", 67 | "@vitejs/plugin-vue": "^2.0.1", 68 | "assert": "^2.0.0", 69 | "buffer": "^6.0.3", 70 | "constants-browserify": "^1.0.0", 71 | "crypto-browserify": "^3.12.0", 72 | "https-browserify": "^1.0.0", 73 | "os-browserify": "^0.3.0", 74 | "parcel": "^2.2.0", 75 | "path-browserify": "^1.0.1", 76 | "pinia": "^2.0.9", 77 | "process": "^0.11.10", 78 | "stream-browserify": "^3.0.0", 79 | "tsup": "^6.1.2", 80 | "tty-browserify": "^0.0.1", 81 | "typescript": "^4.5.4", 82 | "url": "^0.11.0", 83 | "util": "^0.12.4", 84 | "vite": "^2.7.11", 85 | "vitest": "^0.9.2", 86 | "vm-browserify": "^1.1.2", 87 | "vue": "^3.2.37" 88 | } 89 | } 90 | -------------------------------------------------------------------------------- /postBuildCleanup.js: -------------------------------------------------------------------------------- 1 | import fs from 'fs' 2 | 3 | // As the project using this library will provide it's own type definition for the type "doubleTypes" we remove it from our type definition 4 | let types = fs.readFileSync('./dist/index.d.ts').toString() 5 | types = types.replace(/import { . as doubleTypes }.+/, '') 6 | fs.writeFileSync('./dist/index.d.ts', types) 7 | 8 | let piniaTypes = fs.readFileSync('./dist/pinia/index.d.ts').toString() 9 | piniaTypes = piniaTypes.replace(/import { . as doubleTypes }.+/, '') 10 | fs.writeFileSync('./dist/pinia/index.d.ts', piniaTypes) 11 | 12 | 13 | // Copy pinia output files to the root, so it can be imported with 'double-vue/pinia' instead of 'double-vue/dist/pinia' 14 | fs.copyFileSync('./dist/pinia/index.js', './pinia.js') 15 | fs.copyFileSync('./dist/pinia/index.d.ts', './pinia.d.ts') 16 | 17 | let piniaCode = fs.readFileSync('./pinia.js').toString() 18 | piniaCode = piniaCode.replace(/'\.\.\/chunk-/g, '\'./dist/chunk-') 19 | fs.writeFileSync('pinia.js', piniaCode) -------------------------------------------------------------------------------- /src/bundler.ts: -------------------------------------------------------------------------------- 1 | export { doubleRollupPlugin, doubleVitePlugin, doubleWebpackPlugin } from "./bundler/plugins" -------------------------------------------------------------------------------- /src/bundler/plugins.ts: -------------------------------------------------------------------------------- 1 | import { VitePlugin, createUnplugin } from "unplugin"; 2 | import { Bundler } from "../double/bundler"; 3 | import { getApiMap } from "./transform/apiMap"; 4 | import { updateTypescriptDefinition } from "./transform/typescriptUpdater"; 5 | 6 | // TODO: Make this configurable 7 | const doubleBasePath = process.cwd() 8 | 9 | const phpFileRegex = /\.php/ 10 | 11 | type UserOptions = { 12 | bundler: Bundler 13 | }; 14 | 15 | export const unpluginPHP = createUnplugin((userOptions: UserOptions) => { 16 | return { 17 | name: 'double-php', 18 | transformInclude(id) { 19 | return phpFileRegex.test(id) 20 | }, 21 | transform(phpSrc, id) { 22 | if(phpFileRegex.test(id)) { 23 | const phpFilePath = id.replace(/\?.+/, '') 24 | const doublePath = phpFilePath.replace(doubleBasePath, '').replace('.php', '') 25 | let tsPath = doublePath 26 | updateTypescriptDefinition(phpSrc, tsPath) 27 | return { 28 | code: `export default ${JSON.stringify(getApiMap(phpSrc))}`, 29 | map: null, 30 | } 31 | } 32 | return null 33 | } 34 | } 35 | }) 36 | 37 | export const doubleVitePlugin = (): VitePlugin => { 38 | return unpluginPHP.vite({ 39 | bundler: 'vite', 40 | }) 41 | } 42 | 43 | export const doubleWebpackPlugin = (): VitePlugin => { 44 | return unpluginPHP.webpack({ 45 | bundler: 'webpack', 46 | }) 47 | } 48 | 49 | export const doubleRollupPlugin = (): VitePlugin => { 50 | return unpluginPHP.rollup({ 51 | bundler: 'rollup', 52 | }) 53 | } -------------------------------------------------------------------------------- /src/bundler/transform/apiMap.ts: -------------------------------------------------------------------------------- 1 | import { getPHPMetaData } from "./phpParser" 2 | const fs = require('fs') 3 | 4 | export type ApiMapEntry = { actions: string[], getters: string[] } 5 | 6 | export function getApiMap(src: string): ApiMapEntry { 7 | const metaData = getPHPMetaData(src) 8 | return { 9 | getters: metaData.getters.map(entry => entry.name), 10 | actions: metaData.actions.map(entry => entry.name), 11 | } 12 | } 13 | 14 | export const updateApiMap = (src: string, doublePath: string) => { 15 | const mapPath = './doubleApiMap.ts' 16 | let map = {} 17 | const mapPrefix = 'export const apiMap = ' 18 | if(fs.existsSync(mapPath)) { 19 | try { 20 | map = JSON.parse(fs.readFileSync(mapPath).toString().substr(mapPrefix.length - 1)) 21 | } catch(e) { 22 | console.log(e) 23 | } 24 | } 25 | const oldData = map[doublePath] 26 | map[doublePath] = getApiMap(src) 27 | 28 | // Only write the new file if something changed 29 | if(JSON.stringify(oldData) != JSON.stringify(map[doublePath])) { 30 | fs.writeFileSync(mapPath, mapPrefix + JSON.stringify(map)) 31 | } 32 | } -------------------------------------------------------------------------------- /src/bundler/transform/phpParser.ts: -------------------------------------------------------------------------------- 1 | import { Array, Class, Declaration, Engine, Entry, Expression, Method, New, Return } from 'php-parser' 2 | const fs = require('fs') 3 | type MethodDefinition = { 4 | name: string 5 | return: string 6 | } 7 | 8 | type PHPMetaData = { 9 | getters: MethodDefinition[] 10 | actions: MethodDefinition[] 11 | } 12 | 13 | function treeToReturnTS(tree: Expression): string { 14 | if (!tree) { 15 | return 'any' 16 | } 17 | 18 | if (tree.kind === 'array') { 19 | const arrayLike = tree as Array 20 | // @ts-ignore this is a wrong type coming from php-parser 21 | const children = arrayLike.items.map(item => treeToReturnTS(item)) 22 | if(!arrayLike.items[0]) { 23 | return `[]` 24 | } 25 | // TODO: This does not work properly for all array definitions. 26 | // For example for arrays where some children have explicit keys and others don't 27 | // TODO: Take care of proper indentation 28 | 29 | // @ts-ignore this is a wrong type coming from php-parser 30 | const noItemHasKey = arrayLike.items.every(item => !item.hasOwnProperty('key') || [undefined, null].includes(item?.key)) 31 | 32 | if (noItemHasKey) { 33 | // Check if all children are the same 34 | const allChildrenIdentical = !children.some(child => child !== children[0]) 35 | if(allChildrenIdentical) { 36 | return `${children[0]}[]` 37 | } 38 | return `(${children.join(' | ')})[]` 39 | } else { 40 | let keylessIndex = 0 41 | // @ts-ignore this is a wrong type coming from php-parser 42 | const childData = arrayLike.items.map((item, idx) => { 43 | // For items without a key, the key is a number starting from 0 44 | // and being 1 larger than the last numeric key 45 | // https://www.php.net/manual/en/language.types.array.php 46 | let key = item.key?.value 47 | if([undefined, null].includes(key)) { 48 | key = keylessIndex++ 49 | } else { 50 | if(item.key.kind === 'number') { 51 | keylessIndex = parseInt(item.key.value) + 1 52 | } 53 | } 54 | return key + ': ' + children[idx] 55 | }) 56 | return `{\n ${childData.join('\n ')}\n }` 57 | 58 | } 59 | } 60 | if(tree.kind === 'entry') { 61 | const entryLike = tree as Entry 62 | if (!entryLike.key) { 63 | return treeToReturnTS(entryLike.value) 64 | } 65 | if (entryLike.key) { 66 | if (['string', 'number'].includes(entryLike.key.kind)) { 67 | return treeToReturnTS(entryLike.value) 68 | } 69 | } 70 | } 71 | 72 | if(['number', 'string'].includes(tree.kind)) { 73 | // @ts-ignore value actually does exist 74 | return tree.kind 75 | } 76 | 77 | return 'any' 78 | } 79 | 80 | export const getPHPMetaData = (src: string): PHPMetaData => { 81 | const parser = new Engine({ 82 | parser: { 83 | extractDoc: true, 84 | php7: true 85 | }, 86 | ast: { 87 | withPositions: false 88 | } 89 | }); 90 | 91 | const responseData = { 92 | getters: [], 93 | actions: [], 94 | } 95 | 96 | try { 97 | const returnValue = parser.parseCode(src, '').children.find(child => child.kind === 'return') as Return 98 | 99 | 100 | // Sadly the php-parser typescript definition can't automatically detect which node type 101 | // an entry has 102 | // @ts-ignore 103 | if(!returnValue?.expr?.what?.body) { 104 | return responseData 105 | } 106 | const returnBody = ((returnValue.expr as New).what as Class).body 107 | // fs.writeFileSync('debug.json', JSON.stringify(returnBody)) 108 | returnBody.forEach((bodyEntry: Declaration) => { 109 | if (bodyEntry.kind === 'method') { 110 | const method = bodyEntry as Method 111 | 112 | if(method.isStatic || method.visibility !== 'public') { 113 | return 114 | } 115 | 116 | let methodName = method.name 117 | if(typeof methodName !== 'string') { 118 | methodName = methodName.name 119 | } 120 | 121 | let returnType = 'any' 122 | const returnValue = method.body?.children.find(child => child.kind === 'return') as undefined | Return 123 | if (returnValue) { 124 | returnType = treeToReturnTS(returnValue.expr) 125 | } 126 | 127 | // We handle methods starting with a "get" followed by an uppercase character differently 128 | // Those are methods we call to get our data object. 129 | // All other methods will be available as actions to be called on demand 130 | if (methodName.startsWith('get')) { 131 | 132 | const firstKeyCharacter = methodName.substring(3, 4) 133 | if (firstKeyCharacter === firstKeyCharacter.toLocaleUpperCase()) { 134 | // This is a getXyz method 135 | const nameWithoutPrefix = firstKeyCharacter.toLocaleLowerCase() + methodName.substring(4) 136 | responseData.getters.push({ 137 | name: nameWithoutPrefix, 138 | return: returnType 139 | }) 140 | } 141 | } else { 142 | responseData.actions.push({ 143 | name: methodName, 144 | return: returnType 145 | }) 146 | } 147 | } 148 | }) 149 | } catch(e) { 150 | console.log('Could not parse PHP file:') 151 | console.log(e) 152 | return responseData 153 | } 154 | 155 | return responseData 156 | } -------------------------------------------------------------------------------- /src/bundler/transform/typescriptGenerator.ts: -------------------------------------------------------------------------------- 1 | import { getPHPMetaData } from "./phpParser" 2 | 3 | export const getTypescriptDefinition = (src: string, id: string) => { 4 | const metaData = getPHPMetaData(src) 5 | const tsID = id 6 | .toLowerCase() 7 | .replaceAll(/[^a-zA-Z0-9\/]/g, 'x') 8 | .split('/') 9 | .filter((part: string) => !!part) 10 | .map((part: string) => { 11 | return `${part[0].toLocaleUpperCase()}${part.slice(1)}` 12 | }) 13 | .join('') 14 | 15 | const getters = metaData.getters.map(entry => { 16 | return `\n ${entry.name}: ${entry.return}` 17 | }) 18 | 19 | const actions = metaData.actions.map(entry => { 20 | return `\n ${entry.name}: (options?: Record) => Promise` 21 | }) 22 | 23 | const isLoading = metaData.actions.map(entry => { 24 | return `\n ${entry.name}?: boolean` 25 | }) 26 | 27 | 28 | const tsDefinition = `type ${tsID}MainType = { 29 | state: { ${getters.join("")} 30 | } 31 | actions: { ${actions.join("")} 32 | } 33 | isLoading: { ${isLoading.join("")} 34 | } 35 | } 36 | ` 37 | return { 38 | tsDefinition, 39 | tsID, 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /src/bundler/transform/typescriptUpdater.ts: -------------------------------------------------------------------------------- 1 | import { escapeRegex } from "../../double/helpers" 2 | import { getTypescriptDefinition } from "./typescriptGenerator" 3 | const path = require('path') 4 | const fs = require('fs') 5 | 6 | export const updateTypescriptDefinition = (src: string, doublePath: string) => { 7 | const typesPath = './src/double.d.ts' 8 | 9 | const {tsDefinition, tsID} = getTypescriptDefinition(src, doublePath) 10 | if(!fs.existsSync(path.dirname(typesPath))) { 11 | fs.mkdirSync(path.dirname(typesPath)) 12 | } 13 | let existingTypes = '' 14 | if(fs.existsSync(typesPath)) { 15 | existingTypes = fs.readFileSync(typesPath).toString() 16 | } 17 | const beginIndicator = '// BEGIN: ' + escapeRegex(doublePath) + '\n' 18 | const endIndicator = '// END: ' + escapeRegex(doublePath) + '\n\n' 19 | let newTypes = existingTypes.replace(new RegExp(beginIndicator + '[\\s\\S]*' + endIndicator), '') 20 | newTypes += beginIndicator + tsDefinition + endIndicator 21 | 22 | // Update the global `double` type 23 | const globalBeginIndicator = '\n\ntype doubleTypes = {\n' 24 | const globalEndIndicator = '}\n' 25 | const globalTypeRegex = new RegExp(globalBeginIndicator + '([^}]*)' + globalEndIndicator) 26 | const existingGlobalTypeBlock = newTypes.match(globalTypeRegex) 27 | newTypes = newTypes.replace(globalTypeRegex, '') 28 | let globalTypes = '' 29 | if(existingGlobalTypeBlock) { 30 | globalTypes = existingGlobalTypeBlock[1] 31 | globalTypes = globalTypes.replace(new RegExp('\[ \t]*\'' + doublePath + '\':.*\\n'), '') 32 | } 33 | 34 | globalTypes += ` '${doublePath}': ${tsID}MainType\n` 35 | 36 | newTypes += globalBeginIndicator + globalTypes + globalEndIndicator 37 | 38 | fs.writeFileSync(typesPath, newTypes) 39 | } -------------------------------------------------------------------------------- /src/double/api.ts: -------------------------------------------------------------------------------- 1 | import { doubleTypes } from "../../dev-types" 2 | import { deepUnref } from "./deepUnref" 3 | let backendPath = '' 4 | 5 | /** 6 | * Custom headers we sent with every requests 7 | * This will most probably be used for an auth header 8 | */ 9 | let customHeaders: Record = {} 10 | 11 | const assureBackendPath = () => { 12 | if(!backendPath) { 13 | throw new Error('No backend path is set. Did you call the double install function?') 14 | } 15 | } 16 | 17 | /** 18 | * This sets the HTTP path pointing to the root double API in the Laravel project 19 | * By default this should be https://{yourdomain}.com/double 20 | * 21 | * @param path 22 | */ 23 | export const setBackendPath = (path: string): void => { 24 | backendPath = path 25 | } 26 | 27 | /** 28 | * Sets the custom headers. This overrides all existing custom headers 29 | * @example { Cookie: 'myCookie=1', 'x-my-custom-header': 5 } 30 | * @param headers An object where the keys are the names of the header and the values are the values that should be set 31 | */ 32 | export const setCustomHeaders = (headers: Record): void => { 33 | customHeaders = headers 34 | } 35 | 36 | /** 37 | * Sets a single header that will be sent with every request 38 | * 39 | * @param header the name of the header 40 | * @param value the value that should be set for that header 41 | */ 42 | export const setCustomHeader = (header: string, value: string): void => { 43 | customHeaders[header] = value 44 | } 45 | 46 | export const getHeaders = (): Record => { 47 | const baseHeaders = { 48 | 'Content-Type': 'application/json' 49 | } 50 | return { 51 | ...baseHeaders, 52 | ...customHeaders, 53 | } 54 | } 55 | 56 | export const loadData = async (path: Path, config: Record = {}) => { 57 | assureBackendPath() 58 | const res = await fetch(`${backendPath}/data?path=${encodeURIComponent(path)}`, { 59 | method: 'POST', 60 | credentials: "include", 61 | headers: getHeaders(), 62 | body: JSON.stringify({ config: deepUnref(config) }) 63 | }) 64 | return await res.json() 65 | } 66 | 67 | export const callAction = async (path: Path, method: string, data: Record) => { 68 | assureBackendPath() 69 | const res = await fetch(`${backendPath}/action?path=${encodeURIComponent(path)}&method=${encodeURIComponent(method)}`, { 70 | method: 'POST', 71 | credentials: "include", 72 | headers: getHeaders(), 73 | body: JSON.stringify(deepUnref(data)) 74 | }) 75 | return await res.json() 76 | } -------------------------------------------------------------------------------- /src/double/apiMap.ts: -------------------------------------------------------------------------------- 1 | import { doubleTypes } from "../../dev-types" 2 | import { ApiMapEntry } from "../bundler/transform/apiMap" 3 | 4 | let apiMap = {} as Record 5 | 6 | const setApiMap = (map) => { 7 | apiMap = map 8 | } 9 | 10 | export { apiMap, setApiMap } -------------------------------------------------------------------------------- /src/double/bundler.ts: -------------------------------------------------------------------------------- 1 | export type Bundler = 'webpack' | 'vite' | 'rollup' 2 | let bundler: Bundler = 'vite' 3 | 4 | export const setBundler = (newBundler: Bundler): void => { 5 | bundler = newBundler 6 | } 7 | 8 | export const getBundler = (): Bundler => { 9 | return bundler 10 | } 11 | 12 | export const getBundlerFilePrefix = (): string => { 13 | if(bundler === 'webpack') { 14 | return '/src/' 15 | } 16 | return '' 17 | } -------------------------------------------------------------------------------- /src/double/deepUnref.ts: -------------------------------------------------------------------------------- 1 | // Inspired by https://github.com/DanHulton/vue-deepunref 2 | // Published on the MIT license 3 | import { unref, isRef } from 'vue' 4 | 5 | const isObject = (val) => val !== null && typeof val === 'object'; 6 | const isArray = Array.isArray; 7 | 8 | /** 9 | * Deeply unref a value, recursing into objects and arrays. 10 | * 11 | * @param {Mixed} val - The value to deeply unref. 12 | * 13 | * @return {Mixed} 14 | */ 15 | export const deepUnref = (val) => { 16 | const checkedVal = isRef(val) ? unref(val) : val; 17 | 18 | if (! isObject(checkedVal)) { 19 | return checkedVal; 20 | } 21 | 22 | if (isArray(checkedVal)) { 23 | return unrefArray(checkedVal); 24 | } 25 | 26 | return unrefObject(checkedVal); 27 | }; 28 | 29 | /** 30 | * Unref a value, recursing into it if it's an object. 31 | * 32 | * @param {Mixed} val - The value to unref. 33 | * 34 | * @return {Mixed} 35 | */ 36 | const smartUnref = (val) => { 37 | // Non-ref object? Go deeper! 38 | if (val !== null && ! isRef(val) && typeof val === 'object') { 39 | return deepUnref(val); 40 | } 41 | 42 | return unref(val); 43 | }; 44 | 45 | /** 46 | * Unref an array, recursively. 47 | * 48 | * @param {Array} arr - The array to unref. 49 | * 50 | * @return {Array} 51 | */ 52 | const unrefArray = (arr) => arr.map(smartUnref); 53 | 54 | /** 55 | * Unref an object, recursively. 56 | * 57 | * @param {Object} obj - The object to unref. 58 | * 59 | * @return {Object} 60 | */ 61 | const unrefObject = (obj) => { 62 | const unreffed = {}; 63 | 64 | // Object? un-ref it! 65 | Object.keys(obj).forEach((key) => { 66 | unreffed[key] = smartUnref(obj[key]); 67 | }); 68 | 69 | return unreffed; 70 | } -------------------------------------------------------------------------------- /src/double/helpers.ts: -------------------------------------------------------------------------------- 1 | export const escapeRegex = (value: string) => { 2 | return value.replace( /[\-\[\]{}()*+?.,\\\^$|#\s]/g, "\\$&" ) 3 | } -------------------------------------------------------------------------------- /src/double/install.ts: -------------------------------------------------------------------------------- 1 | import { setBackendPath } from "./api" 2 | import { setBundler, Bundler } from "./bundler" 3 | 4 | 5 | /** 6 | * This sets the HTTP path pointing to the root double API in the Laravel project 7 | * By default this should be https://{yourdomain}.com/double 8 | * 9 | * @param backendPath 10 | */ 11 | export const installDouble = (backendPath: string, bundler: Bundler) => { 12 | setBackendPath(backendPath) 13 | setBundler(bundler) 14 | } -------------------------------------------------------------------------------- /src/double/useDouble.ts: -------------------------------------------------------------------------------- 1 | import {reactive, ref, watch, isRef } from "vue" 2 | 3 | import { callAction, loadData } from "./api"; 4 | import { doubleTypes } from "../../dev-types"; 5 | import { getBundler } from "./bundler"; 6 | 7 | export async function useDouble(path: Path, config: Record = {}): 8 | Promise< 9 | doubleTypes[Path]['state'] & 10 | doubleTypes[Path]['actions'] & 11 | { isLoading: doubleTypes[Path]['isLoading'] } & 12 | { refresh: () => Promise } 13 | > { 14 | // To be able to watch the config it has to be a ref 15 | if(!isRef(config)) { 16 | config = ref(config) 17 | } 18 | 19 | const loadInitialDoubleData = async () => { 20 | return await loadData(path, config) 21 | } 22 | let data = {} as any 23 | 24 | const apiMap = await getApiMap(path) 25 | 26 | apiMap.getters.forEach(entry => { 27 | data[entry] = ref(null) 28 | }) 29 | const setData = (newData) => { 30 | Object.entries(newData).forEach(([key, value]) => { 31 | if(data[key] === undefined) { 32 | data[key] = ref(value) 33 | } else { 34 | data[key].value = value 35 | } 36 | }) 37 | } 38 | setData(await loadInitialDoubleData()) 39 | 40 | const isLoading = reactive>({}) 41 | 42 | // TODO: Only re-request the entrypoints where their config has actually changed 43 | watch(config, async () => { 44 | setData(await loadInitialDoubleData()) 45 | }, { 46 | deep: true, 47 | }) 48 | 49 | const actions = {} 50 | apiMap.actions.forEach(method => { 51 | isLoading[method] = false 52 | actions[method] = async function(data: Record) { 53 | isLoading[method] = true 54 | let result = null 55 | try { 56 | result = await callAction(path, method, data) 57 | isLoading[method] = false 58 | } catch (e) { 59 | isLoading[method] = false 60 | throw e 61 | } 62 | return result 63 | } 64 | }) 65 | 66 | const refresh = async () => { 67 | setData(await loadInitialDoubleData()) 68 | } 69 | 70 | return { 71 | ...data, 72 | ...actions, 73 | isLoading, 74 | refresh, 75 | } 76 | } 77 | 78 | export async function getApiMap(path: string): Promise<{ getters: string[], actions: string[] }> { 79 | let apiMap = null 80 | if(getBundler() === 'webpack') { 81 | // Webpack can't dynamically import files from the root folder, so we have to remove the mandatory /src/ prefix 82 | // because we need it to be hardcodet in the import call. 83 | path = path.replace(/^\/?src\//, '') 84 | apiMap = (await import(/* webpackPreload: true */ '/src/' + path + '.php')).default 85 | } else { 86 | apiMap = (await import(/* @vite-ignore */ path + '.php')).default 87 | } 88 | if(!apiMap) { 89 | console.error(`Could not fetch the ${path}.php file. Try restarting your dev server.`) 90 | } 91 | return apiMap 92 | } -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | export { useDouble } from "./double/useDouble" 2 | export { installDouble } from "./double/install" 3 | export { setCustomHeaders, setCustomHeader } from "./double/api" -------------------------------------------------------------------------------- /src/nuxt.ts: -------------------------------------------------------------------------------- 1 | export { doubleNuxtModule } from "./nuxt/nuxtModule" -------------------------------------------------------------------------------- /src/nuxt/nuxtModule.ts: -------------------------------------------------------------------------------- 1 | import { 2 | defineNuxtModule, 3 | extendViteConfig, 4 | } from '@nuxt/kit-edge' 5 | 6 | import { doubleVitePlugin } from '../bundler/plugins' 7 | 8 | export const doubleNuxtModule = defineNuxtModule({ 9 | setup() { 10 | extendViteConfig((config) => { 11 | if(!config.optimizeDeps) { 12 | config.optimizeDeps = { 13 | exclude: [] 14 | } 15 | } 16 | config.plugins.push(doubleVitePlugin()) 17 | 18 | return config 19 | }) 20 | }, 21 | }) 22 | -------------------------------------------------------------------------------- /src/pinia.ts: -------------------------------------------------------------------------------- 1 | export { defineDoublePiniaStore } from "./pinia/doublePiniaStore" -------------------------------------------------------------------------------- /src/pinia/doublePiniaStore.ts: -------------------------------------------------------------------------------- 1 | // @ts-ignore 2 | import type { _GettersTree, DefineStoreOptions, StateTree, Store, _StoreWithState } from "pinia"; 3 | // @ts-ignore 4 | import { defineStore } from 'pinia'; 5 | import { computed, reactive, Ref, watch } from "vue"; 6 | import { doubleTypes } from "../../dev-types"; 7 | import { getApiMap, useDouble } from "../double/useDouble"; 8 | 9 | const stores: Record = {} 10 | 11 | export function defineDoublePiniaStore< 12 | Path extends keyof doubleTypes, 13 | Id extends string, 14 | State extends StateTree = {}, 15 | Getters extends _GettersTree = {}, 16 | Actions = {} 17 | >(path: Path, options: Omit, 'id'>): () => Promise Promise } & doubleTypes[Path]['actions'] 22 | >> { 23 | return () => { 24 | return new Promise((resolve, reject) => { 25 | if(stores[path] !== undefined) { 26 | resolve(stores[path]) 27 | return 28 | } 29 | injectDouble(path, options).then(({storeOptions, config}) => { 30 | // @ts-ignore 31 | const id: Id = path 32 | const store = defineStore(id, storeOptions)() 33 | 34 | // Setup a watcher on the queryConfig getter 35 | // So we can tell double to re-fetch the data whenever it changes 36 | if(typeof store.queryConfig !== undefined) { 37 | watch(computed(() => store.queryConfig), () => { 38 | Object.entries(store.queryConfig).forEach(([key, value]) => { 39 | config[key] = value 40 | }) 41 | }) 42 | } 43 | stores[path] = store 44 | resolve(store) 45 | }).catch((e) => { 46 | throw e 47 | }) 48 | }) 49 | 50 | } 51 | } 52 | 53 | export async function injectDouble< 54 | Path extends keyof doubleTypes, 55 | Id extends string, 56 | State extends StateTree = {}, 57 | Getters extends _GettersTree = {}, 58 | Actions = {} 59 | >(path: Path, options: Omit, 'id'>): 60 | Promise<{ 61 | storeOptions: Omit Promise } & doubleTypes[Path]['actions'] 66 | >, 'id'>, 67 | config: {} 68 | }> { 69 | 70 | const originalState = options.state 71 | 72 | // Todo: This might not be the best idea, as options.state is a function which returns a unique state object and all of those 73 | // state objects will use the same `double` store 74 | const config = reactive({}) 75 | const double = await useDouble(path, config) 76 | const apiMap = await getApiMap(path) 77 | 78 | // TODO: Properly fix @ts-ignore stuff 79 | 80 | // @ts-ignore 81 | options.state = () => { 82 | // @ts-ignore 83 | if(options.actions.refresh !== undefined) { 84 | console.warn('Overriding the refresh action is currently not supported. You can create a customRefresh action which calls double\'s refresh action.') 85 | } 86 | // @ts-ignore 87 | options.actions.refresh = function () { 88 | double.refresh() 89 | } 90 | 91 | apiMap.actions.forEach((action) => { 92 | if(options.actions[action] !== undefined) { 93 | console.warn('You can not specify the ' + action + ' action if your PHP file already defines it. You can create your own custom action which calls this action.') 94 | } 95 | options.actions[action] = double[action] 96 | }) 97 | let state: Record = {} 98 | if (originalState) { 99 | state = originalState() 100 | } 101 | // In this context "getter" means a double getter (e.g. getBlogEntries) and not a pinia getter. 102 | apiMap.getters.forEach((getter) => { 103 | if(state[getter] !== undefined) { 104 | console.warn('You can not specify the ' + getter + ' state key if your PHP file already defines it.') 105 | } 106 | state[getter] = double[getter] 107 | }) 108 | 109 | if(state.isLoading !== undefined) { 110 | console.warn('You can not specify the isLoading state key, because it\'s reserved by double.') 111 | } 112 | state.isLoading = double.isLoading 113 | 114 | return state 115 | } 116 | if (options.actions === undefined) { 117 | // @ts-ignore 118 | options.actions = {} 119 | } 120 | 121 | return { 122 | // @ts-ignore 123 | storeOptions: options, 124 | config, 125 | } 126 | } 127 | -------------------------------------------------------------------------------- /test/bundler/phpParser.test.ts: -------------------------------------------------------------------------------- 1 | import { assert, expect, test } from 'vitest' 2 | import { getPHPMetaData } from '../../src/bundler/transform/phpParser' 3 | 4 | test('numberReturnType', () => { 5 | const php = ` { 22 | const php = ` { 39 | const php = ` { 57 | const tests = [ 58 | [ 59 | `[]`, 60 | '[]' 61 | ], 62 | [ 63 | `[1]`, 64 | 'number[]' 65 | ], 66 | [ 67 | `[1,2]`, 68 | 'number[]' 69 | ], 70 | [ 71 | `['a' => 1]`, 72 | `{ 73 | a: number 74 | }` 75 | ], 76 | [ 77 | `['a' => 1, 'b' => 2,]`, 78 | `{ 79 | a: number 80 | b: number 81 | }` 82 | ], 83 | [ 84 | `['a' => ['b' => 2]]`, 85 | `{ 86 | a: { 87 | b: number 88 | } 89 | }` 90 | ], 91 | [ 92 | `[1, 'foo'=>'bar', 3 => 4, 'baz']`, 93 | `{ 94 | 0: number 95 | foo: string 96 | 3: number 97 | 4: string 98 | }` 99 | ], 100 | ] 101 | tests.forEach(test => { 102 | const php = ` { 121 | const phpFiles = [ 122 | ``, 123 | ` { 133 | const consoleLog = console.log; 134 | console.log = () => { } 135 | try { 136 | expect(getPHPMetaData(php)).toStrictEqual({ 137 | actions: [], 138 | getters: [], 139 | }) 140 | } catch (e) { 141 | console.error(e) 142 | } finally { 143 | console.log = consoleLog 144 | } 145 | }) 146 | }) 147 | 148 | 149 | test('privateMethodsAreIgnored', () => { 150 | const php = 151 | ` { 172 | const php = 173 | ` 2 | 3 | 14 | 22 | -------------------------------------------------------------------------------- /tsconfig-base.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "allowSyntheticDefaultImports": true, 4 | "baseUrl": "src", 5 | "declaration": true, 6 | "esModuleInterop": true, 7 | "inlineSourceMap": false, 8 | "lib": ["esnext", "DOM"], 9 | "listEmittedFiles": false, 10 | "listFiles": false, 11 | "moduleResolution": "node", 12 | "noFallthroughCasesInSwitch": true, 13 | "pretty": true, 14 | "resolveJsonModule": true, 15 | "rootDir": "src", 16 | "skipLibCheck": true, 17 | "traceResolution": false, 18 | "types": ["node"] 19 | }, 20 | "compileOnSave": false, 21 | "exclude": ["node_modules", "dist"], 22 | "include": [ 23 | "src/**/*", 24 | "dev-types.d.ts" 25 | ] 26 | } -------------------------------------------------------------------------------- /tsconfig-cjs.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig-base.json", 3 | "compilerOptions": { 4 | "module": "commonjs", 5 | "outDir": "dist/cjs", 6 | "target": "es2015" 7 | } 8 | } -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ESNext", 4 | "module": "ESNext", 5 | "moduleResolution": "Node", 6 | "isolatedModules": true, 7 | "types": [ 8 | "node" 9 | ], 10 | }, 11 | "include": [ 12 | "src/**/*", 13 | "dev-types.d.ts" 14 | ] 15 | } -------------------------------------------------------------------------------- /tsup.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from 'tsup' 2 | 3 | export default defineConfig((options) => { 4 | return { 5 | entry: { 6 | 'index': 'src/index.ts', 7 | 'pinia/index': 'src/pinia.ts', 8 | 'nuxt/index': 'src/nuxt.ts', 9 | 'bundler/index': 'src/bundler.ts', 10 | }, 11 | treeshake: true, 12 | splitting: true, // Important, otherwise the pinia part of double doesn't use the same double installation as the normal "useDouble" 13 | dts: true, 14 | format: ['esm', 'cjs'], // ESM is for our browser-targeted bundles, cjs for the bundler plugins. 15 | sourcemap: true, 16 | clean: !options.watch, 17 | minify: !options.watch, 18 | } 19 | }) -------------------------------------------------------------------------------- /vitest.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from 'vitest/config' 2 | 3 | export default defineConfig({ 4 | test: { 5 | }, 6 | }) --------------------------------------------------------------------------------