├── .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 | 
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 | 
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 | [](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 |
77 | Users
78 |
82 |
83 | {{ user.username }} #{{ user.id }}
84 |
85 |
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 |
4 |
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
20 |
21 |
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 | })
--------------------------------------------------------------------------------