Skip to content
222 changes: 222 additions & 0 deletions php-templates/livewire-components.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,222 @@
<?php

$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<int, array{path: string, isVendor: string, key: string}>
*/
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<int, array{name: string, type: string, hasDefault: bool, default: mixed}>
*/
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());
4 changes: 4 additions & 0 deletions src/repositories/configs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,10 @@ interface ConfigGroupResult {
paths: string[];
}

export const getConfigByName = (name: string): Config | undefined => {
return getConfigs().items.configs.find((item) => item.name === name);
};

export const getConfigPathByName = (match: string): string | undefined => {
const filePath = match.replace(/\.[^.]+$/, "");

Expand Down
58 changes: 58 additions & 0 deletions src/repositories/livewireComponents.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
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<LivewireComponents>(template("livewireComponents"));
};

export const getLivewireComponents = repository<LivewireComponents>({
load,
pattern: () =>
waitForValue(() => livewirePaths).then((paths) => {
if (paths === null || paths.length === 0) {
return null;
}

return paths.map((path) => path + "/{*,**/*}");
}),
itemsDefault: {
components: {},
},
fileWatcherEvents: ["create", "delete"],
});
3 changes: 3 additions & 0 deletions src/support/str.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
export const lcfirst = (str: string): string => {
return str.charAt(0).toLowerCase() + str.slice(1);
};
2 changes: 2 additions & 0 deletions src/templates/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -14,6 +15,7 @@ import views from "./views";
const templates = {
app,
auth,
livewireComponents,
bladeComponents,
bladeDirectives,
bootstrapLaravel,
Expand Down
Loading