├── .env.example ├── .release-it.json ├── .vscode ├── settings.json └── tasks.json ├── CHANGELOG.md ├── LICENSE.md ├── README.md ├── composer.json ├── config └── typescriptable.php ├── eslint.config.js ├── package.json ├── pint.json ├── plugin ├── LICENSE.md ├── README.md ├── global.d.ts ├── package.json ├── routes.ts ├── src │ ├── composables │ │ ├── index.ts │ │ ├── useClickOutside.ts │ │ ├── useDate.ts │ │ ├── useFetch.ts │ │ ├── useInertia.ts │ │ ├── useLazy.ts │ │ ├── useNotification.ts │ │ ├── usePagination.ts │ │ ├── useQuery.ts │ │ ├── useRouter.ts │ │ ├── useSearch.ts │ │ ├── useSidebar.ts │ │ └── useSlideover.ts │ ├── index.ts │ ├── shared │ │ ├── http │ │ │ ├── HttpDownload.ts │ │ │ ├── HttpRequest.ts │ │ │ └── HttpResponse.ts │ │ ├── index.ts │ │ └── router │ │ │ └── LaravelRouter.ts │ ├── types │ │ ├── http.ts │ │ ├── index.ts │ │ └── inertia.ts │ ├── vite.ts │ ├── vite │ │ ├── plugin.ts │ │ └── server.ts │ ├── vue.ts │ └── vue │ │ ├── components │ │ ├── Notification.ts │ │ ├── Notification.vue │ │ ├── Notifications.ts │ │ └── Notifications.vue │ │ ├── index.ts │ │ └── plugin.ts ├── tests │ ├── http.spec.ts │ ├── route.spec.ts │ └── router.spec.ts ├── tsconfig.json ├── tsup.config.ts ├── type-models.d.ts ├── type-routes.d.ts ├── types-inertia-global.d.ts ├── types-inertia.d.ts ├── vite.config.ts └── vitest.config.ts ├── pnpm-lock.yaml ├── pnpm-workspace.yaml ├── resources └── views │ └── .gitkeep ├── src ├── Commands │ ├── EloquentListCommand.php │ ├── TypescriptableCommand.php │ ├── TypescriptableEloquentCommand.php │ ├── TypescriptableRoutesCommand.php │ └── TypescriptableSettingsCommand.php ├── Typed │ ├── Database │ │ ├── DatabaseConversion.php │ │ ├── DatabaseDriverEnum.php │ │ ├── Table.php │ │ └── Types │ │ │ ├── IColumn.php │ │ │ ├── MysqlColumn.php │ │ │ ├── PostgreColumn.php │ │ │ ├── SqlServerColumn.php │ │ │ └── SqliteColumn.php │ ├── Eloquent │ │ ├── EloquentConfig.php │ │ ├── EloquentType.php │ │ ├── EloquentTypeArtisan.php │ │ ├── EloquentTypeParser.php │ │ ├── IEloquentType.php │ │ ├── Parser │ │ │ ├── ParserAccessor.php │ │ │ ├── ParserModelFillable.php │ │ │ ├── ParserPhpType.php │ │ │ └── ParserRelation.php │ │ ├── Printer │ │ │ ├── PrinterEloquentPhp.php │ │ │ └── PrinterEloquentTypescript.php │ │ └── Schemas │ │ │ ├── Model │ │ │ ├── SchemaModel.php │ │ │ ├── SchemaModelAttribute.php │ │ │ └── SchemaModelRelation.php │ │ │ └── SchemaApp.php │ ├── Route │ │ ├── Printer │ │ │ ├── PrinterRouteList.php │ │ │ └── PrinterRouteTypes.php │ │ ├── RouteConfig.php │ │ ├── RouteType.php │ │ └── Schemas │ │ │ ├── RouteTypeItem.php │ │ │ └── RouteTypeItemParam.php │ ├── Settings │ │ ├── Printer │ │ │ └── PrinterSettings.php │ │ ├── Schemas │ │ │ ├── SettingItemProperty.php │ │ │ └── SettingsItem.php │ │ ├── SettingsConfig.php │ │ └── SettingsType.php │ └── Utils │ │ ├── EloquentList.php │ │ ├── LaravelPaginateType.php │ │ ├── Schema │ │ ├── SchemaClass.php │ │ └── SchemaCollection.php │ │ ├── TypescriptToPhp.php │ │ └── TypescriptableUtils.php ├── Typescriptable.php ├── TypescriptableConfig.php └── TypescriptableServiceProvider.php └── tsconfig.json /.env.example: -------------------------------------------------------------------------------- 1 | DB_CONNECTION=sqlite # mysql, mariadb, sqlite, pgsql, sqlsrv 2 | 3 | DB_MYSQL_HOST=127.0.0.1 4 | DB_MYSQL_PORT=3306 5 | DB_MYSQL_USER=testing 6 | DB_MYSQL_PASSWORD=testing 7 | DB_MYSQL_DATABASE=testing 8 | 9 | DB_MARIADB_HOST=127.0.0.1 10 | DB_MARIADB_PORT=3307 11 | DB_MARIADB_USER=testing 12 | DB_MARIADB_PASSWORD=testing 13 | DB_MARIADB_DATABASE=testing 14 | 15 | DB_SQLITE_HOST= 16 | DB_SQLITE_PORT= 17 | DB_SQLITE_USER= 18 | DB_SQLITE_PASSWORD= 19 | DB_SQLITE_DATABASE=:memory: 20 | 21 | DB_PGSQL_HOST=127.0.0.1 22 | DB_PGSQL_PORT=5432 23 | DB_PGSQL_USER=testing 24 | DB_PGSQL_PASSWORD=testing 25 | DB_PGSQL_DATABASE=testing 26 | 27 | DB_SQLSRV_HOST=127.0.0.1 28 | DB_SQLSRV_PORT=1433 29 | DB_SQLSRV_USER=sa 30 | DB_SQLSRV_PASSWORD=12345OHdf%e 31 | DB_SQLSRV_DATABASE=testing 32 | 33 | DB_MONGODB_HOST=127.0.0.1 34 | DB_MONGODB_PORT=27017 35 | DB_MONGODB_USER=testing 36 | DB_MONGODB_PASSWORD=testing 37 | DB_MONGODB_DATABASE=testing 38 | 39 | DB_PREFIX=ts_ 40 | DATABASE_TYPES=mysql,mariadb,sqlite,pgsql,sqlsrv # mysql,mariadb,sqlite,pgsql,sqlsrv 41 | -------------------------------------------------------------------------------- /.release-it.json: -------------------------------------------------------------------------------- 1 | { 2 | "git": { 3 | "commitMessage": "chore: release v${version}" 4 | }, 5 | "github": { 6 | "release": true 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "typescript.tsdk": "node_modules/typescript/lib", 3 | "dotenv.enableAutocloaking": false, 4 | // "laravel-pint.configPath": "./pint.json" 5 | "eslint.experimental.useFlatConfig": true 6 | } 7 | -------------------------------------------------------------------------------- /.vscode/tasks.json: -------------------------------------------------------------------------------- 1 | { 2 | // See https://go.microsoft.com/fwlink/?LinkId=733558 3 | // for the documentation about the tasks.json format 4 | "version": "2.0.0", 5 | "tasks": [ 6 | { 7 | "label": "COMPOSER install", 8 | "type": "shell", 9 | "command": "composer i", 10 | "problemMatcher": [], 11 | "presentation": { 12 | "reveal": "silent", 13 | "revealProblems": "onProblem", 14 | "close": true 15 | } 16 | }, 17 | { 18 | "label": "COMPOSER update", 19 | "type": "shell", 20 | "command": "composer update", 21 | "problemMatcher": [], 22 | "presentation": { 23 | "reveal": "silent", 24 | "revealProblems": "onProblem", 25 | "close": true 26 | } 27 | }, 28 | { 29 | "label": "COMPOSER helper", 30 | "type": "shell", 31 | "command": "composer helper", 32 | "problemMatcher": [], 33 | "presentation": { 34 | "reveal": "silent", 35 | "revealProblems": "onProblem", 36 | "close": true 37 | } 38 | }, 39 | { 40 | "label": "COMPOSER format", 41 | "type": "shell", 42 | "command": "composer format", 43 | "problemMatcher": [], 44 | "presentation": { 45 | "reveal": "silent", 46 | "revealProblems": "onProblem", 47 | "close": true 48 | } 49 | }, 50 | { 51 | "label": "COMPOSER analyse", 52 | "type": "shell", 53 | "command": "composer analyse", 54 | "problemMatcher": [], 55 | "presentation": { 56 | "reveal": "silent", 57 | "revealProblems": "onProblem", 58 | "close": true 59 | } 60 | }, 61 | { 62 | "label": "COMPOSER queue", 63 | "type": "shell", 64 | "command": "composer queue:listen", 65 | "problemMatcher": [], 66 | "presentation": { 67 | "reveal": "silent", 68 | "revealProblems": "onProblem", 69 | "close": true 70 | } 71 | }, 72 | { 73 | "label": "COMPOSER tests", 74 | "type": "shell", 75 | "command": "composer tests", 76 | "problemMatcher": [], 77 | "presentation": { 78 | "reveal": "silent", 79 | "revealProblems": "onProblem", 80 | "close": true 81 | } 82 | }, 83 | { 84 | "label": "grumphp", 85 | "type": "shell", 86 | "command": "composer grumphp", 87 | "problemMatcher": [], 88 | "presentation": { 89 | "reveal": "silent", 90 | "revealProblems": "onProblem", 91 | "close": true 92 | } 93 | }, 94 | { 95 | "label": "NPM install", 96 | "type": "shell", 97 | "command": "pnpm i", 98 | "problemMatcher": [], 99 | "presentation": { 100 | "revealProblems": "onProblem", 101 | "close": true 102 | } 103 | }, 104 | { 105 | "label": "NPM update", 106 | "type": "shell", 107 | "command": "pnpm update --latest", 108 | "problemMatcher": [], 109 | "presentation": { 110 | "revealProblems": "onProblem", 111 | "close": true 112 | } 113 | }, 114 | { 115 | "label": "NPM build", 116 | "type": "shell", 117 | "command": "pnpm build", 118 | "problemMatcher": [], 119 | "presentation": { 120 | "revealProblems": "onProblem", 121 | "close": true 122 | } 123 | }, 124 | { 125 | "label": "NPM watch", 126 | "type": "shell", 127 | "command": "pnpm watch", 128 | "problemMatcher": [], 129 | "presentation": { 130 | "revealProblems": "onProblem", 131 | "close": true 132 | } 133 | }, 134 | { 135 | "label": "NPM package", 136 | "type": "shell", 137 | "command": "pnpm package", 138 | "problemMatcher": [], 139 | "presentation": { 140 | "reveal": "never", 141 | "revealProblems": "never", 142 | "close": true 143 | } 144 | }, 145 | { 146 | "label": "NPM release", 147 | "type": "shell", 148 | "command": "pnpm run release", 149 | "problemMatcher": [], 150 | "presentation": { 151 | "reveal": "always", 152 | "revealProblems": "always", 153 | "close": false 154 | } 155 | }, 156 | { 157 | "label": "GIT pre-deploy", 158 | "type": "shell", 159 | "command": "git checkout main && git merge develop && git push && git checkout develop", 160 | "problemMatcher": [], 161 | "presentation": { 162 | "revealProblems": "onProblem", 163 | "close": true 164 | } 165 | } 166 | ] 167 | } 168 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) kiwilan 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in 13 | all copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 21 | THE SOFTWARE. 22 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "kiwilan/typescriptable-laravel", 3 | "description": "PHP package for Laravel to type Eloquent models, routes, Spatie Settings with autogenerated TypeScript. If you want to use some helpers with Inertia, you can install associated NPM package.", 4 | "version": "3.1.06", 5 | "keywords": [ 6 | "kiwilan", 7 | "laravel", 8 | "typescriptable-laravel", 9 | "php", 10 | "typescript", 11 | "ts", 12 | "inertia", 13 | "eloquent", 14 | "model", 15 | "routes", 16 | "vue" 17 | ], 18 | "homepage": "https://github.com/kiwilan/typescriptable-laravel", 19 | "license": "MIT", 20 | "authors": [ 21 | { 22 | "name": "Kiwilan", 23 | "email": "ewilan.riviere@gmail.com", 24 | "role": "Developer" 25 | } 26 | ], 27 | "require": { 28 | "php": "^8.2 || ^8.4", 29 | "illuminate/contracts": "^11.0 || ^12.0", 30 | "illuminate/database": "^11.0 || ^12.0", 31 | "illuminate/support": "^11.0 || ^12.0", 32 | "spatie/laravel-package-tools": "^1.16.3" 33 | }, 34 | "require-dev": { 35 | "larastan/larastan": "^2.8.0", 36 | "laravel/pint": "^1.0", 37 | "mongodb/laravel-mongodb": "^5.1.0", 38 | "nunomaduro/collision": "^8.0", 39 | "orchestra/testbench": "^9.0", 40 | "pestphp/pest": "^2.8.1", 41 | "pestphp/pest-plugin-laravel": "*", 42 | "phpstan/extension-installer": "^1.1", 43 | "phpstan/phpstan-deprecation-rules": "^1.0", 44 | "phpstan/phpstan-phpunit": "^1.0", 45 | "spatie/laravel-medialibrary": "^11.6", 46 | "spatie/laravel-ray": "^1.26", 47 | "vlucas/phpdotenv": "^5.5" 48 | }, 49 | "autoload": { 50 | "psr-4": { 51 | "Kiwilan\\Typescriptable\\": "src" 52 | } 53 | }, 54 | "autoload-dev": { 55 | "psr-4": { 56 | "Kiwilan\\Typescriptable\\Tests\\": "tests" 57 | } 58 | }, 59 | "scripts": { 60 | "post-autoload-dump": "@php ./vendor/bin/testbench package:discover --ansi", 61 | "test": "vendor/bin/pest", 62 | "test-filter": "vendor/bin/pest --filter", 63 | "test-parallel": "vendor/bin/pest --parallel", 64 | "test-coverage": "vendor/bin/pest --coverage", 65 | "test-coverage-parallel": "vendor/bin/pest --parallel --coverage", 66 | "analyse": "vendor/bin/phpstan analyse", 67 | "format": "vendor/bin/pint" 68 | }, 69 | "config": { 70 | "sort-packages": true, 71 | "allow-plugins": { 72 | "pestphp/pest-plugin": true, 73 | "phpstan/extension-installer": true 74 | } 75 | }, 76 | "extra": { 77 | "laravel": { 78 | "providers": [ 79 | "Kiwilan\\Typescriptable\\TypescriptableServiceProvider" 80 | ], 81 | "aliases": { 82 | "Typescriptable": "Kiwilan\\Typescriptable\\Facades\\Typescriptable" 83 | } 84 | } 85 | }, 86 | "minimum-stability": "dev", 87 | "prefer-stable": true 88 | } 89 | -------------------------------------------------------------------------------- /config/typescriptable.php: -------------------------------------------------------------------------------- 1 | [ 8 | /** 9 | * `artisan` will use the `php artisan model:show` command to parse the models. 10 | * `parser` will use internal engine to parse the models. 11 | */ 12 | 'eloquent' => 'artisan', // artisan / parser 13 | ], 14 | 15 | /** 16 | * The path to the output directory for all the types. 17 | */ 18 | 'output_path' => resource_path('js'), 19 | 20 | /** 21 | * Options for the Eloquent models. 22 | */ 23 | 'eloquent' => [ 24 | 'filename' => 'types-eloquent.d.ts', 25 | /** 26 | * The path to the models directory. 27 | */ 28 | 'directory' => app_path('Models'), 29 | /** 30 | * The path to print PHP classes if you want to convert Models to simple classes. 31 | * If null will not print PHP classes. 32 | * 33 | * @example `app_path('Raw')` 34 | */ 35 | 'php_path' => null, 36 | /** 37 | * Models to skip. 38 | */ 39 | 'skip' => [ 40 | // 'App\\Models\\User', 41 | ], 42 | /** 43 | * Whether to add the LaravelPaginate type (with API type and view type). 44 | */ 45 | 'paginate' => true, 46 | ], 47 | /** 48 | * Options for the Spatie settings. 49 | */ 50 | 'settings' => [ 51 | 'filename' => 'types-settings.d.ts', 52 | /** 53 | * The path to the settings directory. 54 | */ 55 | 'directory' => app_path('Settings'), 56 | /** 57 | * Extended class for the settings. 58 | */ 59 | 'extends' => 'Spatie\LaravelSettings\Settings', 60 | /** 61 | * Settings to skip. 62 | */ 63 | 'skip' => [ 64 | // 'App\\Settings\\Home', 65 | ], 66 | ], 67 | /** 68 | * Options for the routes. 69 | */ 70 | 'routes' => [ 71 | /** 72 | * The path to the routes types. 73 | */ 74 | 'types' => 'types-routes.d.ts', 75 | /** 76 | * The path to the routes list. 77 | */ 78 | 'list' => 'routes.ts', 79 | /** 80 | * Whether to print the list of routes. 81 | */ 82 | 'print_list' => true, 83 | /** 84 | * Add routes to `window` from list, can be find with `window.Routes`. 85 | */ 86 | 'add_to_window' => true, 87 | /** 88 | * Use routes `path` instead of `name` for the type name. 89 | */ 90 | 'use_path' => false, 91 | /** 92 | * Routes to skip. 93 | */ 94 | 'skip' => [ 95 | 'name' => [ 96 | 'debugbar.*', 97 | 'horizon.*', 98 | 'telescope.*', 99 | 'nova.*', 100 | 'lighthouse.*', 101 | 'filament.*', 102 | 'log-viewer.*', 103 | 'two-factor.*', 104 | ], 105 | 'path' => [ 106 | '_ignition/*', 107 | '__clockwork/*', 108 | 'clockwork/*', 109 | 'two-factor-challenge', 110 | 'livewire', 111 | ], 112 | ], 113 | ], 114 | ]; 115 | -------------------------------------------------------------------------------- /eslint.config.js: -------------------------------------------------------------------------------- 1 | import antfu from '@antfu/eslint-config' 2 | 3 | // https://github.com/antfu/eslint-config 4 | export default antfu({ 5 | markdown: false, 6 | ignores: [ 7 | './.github/*', 8 | './node_modules/*', 9 | './lib/coverage/*', 10 | './lib/dist/*', 11 | './lib/node_modules/*', 12 | './tests/output/*', 13 | './vendor/*', 14 | './**/*.d.ts', 15 | ], 16 | rules: { 17 | 'no-console': 'warn', 18 | 'no-unused-vars': 'off', 19 | 'n/prefer-global/process': 'off', 20 | 'node/prefer-global/process': 'off', 21 | }, 22 | }) 23 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@kiwilan/typescriptable-laravel", 3 | "type": "module", 4 | "license": "MIT", 5 | "scripts": { 6 | "dev": "pnpm run --parallel dev", 7 | "build": "pnpm recursive run build", 8 | "preinstall": "npx only-allow pnpm", 9 | "lint": "pnpm recursive run lint", 10 | "lint:fix": "pnpm recursive run lint:fix", 11 | "test": "pnpm recursive run test", 12 | "coverage": "pnpm recursive run coverage", 13 | "package": "pnpm recursive run package", 14 | "release": "pnpm recursive run release" 15 | }, 16 | "dependencies": { 17 | "@antfu/eslint-config": "^2.6.2", 18 | "eslint": "^8.56.0", 19 | "typescript": "^5.3.3" 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /pint.json: -------------------------------------------------------------------------------- 1 | { 2 | "exclude": [ 3 | "tests/output" 4 | ] 5 | } -------------------------------------------------------------------------------- /plugin/LICENSE.md: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) Kiwilan 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in 13 | all copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 21 | THE SOFTWARE. 22 | -------------------------------------------------------------------------------- /plugin/global.d.ts: -------------------------------------------------------------------------------- 1 | declare global { 2 | interface Window { 3 | Routes: Record 4 | } 5 | } 6 | 7 | // @ts-expect-error - Routes is defined in the global scope 8 | window?.Routes = window?.Routes || {} 9 | 10 | export {} 11 | -------------------------------------------------------------------------------- /plugin/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@kiwilan/typescriptable-laravel", 3 | "type": "module", 4 | "version": "2.4.47", 5 | "description": "Add some helpers for your Inertia app with TypeScript.", 6 | "author": "Ewilan Rivière ", 7 | "license": "MIT", 8 | "homepage": "https://github.com/kiwilan/typescriptable-laravel/tree/main/lib", 9 | "repository": { 10 | "type": "git", 11 | "url": "git+https://github.com/kiwilan/typescriptable-laravel.git" 12 | }, 13 | "bugs": { 14 | "url": "https://github.com/kiwilan/typescriptable-laravel/issues" 15 | }, 16 | "keywords": [ 17 | "typescriptable", 18 | "laravel", 19 | "inertia", 20 | "ts", 21 | "vue", 22 | "vite", 23 | "typescript" 24 | ], 25 | "exports": { 26 | "./vite": { 27 | "types": "./dist/vite.d.ts", 28 | "import": "./dist/vite.js", 29 | "require": "./dist/vite.cjs" 30 | }, 31 | "./vue": { 32 | "types": "./dist/vue.d.ts", 33 | "import": "./dist/vue.js", 34 | "require": "./dist/vue.cjs" 35 | }, 36 | ".": { 37 | "types": "./dist/index.d.ts", 38 | "import": "./dist/index.js", 39 | "require": "./dist/index.cjs" 40 | } 41 | }, 42 | "main": "dist/index.cjs", 43 | "module": "dist/index.js", 44 | "types": "dist/index.d.ts", 45 | "typesVersions": { 46 | "*": { 47 | "vite": [ 48 | "./dist/vite.d.ts" 49 | ], 50 | "vue": [ 51 | "./dist/vue.d.ts" 52 | ] 53 | } 54 | }, 55 | "files": [ 56 | "dist" 57 | ], 58 | "scripts": { 59 | "build": "tsup --clean", 60 | "watch": "tsup --watch", 61 | "lint": "eslint .", 62 | "lint:fix": "eslint . --fix", 63 | "test": "vitest", 64 | "coverage": "vitest run --coverage", 65 | "clean": "rimraf dist && rimraf vue", 66 | "local": "rm -f ~/kiwilan-typescriptable-laravel-*.tgz || true && mv ./kiwilan-typescriptable-laravel-*.tgz ~/kiwilan-typescriptable-laravel.tgz", 67 | "package": "npm run clean && npm run build && npm pack && npm run local", 68 | "release": "npm run clean && npm run build && npm pack --dry-run && npm publish --access public" 69 | }, 70 | "peerDependencies": { 71 | "vite": "^4.x.x || ^5.x.x", 72 | "vue": "^3.x.x" 73 | }, 74 | "peerDependenciesMeta": { 75 | "laravel-vite-plugin": { 76 | "optional": true 77 | }, 78 | "vite": { 79 | "optional": true 80 | }, 81 | "vue": { 82 | "optional": true 83 | } 84 | }, 85 | "devDependencies": { 86 | "@inertiajs/core": "^1.0.14", 87 | "@inertiajs/vue3": "^1.0.14", 88 | "@types/node": "^20.11.0", 89 | "@vitejs/plugin-vue": "^5.0.3", 90 | "@vitest/coverage-v8": "^1.2.0", 91 | "autoprefixer": "^10.4.16", 92 | "laravel-vite-plugin": "^1.0.1", 93 | "postcss": "^8.4.33", 94 | "rimraf": "^5.0.7", 95 | "tailwindcss": "^3.4.1", 96 | "tsup": "^8.0.1", 97 | "vite": "^5.1.8", 98 | "vite-plugin-dts": "^3.7.0", 99 | "vitest": "^1.2.0", 100 | "vue": "^3.4.13" 101 | } 102 | } 103 | -------------------------------------------------------------------------------- /plugin/src/composables/index.ts: -------------------------------------------------------------------------------- 1 | import { useClickOutside } from './useClickOutside' 2 | import { useDate } from './useDate' 3 | import { useFetch } from './useFetch' 4 | import { useInertia } from './useInertia' 5 | import { useLazy } from './useLazy' 6 | import { useNotification } from './useNotification' 7 | import { usePagination } from './usePagination' 8 | import { useQuery } from './useQuery' 9 | import { useRouter } from './useRouter' 10 | import { useSearch } from './useSearch' 11 | import { useSidebar } from './useSidebar' 12 | import { useSlideover } from './useSlideover' 13 | import type { Query, SortItem } from './useQuery' 14 | 15 | export { 16 | useClickOutside, 17 | useDate, 18 | useFetch, 19 | useInertia, 20 | useLazy, 21 | useNotification, 22 | usePagination, 23 | useQuery, 24 | useRouter, 25 | useSearch, 26 | useSidebar, 27 | useSlideover, 28 | Query, 29 | SortItem, 30 | } 31 | -------------------------------------------------------------------------------- /plugin/src/composables/useClickOutside.ts: -------------------------------------------------------------------------------- 1 | import type { Ref } from 'vue' 2 | import { onBeforeUnmount, onMounted } from 'vue' 3 | 4 | export function useClickOutside(element: Ref, callback: () => void) { 5 | if (!element) 6 | return 7 | 8 | const listener = (e: MouseEvent) => { 9 | const el = element.value as EventTarget 10 | if (typeof el === 'undefined') 11 | return 12 | 13 | if (e.target === el || e.composedPath().includes(el)) 14 | return 15 | 16 | if (typeof callback === 'function') 17 | callback() 18 | } 19 | 20 | onMounted(() => { 21 | window.addEventListener('click', listener) 22 | }) 23 | 24 | onBeforeUnmount(() => { 25 | window.removeEventListener('click', listener) 26 | }) 27 | 28 | return { 29 | listener, 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /plugin/src/composables/useDate.ts: -------------------------------------------------------------------------------- 1 | export function useDate() { 2 | const defaultDTO: Intl.DateTimeFormatOptions = { 3 | day: '2-digit', 4 | month: '2-digit', 5 | year: 'numeric', 6 | } 7 | const language = 'en-US' || navigator?.language 8 | function toDate(date?: string | Date): Date { 9 | if (!date) 10 | return new Date() 11 | 12 | return new Date(date) 13 | } 14 | 15 | /** 16 | * Format date to human readable format. 17 | */ 18 | function formatDate(date?: string | Date, options: Intl.DateTimeFormatOptions = defaultDTO): string | undefined { 19 | if (!date) 20 | return undefined 21 | 22 | return toDate(date).toLocaleDateString(language, options) 23 | } 24 | 25 | /** 26 | * Format date to human readable format. 27 | * @example 01/01/2021 28 | */ 29 | function dateSlash(date?: string | Date): string | undefined { 30 | return formatDate(date, defaultDTO) 31 | } 32 | 33 | /** 34 | * Format date to human readable format. 35 | * @example Jan 1, 2021 36 | */ 37 | function dateString(date?: string | Date): string | undefined { 38 | return formatDate(date, { 39 | year: 'numeric', 40 | month: 'short', 41 | day: 'numeric', 42 | }) 43 | } 44 | 45 | return { 46 | formatDate, 47 | dateSlash, 48 | dateString, 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /plugin/src/composables/useInertia.ts: -------------------------------------------------------------------------------- 1 | import { usePage } from '@inertiajs/vue3' 2 | import { computed } from 'vue' 3 | 4 | export function useInertia() { 5 | /** 6 | * Shortcut to the page object, same than Inertia's composable `usePage()` with types. 7 | * 8 | * @see https://inertiajs.com/the-protocol#the-page-object 9 | */ 10 | const page = usePage() 11 | 12 | const component = computed((): Inertia.PageProps['component'] => { 13 | return page.component 14 | }) 15 | 16 | const props = computed(() => { 17 | return page.props as Inertia.PageProps 18 | }) 19 | 20 | const url = computed((): string => { 21 | return page.url 22 | }) 23 | 24 | const version = computed((): string | null => { 25 | return page.version 26 | }) 27 | 28 | const auth = computed((): { user: T } => { 29 | return props.value.auth as { user: T } 30 | }) 31 | 32 | const user = computed((): T => { 33 | return auth.value?.user as T 34 | }) 35 | 36 | /** 37 | * Check if the app is running in development mode. 38 | */ 39 | const isDev = computed(() => { 40 | return process.env.NODE_ENV === 'development' 41 | }) 42 | 43 | const isClient = computed(() => { 44 | return typeof window !== 'undefined' 45 | }) 46 | 47 | return { 48 | page, 49 | component, 50 | props, 51 | url, 52 | version, 53 | auth, 54 | user, 55 | isDev, 56 | isClient, 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /plugin/src/composables/useLazy.ts: -------------------------------------------------------------------------------- 1 | import { ref } from 'vue' 2 | 3 | /** 4 | * Lazy load images 5 | */ 6 | export function useLazy(loadingColor?: string, fallbackPath = '/placeholder.webp') { 7 | let url = 'http://localhost' 8 | let domain = '' 9 | let fallbackError = false 10 | const fallbackDefault = 'https://raw.githubusercontent.com/kiwilan/steward-laravel/main/public/no-image-available.jpg' 11 | 12 | if (isClient()) { 13 | url = window?.location.href 14 | const baseURL = url.split('/') 15 | baseURL.pop() 16 | url = baseURL.join('/') 17 | 18 | const newUrl = new URL(url) 19 | domain = newUrl.origin 20 | } 21 | 22 | if (!loadingColor) 23 | loadingColor = '#1f2937' 24 | 25 | const src = ref() 26 | const alt = ref() 27 | const classes = ref() 28 | const styles = ref() 29 | 30 | function createWrapper(el: HTMLElement) { 31 | const styleWrapper = [ 32 | 'position: relative;', 33 | ] 34 | 35 | const wrapper = document.createElement('div') 36 | wrapper.setAttribute('class', el.getAttribute('class') ?? '') 37 | wrapper.setAttribute('style', `${styleWrapper.join(' ')}`) 38 | 39 | return wrapper 40 | } 41 | 42 | function createImg() { 43 | const img = document.createElement('img') 44 | img.setAttribute('alt', alt.value!) 45 | img.setAttribute('class', classes.value!) 46 | img.setAttribute('style', styles.value!) 47 | img.setAttribute('loading', 'lazy') 48 | 49 | return img 50 | } 51 | 52 | function isClient(): boolean { 53 | return typeof window !== 'undefined' 54 | } 55 | 56 | async function checkFallbackUrl(url: string) { 57 | const response = await fetch(url) 58 | fallbackError = !response.ok 59 | } 60 | 61 | const vLazy = { 62 | beforeMount(el: HTMLElement) { 63 | src.value = el.getAttribute('src') ?? '' 64 | alt.value = el.getAttribute('alt') ?? '' 65 | classes.value = el.getAttribute('class') ?? '' 66 | styles.value = el.getAttribute('style') ?? '' 67 | el.setAttribute('src', '') 68 | el.setAttribute('alt', '') 69 | 70 | if (!src.value.startsWith('http')) 71 | src.value = `${url}${src.value}` 72 | }, 73 | mounted(el: HTMLElement) { 74 | const stylePlaceholder = [ 75 | `background-color: ${loadingColor};`, 76 | 'height: 100%;', 77 | 'width: 100%;', 78 | 'position: absolute;', 79 | 'top: 0;', 80 | 'left: 0;', 81 | 'right: 0;', 82 | 'bottom: 0;', 83 | 'opacity: 1;', 84 | 'transition: opacity 0.3s ease-in-out;', 85 | ] 86 | 87 | const wrapper = createWrapper(el) 88 | 89 | const placeholder = document.createElement('div') 90 | placeholder.setAttribute('style', `${stylePlaceholder.join(' ')}`) 91 | placeholder.setAttribute('class', el.getAttribute('class') ?? '') 92 | 93 | const img = createImg() 94 | 95 | el.replaceWith(wrapper) 96 | wrapper.appendChild(placeholder) 97 | wrapper.appendChild(img) 98 | 99 | setTimeout(() => { 100 | img.setAttribute('src', src.value!) 101 | img.setAttribute('style', `height: 100%;`) 102 | 103 | img.onload = () => { 104 | placeholder.setAttribute('style', `${stylePlaceholder.join(' ')} opacity: 0;`) 105 | setTimeout(() => { 106 | placeholder.remove() 107 | }, 500) 108 | } 109 | 110 | img.onerror = () => { 111 | let fallbackUrl = `${domain}${fallbackPath}` 112 | if (fallbackError) 113 | fallbackUrl = fallbackDefault 114 | 115 | img.setAttribute('src', fallbackUrl) 116 | checkFallbackUrl(fallbackUrl) 117 | 118 | placeholder.setAttribute('style', `${stylePlaceholder.join(' ')} opacity: 0;`) 119 | setTimeout(() => { 120 | placeholder.remove() 121 | }, 500) 122 | } 123 | }, 50) 124 | }, 125 | } 126 | 127 | return { 128 | vLazy, 129 | } 130 | } 131 | -------------------------------------------------------------------------------- /plugin/src/composables/useNotification.ts: -------------------------------------------------------------------------------- 1 | import { ref } from 'vue' 2 | 3 | export interface Notification { 4 | type?: 'success' | 'error' | 'info' | 'warning' 5 | title: string 6 | description?: string 7 | duration?: number 8 | } 9 | 10 | export interface NotificationExtended extends Notification { 11 | id: number 12 | timeout: number 13 | timer: number 14 | } 15 | 16 | const notifications = ref([]) 17 | 18 | export function useNotification(timeout = 5000) { 19 | function push(notification: Notification) { 20 | const n = { 21 | ...notification, 22 | id: Date.now(), 23 | timeout: notification.duration || timeout, 24 | timer: 0, 25 | } 26 | 27 | notifications.value.unshift(n) 28 | 29 | setTimeout(() => { 30 | notifications.value = notifications.value.filter(item => item.id !== n.id) 31 | }, n.timeout) 32 | } 33 | 34 | function remove(id: number) { 35 | notifications.value = notifications.value.filter(item => item.id !== id) 36 | } 37 | 38 | function clearAll() { 39 | notifications.value = [] 40 | } 41 | 42 | return { 43 | notifications, 44 | push, 45 | remove, 46 | clearAll, 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /plugin/src/composables/usePagination.ts: -------------------------------------------------------------------------------- 1 | import { usePage } from '@inertiajs/vue3' 2 | import { computed, onMounted, ref } from 'vue' 3 | 4 | interface PaginateLink extends App.PaginateLink { 5 | isLink?: boolean 6 | class?: string 7 | } 8 | 9 | export function usePagination(models: App.Paginate) { 10 | const onEachSide = 2 11 | const firstPage = ref() 12 | const lastPage = ref() 13 | const currentPage = ref() 14 | const pages = ref([]) 15 | const previousPage = ref() 16 | const nextPage = ref() 17 | 18 | function getQuery() { 19 | const query = new URLSearchParams(window?.location.search) 20 | query.delete('page') 21 | 22 | return query.toString() 23 | } 24 | 25 | function paginate() { 26 | const allPages: PaginateLink[] = [] 27 | const baseURL = models.last_page_url.replace(/\?.*$/, '') 28 | 29 | for (let i = 0; i < models.last_page; i++) { 30 | allPages.push({ 31 | label: `${i + 1}`, 32 | url: `${baseURL}?page=${i + 1}`, 33 | active: i + 1 === models.current_page, 34 | isLink: true, 35 | class: `page ${i + 1 === models.current_page ? 'page-active' : 'page-not-active'}`, 36 | }) 37 | } 38 | 39 | firstPage.value = allPages[0] 40 | lastPage.value = allPages[allPages.length - 1] 41 | currentPage.value = allPages.find(page => page.active) 42 | 43 | allPages.shift() 44 | allPages.pop() 45 | 46 | const activeIndex = allPages.findIndex(page => page.active) 47 | const seperator: PaginateLink = { 48 | label: '...', 49 | url: '', 50 | active: false, 51 | isLink: false, 52 | class: 'page page-disabled', 53 | } 54 | 55 | const max = onEachSide * 3 56 | 57 | // if (allPages.length < 2) { 58 | // pages.value = allPages 59 | // return 60 | // } 61 | 62 | if (activeIndex <= onEachSide && models.current_page !== models.last_page) { 63 | pages.value = allPages.slice(0, onEachSide + onEachSide) 64 | if (models.last_page !== max && models.last_page > max) { 65 | pages.value = [ 66 | ...pages.value, 67 | seperator, 68 | ] 69 | } 70 | } 71 | else if (activeIndex >= allPages.length - onEachSide || models.current_page === models.last_page) { 72 | if (models.last_page !== max || models.last_page > max) { 73 | pages.value = [ 74 | seperator, 75 | ] 76 | } 77 | 78 | pages.value = [ 79 | ...pages.value, 80 | ...allPages.slice(allPages.length - onEachSide - onEachSide, allPages.length), 81 | ] 82 | } 83 | else { 84 | pages.value = [ 85 | seperator, 86 | ...allPages.slice(activeIndex - onEachSide, activeIndex), 87 | currentPage.value!, 88 | ...allPages.slice(activeIndex + 1, activeIndex + onEachSide + 1), 89 | ] 90 | 91 | if (activeIndex === onEachSide) 92 | pages.value.splice(1, 1) 93 | 94 | if (models.current_page + onEachSide + 1 !== models.last_page) { 95 | pages.value = [ 96 | ...pages.value, 97 | seperator, 98 | ] 99 | } 100 | } 101 | 102 | pages.value.unshift(firstPage.value!) 103 | pages.value.push(lastPage.value!) 104 | 105 | pages.value.forEach((page) => { 106 | page.url = `${page.url}&${getQuery()}` 107 | }) 108 | 109 | previousPage.value = models.prev_page_url ? `${models.prev_page_url}&${getQuery()}` : undefined 110 | nextPage.value = models.next_page_url ? `${models.next_page_url}&${getQuery()}` : undefined 111 | } 112 | 113 | function convertUrl(queryName: string, queryValue: number | string) { 114 | const { url, props } = usePage() 115 | const baseURL = (props.ziggy as any)?.url 116 | if (!baseURL) 117 | return '' 118 | 119 | let currentUrl = `${baseURL}${url}` 120 | if (currentUrl.includes(`${queryName}=`)) 121 | currentUrl = currentUrl.replace(/page=\d+/, `${queryName}=${queryValue}`) 122 | else if (currentUrl.includes('?')) 123 | currentUrl += `&${queryName}=${queryValue}` 124 | else 125 | currentUrl += `?${queryName}=${queryValue}` 126 | 127 | return currentUrl 128 | } 129 | 130 | const nextPageLink = computed((): string => { 131 | return convertUrl('page', models.current_page + 1) 132 | }) 133 | 134 | const previousPageLink = computed((): string => { 135 | return convertUrl('page', models.current_page - 1) 136 | }) 137 | 138 | onMounted(() => { 139 | paginate() 140 | }) 141 | 142 | return { 143 | pages, 144 | previousPage, 145 | nextPage, 146 | nextPageLink, 147 | previousPageLink, 148 | } 149 | } 150 | -------------------------------------------------------------------------------- /plugin/src/composables/useQuery.ts: -------------------------------------------------------------------------------- 1 | import { useForm, usePage } from '@inertiajs/vue3' 2 | import { onMounted, ref } from 'vue' 3 | 4 | export interface Query extends App.Paginate { 5 | sort?: string 6 | filter?: string | number | boolean | string[] | number[] | boolean[] 7 | } 8 | 9 | export interface SortItem { 10 | label: string 11 | value: string 12 | } 13 | 14 | export function useQuery(propQuery: App.Paginate, prop: string = 'query') { 15 | const current = ref>() 16 | const total = ref() 17 | const isCleared = ref(false) 18 | const sort = ref() 19 | const limit = ref(10) 20 | const isReversed = ref(false) 21 | 22 | current.value = propQuery 23 | total.value = propQuery.total 24 | sort.value = current.value.sort 25 | limit.value = current.value.per_page 26 | 27 | /** 28 | * Set the sort value to the query. 29 | */ 30 | function initializeSort() { 31 | const { url } = usePage() 32 | let search: string | undefined 33 | if (url.includes('?')) 34 | search = `?${url.split('?')[1]}` 35 | 36 | const query = new URLSearchParams(search) 37 | const querySort = query.get('sort') 38 | if (querySort) 39 | sort.value = querySort 40 | if (sort.value && sort.value.startsWith('-')) 41 | isReversed.value = true 42 | setLimit() 43 | } 44 | 45 | function setLimit() { 46 | const localLimit = localStorage.getItem('limit') ?? limit.value.toString() 47 | limit.value = Number.parseInt(localLimit) 48 | 49 | merge({ 50 | limit: limit.value.toString(), 51 | }) 52 | } 53 | 54 | /** 55 | * Limit the number of results. 56 | */ 57 | function limitTo(l: number) { 58 | limit.value = l 59 | localStorage.setItem('limit', limit.value.toString()) 60 | 61 | merge({ 62 | limit: l.toString(), 63 | }) 64 | } 65 | 66 | /** 67 | * Execute the query. 68 | */ 69 | function execute(q: Record) { 70 | const form = useForm({ 71 | ...q, 72 | }) 73 | form.get(location.pathname, { 74 | preserveState: true, 75 | onSuccess: (page) => { 76 | // if `defineProps` use different name for query, we need to get it from `page.props` 77 | const d = page.props[prop] as T 78 | current.value = undefined 79 | setTimeout(() => { 80 | current.value = d as any 81 | }, 250) 82 | }, 83 | }) 84 | } 85 | 86 | /** 87 | * Clear the filter value from the query. 88 | */ 89 | const clear = () => { 90 | isCleared.value = true 91 | execute({}) 92 | } 93 | 94 | /** 95 | * Compare deep equality of two objects. 96 | */ 97 | function deepEqual(x: object, y: object): boolean { 98 | const ok = Object.keys 99 | const tx = typeof x 100 | const ty = typeof y 101 | return (x && y && tx === 'object' && tx === ty) 102 | ? ( 103 | ok(x).length === ok(y).length 104 | && ok(x).every(key => deepEqual(x[key], y[key])) 105 | ) 106 | : (x === y) 107 | } 108 | 109 | /** 110 | * Merge the given query into the current query string. 111 | */ 112 | function merge(queryToAdd: Record) { 113 | const c = new URLSearchParams(location.search) 114 | 115 | const current: Record = {} 116 | c.forEach((value, key) => { 117 | current[key] = value 118 | }) 119 | 120 | const mergedQuery: Record = { 121 | ...queryToAdd, 122 | } 123 | 124 | for (const currentKey in current) { 125 | const currentValue = current[currentKey] 126 | 127 | // If it's an array, we need to merge the values 128 | if (queryToAdd[currentKey] && currentKey.includes('[')) { 129 | const existing = currentValue.split(',') 130 | const values = [queryToAdd[currentKey], ...existing] 131 | const cleaned = [...new Set(values)].sort() 132 | mergedQuery[currentKey] = cleaned.join(',') 133 | } 134 | // If it's not an array, we just need to override the value 135 | else { 136 | mergedQuery[currentKey] = queryToAdd[currentKey] || current[currentKey] 137 | } 138 | } 139 | 140 | if (!deepEqual(mergedQuery, current)) 141 | execute(mergedQuery) 142 | 143 | return mergedQuery 144 | } 145 | 146 | /** 147 | * Sort by the given field. 148 | */ 149 | function sortBy(field: string) { 150 | if (field === sort.value) { 151 | sortReverse() 152 | return 153 | } 154 | 155 | sort.value = field 156 | merge({ 157 | sort: sort.value, 158 | }) 159 | } 160 | 161 | /** 162 | * Reverse the current sort direction. 163 | */ 164 | function sortReverse() { 165 | if (sort.value) { 166 | const isReverse = sort.value?.startsWith('-') 167 | const s = sort.value?.replace('-', '') 168 | sort.value = isReverse ? s : `-${s}` 169 | 170 | merge({ 171 | sort: sort.value, 172 | }) 173 | 174 | isReversed.value = !isReversed.value 175 | } 176 | } 177 | 178 | onMounted(() => { 179 | initializeSort() 180 | }) 181 | 182 | return { 183 | query: current, 184 | total, 185 | clear, 186 | sortBy, 187 | sortReverse, 188 | isReversed, 189 | limitTo, 190 | } 191 | } 192 | -------------------------------------------------------------------------------- /plugin/src/composables/useRouter.ts: -------------------------------------------------------------------------------- 1 | import { computed } from 'vue' 2 | import { LaravelRouter } from '../shared/router/LaravelRouter' 3 | 4 | /** 5 | * Composable for advanced usage of Laravel router. 6 | * 7 | * @method `isRouteEqualTo` Check if current route is the given route. 8 | * @method `currentRoute` Get current route. 9 | * @method `route` Get route URL from route name and params, can be used into template with `$route` helper. 10 | * @method `router` Get router instance. 11 | */ 12 | export function useRouter() { 13 | /** 14 | * Check if current route is the given route. 15 | */ 16 | function isRouteEqualTo(route: App.Route.Name): boolean { 17 | const laravelRouter = LaravelRouter.create() // keep it here for Vue plugin provider 18 | const currentRoute = laravelRouter.urlToRoute(laravelRouter.getUrl()) 19 | 20 | const current: string = route 21 | if (currentRoute) { 22 | const currentRouteName: string = currentRoute.name 23 | const currentRoutePath: string = currentRoute.path 24 | 25 | if (currentRouteName === current) 26 | return true 27 | 28 | if (currentRoutePath === current) 29 | return true 30 | } 31 | 32 | return false 33 | } 34 | 35 | /** 36 | * Get current route. 37 | */ 38 | const currentRoute = computed((): App.Route.Link | undefined => { 39 | const laravelRouter = LaravelRouter.create() // keep it here for Vue plugin provider 40 | return laravelRouter.urlToRoute(laravelRouter.getUrl()) 41 | }) 42 | 43 | /** 44 | * Get route URL from route name and params, can be used into template with `$route` helper. 45 | * 46 | * @example 47 | * 48 | * ```vue 49 | * 54 | * ``` 55 | */ 56 | function route(name: T, params?: T extends keyof App.Route.Params ? App.Route.Params[T] : never): string { 57 | const laravelRouter = LaravelRouter.create() // keep it here for Vue plugin provider 58 | return laravelRouter.routeToUrl({ 59 | name, 60 | params, 61 | }) 62 | } 63 | 64 | const laravelRouter = computed(() => LaravelRouter.create()) 65 | 66 | const baseURL = computed(() => laravelRouter.value.getBaseURL()) 67 | 68 | return { 69 | isRouteEqualTo, 70 | currentRoute, 71 | route, 72 | router: laravelRouter, 73 | baseURL, 74 | } 75 | } 76 | -------------------------------------------------------------------------------- /plugin/src/composables/useSearch.ts: -------------------------------------------------------------------------------- 1 | import { router } from '@inertiajs/vue3' 2 | import { ref } from 'vue' 3 | 4 | /** 5 | * Options for the search function 6 | * 7 | * - `searchQuery` - The query parameter for the search, default is `search` 8 | * - `limitQuery` - The query parameter for the limit, default is `limit` 9 | * - `limit` - The limit of the search, default is `false` 10 | * - `shortcut` - The keybinding for the search field, default is `Ctrl+K` 11 | * - `shortcutMacos` - The keybinding for MacOS, default is `⌘+K` 12 | */ 13 | interface SearchOptions { 14 | searchQuery?: string 15 | limitQuery?: string 16 | limit?: number | false 17 | shortcut?: string 18 | shortcutMacos?: string 19 | } 20 | 21 | export function useSearch(searchUrl: string, options: SearchOptions = {}) { 22 | const loading = ref(false) 23 | const used = ref(false) 24 | const shortcut = ref('Ctrl+K') 25 | 26 | const input = ref() 27 | const query = ref() 28 | const response = ref() 29 | 30 | /** 31 | * Search for the given value 32 | * 33 | * @param event - The input event 34 | * @param timeout - The timeout before searching 35 | */ 36 | async function search(event: Event, timeout = 500) { 37 | used.value = true // now search is used 38 | loading.value = true // start loading 39 | 40 | setTimeout(async () => { 41 | const e = event.target as HTMLInputElement 42 | query.value = e.value // set the query 43 | 44 | let url = searchUrl 45 | const params = new URLSearchParams() 46 | params.append(options.searchQuery ?? 'search', query.value) 47 | if (options.limit) 48 | params.append(options.limitQuery ?? 'limit', options.limit.toString()) 49 | url += `?${params.toString()}` 50 | 51 | const res = await fetch(url) 52 | const body = await res.json() 53 | response.value = body 54 | 55 | loading.value = false // stop loading 56 | }, timeout) 57 | } 58 | 59 | /** 60 | * Clear the search field and results 61 | */ 62 | function clear() { 63 | if (input.value) 64 | input.value.value = '' 65 | 66 | query.value = '' 67 | used.value = false 68 | response.value = undefined 69 | } 70 | 71 | /** 72 | * Close the search field 73 | */ 74 | function close() { 75 | clear() 76 | loading.value = false 77 | used.value = false 78 | input.value?.blur() 79 | } 80 | 81 | /** 82 | * Keybinding for the search field 83 | * - `Ctrl+K` or `⌘+K` to focus the search field 84 | * - `Enter` to search 85 | * - `Escape` to close the search 86 | * 87 | * ```js 88 | * import { useSearch } from '@kiwilan/typescriptable-laravel' 89 | * import { onMounted } from 'vue' 90 | * 91 | * const { keybinding } = useSearch() 92 | * 93 | * onMounted(() => { 94 | * keybinding() 95 | * }) 96 | * ``` 97 | */ 98 | function keybinding() { 99 | document.addEventListener('keydown', (event) => { 100 | if (event.metaKey && event.key === 'k') { 101 | event.preventDefault() 102 | input.value?.focus() 103 | } 104 | if (event.ctrlKey && event.key === 'k') { 105 | event.preventDefault() 106 | input.value?.focus() 107 | } 108 | if (event.key === 'Escape') 109 | close() 110 | 111 | const element = input.value 112 | if (element && element === document.activeElement && element.value.length > 0) { 113 | if (event.key === 'Enter') { 114 | event.preventDefault() 115 | router.get(`/search?search=${encodeURIComponent(element.value)}`) 116 | } 117 | } 118 | }) 119 | } 120 | 121 | function checkSystem() { 122 | const shortcutOther = options.shortcut ?? 'Ctrl+K' 123 | const shortcutMacos = options.shortcutMacos ?? '⌘+K' 124 | 125 | if (typeof navigator !== 'undefined') { 126 | const isMac = navigator?.userAgent.toUpperCase().includes('MAC') 127 | shortcut.value = isMac ? shortcutMacos : shortcutOther 128 | } 129 | } 130 | checkSystem() 131 | 132 | return { 133 | search, 134 | keybinding, 135 | shortcut, 136 | input, 137 | loading, 138 | used, 139 | clear, 140 | close, 141 | response, 142 | } 143 | } 144 | -------------------------------------------------------------------------------- /plugin/src/composables/useSidebar.ts: -------------------------------------------------------------------------------- 1 | import { ref } from 'vue' 2 | 3 | const overlay = ref(false) 4 | const layer = ref(false) 5 | const isOpen = ref(false) 6 | 7 | export function useSidebar() { 8 | function toggle() { 9 | if (isOpen.value) 10 | close() 11 | else 12 | open() 13 | } 14 | 15 | function open() { 16 | layer.value = true 17 | setTimeout(() => { 18 | overlay.value = true 19 | isOpen.value = true 20 | }, 150) 21 | } 22 | 23 | function close() { 24 | overlay.value = false 25 | isOpen.value = false 26 | setTimeout(() => { 27 | layer.value = false 28 | }, 150) 29 | } 30 | 31 | return { 32 | overlay, 33 | layer, 34 | isOpen, 35 | toggle, 36 | open, 37 | close, 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /plugin/src/composables/useSlideover.ts: -------------------------------------------------------------------------------- 1 | import { ref } from 'vue' 2 | 3 | const layer = ref(false) 4 | const isOpen = ref(false) 5 | 6 | export function useSlideover() { 7 | function toggle() { 8 | if (isOpen.value) 9 | close() 10 | else 11 | open() 12 | } 13 | 14 | function open() { 15 | layer.value = true 16 | setTimeout(() => { 17 | isOpen.value = true 18 | }, 150) 19 | } 20 | 21 | function close() { 22 | isOpen.value = false 23 | setTimeout(() => { 24 | layer.value = false 25 | }, 700) 26 | } 27 | 28 | return { 29 | layer, 30 | isOpen, 31 | toggle, 32 | open, 33 | close, 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /plugin/src/index.ts: -------------------------------------------------------------------------------- 1 | import { resolvePageComponent } from 'laravel-vite-plugin/inertia-helpers' 2 | import type { DefineComponent } from 'vue' 3 | import { VueTypescriptable } from './vue/plugin' 4 | 5 | function resolveTitle(title: string, appName: string, seperator = '·'): string { 6 | return title ? `${title} ${seperator} ${appName}` : `${appName}` 7 | } 8 | 9 | async function resolvePages(name: string, glob: Record Promise>): Promise { 10 | return resolvePageComponent(`./Pages/${name}.vue`, glob) as Promise 11 | } 12 | 13 | export type { 14 | Query, 15 | SortItem, 16 | } from './composables' 17 | 18 | export { 19 | useClickOutside, 20 | useDate, 21 | useFetch, 22 | useInertia, 23 | useLazy, 24 | useNotification, 25 | usePagination, 26 | useQuery, 27 | useRouter, 28 | useSearch, 29 | useSidebar, 30 | useSlideover, 31 | } from './composables' 32 | 33 | export type { 34 | RoutesType, 35 | } from './shared' 36 | 37 | export { 38 | HttpRequest, 39 | HttpResponse, 40 | LaravelRouter, 41 | } from './shared' 42 | 43 | export { 44 | VueTypescriptable, 45 | resolveTitle, 46 | resolvePages, 47 | } 48 | -------------------------------------------------------------------------------- /plugin/src/shared/http/HttpDownload.ts: -------------------------------------------------------------------------------- 1 | import type { HttpResponse } from './HttpResponse' 2 | 3 | export class HttpDownload { 4 | private constructor( 5 | private response: HttpResponse, 6 | private filename?: string, 7 | ) {} 8 | 9 | /** 10 | * Create a new download instance. 11 | */ 12 | public static async create(response: HttpResponse, forceFilename?: string): Promise { 13 | const self = new HttpDownload(response, forceFilename) 14 | if (!forceFilename) 15 | self.filename = self.findName(response) 16 | 17 | return self 18 | } 19 | 20 | /** 21 | * Trigger a download from a URL. 22 | */ 23 | public static direct(url: string | undefined, filename?: string): void { 24 | if (!url) { 25 | console.warn('No URL provided to download') 26 | return 27 | } 28 | 29 | const a = document.createElement('a') 30 | a.setAttribute('href', url) 31 | if (filename) { 32 | a.setAttribute('download', filename) 33 | } 34 | else { 35 | const name = url.split('/').pop() 36 | if (name) 37 | a.download = name 38 | } 39 | 40 | document.body.appendChild(a) 41 | a.click() 42 | document.body.removeChild(a) 43 | } 44 | 45 | /** 46 | * Download the response as a Blob. 47 | */ 48 | public async blob(): Promise { 49 | const blob = await this.response.getBody('blob') 50 | if (!blob) { 51 | console.warn('No Blob provided to download') 52 | return 53 | } 54 | return this.fromBlob(blob, this.filename) 55 | } 56 | 57 | /** 58 | * Download the response as an ArrayBuffer. 59 | */ 60 | public async arrayBuffer(): Promise { 61 | const arrayBuffer = await this.response.getBody('arrayBuffer') 62 | if (!arrayBuffer) { 63 | console.warn('No ArrayBuffer provided to download') 64 | return 65 | } 66 | return this.fromArrayBuffer(arrayBuffer, this.filename) 67 | } 68 | 69 | /** 70 | * Download the response as a Blob. 71 | */ 72 | private fromBlob(blob: Blob, filename?: string): void { 73 | const url = URL.createObjectURL(blob) // Create a URL for the Blob 74 | HttpDownload.direct(url, filename) 75 | 76 | URL.revokeObjectURL(url) // Revoke the Blob URL to free up memory 77 | } 78 | 79 | /** 80 | * Download the response as an ArrayBuffer. 81 | */ 82 | private fromArrayBuffer(arrayBuffer: ArrayBuffer, filename?: string): void { 83 | const blob = new Blob([arrayBuffer]) 84 | this.fromBlob(blob, filename) 85 | } 86 | 87 | /** 88 | * Find the filename from the response headers. 89 | */ 90 | private findName(response: HttpResponse): string | undefined { 91 | const name = response.getHeader('Content-Disposition') ?? undefined 92 | if (name) 93 | return name.split('filename=')[1] 94 | 95 | return undefined 96 | } 97 | } 98 | -------------------------------------------------------------------------------- /plugin/src/shared/http/HttpRequest.ts: -------------------------------------------------------------------------------- 1 | import { router as inertia } from '@inertiajs/vue3' 2 | import { LaravelRouter } from '../router/LaravelRouter' 3 | import type { HttpMethod, HttpRequestAnonymous, HttpRequestQuery, RequestPayload } from '@/types/http' 4 | 5 | export class HttpRequest { 6 | protected constructor( 7 | protected laravelRouter: LaravelRouter, 8 | ) {} 9 | 10 | static create(): HttpRequest { 11 | return new HttpRequest(LaravelRouter.create()) 12 | } 13 | 14 | /** 15 | * Make a raw HTTP request. 16 | * Useful for API, use `fetch` under the hood. 17 | */ 18 | public async http(url: string, method: HttpMethod, options: HttpRequestAnonymous = { contentType: 'application/json' }): Promise { 19 | const urlBuiltWithQuery = this.urlBuiltWithQuery(url, options.query) 20 | 21 | return await fetch(urlBuiltWithQuery, { 22 | method, 23 | headers: { 24 | 'Content-Type': options.contentType || 'application/json', 25 | ...options.headers, 26 | }, 27 | body: options.body ? JSON.stringify(options.body) : undefined, 28 | }) 29 | } 30 | 31 | /** 32 | * Make an Inertia request with Laravel route name. 33 | */ 34 | public inertia(name: T, method: HttpMethod, params?: T extends keyof App.Route.Params ? App.Route.Params[T] : never, options?: HttpRequestQuery): void { 35 | const url = this.toUrl(name, params, options?.query) 36 | return this.sendInertia(method, url) 37 | } 38 | 39 | public toUrl(name: App.Route.Name, params?: any, query?: Record): string { 40 | const p = params as Record 41 | const route = this.laravelRouter.routeNameToLink(name) 42 | const url = this.assignParams(route.path, p) 43 | 44 | if (!query) 45 | return url 46 | 47 | const q = this.queryToString(query) 48 | 49 | return `${url}${q}` 50 | } 51 | 52 | private assignParams(url: string, params?: Record): string { 53 | if (!params) 54 | return url 55 | 56 | // detect params in url with braces 57 | const matches = url.match(/(\{\w+\})/g) 58 | if (!matches) 59 | return url 60 | 61 | // replace params in url with values 62 | const p = params as Record 63 | const u = matches.reduce((url, match) => { 64 | const key = match.replace('{', '').replace('}', '') 65 | const value = p[key] 66 | return url.replace(match, value) 67 | }, url) 68 | 69 | return u 70 | } 71 | 72 | private queryToString(query: Record): string { 73 | if (!query) 74 | return '' 75 | 76 | const q = Object.entries(query).map(([key, value]) => `${key}=${value}`).join('&') 77 | return `?${q}` 78 | } 79 | 80 | private urlBuiltWithQuery(url: string, query: Record = {}): string { 81 | const q = this.queryToString(query) 82 | let fullUrl = `${url}${q}` 83 | if (fullUrl.endsWith('?')) 84 | fullUrl = fullUrl.slice(0, -1) 85 | 86 | return fullUrl 87 | } 88 | 89 | private sendInertia(method: HttpMethod, url: string, body?: Record): void { 90 | return inertia[method.toLowerCase()](url, this.inertiaBody(body)) 91 | } 92 | 93 | private inertiaBody(data?: RequestPayload): Record | undefined { 94 | if (!data) 95 | return undefined 96 | 97 | if (typeof data.transform === 'function') { 98 | return data.transform(data => ({ 99 | ...data, 100 | })) 101 | } 102 | 103 | return data 104 | } 105 | } 106 | -------------------------------------------------------------------------------- /plugin/src/shared/http/HttpResponse.ts: -------------------------------------------------------------------------------- 1 | import { HttpRequest } from './HttpRequest' 2 | import type { BodyType, HttpMethod, HttpRequestAnonymous } from '@/types/http' 3 | 4 | export class HttpResponse { 5 | private constructor( 6 | private response: Response, 7 | private headers: Headers, 8 | private statusCode: number, 9 | private statusText: string, 10 | private type: ResponseType, 11 | private ok: boolean, 12 | private redirected: boolean, 13 | private url: string, 14 | ) {} 15 | 16 | /** 17 | * Create a HTTP response from URL and method. 18 | */ 19 | public static async create(url: string, method: HttpMethod, options?: HttpRequestAnonymous): Promise { 20 | const request = this.getRequest() 21 | const response = await request.http(url, method, options) 22 | 23 | const httpResponse = new HttpResponse( 24 | response, 25 | response.headers, 26 | response.status, 27 | response.statusText, 28 | response.type, 29 | response.ok, 30 | response.redirected, 31 | response.url, 32 | ) 33 | 34 | return httpResponse 35 | } 36 | 37 | /** 38 | * Get headers of the response. 39 | */ 40 | public getHeaders(): Headers { 41 | return this.headers 42 | } 43 | 44 | /** 45 | * Get header of the response. 46 | * 47 | * @param name Header name. 48 | */ 49 | public getHeader(name: string): string | null { 50 | return this.headers.get(name) 51 | } 52 | 53 | /** 54 | * @deprecated Use `getStatusCode()` instead. 55 | * 56 | * Get status code of the response (200, 404, 500, etc.) 57 | */ 58 | public getStatus(): number { 59 | return this.statusCode 60 | } 61 | 62 | /** 63 | * Get status code of the response (200, 404, 500, etc.) 64 | */ 65 | public getStatusCode(): number { 66 | return this.statusCode 67 | } 68 | 69 | /** 70 | * Get status text of the response. 71 | * 72 | */ 73 | public getStatusText(): string { 74 | return this.statusText 75 | } 76 | 77 | /** 78 | * Get type of the response. 79 | * 80 | * - `basic`: standard CORS or same-origin request with `fetch`. 81 | * - `cors`: cross-origin request with `fetch`. 82 | * - `default`: no-cors request with `fetch`. 83 | * - `error`: network error. 84 | * - `opaque`: no-cors request. 85 | * - `opaqueredirect`: no-cors request redirected. 86 | */ 87 | public getType(): ResponseType { 88 | return this.type 89 | } 90 | 91 | /** 92 | * Check if the response is OK (status code 200-299). 93 | */ 94 | public isOk(): boolean { 95 | return this.ok 96 | } 97 | 98 | /** 99 | * Check if the response is redirected. 100 | */ 101 | public isRedirected(): boolean { 102 | return this.redirected 103 | } 104 | 105 | /** 106 | * Get URL of the response. 107 | */ 108 | public getUrl(): string { 109 | return this.url 110 | } 111 | 112 | /** 113 | * Get body of the response, default is JSON. 114 | */ 115 | public async getBody(bodyType: BodyType = 'json'): Promise { 116 | let body: T | undefined 117 | switch (bodyType) { 118 | case 'json': 119 | body = await this.response.json() 120 | break 121 | case 'text': 122 | body = await this.response.text() as any 123 | break 124 | case 'blob': 125 | body = await this.response.blob() as any 126 | break 127 | case 'formData': 128 | body = await this.response.formData() as any 129 | break 130 | case 'arrayBuffer': 131 | body = await this.response.arrayBuffer() as any 132 | break 133 | 134 | default: 135 | body = await this.response.json() 136 | break 137 | } 138 | 139 | return body as T 140 | } 141 | 142 | private static getRequest(): HttpRequest { 143 | return HttpRequest.create() 144 | } 145 | } 146 | -------------------------------------------------------------------------------- /plugin/src/shared/index.ts: -------------------------------------------------------------------------------- 1 | import { LaravelRouter } from './router/LaravelRouter' 2 | import { HttpRequest } from './http/HttpRequest' 3 | import { HttpResponse } from './http/HttpResponse' 4 | 5 | type RoutesType = Record | undefined 6 | 7 | export type { 8 | RoutesType, 9 | } 10 | export { 11 | LaravelRouter, 12 | HttpRequest, 13 | HttpResponse, 14 | } 15 | -------------------------------------------------------------------------------- /plugin/src/types/http.ts: -------------------------------------------------------------------------------- 1 | import type { InertiaForm } from '@inertiajs/vue3' 2 | 3 | export type RequestPayload = Record | InertiaForm 4 | export type RouteName = App.Route.Name 5 | export type HttpMethod = 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE' 6 | export type BodyType = 'json' | 'text' | 'blob' | 'formData' | 'arrayBuffer' 7 | 8 | export interface HttpRequestQuery { 9 | /** 10 | * Add query data to URL. 11 | */ 12 | query?: Record 13 | } 14 | 15 | export interface HttpRequestBody extends HttpRequestQuery { 16 | /** 17 | * Body data. 18 | * 19 | * @default {} 20 | */ 21 | body?: RequestPayload 22 | } 23 | 24 | export interface HttpRequestAnonymous { 25 | /** 26 | * Query data. 27 | * 28 | * @default {} 29 | */ 30 | query?: Record 31 | /** 32 | * Body data. 33 | * 34 | * @default {} 35 | */ 36 | body?: RequestPayload 37 | /** 38 | * HTTP headers. 39 | * 40 | * @default {} 41 | */ 42 | headers?: Record 43 | /** 44 | * HTTP Content-Type header. 45 | * 46 | * @default 'application/json' 47 | */ 48 | contentType?: string 49 | } 50 | -------------------------------------------------------------------------------- /plugin/src/types/index.ts: -------------------------------------------------------------------------------- 1 | export interface ViteTypescriptableOptions { 2 | /** 3 | * Enable types for Eloquent models. 4 | * 5 | * @default true 6 | */ 7 | eloquent?: boolean 8 | /** 9 | * Enable types for Spatie 10 | * 11 | * @default false 12 | */ 13 | settings?: boolean 14 | /** 15 | * Enable types for Laravel Routes. 16 | * 17 | * @default true 18 | */ 19 | routes?: boolean 20 | /** 21 | * Print a list of routes. 22 | * 23 | * @default false 24 | */ 25 | routesList?: boolean 26 | /** 27 | * Enable types for Inertia. 28 | * 29 | * @default true 30 | */ 31 | inertia?: boolean 32 | /** 33 | * Update paths for Inertia's types. 34 | * 35 | * You have to install Vue plugin to use this. 36 | * 37 | * ```ts 38 | * import { VueTypescriptable } from '@kiwilan/typescriptable-laravel' 39 | * 40 | * app.use(VueTypescriptable) 41 | * ``` 42 | * 43 | * @default { 44 | * base: 'resources/js', 45 | * pageType: 'types-inertia.d.ts', 46 | * globalType: 'types-inertia-global.d.ts', 47 | * } 48 | */ 49 | inertiaPaths?: { 50 | base?: string 51 | pageType?: string 52 | globalType?: string 53 | } 54 | /** 55 | * Enable Vite autoreload on PHP files changes. 56 | * 57 | * @default true 58 | */ 59 | autoreload?: boolean 60 | } 61 | -------------------------------------------------------------------------------- /plugin/src/types/inertia.ts: -------------------------------------------------------------------------------- 1 | import { write } from '../vite/server' 2 | import type { ViteTypescriptableOptions } from '../vite' 3 | 4 | export class InertiaType { 5 | readonly HEAD = [ 6 | '/* eslint-disable */', 7 | '/* prettier-ignore */', 8 | '// @ts-nocheck', 9 | '// Generated by laravel-typescriptable', 10 | ] 11 | 12 | static async make(opts: ViteTypescriptableOptions) { 13 | const self = new InertiaType() 14 | const basePath = opts.inertiaPaths?.base || 'resources/js' 15 | const inertiaTypeFile = opts.inertiaPaths?.pageType || 'types-inertia.d.ts' 16 | const inertiaGlobalTypeFile = opts.inertiaPaths?.globalType || 'types-inertia-global.d.ts' 17 | 18 | if (inertiaTypeFile) { 19 | await self.setFile(`${basePath}/${inertiaTypeFile}`, self.setPageType()) 20 | // eslint-disable-next-line no-console 21 | console.log('Inertia types ready!') 22 | } 23 | 24 | if (inertiaGlobalTypeFile) { 25 | await self.setFile(`${basePath}/${inertiaGlobalTypeFile}`, self.setGlobalType()) 26 | // eslint-disable-next-line no-console 27 | console.log('Inertia global types ready!') 28 | } 29 | } 30 | 31 | private rootPath(): string { 32 | return process.cwd() 33 | } 34 | 35 | private async setFile(filename: string, content: string) { 36 | const path = `${this.rootPath()}/${filename}` 37 | 38 | await write(path, content) 39 | } 40 | 41 | private setPageType(): string { 42 | const head = this.HEAD.join('\n') 43 | return `${head} 44 | declare namespace Inertia { 45 | type Errors = Record 46 | type ErrorBag = Record 47 | interface Page { 48 | component: string 49 | props: { 50 | user: App.Models.User 51 | jetstream?: { 52 | canCreateTeams?: boolean 53 | hasTeamFeatures?: boolean 54 | managesProfilePhotos?: boolean 55 | hasApiFeatures?: boolean 56 | canUpdateProfileInformation?: boolean 57 | canUpdatePassword?: boolean 58 | canManageTwoFactorAuthentication?: boolean 59 | hasAccountDeletionFeatures?: boolean 60 | hasEmailVerification?: boolean 61 | flash?: { 62 | bannerStyle?: string 63 | banner?: string 64 | message?: string 65 | style?: string 66 | } 67 | } 68 | [key: string]: unknown 69 | errors: Inertia.Errors & Inertia.ErrorBag 70 | } 71 | url: string 72 | version: string | null 73 | scrollRegions: Array<{ 74 | top: number 75 | left: number 76 | }> 77 | errors: Inertia.Errors & Inertia.ErrorBag 78 | rememberedState: Record 79 | resolvedErrors: Inertia.Errors 80 | } 81 | } 82 | ` 83 | } 84 | 85 | private setGlobalType(): string { 86 | const head = this.HEAD.join('\n') 87 | return `${head} 88 | declare module 'vue' { 89 | interface ComponentCustomProperties { 90 | $route: (name: T, params?: T extends keyof App.Route.Params ? App.Route.Params[T] : never) => string 91 | $isRouteEqualTo: (name: App.Route.Name) => boolean 92 | $currentRoute: () => App.Route.Link | undefined 93 | $to: (route: App.Route.RouteConfig) => string 94 | $page: Inertia.Page 95 | sessions: { agent: { is_desktop: boolean; browser: string; platform: string }; ip_address: string; is_current_device: boolean; last_active: string }[] 96 | } 97 | export interface GlobalComponents { 98 | IHead: typeof import('@inertiajs/vue3').Head 99 | ILink: typeof import('@inertiajs/vue3').Link 100 | } 101 | } 102 | 103 | export {} 104 | ` 105 | } 106 | } 107 | -------------------------------------------------------------------------------- /plugin/src/vite.ts: -------------------------------------------------------------------------------- 1 | import ViteTypescriptable from './vite/plugin' 2 | import type { ViteTypescriptableOptions } from './vite/plugin' 3 | 4 | export type { ViteTypescriptableOptions } 5 | export default ViteTypescriptable 6 | -------------------------------------------------------------------------------- /plugin/src/vite/plugin.ts: -------------------------------------------------------------------------------- 1 | import type { Plugin } from 'vite' 2 | import type { ViteTypescriptableOptions } from '../types/index' 3 | import { InertiaType } from '../types/inertia' 4 | import { execute } from './server' 5 | 6 | const DEFAULT_OPTIONS: ViteTypescriptableOptions = { 7 | eloquent: true, 8 | settings: false, 9 | routes: true, 10 | routesList: true, 11 | inertia: true, 12 | inertiaPaths: { 13 | base: 'resources/js', 14 | pageType: 'types-inertia.d.ts', 15 | globalType: 'types-inertia-global.d.ts', 16 | }, 17 | autoreload: true, 18 | } 19 | 20 | /** 21 | * Vite plugin to generate TypeScript types for Laravel. 22 | * 23 | * @example 24 | * 25 | * ```ts 26 | * import typescriptable from '@kiwilan/typescriptable-laravel/vite' 27 | * 28 | * export default defineConfig({ 29 | * plugins: [ 30 | * typescriptable({ 31 | * eloquent: true, 32 | * settings: false, 33 | * routes: true, 34 | * routesList: true, 35 | * inertia: true, 36 | * inertiaPaths: { 37 | * base: 'resources/js', 38 | * pageType: 'types-inertia.d.ts', 39 | * globalType: 'types-inertia-global.d.ts', 40 | * }, 41 | * autoreload: true, 42 | * }), 43 | * ], 44 | * }) 45 | * ``` 46 | */ 47 | function ViteTypescriptable(userOptions: ViteTypescriptableOptions = {}): Plugin { 48 | return { 49 | name: 'vite-plugin-typescriptable-laravel', 50 | async buildStart() { 51 | const opts: ViteTypescriptableOptions = Object.assign({}, DEFAULT_OPTIONS, userOptions) 52 | 53 | if (opts.eloquent) 54 | await execute('php artisan typescriptable:eloquent') 55 | 56 | if (opts.settings) 57 | await execute('php artisan typescriptable:settings') 58 | 59 | if (opts.routes) 60 | await execute('php artisan typescriptable:routes') 61 | 62 | if (opts.routesList) 63 | await execute('php artisan typescriptable:routes --list') 64 | 65 | if (opts.inertia) 66 | await InertiaType.make(opts) 67 | }, 68 | async handleHotUpdate({ file, server }) { 69 | const opts = Object.assign({}, DEFAULT_OPTIONS, userOptions) 70 | 71 | if (opts.autoreload) { 72 | const patterns = [ 73 | /^app\/Models\/[^\/]+\.php$/, 74 | /^app\/Settings\/[^\/]+\.php$/, 75 | /^app\/Http\/Controllers\/[^\/]+\.php$/, 76 | /^routes\/[^\/]+\.php$/, 77 | ] 78 | 79 | const root = process.cwd() 80 | file = file.replace(root, '') 81 | file = file.substring(1) 82 | 83 | for (const pattern of patterns) { 84 | if (pattern.test(file)) 85 | server.restart() 86 | } 87 | } 88 | }, 89 | } 90 | } 91 | 92 | export type { ViteTypescriptableOptions } 93 | export default ViteTypescriptable 94 | -------------------------------------------------------------------------------- /plugin/src/vite/server.ts: -------------------------------------------------------------------------------- 1 | export async function execute(command: string): Promise { 2 | const { exec } = await import('node:child_process') 3 | 4 | exec(command, (error) => { 5 | if (error) { 6 | console.error(`exec error: ${error}`) 7 | return 8 | } 9 | // eslint-disable-next-line no-console 10 | console.log(`${command} ready!`) 11 | }) 12 | } 13 | 14 | export async function write(path: string, content: string): Promise { 15 | const { writeFile } = await import('node:fs/promises') 16 | await writeFile(path, content) 17 | } 18 | -------------------------------------------------------------------------------- /plugin/src/vue.ts: -------------------------------------------------------------------------------- 1 | export { 2 | Notifications, 3 | } from './vue/index' 4 | -------------------------------------------------------------------------------- /plugin/src/vue/components/Notification.ts: -------------------------------------------------------------------------------- 1 | import type { PropType } from 'vue' 2 | import { h, onMounted, ref } from 'vue' 3 | import type { NotificationExtended } from '../composables/useNotification' 4 | 5 | interface Props { 6 | notification: NotificationExtended 7 | } 8 | 9 | export default { 10 | props: { 11 | notification: { 12 | type: Object as PropType, 13 | required: true, 14 | }, 15 | }, 16 | setup(props: Props) { 17 | // const { remove } = useNotification() 18 | const displayed = ref(false) 19 | 20 | // const icons = { 21 | // success: 'M9 12.75 11.25 15 15 9.75M21 12a9 9 0 1 1-18 0 9 9 0 0 1 18 0Z', 22 | // error: 'M12 9v3.75m9-.75a9 9 0 1 1-18 0 9 9 0 0 1 18 0Zm-9 3.75h.008v.008H12v-.008Z', 23 | // warning: 'M12 9v3.75m-9.303 3.376c-.866 1.5.217 3.374 1.948 3.374h14.71c1.73 0 2.813-1.874 1.948-3.374L13.949 3.378c-.866-1.5-3.032-1.5-3.898 0L2.697 16.126ZM12 15.75h.007v.008H12v-.008Z', 24 | // info: 'm11.25 11.25.041-.02a.75.75 0 0 1 1.063.852l-.708 2.836a.75.75 0 0 0 1.063.853l.041-.021M21 12a9 9 0 1 1-18 0 9 9 0 0 1 18 0Zm-9-3.75h.008v.008H12V8.25Z', 25 | // } 26 | 27 | // const type = computed(() => props.notification.type ?? 'info') 28 | // const color = computed(() => { 29 | // switch (type.value) { 30 | // case 'success': 31 | // return 'notification-success' 32 | // case 'error': 33 | // return 'notification-error' 34 | // case 'warning': 35 | // return 'notification-warning' 36 | // case 'info': 37 | // return 'notification-info' 38 | // default: 39 | // return 'notification-info' 40 | // } 41 | // }) 42 | 43 | onMounted(() => { 44 | setTimeout(() => { 45 | displayed.value = true 46 | const timeout = (props.notification.timeout || 5000) - 500 47 | 48 | setTimeout(() => { 49 | displayed.value = false 50 | }, timeout) 51 | }, 150) 52 | }) 53 | 54 | return () => h('div', [`notification ${props.notification.title}`]) 55 | }, 56 | } 57 | -------------------------------------------------------------------------------- /plugin/src/vue/components/Notification.vue: -------------------------------------------------------------------------------- 1 | 46 | 47 | 103 | 104 | 172 | -------------------------------------------------------------------------------- /plugin/src/vue/components/Notifications.ts: -------------------------------------------------------------------------------- 1 | import { h } from 'vue' 2 | import { useNotification } from '../../composables/useNotification' 3 | import Notification from './Notification' 4 | 5 | interface Props { 6 | msg: string 7 | } 8 | 9 | export default { 10 | props: { 11 | msg: { 12 | type: String, // Object as PropType 13 | required: false, 14 | default: 'default message', 15 | }, 16 | }, 17 | setup(props: Props) { 18 | const { notifications } = useNotification() 19 | return () => h('div', { 'aria-live': 'assertive', 'class': 'notifications-container' }, [ 20 | h('div', { class: 'notifications-container_wrapper' }, [ 21 | h('div', `notifications ${props.msg}`), 22 | h('div', [notifications.value.length 23 | ? h( 24 | 'div', 25 | notifications.value.map((notification) => { 26 | return h(Notification as any, { key: notification.id, notification }) 27 | }), 28 | ) 29 | : h('span'), 30 | ]), 31 | ]), 32 | ]) 33 | }, 34 | } 35 | -------------------------------------------------------------------------------- /plugin/src/vue/components/Notifications.vue: -------------------------------------------------------------------------------- 1 | 6 | 7 | 23 | 24 | 34 | -------------------------------------------------------------------------------- /plugin/src/vue/index.ts: -------------------------------------------------------------------------------- 1 | import Notifications from './components/Notifications' 2 | 3 | export { 4 | Notifications, 5 | } 6 | -------------------------------------------------------------------------------- /plugin/src/vue/plugin.ts: -------------------------------------------------------------------------------- 1 | import type { Plugin } from 'vue' 2 | import { Head, Link } from '@inertiajs/vue3' 3 | import { useRouter } from '../composables/useRouter' 4 | 5 | /** 6 | * Vue plugin to use Inertia.js with TypeScript. 7 | * 8 | * - Add `$route`, `$isRouteEqualTo`, and `$currentRoute` helpers. 9 | * - Add `` and `` components. 10 | * 11 | * @see https://inertiajs.com/title-and-meta 12 | * @see https://inertiajs.com/links 13 | * 14 | * @example 15 | * 16 | * ```ts 17 | * import { createApp } from 'vue' 18 | * import { VueTypescriptable } from '@kiwilan/typescriptable-laravel' 19 | * 20 | * createInertiaApp({ 21 | * title: title => `${title} - MyApp`, 22 | * resolve: name => { 23 | * const pages = import.meta.glob('./Pages/*.vue', { eager: true }) 24 | * return pages[`./Pages/${name}.vue`] 25 | * }, 26 | * setup({ el, App, props, plugin }) { 27 | * const app = createApp({ render: () => h(App, props) }) 28 | * .use(plugin) 29 | * .use(VueTypescriptable) 30 | * 31 | * app.mount(el) 32 | * }, 33 | * }) 34 | * ``` 35 | * 36 | * In your Vue components: 37 | * 38 | * ```vue 39 | * 47 | * ``` 48 | */ 49 | export const VueTypescriptable: Plugin = { 50 | install: (app) => { 51 | const router = useRouter() 52 | 53 | app.config.globalProperties.$route = router.route 54 | app.config.globalProperties.$isRouteEqualTo = router.isRouteEqualTo 55 | app.config.globalProperties.$currentRoute = router.currentRoute 56 | 57 | app.provide('inertia', { 58 | route: app.config.globalProperties.$route, 59 | isRouteEqualTo: app.config.globalProperties.$isRouteEqualTo, 60 | currentRoute: app.config.globalProperties.$currentRoute, 61 | }) 62 | 63 | app.component('IHead', Head) 64 | app.component('ILink', Link) 65 | 66 | return app 67 | }, 68 | } 69 | -------------------------------------------------------------------------------- /plugin/tests/http.spec.ts: -------------------------------------------------------------------------------- 1 | import { expect, it } from 'vitest' 2 | import { LaravelRouter } from '../src/methods' 3 | import { Routes } from '../routes' 4 | 5 | it('can use router', async () => { 6 | const list = LaravelRouter.create(Routes).getAllRoutes() 7 | 8 | expect(list).not.toBe(undefined) 9 | expect(list?.home.name).toBe('home') 10 | }) 11 | -------------------------------------------------------------------------------- /plugin/tests/route.spec.ts: -------------------------------------------------------------------------------- 1 | import { expect, it } from 'vitest' 2 | import { LaravelRoute } from '../src/methods' 3 | import { Routes } from '../routes' 4 | 5 | it('can use route item', async () => { 6 | const item = LaravelRoute.create('feeds.show', Routes) 7 | 8 | expect(item).not.toBe(undefined) 9 | expect(item.getPath()).toBe('/feeds/?') 10 | }) 11 | -------------------------------------------------------------------------------- /plugin/tests/router.spec.ts: -------------------------------------------------------------------------------- 1 | import { expect, it } from 'vitest' 2 | import { LaravelRouter } from '../src/methods' 3 | import { Routes } from '../routes' 4 | 5 | it('can get all routes', async () => { 6 | const list = LaravelRouter.create(Routes).getAllRoutes() 7 | 8 | expect(list).not.toBe(undefined) 9 | expect(list?.home.name).toBe('home') 10 | }) 11 | 12 | it('can use bind route', async () => { 13 | const list = LaravelRouter.create(Routes) 14 | 15 | list.getRouteBind({ 16 | name: 'feeds.show', 17 | params: { 18 | feed_slug: 'string', 19 | }, 20 | }) 21 | }) 22 | 23 | it('can find routes', async () => { 24 | const list = LaravelRouter.create(Routes) 25 | 26 | const routeFromHome = list.getRouteFromUrl('/') 27 | const routeFromPosts = list.getRouteFromUrl('/blog') 28 | const routeFromPostsSlug = list.getRouteFromUrl('/blog/my-post') 29 | 30 | expect(routeFromHome?.name).toBe('home') 31 | expect(routeFromHome?.path).toBe('/') 32 | expect(routeFromHome?.params).toBe(undefined) 33 | expect(routeFromHome?.methods).toStrictEqual(['GET']) 34 | 35 | expect(routeFromPosts?.name).toBe('posts.index') 36 | expect(routeFromPosts?.path).toBe('/blog') 37 | expect(routeFromPosts?.params).toBe(undefined) 38 | expect(routeFromPosts?.methods).toStrictEqual(['GET']) 39 | 40 | expect(routeFromPostsSlug?.name).toBe('posts.show') 41 | expect(routeFromPostsSlug?.path).toBe('/blog/{post_slug}') 42 | expect(routeFromPostsSlug?.params).toEqual({ post_slug: 'string' }) 43 | expect(routeFromPostsSlug?.methods).toStrictEqual(['GET']) 44 | }) 45 | 46 | it('can get route', async () => { 47 | const list = LaravelRouter.create(Routes) 48 | 49 | const home = list.getRouteLink('home') 50 | const postsSlug = list.getRouteLink('posts.show') 51 | 52 | expect(home?.name).toBe('home') 53 | expect(home?.path).toBe('/') 54 | expect(home?.params).toBe(undefined) 55 | expect(home?.methods).toStrictEqual(['GET']) 56 | 57 | expect(postsSlug?.name).toBe('posts.show') 58 | expect(postsSlug?.path).toBe('/blog/{post_slug}') 59 | expect(postsSlug?.params).toEqual({ post_slug: 'string' }) 60 | expect(postsSlug?.methods).toStrictEqual(['GET']) 61 | }) 62 | -------------------------------------------------------------------------------- /plugin/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ESNext", 4 | "jsx": "preserve", 5 | "jsxImportSource": "vue", 6 | "lib": ["ESNext", "DOM"], 7 | "module": "ES2020", 8 | "moduleResolution": "Node", 9 | "paths": { 10 | "@": ["./src"], 11 | "@/*": ["./src/*"], 12 | "~": ["./"], 13 | "~/*": ["./*"] 14 | }, 15 | "strict": true, 16 | "strictNullChecks": true, 17 | "noImplicitAny": false, 18 | "declaration": true, 19 | "esModuleInterop": true 20 | }, 21 | "include": [ 22 | "src", 23 | "tests", 24 | "*.d.ts" 25 | ], 26 | "exclude": ["**/dist", "**/node_modules"] 27 | } 28 | -------------------------------------------------------------------------------- /plugin/tsup.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from 'tsup' 2 | 3 | export default defineConfig({ 4 | name: 'vite-plugin-typescriptable-laravel', 5 | entry: { 6 | index: 'src/index.ts', 7 | vite: 'src/vite.ts', 8 | vue: 'src/vue.ts', 9 | }, 10 | outDir: 'dist', 11 | clean: true, 12 | minify: true, 13 | format: ['cjs', 'esm'], 14 | dts: true, 15 | treeshake: true, 16 | splitting: true, 17 | sourcemap: true, 18 | // onSuccess: 'npm run build:fix', 19 | external: [ 20 | 'vue', 21 | 'vite', 22 | '@inertiajs/vue3', 23 | 'node', 24 | 'node:fs', 25 | 'node:fs/promises', 26 | 'node:child_process', 27 | ], 28 | }) 29 | -------------------------------------------------------------------------------- /plugin/type-models.d.ts: -------------------------------------------------------------------------------- 1 | // This file is auto generated by TypescriptableLaravel. 2 | declare namespace App.Models { 3 | export interface Category { 4 | id: number 5 | name: string 6 | slug: string 7 | created_at?: Date 8 | updated_at?: Date 9 | posts?: Post[] 10 | posts_count?: number 11 | } 12 | export interface Feed { 13 | id: number 14 | title: string 15 | slug: string 16 | guid: string 17 | subtitle?: string 18 | picture?: string 19 | link: string 20 | description?: string 21 | author?: string 22 | owner_name?: string 23 | owner_email?: string 24 | language?: string 25 | copyright?: string 26 | generator?: string 27 | keywords: any[] 28 | explicit?: string 29 | type?: string 30 | is_block: boolean 31 | categories: any[] 32 | link_spotify?: string 33 | link_apple_podcasts?: string 34 | link_overcast?: string 35 | link_youtube?: string 36 | link_deezer?: string 37 | link_google_podcasts?: string 38 | created_at?: Date 39 | updated_at?: Date 40 | link_rss?: any 41 | mediables_list?: string[] 42 | mediable?: { picture?: string } 43 | podcasts?: Podcast[] 44 | podcasts_count?: number 45 | } 46 | export interface Podcast { 47 | id: number 48 | title: string 49 | subtitle?: string 50 | slug: string 51 | picture?: string 52 | description?: string 53 | guid?: string 54 | file?: string 55 | size: number 56 | duration: number 57 | serie_id?: number 58 | feed_id?: number 59 | status: 'draft' | 'scheduled' | 'published' 60 | published_at: Date 61 | audio_title?: string 62 | audio_artist?: string 63 | audio_album?: string 64 | audio_genre?: string 65 | audio_year: number 66 | audio_track_number: number 67 | audio_comment?: string 68 | audio_album_artist?: string 69 | audio_composer?: string 70 | audio_disc_number: number 71 | audio_is_compilation: boolean 72 | audio_picture?: string 73 | meta_title?: string 74 | meta_description?: string 75 | created_at?: Date 76 | updated_at?: Date 77 | size_human?: any 78 | duration_human?: any 79 | download_link?: any 80 | direct_path?: any 81 | direct_link?: any 82 | mediables_list?: string[] 83 | seo?: string[] 84 | mediable?: { picture?: string } 85 | serie?: Serie 86 | feed?: Feed 87 | comments?: Comment[] 88 | comments_count?: number 89 | } 90 | export interface Post { 91 | id: number 92 | title: string 93 | slug: string 94 | abstract?: string 95 | picture?: string 96 | description?: string 97 | body?: string 98 | status: 'draft' | 'scheduled' | 'published' 99 | published_at: Date 100 | author_id?: number 101 | meta_title?: string 102 | meta_description?: string 103 | created_at?: Date 104 | updated_at?: Date 105 | seo?: string[] 106 | categories?: Category[] 107 | author?: User 108 | comments?: Comment[] 109 | categories_count?: number 110 | comments_count?: number 111 | } 112 | export interface Serie { 113 | id: number 114 | title: string 115 | slug: string 116 | season_number: number 117 | description?: string 118 | picture?: string 119 | recording_is_finished: boolean 120 | broadcasting_is_finished: boolean 121 | comment?: string 122 | status: 'draft' | 'scheduled' | 'published' 123 | published_at: Date 124 | meta_title?: string 125 | meta_description?: string 126 | created_at?: Date 127 | updated_at?: Date 128 | seo?: string[] 129 | mediables_list?: string[] 130 | mediable?: { picture?: string } 131 | podcasts?: Podcast[] 132 | podcasts_count?: number 133 | } 134 | export interface User { 135 | id: number 136 | name: string 137 | email: string 138 | email_verified_at: Date 139 | two_factor_confirmed_at?: Date 140 | current_team_id?: number 141 | picture?: string 142 | role: 'super_admin' | 'admin' | 'editor' | 'user' 143 | created_at?: Date 144 | updated_at?: Date 145 | profile_photo_url?: any 146 | is_super_admin?: boolean 147 | is_admin?: boolean 148 | is_editor?: boolean 149 | is_user?: boolean 150 | mediables_list?: string[] 151 | mediable?: { picture?: string } 152 | posts?: Post[] 153 | posts_count?: number 154 | is_blocked: boolean 155 | } 156 | export interface Comment { 157 | id: number 158 | name?: string 159 | email?: string 160 | url?: string 161 | content?: string 162 | is_approved: boolean 163 | approved_at: Date 164 | rejected_at: Date 165 | comment_id?: number 166 | commentable_type: string 167 | commentable_id: number 168 | created_at?: Date 169 | updated_at?: Date 170 | gravatar?: string 171 | commentable?: any 172 | } 173 | export interface Page { 174 | id: number 175 | title: string 176 | slug: string 177 | description?: string 178 | content: any[] 179 | meta_title?: string 180 | meta_description?: string 181 | created_at?: Date 182 | updated_at?: Date 183 | seo?: string[] 184 | template_data?: any[] 185 | template: 'home' | 'about' 186 | } 187 | } 188 | 189 | declare namespace App { 190 | export interface PaginateLink { 191 | url: string 192 | label: string 193 | active: boolean 194 | } 195 | export interface Paginate { 196 | data: T[] 197 | current_page: number 198 | first_page_url: string 199 | from: number 200 | last_page: number 201 | last_page_url: string 202 | links: App.PaginateLink[] 203 | next_page_url: string 204 | path: string 205 | per_page: number 206 | prev_page_url: string 207 | to: number 208 | total: number 209 | } 210 | export interface ApiPaginate { 211 | data: T[] 212 | links: { 213 | first?: string 214 | last?: string 215 | prev?: string 216 | next?: string 217 | } 218 | meta: { 219 | current_page: number 220 | from: number 221 | last_page: number 222 | links: App.PaginateLink[] 223 | path: string 224 | per_page: number 225 | to: number 226 | total: number 227 | } 228 | } 229 | } 230 | -------------------------------------------------------------------------------- /plugin/type-routes.d.ts: -------------------------------------------------------------------------------- 1 | // This file is auto generated by TypescriptableLaravel. 2 | declare namespace App.Route { 3 | export type Name = 'api/user' | 'current-user-photo.destroy' | 'current-user.destroy' | 'dashboard' | 'download.show' | 'feeds.index' | 'feeds.show' | 'home' | 'login' | 'logout' | 'other-browser-sessions.destroy' | 'page.about' | 'page.p1pdd' | 'page.pqd2p' | 'page.subscribe' | 'password.confirm' | 'password.confirmation' | 'password.email' | 'password.request' | 'password.reset' | 'password.update' | 'podcasts.index' | 'podcasts.show' | 'posts.index' | 'posts.show' | 'profile.show' | 'rss.index' | 'rss.show' | 'sanctum.csrf-cookie' | 'submission.index' | 'submission.store' | 'two-factor-challenge' | 'two-factor.confirm' | 'two-factor.disable' | 'two-factor.enable' | 'two-factor.login' | 'two-factor.qr-code' | 'two-factor.recovery-codes' | 'two-factor.secret-key' | 'user-password.update' | 'user-profile-information.update' | 'user/confirm-password' | 'user/two-factor-recovery-codes' 4 | export type Path = '/' | '/a-propos' | '/api/user' | '/blog' | '/blog/{post_slug}' | '/contact' | '/dashboard' | '/download/{feed_slug}/{podcast_slug}' | '/feeds' | '/feeds/{feed_slug}' | '/forgot-password' | '/login' | '/logout' | '/p1pdd' | '/podcasts' | '/podcasts/{podcast_slug}' | '/pqd2p' | '/reset-password' | '/reset-password/{token}' | '/rss' | '/rss/{feed_slug}' | '/s-abonner' | '/sanctum/csrf-cookie' | '/two-factor-challenge' | '/user' | '/user/confirm-password' | '/user/confirmed-password-status' | '/user/confirmed-two-factor-authentication' | '/user/other-browser-sessions' | '/user/password' | '/user/profile' | '/user/profile-information' | '/user/profile-photo' | '/user/two-factor-authentication' | '/user/two-factor-qr-code' | '/user/two-factor-recovery-codes' | '/user/two-factor-secret-key' 5 | export interface Params { 6 | 'login': never 7 | 'logout': never 8 | 'password.request': never 9 | 'password.reset': { 10 | 'token'?: App.Route.Param 11 | } 12 | 'password.email': never 13 | 'password.update': never 14 | 'user-profile-information.update': never 15 | 'user-password.update': never 16 | 'user/confirm-password': never 17 | 'password.confirmation': never 18 | 'password.confirm': never 19 | 'two-factor.login': never 20 | 'two-factor-challenge': never 21 | 'two-factor.enable': never 22 | 'two-factor.confirm': never 23 | 'two-factor.disable': never 24 | 'two-factor.qr-code': never 25 | 'two-factor.secret-key': never 26 | 'two-factor.recovery-codes': never 27 | 'user/two-factor-recovery-codes': never 28 | 'profile.show': never 29 | 'other-browser-sessions.destroy': never 30 | 'current-user-photo.destroy': never 31 | 'current-user.destroy': never 32 | 'sanctum.csrf-cookie': never 33 | 'download.show': { 34 | 'podcast_slug': App.Route.Param 35 | } 36 | 'feeds.index': never 37 | 'feeds.show': { 38 | 'feed_slug': App.Route.Param 39 | } 40 | 'home': never 41 | 'page.about': never 42 | 'page.subscribe': never 43 | 'page.p1pdd': never 44 | 'page.pqd2p': never 45 | 'podcasts.index': never 46 | 'podcasts.show': { 47 | 'podcast_slug'?: App.Route.Param 48 | } 49 | 'posts.index': never 50 | 'posts.show': { 51 | 'post_slug': App.Route.Param 52 | } 53 | 'rss.index': never 54 | 'rss.show': { 55 | 'feed_slug': App.Route.Param 56 | } 57 | 'submission.index': never 58 | 'submission.store': never 59 | 'api/user': never 60 | 'dashboard': never 61 | } 62 | 63 | export type Method = 'HEAD' | 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE' 64 | export type Param = string | number | boolean | undefined 65 | export interface Link { name: App.Route.Name, path: App.Route.Path, params?: App.Route.Params[App.Route.Name], methods: App.Route.Method[] } 66 | export interface RouteConfig { 67 | name: T 68 | params?: T extends keyof App.Route.Params ? App.Route.Params[T] : never 69 | } 70 | } 71 | -------------------------------------------------------------------------------- /plugin/types-inertia-global.d.ts: -------------------------------------------------------------------------------- 1 | import type { ComputedRef } from 'vue' 2 | 3 | declare module 'vue' { 4 | interface ComponentCustomProperties { 5 | $route: (name: T, params?: T extends keyof App.Route.Params ? App.Route.Params[T] : never) => string 6 | $isRoute: (name: App.Route.Name) => boolean 7 | $currentRoute: ComputedRef 8 | $to: (route: App.Route.RouteConfig) => string 9 | // @ts-expect-error - Routes is defined in the global scope 10 | $page: Inertia.Page 11 | sessions: { agent: { is_desktop: boolean, browser: string, platform: string }, ip_address: string, is_current_device: boolean, last_active: string }[] 12 | } 13 | export interface GlobalComponents { 14 | Head: typeof import('@inertiajs/vue3').Head 15 | Link: typeof import('@inertiajs/vue3').Link 16 | } 17 | } 18 | 19 | export {} 20 | -------------------------------------------------------------------------------- /plugin/types-inertia.d.ts: -------------------------------------------------------------------------------- 1 | // This file is auto generated by TypescriptableLaravel. 2 | declare namespace Inertia { 3 | type Errors = Record 4 | type ErrorBag = Record 5 | type PageProps = Record 6 | 7 | interface Page { 8 | component: string 9 | props: { 10 | user: App.Models.User 11 | jetstream?: { 12 | canCreateTeams?: boolean 13 | hasTeamFeatures?: boolean 14 | managesProfilePhotos?: boolean 15 | hasApiFeatures?: boolean 16 | canUpdateProfileInformation?: boolean 17 | canUpdatePassword?: boolean 18 | canManageTwoFactorAuthentication?: boolean 19 | hasAccountDeletionFeatures?: boolean 20 | hasEmailVerification?: boolean 21 | flash?: { 22 | bannerStyle?: string 23 | banner?: string 24 | message?: string 25 | style?: string 26 | } 27 | } 28 | [key: string]: unknown 29 | errors: Inertia.Errors & Inertia.ErrorBag 30 | } 31 | url: string 32 | version: string | null 33 | scrollRegions: Array<{ 34 | top: number 35 | left: number 36 | }> 37 | rememberedState: Record 38 | resolvedErrors: Inertia.Errors 39 | [key: string]: unknown 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /plugin/vite.config.ts: -------------------------------------------------------------------------------- 1 | import { resolve } from 'node:path' 2 | import { defineConfig } from 'vite' 3 | import vue from '@vitejs/plugin-vue' 4 | import dts from 'vite-plugin-dts' 5 | 6 | export default defineConfig({ 7 | resolve: { 8 | alias: { 9 | '@': resolve(__dirname, 'src'), 10 | }, 11 | }, 12 | build: { 13 | lib: { 14 | entry: resolve(__dirname, 'src/index.ts'), 15 | name: 'vite-plugin-typescriptable-laravel', 16 | fileName: 'index', 17 | formats: ['cjs', 'es'], 18 | }, 19 | outDir: 'dist', 20 | rollupOptions: { 21 | external: ['vue', '@inertiajs/vue3', 'node:fs/promises', 'node:child_process'], 22 | }, 23 | }, 24 | plugins: [ 25 | vue(), 26 | dts({ 27 | entryRoot: resolve(__dirname, 'src'), 28 | outDir: resolve(__dirname, 'dist'), 29 | }), 30 | ], 31 | }) 32 | -------------------------------------------------------------------------------- /plugin/vitest.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from 'vitest/config' 2 | 3 | export default defineConfig({ 4 | test: {}, 5 | }) 6 | -------------------------------------------------------------------------------- /pnpm-workspace.yaml: -------------------------------------------------------------------------------- 1 | packages: 2 | - "plugin" 3 | - "!**/docs/**" 4 | -------------------------------------------------------------------------------- /resources/views/.gitkeep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kiwilan/typescriptable-laravel/adf03085cd7e826c736931bd98be1c9b8e3be939/resources/views/.gitkeep -------------------------------------------------------------------------------- /src/Commands/EloquentListCommand.php: -------------------------------------------------------------------------------- 1 | table( 20 | ['Name', 'Namespace', 'Path'], 21 | array_map(fn (SchemaClass $model) => [ 22 | $model->name(), 23 | $model->namespace(), 24 | $model->path(), 25 | ], $list->models()) 26 | ); 27 | 28 | return self::SUCCESS; 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /src/Commands/TypescriptableCommand.php: -------------------------------------------------------------------------------- 1 | newLine(); 20 | $this->info('Generating types...'); 21 | 22 | $eloquent = $this->option('eloquent') ?: false; 23 | $routes = $this->option('routes') ?: false; 24 | $settings = $this->option('settings') ?: false; 25 | 26 | if (! $eloquent && ! $routes && ! $settings) { 27 | $eloquent = true; 28 | $routes = true; 29 | $settings = true; 30 | } 31 | 32 | if ($eloquent) { 33 | $this->info('Generating types for Eloquent...'); 34 | Artisan::call('typescriptable:eloquent', [ 35 | ], $this->output); 36 | } 37 | 38 | if ($routes) { 39 | $this->info('Generating types for Routes...'); 40 | Artisan::call('typescriptable:routes', [ 41 | ], $this->output); 42 | } 43 | 44 | if ($settings) { 45 | $this->info('Generating types for Settings...'); 46 | Artisan::call('typescriptable:settings', [ 47 | ], $this->output); 48 | } 49 | 50 | $this->info('Generated types.'); 51 | 52 | return self::SUCCESS; 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /src/Commands/TypescriptableEloquentCommand.php: -------------------------------------------------------------------------------- 1 | setAliases([ 17 | 'typescriptable:models', 18 | ]); 19 | 20 | parent::configure(); 21 | } 22 | 23 | public function handle(): int 24 | { 25 | $service = Typescriptable::models(); 26 | $namespaces = []; 27 | 28 | foreach ($service->app()->models() as $model) { 29 | $namespaces[] = [$model->schemaClass()->namespace()]; 30 | } 31 | $this->table(['Models'], $namespaces); 32 | 33 | $this->info('Generated model types.'); 34 | 35 | return self::SUCCESS; 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /src/Commands/TypescriptableRoutesCommand.php: -------------------------------------------------------------------------------- 1 | info('Generated Routes types.'); 19 | 20 | return self::SUCCESS; 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /src/Commands/TypescriptableSettingsCommand.php: -------------------------------------------------------------------------------- 1 | info('Generated settings types.'); 19 | 20 | return self::SUCCESS; 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /src/Typed/Database/DatabaseConversion.php: -------------------------------------------------------------------------------- 1 | phpType = $self->databaseDriver->toPhp($self->databaseType); 34 | $self->castType = $cast; 35 | if ($cast) { 36 | $self->parseCast($cast); 37 | } else { 38 | $self->typescriptType = ParserPhpType::toTypescript($self->phpType); 39 | } 40 | 41 | return $self; 42 | } 43 | 44 | /** 45 | * Get database driver. 46 | */ 47 | public function databaseDriver(): DatabaseDriverEnum 48 | { 49 | return $this->databaseDriver; 50 | } 51 | 52 | /** 53 | * Get database type. 54 | */ 55 | public function databaseType(): string 56 | { 57 | return $this->databaseType; 58 | } 59 | 60 | /** 61 | * Get PHP type. 62 | */ 63 | public function phpType(): string 64 | { 65 | return $this->phpType; 66 | } 67 | 68 | /** 69 | * Get Laravel cast type. 70 | */ 71 | public function castType(): ?string 72 | { 73 | return $this->castType; 74 | } 75 | 76 | /** 77 | * Get TypeScript type. 78 | */ 79 | public function typescriptType(): string 80 | { 81 | return $this->typescriptType; 82 | } 83 | 84 | private function parseDateTimePhpCast(string $cast): self 85 | { 86 | if (! in_array($cast, ['date', 87 | 'datetime', 88 | 'immutable_date', 89 | 'immutable_datetime', 90 | 'timestamp'])) { 91 | return $this; 92 | } 93 | 94 | $this->phpType = '\\DateTime'; 95 | 96 | return $this; 97 | } 98 | 99 | /** 100 | * Convert Laravel cast to TypeScript type. 101 | */ 102 | private function parseCast(string $cast): self 103 | { 104 | if (str_contains($cast, ':')) { 105 | $cast = explode(':', $cast)[0]; 106 | } 107 | 108 | $typescriptCastable = match ($cast) { 109 | 'array' => 'any[]', 110 | 'collection' => 'any[]', 111 | 'encrypted:array' => 'any[]', 112 | 'encrypted:collection' => 'any[]', 113 | 'encrypted:object' => 'any', 114 | 'object' => 'any', 115 | 116 | 'AsStringable::class' => 'string', 117 | 118 | 'boolean' => 'boolean', 119 | 120 | 'date' => 'string', 121 | 'datetime' => 'string', 122 | 'immutable_date' => 'string', 123 | 'immutable_datetime' => 'string', 124 | 'timestamp' => 'string', 125 | 126 | 'decimal' => 'number', 127 | 'double' => 'number', 128 | 'float' => 'number', 129 | 'integer' => 'number', 130 | 'int' => 'number', 131 | 132 | 'encrypted' => 'string', 133 | 'hashed' => 'string', 134 | 'real' => 'string', 135 | 136 | 'string' => 'string', 137 | 138 | default => 'unknown', 139 | }; 140 | 141 | if ($typescriptCastable === 'unknown') { 142 | // enum case 143 | if (str_contains($cast, '\\')) { 144 | $this->phpType = "\\{$cast}"; 145 | $enums = $this->parseEnum($cast); 146 | $this->typescriptType = $this->arrayToTypescriptTypes($enums); 147 | 148 | return $this; 149 | } else { 150 | // attribute or accessor case 151 | return $this; 152 | } 153 | } 154 | 155 | $this->typescriptType = $typescriptCastable; 156 | $this->parseDateTimePhpCast($cast); 157 | 158 | return $this; 159 | } 160 | 161 | /** 162 | * Parse enum. 163 | * 164 | * @param string $namespace Enum namespace. 165 | * @return string[] 166 | */ 167 | private function parseEnum(string $namespace): array 168 | { 169 | $reflect = new \ReflectionClass($namespace); 170 | 171 | $enums = []; 172 | $constants = $reflect->getConstants(); 173 | $constants = array_filter($constants, fn ($value) => is_object($value)); 174 | 175 | foreach ($constants as $name => $enum) { 176 | if ($enum instanceof BackedEnum) { 177 | $enums[$name] = $enum->value; 178 | } elseif ($enum instanceof UnitEnum) { 179 | $enums[$name] = $enum->name; 180 | } 181 | } 182 | 183 | return $enums; 184 | } 185 | 186 | /** 187 | * Convert array to TypeScript types. 188 | */ 189 | private function arrayToTypescriptTypes(array $types): string 190 | { 191 | $typescript = ''; 192 | 193 | foreach ($types as $type) { 194 | $typescript .= " '{$type}' |"; 195 | } 196 | 197 | $typescript = rtrim($typescript, '|'); 198 | 199 | return trim($typescript); 200 | } 201 | } 202 | -------------------------------------------------------------------------------- /src/Typed/Database/Table.php: -------------------------------------------------------------------------------- 1 | getDriverName(), 28 | name: $table 29 | ); 30 | 31 | $self->select = $self->setSelect(); 32 | $self->attributes = $self->setAttributes(); 33 | 34 | return $self; 35 | } 36 | 37 | /** 38 | * @return SchemaModelAttribute[] 39 | */ 40 | public function attributes(): array 41 | { 42 | return $this->attributes; 43 | } 44 | 45 | public function addAttribute(SchemaModelAttribute $attribute): void 46 | { 47 | $this->attributes[$attribute->name()] = $attribute; 48 | } 49 | 50 | public function name(): string 51 | { 52 | return $this->name; 53 | } 54 | 55 | public function driver(): string 56 | { 57 | return $this->driver; 58 | } 59 | 60 | public function select(): string 61 | { 62 | return $this->select; 63 | } 64 | 65 | /** 66 | * @return SchemaModelAttribute[] 67 | */ 68 | private function setAttributes(): array 69 | { 70 | /** @var SchemaModelAttribute[] */ 71 | $attributes = []; 72 | 73 | $driver = match ($this->driver) { 74 | 'mysql' => MysqlColumn::class, 75 | 'mariadb' => MysqlColumn::class, 76 | 'pgsql' => PostgreColumn::class, 77 | 'sqlite' => SqliteColumn::class, 78 | 'sqlsrv' => SqlServerColumn::class, 79 | 'mongodb' => 'mongodb', 80 | default => null, 81 | }; 82 | 83 | if ($driver === null) { 84 | throw new \Exception("Database driver not supported: {$this->driver}"); 85 | } 86 | 87 | $schemaTables = []; 88 | if (intval(app()->version()) >= 11) { 89 | $schemaTables = Schema::getTableListing(); 90 | } else { 91 | $schemaTables = Schema::getConnection()->getDoctrineSchemaManager()->listTableNames(); // @phpstan-ignore-line 92 | } 93 | 94 | if (! in_array($this->name, $schemaTables)) { 95 | return []; 96 | } 97 | 98 | $select = $this->driver === 'mongodb' ? [] : DB::select($this->select); 99 | foreach ($select as $data) { 100 | if ($this->driver === 'mongodb') { 101 | continue; 102 | } 103 | $attribute = $driver::make($data); 104 | $attributes[$attribute->name()] = $attribute; 105 | } 106 | 107 | return $attributes; 108 | } 109 | 110 | private function setSelect(): ?string 111 | { 112 | return match ($this->driver) { 113 | 'mysql' => "SHOW COLUMNS FROM {$this->name}", 114 | 'mariadb' => "SHOW COLUMNS FROM {$this->name}", 115 | 'pgsql' => "SELECT column_name, data_type, is_nullable, column_default FROM information_schema.columns WHERE table_name = '{$this->name}'", 116 | 'sqlite' => "PRAGMA table_info({$this->name})", 117 | 'sqlsrv' => "SELECT COLUMN_NAME, DATA_TYPE, IS_NULLABLE, COLUMN_DEFAULT FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_NAME = '{$this->name}'", 118 | 'mongodb' => null, 119 | default => "SHOW COLUMNS FROM {$this->name}", 120 | }; 121 | } 122 | } 123 | -------------------------------------------------------------------------------- /src/Typed/Database/Types/IColumn.php: -------------------------------------------------------------------------------- 1 | Field, 33 | databaseType: $self->Type, 34 | increments: $self->Extra === 'auto_increment', 35 | nullable: $self->Null === 'YES', 36 | default: $self->Default, 37 | unique: $self->Key === 'UNI', 38 | databaseFields: $data, 39 | ); 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /src/Typed/Database/Types/PostgreColumn.php: -------------------------------------------------------------------------------- 1 | column_name, 29 | databaseType: $self->data_type, 30 | increments: false, 31 | nullable: $self->is_nullable === 'YES', 32 | default: $self->column_default, 33 | databaseFields: $data, 34 | ); 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /src/Typed/Database/Types/SqlServerColumn.php: -------------------------------------------------------------------------------- 1 | COLUMN_NAME, 29 | databaseType: $self->DATA_TYPE, 30 | increments: false, 31 | nullable: $self->IS_NULLABLE === 'YES', 32 | default: $self->COLUMN_DEFAULT, 33 | databaseFields: $data, 34 | ); 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /src/Typed/Database/Types/SqliteColumn.php: -------------------------------------------------------------------------------- 1 | name, 33 | databaseType: $self->type, 34 | increments: $self->pk === 1, 35 | nullable: $self->notnull === 0, 36 | default: $self->dflt_value, 37 | unique: false, 38 | databaseFields: $data, 39 | ); 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /src/Typed/Eloquent/EloquentConfig.php: -------------------------------------------------------------------------------- 1 | modelsPath) { 17 | $this->modelsPath = TypescriptableConfig::eloquentDirectory(); 18 | } 19 | 20 | if (! $this->useParser) { 21 | $this->useParser = TypescriptableConfig::engineEloquent() === 'parser'; 22 | } 23 | 24 | if (! $this->phpPath) { 25 | $this->phpPath = TypescriptableConfig::eloquentPhpPath(); 26 | } 27 | 28 | if (! $this->skipModels) { 29 | $this->skipModels = TypescriptableConfig::eloquentSkip(); 30 | } 31 | 32 | if (! $this->typescriptFilename) { 33 | $this->typescriptFilename = TypescriptableConfig::eloquentFilename(); 34 | } 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /src/Typed/Eloquent/EloquentType.php: -------------------------------------------------------------------------------- 1 | config->useParser) { 31 | $type = new EloquentTypeParser($this->config); 32 | } else { 33 | $type = new EloquentTypeArtisan($this->config); 34 | } 35 | 36 | $type->run(); 37 | 38 | $type->typescript = PrinterEloquentTypescript::make($type->app()->models()); 39 | TypescriptableUtils::print($type->typescript, TypescriptableConfig::setPath($type->config()->typescriptFilename)); 40 | 41 | if ($type->config()->phpPath) { 42 | $printer = PrinterEloquentPhp::make($type->app()->models(), $type->config()->phpPath); 43 | $printer->print(); 44 | } 45 | 46 | return $type; 47 | } 48 | 49 | public function app(): SchemaApp 50 | { 51 | if (! $this->app) { 52 | $this->app = SchemaApp::make($this->config->modelsPath, $this->config->phpPath); 53 | } 54 | 55 | return $this->app; 56 | } 57 | 58 | public function config(): EloquentConfig 59 | { 60 | return $this->config; 61 | } 62 | 63 | public function typescript(): ?string 64 | { 65 | return $this->typescript; 66 | } 67 | 68 | /** 69 | * Parse MongoDB model. 70 | */ 71 | protected function parseMongoDb(SchemaClass $schemaClass, string $driver): ?array 72 | { 73 | if ($driver !== 'mongodb') { 74 | return null; 75 | } 76 | 77 | $fillable = ParserModelFillable::make($schemaClass); 78 | 79 | return $fillable->attributes(); 80 | } 81 | } 82 | -------------------------------------------------------------------------------- /src/Typed/Eloquent/EloquentTypeArtisan.php: -------------------------------------------------------------------------------- 1 | app = SchemaApp::make($this->config->modelsPath, $this->config->phpPath); 16 | 17 | $collect = SchemaCollection::make($this->config->modelsPath, $this->config->skipModels); 18 | $schemas = $collect->onlyModels(); 19 | 20 | $this->app->parseBaseNamespace($schemas); 21 | 22 | $models = $this->parseModels($schemas); 23 | $this->app->setModels($models); 24 | 25 | return $this; 26 | } 27 | 28 | /** 29 | * @param SchemaClass[] $schemas 30 | * @return SchemaModel[] 31 | */ 32 | private function parseModels(array $schemas): array 33 | { 34 | $models = []; 35 | foreach ($schemas as $schema) { 36 | Artisan::call('model:show', [ 37 | 'model' => $schema->namespace(), 38 | '--json' => true, 39 | ]); 40 | 41 | $models[$schema->namespace()] = SchemaModel::make(json_decode(Artisan::output(), true), $schema); 42 | $attributes = $this->parseMongoDb($schema, $this->app->driver()); 43 | if ($attributes) { 44 | $models[$schema->namespace()]->setAttributes($attributes); 45 | } 46 | } 47 | 48 | return $models; 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /src/Typed/Eloquent/EloquentTypeParser.php: -------------------------------------------------------------------------------- 1 | app = SchemaApp::make($this->config->modelsPath, $this->config->phpPath)->enableParer(); 19 | 20 | $collect = SchemaCollection::make($this->config->modelsPath, $this->config->skipModels); 21 | $schemas = $collect->onlyModels(); 22 | 23 | $this->app->parseBaseNamespace($schemas); 24 | 25 | $models = $this->parseModels($schemas); 26 | $this->app->setModels($models); 27 | 28 | return $this; 29 | } 30 | 31 | /** 32 | * @param SchemaClass[] $schemas 33 | * @return SchemaModel[] 34 | */ 35 | private function parseModels(array $schemas): array 36 | { 37 | $models = []; 38 | foreach ($schemas as $schema) { 39 | $namespace = $schema->namespace(); 40 | /** @var Model */ 41 | $instance = new $namespace; 42 | $tableName = $instance->getTable(); 43 | 44 | if ($this->app->databasePrefix()) { 45 | $tableName = $this->app->databasePrefix().$tableName; 46 | } 47 | 48 | $table = $this->parseModel($instance, $this->app->databasePrefix()); 49 | $relations = $this->parseRelations($schema->reflect()); 50 | 51 | $models[$schema->namespace()] = SchemaModel::make([ 52 | 'class' => $schema->namespace(), 53 | 'database' => $this->app->driver(), 54 | 'table' => $tableName, 55 | 'attributes' => $table->attributes(), 56 | 'relations' => $relations, 57 | ], $schema); 58 | 59 | $attributes = $this->parseMongoDb($schema, $this->app->driver()); 60 | if ($attributes) { 61 | $models[$schema->namespace()]->setAttributes($attributes); 62 | } 63 | } 64 | 65 | return $models; 66 | } 67 | 68 | private function parseModel(Model $model, ?string $prefix): Table 69 | { 70 | $table = Table::make($prefix.$model->getTable()); 71 | 72 | $fillables = $model->getFillable(); 73 | $hiddens = $model->getHidden(); 74 | $casts = $model->getCasts(); 75 | $accessors = $this->parseAccessors($model); 76 | 77 | foreach ($table->attributes() as $attribute) { 78 | if (in_array($attribute->name(), $fillables)) { 79 | $attribute->isFillable(); 80 | } 81 | 82 | if (in_array($attribute->name(), $hiddens)) { 83 | $attribute->isHidden(); 84 | } 85 | 86 | if (array_key_exists($attribute->name(), $casts)) { 87 | $attribute->setCast($casts[$attribute->name()]); 88 | } 89 | } 90 | 91 | foreach ($accessors as $accessor) { 92 | $table->addAttribute($accessor); 93 | } 94 | 95 | return $table; 96 | } 97 | 98 | /** 99 | * @return SchemaModelAttribute[] 100 | */ 101 | private function parseAccessors(Model $model): array 102 | { 103 | $accessors = []; 104 | foreach ($model->getMutatedAttributes() as $attribute) { 105 | $accessors[] = new SchemaModelAttribute( 106 | name: $attribute, 107 | databaseType: null, 108 | increments: false, 109 | nullable: true, 110 | default: null, 111 | unique: false, 112 | appended: true, 113 | cast: 'accessor', 114 | ); 115 | } 116 | 117 | return $accessors; 118 | } 119 | 120 | private function parseRelations(\ReflectionClass $reflect) 121 | { 122 | $relations = []; 123 | 124 | foreach ($reflect->getMethods() as $method) { 125 | if (! $method->getReturnType()) { 126 | continue; 127 | } 128 | 129 | $isRelation = str_contains($method->getReturnType(), 'Illuminate\Database\Eloquent\Relations'); 130 | 131 | if (! $isRelation) { 132 | continue; 133 | } 134 | 135 | $relation = ParserRelation::make($method, $this->app()->baseNamespace()); 136 | $relations[$relation->name()] = [ 137 | 'name' => $relation->name(), 138 | 'type' => $relation->type(), 139 | 'related' => $relation->related(), 140 | ]; 141 | } 142 | 143 | return $relations; 144 | } 145 | } 146 | -------------------------------------------------------------------------------- /src/Typed/Eloquent/IEloquentType.php: -------------------------------------------------------------------------------- 1 | reflect()->getMethods() as $method) { 26 | $name = $method->getName(); 27 | $return = $method->getReturnType(); 28 | 29 | $item = new self( 30 | field: $name, 31 | phpType: 'string', 32 | ); 33 | 34 | if ($name === 'getMediableAttribute') { 35 | continue; 36 | } 37 | 38 | if (! $return instanceof \ReflectionNamedType) { 39 | continue; 40 | } 41 | 42 | $type = $return->getName(); 43 | 44 | // New attributes 45 | if ($type === 'Illuminate\Database\Eloquent\Casts\Attribute') { 46 | $item = $item->make($item, $method); 47 | $items[$item->field] = $item; 48 | 49 | continue; 50 | } 51 | 52 | // Legacy attributes 53 | if (str_starts_with($name, 'get') && str_ends_with($name, 'Attribute') && $name !== 'getAttribute') { 54 | $item->isLegacy = true; 55 | $item = $item->make($item, $method); 56 | $items[$item->field] = $item; 57 | } 58 | } 59 | 60 | return $items; 61 | } 62 | 63 | private function make(ParserAccessor $item, \ReflectionMethod $method): ParserAccessor 64 | { 65 | $field = str_replace('Attribute', '', str_replace('get', '', $item->field)); 66 | $field = Str::snake($field); 67 | 68 | $doc = $method->getDocComment(); 69 | $return = null; 70 | 71 | $regex = '/(?m)@return *\K(?>(\S+) *)??(\S+)$/'; 72 | 73 | if (preg_match($regex, $doc, $matches)) { 74 | $return = $matches[0]; 75 | } 76 | 77 | $type = $method->getReturnType(); 78 | 79 | if ($return) { 80 | $type = $return; 81 | } 82 | 83 | if ($type instanceof \ReflectionNamedType) { 84 | $type = $type->getName(); 85 | } 86 | 87 | $item->field = $field; 88 | 89 | if (str_contains($type, 'Attribute<')) { 90 | $type = str_replace('Attribute<', '', $type); 91 | $type = str_replace('>', '', $type); 92 | } 93 | 94 | if ($type) { 95 | $item->phpType = $type; 96 | 97 | $parser = ParserPhpType::make($type); 98 | $item->typescriptType = $parser->typescriptType(); 99 | } 100 | 101 | if (str_contains($type, '[]') || str_contains($type, 'Collection') || str_contains($type, 'array')) { 102 | $item->isArray = true; 103 | } 104 | 105 | if (str_contains($type, 'boolean')) { 106 | $item->phpType = 'bool'; 107 | } 108 | 109 | $advanced = $this->isAdvancedArray($item->phpType); 110 | 111 | if ($advanced) { 112 | $item->phpType = "{$advanced}[]"; 113 | 114 | $parser = ParserPhpType::make($advanced); 115 | $item->typescriptType = $parser->typescriptType().'[]'; 116 | } 117 | 118 | return $item; 119 | } 120 | 121 | private function isAdvancedArray(string $type): string|false 122 | { 123 | $regex = '/array<(.*?)\>/'; 124 | 125 | if (preg_match($regex, $type, $matches)) { 126 | $matches = $matches[1]; 127 | $matches = explode(',', $matches); 128 | 129 | return $matches[0]; 130 | } 131 | 132 | $regex = '/(.*?)\[]/'; 133 | 134 | if (preg_match($regex, $type, $matches)) { 135 | return $matches[1]; 136 | } 137 | 138 | return false; 139 | } 140 | } 141 | -------------------------------------------------------------------------------- /src/Typed/Eloquent/Parser/ParserModelFillable.php: -------------------------------------------------------------------------------- 1 | namespace(), 25 | ); 26 | 27 | $model = $self->namespace; 28 | /** @var Model */ 29 | $instance = new $model; 30 | 31 | $key = $instance->getKeyName(); 32 | $casts = $instance->getCasts(); 33 | 34 | if ($key) { 35 | $self->attributes[$key] = SchemaModelAttribute::make('mongodb', new SchemaModelAttribute( 36 | name: $key, 37 | databaseType: 'string', 38 | increments: false, 39 | nullable: false, 40 | default: null, 41 | unique: true, 42 | fillable: false, 43 | hidden: false, 44 | appended: false, 45 | cast: $casts[$key] ?? null, 46 | )); 47 | } 48 | 49 | foreach ($instance->getHidden() as $field) { 50 | $self->attributes[$field] = SchemaModelAttribute::make('mongodb', new SchemaModelAttribute( 51 | name: $field, 52 | databaseType: 'string', 53 | increments: false, 54 | nullable: true, 55 | default: null, 56 | unique: false, 57 | fillable: true, 58 | hidden: true, 59 | appended: false, 60 | cast: $casts[$field] ?? null, 61 | )); 62 | } 63 | 64 | foreach ($instance->getFillable() as $field) { 65 | $self->attributes[$field] = SchemaModelAttribute::make('mongodb', new SchemaModelAttribute( 66 | name: $field, 67 | databaseType: 'string', 68 | increments: false, 69 | nullable: true, 70 | default: null, 71 | unique: false, 72 | fillable: true, 73 | hidden: false, 74 | appended: false, 75 | cast: $casts[$field] ?? null, 76 | )); 77 | } 78 | 79 | return $self; 80 | } 81 | 82 | /** 83 | * Get namespace. 84 | */ 85 | public function namespace(): string 86 | { 87 | return $this->namespace; 88 | } 89 | 90 | /** 91 | * Get attributes. 92 | * 93 | * @return SchemaModelAttribute[] 94 | */ 95 | public function attributes(): array 96 | { 97 | return $this->attributes; 98 | } 99 | } 100 | -------------------------------------------------------------------------------- /src/Typed/Eloquent/Parser/ParserPhpType.php: -------------------------------------------------------------------------------- 1 | parse(); 18 | 19 | return $self; 20 | } 21 | 22 | /** 23 | * Convert PHP type to TypeScript type. 24 | */ 25 | public static function toTypescript(string $phpType): string 26 | { 27 | return match ($phpType) { 28 | 'int' => 'number', 29 | 'float' => 'number', 30 | 'string' => 'string', 31 | 'bool' => 'boolean', 32 | 'true' => 'boolean', 33 | 'false' => 'boolean', 34 | 'array' => 'any[]', 35 | 'object' => 'any', 36 | 'mixed' => 'any', 37 | 'null' => 'undefined', 38 | 'void' => 'void', 39 | 'callable' => 'Function', 40 | 'iterable' => 'any[]', 41 | 'DateTime' => 'Date', 42 | 'DateTimeInterface' => 'Date', 43 | 'Carbon' => 'Date', 44 | 'Model' => 'any', 45 | default => 'any', // skip `Illuminate\Database\Eloquent\Casts\Attribute` 46 | }; 47 | } 48 | 49 | public function phpType(): string 50 | { 51 | return $this->phpType; 52 | } 53 | 54 | public function typescriptType(): string 55 | { 56 | return $this->typescriptType; 57 | } 58 | 59 | public function isArray(): bool 60 | { 61 | return $this->isArray; 62 | } 63 | 64 | public function isAdvanced(): bool 65 | { 66 | return $this->isAdvanced; 67 | } 68 | 69 | private function parse(): static 70 | { 71 | if (str_contains($this->phpType, 'date')) { 72 | $this->typescriptType = 'DateTime'; 73 | } 74 | 75 | if (str_contains($this->phpType, 'array<')) { 76 | $regex = '/array<[^,]+,[^>]+>/'; 77 | preg_match($regex, $this->phpType, $matches); 78 | 79 | if (count($matches) > 0) { 80 | $this->isAdvanced = true; 81 | $type = str_replace('array<', '', $this->phpType); 82 | $type = str_replace('>', '', $type); 83 | 84 | $types = explode(',', $type); 85 | $type = ''; 86 | 87 | $keyType = trim($types[0]); 88 | $valueType = trim($types[1]); 89 | 90 | $keyType = self::toTypescript($this->phpType); 91 | $valueType = self::toTypescript($this->phpType); 92 | 93 | $this->typescriptType = "{[key: {$keyType}]: {$valueType}}"; 94 | 95 | return $this; 96 | } 97 | } 98 | 99 | if (str_contains($this->phpType, '[]')) { 100 | $this->isArray = true; 101 | $type = str_replace('[]', '', $this->phpType); 102 | } 103 | 104 | if (str_contains($this->phpType, 'array<')) { 105 | $this->isArray = true; 106 | $type = str_replace('array<', '', $this->phpType); 107 | $type = str_replace('>', '', $type); 108 | } 109 | 110 | $this->typescriptType = self::toTypescript($this->phpType); 111 | 112 | if ($this->isArray) { 113 | $this->typescriptType .= '[]'; 114 | } 115 | 116 | return $this; 117 | } 118 | } 119 | -------------------------------------------------------------------------------- /src/Typed/Eloquent/Parser/ParserRelation.php: -------------------------------------------------------------------------------- 1 | getName(), 21 | ); 22 | 23 | $lastLine = $self->getLastLine($method); 24 | $self->type = $self->parseReturnType($method); 25 | $self->relatedBase = $self->parseRelationModel($lastLine); 26 | $self->related = $defaultNamespace.$self->relatedBase; 27 | 28 | $lastChar = substr($self->related, -1); 29 | if ($lastChar === '\\') { 30 | $self->parseExternalClass($lastLine, $method); 31 | } 32 | 33 | if ($self->related === null) { 34 | $self->related = $method->getDeclaringClass()->name; 35 | } 36 | 37 | if ($self->name === 'author') { 38 | $lines = $self->getUseLines($method); 39 | foreach ($lines as $line) { 40 | // remove last char 41 | $line = substr($line, 0, -1); 42 | $lastWord = explode('\\', $line); 43 | $lastWord = end($lastWord); 44 | 45 | if ($lastWord === $self->relatedBase) { 46 | $line = str_replace('use ', '', $line); 47 | $self->related = $line; 48 | } 49 | } 50 | } 51 | 52 | return $self; 53 | } 54 | 55 | public function name(): string 56 | { 57 | return $this->name; 58 | } 59 | 60 | public function type(): ?string 61 | { 62 | return $this->type; 63 | } 64 | 65 | public function related(): ?string 66 | { 67 | return $this->related; 68 | } 69 | 70 | private function parseExternalClass(string $lastLine, ReflectionMethod $method): void 71 | { 72 | $this->related = null; 73 | $external = $this->parseNotInternalClass($lastLine); 74 | 75 | $reflect = $method->getDeclaringClass()->newInstance(); 76 | $externalMethod = str_replace('$this->', '', $external); 77 | $externalMethod = str_replace('()', '', $externalMethod); 78 | if (method_exists($reflect, $externalMethod)) { 79 | try { 80 | $external = $reflect->{$externalMethod}(); 81 | if ($external) { 82 | $this->related = $external; 83 | } 84 | } catch (\Throwable $th) { 85 | //throw $th; 86 | } 87 | } 88 | } 89 | 90 | private function parseReturnType(\ReflectionMethod $method): string 91 | { 92 | $reflectionNamedType = $method->getReturnType(); 93 | $returnType = 'BelongsToMany'; 94 | 95 | if ($reflectionNamedType instanceof \ReflectionNamedType) { 96 | $reflectionNamedTypeName = $reflectionNamedType->getName(); 97 | if (str_contains($reflectionNamedTypeName, '\\')) { 98 | $returnTypeFull = explode('\\', $reflectionNamedTypeName); 99 | $returnType = end($returnTypeFull); 100 | } 101 | } 102 | 103 | return $returnType; 104 | } 105 | 106 | private function parseRelationModel(string $lastLine): ?string 107 | { 108 | $type = null; 109 | 110 | $regex = '/\w+::class/'; 111 | if (preg_match($regex, $lastLine, $matches)) { 112 | $type = $matches[0]; 113 | $type = str_replace('::class', '', $type); 114 | } 115 | 116 | return $type; 117 | } 118 | 119 | private function parseNotInternalClass(string $lastLine): ?string 120 | { 121 | if (preg_match('/\((.*?)\)/', $lastLine, $matches)) { 122 | $type = $matches[1]; 123 | $lastChar = substr($type, -1); 124 | if ($lastChar === '(') { 125 | $type = "{$type})"; 126 | } 127 | 128 | return $type; 129 | } 130 | 131 | return null; 132 | } 133 | 134 | private function getLastLine(\ReflectionMethod $method): string 135 | { 136 | $startLine = $method->getStartLine(); 137 | $endLine = $method->getEndLine(); 138 | 139 | $contents = file($method->getFileName()); 140 | $lines = []; 141 | 142 | for ($i = $startLine; $i < $endLine; $i++) { 143 | $lines[] = $contents[$i]; 144 | } 145 | 146 | $removeChars = ['{', '}', ';', '"', "'", "\n", "\r", "\t", '']; 147 | $lines = array_map(fn ($line) => str_replace($removeChars, '', $line), $lines); 148 | $lines = array_map(fn ($line) => trim($line), $lines); 149 | $lines = array_filter($lines, fn ($line) => ! empty($line)); 150 | $line = implode(' ', $lines); 151 | 152 | return $line; 153 | } 154 | 155 | private function getUseLines(\ReflectionMethod $method): array 156 | { 157 | $lines = []; 158 | $contents = file($method->getFileName()); 159 | 160 | foreach ($contents as $line) { 161 | if (str_contains($line, 'use ')) { 162 | $lines[] = $line; 163 | } 164 | } 165 | 166 | // trim the lines 167 | $lines = array_map(fn ($line) => trim($line), $lines); 168 | // remove carriage return 169 | $lines = array_map(fn ($line) => str_replace("\n", '', $line), $lines); 170 | 171 | return $lines; 172 | } 173 | } 174 | -------------------------------------------------------------------------------- /src/Typed/Eloquent/Printer/PrinterEloquentPhp.php: -------------------------------------------------------------------------------- 1 | */ 11 | public array $content; 12 | 13 | protected function __construct( 14 | public string $path, 15 | ) {} 16 | 17 | /** 18 | * @param SchemaModel[] $models 19 | */ 20 | public static function make(array $models, string $path): self 21 | { 22 | $self = new self($path); 23 | 24 | foreach ($models as $modelNamespace => $model) { 25 | $content = []; 26 | 27 | $content[] = 'schemaClass()?->fullname()}"; 33 | $content[] = '{'; 34 | 35 | // $count = count($eloquent); 36 | $i = 0; 37 | foreach ($model->attributes() as $attribute) { 38 | $type = $attribute->phpType(); 39 | // handle advanced types 40 | if (str_contains($type, '[]')) { 41 | $type = 'array'; 42 | $content[] = " /** @var {$attribute->phpType()} */"; 43 | } 44 | 45 | // handle `Attribute` type 46 | if (str_contains($type, 'Illuminate\\')) { 47 | $type = "\\{$type}"; 48 | } 49 | 50 | $content[] = " public {$type} \${$attribute->name()};\n"; 51 | } 52 | 53 | foreach ($model->relations() as $relation) { 54 | if ($relation->isMany()) { 55 | $type = "{$relation->phpType()}"; 56 | 57 | // handle package relations type 58 | if (! $relation->isInternal()) { 59 | $type = "\\{$type}"; 60 | } 61 | 62 | $content[] = " /** @var {$type} */"; 63 | $content[] = " public array \${$relation->name()};\n"; 64 | } else { 65 | $type = "{$relation->phpType()}"; 66 | if (! $relation->isInternal()) { 67 | $type = "\\{$type}"; 68 | } 69 | $content[] = " public {$type} \${$relation->name()};\n"; 70 | } 71 | 72 | } 73 | 74 | $content[] = '}'; 75 | $content[] = ''; 76 | 77 | $self->content["{$model->schemaClass()?->fullname()}.php"] = implode(PHP_EOL, $content); 78 | } 79 | 80 | return $self; 81 | } 82 | 83 | public function print(bool $delete = false): void 84 | { 85 | if (! File::exists($this->path)) { 86 | File::makeDirectory($this->path); 87 | } 88 | 89 | foreach ($this->content as $name => $content) { 90 | $path = "{$this->path}/{$name}"; 91 | if ($delete) { 92 | File::delete($path); 93 | } 94 | 95 | File::put($path, $content); 96 | } 97 | } 98 | } 99 | -------------------------------------------------------------------------------- /src/Typed/Eloquent/Printer/PrinterEloquentTypescript.php: -------------------------------------------------------------------------------- 1 | $model) { 26 | $contents[] = " export interface {$model->schemaClass()?->fullname()} {"; 27 | 28 | foreach ($model->attributes() as $attributeName => $attribute) { 29 | $field = $attribute->nullable() ? "{$attributeName}?" : $attributeName; 30 | $contents[] = " {$field}: {$attribute->typescriptType()}"; 31 | } 32 | 33 | foreach ($model->relations() as $relationName => $relation) { 34 | $contents[] = " {$relationName}?: {$relation->typescriptType()}"; 35 | } 36 | 37 | $contents[] = ' }'; 38 | } 39 | 40 | $contents[] = '}'; 41 | $contents[] = ''; 42 | 43 | if (TypescriptableConfig::eloquentPaginate()) { 44 | $contents[] = 'declare namespace App {'; 45 | $contents[] = LaravelPaginateType::make(); 46 | $contents[] = '}'; 47 | } 48 | $contents[] = ''; 49 | 50 | return implode(PHP_EOL, $contents); 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /src/Typed/Eloquent/Schemas/Model/SchemaModel.php: -------------------------------------------------------------------------------- 1 | SchemaModelAttribute::make($self->driver, $item), $data['attributes'] ?? []) as $attribute) { 40 | $self->attributes[$attribute->name()] = $attribute; 41 | } 42 | 43 | foreach (array_map(fn ($item) => SchemaModelRelation::make($item), $data['relations'] ?? []) as $relation) { 44 | $self->relations[$relation->name()] = $relation; 45 | } 46 | 47 | $self->typescriptModelName = $self->schemaClass->fullname(); 48 | 49 | return $self; 50 | } 51 | 52 | public function schemaClass(): ?SchemaClass 53 | { 54 | return $this->schemaClass; 55 | } 56 | 57 | public function namespace(): string 58 | { 59 | return $this->namespace; 60 | } 61 | 62 | public function driver(): string 63 | { 64 | return $this->driver; 65 | } 66 | 67 | public function table(): string 68 | { 69 | return $this->table; 70 | } 71 | 72 | public function policy(): mixed 73 | { 74 | return $this->policy; 75 | } 76 | 77 | /** 78 | * @return SchemaModelAttribute[] 79 | */ 80 | public function attributes(): array 81 | { 82 | return $this->attributes; 83 | } 84 | 85 | public function getAttribute(string $name): ?SchemaModelAttribute 86 | { 87 | return $this->attributes[$name] ?? null; 88 | } 89 | 90 | public function setAttribute(SchemaModelAttribute $attribute): self 91 | { 92 | $this->attributes[$attribute->name()] = $attribute; 93 | 94 | return $this; 95 | } 96 | 97 | /** 98 | * @param SchemaModelAttribute[] $attributes 99 | */ 100 | public function setAttributes(array $attributes): self 101 | { 102 | $this->attributes = [ 103 | ...$this->attributes, 104 | ...$attributes, 105 | ]; 106 | 107 | return $this; 108 | } 109 | 110 | public function updateAccessor(ParserAccessor $accessor): self 111 | { 112 | $attribute = $this->attributes[$accessor->field] ?? null; 113 | if ($attribute) { 114 | $attribute->setPhpType($accessor->phpType); 115 | $attribute->setTypescriptType($accessor->typescriptType); 116 | } 117 | 118 | return $this; 119 | } 120 | 121 | /** 122 | * @return SchemaModelRelation[] 123 | */ 124 | public function relations(): array 125 | { 126 | return $this->relations; 127 | } 128 | 129 | public function getRelation(string $name): ?SchemaModelRelation 130 | { 131 | return $this->relations[$name] ?? null; 132 | } 133 | 134 | public function observers(): array 135 | { 136 | return $this->observers; 137 | } 138 | 139 | public function typescriptModelName(): ?string 140 | { 141 | return $this->typescriptModelName; 142 | } 143 | } 144 | -------------------------------------------------------------------------------- /src/Typed/Eloquent/Schemas/Model/SchemaModelAttribute.php: -------------------------------------------------------------------------------- 1 | $data Attribute data. 30 | */ 31 | public static function make(string $driver, array|SchemaModelAttribute $data): self 32 | { 33 | if ($data instanceof self) { 34 | $types = DatabaseConversion::make($driver, $data->databaseType, $data->cast); 35 | $data->phpType = $types->phpType(); 36 | $data->typescriptType = $types->typescriptType(); 37 | 38 | return $data; 39 | } 40 | 41 | $self = new self( 42 | $data['name'] ?? null, 43 | $data['type'] ?? null, 44 | $data['increments'] ?? null, 45 | $data['nullable'] ?? true, 46 | $data['default'] ?? null, 47 | $data['unique'] ?? false, 48 | $data['fillable'] ?? false, 49 | $data['hidden'] ?? false, 50 | $data['appended'] ?? false, 51 | $data['cast'] ?? null, 52 | ); 53 | 54 | if ($self->default === 'NULL') { 55 | $self->default = null; 56 | } 57 | 58 | $types = DatabaseConversion::make($driver, $self->databaseType, $self->cast); 59 | $self->phpType = $types->phpType(); 60 | $self->typescriptType = $types->typescriptType(); 61 | 62 | return $self; 63 | } 64 | 65 | public function name(): string 66 | { 67 | return $this->name; 68 | } 69 | 70 | public function databaseType(): ?string 71 | { 72 | return $this->databaseType; 73 | } 74 | 75 | public function increments(): bool 76 | { 77 | return $this->increments ?? false; 78 | } 79 | 80 | public function nullable(): bool 81 | { 82 | return $this->nullable ?? false; 83 | } 84 | 85 | public function default(): mixed 86 | { 87 | return $this->default; 88 | } 89 | 90 | public function unique(): bool 91 | { 92 | return $this->unique ?? false; 93 | } 94 | 95 | public function fillable(): bool 96 | { 97 | return $this->fillable ?? false; 98 | } 99 | 100 | public function isFillable(): self 101 | { 102 | $this->fillable = true; 103 | 104 | return $this; 105 | } 106 | 107 | public function hidden(): bool 108 | { 109 | return $this->hidden ?? false; 110 | } 111 | 112 | public function isHidden(): self 113 | { 114 | $this->hidden = true; 115 | 116 | return $this; 117 | } 118 | 119 | public function appended(): bool 120 | { 121 | return $this->appended ?? false; 122 | } 123 | 124 | public function isAppended(): self 125 | { 126 | $this->appended = true; 127 | 128 | return $this; 129 | } 130 | 131 | public function cast(): ?string 132 | { 133 | return $this->cast; 134 | } 135 | 136 | public function setCast(string $cast): self 137 | { 138 | $this->cast = $cast; 139 | 140 | return $this; 141 | } 142 | 143 | public function phpType(): ?string 144 | { 145 | return $this->phpType; 146 | } 147 | 148 | public function setPhpType(string $phpType): self 149 | { 150 | $this->phpType = $phpType; 151 | 152 | return $this; 153 | } 154 | 155 | public function typescriptType(): ?string 156 | { 157 | return $this->typescriptType; 158 | } 159 | 160 | public function setTypescriptType(string $typescriptType): self 161 | { 162 | $this->typescriptType = $typescriptType; 163 | 164 | return $this; 165 | } 166 | } 167 | -------------------------------------------------------------------------------- /src/Typed/Eloquent/Schemas/Model/SchemaModelRelation.php: -------------------------------------------------------------------------------- 1 | snakeCaseName = $self->toSnakeCaseName($self->name); 26 | $self->isMany = $self->relationTypeisMany($self->laravelType); 27 | $self->phpType = $self->relatedToModel; 28 | if ($self->isMany) { 29 | $self->phpType = "{$self->relatedToModel}[]"; 30 | } 31 | 32 | return $self; 33 | } 34 | 35 | public function name(): string 36 | { 37 | return $this->name; 38 | } 39 | 40 | public function laravelType(): ?string 41 | { 42 | return $this->laravelType; 43 | } 44 | 45 | public function relatedToModel(): ?string 46 | { 47 | return $this->relatedToModel; 48 | } 49 | 50 | public function snakeCaseName(): string 51 | { 52 | return $this->snakeCaseName; 53 | } 54 | 55 | public function isInternal(): bool 56 | { 57 | return $this->isInternal; 58 | } 59 | 60 | public function isMany(): bool 61 | { 62 | return $this->isMany; 63 | } 64 | 65 | public function phpType(): string 66 | { 67 | return $this->phpType; 68 | } 69 | 70 | public function setPhpType(string $phpType): self 71 | { 72 | $this->phpType = $phpType; 73 | if ($this->isMany) { 74 | $this->phpType = "{$phpType}[]"; 75 | } 76 | 77 | return $this; 78 | } 79 | 80 | public function typescriptType(): string 81 | { 82 | return $this->typescriptType; 83 | } 84 | 85 | public function setTypescriptType(string $typescriptType, string $baseNamespace): self 86 | { 87 | // e.g. `Spatie\MediaLibrary\MediaCollections\Models\Media` 88 | if (! str_contains($this->relatedToModel, $baseNamespace)) { 89 | $this->isInternal = false; 90 | } 91 | 92 | if ($this->isInternal) { 93 | if ($typescriptType === 'any') { 94 | $this->typescriptType = 'any'; 95 | } else { 96 | $this->typescriptType = "App.Models.{$typescriptType}"; 97 | } 98 | } else { 99 | $this->typescriptType = $typescriptType; 100 | } 101 | 102 | if ($this->isMany) { 103 | $this->typescriptType = "{$this->typescriptType}[]"; 104 | } 105 | 106 | return $this; 107 | } 108 | 109 | private function toSnakeCaseName(string $string): string 110 | { 111 | $string = preg_replace('/\s+/', '', $string); 112 | $string = preg_replace('/(? $routes 15 | */ 16 | public static function make(Collection $routes): string 17 | { 18 | $self = new self; 19 | 20 | $contents = []; 21 | foreach ($routes as $route) { 22 | $contents[] = $self->typescript($route); 23 | } 24 | 25 | return $self->template(implode("\n", $contents)); 26 | } 27 | 28 | private function template(string $contents): string 29 | { 30 | $head = Typescriptable::head(); 31 | $appUrl = config('app.url'); 32 | 33 | $global = ''; 34 | $addToWindow = ''; 35 | if (TypescriptableConfig::routesAddToWindow()) { 36 | $global = <<<'typescript' 37 | 38 | declare global { 39 | interface Window { 40 | Routes: Record 41 | } 42 | } 43 | 44 | typescript; 45 | $addToWindow = <<<'typescript' 46 | 47 | if (typeof window !== 'undefined') { 48 | window.Routes = Routes 49 | } 50 | 51 | typescript; 52 | } 53 | 54 | return << = { 57 | {$contents} 58 | } 59 | {$global} 60 | const appUrl = '{$appUrl}' 61 | {$addToWindow} 62 | export { Routes, appUrl } 63 | 64 | typescript; 65 | } 66 | 67 | private function typescript(RouteTypeItem $route): string 68 | { 69 | $params = collect($route->parameters()) 70 | ->map(fn (RouteTypeItemParam $param) => "{$param->name()}: 'string',"); 71 | 72 | if ($params->isEmpty()) { 73 | $params = 'params: undefined'; 74 | } else { 75 | $params = $params->join(' '); 76 | if (str_contains($params, ',')) { 77 | $paramsExplode = explode(',', $params); 78 | $paramsExplode = array_map(fn ($param) => trim($param), $paramsExplode); 79 | $paramsExplode = array_filter($paramsExplode, fn ($param) => ! empty($param)); 80 | $paramsExplode = array_map(fn ($param) => "{$param},", $paramsExplode); 81 | $params = implode("\n ", $paramsExplode); 82 | } 83 | $params = <<methods(); 91 | $methods = array_filter($methods, fn ($method) => $method !== 'HEAD'); 92 | $methods = array_map(fn ($method) => "'{$method}'", $methods); 93 | $methods = implode(', ', $methods); 94 | 95 | $name = TypescriptableConfig::routesUsePath() 96 | ? $route->uri() 97 | : $route->name(); 98 | 99 | return <<name()}', 102 | path: '{$route->uri()}', 103 | {$params}, 104 | methods: [{$methods}], 105 | }, 106 | typescript; 107 | } 108 | } 109 | -------------------------------------------------------------------------------- /src/Typed/Route/Printer/PrinterRouteTypes.php: -------------------------------------------------------------------------------- 1 | $routes 16 | */ 17 | protected function __construct( 18 | protected Collection $routes, 19 | protected ?string $routeNames = null, 20 | protected ?string $routePaths = null, 21 | protected ?string $routeParams = null, 22 | ) {} 23 | 24 | /** 25 | * @param Collection $routes 26 | */ 27 | public static function make(Collection $routes): string 28 | { 29 | $self = new self($routes); 30 | 31 | $routeNames = $self->parseRouteNames(); 32 | $self->routeNames = empty($routeNames) ? 'never' : $routeNames; 33 | 34 | $routePaths = $self->parseRoutePaths(); 35 | $self->routePaths = empty($routePaths) ? 'never' : $routePaths; 36 | 37 | $self->routeParams = $self->parseRouteParams(); 38 | 39 | return $self->typescript(); 40 | } 41 | 42 | public function typescript(): string 43 | { 44 | $head = Typescriptable::head(); 45 | 46 | return <<routeNames}; 50 | export type Path = {$this->routePaths}; 51 | export interface Params { 52 | {$this->routeParams} 53 | } 54 | 55 | export type Method = 'HEAD' | 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE' 56 | export type ParamType = string | number | boolean | undefined 57 | export interface Link { name: App.Route.Name; path: App.Route.Path; params?: App.Route.Params[App.Route.Name], methods: App.Route.Method[] } 58 | export interface RouteConfig { 59 | name: T 60 | params?: T extends keyof App.Route.Params ? App.Route.Params[T] : never 61 | } 62 | } 63 | 64 | typescript; 65 | } 66 | 67 | private function parseRouteNames(): string 68 | { 69 | $names = []; 70 | $this->collectRoutes(function (RouteTypeItem $route) use (&$names) { 71 | $name = TypescriptableConfig::routesUsePath() 72 | ? $route->uri() 73 | : $route->name(); 74 | 75 | $names[] = "'{$name}'"; 76 | }); 77 | 78 | $names = array_unique($names); 79 | sort($names); 80 | 81 | return implode(' | ', $names); 82 | } 83 | 84 | private function parseRoutePaths(): string 85 | { 86 | $uri = []; 87 | $this->collectRoutes(function (RouteTypeItem $route) use (&$uri) { 88 | if ($route->uri() === '/') { 89 | $uri[] = "'/'"; 90 | 91 | return; 92 | } 93 | 94 | $uri[] = "'{$route->uri()}'"; 95 | }); 96 | 97 | $uri = array_unique($uri); 98 | sort($uri); 99 | 100 | return implode(' | ', $uri); 101 | } 102 | 103 | private function parseRouteParams(): string 104 | { 105 | return $this->collectRoutes(function (RouteTypeItem $route) { 106 | $hasParams = count($route->parameters()) > 0; 107 | $name = TypescriptableConfig::routesUsePath() 108 | ? $route->uri() 109 | : $route->name(); 110 | 111 | if ($hasParams) { 112 | $params = collect($route->parameters()) 113 | ->map(fn (RouteTypeItemParam $param) => "'{$param->name()}': App.Route.ParamType") 114 | ->join("\n "); 115 | 116 | return " '{$name}': {\n {$params}\n }"; 117 | } else { 118 | return " '{$name}': never"; 119 | } 120 | }, "\n"); 121 | } 122 | 123 | private function collectRoutes(Closure $closure, ?string $join = null): string|Collection 124 | { 125 | $routes = $this->routes->map(fn (RouteTypeItem $route, string $key) => $closure($route, $key)); 126 | 127 | if ($join) { 128 | return $routes->join($join); 129 | } 130 | 131 | return $routes; 132 | } 133 | } 134 | -------------------------------------------------------------------------------- /src/Typed/Route/RouteConfig.php: -------------------------------------------------------------------------------- 1 | filenameTypes) { 22 | $this->filenameTypes = TypescriptableConfig::routesFilename(); 23 | } 24 | 25 | if (! $this->filenameList) { 26 | $this->filenameList = TypescriptableConfig::routesFilenameList(); 27 | } 28 | 29 | if (! $this->printList) { 30 | $this->printList = TypescriptableConfig::routesPrintList(); 31 | } 32 | if (! $this->namesToSkip) { 33 | $this->namesToSkip = TypescriptableConfig::routesSkipName(); 34 | } 35 | 36 | if (! $this->pathsToSkip) { 37 | $this->pathsToSkip = TypescriptableConfig::routesSkipPath(); 38 | } 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /src/Typed/Route/RouteType.php: -------------------------------------------------------------------------------- 1 | $routes 18 | */ 19 | protected function __construct( 20 | protected RouteConfig $config, 21 | protected ?Collection $routes = null, 22 | protected ?string $typescriptList = null, 23 | protected ?string $typescriptTypes = null, 24 | ) {} 25 | 26 | public static function make(RouteConfig $config = new RouteConfig): self 27 | { 28 | $self = new self($config); 29 | $self->routes = $self->parseRoutes(); 30 | 31 | $self->typescriptTypes = PrinterRouteTypes::make($self->routes); 32 | $self->typescriptList = PrinterRouteList::make($self->routes); 33 | 34 | TypescriptableUtils::print($self->typescriptTypes, TypescriptableConfig::setPath($self->config->filenameTypes)); 35 | if ($self->config->printList) { 36 | TypescriptableUtils::print($self->typescriptList, TypescriptableConfig::setPath($self->config->filenameList)); 37 | } 38 | 39 | return $self; 40 | } 41 | 42 | public function config(): RouteConfig 43 | { 44 | return $this->config; 45 | } 46 | 47 | /** 48 | * @return Collection 49 | */ 50 | public function routes(): Collection 51 | { 52 | return $this->routes; 53 | } 54 | 55 | public function typescriptList(): ?string 56 | { 57 | return $this->typescriptList; 58 | } 59 | 60 | public function typescriptTypes(): ?string 61 | { 62 | return $this->typescriptTypes; 63 | } 64 | 65 | /** 66 | * @return Collection 67 | */ 68 | private function parseRoutes(): Collection 69 | { 70 | if ($this->config->json === null) { 71 | Artisan::call(RouteListCommand::class, [ 72 | '--json' => true, 73 | ]); 74 | $json = Artisan::output(); 75 | $this->config->json = json_decode($json, true); 76 | } 77 | 78 | $routes = $this->config->json; 79 | 80 | $skipNames = $this->toSkip($this->config->namesToSkip); 81 | $skipPaths = $this->toSkip($this->config->pathsToSkip); 82 | 83 | if (! $routes) { 84 | return collect(); 85 | } 86 | $routes = array_filter($routes, fn ($route) => $this->filterBy($route, 'uri', $skipPaths)); 87 | $routes = array_filter($routes, fn ($route) => $this->filterBy($route, 'name', $skipNames)); 88 | $routes = array_values($routes); 89 | 90 | $collect = collect(); 91 | foreach ($routes as $route) { 92 | $item = RouteTypeItem::make($route); 93 | $collect->put($item->id(), $item); 94 | } 95 | 96 | return $collect; 97 | } 98 | 99 | /** 100 | * Get the routes to skip. 101 | * 102 | * @param string[] $toSkip 103 | * @return string[] 104 | */ 105 | private function toSkip(array $toSkip): array 106 | { 107 | $items = []; 108 | foreach ($toSkip as $item) { 109 | $item = str_replace('/*', '', $item); 110 | $item = str_replace('.*', '', $item); 111 | array_push($items, $item); 112 | } 113 | 114 | return $items; 115 | } 116 | 117 | private function filterBy(array $route, string $attribute, array $toSkip): bool 118 | { 119 | foreach ($toSkip as $skip) { 120 | if (str_starts_with($route[$attribute], $skip)) { 121 | return false; 122 | } 123 | } 124 | 125 | return true; 126 | } 127 | } 128 | -------------------------------------------------------------------------------- /src/Typed/Route/Schemas/RouteTypeItem.php: -------------------------------------------------------------------------------- 1 | $route 28 | */ 29 | public static function make(array $route): self 30 | { 31 | $method = $route['method'] ?? 'GET|HEAD'; 32 | $methods = explode('|', $method); 33 | $uri = $route['uri'] ?? '/'; 34 | 35 | $self = new self( 36 | domain: $route['domain'] ?? null, 37 | uri: $uri === '/' ? $uri : "/{$uri}", 38 | name: $route['name'] ?? null, 39 | action: $route['action'] ?? null, 40 | methodMain: $methods[0] ?? 'GET', 41 | methods: $methods, 42 | middlewares: $route['middleware'] ?? [], 43 | ); 44 | $self->id = $self->generateId(); 45 | $self->parameters = $self->parseParameters(); 46 | 47 | if (! $self->name) { 48 | $name = str_replace('/', '.', $self->uri); 49 | $self->name = Str::slug($name, '.'); 50 | } 51 | 52 | return $self; 53 | } 54 | 55 | public function domain(): ?string 56 | { 57 | return $this->domain; 58 | } 59 | 60 | public function uri(): string 61 | { 62 | return $this->uri; 63 | } 64 | 65 | public function name(): ?string 66 | { 67 | return $this->name; 68 | } 69 | 70 | public function action(): ?string 71 | { 72 | return $this->action; 73 | } 74 | 75 | public function methodMain(): string 76 | { 77 | return $this->methodMain; 78 | } 79 | 80 | /** 81 | * @return string[] 82 | */ 83 | public function methods(): array 84 | { 85 | return $this->methods; 86 | } 87 | 88 | /** 89 | * @return RouteTypeItemParam[] 90 | */ 91 | public function parameters(): array 92 | { 93 | return $this->parameters; 94 | } 95 | 96 | /** 97 | * @return string[] 98 | */ 99 | public function middlewares(): array 100 | { 101 | return $this->middlewares; 102 | } 103 | 104 | public function id(): string 105 | { 106 | return $this->id; 107 | } 108 | 109 | private function generateId(): string 110 | { 111 | $name = $this->uri; 112 | if ($this->name) { 113 | $name = $this->name; 114 | } 115 | $name = str_replace('/', '.', $name); 116 | $id = strtolower("{$this->methodMain} {$name}"); 117 | 118 | return Str::slug($id, '.'); 119 | } 120 | 121 | /** 122 | * @return RouteTypeItemParam[] 123 | */ 124 | private function parseParameters(): array 125 | { 126 | $params = []; 127 | preg_match_all('/\{([^\}]+)\}/', $this->uri, $params); 128 | 129 | if (array_key_exists(1, $params)) { 130 | $params = $params[1]; 131 | } 132 | 133 | /** @var RouteTypeItemParam[] */ 134 | $items = []; 135 | 136 | foreach ($params as $param) { 137 | $name = $param; 138 | $required = true; 139 | 140 | if (str_contains($param, '?')) { 141 | $name = str_replace('?', '', $param); 142 | $required = false; 143 | } 144 | 145 | $items[] = new RouteTypeItemParam( 146 | name: $name, 147 | required: $required, 148 | ); 149 | } 150 | 151 | return $items; 152 | } 153 | } 154 | -------------------------------------------------------------------------------- /src/Typed/Route/Schemas/RouteTypeItemParam.php: -------------------------------------------------------------------------------- 1 | name; 17 | } 18 | 19 | public function type(): string 20 | { 21 | return $this->type; 22 | } 23 | 24 | public function required(): bool 25 | { 26 | return $this->required; 27 | } 28 | 29 | public function default(): ?string 30 | { 31 | return $this->default; 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /src/Typed/Settings/Printer/PrinterSettings.php: -------------------------------------------------------------------------------- 1 | $items 12 | */ 13 | public static function make(array $items): string 14 | { 15 | $self = new self; 16 | 17 | /** @var string[] */ 18 | $content = []; 19 | 20 | $content = array_merge($content, Typescriptable::TS_HEAD); 21 | $content[] = 'declare namespace App.Settings {'; 22 | 23 | foreach ($items as $model => $item) { 24 | $content[] = " export interface {$model} {"; 25 | foreach ($item->properties() as $field => $property) { 26 | $field = $property->isNullable() ? "{$field}?" : $field; 27 | $content[] = " {$field}: {$property->typescriptType()}"; 28 | } 29 | $content[] = ' }'; 30 | } 31 | $content[] = '}'; 32 | $content[] = ''; 33 | 34 | return implode(PHP_EOL, $content); 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /src/Typed/Settings/Schemas/SettingItemProperty.php: -------------------------------------------------------------------------------- 1 | getType(); 22 | $docComment = $property->getDocComment(); 23 | $typeDoc = null; 24 | if ($docComment) { 25 | $phpDoc = "/**\n* @var array\n*/"; 26 | $regex = '/@var\s+(.*)/'; 27 | preg_match($regex, $phpDoc, $matches); 28 | $typeDoc = $matches[1]; 29 | } 30 | 31 | $name = $property->getName(); 32 | $phpType = 'mixed'; 33 | $isNullable = false; 34 | $isBuiltin = false; 35 | 36 | if ($reflectionPropertyType instanceof ReflectionNamedType) { 37 | $phpType = $reflectionPropertyType->getName(); 38 | $isNullable = $reflectionPropertyType->allowsNull(); 39 | $isBuiltin = $reflectionPropertyType->isBuiltin(); 40 | } 41 | 42 | if ($typeDoc) { 43 | $phpType = $typeDoc; 44 | } 45 | 46 | $self = new self( 47 | name: $name, 48 | phpType: $phpType, 49 | isNullable: $isNullable, 50 | isBuiltin: $isBuiltin, 51 | ); 52 | 53 | $self->typescriptType = ParserPhpType::toTypescript($self->phpType); 54 | 55 | return $self; 56 | } 57 | 58 | public function name(): string 59 | { 60 | return $this->name; 61 | } 62 | 63 | public function phpType(): string 64 | { 65 | return $this->phpType; 66 | } 67 | 68 | public function isNullable(): bool 69 | { 70 | return $this->isNullable; 71 | } 72 | 73 | public function isBuiltin(): bool 74 | { 75 | return $this->isBuiltin; 76 | } 77 | 78 | public function typescriptType(): string 79 | { 80 | return $this->typescriptType; 81 | } 82 | } 83 | -------------------------------------------------------------------------------- /src/Typed/Settings/Schemas/SettingsItem.php: -------------------------------------------------------------------------------- 1 | reflect()->getProperties() as $property) { 22 | if ($class->namespace() === $property->class) { 23 | $item = SettingItemProperty::make($property); 24 | $properties[$item->name()] = $item; 25 | } 26 | } 27 | 28 | $self = new self( 29 | class: $class, 30 | name: $class->name(), 31 | properties: $properties, 32 | ); 33 | 34 | return $self; 35 | } 36 | 37 | public function class(): SchemaClass 38 | { 39 | return $this->class; 40 | } 41 | 42 | public function name(): string 43 | { 44 | return $this->name; 45 | } 46 | 47 | /** 48 | * @return SettingItemProperty[] 49 | */ 50 | public function properties(): array 51 | { 52 | return $this->properties; 53 | } 54 | 55 | public function property(string $name): SettingItemProperty 56 | { 57 | return $this->properties[$name]; 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /src/Typed/Settings/SettingsConfig.php: -------------------------------------------------------------------------------- 1 | filename) { 19 | $this->filename = TypescriptableConfig::settingsFilename(); 20 | } 21 | 22 | if (! $this->directory) { 23 | $this->directory = TypescriptableConfig::settingsDirectory(); 24 | } 25 | 26 | if (! $this->extends) { 27 | $this->extends = TypescriptableConfig::settingsExtends(); 28 | } 29 | 30 | if (! $this->toSkip) { 31 | $this->toSkip = TypescriptableConfig::settingsSkip(); 32 | } 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /src/Typed/Settings/SettingsType.php: -------------------------------------------------------------------------------- 1 | $settings 16 | */ 17 | protected function __construct( 18 | protected SettingsConfig $config, 19 | protected array $settings = [], 20 | protected ?string $typescript = null, 21 | ) {} 22 | 23 | public static function make(SettingsConfig $config = new SettingsConfig): ?self 24 | { 25 | $self = new self($config); 26 | 27 | if (! file_exists($self->config->directory)) { 28 | return null; 29 | } 30 | 31 | $collect = SchemaCollection::make($self->config->directory, $self->config->toSkip); 32 | $settings = array_filter($collect->items(), fn (SchemaClass $item) => $item->extends() === $self->config->extends); 33 | 34 | foreach ($settings as $setting) { 35 | $setting = SettingsItem::make($setting); 36 | $self->settings[$setting->name()] = $setting; 37 | } 38 | 39 | $self->typescript = PrinterSettings::make($self->settings); 40 | TypescriptableUtils::print($self->typescript, TypescriptableConfig::setPath($self->config->filename)); 41 | 42 | return $self; 43 | } 44 | 45 | public function config(): SettingsConfig 46 | { 47 | return $this->config; 48 | } 49 | 50 | /** 51 | * @return array 52 | */ 53 | public function settings(): array 54 | { 55 | return $this->settings; 56 | } 57 | 58 | public function setting(string $name): SettingsItem 59 | { 60 | return $this->settings[$name]; 61 | } 62 | 63 | public function typescript(): ?string 64 | { 65 | return $this->typescript; 66 | } 67 | } 68 | -------------------------------------------------------------------------------- /src/Typed/Utils/EloquentList.php: -------------------------------------------------------------------------------- 1 | path, TypescriptableConfig::eloquentSkip()); 27 | $self->models = $collect->onlyModels(); 28 | 29 | return $self; 30 | } 31 | 32 | /** 33 | * Get the path of the models. 34 | */ 35 | public function path(): string 36 | { 37 | return $this->path; 38 | } 39 | 40 | /** 41 | * Get the list of models. 42 | * 43 | * @return SchemaClass[] 44 | */ 45 | public function models(): array 46 | { 47 | return $this->models; 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /src/Typed/Utils/LaravelPaginateType.php: -------------------------------------------------------------------------------- 1 | { 16 | data: T[] 17 | current_page: number 18 | first_page_url: string 19 | from: number 20 | last_page: number 21 | last_page_url: string 22 | links: App.PaginateLink[] 23 | next_page_url: string 24 | path: string 25 | per_page: number 26 | prev_page_url: string 27 | to: number 28 | total: number 29 | } 30 | export interface ApiPaginate { 31 | data: T[] 32 | links: { 33 | first?: string 34 | last?: string 35 | prev?: string 36 | next?: string 37 | } 38 | meta: { 39 | current_page: number 40 | from: number 41 | last_page: number 42 | links: App.PaginateLink[] 43 | path: string 44 | per_page: number 45 | to: number 46 | total: number 47 | } 48 | } 49 | typescript; 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /src/Typed/Utils/Schema/SchemaClass.php: -------------------------------------------------------------------------------- 1 | getExtension(); 29 | if ($ext !== 'php') { 30 | return null; 31 | } 32 | 33 | $namespace = SchemaClass::fileNamespace($file); 34 | 35 | $instance = null; 36 | try { 37 | $instance = new $namespace; 38 | } catch (\Throwable $th) { 39 | return null; 40 | } 41 | 42 | $reflect = new ReflectionClass($instance); 43 | $parent = $reflect->getParentClass(); 44 | 45 | $nestedPath = str_replace($basePath, '', $file->getPathname()); 46 | $nestedPath = str_replace('.php', '', $nestedPath); 47 | $nestedPath = substr($nestedPath, 1); 48 | $nestedPath = str_replace('/', '', $nestedPath); 49 | 50 | $parser = new self( 51 | basePath: $basePath, 52 | path: $file->getPathname(), 53 | file: $file, 54 | namespace: $namespace, 55 | name: $reflect->getShortName(), 56 | fullname: $nestedPath, 57 | reflect: $reflect, 58 | traits: $reflect->getTraitNames(), 59 | extends: $parent ? $parent->getName() : null, 60 | ); 61 | 62 | $parser->isModel = $reflect->isSubclassOf('Illuminate\Database\Eloquent\Model') || $reflect->isSubclassOf('Illuminate\Foundation\Auth\User'); 63 | 64 | return $parser; 65 | } 66 | 67 | /** 68 | * Get base path. 69 | */ 70 | public function basePath(): string 71 | { 72 | return $this->basePath; 73 | } 74 | 75 | /** 76 | * Get path of the file. 77 | */ 78 | public function path(): string 79 | { 80 | return $this->path; 81 | } 82 | 83 | /** 84 | * Get file info. 85 | */ 86 | public function file(): SplFileInfo 87 | { 88 | return $this->file; 89 | } 90 | 91 | public function namespace(): string 92 | { 93 | return $this->namespace; 94 | } 95 | 96 | public function name(): string 97 | { 98 | return $this->name; 99 | } 100 | 101 | public function fullname(): string 102 | { 103 | return $this->fullname; 104 | } 105 | 106 | public function reflect(): ReflectionClass 107 | { 108 | return $this->reflect; 109 | } 110 | 111 | /** 112 | * @return string[] 113 | */ 114 | public function traits(): array 115 | { 116 | return $this->traits; 117 | } 118 | 119 | public function isModel(): bool 120 | { 121 | return $this->isModel; 122 | } 123 | 124 | public function extends(): ?string 125 | { 126 | return $this->extends; 127 | } 128 | 129 | private static function fileNamespace(SplFileInfo $file): string 130 | { 131 | $path = $file->getPathName(); 132 | $name = $file->getBasename('.php'); 133 | 134 | $ns = null; 135 | $handle = fopen($path, 'r'); 136 | 137 | if ($handle) { 138 | while (($line = fgets($handle)) !== false) { 139 | if (strpos($line, 'namespace') === 0) { 140 | $parts = explode(' ', $line); 141 | $ns = rtrim(trim($parts[1]), ';'); 142 | 143 | break; 144 | } 145 | } 146 | fclose($handle); 147 | } 148 | 149 | return "{$ns}\\{$name}"; 150 | } 151 | } 152 | -------------------------------------------------------------------------------- /src/Typed/Utils/Schema/SchemaCollection.php: -------------------------------------------------------------------------------- 1 | isDir()) { 34 | $model = SchemaClass::make($file, $basePath); 35 | if (! $model) { 36 | continue; 37 | } 38 | 39 | $items[$model->fullname()] = $model; 40 | } 41 | } 42 | 43 | $self->items = $self->skipNamespace($items, $self->skip); 44 | 45 | return $self; 46 | } 47 | 48 | public function basePath(): string 49 | { 50 | return $this->basePath; 51 | } 52 | 53 | /** 54 | * @return SchemaClass[] 55 | */ 56 | public function items(): array 57 | { 58 | return $this->items; 59 | } 60 | 61 | /** 62 | * @return SchemaClass[] 63 | */ 64 | public function onlyModels(): array 65 | { 66 | return array_filter($this->items, fn (SchemaClass $item) => $item->isModel()); 67 | } 68 | 69 | /** 70 | * @param SchemaClass[] $classes 71 | * @param string[] $skip 72 | */ 73 | private function skipNamespace(array $classes, array $skip): array 74 | { 75 | return array_filter($classes, fn (SchemaClass $item) => ! in_array($item->namespace(), $skip)); 76 | } 77 | } 78 | -------------------------------------------------------------------------------- /src/Typed/Utils/TypescriptToPhp.php: -------------------------------------------------------------------------------- 1 | > $raw 11 | * @param array $classes 12 | */ 13 | protected function __construct( 14 | protected string $path, 15 | protected array $raw = [], 16 | protected array $classes = [], 17 | ) {} 18 | 19 | public static function make(string $path): self 20 | { 21 | $self = new self($path); 22 | 23 | $className = null; 24 | $self->readLineByLine($self->path, function (string $line, int $lineNumber) use ($self, &$className) { 25 | $line = trim($line); 26 | 27 | if ($self->isType($line)) { 28 | $className = $self->isType($line); 29 | $self->raw[$className] = []; 30 | } 31 | 32 | $self->parseProperty($line, $className); 33 | }); 34 | 35 | foreach ($self->raw as $className => $properties) { 36 | $tsClass = TypescriptClass::make($className, $properties); 37 | $self->classes[$tsClass->name()] = $tsClass; 38 | } 39 | 40 | return $self; 41 | } 42 | 43 | /** 44 | * @return array> 45 | */ 46 | public function raw(): array 47 | { 48 | return $this->raw; 49 | } 50 | 51 | /** 52 | * @return array 53 | */ 54 | public function classes(): array 55 | { 56 | return $this->classes; 57 | } 58 | 59 | /** 60 | * @return array 61 | */ 62 | public function onlyModels(): array 63 | { 64 | $additonalClasses = [ 65 | 'PaginateLink', 66 | 'Paginate', 67 | 'ApiPaginate', 68 | ]; 69 | 70 | $items = []; 71 | foreach ($this->classes as $key => $value) { 72 | if (in_array($key, $additonalClasses)) { 73 | continue; 74 | } 75 | 76 | $items[$key] = $value; 77 | } 78 | 79 | return $items; 80 | } 81 | 82 | private function parseProperty(string $line, ?string $className): void 83 | { 84 | if (! $className) { 85 | return; 86 | } 87 | 88 | $property = explode(':', $line); 89 | if (count($property) !== 2) { 90 | return; 91 | } 92 | 93 | $key = trim($property[0]); 94 | $value = trim($property[1]); 95 | $isNullable = str_contains($key, '?'); 96 | 97 | $key = str_replace('?', '', $key); 98 | $value = str_replace(';', '', $value); 99 | $this->raw[$className][$key] = [ 100 | 'type' => $value, 101 | 'nullable' => $isNullable, 102 | ]; 103 | } 104 | 105 | private function isType(string $content): string|false 106 | { 107 | $regex = '/^export\s+interface\s+([A-Za-z0-9]+)(?:<.*>)?\s*{/'; 108 | 109 | if (preg_match($regex, $content, $matches)) { 110 | $className = $matches[1]; 111 | 112 | return $className; 113 | } 114 | 115 | return false; 116 | } 117 | 118 | /** 119 | * @param Closure $closure function(string $line, int $lineNumber): void 120 | */ 121 | private function readLineByLine(string $path, Closure $closure) 122 | { 123 | $contents = file_get_contents($path); 124 | $lines = explode("\n", $contents); 125 | 126 | foreach ($lines as $lineNumber => $line) { 127 | $closure($line, $lineNumber); 128 | } 129 | } 130 | } 131 | 132 | class TypescriptClass 133 | { 134 | /** 135 | * @param array $properties 136 | */ 137 | protected function __construct( 138 | protected string $name, 139 | protected array $properties = [], 140 | ) {} 141 | 142 | /** 143 | * @param array $properties 144 | */ 145 | public static function make(string $name, array $properties): self 146 | { 147 | $items = []; 148 | foreach ($properties as $key => $property) { 149 | $tsProperty = TypescriptProperty::make($key, $property['type'], $property['nullable']); 150 | $items[$tsProperty->name()] = $tsProperty; 151 | } 152 | 153 | return new self($name, $items); 154 | } 155 | 156 | public function name(): string 157 | { 158 | return $this->name; 159 | } 160 | 161 | /** 162 | * @return array 163 | */ 164 | public function properties(): array 165 | { 166 | return $this->properties; 167 | } 168 | } 169 | 170 | class TypescriptProperty 171 | { 172 | protected function __construct( 173 | protected string $name, 174 | protected string $type, 175 | protected bool $nullable, 176 | ) {} 177 | 178 | public static function make(string $name, string $type, bool $nullable): self 179 | { 180 | return new self($name, $type, $nullable); 181 | } 182 | 183 | public function name(): string 184 | { 185 | return $this->name; 186 | } 187 | 188 | public function type(): string 189 | { 190 | return $this->type; 191 | } 192 | 193 | public function isNullable(): bool 194 | { 195 | return $this->nullable; 196 | } 197 | } 198 | -------------------------------------------------------------------------------- /src/Typed/Utils/TypescriptableUtils.php: -------------------------------------------------------------------------------- 1 | execute(); 33 | } 34 | 35 | public static function routes(RouteConfig $config = new RouteConfig): RouteType 36 | { 37 | return RouteType::make($config); 38 | } 39 | 40 | public static function settings(SettingsConfig $config = new SettingsConfig): ?SettingsType 41 | { 42 | return SettingsType::make($config); 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /src/TypescriptableConfig.php: -------------------------------------------------------------------------------- 1 | name('typescriptable') 24 | ->hasConfigFile() 25 | ->hasCommands([ 26 | TypescriptableCommand::class, 27 | TypescriptableEloquentCommand::class, 28 | TypescriptableRoutesCommand::class, 29 | TypescriptableSettingsCommand::class, 30 | EloquentListCommand::class, 31 | ]); 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "esnext", 4 | "module": "commonjs" 5 | }, 6 | "include": ["plugin"] 7 | } 8 | --------------------------------------------------------------------------------