diff --git a/php-templates/livewire-components.php b/php-templates/livewire-components.php new file mode 100644 index 00000000..16230fcd --- /dev/null +++ b/php-templates/livewire-components.php @@ -0,0 +1,222 @@ +getStandardClasses(), + $this->getStandardViews() + ))->groupBy('key')->map(fn (\Illuminate\Support\Collection $items) => [ + 'isVendor' => $items->first()['isVendor'], + 'paths' => $items->pluck('path')->values(), + 'props' => $items->pluck('props')->values()->filter()->flatMap(fn ($i) => $i), + ]); + + return [ + 'components' => $components + ]; + } + + /** + * @return array + */ + protected function findFiles(string $path, string $extension, \Closure $keyCallback): array + { + if (! is_dir($path)) { + return []; + } + + $files = \Symfony\Component\Finder\Finder::create() + ->files() + ->name("*." . $extension) + ->in($path); + $components = []; + $pathRealPath = realpath($path); + + foreach ($files as $file) { + $realPath = $file->getRealPath(); + + $key = str($realPath) + ->replace($pathRealPath, '') + ->ltrim('/\\') + ->replace('.' . $extension, '') + ->replace(['/', '\\'], '.') + ->pipe(fn (string $str): string => $str); + + $components[] = [ + "path" => LaravelVsCode::relativePath($realPath), + "isVendor" => LaravelVsCode::isVendor($realPath), + "key" => $keyCallback ? $keyCallback($key) : $key, + ]; + } + + return $components; + } + + protected function getStandardClasses(): array + { + /** @var string|null $classNamespace */ + $classNamespace = config('livewire.class_namespace'); + + if (! $classNamespace) { + return []; + } + + $path = str($classNamespace) + ->replace('\\', DIRECTORY_SEPARATOR) + ->lcfirst() + ->toString(); + + $items = $this->findFiles( + $path, + 'php', + fn (\Illuminate\Support\Stringable $key): string => $key->explode('.') + ->map(fn (string $p): string => \Illuminate\Support\Str::kebab($p)) + ->implode('.'), + ); + + return collect($items) + ->map(function ($item) { + $class = str($item['path']) + ->replace('.php', '') + ->replace(DIRECTORY_SEPARATOR, '\\') + ->ucfirst() + ->toString(); + + if (! class_exists($class)) { + return null; + } + + $reflection = new \ReflectionClass($class); + + if (! $reflection->isSubclassOf('Livewire\Component')) { + return null; + } + + return [ + ...$item, + 'props' => $this->getComponentProps($reflection), + ]; + }) + ->filter() + ->values() + ->all(); + } + + protected function getStandardViews(): array + { + /** @var string|null $viewPath */ + $path = config('livewire.view_path'); + + if (! $path) { + return []; + } + + $items = $this->findFiles( + $path, + 'blade.php', + fn (\Illuminate\Support\Stringable $key): string => $key->explode('.') + ->map(fn(string $p): string => \Illuminate\Support\Str::kebab($p)) + ->implode('.'), + ); + + $previousClass = null; + + return collect($items) + ->map(function ($item) use (&$previousClass) { + // This is ugly, I know, but I don't have better idea how to get + // anonymous classes from Volt components + ob_start(); + + try { + require_once $item['path']; + } catch (\Throwable $e) { + return $item; + } + + ob_clean(); + + $declaredClasses = get_declared_classes(); + $class = end($declaredClasses); + + if ($previousClass === $class) { + return $item; + } + + $previousClass = $class; + + if (! \Illuminate\Support\Str::contains($class, '@anonymous')) { + return $item; + } + + $reflection = new \ReflectionClass($class); + + if (! $reflection->isSubclassOf('Livewire\Volt\Component')) { + return $item; + } + + return [ + ...$item, + 'props' => $this->getComponentProps($reflection), + ]; + }) + ->all(); + } + + /** + * @return array + */ + protected function getComponentProps(ReflectionClass $reflection): array + { + $props = collect(); + + // Firstly we need to get the mount method parameters. Remember that + // Livewire components can have multiple mount methods in traits. + + $methods = $reflection->getMethods(); + + $mountMethods = array_filter( + $methods, + fn (\ReflectionMethod $method): bool => + \Illuminate\Support\Str::startsWith($method->getName(), 'mount') + ); + + foreach ($mountMethods as $method) { + $parameters = $method->getParameters(); + + $parameters = collect($parameters) + ->map(fn (\ReflectionParameter $p): array => [ + 'name' => \Illuminate\Support\Str::kebab($p->getName()), + 'type' => (string) ($p->getType() ?? 'mixed'), + // We need to add hasDefault, because null can be also a default value, + // it can't be a flag of no default + 'hasDefault' => $p->isDefaultValueAvailable(), + 'default' => $p->isOptional() ? $p->getDefaultValue() : null + ]) + ->all(); + + $props = $props->merge($parameters); + } + + // Then we need to get the public properties + + $properties = collect($reflection->getProperties()) + ->filter(fn (\ReflectionProperty $p): bool => + $p->isPublic() && $p->getDeclaringClass()->getName() === $reflection->getName() + ) + ->map(fn (\ReflectionProperty $p): array => [ + 'name' => \Illuminate\Support\Str::kebab($p->getName()), + 'type' => (string) ($p->getType() ?? 'mixed'), + 'hasDefault' => $p->hasDefaultValue(), + 'default' => $p->getDefaultValue() + ]) + ->all(); + + return $props + ->merge($properties) + ->unique('name') // Mount parameters always overwrite public properties + ->all(); + } +}; + +echo json_encode($components->all()); diff --git a/src/repositories/configs.ts b/src/repositories/configs.ts index cea56b83..69c2c426 100644 --- a/src/repositories/configs.ts +++ b/src/repositories/configs.ts @@ -2,6 +2,10 @@ import { repository } from "."; import { Config } from ".."; import { runInLaravel, template } from "../support/php"; +export const getConfigByName = (name: string): Config | undefined => { + return getConfigs().items.find((item) => item.name === name); +}; + export const getConfigs = repository({ load: () => { return runInLaravel(template("configs"), "Configs").then( diff --git a/src/repositories/livewireComponents.ts b/src/repositories/livewireComponents.ts new file mode 100644 index 00000000..7e57f1a0 --- /dev/null +++ b/src/repositories/livewireComponents.ts @@ -0,0 +1,50 @@ +import { runInLaravel, template } from "@src/support/php"; +import { relativePath } from "@src/support/project"; +import { lcfirst } from "@src/support/str"; +import { waitForValue } from "@src/support/util"; +import { repository } from "."; +import { getConfigByName, getConfigs } from "./configs"; + +let livewirePaths: string[] | null = null; + +export interface LivewireComponents { + components: { + [key: string]: { + paths: string[]; + isVendor: boolean; + props: { + name: string; + type: string; + hasDefault: boolean; + default: string | null; + }[]; + }; + }; +} + +const load = () => { + getConfigs().whenLoaded(() => { + livewirePaths = [ + lcfirst(getConfigByName('livewire.class_namespace')?.value?.replace(/\\/g, '/') ?? 'app/Livewire'), + relativePath(getConfigByName('livewire.view_path')?.value ?? 'resources/views/livewire') + ]; + }); + + return runInLaravel(template("livewireComponents")); +}; + +export const getLivewireComponents = repository({ + load, + pattern: () => + waitForValue(() => livewirePaths).then((paths) => { + if (paths === null || paths.length === 0) { + return null; + } + + return paths.map(path => path + "/{*,**/*}"); + }), + itemsDefault: { + components: {}, + }, + fileWatcherEvents: ["create", "delete"], +}); diff --git a/src/support/str.ts b/src/support/str.ts new file mode 100644 index 00000000..ac64063e --- /dev/null +++ b/src/support/str.ts @@ -0,0 +1,3 @@ +export const lcfirst = (str: string): string => { + return str.charAt(0).toLowerCase() + str.slice(1); +}; \ No newline at end of file diff --git a/src/templates/index.ts b/src/templates/index.ts index 4b0572f3..40617165 100644 --- a/src/templates/index.ts +++ b/src/templates/index.ts @@ -5,6 +5,7 @@ import bladeDirectives from "./blade-directives"; import bootstrapLaravel from "./bootstrap-laravel"; import configs from "./configs"; import inertia from "./inertia"; +import livewireComponents from "./livewire-components"; import middleware from "./middleware"; import models from "./models"; import routes from "./routes"; @@ -14,6 +15,7 @@ import views from "./views"; const templates = { app, auth, + livewireComponents, bladeComponents, bladeDirectives, bootstrapLaravel, diff --git a/src/templates/livewire-components.ts b/src/templates/livewire-components.ts new file mode 100644 index 00000000..f708577b --- /dev/null +++ b/src/templates/livewire-components.ts @@ -0,0 +1,223 @@ +// This file was generated from php-templates/livewire-components.php, do not edit directly +export default ` +$components = new class { + public function all(): array + { + $components = collect(array_merge( + $this->getStandardClasses(), + $this->getStandardViews() + ))->groupBy('key')->map(fn (\\Illuminate\\Support\\Collection $items) => [ + 'isVendor' => $items->first()['isVendor'], + 'paths' => $items->pluck('path')->values(), + 'props' => $items->pluck('props')->values()->filter()->flatMap(fn ($i) => $i), + ]); + + return [ + 'components' => $components + ]; + } + + /** + * @return array + */ + protected function findFiles(string $path, string $extension, \\Closure $keyCallback): array + { + if (! is_dir($path)) { + return []; + } + + $files = \\Symfony\\Component\\Finder\\Finder::create() + ->files() + ->name("*." . $extension) + ->in($path); + $components = []; + $pathRealPath = realpath($path); + + foreach ($files as $file) { + $realPath = $file->getRealPath(); + + $key = str($realPath) + ->replace($pathRealPath, '') + ->ltrim('/\\\\') + ->replace('.' . $extension, '') + ->replace(['/', '\\\\'], '.') + ->pipe(fn (string $str): string => $str); + + $components[] = [ + "path" => LaravelVsCode::relativePath($realPath), + "isVendor" => LaravelVsCode::isVendor($realPath), + "key" => $keyCallback ? $keyCallback($key) : $key, + ]; + } + + return $components; + } + + protected function getStandardClasses(): array + { + /** @var string|null $classNamespace */ + $classNamespace = config('livewire.class_namespace'); + + if (! $classNamespace) { + return []; + } + + $path = str($classNamespace) + ->replace('\\\\', DIRECTORY_SEPARATOR) + ->lcfirst() + ->toString(); + + $items = $this->findFiles( + $path, + 'php', + fn (\\Illuminate\\Support\\Stringable $key): string => $key->explode('.') + ->map(fn (string $p): string => \\Illuminate\\Support\\Str::kebab($p)) + ->implode('.'), + ); + + return collect($items) + ->map(function ($item) { + $class = str($item['path']) + ->replace('.php', '') + ->replace(DIRECTORY_SEPARATOR, '\\\\') + ->ucfirst() + ->toString(); + + if (! class_exists($class)) { + return null; + } + + $reflection = new \\ReflectionClass($class); + + if (! $reflection->isSubclassOf('Livewire\\Component')) { + return null; + } + + return [ + ...$item, + 'props' => $this->getComponentProps($reflection), + ]; + }) + ->filter() + ->values() + ->all(); + } + + protected function getStandardViews(): array + { + /** @var string|null $viewPath */ + $path = config('livewire.view_path'); + + if (! $path) { + return []; + } + + $items = $this->findFiles( + $path, + 'blade.php', + fn (\\Illuminate\\Support\\Stringable $key): string => $key->explode('.') + ->map(fn(string $p): string => \\Illuminate\\Support\\Str::kebab($p)) + ->implode('.'), + ); + + $previousClass = null; + + return collect($items) + ->map(function ($item) use (&$previousClass) { + // This is ugly, I know, but I don't have better idea how to get + // anonymous classes from Volt components + ob_start(); + + try { + require_once $item['path']; + } catch (\\Throwable $e) { + return $item; + } + + ob_clean(); + + $declaredClasses = get_declared_classes(); + $class = end($declaredClasses); + + if ($previousClass === $class) { + return $item; + } + + $previousClass = $class; + + if (! \\Illuminate\\Support\\Str::contains($class, '@anonymous')) { + return $item; + } + + $reflection = new \\ReflectionClass($class); + + if (! $reflection->isSubclassOf('Livewire\\Volt\\Component')) { + return $item; + } + + return [ + ...$item, + 'props' => $this->getComponentProps($reflection), + ]; + }) + ->all(); + } + + /** + * @return array + */ + protected function getComponentProps(ReflectionClass $reflection): array + { + $props = collect(); + + // Firstly we need to get the mount method parameters. Remember that + // Livewire components can have multiple mount methods in traits. + + $methods = $reflection->getMethods(); + + $mountMethods = array_filter( + $methods, + fn (\\ReflectionMethod $method): bool => + \\Illuminate\\Support\\Str::startsWith($method->getName(), 'mount') + ); + + foreach ($mountMethods as $method) { + $parameters = $method->getParameters(); + + $parameters = collect($parameters) + ->map(fn (\\ReflectionParameter $p): array => [ + 'name' => \\Illuminate\\Support\\Str::kebab($p->getName()), + 'type' => (string) ($p->getType() ?? 'mixed'), + // We need to add hasDefault, because null can be also a default value, + // it can't be a flag of no default + 'hasDefault' => $p->isDefaultValueAvailable(), + 'default' => $p->isOptional() ? $p->getDefaultValue() : null + ]) + ->all(); + + $props = $props->merge($parameters); + } + + // Then we need to get the public properties + + $properties = collect($reflection->getProperties()) + ->filter(fn (\\ReflectionProperty $p): bool => + $p->isPublic() && $p->getDeclaringClass()->getName() === $reflection->getName() + ) + ->map(fn (\\ReflectionProperty $p): array => [ + 'name' => \\Illuminate\\Support\\Str::kebab($p->getName()), + 'type' => (string) ($p->getType() ?? 'mixed'), + 'hasDefault' => $p->hasDefaultValue(), + 'default' => $p->getDefaultValue() + ]) + ->all(); + + return $props + ->merge($properties) + ->unique('name') // Mount parameters always overwrite public properties + ->all(); + } +}; + +echo json_encode($components->all()); +`; \ No newline at end of file