From f35483afc4a5fd2fc7a1354abb7c40e534dfb445 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jean-Fran=C3=A7ois=20L=C3=A9pine?= Date: Sat, 1 Feb 2025 08:50:55 +0100 Subject: [PATCH] refactored project according our previous discussions --- src/Toolkit/registry/default/registry.json | 27 +++++++ .../src/Command/UxToolkitInstallCommand.php | 56 +++++++------ .../src/Compiler/TwigComponentCompiler.php | 14 ++-- .../ComponentIdentifier.php | 41 ---------- .../ComponentRepository.php | 4 +- .../src/ComponentRepository/CurrentTheme.php | 42 ++++++++++ .../ComponentRepository/GithubRepository.php | 66 +++++++++++++-- .../OfficialRepository.php | 13 +-- .../ComponentRepository/RepositoryFactory.php | 13 ++- .../RepositoryIdentifier.php | 48 +++++++++++ ...entIdentity.php => RepositoryIdentity.php} | 15 ++-- .../ComponentRepository/RepositorySources.php | 23 ++++++ .../src/DependencyInjection/Configuration.php | 2 +- src/Toolkit/src/Registry/Registry.php | 18 ++++- src/Toolkit/src/Registry/RegistryFactory.php | 67 ++++++++++++++++ src/Toolkit/src/Registry/RegistryItem.php | 40 +++++++--- src/Toolkit/src/UxToolkitBundle.php | 17 +++- .../Command/UxToolkitInstallCommandTest.php | 46 +++-------- .../Compiler/TwigComponentCompilerTest.php | 38 ++++++--- .../GithubRepositoryTest.php | 80 +++++++++++++++++-- .../OfficialRepositoryTest.php | 19 ++--- .../RepositoryFactoryTest.php | 21 +++-- ...rTest.php => RepositoryIdentifierTest.php} | 34 ++++---- 23 files changed, 530 insertions(+), 214 deletions(-) create mode 100644 src/Toolkit/registry/default/registry.json delete mode 100644 src/Toolkit/src/ComponentRepository/ComponentIdentifier.php create mode 100644 src/Toolkit/src/ComponentRepository/CurrentTheme.php create mode 100644 src/Toolkit/src/ComponentRepository/RepositoryIdentifier.php rename src/Toolkit/src/ComponentRepository/{ComponentIdentity.php => RepositoryIdentity.php} (68%) create mode 100644 src/Toolkit/src/ComponentRepository/RepositorySources.php create mode 100644 src/Toolkit/src/Registry/RegistryFactory.php rename src/Toolkit/tests/ComponentRepository/{ComponentIdentifierTest.php => RepositoryIdentifierTest.php} (61%) diff --git a/src/Toolkit/registry/default/registry.json b/src/Toolkit/registry/default/registry.json new file mode 100644 index 0000000000..45f0bdd92d --- /dev/null +++ b/src/Toolkit/registry/default/registry.json @@ -0,0 +1,27 @@ +{ + "$schema": "https://ui.shadcn.com/schema/registry.json", + "name": "symfony", + "homepage": "https://symfony.com", + "items": [ + { + "name": "badge", + "manifest": "components/Badge.json", + "type": "registry:component", + "title": "Badge", + "description": "A simple badge component", + "registryDependencies": [], + "files": [], + "code": "{%- props variant = 'default', outline = false -%}\n{%- set style = html_cva(\n base: 'inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2',\n variants: {\n variant: {\n default: \"border-transparent bg-primary text-primary-foreground hover:bg-primary/80\",\n secondary: \"border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80\",\n destructive: \"border-transparent bg-destructive text-destructive-foreground hover:bg-destructive/80\",\n },\n outline: {\n true: \"text-foreground bg-white\",\n }\n },\n compoundVariants: [{\n variant: ['default'],\n outline: ['true'],\n class: 'border-primary',\n }, {\n variant: ['secondary'],\n outline: ['true'],\n class: 'border-secondary',\n }, {\n variant: ['destructive'],\n outline: ['true'],\n class: 'border-destructive',\n },]\n) -%}\n\n\n {% block content %}{% endblock %}\n\n" + }, + { + "name": "button", + "manifest": "components/Button.json", + "type": "registry:component", + "title": "Button", + "description": "A Button component", + "registryDependencies": [], + "files": [], + "code": ",\n" + } + ] +} \ No newline at end of file diff --git a/src/Toolkit/src/Command/UxToolkitInstallCommand.php b/src/Toolkit/src/Command/UxToolkitInstallCommand.php index a8bb104e22..d3e7b011a2 100644 --- a/src/Toolkit/src/Command/UxToolkitInstallCommand.php +++ b/src/Toolkit/src/Command/UxToolkitInstallCommand.php @@ -17,11 +17,10 @@ use Symfony\Component\Console\Input\InputInterface; use Symfony\Component\Console\Output\OutputInterface; use Symfony\Component\Console\Style\SymfonyStyle; -use Symfony\Component\Filesystem\Filesystem; use Symfony\UX\Toolkit\Compiler\Exception\TwigComponentAlreadyExist; use Symfony\UX\Toolkit\Compiler\TwigComponentCompiler; -use Symfony\UX\Toolkit\ComponentRepository\ComponentIdentifier; -use Symfony\UX\Toolkit\ComponentRepository\RepositoryFactory; +use Symfony\UX\Toolkit\ComponentRepository\CurrentTheme; +use Symfony\UX\Toolkit\Registry\RegistryFactory; /** * @author Jean-François Lépine @@ -35,8 +34,8 @@ class UxToolkitInstallCommand extends Command { public function __construct( - private readonly RepositoryFactory $repositoryFactory, - private readonly ComponentIdentifier $componentIdentifier, + private readonly CurrentTheme $currentTheme, + private readonly RegistryFactory $registryFactory, private readonly TwigComponentCompiler $compiler, ) { parent::__construct(); @@ -58,52 +57,51 @@ protected function configure(): void protected function execute(InputInterface $input, OutputInterface $output): int { $io = new SymfonyStyle($input, $output); - $filesystem = new Filesystem(); - $name = $input->getArgument('component'); + // Download sources, or get them from vendors + $repository = $this->currentTheme->getIdentity(); - try { - $component = $this->componentIdentifier->identify($name); + $io->info( + \sprintf( + 'Downloading medata for %s/%s..', + $repository->getVendor(), + $repository->getPackage(), + ) + ); - // Get the correct source (remote or official) - $repository = $this->repositoryFactory->factory($component); - } catch (\Throwable $e) { - $io->error($e->getMessage()); + $name = $input->getArgument('component'); + $finder = $this->currentTheme->getRepository()->fetch($repository); + $registry = $this->registryFactory->create($finder); - return Command::FAILURE; - } + if (!$registry->has($name)) { + $io->error(\sprintf('The component "%s" does not exist.', $name)); - if ($io->isVerbose()) { - $io->text('Component information:'); - $io->table(['Vendor', 'Package', 'Version', 'Name'], [ - [$component->getVendor(), $component->getPackage(), $component->getVersion(), $component->getName()], - ]); + return Command::FAILURE; } + $component = $registry->get($name); + $destination = $input->getOption('destination'); try { - $this->compiler->compile($component, $repository, $input->getOption('destination')); + $io->info(\sprintf('Installing component "%s"...', $component->name)); + $this->compiler->compile($component, $destination); } catch (TwigComponentAlreadyExist $e) { if (!$input->isInteractive()) { - $io->error(\sprintf('The component "%s" already exists.', $component->getName())); + $io->error(\sprintf('The component "%s" already exists.', $component->name)); return Command::FAILURE; } if (!$io->confirm( - \sprintf('The component "%s" already exists. Do you want to overwrite it?', $component->getName()) + \sprintf('The component "%s" already exists. Do you want to overwrite it?', $component->name) )) { return Command::FAILURE; } // again - $this->compiler->compile($component, $repository, $input->getOption('destination')); - } - - if ($io->isVerbose()) { - $io->text(\sprintf('The component "%s" has been installed in "%s".', $component->getName(), $filename)); + $this->compiler->compile($component, $destination); } - $io->success(\sprintf('The component "%s" has been installed.', $component->getName())); + $io->success(\sprintf('The component "%s" has been installed.', $component->name)); return Command::SUCCESS; } diff --git a/src/Toolkit/src/Compiler/TwigComponentCompiler.php b/src/Toolkit/src/Compiler/TwigComponentCompiler.php index 7351fea5fe..732afc5958 100644 --- a/src/Toolkit/src/Compiler/TwigComponentCompiler.php +++ b/src/Toolkit/src/Compiler/TwigComponentCompiler.php @@ -13,8 +13,7 @@ use Symfony\Component\Filesystem\Filesystem; use Symfony\UX\Toolkit\Compiler\Exception\TwigComponentAlreadyExist; -use Symfony\UX\Toolkit\ComponentRepository\ComponentIdentity; -use Symfony\UX\Toolkit\ComponentRepository\ComponentRepository; +use Symfony\UX\Toolkit\Registry\RegistryItem; /** * @author Jean-François Lépine @@ -24,16 +23,15 @@ class TwigComponentCompiler { public function __construct( - private readonly string $theme, private readonly string $prefix, ) { } public function compile( - ComponentIdentity $component, - ComponentRepository $repository, + RegistryItem $item, string $directory, ): void { + $filesystem = new Filesystem(); if (!$filesystem->exists($directory)) { $filesystem->mkdir($directory); @@ -42,15 +40,13 @@ public function compile( $filename = implode(\DIRECTORY_SEPARATOR, [ $directory, $this->prefix, - $component->getName().'.html.twig', + $item->name.'.html.twig', ]); if ($filesystem->exists($filename)) { throw new TwigComponentAlreadyExist(); } - $content = $repository->getContent($component); // @todo we should probably inject theme here - - $filesystem->dumpFile($filename, $content); + $filesystem->dumpFile($filename, $item->code); } } diff --git a/src/Toolkit/src/ComponentRepository/ComponentIdentifier.php b/src/Toolkit/src/ComponentRepository/ComponentIdentifier.php deleted file mode 100644 index 16ae34b5cc..0000000000 --- a/src/Toolkit/src/ComponentRepository/ComponentIdentifier.php +++ /dev/null @@ -1,41 +0,0 @@ - - * - * For the full copyright and license information, please view the LICENSE - * file that was distributed with this source code. - */ - -namespace Symfony\UX\Toolkit\ComponentRepository; - -/** - * @author Jean-François Lépine - * - * @internal - */ -final class ComponentIdentifier -{ - public function identify(string $name): ComponentIdentity - { - if (preg_match('!^\w+$!', $name)) { - return new ComponentIdentity('symfony', 'ux-toolkit', ucfirst($name)); - } - - // github.com/vendor/package:component@version - // github.com/vendor/package:component (version is optional) - $name = preg_replace('!^(https://|http://)!', '', $name); - if (preg_match('!^github.com/(\w+)/(\w+):(\w+)(@.+)?$!', $name, $matches)) { - return new ComponentIdentity( - $matches[1], - $matches[2], - ucfirst($matches[3]), - trim($matches[4] ?? 'main', '@') - ); - } - - throw new \InvalidArgumentException('Source is not supported for this component'); - } -} diff --git a/src/Toolkit/src/ComponentRepository/ComponentRepository.php b/src/Toolkit/src/ComponentRepository/ComponentRepository.php index 186e6d0a55..78fa612a4a 100644 --- a/src/Toolkit/src/ComponentRepository/ComponentRepository.php +++ b/src/Toolkit/src/ComponentRepository/ComponentRepository.php @@ -11,6 +11,8 @@ namespace Symfony\UX\Toolkit\ComponentRepository; +use Symfony\Component\Finder\Finder; + /** * @author Jean-François Lépine * @@ -18,5 +20,5 @@ */ interface ComponentRepository { - public function getContent(ComponentIdentity $component): string; + public function fetch(RepositoryIdentity $component): Finder; } diff --git a/src/Toolkit/src/ComponentRepository/CurrentTheme.php b/src/Toolkit/src/ComponentRepository/CurrentTheme.php new file mode 100644 index 0000000000..ff176061c3 --- /dev/null +++ b/src/Toolkit/src/ComponentRepository/CurrentTheme.php @@ -0,0 +1,42 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\UX\Toolkit\ComponentRepository; + +/** + * @author Jean-François Lépine + * + * @internal + */ +final class CurrentTheme +{ + private ComponentRepository $repository; + private RepositoryIdentity $identity; + + public function __construct( + string $theme, + RepositoryFactory $repositoryFactory, + RepositoryIdentifier $repositoryIdentifier, + ) { + $this->identity = $repositoryIdentifier->identify($theme); + $this->repository = $repositoryFactory->factory($this->identity); + } + + public function getRepository(): ComponentRepository + { + return $this->repository; + } + + public function getIdentity(): RepositoryIdentity + { + return $this->identity; + } +} diff --git a/src/Toolkit/src/ComponentRepository/GithubRepository.php b/src/Toolkit/src/ComponentRepository/GithubRepository.php index d16c4b5f5e..83c0773e9c 100644 --- a/src/Toolkit/src/ComponentRepository/GithubRepository.php +++ b/src/Toolkit/src/ComponentRepository/GithubRepository.php @@ -11,6 +11,8 @@ namespace Symfony\UX\Toolkit\ComponentRepository; +use Symfony\Component\Filesystem\Filesystem; +use Symfony\Component\Finder\Finder; use Symfony\Component\HttpClient\HttpClient; use Symfony\Contracts\HttpClient\HttpClientInterface; @@ -21,26 +23,78 @@ */ class GithubRepository implements ComponentRepository { + private readonly \ArrayObject $manifest; + public function __construct( + private readonly Filesystem $filesystem, private readonly ?HttpClientInterface $httpClient = null, ) { if (!class_exists(HttpClient::class)) { throw new \LogicException('You must install "symfony/http-client" to use ux-toolkit with remote component. Try running "composer require symfony/http-client".'); } + + if (!class_exists(\ZipArchive::class)) { + throw new \LogicException('You must have the Zip extension installed to use ux-toolkit with remote components.'); + } } - public function getContent(ComponentIdentity $component): string + public function fetch(RepositoryIdentity $component): Finder { - $url = \sprintf( - 'https://raw.githubusercontent.com/%s/%s/%s/templates/components/%s.html.twig', + // download a zip file of the github repository, place it in a temporary directory in cache + $zipUrl = \sprintf( + 'http://github.com/%s/%s/archive/%s.zip', $component->getVendor(), $component->getPackage(), $component->getVersion(), - $component->getName() ); - $response = $this->httpClient->request('GET', $url); + $destination = $this->getCacheDir(); + $zipFile = $destination.'/'.basename($zipUrl); + + $response = $this->httpClient->request('GET', $zipUrl, [ + 'sink' => $zipFile, + ]); + + // Ensure the request was successful + if (200 !== $response->getStatusCode()) { + throw new \RuntimeException(\sprintf('Failed to download the file from "%s".', $zipUrl)); + } + + // Ensure response contains valid github headers + $headers = $response->getHeaders(); + if (!isset($headers['content-type']) || !\in_array('application/zip', $headers['content-type'])) { + throw new \RuntimeException(\sprintf('The file from "%s" is not a valid zip file.', $zipUrl)); + } + + // Flush the response to the file + $this->filesystem->dumpFile($zipFile, $response->getContent()); + + // unzip the file + $zip = new \ZipArchive(); + $zip->open($zipFile); + $zip->extractTo($destination); + $zip->close(); + + $rootDir = $destination; + $finder = new Finder(); + $finder->files()->in($rootDir); + + return $finder; + } + + public function getCacheDir(): string + { + return $this->createTmpDir('cache'); + } + + private function createTmpDir(string $type): string + { + $dir = sys_get_temp_dir().'/ux_toolkit/'.uniqid($type.'_', true); + + if (!$this->filesystem->exists($dir)) { + $this->filesystem->mkdir($dir); + } - return $response->getContent(); + return $dir; } } diff --git a/src/Toolkit/src/ComponentRepository/OfficialRepository.php b/src/Toolkit/src/ComponentRepository/OfficialRepository.php index 564fecf5b2..d1fb63aa45 100644 --- a/src/Toolkit/src/ComponentRepository/OfficialRepository.php +++ b/src/Toolkit/src/ComponentRepository/OfficialRepository.php @@ -20,18 +20,11 @@ */ class OfficialRepository implements ComponentRepository { - public function getContent(ComponentIdentity $component): string + public function fetch(RepositoryIdentity $repository): Finder { $finder = new Finder(); - $finder->files()->in(__DIR__.'/../../templates/default/components/')->name($component->getName().'.html.twig'); - if (!$finder->hasResults()) { - throw new \InvalidArgumentException(\sprintf('The component "%s" does not exist', $component->getName())); - } + $finder->in(\sprintf(__DIR__.'/../../registry/%s', $repository->getPackage())); - foreach ($finder as $file) { - return $file->getContents(); - } - - throw new \InvalidArgumentException(\sprintf('The component "%s" does not exist', $component->getName())); + return $finder; } } diff --git a/src/Toolkit/src/ComponentRepository/RepositoryFactory.php b/src/Toolkit/src/ComponentRepository/RepositoryFactory.php index 4764a6726f..b196278c26 100644 --- a/src/Toolkit/src/ComponentRepository/RepositoryFactory.php +++ b/src/Toolkit/src/ComponentRepository/RepositoryFactory.php @@ -24,14 +24,13 @@ public function __construct( ) { } - public function factory(ComponentIdentity $component): ComponentRepository + public function factory(RepositoryIdentity $repository): ComponentRepository { - if ('symfony' === $component->getVendor()) { - return $this->officialRepository; - } - - if (str_starts_with($component->getVendor(), 'github.com/')) { - return $this->githubRepository; + switch ($repository->getType()) { + case RepositorySources::EMBEDDED: + return $this->officialRepository; + case RepositorySources::GITHUB: + return $this->githubRepository; } throw new \InvalidArgumentException('Source is not supported for this component'); diff --git a/src/Toolkit/src/ComponentRepository/RepositoryIdentifier.php b/src/Toolkit/src/ComponentRepository/RepositoryIdentifier.php new file mode 100644 index 0000000000..2480c84671 --- /dev/null +++ b/src/Toolkit/src/ComponentRepository/RepositoryIdentifier.php @@ -0,0 +1,48 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\UX\Toolkit\ComponentRepository; + +/** + * @author Jean-François Lépine + * + * @internal + */ +final class RepositoryIdentifier +{ + public function identify(string $name): RepositoryIdentity + { + if (preg_match('!^\w+$!', $name)) { + // Official repository (with only the theme name) + return new RepositoryIdentity( + RepositorySources::EMBEDDED, + 'symfony', + 'default', + null + ); + } + + $name = preg_replace('!^(https://|http://)!', '', $name); + if (preg_match('!^github.com/(\w+)/(\w+)(@.+)?$!', $name, $matches)) { + // github.com/vendor/package@version + // github.com/vendor/package + // https://github.com/vendor/package + return new RepositoryIdentity( + RepositorySources::GITHUB, + $matches[1], + $matches[2], + trim($matches[3] ?? 'main', '@') + ); + } + + throw new \InvalidArgumentException('Source is not supported for this component'); + } +} diff --git a/src/Toolkit/src/ComponentRepository/ComponentIdentity.php b/src/Toolkit/src/ComponentRepository/RepositoryIdentity.php similarity index 68% rename from src/Toolkit/src/ComponentRepository/ComponentIdentity.php rename to src/Toolkit/src/ComponentRepository/RepositoryIdentity.php index 20a5f155c7..0bcff7d18f 100644 --- a/src/Toolkit/src/ComponentRepository/ComponentIdentity.php +++ b/src/Toolkit/src/ComponentRepository/RepositoryIdentity.php @@ -16,14 +16,17 @@ * * @internal */ -final readonly class ComponentIdentity +final readonly class RepositoryIdentity { public function __construct( + private int $type, private string $vendor, private ?string $package = null, - private ?string $name = null, private ?string $version = 'main', ) { + if (!\in_array($type, [RepositorySources::EMBEDDED, RepositorySources::GITHUB], true)) { + throw new \InvalidArgumentException('Only "official" and "github" types are supported for the moment.'); + } } public function getVendor(): string @@ -36,13 +39,13 @@ public function getPackage(): ?string return $this->package; } - public function getName(): ?string + public function getVersion(): ?string { - return $this->name; + return $this->version; } - public function getVersion(): ?string + public function getType(): int { - return $this->version; + return $this->type; } } diff --git a/src/Toolkit/src/ComponentRepository/RepositorySources.php b/src/Toolkit/src/ComponentRepository/RepositorySources.php new file mode 100644 index 0000000000..8dd075bc05 --- /dev/null +++ b/src/Toolkit/src/ComponentRepository/RepositorySources.php @@ -0,0 +1,23 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\UX\Toolkit\ComponentRepository; + +/** + * @author Jean-François Lépine + * + * @internal + */ +enum RepositorySources: int +{ + public const int EMBEDDED = 1; + public const int GITHUB = 2; +} diff --git a/src/Toolkit/src/DependencyInjection/Configuration.php b/src/Toolkit/src/DependencyInjection/Configuration.php index c808dd05ef..613aec99a2 100644 --- a/src/Toolkit/src/DependencyInjection/Configuration.php +++ b/src/Toolkit/src/DependencyInjection/Configuration.php @@ -29,7 +29,7 @@ public function getConfigTreeBuilder(): TreeBuilder ->defaultValue('default') ->end() ->stringNode('prefix') - ->defaultValue('ux') + ->defaultValue(null) ->end() ->end(); diff --git a/src/Toolkit/src/Registry/Registry.php b/src/Toolkit/src/Registry/Registry.php index 68c7e51f9b..0342ff7b10 100644 --- a/src/Toolkit/src/Registry/Registry.php +++ b/src/Toolkit/src/Registry/Registry.php @@ -36,6 +36,22 @@ public function add(RegistryItem $item): void $this->items[] = $item; } + public function has(string $name): bool + { + return null !== $this->get($name); + } + + public function get(string $name): ?RegistryItem + { + foreach ($this->items as $item) { + if ($item->name === $name) { + return $item; + } + } + + return null; + } + public function save(string $registryDir, Filesystem $filesystem): void { $filesystem->mkdir($registryDir); @@ -48,7 +64,7 @@ public function save(string $registryDir, Filesystem $filesystem): void 'type' => $item->type->value, 'code' => $item->code, 'dependencies' => $this->getDependencies($item), - ], \JSON_PRETTY_PRINT|\JSON_UNESCAPED_SLASHES)."\n"); + ], \JSON_PRETTY_PRINT | \JSON_UNESCAPED_SLASHES)."\n"); } } diff --git a/src/Toolkit/src/Registry/RegistryFactory.php b/src/Toolkit/src/Registry/RegistryFactory.php new file mode 100644 index 0000000000..e880fcb0ae --- /dev/null +++ b/src/Toolkit/src/Registry/RegistryFactory.php @@ -0,0 +1,67 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\UX\Toolkit\Registry; + +use Symfony\Component\Filesystem\Filesystem; +use Symfony\Component\Finder\Finder; + +/** + * @author Jean-François Lépine + * + * @internal + */ +final class RegistryFactory +{ + public function __construct( + private readonly Filesystem $filesystem, + ) { + } + + public function create(Finder $finder): Registry + { + $finderManifest = clone $finder; + $files = $finderManifest->files()->name('registry.json')->getIterator(); + $files->rewind(); + $manifestFile = $files->current(); + if (!$manifestFile) { + throw new \RuntimeException('The manifest file is missing.'); + } + + $registry = Registry::empty(); + $manifest = json_decode($manifestFile->getContents(), true); + + foreach ($manifest['items'] ?? [] as $item) { + $filename = $item['manifest']; + $localFinder = clone $finder; + $files = iterator_to_array($localFinder->path($item['manifest'])); + + if (1 !== \count($files)) { + throw new \RuntimeException(\sprintf('The file "%s" declared in the manifest is missing.', $filename)); + } + $file = reset($files); + + if (isset($item['hash'])) { + $hash = hash_file('sha256', $file->getRealPath()); + if ($hash !== $item['hash']) { + throw new \RuntimeException(\sprintf('The file "%s" declared in the manifest has an invalid hash.', $filename)); + } + } + + $item = RegistryItem::fromFile($file); + $registry->add($item); + } + + return $registry; + } +} diff --git a/src/Toolkit/src/Registry/RegistryItem.php b/src/Toolkit/src/Registry/RegistryItem.php index 5dcf29c5bf..84f8ea5aa7 100644 --- a/src/Toolkit/src/Registry/RegistryItem.php +++ b/src/Toolkit/src/Registry/RegistryItem.php @@ -36,21 +36,39 @@ public function __construct( public static function fromFile(SplFileInfo $file): self { - if (!preg_match(self::REGEX_RELATIVE_FILE, $file->getRelativePathname(), $matches)) { - throw new \InvalidArgumentException(\sprintf('Unable to parse file path "%s", it must match the following pattern: "//(/)?.html.twig"', $file->getRelativePathname())); + $json = json_decode($file->getContents(), true); + if (null === $json) { + throw new \RuntimeException(\sprintf('The file "%s" is not a valid JSON file.', $file->getRelativePathname())); } - $theme = $matches['theme']; - $type = RegistryItemType::from($matches['type']); - $name = $matches['name'] ?? $matches['nameOrParentName']; - $parentName = isset($matches['name']) ? $matches['nameOrParentName'] : null; + if (!isset($json['name'], $json['type'], $json['theme'], $json['code'])) { + throw new \RuntimeException(\sprintf('The file "%s" must contain the following keys: "name", "type", "theme" and "code".', $file->getRelativePathname())); + } return new self( - $name, - $type, - $theme, - $parentName, - $file->getContents(), + $json['name'], + RegistryItemType::from($json['type']), + $json['theme'], + $json['parentName'] ?? null, + $json['code'], ); + + // @todo: commented for the moment. Not sure to understand why we need regex here. To avoid to have json extension? + // if (!preg_match(self::REGEX_RELATIVE_FILE, $file->getRelativePathname(), $matches)) { + // throw new \InvalidArgumentException(\sprintf('Unable to parse file path "%s", it must match the following pattern: "//(/)?.html.twig"', $file->getRelativePathname())); + // } + // + // $theme = $matches['theme']; + // $type = RegistryItemType::from($matches['type']); + // $name = $matches['name'] ?? $matches['nameOrParentName']; + // $parentName = isset($matches['name']) ? $matches['nameOrParentName'] : null; + // + // return new self( + // $name, + // $type, + // $theme, + // $parentName, + // $file->getContents(), + // ); } } diff --git a/src/Toolkit/src/UxToolkitBundle.php b/src/Toolkit/src/UxToolkitBundle.php index 8a7a119449..78d6bf436a 100644 --- a/src/Toolkit/src/UxToolkitBundle.php +++ b/src/Toolkit/src/UxToolkitBundle.php @@ -16,11 +16,13 @@ use Symfony\Component\HttpKernel\Bundle\AbstractBundle; use Symfony\UX\Toolkit\Command\UxToolkitInstallCommand; use Symfony\UX\Toolkit\Compiler\TwigComponentCompiler; -use Symfony\UX\Toolkit\ComponentRepository\ComponentIdentifier; +use Symfony\UX\Toolkit\ComponentRepository\CurrentTheme; use Symfony\UX\Toolkit\ComponentRepository\GithubRepository; use Symfony\UX\Toolkit\ComponentRepository\OfficialRepository; use Symfony\UX\Toolkit\ComponentRepository\RepositoryFactory; +use Symfony\UX\Toolkit\ComponentRepository\RepositoryIdentifier; use Symfony\UX\Toolkit\DependencyInjection\ToolkitExtension; +use Symfony\UX\Toolkit\Registry\RegistryFactory; /** * @author Jean-François Lépine @@ -40,12 +42,12 @@ public function build(ContainerBuilder $container): void $container->autowire(OfficialRepository::class); $container->autowire(GithubRepository::class); $container->autowire(RepositoryFactory::class); - $container->autowire(ComponentIdentifier::class); + $container->autowire(RepositoryIdentifier::class); + $container->autowire(RegistryFactory::class); $container->autowire(TwigComponentCompiler::class); $container->getDefinition(TwigComponentCompiler::class) ->setArguments([ - '$theme' => '%ux_toolkit.theme%', '$prefix' => '%ux_toolkit.prefix%', ]); @@ -65,5 +67,14 @@ public function build(ContainerBuilder $container): void $container->getDefinition(GithubRepository::class) ->setArgument('$httpClient', $container->get('http_client')); } + + // current theme + $container->autowire(CurrentTheme::class); + $container->getDefinition(CurrentTheme::class) + ->setArguments([ + '$theme' => '%ux_toolkit.theme%', + ]); + + } } diff --git a/src/Toolkit/tests/Command/UxToolkitInstallCommandTest.php b/src/Toolkit/tests/Command/UxToolkitInstallCommandTest.php index 1848107925..ae0fafd030 100644 --- a/src/Toolkit/tests/Command/UxToolkitInstallCommandTest.php +++ b/src/Toolkit/tests/Command/UxToolkitInstallCommandTest.php @@ -15,15 +15,20 @@ use Symfony\Component\Console\Command\Command; use Symfony\Component\Console\Input\ArrayInput; use Symfony\Component\Console\Output\BufferedOutput; +use Symfony\Component\Filesystem\Filesystem; use Symfony\UX\Toolkit\Command\UxToolkitInstallCommand; use Symfony\UX\Toolkit\Compiler\TwigComponentCompiler; -use Symfony\UX\Toolkit\ComponentRepository\ComponentIdentifier; +use Symfony\UX\Toolkit\ComponentRepository\CurrentTheme; +use Symfony\UX\Toolkit\ComponentRepository\RepositoryIdentifier; use Symfony\UX\Toolkit\ComponentRepository\GithubRepository; use Symfony\UX\Toolkit\ComponentRepository\OfficialRepository; use Symfony\UX\Toolkit\ComponentRepository\RepositoryFactory; +use Symfony\UX\Toolkit\Registry\RegistryFactory; /** * @author Jean-François Lépine + * + * @group wip */ class UxToolkitInstallCommandTest extends TestCase { @@ -33,15 +38,16 @@ public function testShouldAbleToCreateTheBadgeComponent(): void new OfficialRepository(), $this->createMock(GithubRepository::class) ); - $identifier = new ComponentIdentifier(); - $compiler = new TwigComponentCompiler('default', 'Acme'); - $command = new UxToolkitInstallCommand($repositoryFactory, $identifier, $compiler); + $currentTheme = new CurrentTheme('default', $repositoryFactory, new RepositoryIdentifier()); + $compiler = new TwigComponentCompiler('Acme'); + $registryFactory = new RegistryFactory(new Filesystem()); + $command = new UxToolkitInstallCommand($currentTheme, $registryFactory, $compiler); $destination = sys_get_temp_dir().\DIRECTORY_SEPARATOR.uniqid(); mkdir($destination); $input = new ArrayInput([ - 'component' => 'badge', + 'component' => 'Badge', '--destination' => $destination, ]); @@ -49,7 +55,6 @@ public function testShouldAbleToCreateTheBadgeComponent(): void $output = new BufferedOutput(); $result = $command->run($input, $output); - // Command should be successful $this->assertEquals(Command::SUCCESS, $result); @@ -62,33 +67,4 @@ public function testShouldAbleToCreateTheBadgeComponent(): void $actualContent = file_get_contents($expectedFile); $this->assertEquals($expectedContent, $actualContent); } - - public function testByDefaultCannotEraseComponentByMistake(): void - { - $repositoryFactory = new RepositoryFactory( - new OfficialRepository(), - $this->createMock(GithubRepository::class) - ); - $identifier = new ComponentIdentifier(); - $compiler = new TwigComponentCompiler('default', 'ux'); - $command = new UxToolkitInstallCommand($repositoryFactory, $identifier, $compiler); - - $destination = sys_get_temp_dir().\DIRECTORY_SEPARATOR.uniqid(); - mkdir($destination); - - $input = new ArrayInput([ - 'component' => 'badge', - '--destination' => $destination, - ]); - - $input->setInteractive(false); - $output = new BufferedOutput(); - - $result = $command->run($input, $output); - $this->assertEquals(Command::SUCCESS, $result); - - // run again => should fail (non interactive mode) - $result = $command->run($input, $output); - $this->assertEquals(Command::FAILURE, $result); - } } diff --git a/src/Toolkit/tests/Compiler/TwigComponentCompilerTest.php b/src/Toolkit/tests/Compiler/TwigComponentCompilerTest.php index 5a082c2ddf..4fc750805a 100644 --- a/src/Toolkit/tests/Compiler/TwigComponentCompilerTest.php +++ b/src/Toolkit/tests/Compiler/TwigComponentCompilerTest.php @@ -14,8 +14,10 @@ use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase; use Symfony\UX\Toolkit\Compiler\Exception\TwigComponentAlreadyExist; use Symfony\UX\Toolkit\Compiler\TwigComponentCompiler; -use Symfony\UX\Toolkit\ComponentRepository\ComponentIdentity; use Symfony\UX\Toolkit\ComponentRepository\OfficialRepository; +use Symfony\UX\Toolkit\ComponentRepository\RepositoryIdentity; +use Symfony\UX\Toolkit\Registry\RegistryItem; +use Symfony\UX\Toolkit\Registry\RegistryItemType; /** * @author Jean-François Lépine @@ -24,28 +26,42 @@ class TwigComponentCompilerTest extends KernelTestCase { public function testItShouldCompileComponentToFile(): void { - $compiler = new TwigComponentCompiler('default', 'Acme'); + $compiler = new TwigComponentCompiler('Acme'); $destination = sys_get_temp_dir().\DIRECTORY_SEPARATOR.uniqid('component_'); - $repository = new OfficialRepository(); - $component = new ComponentIdentity('symfony', '', 'Badge'); - $compiler->compile($component, $repository, $destination); + + $item = new RegistryItem( + 'Badge', + RegistryItemType::Component, + 'default', + null, + '' + ); + + $compiler->compile($item, $destination); $this->assertFileExists($destination); $this->assertFileExists($destination.'/Acme/Badge.html.twig'); $content = file_get_contents($destination.'/Acme/Badge.html.twig'); - $this->assertStringContainsString('{% block content %}{% endblock %}', $content); + $this->assertStringContainsString('', $content); } public function testShouldThrowExceptionIfFileAlreadyExist(): void { - $compiler = new TwigComponentCompiler('default', 'ux'); + $compiler = new TwigComponentCompiler('Acme'); $destination = sys_get_temp_dir().\DIRECTORY_SEPARATOR.uniqid('component_'); - $repository = new OfficialRepository(); - $component = new ComponentIdentity('symfony', '', 'Badge'); - $compiler->compile($component, $repository, $destination); + + $item = new RegistryItem( + 'Badge', + RegistryItemType::Component, + 'default', + null, + '' + ); + + $compiler->compile($item, $destination); $this->expectException(TwigComponentAlreadyExist::class); - $compiler->compile($component, $repository, $destination); + $compiler->compile($item, $destination); } } diff --git a/src/Toolkit/tests/ComponentRepository/GithubRepositoryTest.php b/src/Toolkit/tests/ComponentRepository/GithubRepositoryTest.php index da5fcb87de..f412a39612 100644 --- a/src/Toolkit/tests/ComponentRepository/GithubRepositoryTest.php +++ b/src/Toolkit/tests/ComponentRepository/GithubRepositoryTest.php @@ -12,10 +12,12 @@ namespace Symfony\UX\Toolkit\Tests\ComponentRepository; use PHPUnit\Framework\TestCase; +use Symfony\Component\Filesystem\Filesystem; use Symfony\Component\HttpClient\MockHttpClient; use Symfony\Component\HttpClient\Response\MockResponse; -use Symfony\UX\Toolkit\ComponentRepository\ComponentIdentity; use Symfony\UX\Toolkit\ComponentRepository\GithubRepository; +use Symfony\UX\Toolkit\ComponentRepository\RepositoryIdentity; +use Symfony\UX\Toolkit\ComponentRepository\RepositorySources; /** * @author Jean-François Lépine @@ -24,13 +26,79 @@ class GithubRepositoryTest extends TestCase { public function testGithubRepositoryUseClientAndTryToDownloadRemoteFile(): void { + // Create a zip file with a pseudo manifest.json file + $manifest = '{"name:""Haleck45/ux-toolkit","version":"1.0.0"}'; + $workdir = sys_get_temp_dir().'/ux-toolkit'; + $filesystem = new Filesystem(); + $filesystem->mkdir($workdir); + $filesystem->dumpFile($workdir.'/manifest.json', $manifest); + $filesystem->dumpFile($workdir.'/README.md', 'My readme content'); + + $zip = new \ZipArchive(); + $zip->open($workdir.'/ux-toolkit-1.0.0.zip', \ZipArchive::CREATE); + $zip->addFile($workdir.'/manifest.json', 'manifest.json'); + $zip->addFile($workdir.'/README.md', 'README.md'); + $zip->close(); + + // Create a mock http client that will return the zip file $client = new MockHttpClient(); - $client->setResponseFactory(fn() => new MockResponse('My badge content from github')); - $repository = new GithubRepository($client); + $client->setResponseFactory(fn () => new MockResponse( + file_get_contents($workdir.'/ux-toolkit-1.0.0.zip'), + [ + 'http_code' => 200, + 'response_headers' => [ + 'content-type' => 'application/zip', + ], + ] + )); + + $filesystem = new Filesystem(); + $repository = new GithubRepository($filesystem, $client); + + $component = new RepositoryIdentity(RepositorySources::GITHUB, 'Halleck45', 'ux-toolkit', '1.0.0'); + $finder = $repository->fetch($component); + + // the manifest file should be extracted + $manifestFile = $finder->files()->path('manifest.json')->count(); + $this->assertSame(1, $manifestFile); + } + + public function testGithubRepositorybUTwITHiNVALIDhEADERS(): void + { + // Create a zip file with a pseudo manifest.json file + $manifest = '{"name:""Haleck45/ux-toolkit","version":"1.0.0"}'; + $workdir = sys_get_temp_dir().'/ux-toolkit'; + $filesystem = new Filesystem(); + $filesystem->mkdir($workdir); + $filesystem->dumpFile($workdir.'/manifest.json', $manifest); + $filesystem->dumpFile($workdir.'/README.md', 'My readme content'); + + $zip = new \ZipArchive(); + $zip->open($workdir.'/ux-toolkit-1.0.0.zip', \ZipArchive::CREATE); + $zip->addFile($workdir.'/manifest.json', 'manifest.json'); + $zip->addFile($workdir.'/README.md', 'README.md'); + $zip->close(); + + // Create a mock http client that will return the zip file + $client = new MockHttpClient(); + $client->setResponseFactory(fn () => new MockResponse( + file_get_contents($workdir.'/ux-toolkit-1.0.0.zip'), + [ + 'http_code' => 200, + 'response_headers' => [ + 'content-type' => 'application/json', + ], + ] + )); + + $filesystem = new Filesystem(); + $repository = new GithubRepository($filesystem, $client); + + $component = new RepositoryIdentity(RepositorySources::GITHUB, 'Halleck45', 'ux-toolkit', '1.0.0'); - $component = new ComponentIdentity('Halleck45', 'ux-toolkit', 'Badge', '1.0.0'); - $content = $repository->getContent($component); + $this->expectException(\RuntimeException::class); + $this->expectExceptionMessage('The file from "http://github.com/Halleck45/ux-toolkit/archive/1.0.0.zip" is not a valid zip file.'); - $this->assertEquals('My badge content from github', $content); + $repository->fetch($component); } } diff --git a/src/Toolkit/tests/ComponentRepository/OfficialRepositoryTest.php b/src/Toolkit/tests/ComponentRepository/OfficialRepositoryTest.php index 441ec82969..7eefbf8bb7 100644 --- a/src/Toolkit/tests/ComponentRepository/OfficialRepositoryTest.php +++ b/src/Toolkit/tests/ComponentRepository/OfficialRepositoryTest.php @@ -12,8 +12,10 @@ namespace Symfony\UX\Toolkit\Tests\ComponentRepository; use PHPUnit\Framework\TestCase; -use Symfony\UX\Toolkit\ComponentRepository\ComponentIdentity; +use Symfony\Component\Finder\Exception\DirectoryNotFoundException; +use Symfony\UX\Toolkit\ComponentRepository\RepositoryIdentity; use Symfony\UX\Toolkit\ComponentRepository\OfficialRepository; +use Symfony\UX\Toolkit\ComponentRepository\RepositorySources; /** * @author Jean-François Lépine @@ -23,21 +25,20 @@ class OfficialRepositoryTest extends TestCase public function testOfficialRepositoryGetContentOfExistentComponent(): void { $repository = new OfficialRepository(); - $component = new ComponentIdentity('symfony', 'ux-toolkit', 'Badge'); + $identity = new RepositoryIdentity(RepositorySources::EMBEDDED, 'symfony', 'default'); - $content = $repository->getContent($component); + $finder = $repository->fetch($identity); - $expectedContent = file_get_contents(__DIR__ . '/../../templates/default/components/Badge.html.twig'); - $this->assertEquals($expectedContent, $content); + $exists = $finder->files()->path('registry.json')->count(); + $this->assertEquals(1, $exists); } public function testOfficialRepositoryFailWhenComponentDoesNotExist(): void { $repository = new OfficialRepository(); - $component = new ComponentIdentity('symfony', 'ux-toolkit', 'NonExistentComponent'); + $identity = new RepositoryIdentity(RepositorySources::EMBEDDED, 'symfony', 'unexistent'); - $this->expectException(\InvalidArgumentException::class); - $this->expectExceptionMessage('The component "NonExistentComponent" does not exist'); - $repository->getContent($component); + $this->expectException(DirectoryNotFoundException::class); + $finder = $repository->fetch($identity); } } diff --git a/src/Toolkit/tests/ComponentRepository/RepositoryFactoryTest.php b/src/Toolkit/tests/ComponentRepository/RepositoryFactoryTest.php index 596db5d41b..fae987a988 100644 --- a/src/Toolkit/tests/ComponentRepository/RepositoryFactoryTest.php +++ b/src/Toolkit/tests/ComponentRepository/RepositoryFactoryTest.php @@ -12,10 +12,11 @@ namespace Symfony\UX\Toolkit\Tests\ComponentRepository; use PHPUnit\Framework\TestCase; -use Symfony\UX\Toolkit\ComponentRepository\ComponentIdentity; use Symfony\UX\Toolkit\ComponentRepository\GithubRepository; use Symfony\UX\Toolkit\ComponentRepository\OfficialRepository; use Symfony\UX\Toolkit\ComponentRepository\RepositoryFactory; +use Symfony\UX\Toolkit\ComponentRepository\RepositoryIdentity; +use Symfony\UX\Toolkit\ComponentRepository\RepositorySources; /** * @author Jean-François Lépine @@ -23,11 +24,10 @@ class RepositoryFactoryTest extends TestCase { /** - * @dataProvider providesNames + * @dataProvider providesSources */ public function testItShouldFactoryRepositoryAccordingToItsName( - string $vendor, - string $name, + int $type, ?string $expectedInstance, bool $shouldThrowException = false, ): void { @@ -40,7 +40,7 @@ public function testItShouldFactoryRepositoryAccordingToItsName( $this->expectException(\InvalidArgumentException::class); } - $result = $factory->factory(new ComponentIdentity($vendor, 'mypackage', $name)); + $result = $factory->factory(new RepositoryIdentity($type, 'myvendor', 'mypackage')); if ($shouldThrowException) { return; @@ -49,13 +49,12 @@ public function testItShouldFactoryRepositoryAccordingToItsName( $this->assertInstanceOf($expectedInstance, $result); } - public static function providesNames(): array + public static function providesSources(): array { return [ - ['symfony', 'button', OfficialRepository::class, false], - ['github.com/Foo', 'bar', GithubRepository::class, false], - ['gitlab.com/Foo', 'bar', null, true], - ['invalid name', 'bar', null, true], + [RepositorySources::EMBEDDED, OfficialRepository::class, false], + [RepositorySources::GITHUB, GithubRepository::class, false], + [99, null, true], ]; } -} \ No newline at end of file +} diff --git a/src/Toolkit/tests/ComponentRepository/ComponentIdentifierTest.php b/src/Toolkit/tests/ComponentRepository/RepositoryIdentifierTest.php similarity index 61% rename from src/Toolkit/tests/ComponentRepository/ComponentIdentifierTest.php rename to src/Toolkit/tests/ComponentRepository/RepositoryIdentifierTest.php index e8a39435bd..f40a04ba8d 100644 --- a/src/Toolkit/tests/ComponentRepository/ComponentIdentifierTest.php +++ b/src/Toolkit/tests/ComponentRepository/RepositoryIdentifierTest.php @@ -12,54 +12,54 @@ namespace Symfony\UX\Toolkit\Tests\ComponentRepository; use PHPUnit\Framework\TestCase; -use Symfony\UX\Toolkit\ComponentRepository\ComponentIdentifier; -use Symfony\UX\Toolkit\ComponentRepository\ComponentIdentity; +use Symfony\UX\Toolkit\ComponentRepository\RepositoryIdentifier; +use Symfony\UX\Toolkit\ComponentRepository\RepositoryIdentity; +use Symfony\UX\Toolkit\ComponentRepository\RepositorySources; /** * @author Jean-François Lépine */ -class ComponentIdentifierTest extends TestCase +class RepositoryIdentifierTest extends TestCase { public function testItShouldIdentifyOfficialComponent(): void { - $identifier = new ComponentIdentifier(); - $identity = $identifier->identify('button'); + $identifier = new RepositoryIdentifier(); + $identity = $identifier->identify('default'); - $this->assertInstanceOf(ComponentIdentity::class, $identity); + $this->assertInstanceOf(RepositoryIdentity::class, $identity); + $this->assertEquals(RepositorySources::EMBEDDED, $identity->getType()); $this->assertEquals('symfony', $identity->getVendor()); - $this->assertEquals('Button', $identity->getName()); } public function testItShouldIdentifyGithubComponent(): void { - $identifier = new ComponentIdentifier(); - $identity = $identifier->identify('https://github.com/Halleck45/uikit:button'); + $identifier = new RepositoryIdentifier(); + $identity = $identifier->identify('https://github.com/Halleck45/uikit'); + $this->assertEquals(RepositorySources::GITHUB, $identity->getType()); $this->assertEquals('Halleck45', $identity->getVendor()); $this->assertEquals('uikit', $identity->getPackage()); - $this->assertEquals('Button', $identity->getName()); - $this->assertEquals('main', $identity->getVersion()); } public function testItShouldIdentifiyGithubComponentEventWithoutScheme(): void { - $identifier = new ComponentIdentifier(); - $identity = $identifier->identify('github.com/Halleck45/uikit:button'); + $identifier = new RepositoryIdentifier(); + $identity = $identifier->identify('github.com/Halleck45/uikit'); + $this->assertEquals(RepositorySources::GITHUB, $identity->getType()); $this->assertEquals('Halleck45', $identity->getVendor()); $this->assertEquals('uikit', $identity->getPackage()); - $this->assertEquals('Button', $identity->getName()); $this->assertEquals('main', $identity->getVersion()); } public function testItShouldIdentifyGithubComponentWithVersion(): void { - $identifier = new ComponentIdentifier(); - $identity = $identifier->identify('github.com/Halleck45/uikit:button@v1.0.0'); + $identifier = new RepositoryIdentifier(); + $identity = $identifier->identify('github.com/Halleck45/uikit@v1.0.0'); + $this->assertEquals(RepositorySources::GITHUB, $identity->getType()); $this->assertEquals('Halleck45', $identity->getVendor()); $this->assertEquals('uikit', $identity->getPackage()); - $this->assertEquals('Button', $identity->getName()); $this->assertEquals('v1.0.0', $identity->getVersion()); } }