Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
10 changes: 10 additions & 0 deletions docs/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand Down
24 changes: 16 additions & 8 deletions docs/INSTALLATION.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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
```
| `<subpath>` | 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/<subpath>` to
`templates/bundles/NowoDeviceIntelligenceBundle/<subpath>`, edit it, then `php bin/console cache:clear`. Logical names:
`@NowoDeviceIntelligenceBundle/<subpath>`.

Logical names: `@NowoDeviceIntelligenceBundle/Collector/device_intelligence.html.twig`.
**Override vs upgrade (freeze rule).** An override does not pick up vendor changes for that `<subpath>` 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

Expand Down
71 changes: 71 additions & 0 deletions src/DependencyInjection/Compiler/TwigPathsPass.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
<?php

declare(strict_types=1);

namespace Nowo\DeviceIntelligenceBundle\DependencyInjection\Compiler;

use Symfony\Component\DependencyInjection\Compiler\CompilerPassInterface;
use Symfony\Component\DependencyInjection\ContainerBuilder;

/**
* REQ-TWIG-001: application overrides win over bundle templates.
*/
final class TwigPathsPass implements CompilerPassInterface
{
private const TWIG_NAMESPACE = 'NowoDeviceIntelligenceBundle';

public function process(ContainerBuilder $container): void
{
$loaderId = $this->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;
}
}
8 changes: 0 additions & 8 deletions src/DependencyInjection/NowoDeviceIntelligenceExtension.php
Original file line number Diff line number Diff line change
Expand Up @@ -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' => [
Expand Down
4 changes: 4 additions & 0 deletions src/NowoDeviceIntelligenceBundle.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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(
Expand Down
10 changes: 7 additions & 3 deletions tests/Integration/ExtensionPrependTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand Down
122 changes: 122 additions & 0 deletions tests/Unit/DependencyInjection/Compiler/TwigPathsPassTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,122 @@
<?php

declare(strict_types=1);

namespace Nowo\DeviceIntelligenceBundle\Tests\Unit\DependencyInjection\Compiler;

use Nowo\DeviceIntelligenceBundle\DependencyInjection\Compiler\TwigPathsPass;
use PHPUnit\Framework\TestCase;
use Symfony\Component\DependencyInjection\ContainerBuilder;
use Symfony\Component\DependencyInjection\Definition;

final class TwigPathsPassTest extends TestCase
{
public function testProcessWithoutTwigLoaderIsNoOp(): void
{
$container = new ContainerBuilder();
$container->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);
}
}
}
Loading