diff --git a/README.md b/README.md index 0755886..32bc17c 100644 --- a/README.md +++ b/README.md @@ -66,6 +66,7 @@ return [ - PHP `>=8.3 <8.6` - Symfony 7.4, 8.0, and 8.1+ - Optional: Doctrine ORM (default persistence), Messenger, Twig / Web Profiler +- Twig Extra (`twig/extra-bundle`, `twig/string-extra`; installed with the bundle, register `TwigExtraBundle` without Flex) ## Demo diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index f131624..347346e 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -28,6 +28,16 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Changed + +- **Twig template overrides win (REQ-TWIG-001).** The views were registered by prepending `twig.paths`, so an application + copy under `templates/bundles/NowoDeviceIntelligenceBundle/` was not guaranteed to take precedence. `TwigPathsPass` + now prepends that directory (when it exists) and then adds the bundle views on the native Twig loader. + +### Documentation + +- INSTALLATION / README: Twig Extra requirement (REQ-TWIG-004); overridable templates table, procedure and freeze rule. + ## [1.4.1] - 2026-10-25 Reproducible JS bundle: the build stamp is a hash of the sources instead of the build time. See [UPGRADING](UPGRADING.md#to-141). diff --git a/docs/INSTALLATION.md b/docs/INSTALLATION.md index 6eddde3..b1d6c65 100644 --- a/docs/INSTALLATION.md +++ b/docs/INSTALLATION.md @@ -17,6 +17,8 @@ - PHP `>=8.3 <8.6` - Symfony `^7.4` or `^8.0` (covers 8.1+) +- Twig Extra: `twig/extra-bundle` and `twig/string-extra` (required by the bundle and installed with it, REQ-TWIG-004). + Without Flex, register `Twig\Extra\TwigExtraBundle\TwigExtraBundle` in `config/bundles.php` ## Composer @@ -104,17 +106,23 @@ Install `pentatrion/vite-bundle` and `vite-plugin-symfony`, then import the sour ## Twig template overrides -Profiler (and any future) templates live under `src/Resources/views/` with namespace **`NowoDeviceIntelligenceBundle`**. +Templates live under `src/Resources/views/` with namespace **`NowoDeviceIntelligenceBundle`**. `TwigPathsPass` registers +`templates/bundles/NowoDeviceIntelligenceBundle/` first (when it exists) and then the bundle views, so application files +**always win**. -Override from the host app: - -``` -templates/bundles/NowoDeviceIntelligenceBundle/Collector/device_intelligence.html.twig -``` +| `` | Purpose | +| --- | --- | +| `Collector/device_intelligence.html.twig` | Web profiler panel and toolbar item (`nowo_device_intelligence`) | +| `Icon/device-intelligence.svg` | Icon used by the profiler panel | -A full-file override hides vendor updates for that path until you merge them. Prefer small diffs. +**Procedure:** copy `vendor/nowo-tech/device-intelligence-bundle/src/Resources/views/` to +`templates/bundles/NowoDeviceIntelligenceBundle/`, edit it, then `php bin/console cache:clear`. Logical names: +`@NowoDeviceIntelligenceBundle/`. -Logical names: `@NowoDeviceIntelligenceBundle/Collector/device_intelligence.html.twig`. +**Override vs upgrade (freeze rule).** An override does not pick up vendor changes for that `` until you remove +or merge it: the file stays frozen on every `composer update` while the rest of the bundle upgrades. The panel is a dev +tool, so prefer leaving it as is and reading the data through the collector service or the events. To recover the vendor +version, delete the override, clear the cache and diff your copy against the new vendor file before re-applying changes. ## Optional packages diff --git a/src/DependencyInjection/Compiler/TwigPathsPass.php b/src/DependencyInjection/Compiler/TwigPathsPass.php new file mode 100644 index 0000000..093a36c --- /dev/null +++ b/src/DependencyInjection/Compiler/TwigPathsPass.php @@ -0,0 +1,71 @@ +getNativeLoaderServiceId($container); + if (null === $loaderId) { + return; + } + + $viewsPath = \dirname(__DIR__, 2).'/Resources/views'; + $definition = $container->getDefinition($loaderId); + + if ($container->hasParameter('kernel.project_dir')) { + $projectDirParam = $container->getParameter('kernel.project_dir'); + if (\is_string($projectDirParam)) { + $projectDir = rtrim($projectDirParam, '/\\'); + $overridePath = $projectDir.'/templates/bundles/NowoDeviceIntelligenceBundle'; + if (is_dir($overridePath)) { + // @igor-ignore - Container compile-time DI mutation; not runtime worker state. + $definition->addMethodCall('prependPath', [$overridePath, self::TWIG_NAMESPACE]); + } + } + } + + // @igor-ignore - Container compile-time DI mutation; not runtime worker state. + $definition->addMethodCall('addPath', [$viewsPath, self::TWIG_NAMESPACE]); + } + + private function getNativeLoaderServiceId(ContainerBuilder $container): ?string + { + if ($container->hasAlias('twig.loader.native')) { + $resolved = $this->resolveDefinitionId($container, (string) $container->getAlias('twig.loader.native')); + if (null !== $resolved) { + return $resolved; + } + } + + if ($container->hasDefinition('twig.loader.native')) { + return 'twig.loader.native'; + } + + if ($container->hasDefinition('twig.loader.native_filesystem')) { + return 'twig.loader.native_filesystem'; + } + + return null; + } + + private function resolveDefinitionId(ContainerBuilder $container, string $id): ?string + { + for ($i = 0; $i < 32 && $container->hasAlias($id); ++$i) { + $id = (string) $container->getAlias($id); + } + + return $container->hasDefinition($id) ? $id : null; + } +} diff --git a/src/DependencyInjection/NowoDeviceIntelligenceExtension.php b/src/DependencyInjection/NowoDeviceIntelligenceExtension.php index 67c8c5e..490cf55 100644 --- a/src/DependencyInjection/NowoDeviceIntelligenceExtension.php +++ b/src/DependencyInjection/NowoDeviceIntelligenceExtension.php @@ -74,14 +74,6 @@ public function getAlias(): string */ public function prepend(ContainerBuilder $container): void { - if ($container->hasExtension('twig')) { - $container->prependExtensionConfig('twig', [ - 'paths' => [ - __DIR__.'/../Resources/views' => 'NowoDeviceIntelligenceBundle', - ], - ]); - } - if ($container->hasExtension('framework')) { $container->prependExtensionConfig('framework', [ 'assets' => [ diff --git a/src/NowoDeviceIntelligenceBundle.php b/src/NowoDeviceIntelligenceBundle.php index b833dc4..a2508df 100644 --- a/src/NowoDeviceIntelligenceBundle.php +++ b/src/NowoDeviceIntelligenceBundle.php @@ -5,6 +5,7 @@ namespace Nowo\DeviceIntelligenceBundle; use Doctrine\Bundle\DoctrineBundle\DependencyInjection\Compiler\DoctrineOrmMappingsPass; +use Nowo\DeviceIntelligenceBundle\DependencyInjection\Compiler\TwigPathsPass; use Nowo\DeviceIntelligenceBundle\DependencyInjection\NowoDeviceIntelligenceExtension; use Symfony\Component\DependencyInjection\ContainerBuilder; use Symfony\Component\DependencyInjection\Extension\ExtensionInterface; @@ -23,6 +24,9 @@ final class NowoDeviceIntelligenceBundle extends Bundle */ public function build(ContainerBuilder $container): void { + // REQ-TWIG-001: templates/bundles/NowoDeviceIntelligenceBundle/ first, then the bundle views. + $container->addCompilerPass(new TwigPathsPass()); + $entityDir = __DIR__.'/Entity'; if (is_dir($entityDir) && class_exists(DoctrineOrmMappingsPass::class)) { $container->addCompilerPass(DoctrineOrmMappingsPass::createAttributeMappingDriver( diff --git a/tests/Integration/ExtensionPrependTest.php b/tests/Integration/ExtensionPrependTest.php index 5dea73c..4381cc8 100644 --- a/tests/Integration/ExtensionPrependTest.php +++ b/tests/Integration/ExtensionPrependTest.php @@ -7,6 +7,7 @@ use Nowo\DeviceIntelligence\DeviceIntelligence; use Nowo\DeviceIntelligence\Infrastructure\InMemoryDeviceRepository; use Nowo\DeviceIntelligence\Port\DeviceRepositoryInterface; +use Nowo\DeviceIntelligenceBundle\DependencyInjection\Compiler\TwigPathsPass; use Nowo\DeviceIntelligenceBundle\DependencyInjection\Configuration; use Nowo\DeviceIntelligenceBundle\DependencyInjection\NowoDeviceIntelligenceExtension; use Nowo\DeviceIntelligenceBundle\Doctrine\DoctrineDeviceRepository; @@ -60,8 +61,8 @@ public function load(array $configs, ContainerBuilder $container): void '/Resources/translations', $framework[0]['translator']['paths'][0], ); - $twig = $container->getExtensionConfig('twig'); - self::assertContains('NowoDeviceIntelligenceBundle', $twig[0]['paths']); + // Twig paths are registered by TwigPathsPass (app overrides first), not prepended as twig.paths. + self::assertSame([], $container->getExtensionConfig('twig')); } public function testBundleExposesExtension(): void @@ -70,8 +71,11 @@ public function testBundleExposesExtension(): void $ext = $bundle->getContainerExtension(); self::assertInstanceOf(NowoDeviceIntelligenceExtension::class, $ext); self::assertSame('nowo_device_intelligence', $ext->getAlias()); - $bundle->build(new ContainerBuilder()); + $builder = new ContainerBuilder(); + $bundle->build($builder); self::assertSame($ext, $bundle->getContainerExtension()); + $passes = array_map(static fn (object $pass): string => $pass::class, $builder->getCompilerPassConfig()->getBeforeOptimizationPasses()); + self::assertContains(TwigPathsPass::class, $passes); } public function testLoadRegistersParametersAndInMemoryWhenDoctrineDisabled(): void diff --git a/tests/Unit/DependencyInjection/Compiler/TwigPathsPassTest.php b/tests/Unit/DependencyInjection/Compiler/TwigPathsPassTest.php new file mode 100644 index 0000000..d394ad0 --- /dev/null +++ b/tests/Unit/DependencyInjection/Compiler/TwigPathsPassTest.php @@ -0,0 +1,122 @@ +setParameter('kernel.project_dir', sys_get_temp_dir()); + + (new TwigPathsPass())->process($container); + + self::assertFalse($container->hasDefinition('twig.loader.native')); + } + + public function testProcessAddsBundleViewsPathToNativeLoader(): void + { + $container = new ContainerBuilder(); + $container->setParameter('kernel.project_dir', sys_get_temp_dir()); + + $definition = new Definition('Twig\\Loader\\FilesystemLoader'); + $container->setDefinition('twig.loader.native', $definition); + + (new TwigPathsPass())->process($container); + + $calls = $definition->getMethodCalls(); + self::assertNotEmpty($calls); + + $addPathCall = null; + foreach ($calls as $call) { + if ('addPath' === $call[0]) { + $addPathCall = $call; + break; + } + } + + self::assertNotNull($addPathCall); + self::assertStringContainsString('Resources/views', $addPathCall[1][0]); + self::assertSame('NowoDeviceIntelligenceBundle', $addPathCall[1][1]); + } + + public function testProcessUsesNativeFilesystemLoaderDefinition(): void + { + $container = new ContainerBuilder(); + $container->setParameter('kernel.project_dir', sys_get_temp_dir()); + + $definition = new Definition('Twig\\Loader\\FilesystemLoader'); + $container->setDefinition('twig.loader.native_filesystem', $definition); + + (new TwigPathsPass())->process($container); + + self::assertNotEmpty($definition->getMethodCalls()); + } + + public function testProcessResolvesChainedAliasToDefinition(): void + { + $container = new ContainerBuilder(); + $container->setParameter('kernel.project_dir', sys_get_temp_dir()); + + $definition = new Definition('Twig\\Loader\\FilesystemLoader'); + $container->setDefinition('twig.loader.real', $definition); + $container->setAlias('twig.loader.chain', 'twig.loader.real'); + $container->setAlias('twig.loader.native', 'twig.loader.chain'); + + (new TwigPathsPass())->process($container); + + self::assertNotEmpty($definition->getMethodCalls()); + } + + public function testProcessIgnoresAliasWithoutDefinition(): void + { + $container = new ContainerBuilder(); + $container->setParameter('kernel.project_dir', sys_get_temp_dir()); + $container->setAlias('twig.loader.native', 'twig.loader.missing'); + + (new TwigPathsPass())->process($container); + + self::assertFalse($container->hasDefinition('twig.loader.missing')); + } + + public function testProcessPrependsOverridePathWhenDirectoryExists(): void + { + $projectDir = sys_get_temp_dir().'/device_intelligence_project_'.uniqid('', true); + $overrideDir = $projectDir.'/templates/bundles/NowoDeviceIntelligenceBundle'; + mkdir($overrideDir, 0o777, true); + + try { + $container = new ContainerBuilder(); + $container->setParameter('kernel.project_dir', $projectDir); + + $definition = new Definition('Twig\\Loader\\FilesystemLoader'); + $container->setDefinition('twig.loader.native', $definition); + + (new TwigPathsPass())->process($container); + + $prependCall = null; + foreach ($definition->getMethodCalls() as $call) { + if ('prependPath' === $call[0]) { + $prependCall = $call; + break; + } + } + + self::assertNotNull($prependCall); + self::assertSame($overrideDir, $prependCall[1][0]); + self::assertSame('NowoDeviceIntelligenceBundle', $prependCall[1][1]); + } finally { + rmdir($overrideDir); + rmdir(\dirname($overrideDir)); + rmdir(\dirname($overrideDir, 2)); + rmdir($projectDir); + } + } +}