diff --git a/.gitignore b/.gitignore index a8e3d84..8cddb79 100644 --- a/.gitignore +++ b/.gitignore @@ -2,4 +2,4 @@ /.idea/ .phpunit.result.cache .env -.DS_Store \ No newline at end of file +.DS_Store diff --git a/composer.lock b/composer.lock index f46f6dd..5c8a509 100644 --- a/composer.lock +++ b/composer.lock @@ -145,16 +145,16 @@ }, { "name": "myclabs/deep-copy", - "version": "1.13.0", + "version": "1.13.4", "source": { "type": "git", "url": "https://github.com/myclabs/DeepCopy.git", - "reference": "024473a478be9df5fdaca2c793f2232fe788e414" + "reference": "07d290f0c47959fd5eed98c95ee5602db07e0b6a" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/myclabs/DeepCopy/zipball/024473a478be9df5fdaca2c793f2232fe788e414", - "reference": "024473a478be9df5fdaca2c793f2232fe788e414", + "url": "https://api.github.com/repos/myclabs/DeepCopy/zipball/07d290f0c47959fd5eed98c95ee5602db07e0b6a", + "reference": "07d290f0c47959fd5eed98c95ee5602db07e0b6a", "shasum": "" }, "require": { @@ -193,7 +193,7 @@ ], "support": { "issues": "https://github.com/myclabs/DeepCopy/issues", - "source": "https://github.com/myclabs/DeepCopy/tree/1.13.0" + "source": "https://github.com/myclabs/DeepCopy/tree/1.13.4" }, "funding": [ { @@ -201,20 +201,20 @@ "type": "tidelift" } ], - "time": "2025-02-12T12:17:51+00:00" + "time": "2025-08-01T08:46:24+00:00" }, { "name": "nikic/php-parser", - "version": "v5.4.0", + "version": "v5.6.1", "source": { "type": "git", "url": "https://github.com/nikic/PHP-Parser.git", - "reference": "447a020a1f875a434d62f2a401f53b82a396e494" + "reference": "f103601b29efebd7ff4a1ca7b3eeea9e3336a2a2" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/nikic/PHP-Parser/zipball/447a020a1f875a434d62f2a401f53b82a396e494", - "reference": "447a020a1f875a434d62f2a401f53b82a396e494", + "url": "https://api.github.com/repos/nikic/PHP-Parser/zipball/f103601b29efebd7ff4a1ca7b3eeea9e3336a2a2", + "reference": "f103601b29efebd7ff4a1ca7b3eeea9e3336a2a2", "shasum": "" }, "require": { @@ -233,7 +233,7 @@ "type": "library", "extra": { "branch-alias": { - "dev-master": "5.0-dev" + "dev-master": "5.x-dev" } }, "autoload": { @@ -257,9 +257,9 @@ ], "support": { "issues": "https://github.com/nikic/PHP-Parser/issues", - "source": "https://github.com/nikic/PHP-Parser/tree/v5.4.0" + "source": "https://github.com/nikic/PHP-Parser/tree/v5.6.1" }, - "time": "2024-12-30T11:07:19+00:00" + "time": "2025-08-13T20:13:15+00:00" }, { "name": "phar-io/manifest", @@ -759,16 +759,16 @@ }, { "name": "phpunit/phpunit", - "version": "9.6.22", + "version": "9.6.29", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/phpunit.git", - "reference": "f80235cb4d3caa59ae09be3adf1ded27521d1a9c" + "reference": "9ecfec57835a5581bc888ea7e13b51eb55ab9dd3" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/phpunit/zipball/f80235cb4d3caa59ae09be3adf1ded27521d1a9c", - "reference": "f80235cb4d3caa59ae09be3adf1ded27521d1a9c", + "url": "https://api.github.com/repos/sebastianbergmann/phpunit/zipball/9ecfec57835a5581bc888ea7e13b51eb55ab9dd3", + "reference": "9ecfec57835a5581bc888ea7e13b51eb55ab9dd3", "shasum": "" }, "require": { @@ -779,7 +779,7 @@ "ext-mbstring": "*", "ext-xml": "*", "ext-xmlwriter": "*", - "myclabs/deep-copy": "^1.12.1", + "myclabs/deep-copy": "^1.13.4", "phar-io/manifest": "^2.0.4", "phar-io/version": "^3.2.1", "php": ">=7.3", @@ -790,11 +790,11 @@ "phpunit/php-timer": "^5.0.3", "sebastian/cli-parser": "^1.0.2", "sebastian/code-unit": "^1.0.8", - "sebastian/comparator": "^4.0.8", + "sebastian/comparator": "^4.0.9", "sebastian/diff": "^4.0.6", "sebastian/environment": "^5.1.5", - "sebastian/exporter": "^4.0.6", - "sebastian/global-state": "^5.0.7", + "sebastian/exporter": "^4.0.8", + "sebastian/global-state": "^5.0.8", "sebastian/object-enumerator": "^4.0.4", "sebastian/resource-operations": "^3.0.4", "sebastian/type": "^3.2.1", @@ -842,7 +842,7 @@ "support": { "issues": "https://github.com/sebastianbergmann/phpunit/issues", "security": "https://github.com/sebastianbergmann/phpunit/security/policy", - "source": "https://github.com/sebastianbergmann/phpunit/tree/9.6.22" + "source": "https://github.com/sebastianbergmann/phpunit/tree/9.6.29" }, "funding": [ { @@ -853,12 +853,20 @@ "url": "https://github.com/sebastianbergmann", "type": "github" }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, { "url": "https://tidelift.com/funding/github/packagist/phpunit/phpunit", "type": "tidelift" } ], - "time": "2024-12-05T13:48:26+00:00" + "time": "2025-09-24T06:29:11+00:00" }, { "name": "sebastian/cli-parser", @@ -1029,16 +1037,16 @@ }, { "name": "sebastian/comparator", - "version": "4.0.8", + "version": "4.0.9", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/comparator.git", - "reference": "fa0f136dd2334583309d32b62544682ee972b51a" + "reference": "67a2df3a62639eab2cc5906065e9805d4fd5dfc5" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/comparator/zipball/fa0f136dd2334583309d32b62544682ee972b51a", - "reference": "fa0f136dd2334583309d32b62544682ee972b51a", + "url": "https://api.github.com/repos/sebastianbergmann/comparator/zipball/67a2df3a62639eab2cc5906065e9805d4fd5dfc5", + "reference": "67a2df3a62639eab2cc5906065e9805d4fd5dfc5", "shasum": "" }, "require": { @@ -1091,15 +1099,27 @@ ], "support": { "issues": "https://github.com/sebastianbergmann/comparator/issues", - "source": "https://github.com/sebastianbergmann/comparator/tree/4.0.8" + "source": "https://github.com/sebastianbergmann/comparator/tree/4.0.9" }, "funding": [ { "url": "https://github.com/sebastianbergmann", "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/sebastian/comparator", + "type": "tidelift" } ], - "time": "2022-09-14T12:41:17+00:00" + "time": "2025-08-10T06:51:50+00:00" }, { "name": "sebastian/complexity", @@ -1289,16 +1309,16 @@ }, { "name": "sebastian/exporter", - "version": "4.0.6", + "version": "4.0.8", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/exporter.git", - "reference": "78c00df8f170e02473b682df15bfcdacc3d32d72" + "reference": "14c6ba52f95a36c3d27c835d65efc7123c446e8c" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/exporter/zipball/78c00df8f170e02473b682df15bfcdacc3d32d72", - "reference": "78c00df8f170e02473b682df15bfcdacc3d32d72", + "url": "https://api.github.com/repos/sebastianbergmann/exporter/zipball/14c6ba52f95a36c3d27c835d65efc7123c446e8c", + "reference": "14c6ba52f95a36c3d27c835d65efc7123c446e8c", "shasum": "" }, "require": { @@ -1354,28 +1374,40 @@ ], "support": { "issues": "https://github.com/sebastianbergmann/exporter/issues", - "source": "https://github.com/sebastianbergmann/exporter/tree/4.0.6" + "source": "https://github.com/sebastianbergmann/exporter/tree/4.0.8" }, "funding": [ { "url": "https://github.com/sebastianbergmann", "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/sebastian/exporter", + "type": "tidelift" } ], - "time": "2024-03-02T06:33:00+00:00" + "time": "2025-09-24T06:03:27+00:00" }, { "name": "sebastian/global-state", - "version": "5.0.7", + "version": "5.0.8", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/global-state.git", - "reference": "bca7df1f32ee6fe93b4d4a9abbf69e13a4ada2c9" + "reference": "b6781316bdcd28260904e7cc18ec983d0d2ef4f6" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/global-state/zipball/bca7df1f32ee6fe93b4d4a9abbf69e13a4ada2c9", - "reference": "bca7df1f32ee6fe93b4d4a9abbf69e13a4ada2c9", + "url": "https://api.github.com/repos/sebastianbergmann/global-state/zipball/b6781316bdcd28260904e7cc18ec983d0d2ef4f6", + "reference": "b6781316bdcd28260904e7cc18ec983d0d2ef4f6", "shasum": "" }, "require": { @@ -1418,15 +1450,27 @@ ], "support": { "issues": "https://github.com/sebastianbergmann/global-state/issues", - "source": "https://github.com/sebastianbergmann/global-state/tree/5.0.7" + "source": "https://github.com/sebastianbergmann/global-state/tree/5.0.8" }, "funding": [ { "url": "https://github.com/sebastianbergmann", "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/sebastian/global-state", + "type": "tidelift" } ], - "time": "2024-03-02T06:35:11+00:00" + "time": "2025-08-10T07:10:35+00:00" }, { "name": "sebastian/lines-of-code", @@ -1599,16 +1643,16 @@ }, { "name": "sebastian/recursion-context", - "version": "4.0.5", + "version": "4.0.6", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/recursion-context.git", - "reference": "e75bd0f07204fec2a0af9b0f3cfe97d05f92efc1" + "reference": "539c6691e0623af6dc6f9c20384c120f963465a0" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/recursion-context/zipball/e75bd0f07204fec2a0af9b0f3cfe97d05f92efc1", - "reference": "e75bd0f07204fec2a0af9b0f3cfe97d05f92efc1", + "url": "https://api.github.com/repos/sebastianbergmann/recursion-context/zipball/539c6691e0623af6dc6f9c20384c120f963465a0", + "reference": "539c6691e0623af6dc6f9c20384c120f963465a0", "shasum": "" }, "require": { @@ -1650,15 +1694,27 @@ "homepage": "https://github.com/sebastianbergmann/recursion-context", "support": { "issues": "https://github.com/sebastianbergmann/recursion-context/issues", - "source": "https://github.com/sebastianbergmann/recursion-context/tree/4.0.5" + "source": "https://github.com/sebastianbergmann/recursion-context/tree/4.0.6" }, "funding": [ { "url": "https://github.com/sebastianbergmann", "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/sebastian/recursion-context", + "type": "tidelift" } ], - "time": "2023-02-03T06:07:39+00:00" + "time": "2025-08-10T06:57:39+00:00" }, { "name": "sebastian/resource-operations", @@ -1876,12 +1932,12 @@ ], "aliases": [], "minimum-stability": "stable", - "stability-flags": {}, + "stability-flags": [], "prefer-stable": false, "prefer-lowest": false, "platform": { "php": ">=8.0" }, - "platform-dev": {}, - "plugin-api-version": "2.6.0" + "platform-dev": [], + "plugin-api-version": "2.3.0" } diff --git a/src/Detection/Framework.php b/src/Detection/Framework.php index c347043..fceaffb 100644 --- a/src/Detection/Framework.php +++ b/src/Detection/Framework.php @@ -26,6 +26,14 @@ abstract public function getName(): string; */ abstract public function getFiles(): array; + /** + * @return array List of package names for detection. + */ + public function getPackages(): array + { + return []; + } + abstract public function getInstallCommand(): string; abstract public function getBuildCommand(): string; diff --git a/src/Detection/Framework/Analog.php b/src/Detection/Framework/Analog.php index 19de424..31f824a 100644 --- a/src/Detection/Framework/Analog.php +++ b/src/Detection/Framework/Analog.php @@ -2,21 +2,27 @@ namespace Utopia\Detector\Detection\Framework; -use Utopia\Detector\Detection\Framework; - -class Analog extends Framework +class Analog extends Angular { public function getName(): string { return 'analog'; } + /** + * @return array + */ + public function getPackages(): array + { + return \array_merge(['@analogjs/platform'], parent::getPackages()); + } + /** * @return array */ public function getFiles(): array { - return ['angular.json']; + return parent::getFiles(); } public function getInstallCommand(): string diff --git a/src/Detection/Framework/Angular.php b/src/Detection/Framework/Angular.php index 49fa032..e676885 100644 --- a/src/Detection/Framework/Angular.php +++ b/src/Detection/Framework/Angular.php @@ -2,21 +2,27 @@ namespace Utopia\Detector\Detection\Framework; -use Utopia\Detector\Detection\Framework; - -class Angular extends Framework +class Angular extends JS { public function getName(): string { return 'angular'; } + /** + * @return array + */ + public function getPackages(): array + { + return \array_merge(['@angular/core'], parent::getPackages()); + } + /** * @return array */ public function getFiles(): array { - return ['angular.json']; + return \array_merge(['angular.json'], parent::getFiles()); } public function getInstallCommand(): string diff --git a/src/Detection/Framework/Astro.php b/src/Detection/Framework/Astro.php index 177be04..58a95d1 100644 --- a/src/Detection/Framework/Astro.php +++ b/src/Detection/Framework/Astro.php @@ -2,21 +2,45 @@ namespace Utopia\Detector\Detection\Framework; -use Utopia\Detector\Detection\Framework; - -class Astro extends Framework +class Astro extends JS { public function getName(): string { return 'astro'; } + /** + * @return array + */ + public function getPackages(): array + { + $packages = \array_merge( + ['astro'], + parent::getPackages(), + (new Angular())->getPackages(), + (new React())->getPackages(), + (new Vue())->getPackages(), + (new Svelte())->getPackages() + ); + + return \array_unique($packages); + } + /** * @return array */ public function getFiles(): array { - return ['astro.config.mjs', 'astro.config.js']; + $files = \array_merge( + ['astro.config.mjs', 'astro.config.js', 'astro.config.ts'], + parent::getFiles(), + (new Angular())->getFiles(), + (new React())->getFiles(), + (new Vue())->getFiles(), + (new Svelte())->getFiles() + ); + + return \array_unique($files); } public function getInstallCommand(): string diff --git a/src/Detection/Framework/JS.php b/src/Detection/Framework/JS.php new file mode 100644 index 0000000..c7452c2 --- /dev/null +++ b/src/Detection/Framework/JS.php @@ -0,0 +1,24 @@ + + */ + public function getPackages(): array + { + return []; + } + + /** + * @return array + */ + public function getFiles(): array + { + return ['package.json']; + } +} diff --git a/src/Detection/Framework/Lynx.php b/src/Detection/Framework/Lynx.php index 790d191..376c8cd 100644 --- a/src/Detection/Framework/Lynx.php +++ b/src/Detection/Framework/Lynx.php @@ -2,21 +2,27 @@ namespace Utopia\Detector\Detection\Framework; -use Utopia\Detector\Detection\Framework; - -class Lynx extends Framework +class Lynx extends React { public function getName(): string { return 'lynx'; } + /** + * @return array + */ + public function getPackages(): array + { + return \array_merge(['@lynx-js/react'], parent::getPackages()); + } + /** * @return array */ public function getFiles(): array { - return ['lynx.config.ts', 'lynx.config.js']; + return \array_merge(['lynx.config.ts', 'lynx.config.js', 'lynx.config.mjs'], parent::getFiles()); } public function getInstallCommand(): string diff --git a/src/Detection/Framework/NextJs.php b/src/Detection/Framework/NextJs.php index edd8981..2245844 100644 --- a/src/Detection/Framework/NextJs.php +++ b/src/Detection/Framework/NextJs.php @@ -2,21 +2,27 @@ namespace Utopia\Detector\Detection\Framework; -use Utopia\Detector\Detection\Framework; - -class NextJs extends Framework +class NextJs extends React { public function getName(): string { return 'nextjs'; } + /** + * @return array + */ + public function getPackages(): array + { + return \array_merge(['next'], parent::getPackages()); + } + /** * @return array */ public function getFiles(): array { - return ['next.config.js', 'next.config.ts', 'next.config.mjs']; + return \array_merge(['next.config.js', 'next.config.ts', 'next.config.mjs'], parent::getFiles()); } public function getInstallCommand(): string diff --git a/src/Detection/Framework/Nuxt.php b/src/Detection/Framework/Nuxt.php index 5d92828..356cd8c 100644 --- a/src/Detection/Framework/Nuxt.php +++ b/src/Detection/Framework/Nuxt.php @@ -2,21 +2,27 @@ namespace Utopia\Detector\Detection\Framework; -use Utopia\Detector\Detection\Framework; - -class Nuxt extends Framework +class Nuxt extends Vue { public function getName(): string { return 'nuxt'; } + /** + * @return array + */ + public function getPackages(): array + { + return \array_merge(['nuxt'], parent::getPackages()); + } + /** * @return array */ public function getFiles(): array { - return ['nuxt.config.js', 'nuxt.config.ts']; + return \array_merge(['nuxt.config.js', 'nuxt.config.ts', 'nuxt.config.mjs'], parent::getFiles()); } public function getInstallCommand(): string diff --git a/src/Detection/Framework/React.php b/src/Detection/Framework/React.php new file mode 100644 index 0000000..2386e73 --- /dev/null +++ b/src/Detection/Framework/React.php @@ -0,0 +1,50 @@ + + */ + public function getPackages(): array + { + return \array_merge(['react'], parent::getPackages()); + } + + /** + * @return array + */ + public function getFiles(): array + { + return \array_merge([], parent::getFiles()); + } + + public function getInstallCommand(): string + { + return match ($this->packager) { + 'yarn' => 'yarn install', + 'pnpm' => 'pnpm install', + default => 'npm install', + }; + } + + public function getBuildCommand(): string + { + return match ($this->packager) { + 'yarn' => 'yarn build', + 'pnpm' => 'pnpm build', + default => 'npm run build', + }; + } + + public function getOutputDirectory(): string + { + return './dist'; + } +} diff --git a/src/Detection/Framework/ReactNative.php b/src/Detection/Framework/ReactNative.php new file mode 100644 index 0000000..4616079 --- /dev/null +++ b/src/Detection/Framework/ReactNative.php @@ -0,0 +1,50 @@ + + */ + public function getPackages(): array + { + return \array_merge(['react-native'], parent::getPackages()); + } + + /** + * @return array + */ + public function getFiles(): array + { + return \array_merge([], parent::getFiles()); + } + + public function getInstallCommand(): string + { + return match ($this->packager) { + 'yarn' => 'yarn install', + 'pnpm' => 'pnpm install', + default => 'npm install', + }; + } + + public function getBuildCommand(): string + { + return match ($this->packager) { + 'yarn' => 'yarn build', + 'pnpm' => 'pnpm build', + default => 'npm run build', + }; + } + + public function getOutputDirectory(): string + { + return './dist'; + } +} diff --git a/src/Detection/Framework/Remix.php b/src/Detection/Framework/Remix.php index 7a34da7..5dee017 100644 --- a/src/Detection/Framework/Remix.php +++ b/src/Detection/Framework/Remix.php @@ -2,21 +2,27 @@ namespace Utopia\Detector\Detection\Framework; -use Utopia\Detector\Detection\Framework; - -class Remix extends Framework +class Remix extends React { public function getName(): string { return 'remix'; } + /** + * @return array + */ + public function getPackages(): array + { + return \array_merge(['@remix-run/react'], parent::getPackages()); + } + /** * @return array */ public function getFiles(): array { - return ['remix.config.js', 'remix.config.ts']; + return \array_merge(['remix.config.js', 'remix.config.ts', 'remix.config.mjs'], parent::getFiles()); } public function getInstallCommand(): string diff --git a/src/Detection/Framework/Svelte.php b/src/Detection/Framework/Svelte.php new file mode 100644 index 0000000..d509273 --- /dev/null +++ b/src/Detection/Framework/Svelte.php @@ -0,0 +1,50 @@ + + */ + public function getPackages(): array + { + return \array_merge(['svelte'], parent::getPackages()); + } + + /** + * @return array + */ + public function getFiles(): array + { + return \array_merge(['svelte.config.js', 'svelte.config.mjs', 'svelte.config.ts'], parent::getFiles()); + } + + public function getInstallCommand(): string + { + return match ($this->packager) { + 'yarn' => 'yarn install', + 'pnpm' => 'pnpm install', + default => 'npm install', + }; + } + + public function getBuildCommand(): string + { + return match ($this->packager) { + 'yarn' => 'yarn build', + 'pnpm' => 'pnpm build', + default => 'npm run build', + }; + } + + public function getOutputDirectory(): string + { + return './build'; + } +} diff --git a/src/Detection/Framework/SvelteKit.php b/src/Detection/Framework/SvelteKit.php index d84d63d..3a08445 100644 --- a/src/Detection/Framework/SvelteKit.php +++ b/src/Detection/Framework/SvelteKit.php @@ -2,21 +2,27 @@ namespace Utopia\Detector\Detection\Framework; -use Utopia\Detector\Detection\Framework; - -class SvelteKit extends Framework +class SvelteKit extends Svelte { public function getName(): string { return 'sveltekit'; } + /** + * @return array + */ + public function getPackages(): array + { + return \array_merge(['@sveltejs/kit'], parent::getPackages()); + } + /** * @return array */ public function getFiles(): array { - return ['svelte.config.js']; + return \array_merge([], parent::getFiles()); } public function getInstallCommand(): string diff --git a/src/Detection/Framework/TanStackStart.php b/src/Detection/Framework/TanStackStart.php new file mode 100644 index 0000000..a53ad87 --- /dev/null +++ b/src/Detection/Framework/TanStackStart.php @@ -0,0 +1,50 @@ + + */ + public function getFiles(): array + { + return \array_merge([], parent::getFiles()); + } + + /** + * @return array + */ + public function getPackages(): array + { + return \array_merge(['@tanstack/react-start', '@tanstack/solid-start'], parent::getPackages()); + } + + public function getInstallCommand(): string + { + return match ($this->packager) { + 'yarn' => 'yarn install', + 'pnpm' => 'pnpm install', + default => 'npm install', + }; + } + + public function getBuildCommand(): string + { + return match ($this->packager) { + 'yarn' => 'yarn build', + 'pnpm' => 'pnpm build', + default => 'npm run build', + }; + } + + public function getOutputDirectory(): string + { + return './dist'; + } +} diff --git a/src/Detection/Framework/Vue.php b/src/Detection/Framework/Vue.php new file mode 100644 index 0000000..ad29e18 --- /dev/null +++ b/src/Detection/Framework/Vue.php @@ -0,0 +1,50 @@ + + */ + public function getPackages(): array + { + return \array_merge(['vue'], parent::getPackages()); + } + + /** + * @return array + */ + public function getFiles(): array + { + return \array_merge([], parent::getFiles()); + } + + public function getInstallCommand(): string + { + return match ($this->packager) { + 'yarn' => 'yarn install', + 'pnpm' => 'pnpm install', + default => 'npm install', + }; + } + + public function getBuildCommand(): string + { + return match ($this->packager) { + 'yarn' => 'yarn build', + 'pnpm' => 'pnpm build', + default => 'npm run build', + }; + } + + public function getOutputDirectory(): string + { + return './dist'; + } +} diff --git a/src/Detection/Rendering.php b/src/Detection/Rendering.php index 42bd3d5..cd8b756 100644 --- a/src/Detection/Rendering.php +++ b/src/Detection/Rendering.php @@ -17,4 +17,9 @@ public function getFallbackFile(): ?string } abstract public function getName(): string; + + /** + * @return array List of relevant files for detection. + */ + abstract public function getFiles(string $framework): array; } diff --git a/src/Detection/Rendering/SSR.php b/src/Detection/Rendering/SSR.php index a4631d7..b57dc19 100644 --- a/src/Detection/Rendering/SSR.php +++ b/src/Detection/Rendering/SSR.php @@ -6,7 +6,7 @@ class SSR extends Rendering { - public const FRAMEWORK_FILES = [ + private const FRAMEWORK_FILES = [ 'nextjs' => ['.next/server/pages/_app.js'], 'nuxt' => ['server/index.mjs'], 'sveltekit' => ['handler.js'], @@ -14,10 +14,19 @@ class SSR extends Rendering 'remix' => ['build/server/index.js'], 'angular' => ['server/server.mjs'], 'analog' => ['server/index.mjs'], + 'tanstack-start' => ['server/server.js', 'server/index.mjs'], 'flutter' => [], 'lynx' => [], ]; + /** + * @return array + */ + public function getFiles(string $framework): array + { + return self::FRAMEWORK_FILES[$framework] ?? []; + } + public function getName(): string { return 'ssr'; diff --git a/src/Detection/Rendering/XStatic.php b/src/Detection/Rendering/XStatic.php index 7c0c667..efb1717 100644 --- a/src/Detection/Rendering/XStatic.php +++ b/src/Detection/Rendering/XStatic.php @@ -10,4 +10,9 @@ public function getName(): string { return 'static'; } + + public function getFiles(string $framework): array + { + return []; + } } diff --git a/src/Detector.php b/src/Detector.php index 9b2dafd..dc658eb 100644 --- a/src/Detector.php +++ b/src/Detector.php @@ -10,10 +10,28 @@ abstract class Detector protected array $options = []; /** - * @param array $inputs + * @var array */ - public function __construct(protected array $inputs) + protected array $inputs = []; + + public function __construct() + { + } + + /** + * Add input with its type + * + * @param string $content Input content + * @param string $type Input type (e.g., 'path', 'packages', 'extension', 'language', ..) + */ + public function addInput(string $content, string $type = ''): self { + $this->inputs[] = [ + 'type' => $type, + 'content' => $content, + ]; + + return $this; } abstract public function detect(): ?Detection; diff --git a/src/Detector/Framework.php b/src/Detector/Framework.php index 0a7478d..201601c 100644 --- a/src/Detector/Framework.php +++ b/src/Detector/Framework.php @@ -3,22 +3,36 @@ namespace Utopia\Detector\Detector; use Utopia\Detector\Detection\Framework as FrameworkDetection; +use Utopia\Detector\Detection\Framework\Astro; use Utopia\Detector\Detector; class Framework extends Detector { + public const INPUT_FILE = 'file'; + public const INPUT_PACKAGES = 'packages'; + /** * @var array */ protected array $options = []; - /** - * @param array $inputs - */ - public function __construct( - protected array $inputs, - protected string $packager = 'npm' - ) { + protected string $packager = 'npm'; + + public function __construct(string $packager = 'npm') + { + parent::__construct(); + $this->packager = $packager; + } + + public function addInput(string $content, string $type = ''): self + { + if ($type !== self::INPUT_FILE && $type !== self::INPUT_PACKAGES) { + throw new \InvalidArgumentException("Invalid input type '{$type}'"); + } + + parent::addInput($content, $type); + + return $this; } /** @@ -28,13 +42,83 @@ public function __construct( */ public function detect(): ?FrameworkDetection { + $files = array_filter($this->inputs, fn ($input) => $input['type'] === self::INPUT_FILE); + $files = array_map(fn ($input) => $input['content'], $files); + + $packages = array_filter($this->inputs, fn ($input) => $input['type'] === self::INPUT_PACKAGES); + $packages = array_map(fn ($input) => $input['content'], $packages); + + // List of frameworks with count of matches + $frameworkMatches = []; + + // List of frameworks with count of parents + // Helps decide framework priority when multiple frameworks got same matches count + // (framework with least parents wins) + $fameworkParents = []; + foreach ($this->options as $detector) { - $detectorFiles = $detector->getFiles(); + $frameworkMatches[$detector->getName()] = 0; + $fameworkParents[$detector->getName()] = 0; + } - $matches = array_intersect($detectorFiles, $this->inputs); - if (count($matches) > 0) { - $detector->setPackager($this->packager); + foreach ($this->options as $detector) { + // Check package-based detection + foreach ($packages as $packageJson) { + foreach ($detector->getPackages() as $packageNeeded) { + if (str_contains($packageJson, '"'.$packageNeeded.'"')) { + $frameworkMatches[$detector->getName()] += 1; + } + } + } + + // Check path-based detection + $matches = array_intersect($detector->getFiles(), $files); + $frameworkMatches[$detector->getName()] += \count($matches); + + // Figure out how many classes detector extends using native PHP + $parent = \get_parent_class($detector); + while ($parent !== false) { + $fameworkParents[$detector->getName()] += 1; + $parent = \get_parent_class($parent); + } + } + + // Filter out frameworks with 0 matches + $frameworkMatches = array_filter($frameworkMatches, fn ($count) => $count > 0); + + if (\count($frameworkMatches) <= 0) { + return null; + } + + // Sort for framework with most matches to be first + arsort($frameworkMatches); + // Filter out non-max matches + $highestMatch = $frameworkMatches[\array_key_first($frameworkMatches)]; + $frameworkMatches = array_filter($frameworkMatches, fn ($count) => $count == $highestMatch); + + if (\count($frameworkMatches) === 1) { + $bestFramework = \array_key_first($frameworkMatches); + } else { + $bestFrameworks = \array_keys($frameworkMatches); + usort($bestFrameworks, fn ($a, $b) => $fameworkParents[$a] <=> $fameworkParents[$b]); + + $bestFramework = $bestFrameworks[0]; + + // Trick to prevent Astro (library-agnostic framework) from detecting everywhere + $astro = (new Astro())->getName(); + if ( + $bestFramework === $astro && + // and 2nd detection equally good as Astro + $frameworkMatches[$astro] == $frameworkMatches[$bestFrameworks[1]] + ) { + $bestFramework = $bestFrameworks[1]; + } + } + + foreach ($this->options as $detector) { + if ($detector->getName() === $bestFramework) { + $detector->setPackager($this->packager); return $detector; } } diff --git a/src/Detector/Packager.php b/src/Detector/Packager.php index cd7c1bc..b3bf7c5 100644 --- a/src/Detector/Packager.php +++ b/src/Detector/Packager.php @@ -12,19 +12,17 @@ class Packager extends Detector */ protected array $options = []; - /** - * @param array $inputs - */ - public function __construct(protected array $inputs) + public function __construct() { + parent::__construct(); } public function detect(): ?PackagerDetection { - foreach ($this->options as $packager) { - $packagerFiles = $packager->getFiles(); + $files = array_map(fn ($input) => $input['content'], $this->inputs); - $matches = array_intersect($packagerFiles, $this->inputs); + foreach ($this->options as $packager) { + $matches = array_intersect($packager->getFiles(), $files); if (count($matches) > 0) { return $packager; } diff --git a/src/Detector/Rendering.php b/src/Detector/Rendering.php index ec0df63..d0523d3 100644 --- a/src/Detector/Rendering.php +++ b/src/Detector/Rendering.php @@ -4,30 +4,38 @@ use Utopia\Detector\Detection\Rendering as RenderingDetection; use Utopia\Detector\Detection\Rendering\XStatic; -use Utopia\Detector\Detection\Rendering\SSR; use Utopia\Detector\Detector; class Rendering extends Detector { + protected string $framework; + /** - * @param array $inputs + * @var array */ - public function __construct(protected array $inputs, protected string $framework) + protected array $options = []; + + public function __construct(string $framework) { + parent::__construct(); + + $this->framework = $framework; } public function detect(): RenderingDetection { - $files = SSR::FRAMEWORK_FILES[$this->framework] ?? []; - $matches = array_intersect($this->inputs, $files); + $files = array_map(fn ($input) => $input['content'], $this->inputs); - if (count($matches) > 0) { - return new SSR(); + foreach ($this->options as $strategy) { + $matches = array_intersect($strategy->getFiles($this->framework), $files); + if (count($matches) > 0) { + return $strategy; + } } // set fallback file for Static if there is only one html file $htmlFiles = []; - foreach ($this->inputs as $file) { + foreach ($files as $file) { if (\pathinfo($file, PATHINFO_EXTENSION) === 'html') { $htmlFiles[] = $file; } diff --git a/src/Detector/Runtime.php b/src/Detector/Runtime.php index b39d66a..735ebb8 100644 --- a/src/Detector/Runtime.php +++ b/src/Detector/Runtime.php @@ -12,23 +12,26 @@ class Runtime extends Detector */ protected array $options = []; - /** - * @param array $inputs - */ - public function __construct( - protected array $inputs, - protected Strategy $strategy, - protected string $packager = 'npm' - ) { + protected Strategy $strategy; + + protected string $packager = 'npm'; + + public function __construct(Strategy $strategy, string $packager = 'npm') + { + parent::__construct(); + $this->strategy = $strategy; + $this->packager = $packager; } public function detect(): ?RuntimeDetection { + $inputs = array_map(fn ($input) => $input['content'], $this->inputs); + switch ($this->strategy->getValue()) { case Strategy::FILEMATCH: foreach ($this->options as $detector) { $detectorFiles = $detector->getFiles(); - $matches = array_intersect($detectorFiles, $this->inputs); + $matches = array_intersect($detectorFiles, $inputs); if (count($matches) > 0) { $detector->setPackager($this->packager); @@ -40,9 +43,10 @@ public function detect(): ?RuntimeDetection case Strategy::EXTENSION: foreach ($this->options as $detector) { $detectorExtensions = $detector->getFileExtensions(); - $matches = array_intersect($detectorExtensions, array_map(function ($file) { + $inputExtensions = array_map(function ($file) { return pathinfo($file, PATHINFO_EXTENSION); - }, $this->inputs)); + }, $inputs); + $matches = array_intersect($detectorExtensions, $inputExtensions); if (count($matches) > 0) { $detector->setPackager($this->packager); @@ -54,7 +58,7 @@ public function detect(): ?RuntimeDetection case Strategy::LANGUAGES: foreach ($this->options as $detector) { $detectorLanguages = $detector->getLanguages(); - $matches = array_intersect($detectorLanguages, $this->inputs); + $matches = array_intersect($detectorLanguages, $inputs); if (count($matches) > 0) { $detector->setPackager($this->packager); diff --git a/tests/unit/DetectorTest.php b/tests/unit/DetectorTest.php index d52e760..30d9114 100644 --- a/tests/unit/DetectorTest.php +++ b/tests/unit/DetectorTest.php @@ -10,8 +10,13 @@ use Utopia\Detector\Detection\Framework\Lynx; use Utopia\Detector\Detection\Framework\NextJs; use Utopia\Detector\Detection\Framework\Nuxt; +use Utopia\Detector\Detection\Framework\React; +use Utopia\Detector\Detection\Framework\ReactNative; use Utopia\Detector\Detection\Framework\Remix; +use Utopia\Detector\Detection\Framework\Svelte; use Utopia\Detector\Detection\Framework\SvelteKit; +use Utopia\Detector\Detection\Framework\TanStackStart; +use Utopia\Detector\Detection\Framework\Vue; use Utopia\Detector\Detection\Packager\NPM; use Utopia\Detector\Detection\Packager\PNPM; use Utopia\Detector\Detection\Packager\Yarn; @@ -42,12 +47,16 @@ class DetectorTest extends TestCase */ public function testDetectPackager(array $files, ?string $expectedPackager): void { - $detector = new Packager($files); + $detector = new Packager(); $detector ->addOption(new PNPM()) ->addOption(new Yarn()) ->addOption(new NPM()); + foreach ($files as $file) { + $detector->addInput($file); + } + $detectedPackager = $detector->detect(); if ($expectedPackager) { @@ -82,7 +91,6 @@ public function testDetectRuntimeByFilematch( string $packager = 'npm' ): void { $detector = new Runtime( - $files, new Strategy(Strategy::FILEMATCH), $packager ); @@ -100,6 +108,10 @@ public function testDetectRuntimeByFilematch( ->addOption(new CPP()) ->addOption(new Dotnet()); + foreach ($files as $file) { + $detector->addInput($file); + } + $detectedRuntime = $detector->detect(); if ($runtime) { @@ -138,7 +150,6 @@ public function testDetectRuntimeByLanguage( string $packager = 'npm' ): void { $detector = new Runtime( - $files, new Strategy(Strategy::LANGUAGES), $packager ); @@ -156,6 +167,10 @@ public function testDetectRuntimeByLanguage( ->addOption(new CPP()) ->addOption(new Dotnet()); + foreach ($files as $file) { + $detector->addInput($file); + } + $detectedRuntime = $detector->detect(); if ($runtime) { @@ -206,7 +221,6 @@ public function testDetectRuntimeByFileExtension( string $packager = 'npm' ): void { $detector = new Runtime( - $files, new Strategy(Strategy::EXTENSION), $packager ); @@ -224,6 +238,10 @@ public function testDetectRuntimeByFileExtension( ->addOption(new CPP()) ->addOption(new Dotnet()); + foreach ($files as $file) { + $detector->addInput($file); + } + $detectedRuntime = $detector->detect(); if ($runtime) { @@ -254,7 +272,7 @@ public function runtimeDataProviderByFileExtensions(): array */ public function testFrameworkDetection(array $files, ?string $framework, ?string $installCommand = null, ?string $buildCommand = null, ?string $outputDirectory = null, string $packager = 'npm'): void { - $detector = new Framework($files, $packager); + $detector = new Framework($packager); $detector ->addOption(new Flutter()) @@ -265,7 +283,12 @@ public function testFrameworkDetection(array $files, ?string $framework, ?string ->addOption(new NextJs()) ->addOption(new Lynx()) ->addOption(new Angular()) - ->addOption(new Analog()); + ->addOption(new Analog()) + ->addOption(new TanStackStart()); + + foreach ($files as $file) { + $detector->addInput($file, Framework::INPUT_FILE); + } $detectedFramework = $detector->detect(); @@ -307,11 +330,15 @@ public function frameworkDataProvider(): array */ public function testRenderingDetection(array $files, string $framework, string $rendering, ?string $fallbackFile): void { - $detector = new Rendering($files, $framework); + $detector = new Rendering($framework); $detector ->addOption(new SSR()) ->addOption(new XStatic()); + foreach ($files as $file) { + $detector->addInput($file); + } + $detectedRendering = $detector->detect(); $this->assertNotNull($detectedRendering); @@ -330,6 +357,7 @@ public function renderingDataProvider(): array [['nitro.json', 'server/index.mjs'], 'nuxt', 'ssr', null], [['server/server.mjs'], 'angular', 'ssr', null], [['server/index.mjs'], 'analog', 'ssr', null], + [['server/index.mjs'], 'tanstack-start', 'ssr', null], [['index.html', '_nuxt/something.js'], 'nuxt', 'static', 'index.html'], [['server/pages/index.js', 'prerendered/about.html', 'handler.js'], 'sveltekit', 'ssr', null], [['index.html', 'about.html'], 'sveltekit', 'static', null], @@ -340,6 +368,424 @@ public function renderingDataProvider(): array [['index.html', 'about.html'], 'remix', 'static', null], [['about.html', 'style.css'], 'remix', 'static', 'about.html'], [['index.html', 'style.css'], 'flutter', 'static', 'index.html'], + [['index.html', 'about.html'], 'tanstack-start', 'static', null], + ]; + } + + /** + * Test TanStack Start framework detection with packages type input + */ + public function testTanStackStartDetectionWithPackages(): void + { + $detector = new Framework('npm'); + + $detector + ->addOption(new Flutter()) + ->addOption(new Nuxt()) + ->addOption(new Astro()) + ->addOption(new Remix()) + ->addOption(new SvelteKit()) + ->addOption(new NextJs()) + ->addOption(new Lynx()) + ->addOption(new Angular()) + ->addOption(new Analog()) + ->addOption(new TanStackStart()); + + $packageJson = json_encode([ + 'name' => 'my-app', + 'dependencies' => [ + '@tanstack/react-start' => '^1.0.0', + 'react' => '^18.0.0', + ], + ], JSON_UNESCAPED_SLASHES) ?: ''; + + $detector->addInput($packageJson, Framework::INPUT_PACKAGES); + + $detectedFramework = $detector->detect(); + + $this->assertNotNull($detectedFramework); + // Makes static code analyser smarter + if (is_null($detectedFramework)) { + throw new \Exception('Framework not detected'); + } + $this->assertEquals('tanstack-start', $detectedFramework->getName()); + $this->assertEquals('npm install', $detectedFramework->getInstallCommand()); + $this->assertEquals('npm run build', $detectedFramework->getBuildCommand()); + $this->assertEquals('./dist', $detectedFramework->getOutputDirectory()); + } + + /** + * Test TanStack Start framework detection with devDependencies + */ + public function testTanStackStartDetectionWithDevPackages(): void + { + $detector = new Framework('pnpm'); + + $detector->addOption(new TanStackStart()); + + $packageJson = json_encode([ + 'name' => 'my-app', + 'devDependencies' => [ + '@tanstack/react-start' => '^1.0.0', + ], + ], JSON_UNESCAPED_SLASHES) ?: ''; + + $detector->addInput($packageJson, Framework::INPUT_PACKAGES); + + $detectedFramework = $detector->detect(); + + $this->assertNotNull($detectedFramework); + // Makes static code analyser smarter + if (is_null($detectedFramework)) { + throw new \Exception('Framework not detected'); + } + + $this->assertEquals('tanstack-start', $detectedFramework->getName()); + $this->assertEquals('pnpm install', $detectedFramework->getInstallCommand()); + $this->assertEquals('pnpm build', $detectedFramework->getBuildCommand()); + } + + /** + * Test that Framework detector rejects invalid input types + */ + public function testFrameworkDetectorRejectsInvalidInputType(): void + { + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage("Invalid input type 'language'"); + + $detector = new Framework('npm'); + $detector->addInput('JavaScript', 'language'); + } + + /** + * @return array + */ + public function frameworkEdgeCasesProvider(): array + { + return [ + // React-based + [ + 'assertion' => 'Just react should mean just react', + 'files' => [ + 'package.json', + ], + 'package' => \json_encode([ + 'dependencies' => [ + 'react' => '^17.0.2' + ] + ]) ?: '', + 'framework' => 'react', + ], + [ + 'assertion' => 'React with Next package is Next.js', + 'files' => [ + 'package.json', + ], + 'package' => \json_encode([ + 'dependencies' => [ + 'react' => '^17.0.2', + 'next' => '^12.0.7' + ] + ]) ?: '', + 'framework' => 'nextjs', + ], + [ + 'assertion' => 'React with Next config is Next.js', + 'files' => [ + 'package.json', + 'next.config.js' + ], + 'package' => \json_encode([ + 'dependencies' => [ + 'react' => '^17.0.2', + ] + ]) ?: '', + 'framework' => 'nextjs', + ], + [ + 'assertion' => 'React with React Native is React Native', + 'files' => [ + 'package.json', + ], + 'package' => \json_encode([ + 'dependencies' => [ + 'react' => '^17.0.2', + 'react-native' => '^0.68.2' + ] + ]) ?: '', + 'framework' => 'react-native', + ], + [ + 'assertion' => 'React with Tanstack Start is Tanstack Start', + 'files' => [ + 'package.json', + ], + 'package' => \json_encode([ + 'dependencies' => [ + 'react' => '^17.0.2', + '@tanstack/react-start' => '^1.0.0' + ] + ], JSON_UNESCAPED_SLASHES) ?: '', + 'framework' => 'tanstack-start', + ], + [ + 'assertion' => 'React with Remix is Remix', + 'files' => [ + 'package.json', + 'remix.config.js' + ], + 'package' => \json_encode([ + 'dependencies' => [ + 'react' => '^17.0.2' + ] + ]) ?: '', + 'framework' => 'remix', + ], + [ + 'assertion' => 'React with Lynx config file is Lynx', + 'files' => [ + 'package.json', + 'lynx.config.ts' + ], + 'package' => \json_encode([ + 'dependencies' => [ + 'react' => '^17.0.2' + ] + ]) ?: '', + 'framework' => 'lynx', + ], + [ + 'assertion' => 'React with Lynx package is Lynx', + 'files' => [ + 'package.json' + ], + 'package' => \json_encode([ + 'dependencies' => [ + 'react' => '^17.0.2', + '@lynx-js/react' => '^1.0.0' + ] + ], JSON_UNESCAPED_SLASHES) ?: '', + 'framework' => 'lynx', + ], + + // Angular-based + [ + 'assertion' => 'Just Angular should mean just Angular', + 'files' => [ + 'package.json', + ], + 'package' => \json_encode([ + 'dependencies' => [ + '@angular/core' => '^14.0.0' + ] + ]) ?: '', + 'framework' => 'angular', + ], + [ + 'assertion' => 'Angular with Analog is Analog', + 'files' => [ + 'package.json', + 'angular.json', + ], + 'package' => \json_encode([ + 'dependencies' => [ + '@angular/core' => '^14.0.0', + '@analogjs/platform' => '^14.0.0', + ] + ], JSON_UNESCAPED_SLASHES) ?: '', + 'framework' => 'analog', + ], + + // Vue-based + [ + 'assertion' => 'Just Vue should mean just Vue', + 'files' => [ + 'package.json', + ], + 'package' => \json_encode([ + 'dependencies' => [ + 'vue' => '^3.2.47', + ] + ]) ?: '', + 'framework' => 'vue', + ], + [ + 'assertion' => 'Vue with Nuxt config file is Nuxt', + 'files' => [ + 'package.json', + 'nuxt.config.js', + ], + 'package' => \json_encode([ + 'dependencies' => [ + 'vue' => '^3.2.47', + ] + ]) ?: '', + 'framework' => 'nuxt', + ], + [ + 'assertion' => 'Vue with Nuxt package is Nuxt', + 'files' => [ + 'package.json', + ], + 'package' => \json_encode([ + 'dependencies' => [ + 'vue' => '^3.2.47', + 'nuxt' => '^3.0.0' + ] + ]) ?: '', + 'framework' => 'nuxt', + ], + + // Astro-based + [ + 'assertion' => 'Just Astro should mean just Astro', + 'files' => [ + 'package.json', + ], + 'package' => \json_encode([ + 'dependencies' => [ + 'astro' => '^5.0.0' + ] + ]) ?: '', + 'framework' => 'astro', + ], + [ + 'assertion' => 'Astro with React is Astro', + 'files' => [ + 'package.json', + ], + 'package' => \json_encode([ + 'dependencies' => [ + 'astro' => '^5.0.0', + 'react' => '^18.2.0' + ] + ]) ?: '', + 'framework' => 'astro', + ], + [ + 'assertion' => 'Astro with Angular package is Astro', + 'files' => [ + 'package.json', + ], + 'package' => \json_encode([ + 'dependencies' => [ + 'astro' => '^5.0.0', + '@angular/core' => '^18.2.0' + ] + ], JSON_UNESCAPED_SLASHES) ?: '', + 'framework' => 'astro', + ], + [ + 'assertion' => 'Astro with Angular file is Astro', + 'files' => [ + 'package.json', + 'angular.json', + ], + 'package' => \json_encode([ + 'dependencies' => [ + 'astro' => '^5.0.0', + ] + ], JSON_UNESCAPED_SLASHES) ?: '', + 'framework' => 'astro', + ], + [ + 'assertion' => 'Astro with Angular file and package is Astro', + 'files' => [ + 'package.json', + 'angular.json', + ], + 'package' => \json_encode([ + 'dependencies' => [ + 'astro' => '^5.0.0', + 'angular' => '^18.2.0' + ] + ], JSON_UNESCAPED_SLASHES) ?: '', + 'framework' => 'astro', + ], + [ + 'assertion' => 'Astro with Vue is Astro', + 'files' => [ + 'package.json', + ], + 'package' => \json_encode([ + 'dependencies' => [ + 'astro' => '^5.0.0', + 'vue' => '^3.2.47' + ] + ]) ?: '', + 'framework' => 'astro', + ], + + + // Svelte-based + [ + 'assertion' => 'Just Svelte should mean just Svelte', + 'files' => [ + 'package.json', + ], + 'package' => \json_encode([ + 'dependencies' => [ + 'svelte' => '^3.54.0' + ] + ]) ?: '', + 'framework' => 'svelte', + ], + [ + 'assertion' => 'Svelte with SvelteKit is SvelteKit', + 'files' => [ + 'package.json', + ], + 'package' => \json_encode([ + 'dependencies' => [ + 'svelte' => '^3.54.0', + '@sveltejs/kit' => '^1.0.0' + ] + ], JSON_UNESCAPED_SLASHES) ?: '', + 'framework' => 'sveltekit', + ], ]; } + + /** + * Test scenarios that can possibly result in multiple frameworks, + * but only one is accurate detection. + * @param array $files + * @dataProvider frameworkEdgeCasesProvider + */ + public function testFrameworkEdgeCases(string $assertion, array $files, string $packageFile, string $framework): void + { + $detector = new Framework('npm'); + + $detector + ->addOption(new Analog()) + ->addOption(new Angular()) + ->addOption(new Astro()) + ->addOption(new Flutter()) + ->addOption(new Lynx()) + ->addOption(new NextJs()) + ->addOption(new Nuxt()) + ->addOption(new React()) + ->addOption(new ReactNative()) + ->addOption(new Remix()) + ->addOption(new Svelte()) + ->addOption(new SvelteKit()) + ->addOption(new TanStackStart()) + ->addOption(new Vue()); + + foreach ($files as $file) { + $detector->addInput($file, Framework::INPUT_FILE); + } + + $detector->addInput($packageFile, Framework::INPUT_PACKAGES); + + $detection = $detector->detect(); + + $this->assertNotNull($detection, $assertion); + // Makes static code analyser smarter + if (is_null($detection)) { + throw new \Exception('Framework not detected'); + } + + $this->assertEquals($framework, $detection->getName(), $assertion); + } }