From b8acf1a2617be7f007ce26c4fd6f10e486e1ab57 Mon Sep 17 00:00:00 2001 From: Ryan Weaver Date: Fri, 19 May 2023 16:07:27 -0400 Subject: [PATCH] Adding Asset Mapper support + new StimulusBundle --- .github/build-packages.php | 62 +++- .github/workflows/test-turbo.yml | 8 +- .github/workflows/test.yaml | 129 +++---- README.md | 5 +- build.js | 32 ++ composer.json | 1 + package.json | 2 +- rollup.config.js | 69 ++-- src/Autocomplete/CHANGELOG.md | 4 + src/Autocomplete/assets/package.json | 4 + src/Autocomplete/composer.json | 5 +- .../AutocompleteExtension.php | 19 +- src/Chartjs/CHANGELOG.md | 10 + src/Chartjs/assets/package.json | 4 + src/Chartjs/composer.json | 21 +- src/Chartjs/doc/index.rst | 4 +- .../DependencyInjection/ChartjsExtension.php | 33 +- src/Chartjs/src/Twig/ChartExtension.php | 35 +- src/Chartjs/tests/ChartjsBundleTest.php | 16 +- src/Chartjs/tests/Kernel/AppKernelTrait.php | 41 --- src/Chartjs/tests/Kernel/EmptyAppKernel.php | 35 -- .../tests/Kernel/FrameworkAppKernel.php | 40 --- src/Chartjs/tests/Kernel/TwigAppKernel.php | 30 +- src/Chartjs/tests/Twig/ChartExtensionTest.php | 2 - src/Cropperjs/CHANGELOG.md | 6 + src/Cropperjs/assets/package.json | 4 + src/Cropperjs/composer.json | 18 +- .../CropperjsExtension.php | 19 +- src/Dropzone/CHANGELOG.md | 4 + src/Dropzone/assets/package.json | 3 + src/Dropzone/composer.json | 16 +- .../DependencyInjection/DropzoneExtension.php | 15 +- src/LazyImage/CHANGELOG.md | 6 + src/LazyImage/assets/package.json | 3 + src/LazyImage/composer.json | 12 +- src/LazyImage/doc/index.rst | 4 +- .../LazyImageExtension.php | 19 +- src/LiveComponent/CHANGELOG.md | 4 + src/LiveComponent/assets/package.json | 3 + .../LiveComponentExtension.php | 15 +- src/Notify/CHANGELOG.md | 6 + src/Notify/assets/package.json | 3 + src/Notify/composer.json | 4 +- .../DependencyInjection/NotifyExtension.php | 21 +- src/Notify/src/Twig/NotifyExtension.php | 2 +- src/Notify/src/Twig/NotifyRuntime.php | 31 +- src/Notify/tests/Kernel/AppKernelTrait.php | 41 --- src/Notify/tests/Kernel/EmptyAppKernel.php | 35 -- .../tests/Kernel/FrameworkAppKernel.php | 41 --- src/Notify/tests/Kernel/TwigAppKernel.php | 28 +- src/Notify/tests/NotifyBundleTest.php | 19 +- src/Notify/tests/Twig/NotifyRuntimeTest.php | 6 +- src/React/CHANGELOG.md | 8 + src/React/assets/package.json | 5 + src/React/composer.json | 11 +- .../DependencyInjection/ReactExtension.php | 21 +- .../src/Twig/ReactComponentExtension.php | 25 +- src/React/tests/Kernel/AppKernelTrait.php | 41 --- src/React/tests/Kernel/FrameworkAppKernel.php | 42 --- src/React/tests/Kernel/TwigAppKernel.php | 28 +- src/React/tests/ReactBundleTest.php | 14 +- .../Twig/ReactComponentExtensionTest.php | 3 +- src/StimulusBundle/.gitignore | 5 + src/StimulusBundle/.symfony.bundle.yaml | 3 + src/StimulusBundle/CHANGELOG.md | 5 + src/StimulusBundle/README.md | 14 + .../assets/dist/controllers.d.ts | 12 + src/StimulusBundle/assets/dist/controllers.js | 5 + src/StimulusBundle/assets/dist/loader.d.ts | 4 + src/StimulusBundle/assets/dist/loader.js | 83 +++++ src/StimulusBundle/assets/jest.config.js | 1 + src/StimulusBundle/assets/package.json | 17 + src/StimulusBundle/assets/src/controllers.ts | 13 + src/StimulusBundle/assets/src/loader.ts | 128 +++++++ src/StimulusBundle/assets/test/loader.test.ts | 58 ++++ src/StimulusBundle/composer.json | 41 +++ src/StimulusBundle/config/services.php | 52 +++ src/StimulusBundle/doc/index.rst | 316 ++++++++++++++++++ src/StimulusBundle/phpunit.xml.dist | 34 ++ .../AssetMapper/ControllersMapGenerator.php | 126 +++++++ .../src/AssetMapper/MappedControllerAsset.php | 26 ++ .../StimulusLoaderJavaScriptCompiler.php | 92 +++++ .../RemoveAssetMapperServicesCompiler.php | 29 ++ .../DependencyInjection/StimulusExtension.php | 83 +++++ .../src/Dto/StimulusAttributes.php | 226 +++++++++++++ .../src/Helper/StimulusHelper.php | 37 ++ src/StimulusBundle/src/StimulusBundle.php | 32 ++ .../src/Twig/StimulusTwigExtension.php | 112 +++++++ .../src/Ux/UxPackageMetadata.php | 27 ++ src/StimulusBundle/src/Ux/UxPackageReader.php | 50 +++ .../ControllerMapGeneratorTest.php | 94 ++++++ ...StimulusControllerLoaderFunctionalTest.php | 71 ++++ .../StimulusLoaderJavaScriptCompilerTest.php | 113 +++++++ .../tests/Dto/StimulusAttributesTest.php | 151 +++++++++ .../tests/Helper/StimulusHelperTest.php | 28 ++ .../tests/StimulusIntegrationTestKernel.php | 63 ++++ .../tests/Twig/StimulusTwigExtensionTest.php | 222 ++++++++++++ .../tests/Ux/UxPackageReaderTest.php | 53 +++ .../tests/fixtures/StimulusTestKernel.php | 83 +++++ .../tests/fixtures/assets/app.js | 3 + .../tests/fixtures/assets/controllers.json | 20 ++ .../assets/controllers/bye_controller.js | 6 + .../assets/controllers/hello-controller.js | 6 + .../controllers/some-non-controller-file.js | 2 + .../controllers/subdir/deeper-controller.js | 5 + .../more-controllers/hello-controller.js | 5 + .../more-controllers/other-controller.js | 6 + .../tests/fixtures/importmap.php | 22 ++ .../fixtures/templates/homepage.html.twig | 10 + .../assets/dist/package-controller-second.js | 1 + .../ux-package1/assets/package.json | 16 + .../assets/dist/package-hello-controller.js | 1 + .../ux-package2/Resources/assets/package.json | 11 + src/Svelte/CHANGELOG.md | 13 + src/Svelte/assets/package.json | 4 + src/Svelte/composer.json | 3 +- .../DependencyInjection/SvelteExtension.php | 21 +- .../src/Twig/SvelteComponentExtension.php | 25 +- src/Svelte/tests/Kernel/AppKernelTrait.php | 42 --- .../tests/Kernel/FrameworkAppKernel.php | 43 --- src/Svelte/tests/Kernel/TwigAppKernel.php | 28 +- src/Svelte/tests/SvelteBundleTest.php | 14 +- .../Twig/SvelteComponentExtensionTest.php | 4 +- src/Swup/CHANGELOG.md | 6 + src/Swup/assets/package.json | 8 + src/Swup/composer.json | 5 + src/Swup/doc/index.rst | 4 +- .../src/DependencyInjection/SwupExtension.php | 42 +++ src/Swup/src/SwupBundle.php | 25 ++ src/Translator/CHANGELOG.md | 6 +- src/Translator/assets/package.json | 5 + src/Translator/doc/index.rst | 5 +- .../UxTranslatorExtension.php | 19 +- src/Turbo/CHANGELOG.md | 8 + src/Turbo/assets/package.json | 4 + src/Turbo/composer.json | 24 +- src/Turbo/doc/index.rst | 20 +- .../Mercure/TurboStreamListenRenderer.php | 22 +- .../DependencyInjection/TurboExtension.php | 22 +- src/Turbo/tests/app/Kernel.php | 2 + src/TwigComponent/CHANGELOG.md | 6 + src/TwigComponent/composer.json | 1 + src/TwigComponent/doc/index.rst | 12 +- src/TwigComponent/src/ComponentAttributes.php | 22 +- .../tests/Unit/ComponentAttributesTest.php | 34 ++ src/Typed/CHANGELOG.md | 6 + src/Typed/assets/package.json | 4 + src/Typed/composer.json | 5 + .../DependencyInjection/TypedExtension.php | 42 +++ src/Typed/src/TypedBundle.php | 25 ++ src/Vue/CHANGELOG.md | 8 + src/Vue/assets/package.json | 4 + src/Vue/composer.json | 11 +- .../src/DependencyInjection/VueExtension.php | 21 +- src/Vue/src/Twig/VueComponentExtension.php | 25 +- src/Vue/tests/Kernel/AppKernelTrait.php | 42 --- src/Vue/tests/Kernel/FrameworkAppKernel.php | 43 --- src/Vue/tests/Kernel/TwigAppKernel.php | 28 +- .../tests/Twig/VueComponentExtensionTest.php | 3 +- src/Vue/tests/VueBundleTest.php | 14 +- 160 files changed, 3718 insertions(+), 882 deletions(-) create mode 100644 build.js delete mode 100644 src/Chartjs/tests/Kernel/AppKernelTrait.php delete mode 100644 src/Chartjs/tests/Kernel/EmptyAppKernel.php delete mode 100644 src/Chartjs/tests/Kernel/FrameworkAppKernel.php delete mode 100644 src/Notify/tests/Kernel/AppKernelTrait.php delete mode 100644 src/Notify/tests/Kernel/EmptyAppKernel.php delete mode 100644 src/Notify/tests/Kernel/FrameworkAppKernel.php delete mode 100644 src/React/tests/Kernel/AppKernelTrait.php delete mode 100644 src/React/tests/Kernel/FrameworkAppKernel.php create mode 100644 src/StimulusBundle/.gitignore create mode 100644 src/StimulusBundle/.symfony.bundle.yaml create mode 100644 src/StimulusBundle/CHANGELOG.md create mode 100644 src/StimulusBundle/README.md create mode 100644 src/StimulusBundle/assets/dist/controllers.d.ts create mode 100644 src/StimulusBundle/assets/dist/controllers.js create mode 100644 src/StimulusBundle/assets/dist/loader.d.ts create mode 100644 src/StimulusBundle/assets/dist/loader.js create mode 100644 src/StimulusBundle/assets/jest.config.js create mode 100644 src/StimulusBundle/assets/package.json create mode 100644 src/StimulusBundle/assets/src/controllers.ts create mode 100644 src/StimulusBundle/assets/src/loader.ts create mode 100644 src/StimulusBundle/assets/test/loader.test.ts create mode 100644 src/StimulusBundle/composer.json create mode 100644 src/StimulusBundle/config/services.php create mode 100644 src/StimulusBundle/doc/index.rst create mode 100644 src/StimulusBundle/phpunit.xml.dist create mode 100644 src/StimulusBundle/src/AssetMapper/ControllersMapGenerator.php create mode 100644 src/StimulusBundle/src/AssetMapper/MappedControllerAsset.php create mode 100644 src/StimulusBundle/src/AssetMapper/StimulusLoaderJavaScriptCompiler.php create mode 100644 src/StimulusBundle/src/DependencyInjection/Compiler/RemoveAssetMapperServicesCompiler.php create mode 100644 src/StimulusBundle/src/DependencyInjection/StimulusExtension.php create mode 100644 src/StimulusBundle/src/Dto/StimulusAttributes.php create mode 100644 src/StimulusBundle/src/Helper/StimulusHelper.php create mode 100644 src/StimulusBundle/src/StimulusBundle.php create mode 100644 src/StimulusBundle/src/Twig/StimulusTwigExtension.php create mode 100644 src/StimulusBundle/src/Ux/UxPackageMetadata.php create mode 100644 src/StimulusBundle/src/Ux/UxPackageReader.php create mode 100644 src/StimulusBundle/tests/AssetMapper/ControllerMapGeneratorTest.php create mode 100644 src/StimulusBundle/tests/AssetMapper/StimulusControllerLoaderFunctionalTest.php create mode 100644 src/StimulusBundle/tests/AssetMapper/StimulusLoaderJavaScriptCompilerTest.php create mode 100644 src/StimulusBundle/tests/Dto/StimulusAttributesTest.php create mode 100644 src/StimulusBundle/tests/Helper/StimulusHelperTest.php create mode 100644 src/StimulusBundle/tests/StimulusIntegrationTestKernel.php create mode 100644 src/StimulusBundle/tests/Twig/StimulusTwigExtensionTest.php create mode 100644 src/StimulusBundle/tests/Ux/UxPackageReaderTest.php create mode 100644 src/StimulusBundle/tests/fixtures/StimulusTestKernel.php create mode 100644 src/StimulusBundle/tests/fixtures/assets/app.js create mode 100644 src/StimulusBundle/tests/fixtures/assets/controllers.json create mode 100644 src/StimulusBundle/tests/fixtures/assets/controllers/bye_controller.js create mode 100644 src/StimulusBundle/tests/fixtures/assets/controllers/hello-controller.js create mode 100644 src/StimulusBundle/tests/fixtures/assets/controllers/some-non-controller-file.js create mode 100644 src/StimulusBundle/tests/fixtures/assets/controllers/subdir/deeper-controller.js create mode 100644 src/StimulusBundle/tests/fixtures/assets/more-controllers/hello-controller.js create mode 100644 src/StimulusBundle/tests/fixtures/assets/more-controllers/other-controller.js create mode 100644 src/StimulusBundle/tests/fixtures/importmap.php create mode 100644 src/StimulusBundle/tests/fixtures/templates/homepage.html.twig create mode 100644 src/StimulusBundle/tests/fixtures/vendor/fake-vendor/ux-package1/assets/dist/package-controller-second.js create mode 100644 src/StimulusBundle/tests/fixtures/vendor/fake-vendor/ux-package1/assets/package.json create mode 100644 src/StimulusBundle/tests/fixtures/vendor/fake-vendor/ux-package2/Resources/assets/dist/package-hello-controller.js create mode 100644 src/StimulusBundle/tests/fixtures/vendor/fake-vendor/ux-package2/Resources/assets/package.json create mode 100644 src/Svelte/CHANGELOG.md delete mode 100644 src/Svelte/tests/Kernel/AppKernelTrait.php delete mode 100644 src/Svelte/tests/Kernel/FrameworkAppKernel.php create mode 100644 src/Swup/src/DependencyInjection/SwupExtension.php create mode 100644 src/Swup/src/SwupBundle.php create mode 100644 src/Typed/src/DependencyInjection/TypedExtension.php create mode 100644 src/Typed/src/TypedBundle.php delete mode 100644 src/Vue/tests/Kernel/AppKernelTrait.php delete mode 100644 src/Vue/tests/Kernel/FrameworkAppKernel.php diff --git a/.github/build-packages.php b/.github/build-packages.php index 87f8e41f23b..5570b69e88f 100644 --- a/.github/build-packages.php +++ b/.github/build-packages.php @@ -1,21 +1,53 @@ in(__DIR__.'/../src/*/') + ->depth(0) + ->name('composer.json') +; -$package->repositories[] = [ - 'type' => 'path', - 'url' => '../TwigComponent', -]; +foreach ($finder as $composerFile) { + $json = file_get_contents($composerFile->getPathname()); + if (null === $packageData = json_decode($json, true)) { + passthru(sprintf('composer validate %s', $composerFile->getPathname())); + exit(1); + } -$json = preg_replace('/\n "repositories": \[\n.*?\n \],/s', '', $json); -$json = rtrim(json_encode(['repositories' => $package->repositories], $flags), "\n}").','.substr($json, 1); -$json = preg_replace('/"symfony\/ux-twig-component": "(\^[\d]+\.[\d]+)"/s', '"symfony/ux-twig-component": "@dev"', $json); -file_put_contents($dir.'/composer.json', $json); + $repositories = []; + if (isset($packageData['require']['symfony/ux-twig-component']) + || isset($packageData['require-dev']['symfony/ux-twig-component']) + ) { + $repositories[] = [ + 'type' => 'path', + 'url' => '../TwigComponent', + ]; + $key = isset($packageData['require']['symfony/ux-twig-component']) ? 'require' : 'require-dev'; + $packageData[$key]['symfony/ux-twig-component'] = '@dev'; + } + + if (isset($packageData['require']['symfony/stimulus-bundle']) + || isset($packageData['require-dev']['symfony/stimulus-bundle']) + ) { + $repositories[] = [ + 'type' => 'path', + 'url' => '../StimulusBundle', + ]; + $key = isset($packageData['require']['symfony/stimulus-bundle']) ? 'require' : 'require-dev'; + $packageData[$key]['symfony/stimulus-bundle'] = '@dev'; + } + if ($repositories) { + $packageData['repositories'] = $repositories; + } + + $json = json_encode($packageData, \JSON_PRETTY_PRINT | \JSON_UNESCAPED_SLASHES | \JSON_UNESCAPED_UNICODE); + file_put_contents($composerFile->getPathname(), $json."\n"); +} diff --git a/.github/workflows/test-turbo.yml b/.github/workflows/test-turbo.yml index 6b09461a5e3..d3e8a45a3e8 100644 --- a/.github/workflows/test-turbo.yml +++ b/.github/workflows/test-turbo.yml @@ -14,7 +14,7 @@ jobs: - name: Setup PHP uses: shivammathur/setup-php@v2 with: - php-version: '8.0' + php-version: '8.1' extensions: zip - uses: ramsey/composer-install@v2 @@ -33,7 +33,7 @@ jobs: runs-on: ubuntu-latest strategy: matrix: - php-versions: ['7.3', '7.4', '8.0'] + php-versions: ['8.1'] fail-fast: false name: PHP ${{ matrix.php-versions }} Test on ubuntu-latest @@ -96,7 +96,7 @@ jobs: tests-php-low-deps: runs-on: ubuntu-latest - name: PHP 8.0 (lowest) Test on ubuntu-latest + name: PHP 8.1 (lowest) Test on ubuntu-latest services: mercure: @@ -118,7 +118,7 @@ jobs: - name: Setup PHP uses: shivammathur/setup-php@v2 with: - php-version: '8.0' + php-version: '8.1' extensions: zip, pdo_sqlite - uses: ramsey/composer-install@v2 diff --git a/.github/workflows/test.yaml b/.github/workflows/test.yaml index 9eb3f186ea7..33e1399dc8a 100644 --- a/.github/workflows/test.yaml +++ b/.github/workflows/test.yaml @@ -75,15 +75,6 @@ jobs: with: php-version: '7.2' - - name: Chartjs Dependencies - uses: ramsey/composer-install@v2 - with: - working-directory: src/Chartjs - dependency-versions: lowest - - name: Chartjs Tests - run: php vendor/bin/simple-phpunit - working-directory: src/Chartjs - - name: Cropperjs Dependencies uses: ramsey/composer-install@v2 with: @@ -111,24 +102,6 @@ jobs: run: php vendor/bin/simple-phpunit working-directory: src/LazyImage - - name: React Dependencies - uses: ramsey/composer-install@v2 - with: - working-directory: src/React - dependency-versions: lowest - - name: React Tests - run: php vendor/bin/simple-phpunit - working-directory: src/React - - - name: Svelte Dependencies - uses: ramsey/composer-install@v2 - with: - working-directory: src/Svelte - dependency-versions: lowest - - name: Svelte Tests - run: php vendor/bin/simple-phpunit - working-directory: src/Svelte - tests-php8-low-deps: runs-on: ubuntu-latest steps: @@ -142,6 +115,8 @@ jobs: with: working-directory: src/TwigComponent dependency-versions: lowest + # needed for php 8.0 to skip symfony/stimulus-bundle + composer-options: "--ignore-platform-reqs" - name: TwigComponent Tests run: php vendor/bin/simple-phpunit working-directory: src/TwigComponent @@ -173,22 +148,28 @@ jobs: run: php vendor/bin/simple-phpunit working-directory: src/Translator + - name: StimulusBundle Dependencies + uses: ramsey/composer-install@v2 + with: + working-directory: src/StimulusBundle + dependency-versions: lowest + - name: StimulusBundle Tests + working-directory: src/StimulusBundle + run: php vendor/bin/simple-phpunit + tests-php-high-deps: runs-on: ubuntu-latest steps: - uses: actions/checkout@master + - uses: shivammathur/setup-php@v2 with: php-version: '8.0' - - run: php .github/build-packages.php - - name: Chartjs Dependencies + - name: Install root Dependencies uses: ramsey/composer-install@v2 - with: - working-directory: src/Chartjs - - name: Chartjs Tests - run: php vendor/bin/simple-phpunit - working-directory: src/Chartjs + + - run: php .github/build-packages.php - name: Cropperjs Dependencies uses: ramsey/composer-install@v2 @@ -214,14 +195,6 @@ jobs: run: php vendor/bin/simple-phpunit working-directory: src/LazyImage - - name: TwigComponent Dependencies - uses: ramsey/composer-install@v2 - with: - working-directory: src/TwigComponent - - name: TwigComponent Tests - run: php vendor/bin/simple-phpunit - working-directory: src/TwigComponent - - name: LiveComponent Dependencies uses: ramsey/composer-install@v2 with: @@ -230,14 +203,6 @@ jobs: working-directory: src/LiveComponent run: php vendor/bin/simple-phpunit - - name: React Dependencies - uses: ramsey/composer-install@v2 - with: - working-directory: src/React - - name: React Tests - working-directory: src/React - run: php vendor/bin/simple-phpunit - - name: Autocomplete Dependencies uses: ramsey/composer-install@v2 with: @@ -246,23 +211,28 @@ jobs: working-directory: src/Autocomplete run: php vendor/bin/simple-phpunit - - name: Svelte Dependencies - uses: ramsey/composer-install@v2 - with: - working-directory: src/Svelte - - name: Svelte Tests - working-directory: src/Svelte - run: php vendor/bin/simple-phpunit - tests-php81-high-deps: runs-on: ubuntu-latest steps: - uses: actions/checkout@master + - uses: shivammathur/setup-php@v2 with: php-version: '8.1' + + - name: Install root Dependencies + uses: ramsey/composer-install@v2 + - run: php .github/build-packages.php + - name: Chartjs Dependencies + uses: ramsey/composer-install@v2 + with: + working-directory: src/Chartjs + - name: Chartjs Tests + working-directory: src/Chartjs + run: php vendor/bin/simple-phpunit + - name: Notify Dependencies uses: ramsey/composer-install@v2 with: @@ -271,6 +241,32 @@ jobs: working-directory: src/Notify run: php vendor/bin/simple-phpunit + - name: React Dependencies + uses: ramsey/composer-install@v2 + with: + working-directory: src/React + dependency-versions: lowest + - name: React Tests + run: php vendor/bin/simple-phpunit + working-directory: src/React + + - name: StimulusBundle Dependencies + uses: ramsey/composer-install@v2 + with: + working-directory: src/StimulusBundle + - name: StimulusBundle Tests + working-directory: src/StimulusBundle + run: php vendor/bin/simple-phpunit + + - name: Svelte Dependencies + uses: ramsey/composer-install@v2 + with: + working-directory: src/Svelte + dependency-versions: lowest + - name: Svelte Tests + run: php vendor/bin/simple-phpunit + working-directory: src/Svelte + - name: Translator Dependencies uses: ramsey/composer-install@v2 with: @@ -279,6 +275,23 @@ jobs: working-directory: src/Translator run: php vendor/bin/simple-phpunit + - name: TwigComponent Dependencies + uses: ramsey/composer-install@v2 + with: + working-directory: src/TwigComponent + - name: TwigComponent Tests + run: php vendor/bin/simple-phpunit + working-directory: src/TwigComponent + + - name: Vue Dependencies + uses: ramsey/composer-install@v2 + with: + working-directory: src/Vue + dependency-versions: lowest + - name: Vue Tests + run: php vendor/bin/simple-phpunit + working-directory: src/Vue + tests-js: runs-on: ubuntu-latest steps: diff --git a/README.md b/README.md index 00a323a69df..08a522dca83 100644 --- a/README.md +++ b/README.md @@ -15,8 +15,9 @@ to build the chart in PHP. The JavaScript is handled for you automatically. **That's Symfony UX.** Symfony UX leverages [Stimulus](https://stimulus.hotwired.dev/) for JavaScript -and the [Stimulus Bridge](https://github.com/symfony/stimulus-bridge) for -integrating it into [Webpack Encore](https://github.com/symfony/webpack-encore). +and can integrate with [Webpack Encore](https://github.com/symfony/webpack-encore) +(with the help of [Stimulus Bridge](https://github.com/symfony/stimulus-bridge)) +or with [AssetMapper](https://symfony.com/doc/current/frontend/asset-mapper.html) ## Resources diff --git a/build.js b/build.js new file mode 100644 index 00000000000..4ffc3eb5efc --- /dev/null +++ b/build.js @@ -0,0 +1,32 @@ +/** + * This file is used to compile the TypeScript files in the assets/src directory + * of each package. + * + * It allows each package to spawn its own rollup process, which is necessary + * to keep memory usage down. + */ +const { spawnSync } = require('child_process'); +const glob = require('glob'); + +const files = [ + // custom handling for StimulusBundle + 'src/StimulusBundle/assets/src/loader.ts', + 'src/StimulusBundle/assets/src/controllers.ts', + ...glob.sync('src/*/assets/src/*controller.ts'), +]; + +files.forEach((file) => { + const result = spawnSync('node', [ + 'node_modules/.bin/rollup', + '-c', + '--environment', + `INPUT_FILE:${file}`, + ], { + stdio: 'inherit', + shell: true + }); + + if (result.error) { + console.error(`Error compiling ${file}:`, result.error); + } +}); diff --git a/composer.json b/composer.json index 14281c9d266..5dbfe430816 100644 --- a/composer.json +++ b/composer.json @@ -3,6 +3,7 @@ "license": "MIT", "require-dev": { "symfony/filesystem": "^5.2|^6.0", + "symfony/finder": "^5.4|^6.0", "php-cs-fixer/shim": "^3.13" } } diff --git a/package.json b/package.json index be00d62100f..000fc7972fe 100644 --- a/package.json +++ b/package.json @@ -4,7 +4,7 @@ "src/*/assets" ], "scripts": { - "build": "yarn rollup -c", + "build": "node build.js", "test": "yarn workspaces run jest", "lint": "yarn workspaces run eslint src test", "format": "prettier src/*/assets/src/*.ts src/*/assets/test/*.js {,src/*/}*.{json,md} --write", diff --git a/rollup.config.js b/rollup.config.js index 4ef5774c4d7..e6d54e49f8d 100644 --- a/rollup.config.js +++ b/rollup.config.js @@ -61,37 +61,40 @@ const moveTypescriptDeclarationsPlugin = (packagePath) => ({ } }); -const files = glob.sync('src/*/assets/src/*controller.ts'); -module.exports = files.map((file) => { - const packageRoot = path.join(file, '..', '..'); - const packagePath = path.join(packageRoot, 'package.json'); - const packageData = JSON.parse(fs.readFileSync(packagePath, 'utf8')); - const peerDependencies = [ - '@hotwired/stimulus', - ...(packageData.peerDependencies ? Object.keys(packageData.peerDependencies) : []) - ]; +const file = process.env.INPUT_FILE; +const packageRoot = path.join(file, '..', '..'); +const packagePath = path.join(packageRoot, 'package.json'); +const packageData = JSON.parse(fs.readFileSync(packagePath, 'utf8')); +const peerDependencies = [ + '@hotwired/stimulus', + ...(packageData.peerDependencies ? Object.keys(packageData.peerDependencies) : []) +]; - return { - input: file, - output: { - file: path.join(packageRoot, 'dist', path.basename(file, '.ts') + '.js'), - format: 'esm', - }, - external: peerDependencies, - plugins: [ - resolve(), - typescript({ - filterRoot: packageRoot, - include: ['src/**/*.ts'], - compilerOptions: { - outDir: 'dist', - declaration: true, - emitDeclarationOnly: true, - } - }), - commonjs(), - wildcardExternalsPlugin(peerDependencies), - moveTypescriptDeclarationsPlugin(packageRoot), - ], - }; -}); +// custom handling for StimulusBundle +if (file.includes('StimulusBundle/assets/src/loader.ts')) { + peerDependencies.push('./controllers.js'); +} + +module.exports = { + input: file, + output: { + file: path.join(packageRoot, 'dist', path.basename(file, '.ts') + '.js'), + format: 'esm', + }, + external: peerDependencies, + plugins: [ + resolve(), + typescript({ + filterRoot: packageRoot, + include: ['src/**/*.ts'], + compilerOptions: { + outDir: 'dist', + declaration: true, + emitDeclarationOnly: true, + } + }), + commonjs(), + wildcardExternalsPlugin(peerDependencies), + moveTypescriptDeclarationsPlugin(packageRoot), + ], +}; diff --git a/src/Autocomplete/CHANGELOG.md b/src/Autocomplete/CHANGELOG.md index db16b5a5037..c94a0747d8b 100644 --- a/src/Autocomplete/CHANGELOG.md +++ b/src/Autocomplete/CHANGELOG.md @@ -1,5 +1,9 @@ # CHANGELOG +## 2.9.0 + +- Add support for symfony/asset-mapper + ## 2.8.0 - The autocomplete now watches for update to any `option` elements inside of diff --git a/src/Autocomplete/assets/package.json b/src/Autocomplete/assets/package.json index e6dac6b3f7b..13ce6f17eaf 100644 --- a/src/Autocomplete/assets/package.json +++ b/src/Autocomplete/assets/package.json @@ -17,6 +17,10 @@ "tom-select/dist/css/tom-select.bootstrap5.css": false } } + }, + "importmap": { + "@hotwired/stimulus": "^3.0.0", + "tom-select": "^2.2.2" } }, "peerDependencies": { diff --git a/src/Autocomplete/composer.json b/src/Autocomplete/composer.json index 5b8c649b7ee..a30ecc96c11 100644 --- a/src/Autocomplete/composer.json +++ b/src/Autocomplete/composer.json @@ -34,7 +34,8 @@ "require-dev": { "doctrine/doctrine-bundle": "^2.4", "doctrine/orm": "^2.9", - "mtdowling/jmespath.php": "2.6.x-dev", + "fakerphp/faker": "^1.22", + "mtdowling/jmespath.php": "^2.6", "symfony/form": "^5.4|^6.0", "symfony/framework-bundle": "^5.4|^6.0", "symfony/maker-bundle": "^1.40", @@ -45,7 +46,7 @@ "symfony/twig-bundle": "^5.4|^6.0", "symfony/uid": "^5.4|^6.0", "zenstruck/browser": "^1.1", - "zenstruck/foundry": "^1.19" + "zenstruck/foundry": "^1.32" }, "conflict": { "doctrine/orm": "2.9.0 || 2.9.1" diff --git a/src/Autocomplete/src/DependencyInjection/AutocompleteExtension.php b/src/Autocomplete/src/DependencyInjection/AutocompleteExtension.php index 3c8fcc4d8f0..db183f943a1 100644 --- a/src/Autocomplete/src/DependencyInjection/AutocompleteExtension.php +++ b/src/Autocomplete/src/DependencyInjection/AutocompleteExtension.php @@ -11,6 +11,7 @@ namespace Symfony\UX\Autocomplete\DependencyInjection; +use Symfony\Component\AssetMapper\AssetMapperInterface; use Symfony\Component\DependencyInjection\ContainerBuilder; use Symfony\Component\DependencyInjection\ContainerInterface; use Symfony\Component\DependencyInjection\Definition; @@ -41,13 +42,21 @@ public function prepend(ContainerBuilder $container) { $bundles = $container->getParameter('kernel.bundles'); - if (!isset($bundles['TwigBundle'])) { - return; + if (isset($bundles['TwigBundle'])) { + $container->prependExtensionConfig('twig', [ + 'form_themes' => ['@Autocomplete/autocomplete_form_theme.html.twig'], + ]); } - $container->prependExtensionConfig('twig', [ - 'form_themes' => ['@Autocomplete/autocomplete_form_theme.html.twig'], - ]); + if (interface_exists(AssetMapperInterface::class)) { + $container->prependExtensionConfig('framework', [ + 'asset_mapper' => [ + 'paths' => [ + __DIR__.'/../../assets/dist' => '@symfony/ux-autocomplete', + ], + ], + ]); + } } public function load(array $configs, ContainerBuilder $container) diff --git a/src/Chartjs/CHANGELOG.md b/src/Chartjs/CHANGELOG.md index 21fa6daebc9..2abac40d9b8 100644 --- a/src/Chartjs/CHANGELOG.md +++ b/src/Chartjs/CHANGELOG.md @@ -1,5 +1,15 @@ # CHANGELOG +## 2.9.0 + +- Add support for symfony/asset-mapper + +- Add dependency on symfony/stimulus-bundle + +- Minimum required PHP version is now 8.1. + +- Minimum required Symfony version is now 5.4. + ## 2.8.0 - The chart will now automatically re-render if the `view` Stimulus value diff --git a/src/Chartjs/assets/package.json b/src/Chartjs/assets/package.json index c278b0f5b0f..05a93e88817 100644 --- a/src/Chartjs/assets/package.json +++ b/src/Chartjs/assets/package.json @@ -13,6 +13,10 @@ "fetch": "eager", "enabled": true } + }, + "importmap": { + "@hotwired/stimulus": "^3.0.0", + "chart.js/auto": "^3.4.1" } }, "peerDependencies": { diff --git a/src/Chartjs/composer.json b/src/Chartjs/composer.json index d5abb81d34b..26bf2408124 100644 --- a/src/Chartjs/composer.json +++ b/src/Chartjs/composer.json @@ -28,21 +28,20 @@ } }, "require": { - "php": ">=7.2.5", - "symfony/config": "^4.4.17|^5.0|^6.0", - "symfony/dependency-injection": "^4.4.17|^5.0|^6.0", - "symfony/http-kernel": "^4.4.17|^5.0|^6.0" + "php": ">=8.1", + "symfony/config": "^5.4|^6.0", + "symfony/dependency-injection": "^5.4|^6.0", + "symfony/http-kernel": "^5.4|^6.0", + "symfony/stimulus-bundle": "^2.9" }, "require-dev": { - "symfony/framework-bundle": "^4.4.17|^5.0|^6.0", - "symfony/phpunit-bridge": "^5.2|^6.0", - "symfony/twig-bundle": "^4.4.17|^5.0|^6.0", - "symfony/var-dumper": "^4.4.17|^5.0|^6.0", - "symfony/webpack-encore-bundle": "^1.11" + "symfony/framework-bundle": "^5.4|^6.0", + "symfony/phpunit-bridge": "^5.4|^6.0", + "symfony/twig-bundle": "^5.4|^6.0", + "symfony/var-dumper": "^5.4|^6.0" }, "conflict": { - "symfony/flex": "<1.13", - "symfony/webpack-encore-bundle": "<1.11" + "symfony/flex": "<1.13" }, "extra": { "thanks": { diff --git a/src/Chartjs/doc/index.rst b/src/Chartjs/doc/index.rst index 5baa79a125c..72627ea038a 100644 --- a/src/Chartjs/doc/index.rst +++ b/src/Chartjs/doc/index.rst @@ -74,8 +74,7 @@ and create charts in PHP:: All options and data are provided as-is to Chart.js. You can read `Chart.js documentation`_ to discover them all. -Once created in PHP, a chart can be displayed using Twig if installed -(requires `Symfony Webpack Encore`_): +Once created in PHP, a chart can be displayed using Twig: .. code-block:: html+twig @@ -244,7 +243,6 @@ the Symfony framework: https://symfony.com/doc/current/contributing/code/bc.html .. _`the Symfony UX initiative`: https://symfony.com/ux .. _`@symfony/stimulus-bridge`: https://github.com/symfony/stimulus-bridge .. _`Chart.js documentation`: https://www.chartjs.org/docs/latest/ -.. _`Symfony Webpack Encore`: https://symfony.com/doc/current/frontend/encore/installation.html .. _`Symfony UX configured in your app`: https://symfony.com/doc/current/frontend/ux.html .. _`a lot of plugins`: https://github.com/chartjs/awesome#plugins .. _`zoom plugin`: https://www.chartjs.org/chartjs-plugin-zoom/latest/ diff --git a/src/Chartjs/src/DependencyInjection/ChartjsExtension.php b/src/Chartjs/src/DependencyInjection/ChartjsExtension.php index 27eb10bea6b..f1e902b9eeb 100644 --- a/src/Chartjs/src/DependencyInjection/ChartjsExtension.php +++ b/src/Chartjs/src/DependencyInjection/ChartjsExtension.php @@ -11,22 +11,22 @@ namespace Symfony\UX\Chartjs\DependencyInjection; +use Symfony\Component\AssetMapper\AssetMapperInterface; use Symfony\Component\DependencyInjection\ContainerBuilder; use Symfony\Component\DependencyInjection\Definition; +use Symfony\Component\DependencyInjection\Extension\PrependExtensionInterface; use Symfony\Component\DependencyInjection\Reference; use Symfony\Component\HttpKernel\DependencyInjection\Extension; use Symfony\UX\Chartjs\Builder\ChartBuilder; use Symfony\UX\Chartjs\Builder\ChartBuilderInterface; use Symfony\UX\Chartjs\Twig\ChartExtension; -use Symfony\WebpackEncoreBundle\Twig\StimulusTwigExtension; -use Twig\Environment; /** * @author Titouan Galopin * * @internal */ -class ChartjsExtension extends Extension +class ChartjsExtension extends Extension implements PrependExtensionInterface { public function load(array $configs, ContainerBuilder $container) { @@ -40,13 +40,26 @@ public function load(array $configs, ContainerBuilder $container) ->setPublic(false) ; - if (class_exists(Environment::class) && class_exists(StimulusTwigExtension::class)) { - $container - ->setDefinition('chartjs.twig_extension', new Definition(ChartExtension::class)) - ->addArgument(new Reference('webpack_encore.twig_stimulus_extension')) - ->addTag('twig.extension') - ->setPublic(false) - ; + $container + ->setDefinition('chartjs.twig_extension', new Definition(ChartExtension::class)) + ->addArgument(new Reference('stimulus.helper')) + ->addTag('twig.extension') + ->setPublic(false) + ; + } + + public function prepend(ContainerBuilder $container) + { + if (!interface_exists(AssetMapperInterface::class)) { + return; } + + $container->prependExtensionConfig('framework', [ + 'asset_mapper' => [ + 'paths' => [ + __DIR__.'/../../assets/dist' => '@symfony/ux-chartjs', + ], + ], + ]); } } diff --git a/src/Chartjs/src/Twig/ChartExtension.php b/src/Chartjs/src/Twig/ChartExtension.php index 7331b416998..78fa73dcb61 100644 --- a/src/Chartjs/src/Twig/ChartExtension.php +++ b/src/Chartjs/src/Twig/ChartExtension.php @@ -12,9 +12,8 @@ namespace Symfony\UX\Chartjs\Twig; use Symfony\UX\Chartjs\Model\Chart; -use Symfony\WebpackEncoreBundle\Dto\StimulusControllersDto; +use Symfony\UX\StimulusBundle\Helper\StimulusHelper; use Symfony\WebpackEncoreBundle\Twig\StimulusTwigExtension; -use Twig\Environment; use Twig\Extension\AbstractExtension; use Twig\TwigFunction; @@ -27,19 +26,27 @@ class ChartExtension extends AbstractExtension { private $stimulus; - public function __construct(StimulusTwigExtension $stimulus) + /** + * @param $stimulus StimulusHelper + */ + public function __construct(StimulusHelper|StimulusTwigExtension $stimulus) { + if ($stimulus instanceof StimulusTwigExtension) { + trigger_deprecation('symfony/ux-chartjs', '2.9', 'Passing an instance of "%s" to "%s" is deprecated, pass an instance of "%s" instead.', StimulusTwigExtension::class, __CLASS__, StimulusHelper::class); + $stimulus = new StimulusHelper(null); + } + $this->stimulus = $stimulus; } public function getFunctions(): array { return [ - new TwigFunction('render_chart', [$this, 'renderChart'], ['needs_environment' => true, 'is_safe' => ['html']]), + new TwigFunction('render_chart', [$this, 'renderChart'], ['is_safe' => ['html']]), ]; } - public function renderChart(Environment $env, Chart $chart, array $attributes = []): string + public function renderChart(Chart $chart, array $attributes = []): string { $chart->setAttributes(array_merge($chart->getAttributes(), $attributes)); @@ -49,15 +56,9 @@ public function renderChart(Environment $env, Chart $chart, array $attributes = } $controllers['@symfony/ux-chartjs/chart'] = ['view' => $chart->createView()]; - if (class_exists(StimulusControllersDto::class)) { - $dto = new StimulusControllersDto($env); - foreach ($controllers as $name => $controllerValues) { - $dto->addController($name, $controllerValues); - } - - $html = 'stimulus->renderStimulusController($env, $controllers).' '; + $stimulusAttributes = $this->stimulus->createStimulusAttributes(); + foreach ($controllers as $name => $controllerValues) { + $stimulusAttributes->addController($name, $controllerValues); } foreach ($chart->getAttributes() as $name => $value) { @@ -66,12 +67,12 @@ public function renderChart(Environment $env, Chart $chart, array $attributes = } if (true === $value) { - $html .= $name.'="'.$name.'" '; + $stimulusAttributes->addAttribute($name, $name); } elseif (false !== $value) { - $html .= $name.'="'.$value.'" '; + $stimulusAttributes->addAttribute($name, $value); } } - return trim($html).'>'; + return sprintf('', $stimulusAttributes); } } diff --git a/src/Chartjs/tests/ChartjsBundleTest.php b/src/Chartjs/tests/ChartjsBundleTest.php index c8910771147..3a33273515c 100644 --- a/src/Chartjs/tests/ChartjsBundleTest.php +++ b/src/Chartjs/tests/ChartjsBundleTest.php @@ -12,9 +12,6 @@ namespace Symfony\UX\Chartjs\Tests; use PHPUnit\Framework\TestCase; -use Symfony\Component\HttpKernel\Kernel; -use Symfony\UX\Chartjs\Tests\Kernel\EmptyAppKernel; -use Symfony\UX\Chartjs\Tests\Kernel\FrameworkAppKernel; use Symfony\UX\Chartjs\Tests\Kernel\TwigAppKernel; /** @@ -24,18 +21,9 @@ */ class ChartjsBundleTest extends TestCase { - public function provideKernels() - { - yield 'empty' => [new EmptyAppKernel('test', true)]; - yield 'framework' => [new FrameworkAppKernel('test', true)]; - yield 'twig' => [new TwigAppKernel('test', true)]; - } - - /** - * @dataProvider provideKernels - */ - public function testBootKernel(Kernel $kernel) + public function testBootKernel() { + $kernel = new TwigAppKernel('test', true); $kernel->boot(); $this->assertArrayHasKey('ChartjsBundle', $kernel->getBundles()); } diff --git a/src/Chartjs/tests/Kernel/AppKernelTrait.php b/src/Chartjs/tests/Kernel/AppKernelTrait.php deleted file mode 100644 index 2fdfb9b514f..00000000000 --- a/src/Chartjs/tests/Kernel/AppKernelTrait.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\Chartjs\Tests\Kernel; - -/** - * @author Titouan Galopin - * - * @internal - */ -trait AppKernelTrait -{ - public function getCacheDir(): string - { - return $this->createTmpDir('cache'); - } - - public function getLogDir(): string - { - return $this->createTmpDir('logs'); - } - - private function createTmpDir(string $type): string - { - $dir = sys_get_temp_dir().'/chartjs_bundle/'.uniqid($type.'_', true); - - if (!file_exists($dir)) { - mkdir($dir, 0777, true); - } - - return $dir; - } -} diff --git a/src/Chartjs/tests/Kernel/EmptyAppKernel.php b/src/Chartjs/tests/Kernel/EmptyAppKernel.php deleted file mode 100644 index 841bb7e037d..00000000000 --- a/src/Chartjs/tests/Kernel/EmptyAppKernel.php +++ /dev/null @@ -1,35 +0,0 @@ - - * - * For the full copyright and license information, please view the LICENSE - * file that was distributed with this source code. - */ - -namespace Symfony\UX\Chartjs\Tests\Kernel; - -use Symfony\Component\Config\Loader\LoaderInterface; -use Symfony\Component\HttpKernel\Kernel; -use Symfony\UX\Chartjs\ChartjsBundle; - -/** - * @author Titouan Galopin - * - * @internal - */ -class EmptyAppKernel extends Kernel -{ - use AppKernelTrait; - - public function registerBundles(): iterable - { - return [new ChartjsBundle()]; - } - - public function registerContainerConfiguration(LoaderInterface $loader) - { - } -} diff --git a/src/Chartjs/tests/Kernel/FrameworkAppKernel.php b/src/Chartjs/tests/Kernel/FrameworkAppKernel.php deleted file mode 100644 index f58d0e676a3..00000000000 --- a/src/Chartjs/tests/Kernel/FrameworkAppKernel.php +++ /dev/null @@ -1,40 +0,0 @@ - - * - * For the full copyright and license information, please view the LICENSE - * file that was distributed with this source code. - */ - -namespace Symfony\UX\Chartjs\Tests\Kernel; - -use Symfony\Bundle\FrameworkBundle\FrameworkBundle; -use Symfony\Component\Config\Loader\LoaderInterface; -use Symfony\Component\DependencyInjection\ContainerBuilder; -use Symfony\Component\HttpKernel\Kernel; -use Symfony\UX\Chartjs\ChartjsBundle; - -/** - * @author Titouan Galopin - * - * @internal - */ -class FrameworkAppKernel extends Kernel -{ - use AppKernelTrait; - - public function registerBundles(): iterable - { - return [new FrameworkBundle(), new ChartjsBundle()]; - } - - public function registerContainerConfiguration(LoaderInterface $loader) - { - $loader->load(function (ContainerBuilder $container) { - $container->loadFromExtension('framework', ['secret' => '$ecret', 'test' => true]); - }); - } -} diff --git a/src/Chartjs/tests/Kernel/TwigAppKernel.php b/src/Chartjs/tests/Kernel/TwigAppKernel.php index 8287121fd20..5d629bd2c88 100644 --- a/src/Chartjs/tests/Kernel/TwigAppKernel.php +++ b/src/Chartjs/tests/Kernel/TwigAppKernel.php @@ -17,7 +17,7 @@ use Symfony\Component\DependencyInjection\ContainerBuilder; use Symfony\Component\HttpKernel\Kernel; use Symfony\UX\Chartjs\ChartjsBundle; -use Symfony\WebpackEncoreBundle\WebpackEncoreBundle; +use Symfony\UX\StimulusBundle\StimulusBundle; /** * @author Titouan Galopin @@ -26,22 +26,40 @@ */ class TwigAppKernel extends Kernel { - use AppKernelTrait; - public function registerBundles(): iterable { - return [new FrameworkBundle(), new TwigBundle(), new WebpackEncoreBundle(), new ChartjsBundle()]; + return [new FrameworkBundle(), new TwigBundle(), new StimulusBundle(), new ChartjsBundle()]; } public function registerContainerConfiguration(LoaderInterface $loader) { $loader->load(function (ContainerBuilder $container) { - $container->loadFromExtension('framework', ['secret' => '$ecret', 'test' => true]); + $container->loadFromExtension('framework', ['secret' => '$ecret', 'test' => true, 'http_method_override' => false]); $container->loadFromExtension('twig', ['default_path' => __DIR__.'/templates', 'strict_variables' => true, 'exception_controller' => null]); - $container->loadFromExtension('webpack_encore', ['output_path' => '%kernel.project_dir%/public/build']); $container->setAlias('test.chartjs.builder', 'chartjs.builder')->setPublic(true); $container->setAlias('test.chartjs.twig_extension', 'chartjs.twig_extension')->setPublic(true); }); } + + public function getCacheDir(): string + { + return $this->createTmpDir('cache'); + } + + public function getLogDir(): string + { + return $this->createTmpDir('logs'); + } + + private function createTmpDir(string $type): string + { + $dir = sys_get_temp_dir().'/chartjs_bundle/'.uniqid($type.'_', true); + + if (!file_exists($dir)) { + mkdir($dir, 0777, true); + } + + return $dir; + } } diff --git a/src/Chartjs/tests/Twig/ChartExtensionTest.php b/src/Chartjs/tests/Twig/ChartExtensionTest.php index f2e51ade09d..03472690cbf 100644 --- a/src/Chartjs/tests/Twig/ChartExtensionTest.php +++ b/src/Chartjs/tests/Twig/ChartExtensionTest.php @@ -15,7 +15,6 @@ use Symfony\UX\Chartjs\Builder\ChartBuilderInterface; use Symfony\UX\Chartjs\Model\Chart; use Symfony\UX\Chartjs\Tests\Kernel\TwigAppKernel; -use Twig\Environment; /** * @author Titouan Galopin @@ -52,7 +51,6 @@ public function testRenderChart() ]); $rendered = $container->get('test.chartjs.twig_extension')->renderChart( - $container->get(Environment::class), $chart, ['data-controller' => 'mycontroller', 'class' => 'myclass'] ); diff --git a/src/Cropperjs/CHANGELOG.md b/src/Cropperjs/CHANGELOG.md index 34bcad08fb9..0b79d09ca10 100644 --- a/src/Cropperjs/CHANGELOG.md +++ b/src/Cropperjs/CHANGELOG.md @@ -1,5 +1,11 @@ # CHANGELOG +## 2.9.0 + +- Add support for symfony/asset-mapper + +- Minimum required Symfony version is now 5.4 + ## 2.7.0 - The JavaScript events now bubble up. diff --git a/src/Cropperjs/assets/package.json b/src/Cropperjs/assets/package.json index ea7372e20e4..08f3f09fe8a 100644 --- a/src/Cropperjs/assets/package.json +++ b/src/Cropperjs/assets/package.json @@ -17,6 +17,10 @@ "@symfony/ux-cropperjs/src/style.css": true } } + }, + "importmap": { + "cropperjs": "^1.5.9", + "@hotwired/stimulus": "^3.0.0" } }, "peerDependencies": { diff --git a/src/Cropperjs/composer.json b/src/Cropperjs/composer.json index 3f431dd9428..7f6d480d597 100644 --- a/src/Cropperjs/composer.json +++ b/src/Cropperjs/composer.json @@ -30,17 +30,17 @@ "require": { "php": ">=7.2.5", "intervention/image": "^2.5", - "symfony/config": "^4.4.17|^5.0|^6.0", - "symfony/dependency-injection": "^4.4.17|^5.0|^6.0", - "symfony/form": "^4.4.17|^5.0|^6.0", - "symfony/http-kernel": "^4.4.17|^5.0|^6.0", - "symfony/validator": "^4.4.17|^5.0|^6.0" + "symfony/config": "^5.4|^6.0", + "symfony/dependency-injection": "^5.4|^6.0", + "symfony/form": "^5.4|^6.0", + "symfony/http-kernel": "^5.4|^6.0", + "symfony/validator": "^5.4|^6.0" }, "require-dev": { - "symfony/framework-bundle": "^4.4.17|^5.0|^6.0", - "symfony/phpunit-bridge": "^5.2|^6.0", - "symfony/twig-bundle": "^4.4.17|^5.0|^6.0", - "symfony/var-dumper": "^4.4.17|^5.0|^6.0" + "symfony/framework-bundle": "^5.4|^6.0", + "symfony/phpunit-bridge": "^5.4|^6.0", + "symfony/twig-bundle": "^5.4|^6.0", + "symfony/var-dumper": "^5.4|^6.0" }, "conflict": { "symfony/flex": "<1.13" diff --git a/src/Cropperjs/src/DependencyInjection/CropperjsExtension.php b/src/Cropperjs/src/DependencyInjection/CropperjsExtension.php index 392305876da..73ca0775647 100644 --- a/src/Cropperjs/src/DependencyInjection/CropperjsExtension.php +++ b/src/Cropperjs/src/DependencyInjection/CropperjsExtension.php @@ -12,8 +12,10 @@ namespace Symfony\UX\Cropperjs\DependencyInjection; use Intervention\Image\ImageManager; +use Symfony\Component\AssetMapper\AssetMapperInterface; use Symfony\Component\DependencyInjection\ContainerBuilder; use Symfony\Component\DependencyInjection\Definition; +use Symfony\Component\DependencyInjection\Extension\PrependExtensionInterface; use Symfony\Component\DependencyInjection\Reference; use Symfony\Component\HttpKernel\DependencyInjection\Extension; use Symfony\UX\Cropperjs\Factory\Cropper; @@ -25,7 +27,7 @@ * * @internal */ -class CropperjsExtension extends Extension +class CropperjsExtension extends Extension implements PrependExtensionInterface { public function load(array $configs, ContainerBuilder $container) { @@ -48,4 +50,19 @@ public function load(array $configs, ContainerBuilder $container) $container->setAlias(CropperInterface::class, 'cropper')->setPublic(false); } + + public function prepend(ContainerBuilder $container) + { + if (!interface_exists(AssetMapperInterface::class)) { + return; + } + + $container->prependExtensionConfig('framework', [ + 'asset_mapper' => [ + 'paths' => [ + __DIR__.'/../../assets/dist' => '@symfony/ux-cropperjs', + ], + ], + ]); + } } diff --git a/src/Dropzone/CHANGELOG.md b/src/Dropzone/CHANGELOG.md index 82818f85fa9..d5681745be2 100644 --- a/src/Dropzone/CHANGELOG.md +++ b/src/Dropzone/CHANGELOG.md @@ -1,5 +1,9 @@ # CHANGELOG +## 2.9.0 + +- Add support for symfony/asset-mapper + ## 2.7.0 - The JavaScript events now bubble up. diff --git a/src/Dropzone/assets/package.json b/src/Dropzone/assets/package.json index 19d7fe86179..22f8f6e111b 100644 --- a/src/Dropzone/assets/package.json +++ b/src/Dropzone/assets/package.json @@ -16,6 +16,9 @@ "@symfony/ux-dropzone/src/style.css": true } } + }, + "importmap": { + "@hotwired/stimulus": "^3.0.0" } }, "peerDependencies": { diff --git a/src/Dropzone/composer.json b/src/Dropzone/composer.json index 7ca7d9394b5..0c86d2df0fe 100644 --- a/src/Dropzone/composer.json +++ b/src/Dropzone/composer.json @@ -29,16 +29,16 @@ }, "require": { "php": ">=7.2.5", - "symfony/config": "^4.4.17|^5.0|^6.0", - "symfony/dependency-injection": "^4.4.17|^5.0|^6.0", - "symfony/form": "^4.4.17|^5.0|^6.0", - "symfony/http-kernel": "^4.4.17|^5.0|^6.0" + "symfony/config": "^5.4|^6.0", + "symfony/dependency-injection": "^5.4|^6.0", + "symfony/form": "^5.4|^6.0", + "symfony/http-kernel": "^5.4|^6.0" }, "require-dev": { - "symfony/framework-bundle": "^4.4.17|^5.0|^6.0", - "symfony/phpunit-bridge": "^5.2|^6.0", - "symfony/twig-bundle": "^4.4.17|^5.0|^6.0", - "symfony/var-dumper": "^4.4.17|^5.0|^6.0" + "symfony/framework-bundle": "^5.4|^6.0", + "symfony/phpunit-bridge": "^5.4|^6.0", + "symfony/twig-bundle": "^5.4|^6.0", + "symfony/var-dumper": "^5.4|^6.0" }, "extra": { "thanks": { diff --git a/src/Dropzone/src/DependencyInjection/DropzoneExtension.php b/src/Dropzone/src/DependencyInjection/DropzoneExtension.php index 4195da42b34..91f85f3fd48 100644 --- a/src/Dropzone/src/DependencyInjection/DropzoneExtension.php +++ b/src/Dropzone/src/DependencyInjection/DropzoneExtension.php @@ -11,6 +11,7 @@ namespace Symfony\UX\Dropzone\DependencyInjection; +use Symfony\Component\AssetMapper\AssetMapperInterface; use Symfony\Component\DependencyInjection\ContainerBuilder; use Symfony\Component\DependencyInjection\Definition; use Symfony\Component\DependencyInjection\Extension\PrependExtensionInterface; @@ -29,11 +30,19 @@ public function prepend(ContainerBuilder $container) // Register the Dropzone form theme if TwigBundle is available $bundles = $container->getParameter('kernel.bundles'); - if (!isset($bundles['TwigBundle'])) { - return; + if (isset($bundles['TwigBundle'])) { + $container->prependExtensionConfig('twig', ['form_themes' => ['@Dropzone/form_theme.html.twig']]); } - $container->prependExtensionConfig('twig', ['form_themes' => ['@Dropzone/form_theme.html.twig']]); + if (interface_exists(AssetMapperInterface::class)) { + $container->prependExtensionConfig('framework', [ + 'asset_mapper' => [ + 'paths' => [ + __DIR__.'/../../assets/dist' => '@symfony/ux-dropzone', + ], + ], + ]); + } } public function load(array $configs, ContainerBuilder $container) diff --git a/src/LazyImage/CHANGELOG.md b/src/LazyImage/CHANGELOG.md index 13f03966189..65749db9364 100644 --- a/src/LazyImage/CHANGELOG.md +++ b/src/LazyImage/CHANGELOG.md @@ -1,5 +1,11 @@ # CHANGELOG +## 2.9.0 + +- Add support for symfony/asset-mapper + +- Minimum required Symfony version is now 5.4 + ## 2.7.0 - The JavaScript events now bubble up. diff --git a/src/LazyImage/assets/package.json b/src/LazyImage/assets/package.json index 1a358993d9d..cc320117158 100644 --- a/src/LazyImage/assets/package.json +++ b/src/LazyImage/assets/package.json @@ -13,6 +13,9 @@ "fetch": "eager", "enabled": true } + }, + "importmap": { + "@hotwired/stimulus": "^3.0.0" } }, "peerDependencies": { diff --git a/src/LazyImage/composer.json b/src/LazyImage/composer.json index 5c5c48d0039..3385983fc95 100644 --- a/src/LazyImage/composer.json +++ b/src/LazyImage/composer.json @@ -29,17 +29,17 @@ }, "require": { "php": ">=7.2.5", - "symfony/config": "^4.4.17|^5.0|^6.0", - "symfony/http-kernel": "^4.4.17|^5.0|^6.0", - "symfony/dependency-injection": "^4.4.17|^5.0|^6.0" + "symfony/config": "^5.4|^6.0", + "symfony/http-kernel": "^5.4|^6.0", + "symfony/dependency-injection": "^5.4|^6.0" }, "require-dev": { "intervention/image": "^2.5", "kornrunner/blurhash": "^1.1", - "symfony/framework-bundle": "^4.4.17|^5.0|^6.0", + "symfony/framework-bundle": "^5.4|^6.0", "symfony/phpunit-bridge": "^5.2|^6.0", - "symfony/twig-bundle": "^4.4.17|^5.0|^6.0", - "symfony/var-dumper": "^4.4.17|^5.0|^6.0" + "symfony/twig-bundle": "^5.4|^6.0", + "symfony/var-dumper": "^5.4|^6.0" }, "extra": { "thanks": { diff --git a/src/LazyImage/doc/index.rst b/src/LazyImage/doc/index.rst index 78e6970ea70..53b916e7732 100644 --- a/src/LazyImage/doc/index.rst +++ b/src/LazyImage/doc/index.rst @@ -75,7 +75,7 @@ There is also support for the ``srcset`` attribute by passing an .. note:: - The ``stimulus_controller()`` function comes from `WebpackEncoreBundle v1.10`_. + The ``stimulus_controller()`` function comes from `StimulusBundle`_. Instead of using a generated thumbnail that would exist on your filesystem, you can use the BlurHash algorithm to create a light, @@ -172,5 +172,5 @@ https://symfony.com/doc/current/contributing/code/bc.html .. _`the Symfony UX initiative`: https://symfony.com/ux .. _`@symfony/stimulus-bridge`: https://github.com/symfony/stimulus-bridge .. _`BlurHash implementation`: https://blurha.sh -.. _`WebpackEncoreBundle v1.10`: https://github.com/symfony/webpack-encore-bundle +.. _`StimulusBundle`: https://symfony.com/bundles/StimulusBundle/current/index.html .. _`Symfony UX configured in your app`: https://symfony.com/doc/current/frontend/ux.html diff --git a/src/LazyImage/src/DependencyInjection/LazyImageExtension.php b/src/LazyImage/src/DependencyInjection/LazyImageExtension.php index 55ae438b8f2..f2f3979214a 100644 --- a/src/LazyImage/src/DependencyInjection/LazyImageExtension.php +++ b/src/LazyImage/src/DependencyInjection/LazyImageExtension.php @@ -12,9 +12,11 @@ namespace Symfony\UX\LazyImage\DependencyInjection; use Intervention\Image\ImageManager; +use Symfony\Component\AssetMapper\AssetMapperInterface; use Symfony\Component\DependencyInjection\ContainerBuilder; use Symfony\Component\DependencyInjection\ContainerInterface; use Symfony\Component\DependencyInjection\Definition; +use Symfony\Component\DependencyInjection\Extension\PrependExtensionInterface; use Symfony\Component\DependencyInjection\Reference; use Symfony\Component\HttpKernel\DependencyInjection\Extension; use Symfony\UX\LazyImage\BlurHash\BlurHash; @@ -26,7 +28,7 @@ * * @internal */ -class LazyImageExtension extends Extension +class LazyImageExtension extends Extension implements PrependExtensionInterface { public function load(array $configs, ContainerBuilder $container) { @@ -52,4 +54,19 @@ public function load(array $configs, ContainerBuilder $container) ->setPublic(false) ; } + + public function prepend(ContainerBuilder $container) + { + if (!interface_exists(AssetMapperInterface::class)) { + return; + } + + $container->prependExtensionConfig('framework', [ + 'asset_mapper' => [ + 'paths' => [ + __DIR__.'/../../assets/dist' => '@symfony/ux-lazy-image', + ], + ], + ]); + } } diff --git a/src/LiveComponent/CHANGELOG.md b/src/LiveComponent/CHANGELOG.md index fa783cf38ad..f9b8286e48a 100644 --- a/src/LiveComponent/CHANGELOG.md +++ b/src/LiveComponent/CHANGELOG.md @@ -1,5 +1,9 @@ # CHANGELOG +## 2.9.0 + +- Add support for symfony/asset-mapper + ## 2.8.1 - Increased the priority of `LiveComponentSubscriber` `ControllerEvent` from diff --git a/src/LiveComponent/assets/package.json b/src/LiveComponent/assets/package.json index 601061a8256..47cf8d7f12f 100644 --- a/src/LiveComponent/assets/package.json +++ b/src/LiveComponent/assets/package.json @@ -17,6 +17,9 @@ "@symfony/ux-live-component/styles/live.css": true } } + }, + "importmap": { + "@hotwired/stimulus": "^3.0.0" } }, "dependencies": { diff --git a/src/LiveComponent/src/DependencyInjection/LiveComponentExtension.php b/src/LiveComponent/src/DependencyInjection/LiveComponentExtension.php index 3eda49cd7c5..0993b1f1901 100644 --- a/src/LiveComponent/src/DependencyInjection/LiveComponentExtension.php +++ b/src/LiveComponent/src/DependencyInjection/LiveComponentExtension.php @@ -11,6 +11,7 @@ namespace Symfony\UX\LiveComponent\DependencyInjection; +use Symfony\Component\AssetMapper\AssetMapperInterface; use Symfony\Component\DependencyInjection\ChildDefinition; use Symfony\Component\DependencyInjection\ContainerBuilder; use Symfony\Component\DependencyInjection\ContainerInterface; @@ -59,11 +60,19 @@ public function prepend(ContainerBuilder $container) // Register the form theme if TwigBundle is available $bundles = $container->getParameter('kernel.bundles'); - if (!isset($bundles['TwigBundle'])) { - return; + if (isset($bundles['TwigBundle'])) { + $container->prependExtensionConfig('twig', ['form_themes' => ['@LiveComponent/form_theme.html.twig']]); } - $container->prependExtensionConfig('twig', ['form_themes' => ['@LiveComponent/form_theme.html.twig']]); + if (interface_exists(AssetMapperInterface::class)) { + $container->prependExtensionConfig('framework', [ + 'asset_mapper' => [ + 'paths' => [ + __DIR__.'/../../assets/dist' => '@symfony/ux-live-component', + ], + ], + ]); + } } public function load(array $configs, ContainerBuilder $container): void diff --git a/src/Notify/CHANGELOG.md b/src/Notify/CHANGELOG.md index 6f5a8a6adfd..604a451557f 100644 --- a/src/Notify/CHANGELOG.md +++ b/src/Notify/CHANGELOG.md @@ -1,5 +1,11 @@ # CHANGELOG +## 2.9.0 + +- Add support for symfony/asset-mapper + +- Replace `symfony/webpack-encore-bundle` by `symfony/stimulus-bundle` in dependencies + ## 2.7.0 - Add `assets/src` to `.gitattributes` to exclude source TypeScript files from diff --git a/src/Notify/assets/package.json b/src/Notify/assets/package.json index fe9df3dcb77..5f47e196287 100644 --- a/src/Notify/assets/package.json +++ b/src/Notify/assets/package.json @@ -13,6 +13,9 @@ "fetch": "eager", "enabled": true } + }, + "importmap": { + "@hotwired/stimulus": "^3.0.0" } }, "peerDependencies": { diff --git a/src/Notify/composer.json b/src/Notify/composer.json index 25d89ea86dc..71ef4c05fb8 100644 --- a/src/Notify/composer.json +++ b/src/Notify/composer.json @@ -34,8 +34,8 @@ "symfony/http-kernel": "^5.4|^6.0", "symfony/mercure-bundle": "^0.3.4", "symfony/mercure-notifier": "^5.4|^6.0", - "symfony/twig-bundle": "^5.4|^6.0", - "symfony/webpack-encore-bundle": "^1.11" + "symfony/stimulus-bundle": "^2.9", + "symfony/twig-bundle": "^5.4|^6.0" }, "require-dev": { "symfony/framework-bundle": "^5.4|^6.0", diff --git a/src/Notify/src/DependencyInjection/NotifyExtension.php b/src/Notify/src/DependencyInjection/NotifyExtension.php index 0f2790afcc9..20decb1134b 100644 --- a/src/Notify/src/DependencyInjection/NotifyExtension.php +++ b/src/Notify/src/DependencyInjection/NotifyExtension.php @@ -11,7 +11,9 @@ namespace Symfony\UX\Notify\DependencyInjection; +use Symfony\Component\AssetMapper\AssetMapperInterface; use Symfony\Component\DependencyInjection\ContainerBuilder; +use Symfony\Component\DependencyInjection\Extension\PrependExtensionInterface; use Symfony\Component\DependencyInjection\Reference; use Symfony\Component\HttpKernel\DependencyInjection\ConfigurableExtension; use Symfony\UX\Notify\Twig\NotifyExtension as TwigNotifyExtension; @@ -22,7 +24,7 @@ * * @internal */ -final class NotifyExtension extends ConfigurableExtension +final class NotifyExtension extends ConfigurableExtension implements PrependExtensionInterface { /** * {@inheritdoc} @@ -36,9 +38,24 @@ public function loadInternal(array $config, ContainerBuilder $container) $container->register('notify.twig_runtime', NotifyRuntime::class) ->setArguments([ new Reference($config['mercure_hub']), - new Reference('webpack_encore.twig_stimulus_extension'), + new Reference('stimulus.helper'), ]) ->addTag('twig.runtime') ; } + + public function prepend(ContainerBuilder $container) + { + if (!interface_exists(AssetMapperInterface::class)) { + return; + } + + $container->prependExtensionConfig('framework', [ + 'asset_mapper' => [ + 'paths' => [ + __DIR__.'/../../assets/dist' => '@symfony/ux-notify', + ], + ], + ]); + } } diff --git a/src/Notify/src/Twig/NotifyExtension.php b/src/Notify/src/Twig/NotifyExtension.php index 4c1f3c78ddb..bd2204aef31 100644 --- a/src/Notify/src/Twig/NotifyExtension.php +++ b/src/Notify/src/Twig/NotifyExtension.php @@ -24,6 +24,6 @@ final class NotifyExtension extends AbstractExtension */ public function getFunctions(): iterable { - yield new TwigFunction('stream_notifications', [NotifyRuntime::class, 'renderStreamNotifications'], ['needs_environment' => true, 'is_safe' => ['html']]); + yield new TwigFunction('stream_notifications', [NotifyRuntime::class, 'renderStreamNotifications'], ['is_safe' => ['html']]); } } diff --git a/src/Notify/src/Twig/NotifyRuntime.php b/src/Notify/src/Twig/NotifyRuntime.php index d9ddf7172e7..24e91d2245e 100644 --- a/src/Notify/src/Twig/NotifyRuntime.php +++ b/src/Notify/src/Twig/NotifyRuntime.php @@ -12,9 +12,8 @@ namespace Symfony\UX\Notify\Twig; use Symfony\Component\Mercure\HubInterface; -use Symfony\WebpackEncoreBundle\Dto\StimulusControllersDto; +use Symfony\UX\StimulusBundle\Helper\StimulusHelper; use Symfony\WebpackEncoreBundle\Twig\StimulusTwigExtension; -use Twig\Environment; use Twig\Extension\RuntimeExtensionInterface; /** @@ -22,13 +21,24 @@ */ final class NotifyRuntime implements RuntimeExtensionInterface { + private StimulusHelper $stimulusHelper; + + /** + * @param $stimulus StimulusHelper + */ public function __construct( private HubInterface $hub, - private StimulusTwigExtension $stimulusTwigExtension, + StimulusHelper|StimulusTwigExtension $stimulus, ) { + if ($stimulus instanceof StimulusTwigExtension) { + trigger_deprecation('symfony/ux-notify', '2.9', 'Passing an instance of "%s" to "%s" is deprecated, pass an instance of "%s" instead.', StimulusTwigExtension::class, __CLASS__, StimulusHelper::class); + $stimulus = new StimulusHelper(null); + } + + $this->stimulusHelper = $stimulus; } - public function renderStreamNotifications(Environment $environment, array|string $topics = [], array $options = []): string + public function renderStreamNotifications(array|string $topics = [], array $options = []): string { $topics = [] === $topics ? ['https://symfony.com/notifier'] : (array) $topics; @@ -38,16 +48,11 @@ public function renderStreamNotifications(Environment $environment, array|string } $controllers['@symfony/ux-notify/notify'] = ['topics' => $topics, 'hub' => $this->hub->getPublicUrl()]; - if (class_exists(StimulusControllersDto::class)) { - $dto = new StimulusControllersDto($environment); - foreach ($controllers as $name => $controllerValues) { - $dto->addController($name, $controllerValues); - } - $html = (string) $dto; - } else { - $html = $this->stimulusTwigExtension->renderStimulusController($environment, $controllers); + $stimulusAttributes = $this->stimulusHelper->createStimulusAttributes(); + foreach ($controllers as $name => $controllerValues) { + $stimulusAttributes->addController($name, $controllerValues); } - return trim(sprintf('
', $html)); + return trim(sprintf('
', $stimulusAttributes)); } } diff --git a/src/Notify/tests/Kernel/AppKernelTrait.php b/src/Notify/tests/Kernel/AppKernelTrait.php deleted file mode 100644 index fa8e6a9c589..00000000000 --- a/src/Notify/tests/Kernel/AppKernelTrait.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\Notify\Tests\Kernel; - -/** - * @author Mathias Arlaud - * - * @internal - */ -trait AppKernelTrait -{ - public function getCacheDir(): string - { - return $this->createTmpDir('cache'); - } - - public function getLogDir(): string - { - return $this->createTmpDir('logs'); - } - - private function createTmpDir(string $type): string - { - $dir = sys_get_temp_dir().'/notify_bundle/'.uniqid($type.'_', true); - - if (!file_exists($dir)) { - mkdir($dir, 0777, true); - } - - return $dir; - } -} diff --git a/src/Notify/tests/Kernel/EmptyAppKernel.php b/src/Notify/tests/Kernel/EmptyAppKernel.php deleted file mode 100644 index 254b93fb300..00000000000 --- a/src/Notify/tests/Kernel/EmptyAppKernel.php +++ /dev/null @@ -1,35 +0,0 @@ - - * - * For the full copyright and license information, please view the LICENSE - * file that was distributed with this source code. - */ - -namespace Symfony\UX\Notify\Tests\Kernel; - -use Symfony\Component\Config\Loader\LoaderInterface; -use Symfony\Component\HttpKernel\Kernel; -use Symfony\UX\Notify\NotifyBundle; - -/** - * @author Mathias Arlaud - * - * @internal - */ -class EmptyAppKernel extends Kernel -{ - use AppKernelTrait; - - public function registerBundles(): iterable - { - yield new NotifyBundle(); - } - - public function registerContainerConfiguration(LoaderInterface $loader) - { - } -} diff --git a/src/Notify/tests/Kernel/FrameworkAppKernel.php b/src/Notify/tests/Kernel/FrameworkAppKernel.php deleted file mode 100644 index a344631fc08..00000000000 --- a/src/Notify/tests/Kernel/FrameworkAppKernel.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\Notify\Tests\Kernel; - -use Symfony\Bundle\FrameworkBundle\FrameworkBundle; -use Symfony\Component\Config\Loader\LoaderInterface; -use Symfony\Component\DependencyInjection\ContainerBuilder; -use Symfony\Component\HttpKernel\Kernel; -use Symfony\UX\Notify\NotifyBundle; - -/** - * @author Mathias Arlaud - * - * @internal - */ -class FrameworkAppKernel extends Kernel -{ - use AppKernelTrait; - - public function registerBundles(): iterable - { - yield new FrameworkBundle(); - yield new NotifyBundle(); - } - - public function registerContainerConfiguration(LoaderInterface $loader) - { - $loader->load(function (ContainerBuilder $container) { - $container->loadFromExtension('framework', ['secret' => '$ecret', 'http_method_override' => false]); - }); - } -} diff --git a/src/Notify/tests/Kernel/TwigAppKernel.php b/src/Notify/tests/Kernel/TwigAppKernel.php index 0922d1dd974..de278e8fcdc 100644 --- a/src/Notify/tests/Kernel/TwigAppKernel.php +++ b/src/Notify/tests/Kernel/TwigAppKernel.php @@ -18,7 +18,7 @@ use Symfony\Component\DependencyInjection\ContainerBuilder; use Symfony\Component\HttpKernel\Kernel; use Symfony\UX\Notify\NotifyBundle; -use Symfony\WebpackEncoreBundle\WebpackEncoreBundle; +use Symfony\UX\StimulusBundle\StimulusBundle; /** * @author Mathias Arlaud @@ -27,14 +27,12 @@ */ class TwigAppKernel extends Kernel { - use AppKernelTrait; - public function registerBundles(): iterable { yield new FrameworkBundle(); yield new TwigBundle(); + yield new StimulusBundle(); yield new MercureBundle(); - yield new WebpackEncoreBundle(); yield new NotifyBundle(); } @@ -43,7 +41,6 @@ public function registerContainerConfiguration(LoaderInterface $loader) $loader->load(function (ContainerBuilder $container) { $container->loadFromExtension('framework', ['secret' => '$ecret', 'test' => true, 'http_method_override' => false]); $container->loadFromExtension('twig', ['default_path' => __DIR__.'/templates', 'strict_variables' => true, 'exception_controller' => null]); - $container->loadFromExtension('webpack_encore', ['output_path' => '%kernel.project_dir%/public/build']); $container->loadFromExtension('mercure', [ 'hubs' => [ 'default' => [ @@ -60,4 +57,25 @@ public function registerContainerConfiguration(LoaderInterface $loader) $container->setAlias('test.notify.twig_runtime', 'notify.twig_runtime')->setPublic(true); }); } + + public function getCacheDir(): string + { + return $this->createTmpDir('cache'); + } + + public function getLogDir(): string + { + return $this->createTmpDir('logs'); + } + + private function createTmpDir(string $type): string + { + $dir = sys_get_temp_dir().'/notify_bundle/'.uniqid($type.'_', true); + + if (!file_exists($dir)) { + mkdir($dir, 0777, true); + } + + return $dir; + } } diff --git a/src/Notify/tests/NotifyBundleTest.php b/src/Notify/tests/NotifyBundleTest.php index 839e9f9bb98..e2816bc471b 100644 --- a/src/Notify/tests/NotifyBundleTest.php +++ b/src/Notify/tests/NotifyBundleTest.php @@ -12,9 +12,6 @@ namespace Symfony\UX\Notify\Tests; use PHPUnit\Framework\TestCase; -use Symfony\Component\HttpKernel\Kernel; -use Symfony\UX\Notify\Tests\Kernel\EmptyAppKernel; -use Symfony\UX\Notify\Tests\Kernel\FrameworkAppKernel; use Symfony\UX\Notify\Tests\Kernel\TwigAppKernel; /** @@ -24,22 +21,10 @@ */ class NotifyBundleTest extends TestCase { - /** - * @dataProvider provideKernels - */ - public function testBootKernel(Kernel $kernel) + public function testBootKernel() { + $kernel = new TwigAppKernel('test', true); $kernel->boot(); $this->assertArrayHasKey('NotifyBundle', $kernel->getBundles()); } - - /** - * @return iterable - */ - public function provideKernels(): iterable - { - yield 'empty' => [new EmptyAppKernel('test', true)]; - yield 'framework' => [new FrameworkAppKernel('test', true)]; - yield 'twig' => [new TwigAppKernel('test', true)]; - } } diff --git a/src/Notify/tests/Twig/NotifyRuntimeTest.php b/src/Notify/tests/Twig/NotifyRuntimeTest.php index 62fcf1e1130..b83c26bbbaf 100644 --- a/src/Notify/tests/Twig/NotifyRuntimeTest.php +++ b/src/Notify/tests/Twig/NotifyRuntimeTest.php @@ -13,7 +13,7 @@ use PHPUnit\Framework\TestCase; use Symfony\UX\Notify\Tests\Kernel\TwigAppKernel; -use Twig\Environment; +use Symfony\UX\Notify\Twig\NotifyRuntime; /** * @author Mathias Arlaud @@ -31,7 +31,9 @@ public function testStreamNotifications(array $params, string $expected) $kernel->boot(); $container = $kernel->getContainer()->get('test.service_container'); - $rendered = $container->get('test.notify.twig_runtime')->renderStreamNotifications($container->get(Environment::class), ...$params); + $runtime = $container->get('test.notify.twig_runtime'); + \assert($runtime instanceof NotifyRuntime); + $rendered = $runtime->renderStreamNotifications(...$params); $this->assertSame($expected, $rendered); } diff --git a/src/React/CHANGELOG.md b/src/React/CHANGELOG.md index a997647f8ad..a3dea56735d 100644 --- a/src/React/CHANGELOG.md +++ b/src/React/CHANGELOG.md @@ -1,5 +1,13 @@ # CHANGELOG +## 2.9.0 + +- Add support for symfony/asset-mapper + +- Replace `symfony/webpack-encore-bundle` by `symfony/stimulus-bundle` in dependencies + +- Minimum PHP version is now 8.1 + ## 2.7.0 - Add `assets/src` to `.gitattributes` to exclude source TypeScript files from diff --git a/src/React/assets/package.json b/src/React/assets/package.json index 5ebf89f2c1b..f3ae524b9d0 100644 --- a/src/React/assets/package.json +++ b/src/React/assets/package.json @@ -13,6 +13,11 @@ "fetch": "eager", "enabled": true } + }, + "importmap": { + "@hotwired/stimulus": "^3.0.0", + "react": "^18.0", + "react-dom": "^18.0" } }, "peerDependencies": { diff --git a/src/React/composer.json b/src/React/composer.json index 7fd7a2b4d9b..6969911ba20 100644 --- a/src/React/composer.json +++ b/src/React/composer.json @@ -28,13 +28,14 @@ } }, "require": { - "symfony/webpack-encore-bundle": "^1.11" + "php": ">=8.1", + "symfony/stimulus-bundle": "^2.9" }, "require-dev": { - "symfony/framework-bundle": "^4.4|^5.0|^6.0", - "symfony/phpunit-bridge": "^5.2|^6.0", - "symfony/twig-bundle": "^4.4|^5.0|^6.0", - "symfony/var-dumper": "^4.4|^5.0|^6.0" + "symfony/framework-bundle": "^5.4|^6.0", + "symfony/phpunit-bridge": "^5.4|^6.0", + "symfony/twig-bundle": "^5.4|^6.0", + "symfony/var-dumper": "^5.4|^6.0" }, "extra": { "thanks": { diff --git a/src/React/src/DependencyInjection/ReactExtension.php b/src/React/src/DependencyInjection/ReactExtension.php index 99ee33f53a8..b535b30da71 100644 --- a/src/React/src/DependencyInjection/ReactExtension.php +++ b/src/React/src/DependencyInjection/ReactExtension.php @@ -11,8 +11,10 @@ namespace Symfony\UX\React\DependencyInjection; +use Symfony\Component\AssetMapper\AssetMapperInterface; use Symfony\Component\DependencyInjection\ContainerBuilder; use Symfony\Component\DependencyInjection\Definition; +use Symfony\Component\DependencyInjection\Extension\PrependExtensionInterface; use Symfony\Component\DependencyInjection\Reference; use Symfony\Component\HttpKernel\DependencyInjection\Extension; use Symfony\UX\React\Twig\ReactComponentExtension; @@ -22,15 +24,30 @@ * * @internal */ -class ReactExtension extends Extension +class ReactExtension extends Extension implements PrependExtensionInterface { public function load(array $configs, ContainerBuilder $container) { $container ->setDefinition('twig.extension.react', new Definition(ReactComponentExtension::class)) - ->setArgument(0, new Reference('webpack_encore.twig_stimulus_extension')) + ->setArgument(0, new Reference('stimulus.helper')) ->addTag('twig.extension') ->setPublic(false) ; } + + public function prepend(ContainerBuilder $container) + { + if (!interface_exists(AssetMapperInterface::class)) { + return; + } + + $container->prependExtensionConfig('framework', [ + 'asset_mapper' => [ + 'paths' => [ + __DIR__.'/../../assets/dist' => '@symfony/ux-react', + ], + ], + ]); + } } diff --git a/src/React/src/Twig/ReactComponentExtension.php b/src/React/src/Twig/ReactComponentExtension.php index ed0460f1736..aabbbd9e12a 100644 --- a/src/React/src/Twig/ReactComponentExtension.php +++ b/src/React/src/Twig/ReactComponentExtension.php @@ -11,8 +11,8 @@ namespace Symfony\UX\React\Twig; +use Symfony\UX\StimulusBundle\Helper\StimulusHelper; use Symfony\WebpackEncoreBundle\Twig\StimulusTwigExtension; -use Twig\Environment; use Twig\Extension\AbstractExtension; use Twig\TwigFunction; @@ -23,27 +23,38 @@ */ class ReactComponentExtension extends AbstractExtension { - private $stimulusExtension; + private $stimulusHelper; - public function __construct(StimulusTwigExtension $stimulusExtension) + /** + * @param $stimulus StimulusHelper + */ + public function __construct(StimulusHelper|StimulusTwigExtension $stimulus) { - $this->stimulusExtension = $stimulusExtension; + if ($stimulus instanceof StimulusTwigExtension) { + trigger_deprecation('symfony/ux-react', '2.9', 'Passing an instance of "%s" to "%s" is deprecated, pass an instance of "%s" instead.', StimulusTwigExtension::class, __CLASS__, StimulusHelper::class); + $stimulus = new StimulusHelper(null); + } + + $this->stimulusHelper = $stimulus; } public function getFunctions(): array { return [ - new TwigFunction('react_component', [$this, 'renderReactComponent'], ['needs_environment' => true, 'is_safe' => ['html_attr']]), + new TwigFunction('react_component', [$this, 'renderReactComponent'], ['is_safe' => ['html_attr']]), ]; } - public function renderReactComponent(Environment $env, string $componentName, array $props = []): string + public function renderReactComponent(string $componentName, array $props = []): string { $params = ['component' => $componentName]; if ($props) { $params['props'] = $props; } - return $this->stimulusExtension->renderStimulusController($env, '@symfony/ux-react/react', $params); + $stimulusAttributes = $this->stimulusHelper->createStimulusAttributes(); + $stimulusAttributes->addController('@symfony/ux-react/react', $params); + + return (string) $stimulusAttributes; } } diff --git a/src/React/tests/Kernel/AppKernelTrait.php b/src/React/tests/Kernel/AppKernelTrait.php deleted file mode 100644 index cd254e41336..00000000000 --- a/src/React/tests/Kernel/AppKernelTrait.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\React\Tests\Kernel; - -/** - * @author Titouan Galopin - * - * @internal - */ -trait AppKernelTrait -{ - public function getCacheDir(): string - { - return $this->createTmpDir('cache'); - } - - public function getLogDir(): string - { - return $this->createTmpDir('logs'); - } - - private function createTmpDir(string $type): string - { - $dir = sys_get_temp_dir().'/react_bundle/'.uniqid($type.'_', true); - - if (!file_exists($dir)) { - mkdir($dir, 0777, true); - } - - return $dir; - } -} diff --git a/src/React/tests/Kernel/FrameworkAppKernel.php b/src/React/tests/Kernel/FrameworkAppKernel.php deleted file mode 100644 index 26209afc4fe..00000000000 --- a/src/React/tests/Kernel/FrameworkAppKernel.php +++ /dev/null @@ -1,42 +0,0 @@ - - * - * For the full copyright and license information, please view the LICENSE - * file that was distributed with this source code. - */ - -namespace Symfony\UX\React\Tests\Kernel; - -use Symfony\Bundle\FrameworkBundle\FrameworkBundle; -use Symfony\Component\Config\Loader\LoaderInterface; -use Symfony\Component\DependencyInjection\ContainerBuilder; -use Symfony\Component\HttpKernel\Kernel; -use Symfony\UX\React\ReactBundle; -use Symfony\WebpackEncoreBundle\WebpackEncoreBundle; - -/** - * @author Titouan Galopin - * - * @internal - */ -class FrameworkAppKernel extends Kernel -{ - use AppKernelTrait; - - public function registerBundles(): iterable - { - return [new WebpackEncoreBundle(), new FrameworkBundle(), new ReactBundle()]; - } - - public function registerContainerConfiguration(LoaderInterface $loader) - { - $loader->load(function (ContainerBuilder $container) { - $container->loadFromExtension('framework', ['secret' => '$ecret', 'test' => true]); - $container->loadFromExtension('webpack_encore', ['output_path' => '%kernel.project_dir%/public/build']); - }); - } -} diff --git a/src/React/tests/Kernel/TwigAppKernel.php b/src/React/tests/Kernel/TwigAppKernel.php index e3f1553baac..a65f66ea5a9 100644 --- a/src/React/tests/Kernel/TwigAppKernel.php +++ b/src/React/tests/Kernel/TwigAppKernel.php @@ -17,7 +17,7 @@ use Symfony\Component\DependencyInjection\ContainerBuilder; use Symfony\Component\HttpKernel\Kernel; use Symfony\UX\React\ReactBundle; -use Symfony\WebpackEncoreBundle\WebpackEncoreBundle; +use Symfony\UX\StimulusBundle\StimulusBundle; /** * @author Titouan Galopin @@ -26,22 +26,40 @@ */ class TwigAppKernel extends Kernel { - use AppKernelTrait; - public function registerBundles(): iterable { - return [new WebpackEncoreBundle(), new FrameworkBundle(), new TwigBundle(), new ReactBundle()]; + return [new FrameworkBundle(), new StimulusBundle(), new TwigBundle(), new ReactBundle()]; } public function registerContainerConfiguration(LoaderInterface $loader) { $loader->load(function (ContainerBuilder $container) { $container->loadFromExtension('framework', ['secret' => '$ecret', 'test' => true]); - $container->loadFromExtension('webpack_encore', ['output_path' => '%kernel.project_dir%/public/build']); $container->loadFromExtension('twig', ['default_path' => __DIR__.'/templates', 'strict_variables' => true, 'exception_controller' => null]); $container->setAlias('test.twig', 'twig')->setPublic(true); $container->setAlias('test.twig.extension.react', 'twig.extension.react')->setPublic(true); }); } + + public function getCacheDir(): string + { + return $this->createTmpDir('cache'); + } + + public function getLogDir(): string + { + return $this->createTmpDir('logs'); + } + + private function createTmpDir(string $type): string + { + $dir = sys_get_temp_dir().'/react_bundle/'.uniqid($type.'_', true); + + if (!file_exists($dir)) { + mkdir($dir, 0777, true); + } + + return $dir; + } } diff --git a/src/React/tests/ReactBundleTest.php b/src/React/tests/ReactBundleTest.php index bc0c982f033..1e7dd94faa0 100644 --- a/src/React/tests/ReactBundleTest.php +++ b/src/React/tests/ReactBundleTest.php @@ -12,8 +12,6 @@ namespace Symfony\UX\React\Tests; use PHPUnit\Framework\TestCase; -use Symfony\Component\HttpKernel\Kernel; -use Symfony\UX\React\Tests\Kernel\FrameworkAppKernel; use Symfony\UX\React\Tests\Kernel\TwigAppKernel; /** @@ -23,17 +21,9 @@ */ class ReactBundleTest extends TestCase { - public function provideKernels() - { - yield 'framework' => [new FrameworkAppKernel('test', true)]; - yield 'twig' => [new TwigAppKernel('test', true)]; - } - - /** - * @dataProvider provideKernels - */ - public function testBootKernel(Kernel $kernel) + public function testBootKernel() { + $kernel = new TwigAppKernel('test', true); $kernel->boot(); $this->assertArrayHasKey('ReactBundle', $kernel->getBundles()); } diff --git a/src/React/tests/Twig/ReactComponentExtensionTest.php b/src/React/tests/Twig/ReactComponentExtensionTest.php index a31e0982fb6..aae2d6b6b6a 100644 --- a/src/React/tests/Twig/ReactComponentExtensionTest.php +++ b/src/React/tests/Twig/ReactComponentExtensionTest.php @@ -31,7 +31,6 @@ public function testRenderComponent() $extension = $kernel->getContainer()->get('test.twig.extension.react'); $rendered = $extension->renderReactComponent( - $kernel->getContainer()->get('test.twig'), 'SubDir/MyComponent', ['fullName' => 'Titouan Galopin'] ); @@ -50,7 +49,7 @@ public function testRenderComponentWithoutProps() /** @var ReactComponentExtension $extension */ $extension = $kernel->getContainer()->get('test.twig.extension.react'); - $rendered = $extension->renderReactComponent($kernel->getContainer()->get('test.twig'), 'SubDir/MyComponent'); + $rendered = $extension->renderReactComponent('SubDir/MyComponent'); $this->assertSame( 'data-controller="symfony--ux-react--react" data-symfony--ux-react--react-component-value="SubDir/MyComponent"', diff --git a/src/StimulusBundle/.gitignore b/src/StimulusBundle/.gitignore new file mode 100644 index 00000000000..f3ee547e3df --- /dev/null +++ b/src/StimulusBundle/.gitignore @@ -0,0 +1,5 @@ +.php-cs-fixer.cache +.phpunit.cache +composer.lock +vendor/ +tests/fixtures/var diff --git a/src/StimulusBundle/.symfony.bundle.yaml b/src/StimulusBundle/.symfony.bundle.yaml new file mode 100644 index 00000000000..6d9a74acb76 --- /dev/null +++ b/src/StimulusBundle/.symfony.bundle.yaml @@ -0,0 +1,3 @@ +branches: ["2.x"] +maintained_branches: ["2.x"] +doc_dir: "doc" diff --git a/src/StimulusBundle/CHANGELOG.md b/src/StimulusBundle/CHANGELOG.md new file mode 100644 index 00000000000..c41847452cb --- /dev/null +++ b/src/StimulusBundle/CHANGELOG.md @@ -0,0 +1,5 @@ +# CHANGELOG + +## 2.9.0 + +- Introduce the bundle diff --git a/src/StimulusBundle/README.md b/src/StimulusBundle/README.md new file mode 100644 index 00000000000..b032e52bd51 --- /dev/null +++ b/src/StimulusBundle/README.md @@ -0,0 +1,14 @@ +# StimulusBundle: Symfony integration with Stimulus! + +**EXPERIMENTAL** This bundle is currently experimental. It is possible that +backwards-compatibility breaks could happen between minor versions. + +This bundle adds integration between Symfony, Stimulus and Symfony UX: + +- A) Twig `stimulus_*` functions & filters to add Stimulus controllers, actions & targets in your templates; +- B) Integration with Symfony UX & AssetMapper; +- C) A helper service to build the Stimulus data attributes and use them in your services. + +[Read the documentation][1] + +[1]: https://symfony.com/bundles/StimulusBundle/current/index.html diff --git a/src/StimulusBundle/assets/dist/controllers.d.ts b/src/StimulusBundle/assets/dist/controllers.d.ts new file mode 100644 index 00000000000..4f47e9e9dc9 --- /dev/null +++ b/src/StimulusBundle/assets/dist/controllers.d.ts @@ -0,0 +1,12 @@ +import { ControllerConstructor } from '@hotwired/stimulus'; +export interface EagerControllersCollection { + [key: string]: ControllerConstructor; +} +export interface LazyControllersCollection { + [key: string]: () => Promise<{ + default: ControllerConstructor; + }>; +} +export declare const eagerControllers: EagerControllersCollection; +export declare const lazyControllers: LazyControllersCollection; +export declare const isApplicationDebug = false; diff --git a/src/StimulusBundle/assets/dist/controllers.js b/src/StimulusBundle/assets/dist/controllers.js new file mode 100644 index 00000000000..2a111724b07 --- /dev/null +++ b/src/StimulusBundle/assets/dist/controllers.js @@ -0,0 +1,5 @@ +const eagerControllers = {}; +const lazyControllers = {}; +const isApplicationDebug = false; + +export { eagerControllers, isApplicationDebug, lazyControllers }; diff --git a/src/StimulusBundle/assets/dist/loader.d.ts b/src/StimulusBundle/assets/dist/loader.d.ts new file mode 100644 index 00000000000..d097475afd8 --- /dev/null +++ b/src/StimulusBundle/assets/dist/loader.d.ts @@ -0,0 +1,4 @@ +import { Application } from '@hotwired/stimulus'; +import { EagerControllersCollection, LazyControllersCollection } from './controllers.js'; +export declare const loadControllers: (application: Application, eagerControllers: EagerControllersCollection, lazyControllers: LazyControllersCollection) => void; +export declare const startStimulusApp: () => Application; diff --git a/src/StimulusBundle/assets/dist/loader.js b/src/StimulusBundle/assets/dist/loader.js new file mode 100644 index 00000000000..78f0ed8c8a2 --- /dev/null +++ b/src/StimulusBundle/assets/dist/loader.js @@ -0,0 +1,83 @@ +import { Application } from '@hotwired/stimulus'; +import { isApplicationDebug, eagerControllers, lazyControllers } from './controllers.js'; + +const controllerAttribute = 'data-controller'; +const loadControllers = (application, eagerControllers, lazyControllers) => { + for (const name in eagerControllers) { + registerController(name, eagerControllers[name], application); + } + const lazyControllerHandler = new StimulusLazyControllerHandler(application, lazyControllers); + lazyControllerHandler.start(); +}; +const startStimulusApp = () => { + const application = Application.start(); + application.debug = isApplicationDebug; + loadControllers(application, eagerControllers, lazyControllers); + return application; +}; +class StimulusLazyControllerHandler { + constructor(application, lazyControllers) { + this.application = application; + this.lazyControllers = lazyControllers; + } + start() { + this.lazyLoadExistingControllers(document.documentElement); + this.lazyLoadNewControllers(document.documentElement); + } + lazyLoadExistingControllers(element) { + this.queryControllerNamesWithin(element).forEach((controllerName) => this.loadLazyController(controllerName)); + } + async loadLazyController(name) { + if (canRegisterController(name, this.application)) { + if (this.lazyControllers[name] === undefined) { + console.error(`Failed to autoload controller: ${name}`); + } + const controllerModule = await this.lazyControllers[name](); + registerController(name, controllerModule.default, this.application); + } + } + lazyLoadNewControllers(element) { + new MutationObserver((mutationsList) => { + for (const { attributeName, target, type } of mutationsList) { + switch (type) { + case 'attributes': { + if (attributeName === controllerAttribute && + target.getAttribute(controllerAttribute)) { + extractControllerNamesFrom(target).forEach((controllerName) => this.loadLazyController(controllerName)); + } + break; + } + case 'childList': { + this.lazyLoadExistingControllers(target); + } + } + } + }).observe(element, { + attributeFilter: [controllerAttribute], + subtree: true, + childList: true, + }); + } + queryControllerNamesWithin(element) { + return Array.from(element.querySelectorAll(`[${controllerAttribute}]`)) + .map(extractControllerNamesFrom) + .flat(); + } +} +function registerController(name, controller, application) { + if (canRegisterController(name, application)) { + application.register(name, controller); + } +} +function extractControllerNamesFrom(element) { + const controllerNameValue = element.getAttribute(controllerAttribute); + if (!controllerNameValue) { + return []; + } + return controllerNameValue.split(/\s+/).filter((content) => content.length); +} +function canRegisterController(name, application) { + return !application.router.modulesByIdentifier.has(name); +} + +export { loadControllers, startStimulusApp }; diff --git a/src/StimulusBundle/assets/jest.config.js b/src/StimulusBundle/assets/jest.config.js new file mode 100644 index 00000000000..0373cb6e952 --- /dev/null +++ b/src/StimulusBundle/assets/jest.config.js @@ -0,0 +1 @@ +module.exports = require('../../../jest.config.js'); diff --git a/src/StimulusBundle/assets/package.json b/src/StimulusBundle/assets/package.json new file mode 100644 index 00000000000..230cd545bb0 --- /dev/null +++ b/src/StimulusBundle/assets/package.json @@ -0,0 +1,17 @@ +{ + "name": "@symfony/stimulus-bundle", + "description": "Integration of @hotwired/stimulus into Symfony", + "version": "1.0.0", + "license": "MIT", + "symfony": { + "needsPackageAsADependency": false, + "importmap": { + "@hotwired/stimulus": "^3.0.0", + "@symfony/stimulus-bundle": "path:dist/loader.js" + } + }, + "peerDependencies": { + "@hotwired/stimulus": "^3.0.0", + "@symfony/stimulus-bridge": "^3.2.0" + } +} diff --git a/src/StimulusBundle/assets/src/controllers.ts b/src/StimulusBundle/assets/src/controllers.ts new file mode 100644 index 00000000000..f6ae0f39c8b --- /dev/null +++ b/src/StimulusBundle/assets/src/controllers.ts @@ -0,0 +1,13 @@ +// This file is dynamically rewritten by StimulusBundle + AssetMapper. +import { ControllerConstructor } from '@hotwired/stimulus'; + +export interface EagerControllersCollection { + [key: string]: ControllerConstructor; +} +export interface LazyControllersCollection { + [key: string]: () => Promise<{ default: ControllerConstructor }>; +} + +export const eagerControllers: EagerControllersCollection = {}; +export const lazyControllers: LazyControllersCollection = {}; +export const isApplicationDebug = false; diff --git a/src/StimulusBundle/assets/src/loader.ts b/src/StimulusBundle/assets/src/loader.ts new file mode 100644 index 00000000000..cd76701d5f7 --- /dev/null +++ b/src/StimulusBundle/assets/src/loader.ts @@ -0,0 +1,128 @@ +/** + * Starts the Stimulus application and reads a map dump in the DOM to load controllers. + * + * Inspired by stimulus-loading.js from stimulus-rails. + */ +import { Application, ControllerConstructor } from '@hotwired/stimulus'; +import { + eagerControllers, + lazyControllers, + isApplicationDebug, + EagerControllersCollection, + LazyControllersCollection, +} from './controllers.js'; + +const controllerAttribute = 'data-controller'; + +export const loadControllers = ( + application: Application, + eagerControllers: EagerControllersCollection, + lazyControllers: LazyControllersCollection +) => { + // loop over the controllers map and require each controller + for (const name in eagerControllers) { + registerController(name, eagerControllers[name], application); + } + + const lazyControllerHandler: StimulusLazyControllerHandler = new StimulusLazyControllerHandler( + application, + lazyControllers + ); + lazyControllerHandler.start(); +}; + +export const startStimulusApp = (): Application => { + const application = Application.start(); + application.debug = isApplicationDebug; + + loadControllers(application, eagerControllers, lazyControllers); + + return application; +}; + +class StimulusLazyControllerHandler { + private readonly application: Application; + private readonly lazyControllers: LazyControllersCollection; + + constructor(application: Application, lazyControllers: LazyControllersCollection) { + this.application = application; + this.lazyControllers = lazyControllers; + } + + start(): void { + this.lazyLoadExistingControllers(document.documentElement); + this.lazyLoadNewControllers(document.documentElement); + } + + private lazyLoadExistingControllers(element: Element) { + this.queryControllerNamesWithin(element).forEach((controllerName) => this.loadLazyController(controllerName)); + } + + private async loadLazyController(name: string) { + if (canRegisterController(name, this.application)) { + if (this.lazyControllers[name] === undefined) { + console.error(`Failed to autoload controller: ${name}`); + } + + const controllerModule = await this.lazyControllers[name](); + + registerController(name, controllerModule.default, this.application); + } + } + + private lazyLoadNewControllers(element: Element) { + new MutationObserver((mutationsList) => { + for (const { attributeName, target, type } of mutationsList) { + switch (type) { + case 'attributes': { + if ( + attributeName === controllerAttribute && + (target as Element).getAttribute(controllerAttribute) + ) { + extractControllerNamesFrom(target as Element).forEach((controllerName) => + this.loadLazyController(controllerName) + ); + } + + break; + } + + case 'childList': { + this.lazyLoadExistingControllers(target as Element); + } + } + } + }).observe(element, { + attributeFilter: [controllerAttribute], + subtree: true, + childList: true, + }); + } + + private queryControllerNamesWithin(element: Element): string[] { + return Array.from(element.querySelectorAll(`[${controllerAttribute}]`)) + .map(extractControllerNamesFrom) + .flat(); + } +} + +function registerController(name: string, controller: ControllerConstructor, application: Application) { + if (canRegisterController(name, application)) { + application.register(name, controller); + } +} + +function extractControllerNamesFrom(element: Element): string[] { + const controllerNameValue = element.getAttribute(controllerAttribute); + + if (!controllerNameValue) { + return []; + } + + return controllerNameValue.split(/\s+/).filter((content) => content.length); +} + +function canRegisterController(name: string, application: Application) { + // @ts-ignore + return !application.router.modulesByIdentifier.has(name); +} diff --git a/src/StimulusBundle/assets/test/loader.test.ts b/src/StimulusBundle/assets/test/loader.test.ts new file mode 100644 index 00000000000..c90b90143dd --- /dev/null +++ b/src/StimulusBundle/assets/test/loader.test.ts @@ -0,0 +1,58 @@ +// load from dist because the source TypeScript file points directly to controllers.js, +// which does not actually exist in the source code +import { loadControllers } from '../dist/loader'; +import { Application, Controller } from '@hotwired/stimulus'; +import { + EagerControllersCollection, + LazyControllersCollection, +} from '../src/controllers'; +import { waitFor } from '@testing-library/dom'; + +let isController1Initialized = false; +let isController2Initialized = false; +let isController3Initialized = false; + +const controller1 = class extends Controller { + initialize() { + isController1Initialized = true; + } +}; +const controller2 = class extends Controller { + initialize() { + isController2Initialized = true; + } +}; +const controller3 = class extends Controller { + initialize() { + isController3Initialized = true; + } +}; + +describe('loader', () => { + it('loads controllers', async () => { + document.body.innerHTML = ` +
+
+ `; + + const application = Application.start(); + const eagerControllers: EagerControllersCollection = { + 'controller1': controller1, + 'controller2': controller2, + }; + const lazyControllers: LazyControllersCollection = { + 'controller3': () => Promise.resolve({ default: controller3 }), + }; + + loadControllers(application, eagerControllers, lazyControllers); + + await waitFor(() => expect(isController1Initialized).toBe(true)); + expect(isController2Initialized).toBe(true); + expect(isController3Initialized).toBe(false); + + document.body.innerHTML = '
'; + // wait a moment for the MutationObserver to fire + await new Promise(resolve => setTimeout(resolve, 10)); + expect(isController3Initialized).toBe(true); + }); +}); diff --git a/src/StimulusBundle/composer.json b/src/StimulusBundle/composer.json new file mode 100644 index 00000000000..1ecc0add6e1 --- /dev/null +++ b/src/StimulusBundle/composer.json @@ -0,0 +1,41 @@ +{ + "name": "symfony/stimulus-bundle", + "description": "Integration with your Symfony app & Stimulus!", + "keywords": [ + "symfony-ux" + ], + "license": "MIT", + "type": "symfony-bundle", + "authors": [ + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "require": { + "php": ">=8.1", + "symfony/config": "^5.4|^6.0", + "symfony/dependency-injection": "^5.4|^6.0", + "symfony/finder": "^5.4|^6.0", + "symfony/http-kernel": "^5.4|^6.0", + "twig/twig": "^2.15.3|^3.4.3" + }, + "require-dev": { + "symfony/asset-mapper": "^6.3", + "symfony/framework-bundle": "^5.4|^6.0", + "symfony/phpunit-bridge": "^5.4|^6.0", + "symfony/twig-bundle": "^5.4|^6.0", + "zenstruck/browser": "^1.4" + }, + "minimum-stability": "dev", + "autoload": { + "psr-4": { + "Symfony\\UX\\StimulusBundle\\": "src" + } + }, + "autoload-dev": { + "psr-4": { + "Symfony\\UX\\StimulusBundle\\Tests\\": "tests/" + } + } +} diff --git a/src/StimulusBundle/config/services.php b/src/StimulusBundle/config/services.php new file mode 100644 index 00000000000..6bf3880018a --- /dev/null +++ b/src/StimulusBundle/config/services.php @@ -0,0 +1,52 @@ +services() + ->set('stimulus.helper', StimulusHelper::class) + ->args([ + service(Environment::class)->nullOnInvalid(), + ]) + + ->set('stimulus.twig_extension', StimulusTwigExtension::class) + ->args([ + service('stimulus.helper'), + ]) + // negative priority actually causes the stimulus_ functions from this + // bundle to be used instead of the ones from WebpackEncoreBundle. + ->tag('twig.extension', ['priority' => -10]) + + ->set('stimulus.asset_mapper.controllers_map_generator', ControllersMapGenerator::class) + ->args([ + service('asset_mapper'), + service('stimulus.asset_mapper.ux_package_reader'), + abstract_arg('controller paths'), + abstract_arg('controllers_json_path'), + ]) + + ->set('stimulus.asset_mapper.ux_package_reader', UxPackageReader::class) + ->args([ + param('kernel.project_dir'), + ]) + + ->set('stimulus.asset_mapper.loader_javascript_compiler', StimulusLoaderJavaScriptCompiler::class) + ->args([ + service('stimulus.asset_mapper.controllers_map_generator'), + param('kernel.debug'), + ]) + ->tag('asset_mapper.compiler', ['priority' => 100]) + ; +}; diff --git a/src/StimulusBundle/doc/index.rst b/src/StimulusBundle/doc/index.rst new file mode 100644 index 00000000000..13d71807495 --- /dev/null +++ b/src/StimulusBundle/doc/index.rst @@ -0,0 +1,316 @@ +StimulusBundle: Symfony integration with Stimulus +================================================= + +**EXPERIMENTAL** This bundle is currently experimental. It is possible that +backwards-compatibility breaks could happen between minor versions. + +This bundle adds integration between Symfony, `Stimulus`_ and Symfony UX: + +A) Twig `stimulus_*` functions & filters to add Stimulus controllers, actions & targets in your templates; +B) Integration with Symfony UX & AssetMapper; +C) A helper service to build the Stimulus data attributes and use them in your services. + +Installation +------------ + +First, if you don't have one yet, choose and install an asset handling system; +both work great with StimulusBundle: + +* A) `Webpack Encore`_ Node-based packaging system: + + .. code-block:: terminal + + $ composer require symfony/webpack-encore-bundle +or + +* B) `AssetMapper`_: PHP-based system for handling assets: + + .. code-block:: terminal + + $ composer require symfony/asset-mapper + +Then, install the bundle: + +.. code-block:: terminal + + $ composer require symfony/stimulus-bundle + +If you're using `Symfony Flex`_, you're done! The recipe will update the +necessary files. If not, or you're curious, see :ref:`Manual Setup `. + +Usage +----- + +You can now create custom Stimulus controllers inside of the ``assets/controllers.`` +directory. In fact, you should have an example controller there already: ``hello_controller.js``. + +Use the Twig functions from this bundle to activate your controllers: + +.. code-block:: html+twig + +
+ ... +
+ +Your app will also activate any 3rd party controllers (installed by UX bundles) +mentioned in your ``assets/controllers.json`` file. + +For a *ton* more details, see the `Symfony UX documentation`_. + +Stimulus Twig Helpers +--------------------- + +stimulus_controller +~~~~~~~~~~~~~~~~~~~ + +This bundle ships with a special ``stimulus_controller()`` Twig function +that can be used to render `Stimulus Controllers & Values`_ and `CSS Classes`_. + +For example: + +.. code-block:: html+twig + +
+ Hello +
+ + +
+ Hello +
+ +If you want to set CSS classes: + +.. code-block:: html+twig + +
+ Hello +
+ + +
+ Hello +
+ + +
+ Hello +
+ +Any non-scalar values (like ``data: [1, 2, 3, 4]``) are JSON-encoded. And all +values are properly escaped (the string ``[`` is an escaped +``[`` character, so the attribute is really ``[1,2,3,4]``). + +If you have multiple controllers on the same element, you can chain them as there's also a ``stimulus_controller`` filter: + +.. code-block:: html+twig + +
+ Hello +
+ +You can also retrieve the generated attributes as an array, which can be helpful e.g. for forms: + +.. code-block:: twig + + {{ form_start(form, { attr: stimulus_controller('chart', { 'name': 'Likes' }).toArray() }) }} + +stimulus_action +~~~~~~~~~~~~~~~ + +The ``stimulus_action()`` Twig function can be used to render `Stimulus Actions`_. + +For example: + +.. code-block:: html+twig + +
Hello
+
Hello
+ + +
Hello
+
Hello
+ +If you have multiple actions and/or methods on the same element, you can chain them as there's also a +``stimulus_action`` filter: + +.. code-block:: html+twig + +
+ Hello +
+ + +
+ Hello +
+ +You can also retrieve the generated attributes as an array, which can be helpful e.g. for forms: + +.. code-block:: twig + + {{ form_row(form.password, { attr: stimulus_action('hello-controller', 'checkPasswordStrength').toArray() }) }} + +You can also pass `parameters`_ to actions: + +.. code-block:: html+twig + +
Hello
+ + +
Hello
+ +stimulus_target +~~~~~~~~~~~~~~~ + +The ``stimulus_target()`` Twig function can be used to render `Stimulus Targets`_. + +For example: + +.. code-block:: html+twig + +
Hello
+
Hello
+ + +
Hello
+
Hello
+ +If you have multiple targets on the same element, you can chain them as there's also a `stimulus_target` filter: + +.. code-block:: html+twig + +
+ Hello +
+ + +
+ Hello +
+ +You can also retrieve the generated attributes as an array, which can be helpful e.g. for forms: + +.. code-block:: twig + + {{ form_row(form.password, { attr: stimulus_target('hello-controller', 'a-target').toArray() }) }} + +Configuration +------------- + +If you're using `AssetMapper`_, you can configure the path to your controllers +directory and the ``controllers.json`` file if you need to use different paths: + +.. code-block:: yaml + + # config/packages/stimulus.yaml + stimulus: + # the default values + controller_paths: + - %kernel.project_dir%/assets/controllers + controllers_json: '%kernel.project_dir%/assets/controllers.json' + +.. _manual-installation: + +Manual Installation Details +--------------------------- + +When you install this bundle, its Flex recipe should handle updating all the files +needed. If you're not using Flex or want to double-check the changes, check out +the `StimulusBundle Flex recipe`_. Here's a summary of what's inside: + +* ``assets/bootstrap.js`` starts the Stimulus application and loads your + controllers. It's imported by ``assets/app.js`` and its exact content + depends on whether you have Webpack Encore or AssetMapper installed + (see below). + +* ``assets/app.js`` is *updated* to import ``assets/bootstrap.js`` + +* ``assets/controllers.json`` This file starts (mostly) empty and is automatically + updated as your install UX packages that provide Stimulus controllers. + +* ``assets/controllers/`` This directory is where you should put your custom Stimulus + controllers. It comes with one example ``hello_controller.js`` file. + +A few other changes depend on which asset system you're using: + +With WebpackEncoreBundle +~~~~~~~~~~~~~~~~~~~~~~~~ + +If you're using Webpack Encore, the recipe will also update your ``webpack.config.js`` +file to include this line: + +.. code-block:: javascript + + // webpack.config.js + .enableStimulusBridge('./assets/controllers.json') + +The ``assets/bootstrap.js`` file will be updated to look like this: + +.. code-block:: javascript + + // assets/bootstrap.js + import { startStimulusApp } from '@symfony/stimulus-bridge'; + + // Registers Stimulus controllers from controllers.json and in the controllers/ directory + export const app = startStimulusApp(require.context( + '@symfony/stimulus-bridge/lazy-controller-loader!./controllers', + true, + /\.[jt]sx?$/ + )); + +And 2 new packages - ``@hotwired/stimulus`` and ``@symfony/stimulus-bridge`` - will +be added to your ``package.json`` file. + +With AssetMapper +~~~~~~~~~~~~~~~~ + +If you're using AssetMapper, two new entries will be added to your ``importmap.php`` +file:: + + // importmap.php + return [ + // ... + + '@symfony/stimulus-bundle' => [ + 'path' => '@symfony/stimulus-bundle/loader.js', + ], + '@hotwired/stimulus' => [ + 'url' => 'https://ga.jspm.io/npm:@hotwired/stimulus@3.2.1/dist/stimulus.js', + ], + ]; + +The recipe will update your ``assets/bootstrap.js`` file to look like this: + +.. code-block:: javascript + + // assets/bootstrap.js + import { startStimulusApp } from '@symfony/stimulus-bundle'; + + const app = startStimulusApp(); + +The ``@symfony/stimulus-bundle`` refers the one of the new entries in your +``importmap.php`` file. This file is dynamically built by the bundle and +will import all your custom controllers as well as those from ``controllers.json``. +It will also dynamically enable "debug" mode in Stimulus when your application +is running in debug mode. + +.. _`Stimulus`: https://stimulus.hotwired.dev/ +.. _`Webpack Encore`: https://symfony.com/doc/current/frontend.html +.. _`AssetMapper`: https://symfony.com/doc/current/frontend/asset-mapper.html +.. _`Symfony UX documentation`: https://symfony.com/doc/current/frontend/ux.html +.. _`Stimulus Controllers & Values`: https://stimulus.hotwired.dev/reference/values +.. _`CSS Classes`: https://stimulus.hotwired.dev/reference/css-classes +.. _`Stimulus Actions`: https://stimulus.hotwired.dev/reference/actions +.. _`parameters`: https://stimulus.hotwired.dev/reference/actions#action-parameters +.. _`Stimulus Targets`: https://stimulus.hotwired.dev/reference/targets +.. _`StimulusBundle Flex recipe`: https://github.com/symfony/recipes/tree/main/symfony/stimulus-bundle diff --git a/src/StimulusBundle/phpunit.xml.dist b/src/StimulusBundle/phpunit.xml.dist new file mode 100644 index 00000000000..811bf84d3c3 --- /dev/null +++ b/src/StimulusBundle/phpunit.xml.dist @@ -0,0 +1,34 @@ + + + + + + + + + + + + + + + ./tests/ + + + + + + ./src + + + + + + + diff --git a/src/StimulusBundle/src/AssetMapper/ControllersMapGenerator.php b/src/StimulusBundle/src/AssetMapper/ControllersMapGenerator.php new file mode 100644 index 00000000000..f1dc8b2ab55 --- /dev/null +++ b/src/StimulusBundle/src/AssetMapper/ControllersMapGenerator.php @@ -0,0 +1,126 @@ + + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\UX\StimulusBundle\AssetMapper; + +use Symfony\Component\AssetMapper\AssetMapperInterface; +use Symfony\Component\Finder\Finder; +use Symfony\UX\StimulusBundle\Ux\UxPackageReader; + +/** + * Finds all Stimulus controllers in the project & controllers.json. + * + * @internal + * + * @experimental + * + * @author Ryan Weaver + */ +class ControllersMapGenerator +{ + public function __construct( + private AssetMapperInterface $assetMapper, + private UxPackageReader $uxPackageReader, + private array $controllerPaths, + private string $controllersJsonPath, + ) { + } + + /** + * @return array + */ + public function getControllersMap(): array + { + return array_merge( + $this->loadUxControllers(), + $this->loadCustomControllers(), + ); + } + + /** + * @return array + */ + private function loadCustomControllers(): array + { + $finder = new Finder(); + $finder->in($this->controllerPaths) + ->files() + ->name('/^.*[-_]controller\.js$/'); + + $controllersMap = []; + foreach ($finder as $file) { + $name = $file->getRelativePathname(); + $name = str_replace(['_controller.js', '-controller.js'], '', $name); + $name = str_replace('/', '--', $name); + + $asset = $this->assetMapper->getAssetFromSourcePath($file->getRealPath()); + $isLazy = preg_match('/\/\*\s*stimulusFetch:\s*\'lazy\'\s*\*\//i', $asset->getContent()); + + $controllersMap[$name] = new MappedControllerAsset($asset, $isLazy); + } + + return $controllersMap; + } + + /** + * @return array + */ + private function loadUxControllers(): array + { + if (!is_file($this->controllersJsonPath)) { + return []; + } + + $jsonData = json_decode(file_get_contents($this->controllersJsonPath), true, 512, \JSON_THROW_ON_ERROR); + + $controllersList = $jsonData['controllers'] ?? []; + + $controllersMap = []; + foreach ($controllersList as $packageName => $packageControllers) { + foreach ($packageControllers as $controllerName => $localControllerConfig) { + $packageMetadata = $this->uxPackageReader->readPackageMetadata($packageName); + + $controllerReference = $packageName.'/'.$controllerName; + $packageControllerConfig = $packageMetadata->symfonyConfig['controllers'][$controllerName] ?? null; + + if (null === $packageControllerConfig) { + throw new \RuntimeException(sprintf('Controller "%s" does not exist in the "%s" package.', $controllerReference, $packageMetadata->packageName)); + } + + if (!$localControllerConfig['enabled']) { + continue; + } + + $controllerMainPath = $packageMetadata->packageDirectory.'/'.$packageControllerConfig['main']; + $fetchMode = $localControllerConfig['fetch'] ?? 'eager'; + $lazy = 'lazy' === $fetchMode; + + $controllerNormalizedName = substr($controllerReference, 1); + $controllerNormalizedName = str_replace(['_', '/'], ['-', '--'], $controllerNormalizedName); + + if (isset($packageControllerConfig['name'])) { + $controllerNormalizedName = str_replace('/', '--', $packageControllerConfig['name']); + } + + if (isset($localControllerConfig['name'])) { + $controllerNormalizedName = str_replace('/', '--', $localControllerConfig['name']); + } + + $asset = $this->assetMapper->getAssetFromSourcePath($controllerMainPath); + if (!$asset) { + throw new \RuntimeException(sprintf('Could not find an asset mapper path that points to the "%s" controller in package "%s", defined in controllers.json.', $controllerName, $packageMetadata->packageName)); + } + + $controllersMap[$controllerNormalizedName] = new MappedControllerAsset($asset, $lazy); + } + } + + return $controllersMap; + } +} diff --git a/src/StimulusBundle/src/AssetMapper/MappedControllerAsset.php b/src/StimulusBundle/src/AssetMapper/MappedControllerAsset.php new file mode 100644 index 00000000000..7dc34b765c9 --- /dev/null +++ b/src/StimulusBundle/src/AssetMapper/MappedControllerAsset.php @@ -0,0 +1,26 @@ + + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\UX\StimulusBundle\AssetMapper; + +use Symfony\Component\AssetMapper\MappedAsset; + +/** + * @experimental + * + * @author Ryan Weaver + */ +class MappedControllerAsset +{ + public function __construct( + public MappedAsset $asset, + public bool $isLazy, + ) { + } +} diff --git a/src/StimulusBundle/src/AssetMapper/StimulusLoaderJavaScriptCompiler.php b/src/StimulusBundle/src/AssetMapper/StimulusLoaderJavaScriptCompiler.php new file mode 100644 index 00000000000..1dd33e753bb --- /dev/null +++ b/src/StimulusBundle/src/AssetMapper/StimulusLoaderJavaScriptCompiler.php @@ -0,0 +1,92 @@ + + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\UX\StimulusBundle\AssetMapper; + +use Symfony\Component\AssetMapper\AssetDependency; +use Symfony\Component\AssetMapper\AssetMapperInterface; +use Symfony\Component\AssetMapper\Compiler\AssetCompilerInterface; +use Symfony\Component\AssetMapper\Compiler\AssetCompilerPathResolverTrait; +use Symfony\Component\AssetMapper\MappedAsset; + +/** + * Compiles the loader.js file to dynamically import the controllers. + * + * @experimental + * + * @author Ryan Weaver + */ +class StimulusLoaderJavaScriptCompiler implements AssetCompilerInterface +{ + use AssetCompilerPathResolverTrait; + + public function __construct( + private ControllersMapGenerator $controllersMapGenerator, + private bool $isDebug, + ) { + } + + public function supports(MappedAsset $asset): bool + { + return $asset->getSourcePath() === realpath(__DIR__.'/../../assets/dist/controllers.js'); + } + + public function compile(string $content, MappedAsset $asset, AssetMapperInterface $assetMapper): string + { + $importLines = []; + $eagerControllerParts = []; + $lazyControllers = []; + $loaderPublicPath = $asset->getPublicPathWithoutDigest(); + foreach ($this->controllersMapGenerator->getControllersMap() as $name => $mappedControllerAsset) { + $controllerPublicPath = $mappedControllerAsset->asset->getPublicPathWithoutDigest(); + $relativeImportPath = $this->createRelativePath($loaderPublicPath, $controllerPublicPath); + + /* + * The AssetDependency will already be added by AssetMapper itself when + * it processes this file. However, due to the "stimulusFetch: 'lazy'" + * that may appear inside the controllers, this file is dependent on + * the "contents" of each controller. So, we add the dependency here + * and mark it as a "content" dependency so that this file's contents + * will be recalculated when the contents of any controller changes. + */ + $asset->addDependency(new AssetDependency( + $mappedControllerAsset->asset, + $mappedControllerAsset->isLazy, + true, + )); + + if ($mappedControllerAsset->isLazy) { + $lazyControllers[] = sprintf('%s: () => import(%s)', json_encode($name), json_encode($relativeImportPath, \JSON_THROW_ON_ERROR | \JSON_UNESCAPED_SLASHES)); + continue; + } + + $controllerNameForVariable = sprintf('controller_%s', \count($eagerControllerParts)); + + $importLines[] = sprintf( + "import %s from '%s';", + $controllerNameForVariable, + $relativeImportPath + ); + $eagerControllerParts[] = sprintf('"%s": %s', $name, $controllerNameForVariable); + } + + $importCode = implode("\n", $importLines); + $eagerControllersJson = sprintf('{%s}', implode(', ', $eagerControllerParts)); + $lazyControllersExpression = sprintf('{%s}', implode(', ', $lazyControllers)); + + $isDebugString = $this->isDebug ? 'true' : 'false'; + + return << + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\UX\StimulusBundle\DependencyInjection\Compiler; + +use Symfony\Component\DependencyInjection\Compiler\CompilerPassInterface; +use Symfony\Component\DependencyInjection\ContainerBuilder; + +/** + * @experimental + * + * @author Ryan Weaver + */ +class RemoveAssetMapperServicesCompiler implements CompilerPassInterface +{ + public function process(ContainerBuilder $container) + { + if (!$container->hasDefinition('asset_mapper')) { + $container->removeDefinition('stimulus.asset_mapper.controllers_map_generator'); + $container->removeDefinition('stimulus.asset_mapper.stimulus_loader_javascript_compiler'); + } + } +} diff --git a/src/StimulusBundle/src/DependencyInjection/StimulusExtension.php b/src/StimulusBundle/src/DependencyInjection/StimulusExtension.php new file mode 100644 index 00000000000..e6b1736ab88 --- /dev/null +++ b/src/StimulusBundle/src/DependencyInjection/StimulusExtension.php @@ -0,0 +1,83 @@ + + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\UX\StimulusBundle\DependencyInjection; + +use Symfony\Component\AssetMapper\AssetMapperInterface; +use Symfony\Component\Config\Definition\Builder\ArrayNodeDefinition; +use Symfony\Component\Config\Definition\Builder\TreeBuilder; +use Symfony\Component\Config\Definition\ConfigurationInterface; +use Symfony\Component\Config\FileLocator; +use Symfony\Component\DependencyInjection\ContainerBuilder; +use Symfony\Component\DependencyInjection\Extension\PrependExtensionInterface; +use Symfony\Component\DependencyInjection\Loader; +use Symfony\Component\HttpKernel\DependencyInjection\Extension; + +/** + * @experimental + * + * @author Ryan Weaver + */ +final class StimulusExtension extends Extension implements PrependExtensionInterface, ConfigurationInterface +{ + public function load(array $configs, ContainerBuilder $container): void + { + $loader = new Loader\PhpFileLoader($container, new FileLocator(__DIR__.'/../../config')); + $loader->load('services.php'); + + $configuration = $this->getConfiguration($configs, $container); + $config = $this->processConfiguration($configuration, $configs); + + $container->findDefinition('stimulus.asset_mapper.controllers_map_generator') + ->replaceArgument(2, $config['controller_paths']) + ->replaceArgument(3, $config['controllers_json']); + } + + public function prepend(ContainerBuilder $container) + { + if (!interface_exists(AssetMapperInterface::class)) { + return; + } + + $container->prependExtensionConfig('framework', [ + 'asset_mapper' => [ + 'paths' => [ + __DIR__.'/../../assets/dist' => '@symfony/stimulus-bundle', + ], + ], + ]); + } + + public function getConfiguration(array $config, ContainerBuilder $container): ConfigurationInterface + { + return $this; + } + + public function getConfigTreeBuilder(): TreeBuilder + { + $treeBuilder = new TreeBuilder('stimulus'); + $rootNode = $treeBuilder->getRootNode(); + \assert($rootNode instanceof ArrayNodeDefinition); + + $rootNode + ->children() + ->arrayNode('controller_paths') + ->defaultValue(['%kernel.project_dir%/assets/controllers']) + ->scalarPrototype()->end() + ->end() + ->scalarNode('controllers_json') + ->defaultValue('%kernel.project_dir%/assets/controllers.json') + ->end() + ->end(); + + return $treeBuilder; + } +} diff --git a/src/StimulusBundle/src/Dto/StimulusAttributes.php b/src/StimulusBundle/src/Dto/StimulusAttributes.php new file mode 100644 index 00000000000..6b6f18bab1f --- /dev/null +++ b/src/StimulusBundle/src/Dto/StimulusAttributes.php @@ -0,0 +1,226 @@ + + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\UX\StimulusBundle\Dto; + +use Twig\Environment; + +/** + * Helper to build Stimulus-related HTML attributes. + * + * @experimental + * + * @author Ryan Weaver + */ +class StimulusAttributes implements \Stringable, \IteratorAggregate +{ + private array $attributes = []; + + private array $controllers = []; + private array $actions = []; + private array $targets = []; + + public function __construct(private Environment $env) + { + } + + public function getIterator(): \Traversable + { + return new \ArrayIterator($this->toArray()); + } + + public function addController(string $controllerName, array $controllerValues = [], array $controllerClasses = []): void + { + $controllerName = $this->normalizeControllerName($controllerName); + $this->controllers[] = $controllerName; + + foreach ($controllerValues as $key => $value) { + if (null === $value) { + continue; + } + + $key = $this->normalizeKeyName($key); + $value = $this->getFormattedValue($value); + + $this->attributes['data-'.$controllerName.'-'.$key.'-value'] = $value; + } + + foreach ($controllerClasses as $key => $class) { + $key = $this->normalizeKeyName($key); + + $this->attributes['data-'.$controllerName.'-'.$key.'-class'] = $class; + } + } + + /** + * @param array $parameters Parameters to pass to the action. Optional. + */ + public function addAction(string $controllerName, string $actionName, string $eventName = null, array $parameters = []): void + { + $controllerName = $this->normalizeControllerName($controllerName); + $this->actions[] = [ + 'controllerName' => $controllerName, + 'actionName' => $actionName, + 'eventName' => $eventName, + ]; + + foreach ($parameters as $name => $value) { + $this->attributes['data-'.$controllerName.'-'.$name.'-param'] = $this->getFormattedValue($value); + } + } + + /** + * @param string $controllerName the Stimulus controller name + * @param string|null $targetNames The space-separated list of target names if a string is passed to the 1st argument. Optional. + */ + public function addTarget(string $controllerName, string $targetNames = null): void + { + if (null === $targetNames) { + return; + } + + $controllerName = $this->normalizeControllerName($controllerName); + + $this->targets['data-'.$controllerName.'-target'] = $targetNames; + } + + public function addAttribute(string $name, string $value): void + { + $this->attributes[$name] = $value; + } + + public function __toString(): string + { + $controllers = array_map(function (string $controllerName): string { + return $this->escapeAsHtmlAttr($controllerName); + }, $this->controllers); + + // done separately so we can escape, but avoid escaping -> + $actions = array_map(function (array $actionData): string { + $controllerName = $this->escapeAsHtmlAttr($actionData['controllerName']); + $actionName = $this->escapeAsHtmlAttr($actionData['actionName']); + $eventName = $actionData['eventName']; + + $action = $controllerName.'#'.$actionName; + if (null !== $eventName) { + $action = $this->escapeAsHtmlAttr($eventName).'->'.$action; + } + + return $action; + }, $this->actions); + + $targets = []; + foreach ($this->targets as $key => $targetNamesString) { + $targetNames = explode(' ', $targetNamesString); + $targets[$key] = implode(' ', array_map(function (string $targetName): string { + return $this->escapeAsHtmlAttr($targetName); + }, $targetNames)); + } + + $attributes = []; + + if ($controllers) { + $attributes[] = sprintf('data-controller="%s"', implode(' ', $controllers)); + } + + if ($actions) { + $attributes[] = sprintf('data-action="%s"', implode(' ', $actions)); + } + + if ($targets) { + $attributes[] = implode(' ', array_map(function (string $key, string $value): string { + return sprintf('%s="%s"', $key, $value); + }, array_keys($targets), $targets)); + } + + return rtrim(implode(' ', [ + ...$attributes, + ...array_map(function (string $attribute, string $value): string { + return $attribute.'="'.$this->escapeAsHtmlAttr($value).'"'; + }, array_keys($this->attributes), $this->attributes), + ])); + } + + public function toArray(): array + { + $actions = array_map(function (array $actionData): string { + $controllerName = $actionData['controllerName']; + $actionName = $actionData['actionName']; + $eventName = $actionData['eventName']; + + $action = $controllerName.'#'.$actionName; + if (null !== $eventName) { + $action = $eventName.'->'.$action; + } + + return $action; + }, $this->actions); + + $attributes = []; + + if ($this->controllers) { + $attributes['data-controller'] = implode(' ', $this->controllers); + } + + if ($actions) { + $attributes['data-action'] = implode(' ', $actions); + } + + if ($this->targets) { + $attributes = array_merge($attributes, $this->targets); + } + + return array_merge($attributes, $this->attributes); + } + + private function getFormattedValue(mixed $value): string + { + if ($value instanceof \Stringable || (\is_object($value) && \is_callable([$value, '__toString']))) { + $value = (string) $value; + } elseif (!\is_scalar($value)) { + $value = json_encode($value); + } elseif (\is_bool($value)) { + $value = $value ? 'true' : 'false'; + } + + return (string) $value; + } + + private function escapeAsHtmlAttr(mixed $value): string + { + return (string) twig_escape_filter($this->env, $value, 'html_attr'); + } + + /** + * Normalize a Stimulus controller name into its HTML equivalent (no special character and / becomes --). + * + * @see https://stimulus.hotwired.dev/reference/controllers + */ + private function normalizeControllerName(string $controllerName): string + { + return preg_replace('/^@/', '', str_replace('_', '-', str_replace('/', '--', $controllerName))); + } + + /** + * Normalize a Stimulus Value API key into its HTML equivalent ("kebab case"). + * Backport features from symfony/string. + * + * @see https://stimulus.hotwired.dev/reference/values + */ + private function normalizeKeyName(string $str): string + { + // Adapted from ByteString::camel + $str = ucfirst(str_replace(' ', '', ucwords(preg_replace('/[^a-zA-Z0-9\x7f-\xff]++/', ' ', $str)))); + + // Adapted from ByteString::snake + return strtolower(preg_replace(['/([A-Z]+)([A-Z][a-z])/', '/([a-z\d])([A-Z])/'], '\1-\2', $str)); + } +} diff --git a/src/StimulusBundle/src/Helper/StimulusHelper.php b/src/StimulusBundle/src/Helper/StimulusHelper.php new file mode 100644 index 00000000000..9ea4a5d39c7 --- /dev/null +++ b/src/StimulusBundle/src/Helper/StimulusHelper.php @@ -0,0 +1,37 @@ + + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\UX\StimulusBundle\Helper; + +use Symfony\UX\StimulusBundle\Dto\StimulusAttributes; +use Twig\Environment; +use Twig\Loader\ArrayLoader; + +/** + * @experimental + * + * @author Ryan Weaver + */ +final class StimulusHelper +{ + private Environment $twig; + + public function __construct(?Environment $twig) + { + // Twig needed just for its escaping mechanism + $this->twig = $twig ?? new Environment(new ArrayLoader()); + } + + public function createStimulusAttributes(): StimulusAttributes + { + return new StimulusAttributes($this->twig); + } +} diff --git a/src/StimulusBundle/src/StimulusBundle.php b/src/StimulusBundle/src/StimulusBundle.php new file mode 100644 index 00000000000..0fef19baf01 --- /dev/null +++ b/src/StimulusBundle/src/StimulusBundle.php @@ -0,0 +1,32 @@ + + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\UX\StimulusBundle; + +use Symfony\Component\DependencyInjection\ContainerBuilder; +use Symfony\Component\HttpKernel\Bundle\Bundle; +use Symfony\UX\StimulusBundle\DependencyInjection\Compiler\RemoveAssetMapperServicesCompiler; + +/** + * @experimental + * + * @author Ryan Weaver + */ +final class StimulusBundle extends Bundle +{ + public function getPath(): string + { + return \dirname(__DIR__); + } + + public function build(ContainerBuilder $container) + { + $container->addCompilerPass(new RemoveAssetMapperServicesCompiler()); + } +} diff --git a/src/StimulusBundle/src/Twig/StimulusTwigExtension.php b/src/StimulusBundle/src/Twig/StimulusTwigExtension.php new file mode 100644 index 00000000000..0b78bd4654c --- /dev/null +++ b/src/StimulusBundle/src/Twig/StimulusTwigExtension.php @@ -0,0 +1,112 @@ + + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\UX\StimulusBundle\Twig; + +use Symfony\UX\StimulusBundle\Dto\StimulusAttributes; +use Symfony\UX\StimulusBundle\Helper\StimulusHelper; +use Twig\Extension\AbstractExtension; +use Twig\TwigFilter; +use Twig\TwigFunction; + +/** + * @experimental + * + * @author Ryan Weaver + */ +final class StimulusTwigExtension extends AbstractExtension +{ + public function __construct(private StimulusHelper $stimulusHelper) + { + } + + public function getFunctions(): array + { + return [ + new TwigFunction('stimulus_controller', [$this, 'renderStimulusController'], ['is_safe' => ['html_attr']]), + new TwigFunction('stimulus_action', [$this, 'renderStimulusAction'], ['is_safe' => ['html_attr']]), + new TwigFunction('stimulus_target', [$this, 'renderStimulusTarget'], ['is_safe' => ['html_attr']]), + ]; + } + + public function getFilters(): array + { + return [ + new TwigFilter('stimulus_controller', [$this, 'appendStimulusController'], ['is_safe' => ['html_attr']]), + new TwigFilter('stimulus_action', [$this, 'appendStimulusAction'], ['is_safe' => ['html_attr']]), + new TwigFilter('stimulus_target', [$this, 'appendStimulusTarget'], ['is_safe' => ['html_attr']]), + ]; + } + + /** + * @param string $controllerName the Stimulus controller name + * @param array $controllerValues array of controller values + * @param array $controllerClasses array of controller CSS classes + */ + public function renderStimulusController(string $controllerName, array $controllerValues = [], array $controllerClasses = []): StimulusAttributes + { + $stimulusAttributes = $this->stimulusHelper->createStimulusAttributes(); + $stimulusAttributes->addController($controllerName, $controllerValues, $controllerClasses); + + return $stimulusAttributes; + } + + public function appendStimulusController(StimulusAttributes $stimulusAttributes, string $controllerName, array $controllerValues = [], array $controllerClasses = []): StimulusAttributes + { + $stimulusAttributes->addController($controllerName, $controllerValues, $controllerClasses); + + return $stimulusAttributes; + } + + /** + * @param array $parameters Parameters to pass to the action. Optional. + */ + public function renderStimulusAction(string $controllerName, string $actionName = null, string $eventName = null, array $parameters = []): StimulusAttributes + { + $stimulusAttributes = $this->stimulusHelper->createStimulusAttributes(); + $stimulusAttributes->addAction($controllerName, $actionName, $eventName, $parameters); + + return $stimulusAttributes; + } + + /** + * @param array $parameters Parameters to pass to the action. Optional. + */ + public function appendStimulusAction(StimulusAttributes $stimulusAttributes, string $controllerName, string $actionName, string $eventName = null, array $parameters = []): StimulusAttributes + { + $stimulusAttributes->addAction($controllerName, $actionName, $eventName, $parameters); + + return $stimulusAttributes; + } + + /** + * @param string $controllerName the Stimulus controller name + * @param string|null $targetNames The space-separated list of target names if a string is passed to the 1st argument. Optional. + */ + public function renderStimulusTarget(string $controllerName, string $targetNames = null): StimulusAttributes + { + $stimulusAttributes = $this->stimulusHelper->createStimulusAttributes(); + $stimulusAttributes->addTarget($controllerName, $targetNames); + + return $stimulusAttributes; + } + + /** + * @param string $controllerName the Stimulus controller name + * @param string|null $targetNames The space-separated list of target names if a string is passed to the 1st argument. Optional. + */ + public function appendStimulusTarget(StimulusAttributes $stimulusAttributes, string $controllerName, string $targetNames = null): StimulusAttributes + { + $stimulusAttributes->addTarget($controllerName, $targetNames); + + return $stimulusAttributes; + } +} diff --git a/src/StimulusBundle/src/Ux/UxPackageMetadata.php b/src/StimulusBundle/src/Ux/UxPackageMetadata.php new file mode 100644 index 00000000000..6737ef3acb7 --- /dev/null +++ b/src/StimulusBundle/src/Ux/UxPackageMetadata.php @@ -0,0 +1,27 @@ + + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\UX\StimulusBundle\Ux; + +/** + * @internal + * + * @experimental + * + * @author Ryan Weaver + */ +class UxPackageMetadata +{ + public function __construct( + public string $packageDirectory, + public array $symfonyConfig, + public string $packageName, + ) { + } +} diff --git a/src/StimulusBundle/src/Ux/UxPackageReader.php b/src/StimulusBundle/src/Ux/UxPackageReader.php new file mode 100644 index 00000000000..8959ed71506 --- /dev/null +++ b/src/StimulusBundle/src/Ux/UxPackageReader.php @@ -0,0 +1,50 @@ + + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\UX\StimulusBundle\Ux; + +/** + * @internal + * + * @experimental + * + * @author Ryan Weaver + */ +class UxPackageReader +{ + public function __construct(private string $projectDir) + { + } + + public function readPackageMetadata(string $packageName): UxPackageMetadata + { + // remove the '@' from the name to get back to the PHP package name + $phpPackageName = substr($packageName, 1); + $phpPackagePath = $this->projectDir.'/vendor/'.$phpPackageName; + if (!is_dir($phpPackagePath)) { + throw new \RuntimeException(sprintf('Could not find package "%s" referred to from controllers.json.', $phpPackageName)); + } + $packageConfigJsonPath = $phpPackagePath.'/assets/package.json'; + if (!file_exists($packageConfigJsonPath)) { + $packageConfigJsonPath = $phpPackagePath.'/Resources/assets/package.json'; + } + if (!file_exists($packageConfigJsonPath)) { + throw new \RuntimeException(sprintf('Could not find package.json in the "%s" package.', $phpPackagePath)); + } + + $packageConfigJson = file_get_contents($packageConfigJsonPath); + $packageConfigData = json_decode($packageConfigJson, true); + + return new UxPackageMetadata( + \dirname($packageConfigJsonPath), + $packageConfigData['symfony'] ?? [], + $phpPackageName + ); + } +} diff --git a/src/StimulusBundle/tests/AssetMapper/ControllerMapGeneratorTest.php b/src/StimulusBundle/tests/AssetMapper/ControllerMapGeneratorTest.php new file mode 100644 index 00000000000..f9f61606fd7 --- /dev/null +++ b/src/StimulusBundle/tests/AssetMapper/ControllerMapGeneratorTest.php @@ -0,0 +1,94 @@ + + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\UX\StimulusBundle\Tests\AssetMapper; + +use PHPUnit\Framework\TestCase; +use Symfony\Component\AssetMapper\AssetMapperInterface; +use Symfony\Component\AssetMapper\MappedAsset; +use Symfony\UX\StimulusBundle\AssetMapper\ControllersMapGenerator; +use Symfony\UX\StimulusBundle\Ux\UxPackageReader; + +class ControllerMapGeneratorTest extends TestCase +{ + public function testGetControllersMap() + { + $mapper = $this->createMock(AssetMapperInterface::class); + $mapper->expects($this->any()) + ->method('getAssetFromSourcePath') + ->willReturnCallback(function ($path) { + if (str_ends_with($path, 'package-controller-first.js')) { + $logicalPath = 'fake-vendor/ux-package1/package-controller-first.js'; + } elseif (str_ends_with($path, 'package-controller-second.js')) { + $logicalPath = 'fake-vendor/ux-package1/package-controller-second.js'; + } elseif (str_ends_with($path, 'package-hello-controller.js')) { + $logicalPath = 'fake-vendor/ux-package2/package-hello-controller.js'; + } else { + // replace windows slashes + $path = str_replace('\\', '/', $path); + $assetsPosition = strpos($path, '/assets/'); + $logicalPath = substr($path, $assetsPosition + 1); + } + + $mappedAsset = new MappedAsset($logicalPath); + $mappedAsset->setSourcePath($path); + $mappedAsset->setContent(file_get_contents($path)); + + return $mappedAsset; + }); + + $packageReader = new UxPackageReader(__DIR__.'/../fixtures'); + + $generator = new ControllersMapGenerator( + $mapper, + $packageReader, + [ + __DIR__.'/../fixtures/assets/controllers', + __DIR__.'/../fixtures/assets/more-controllers', + ], + __DIR__.'/../fixtures/assets/controllers.json', + ); + + $map = $generator->getControllersMap(); + // + 3 controller.json UX controllers + // - 1 controllers.json UX controller is disabled + // + 4 custom controllers (1 file is not a controller & 1 is overridden) + $this->assertCount(6, $map); + $packageNames = array_keys($map); + sort($packageNames); + $this->assertSame([ + 'bye', + 'fake-vendor--ux-package1--controller-second', + 'fake-vendor--ux-package2--hello-controller', + 'hello', + 'other', + 'subdir--deeper', + ], $packageNames); + + $controllerSecond = $map['fake-vendor--ux-package1--controller-second']; + $this->assertSame('fake-vendor/ux-package1/package-controller-second.js', $controllerSecond->asset->getLogicalPath()); + // lazy from user's controller.json + $this->assertTrue($controllerSecond->isLazy); + + $helloControllerFromPackage = $map['fake-vendor--ux-package2--hello-controller']; + $this->assertSame('fake-vendor/ux-package2/package-hello-controller.js', $helloControllerFromPackage->asset->getLogicalPath()); + $this->assertFalse($helloControllerFromPackage->isLazy); + + $helloController = $map['hello']; + $this->assertStringContainsString('hello-controller.js override', file_get_contents($helloController->asset->getSourcePath())); + $this->assertFalse($helloController->isLazy); + + // lazy from stimulusFetch comment + $byeController = $map['bye']; + $this->assertTrue($byeController->isLazy); + + $otherController = $map['other']; + $this->assertTrue($otherController->isLazy); + } +} diff --git a/src/StimulusBundle/tests/AssetMapper/StimulusControllerLoaderFunctionalTest.php b/src/StimulusBundle/tests/AssetMapper/StimulusControllerLoaderFunctionalTest.php new file mode 100644 index 00000000000..6059d30ccaf --- /dev/null +++ b/src/StimulusBundle/tests/AssetMapper/StimulusControllerLoaderFunctionalTest.php @@ -0,0 +1,71 @@ + + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\UX\StimulusBundle\Tests\AssetMapper; + +use Symfony\Bundle\FrameworkBundle\Test\WebTestCase; +use Symfony\Component\Filesystem\Filesystem; +use Symfony\UX\StimulusBundle\Tests\fixtures\StimulusTestKernel; +use Zenstruck\Browser\Test\HasBrowser; + +class StimulusControllerLoaderFunctionalTest extends WebTestCase +{ + use HasBrowser; + + public function testFullApplicationLoad() + { + $filesystem = new Filesystem(); + $filesystem->remove(__DIR__.'/../fixtures/var/cache'); + + $crawler = $this->browser() + ->get('/') + ->crawler() + ; + + $importMapJson = $crawler->filter('script[type="importmap"]')->html(); + $importMap = json_decode($importMapJson, true); + $importMapKeys = array_keys($importMap['imports']); + sort($importMapKeys); + $this->assertSame([ + // 1x import from loader.js (which is aliased to @symfony/stimulus-bundle via importmap) + '/assets/@symfony/stimulus-bundle/controllers.js', + // 2x from "controllers" (hello is overridden) + '/assets/controllers/bye_controller.js', + '/assets/controllers/subdir/deeper-controller.js', + // 2x from UX packages, which are enabled in controllers.json + '/assets/fake-vendor/ux-package1/package-controller-second.js', + '/assets/fake-vendor/ux-package2/package-hello-controller.js', + // 2x from more-controllers + '/assets/more-controllers/hello-controller.js', + '/assets/more-controllers/other-controller.js', + // 3x from importmap.php + '@hotwired/stimulus', + '@symfony/stimulus-bundle', + 'app', + ], $importMapKeys); + + // "app" & loader.js are pre-loaded. So, all non-lazy controllers should be preloaded: + $preLoadHrefs = $crawler->filter('link[rel="modulepreload"]')->each(function ($link) { + return $link->attr('href'); + }); + $this->assertCount(6, $preLoadHrefs); + sort($preLoadHrefs); + $this->assertStringStartsWith('/assets/@symfony/stimulus-bundle/controllers-', $preLoadHrefs[0]); + $this->assertStringStartsWith('/assets/@symfony/stimulus-bundle/loader-', $preLoadHrefs[1]); + $this->assertStringStartsWith('/assets/app-', $preLoadHrefs[2]); + $this->assertStringStartsWith('/assets/controllers/subdir/deeper-controller-', $preLoadHrefs[3]); + $this->assertStringStartsWith('/assets/fake-vendor/ux-package2/package-hello-controller-', $preLoadHrefs[4]); + $this->assertStringStartsWith('/assets/more-controllers/hello-controller-', $preLoadHrefs[5]); + } + + protected static function getKernelClass(): string + { + return StimulusTestKernel::class; + } +} diff --git a/src/StimulusBundle/tests/AssetMapper/StimulusLoaderJavaScriptCompilerTest.php b/src/StimulusBundle/tests/AssetMapper/StimulusLoaderJavaScriptCompilerTest.php new file mode 100644 index 00000000000..977f1051220 --- /dev/null +++ b/src/StimulusBundle/tests/AssetMapper/StimulusLoaderJavaScriptCompilerTest.php @@ -0,0 +1,113 @@ + + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\UX\StimulusBundle\Tests\AssetMapper; + +use PHPUnit\Framework\TestCase; +use Symfony\Component\AssetMapper\AssetMapperInterface; +use Symfony\Component\AssetMapper\MappedAsset; +use Symfony\UX\StimulusBundle\AssetMapper\ControllersMapGenerator; +use Symfony\UX\StimulusBundle\AssetMapper\MappedControllerAsset; +use Symfony\UX\StimulusBundle\AssetMapper\StimulusLoaderJavaScriptCompiler; + +class StimulusLoaderJavaScriptCompilerTest extends TestCase +{ + public function testCompileDynamicallyAddsContents() + { + $controllerMapGenerator = $this->createMock(ControllersMapGenerator::class); + $controllerMapGenerator->expects($this->once()) + ->method('getControllersMap') + ->willReturn([ + 'foo' => new MappedControllerAsset( + $this->createAsset('/assets/controllers/foo-controller.js'), + false, + ), + 'bar' => new MappedControllerAsset( + $this->createAsset('/assets/controllers/bar-controller.js'), + true, + ), + 'in-root' => new MappedControllerAsset( + $this->createAsset('/assets/in-root_controller.js'), + false, + ), + 'deeper-package' => new MappedControllerAsset( + $this->createAsset('/assets/some-vendor/fake-package/deeper-package-controller.js'), + true, + ), + ]); + + $compiler = new StimulusLoaderJavaScriptCompiler( + $controllerMapGenerator, + true, + ); + $loaderAsset = $this->createAsset('/assets/symfony/stimulus-bundle/loader.js'); + $startingContents = file_get_contents(__DIR__.'/../../assets/dist/loader.js'); + + $compiledContents = $compiler->compile($startingContents, $loaderAsset, $this->createMock(AssetMapperInterface::class)); + $this->assertStringContainsString( + "import controller_0 from '../../controllers/foo-controller.js';", + $compiledContents, + ); + $this->assertStringContainsString( + "import controller_1 from '../../in-root_controller.js';", + $compiledContents, + ); + $this->assertStringContainsString( + 'export const eagerControllers = {"foo": controller_0, "in-root": controller_1};', + $compiledContents, + ); + + $this->assertStringContainsString( + 'export const lazyControllers = {"bar": () => import("../../controllers/bar-controller.js"), "deeper-package": () => import("../../some-vendor/fake-package/deeper-package-controller.js")};', + $compiledContents, + ); + + // all 4 controllers should be dependencies + $this->assertCount(4, $loaderAsset->getDependencies()); + } + + public function testDebugModeIsSetCorrectly() + { + $controllerMapGenerator = $this->createMock(ControllersMapGenerator::class); + $controllerMapGenerator->expects($this->any()) + ->method('getControllersMap') + ->willReturn([]); + + $loaderAsset = $this->createAsset('/assets/symfony/stimulus-bundle/loader.js'); + $startingContents = file_get_contents(__DIR__.'/../../assets/dist/loader.js'); + + $compiler = new StimulusLoaderJavaScriptCompiler( + $controllerMapGenerator, + isDebug: true, + ); + $compiledContents = $compiler->compile($startingContents, $loaderAsset, $this->createMock(AssetMapperInterface::class)); + $this->assertStringContainsString( + 'const isApplicationDebug = true;', + $compiledContents, + ); + + $compiler = new StimulusLoaderJavaScriptCompiler( + $controllerMapGenerator, + isDebug: false, + ); + $compiledContents = $compiler->compile($startingContents, $loaderAsset, $this->createMock(AssetMapperInterface::class)); + $this->assertStringContainsString( + 'const isApplicationDebug = false;', + $compiledContents, + ); + } + + private function createAsset(string $publicPath): MappedAsset + { + $asset = new MappedAsset(basename($publicPath)); + $asset->setPublicPathWithoutDigest($publicPath); + + return $asset; + } +} diff --git a/src/StimulusBundle/tests/Dto/StimulusAttributesTest.php b/src/StimulusBundle/tests/Dto/StimulusAttributesTest.php new file mode 100644 index 00000000000..3e211ce65bc --- /dev/null +++ b/src/StimulusBundle/tests/Dto/StimulusAttributesTest.php @@ -0,0 +1,151 @@ + + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\UX\StimulusBundle\Tests\Dto; + +use PHPUnit\Framework\TestCase; +use Symfony\UX\StimulusBundle\Dto\StimulusAttributes; +use Twig\Environment; +use Twig\Loader\ArrayLoader; + +final class StimulusAttributesTest extends TestCase +{ + private StimulusAttributes $stimulusAttributes; + + protected function setUp(): void + { + $this->stimulusAttributes = new StimulusAttributes(new Environment(new ArrayLoader())); + } + + public function testAddAction(): void + { + $this->stimulusAttributes->addAction('foo', 'bar', 'baz', ['qux' => '"']); + $attributesHtml = (string) $this->stimulusAttributes; + self::assertSame('data-action="baz->foo#bar" data-foo-qux-param="""', $attributesHtml); + } + + public function testAddActionToArrayNoEscapingAttributeValues(): void + { + $this->stimulusAttributes->addAction('foo', 'bar', 'baz', ['qux' => '"']); + $attributesArray = $this->stimulusAttributes->toArray(); + self::assertSame(['data-action' => 'baz->foo#bar', 'data-foo-qux-param' => '"'], $attributesArray); + } + + public function testAddActionWithMultiple(): void + { + $this->stimulusAttributes->addAction('my-controller', 'onClick'); + $this->assertSame('data-action="my-controller#onClick"', (string) $this->stimulusAttributes); + $this->assertSame(['data-action' => 'my-controller#onClick'], $this->stimulusAttributes->toArray()); + + $this->stimulusAttributes->addAction('second-controller', 'onClick', 'click'); + $this->assertSame( + 'data-action="my-controller#onClick click->second-controller#onClick"', + (string) $this->stimulusAttributes, + ); + } + + public function testAddControllerToStringEscapingAttributeValues(): void + { + $this->stimulusAttributes->addController('foo', ['bar' => '"'], ['baz' => '"']); + $attributesHtml = (string) $this->stimulusAttributes; + self::assertSame( + 'data-controller="foo" '. + 'data-foo-bar-value=""" '. + 'data-foo-baz-class="""', + $attributesHtml + ); + } + + public function testAddControllerToArrayNoEscapingAttributeValues(): void + { + $this->stimulusAttributes->addController('foo', ['bar' => '"'], ['baz' => '"']); + $attributesArray = $this->stimulusAttributes->toArray(); + self::assertSame( + [ + 'data-controller' => 'foo', + 'data-foo-bar-value' => '"', + 'data-foo-baz-class' => '"', + ], + $attributesArray + ); + } + + public function testAddControllerNormalizesControllerName() + { + $this->stimulusAttributes->addController('@symfony/ux-dropzone/dropzone', + ['my"Key"' => true], + ['second"Key"' => 'loading'] + ); + + $this->assertSame( + 'data-controller="symfony--ux-dropzone--dropzone" data-symfony--ux-dropzone--dropzone-my-key-value="true" data-symfony--ux-dropzone--dropzone-second-key-class="loading"', + (string) $this->stimulusAttributes, + ); + $this->assertSame( + ['data-controller' => 'symfony--ux-dropzone--dropzone', 'data-symfony--ux-dropzone--dropzone-my-key-value' => 'true', 'data-symfony--ux-dropzone--dropzone-second-key-class' => 'loading'], + $this->stimulusAttributes->toArray(), + ); + + $this->stimulusAttributes->addController('my-controller', ['myValue' => 'scalar-value']); + $this->assertSame( + 'data-controller="symfony--ux-dropzone--dropzone my-controller" data-symfony--ux-dropzone--dropzone-my-key-value="true" data-symfony--ux-dropzone--dropzone-second-key-class="loading" data-my-controller-my-value-value="scalar-value"', + (string) $this->stimulusAttributes, + ); + } + + public function testAddTargetToStringEscapingAttributeValues(): void + { + $this->stimulusAttributes->addTarget('foo', '"'); + $attributesHtml = (string) $this->stimulusAttributes; + self::assertSame('data-foo-target="""', $attributesHtml); + } + + public function testAddTargetToArrayNoEscapingAttributeValues(): void + { + $this->stimulusAttributes->addTarget('foo', '"'); + $attributesArray = $this->stimulusAttributes->toArray(); + self::assertSame(['data-foo-target' => '"'], $attributesArray); + } + + public function testAddTargetWithMultiple(): void + { + $this->stimulusAttributes->addTarget('my-controller', 'myTarget'); + $this->assertSame('data-my-controller-target="myTarget"', (string) $this->stimulusAttributes); + $this->assertSame(['data-my-controller-target' => 'myTarget'], $this->stimulusAttributes->toArray()); + + $this->stimulusAttributes->addTarget('second-controller', 'secondTarget'); + $this->assertSame( + 'data-my-controller-target="myTarget" data-second-controller-target="secondTarget"', + (string) $this->stimulusAttributes, + ); + } + + public function testAddMultipleTargetsAtOnce() + { + $this->stimulusAttributes->addTarget('my-controller', 'myTarget myOtherTarget'); + $this->assertSame('data-my-controller-target="myTarget myOtherTarget"', (string) $this->stimulusAttributes); + $this->assertSame(['data-my-controller-target' => 'myTarget myOtherTarget'], $this->stimulusAttributes->toArray()); + } + + public function testIsTraversable() + { + $this->stimulusAttributes->addController('foo', ['bar' => 'baz']); + $actualAttributes = iterator_to_array($this->stimulusAttributes); + self::assertSame(['data-controller' => 'foo', 'data-foo-bar-value' => 'baz'], $actualAttributes); + } + + public function testAddAttribute() + { + $this->stimulusAttributes->addAttribute('foo', 'bar baz'); + $this->assertSame('foo="bar baz"', (string) $this->stimulusAttributes); + $this->assertSame(['foo' => 'bar baz'], $this->stimulusAttributes->toArray()); + } +} diff --git a/src/StimulusBundle/tests/Helper/StimulusHelperTest.php b/src/StimulusBundle/tests/Helper/StimulusHelperTest.php new file mode 100644 index 00000000000..283482373fa --- /dev/null +++ b/src/StimulusBundle/tests/Helper/StimulusHelperTest.php @@ -0,0 +1,28 @@ + + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\UX\StimulusBundle\Tests\Helper; + +use PHPUnit\Framework\TestCase; +use Symfony\UX\StimulusBundle\Dto\StimulusAttributes; +use Symfony\UX\StimulusBundle\Helper\StimulusHelper; +use Twig\Environment; + +final class StimulusHelperTest extends TestCase +{ + public function testCreateStimulusAttributes(): void + { + $helper = new StimulusHelper($this->createMock(Environment::class)); + $attributes = $helper->createStimulusAttributes(); + + $this->assertInstanceOf(StimulusAttributes::class, $attributes); + } +} diff --git a/src/StimulusBundle/tests/StimulusIntegrationTestKernel.php b/src/StimulusBundle/tests/StimulusIntegrationTestKernel.php new file mode 100644 index 00000000000..fd56879f4c7 --- /dev/null +++ b/src/StimulusBundle/tests/StimulusIntegrationTestKernel.php @@ -0,0 +1,63 @@ + + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\UX\StimulusBundle\Tests; + +use Symfony\Bundle\FrameworkBundle\FrameworkBundle; +use Symfony\Bundle\FrameworkBundle\Kernel\MicroKernelTrait; +use Symfony\Bundle\TwigBundle\TwigBundle; +use Symfony\Component\Config\Loader\LoaderInterface; +use Symfony\Component\DependencyInjection\ContainerBuilder; +use Symfony\Component\HttpKernel\Kernel; +use Symfony\UX\StimulusBundle\StimulusBundle; + +final class StimulusIntegrationTestKernel extends Kernel +{ + use MicroKernelTrait; + + public function __construct() + { + parent::__construct('test', true); + } + + public function registerBundles(): array + { + return [ + new FrameworkBundle(), + new TwigBundle(), + new StimulusBundle(), + ]; + } + + protected function configureContainer(ContainerBuilder $container, LoaderInterface $loader): void + { + $frameworkConfig = [ + 'secret' => 'foo', + 'test' => true, + ]; + if (self::VERSION_ID >= 60100) { + $frameworkConfig['http_method_override'] = true; + } + $container->loadFromExtension('framework', $frameworkConfig); + + $container->loadFromExtension('twig'); + } + + public function getCacheDir(): string + { + return sys_get_temp_dir().'/cache'.spl_object_hash($this); + } + + public function getLogDir(): string + { + return sys_get_temp_dir().'/logs'.spl_object_hash($this); + } +} diff --git a/src/StimulusBundle/tests/Twig/StimulusTwigExtensionTest.php b/src/StimulusBundle/tests/Twig/StimulusTwigExtensionTest.php new file mode 100644 index 00000000000..fc92807d5ce --- /dev/null +++ b/src/StimulusBundle/tests/Twig/StimulusTwigExtensionTest.php @@ -0,0 +1,222 @@ + + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\UX\StimulusBundle\Tests\Twig; + +use PHPUnit\Framework\TestCase; +use Symfony\UX\StimulusBundle\Helper\StimulusHelper; +use Symfony\UX\StimulusBundle\Tests\StimulusIntegrationTestKernel; +use Symfony\UX\StimulusBundle\Twig\StimulusTwigExtension; +use Twig\Environment; + +final class StimulusTwigExtensionTest extends TestCase +{ + private Environment $twig; + + protected function setUp(): void + { + $kernel = new StimulusIntegrationTestKernel(); + $kernel->boot(); + $container = $kernel->getContainer()->get('test.service_container'); + $this->twig = $container->get(Environment::class); + } + + /** + * @dataProvider provideRenderStimulusController + */ + public function testRenderStimulusController(string $controllerName, array $controllerValues, array $controllerClasses, string $expectedString, array $expectedArray): void + { + $extension = new StimulusTwigExtension(new StimulusHelper($this->twig)); + $dto = $extension->renderStimulusController($controllerName, $controllerValues, $controllerClasses); + $this->assertSame($expectedString, (string) $dto); + $this->assertSame($expectedArray, $dto->toArray()); + } + + public static function provideRenderStimulusController(): iterable + { + yield 'normalize-names' => [ + 'controllerName' => '@symfony/ux-dropzone/dropzone', + 'controllerValues' => [ + 'my"Key"' => true, + ], + 'controllerClasses' => [ + 'second"Key"' => 'loading', + ], + 'expectedString' => 'data-controller="symfony--ux-dropzone--dropzone" data-symfony--ux-dropzone--dropzone-my-key-value="true" data-symfony--ux-dropzone--dropzone-second-key-class="loading"', + 'expectedArray' => ['data-controller' => 'symfony--ux-dropzone--dropzone', 'data-symfony--ux-dropzone--dropzone-my-key-value' => 'true', 'data-symfony--ux-dropzone--dropzone-second-key-class' => 'loading'], + ]; + + yield 'short-single-controller-no-data' => [ + 'controllerName' => 'my-controller', + 'controllerValues' => [], + 'controllerClasses' => [], + 'expectedString' => 'data-controller="my-controller"', + 'expectedArray' => ['data-controller' => 'my-controller'], + ]; + + yield 'short-single-controller-with-data' => [ + 'controllerName' => 'my-controller', + 'controllerValues' => ['myValue' => 'scalar-value'], + 'controllerClasses' => [], + 'expectedString' => 'data-controller="my-controller" data-my-controller-my-value-value="scalar-value"', + 'expectedArray' => ['data-controller' => 'my-controller', 'data-my-controller-my-value-value' => 'scalar-value'], + ]; + + yield 'false-attribute-value-renders-false' => [ + 'controllerName' => 'false-controller', + 'controllerValues' => ['isEnabled' => false], + 'controllerClasses' => [], + 'expectedString' => 'data-controller="false-controller" data-false-controller-is-enabled-value="false"', + 'expectedArray' => ['data-controller' => 'false-controller', 'data-false-controller-is-enabled-value' => 'false'], + ]; + + yield 'true-attribute-value-renders-true' => [ + 'controllerName' => 'true-controller', + 'controllerValues' => ['isEnabled' => true], + 'controllerClasses' => [], + 'expectedString' => 'data-controller="true-controller" data-true-controller-is-enabled-value="true"', + 'expectedArray' => ['data-controller' => 'true-controller', 'data-true-controller-is-enabled-value' => 'true'], + ]; + + yield 'null-attribute-value-does-not-render' => [ + 'controllerName' => 'null-controller', + 'controllerValues' => ['firstName' => null], + 'controllerClasses' => [], + 'expectedString' => 'data-controller="null-controller"', + 'expectedArray' => ['data-controller' => 'null-controller'], + ]; + + yield 'short-single-controller-no-data-with-class' => [ + 'controllerName' => 'my-controller', + 'controllerValues' => [], + 'controllerClasses' => ['loading' => 'spinner'], + 'expectedString' => 'data-controller="my-controller" data-my-controller-loading-class="spinner"', + 'expectedArray' => ['data-controller' => 'my-controller', 'data-my-controller-loading-class' => 'spinner'], + ]; + } + + public function testAppendStimulusController(): void + { + $extension = new StimulusTwigExtension(new StimulusHelper($this->twig)); + $dto = $extension->renderStimulusController('my-controller', ['myValue' => 'scalar-value']); + $this->assertSame( + 'data-controller="my-controller another-controller" data-my-controller-my-value-value="scalar-value" data-another-controller-another-value-value="scalar-value 2"', + (string) $extension->appendStimulusController($dto, 'another-controller', ['another-value' => 'scalar-value 2']), + ); + } + + /** + * @dataProvider provideRenderStimulusAction + */ + public function testRenderStimulusAction(string $controllerName, ?string $actionName, ?string $eventName, array $parameters, string $expectedString, array $expectedArray): void + { + $extension = new StimulusTwigExtension(new StimulusHelper($this->twig)); + $dto = $extension->renderStimulusAction($controllerName, $actionName, $eventName, $parameters); + $this->assertSame($expectedString, (string) $dto); + $this->assertSame($expectedArray, $dto->toArray()); + } + + public static function provideRenderStimulusAction(): iterable + { + yield 'with default event' => [ + 'controllerName' => 'my-controller', + 'actionName' => 'onClick', + 'eventName' => null, + 'parameters' => [], + 'expectedString' => 'data-action="my-controller#onClick"', + 'expectedArray' => ['data-action' => 'my-controller#onClick'], + ]; + + yield 'with custom event' => [ + 'controllerName' => 'my-controller', + 'actionName' => 'onClick', + 'eventName' => 'click', + 'parameters' => [], + 'expectedString' => 'data-action="click->my-controller#onClick"', + 'expectedArray' => ['data-action' => 'click->my-controller#onClick'], + ]; + + yield 'with parameters' => [ + 'controllerName' => 'my-controller', + 'actionName' => 'onClick', + 'eventName' => null, + 'parameters' => ['bool-param' => true, 'int-param' => 4, 'string-param' => 'test'], + 'expectedString' => 'data-action="my-controller#onClick" data-my-controller-bool-param-param="true" data-my-controller-int-param-param="4" data-my-controller-string-param-param="test"', + 'expectedArray' => ['data-action' => 'my-controller#onClick', 'data-my-controller-bool-param-param' => 'true', 'data-my-controller-int-param-param' => '4', 'data-my-controller-string-param-param' => 'test'], + ]; + + yield 'normalize-name, with default event' => [ + 'controllerName' => '@symfony/ux-dropzone/dropzone', + 'actionName' => 'onClick', + 'eventName' => null, + 'parameters' => [], + 'expectedString' => 'data-action="symfony--ux-dropzone--dropzone#onClick"', + 'expectedArray' => ['data-action' => 'symfony--ux-dropzone--dropzone#onClick'], + ]; + + yield 'normalize-name, with custom event' => [ + 'controllerName' => '@symfony/ux-dropzone/dropzone', + 'actionName' => 'onClick', + 'eventName' => 'click', + 'parameters' => [], + 'expectedString' => 'data-action="click->symfony--ux-dropzone--dropzone#onClick"', + 'expectedArray' => ['data-action' => 'click->symfony--ux-dropzone--dropzone#onClick'], + ]; + } + + public function testAppendStimulusAction(): void + { + $extension = new StimulusTwigExtension(new StimulusHelper($this->twig)); + $dto = $extension->renderStimulusAction('my-controller', 'onClick', 'click'); + $this->assertSame( + 'data-action="click->my-controller#onClick change->my-second-controller#onSomethingElse"', + (string) $extension->appendStimulusAction($dto, 'my-second-controller', 'onSomethingElse', 'change') + ); + } + + /** + * @dataProvider provideRenderStimulusTarget + */ + public function testRenderStimulusTarget(string $controllerName, ?string $targetName, string $expectedString, array $expectedArray) + { + $extension = new StimulusTwigExtension(new StimulusHelper($this->twig)); + $dto = $extension->renderStimulusTarget($controllerName, $targetName); + $this->assertSame($expectedString, (string) $dto); + $this->assertSame($expectedArray, $dto->toArray()); + } + + public static function provideRenderStimulusTarget(): iterable + { + yield 'simple' => [ + 'controllerName' => 'my-controller', + 'targetName' => 'myTarget', + 'expectedString' => 'data-my-controller-target="myTarget"', + 'expectedArray' => ['data-my-controller-target' => 'myTarget'], + ]; + + yield 'normalize-name' => [ + 'controllerName' => '@symfony/ux-dropzone/dropzone', + 'targetName' => 'myTarget', + 'expectedString' => 'data-symfony--ux-dropzone--dropzone-target="myTarget"', + 'expectedArray' => ['data-symfony--ux-dropzone--dropzone-target' => 'myTarget'], + ]; + } + + public function testAppendStimulusTarget(): void + { + $extension = new StimulusTwigExtension(new StimulusHelper($this->twig)); + $dto = $extension->renderStimulusTarget('my-controller', 'myTarget'); + $this->assertSame( + 'data-my-controller-target="myTarget" data-symfony--ux-dropzone--dropzone-target="anotherTarget fooTarget"', + (string) $extension->appendStimulusTarget($dto, '@symfony/ux-dropzone/dropzone', 'anotherTarget fooTarget') + ); + } +} diff --git a/src/StimulusBundle/tests/Ux/UxPackageReaderTest.php b/src/StimulusBundle/tests/Ux/UxPackageReaderTest.php new file mode 100644 index 00000000000..0f40f5fa859 --- /dev/null +++ b/src/StimulusBundle/tests/Ux/UxPackageReaderTest.php @@ -0,0 +1,53 @@ + + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\UX\StimulusBundle\Tests\Ux; + +use PHPUnit\Framework\TestCase; +use Symfony\UX\StimulusBundle\Ux\UxPackageMetadata; +use Symfony\UX\StimulusBundle\Ux\UxPackageReader; + +class UxPackageReaderTest extends TestCase +{ + public function testReadPackageMetadata() + { + $reader = new UxPackageReader(__DIR__.'/../fixtures'); + + $metadata = $reader->readPackageMetadata('@fake-vendor/ux-package1'); + $this->assertInstanceOf(UxPackageMetadata::class, $metadata); + $this->assertSame(__DIR__.'/../fixtures/vendor/fake-vendor/ux-package1/assets', $metadata->packageDirectory); + $this->assertSame('fake-vendor/ux-package1', $metadata->packageName); + $symfonyConfig = $metadata->symfonyConfig; + $this->assertSame([ + 'controller_first' => [ + 'main' => 'dist/package-controller-first.js', + 'fetch' => 'eager', + 'enabled' => true, + ], + 'controller_second' => [ + 'main' => 'dist/package-controller-second.js', + 'fetch' => 'lazy', + 'enabled' => true, + ], + ], $symfonyConfig['controllers']); + + $metadata2 = $reader->readPackageMetadata('@fake-vendor/ux-package2'); + $this->assertInstanceOf(UxPackageMetadata::class, $metadata2); + } + + public function testExceptionIsThrownIfPackageCannotBeFound() + { + $reader = new UxPackageReader(__DIR__.'/../fixtures'); + + $this->expectException(\RuntimeException::class); + $this->expectExceptionMessage('Could not find package "fake-vendor/ux-package3" referred to from controllers.json.'); + + $reader->readPackageMetadata('@fake-vendor/ux-package3'); + } +} diff --git a/src/StimulusBundle/tests/fixtures/StimulusTestKernel.php b/src/StimulusBundle/tests/fixtures/StimulusTestKernel.php new file mode 100644 index 00000000000..0f0ab4b210c --- /dev/null +++ b/src/StimulusBundle/tests/fixtures/StimulusTestKernel.php @@ -0,0 +1,83 @@ + + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\UX\StimulusBundle\Tests\fixtures; + +use Psr\Log\NullLogger; +use Symfony\Bundle\FrameworkBundle\FrameworkBundle; +use Symfony\Bundle\FrameworkBundle\Kernel\MicroKernelTrait; +use Symfony\Bundle\TwigBundle\TwigBundle; +use Symfony\Component\DependencyInjection\ContainerBuilder; +use Symfony\Component\DependencyInjection\Loader\Configurator\ContainerConfigurator; +use Symfony\Component\HttpFoundation\Response; +use Symfony\Component\HttpKernel\Kernel; +use Symfony\Component\Routing\Loader\Configurator\RoutingConfigurator; +use Symfony\UX\StimulusBundle\StimulusBundle; +use Twig\Environment; + +class StimulusTestKernel extends Kernel +{ + use MicroKernelTrait; + + public function homepage(Environment $twig): Response + { + return new Response($twig->render('homepage.html.twig')); + } + + public function registerBundles(): iterable + { + return [ + new FrameworkBundle(), + new TwigBundle(), + new StimulusBundle(), + ]; + } + + public function getProjectDir(): string + { + return __DIR__; + } + + protected function configureContainer(ContainerConfigurator $container): void + { + $container->extension('framework', [ + 'secret' => 'foo000', + 'http_method_override' => false, + 'asset_mapper' => [ + 'paths' => [ + 'assets' => '', + __DIR__.'/vendor/fake-vendor/ux-package1/assets/dist' => 'fake-vendor/ux-package1', + __DIR__.'/vendor/fake-vendor/ux-package2/Resources/assets/dist' => 'fake-vendor/ux-package2', + ], + ], + 'test' => true, + ]); + + $container->extension('twig', [ + 'default_path' => '%kernel.project_dir%/templates', + ]); + + $container->extension('stimulus', [ + 'controller_paths' => [ + __DIR__.'/assets/controllers', + __DIR__.'/assets/more-controllers', + ], + ]); + } + + protected function build(ContainerBuilder $container): void + { + $container->register('logger', NullLogger::class); + } + + protected function configureRoutes(RoutingConfigurator $routes): void + { + $routes->add('homepage', '/')->controller('kernel::homepage'); + } +} diff --git a/src/StimulusBundle/tests/fixtures/assets/app.js b/src/StimulusBundle/tests/fixtures/assets/app.js new file mode 100644 index 00000000000..b22f20c7d5f --- /dev/null +++ b/src/StimulusBundle/tests/fixtures/assets/app.js @@ -0,0 +1,3 @@ +import { startStimulusApp } from '@symfony/stimulus-bundle'; + +const app = startStimulusApp(); diff --git a/src/StimulusBundle/tests/fixtures/assets/controllers.json b/src/StimulusBundle/tests/fixtures/assets/controllers.json new file mode 100644 index 00000000000..7c251c1dabd --- /dev/null +++ b/src/StimulusBundle/tests/fixtures/assets/controllers.json @@ -0,0 +1,20 @@ +{ + "controllers": { + "@fake-vendor/ux-package1": { + "controller_first": { + "enabled": false, + "fetch": "eager" + }, + "controller_second": { + "enabled": true, + "fetch": "lazy" + } + }, + "@fake-vendor/ux-package2": { + "hello_controller": { + "enabled": true, + "fetch": "eager" + } + } + } +} diff --git a/src/StimulusBundle/tests/fixtures/assets/controllers/bye_controller.js b/src/StimulusBundle/tests/fixtures/assets/controllers/bye_controller.js new file mode 100644 index 00000000000..947f15d1136 --- /dev/null +++ b/src/StimulusBundle/tests/fixtures/assets/controllers/bye_controller.js @@ -0,0 +1,6 @@ +// bye_controller.js +import { Controller } from '@hotwired/stimulus'; + +/* stimulusFetch: 'lazy' */ +export default class extends Controller { +} diff --git a/src/StimulusBundle/tests/fixtures/assets/controllers/hello-controller.js b/src/StimulusBundle/tests/fixtures/assets/controllers/hello-controller.js new file mode 100644 index 00000000000..406bddc7d97 --- /dev/null +++ b/src/StimulusBundle/tests/fixtures/assets/controllers/hello-controller.js @@ -0,0 +1,6 @@ +// hello-controller.js + +import { Controller } from '@hotwired/stimulus'; + +export default class extends Controller { +} diff --git a/src/StimulusBundle/tests/fixtures/assets/controllers/some-non-controller-file.js b/src/StimulusBundle/tests/fixtures/assets/controllers/some-non-controller-file.js new file mode 100644 index 00000000000..355ec3f6701 --- /dev/null +++ b/src/StimulusBundle/tests/fixtures/assets/controllers/some-non-controller-file.js @@ -0,0 +1,2 @@ +// some-non-controller-file.js + diff --git a/src/StimulusBundle/tests/fixtures/assets/controllers/subdir/deeper-controller.js b/src/StimulusBundle/tests/fixtures/assets/controllers/subdir/deeper-controller.js new file mode 100644 index 00000000000..7622223c6f9 --- /dev/null +++ b/src/StimulusBundle/tests/fixtures/assets/controllers/subdir/deeper-controller.js @@ -0,0 +1,5 @@ +// subdir/deeper-controller.js +import { Controller } from '@hotwired/stimulus'; + +export default class extends Controller { +} diff --git a/src/StimulusBundle/tests/fixtures/assets/more-controllers/hello-controller.js b/src/StimulusBundle/tests/fixtures/assets/more-controllers/hello-controller.js new file mode 100644 index 00000000000..afb36d08b7e --- /dev/null +++ b/src/StimulusBundle/tests/fixtures/assets/more-controllers/hello-controller.js @@ -0,0 +1,5 @@ +// hello-controller.js override +import { Controller } from '@hotwired/stimulus'; + +export default class extends Controller { +} diff --git a/src/StimulusBundle/tests/fixtures/assets/more-controllers/other-controller.js b/src/StimulusBundle/tests/fixtures/assets/more-controllers/other-controller.js new file mode 100644 index 00000000000..9ef550ac30a --- /dev/null +++ b/src/StimulusBundle/tests/fixtures/assets/more-controllers/other-controller.js @@ -0,0 +1,6 @@ +// other-controller.js +import { Controller } from '@hotwired/stimulus'; + +/* stimulusFetch: 'lazy' */ +export default class extends Controller { +} diff --git a/src/StimulusBundle/tests/fixtures/importmap.php b/src/StimulusBundle/tests/fixtures/importmap.php new file mode 100644 index 00000000000..56cd7fbb626 --- /dev/null +++ b/src/StimulusBundle/tests/fixtures/importmap.php @@ -0,0 +1,22 @@ + + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +return [ + 'app' => [ + 'path' => 'app.js', + 'preload' => true, + ], + '@hotwired/stimulus' => [ + 'url' => 'https://ga.jspm.io/npm:@hotwired/stimulus@3.2.1/dist/stimulus.js', + ], + '@symfony/stimulus-bundle' => [ + 'path' => '@symfony/stimulus-bundle/loader.js', + 'preload' => true, + ], +]; diff --git a/src/StimulusBundle/tests/fixtures/templates/homepage.html.twig b/src/StimulusBundle/tests/fixtures/templates/homepage.html.twig new file mode 100644 index 00000000000..45966b68b21 --- /dev/null +++ b/src/StimulusBundle/tests/fixtures/templates/homepage.html.twig @@ -0,0 +1,10 @@ + + + + + {{ importmap() }} + + +

Hello Stimulus!

+ + diff --git a/src/StimulusBundle/tests/fixtures/vendor/fake-vendor/ux-package1/assets/dist/package-controller-second.js b/src/StimulusBundle/tests/fixtures/vendor/fake-vendor/ux-package1/assets/dist/package-controller-second.js new file mode 100644 index 00000000000..0bb697a2076 --- /dev/null +++ b/src/StimulusBundle/tests/fixtures/vendor/fake-vendor/ux-package1/assets/dist/package-controller-second.js @@ -0,0 +1 @@ +// package-controller-second.js diff --git a/src/StimulusBundle/tests/fixtures/vendor/fake-vendor/ux-package1/assets/package.json b/src/StimulusBundle/tests/fixtures/vendor/fake-vendor/ux-package1/assets/package.json new file mode 100644 index 00000000000..21da5fd099f --- /dev/null +++ b/src/StimulusBundle/tests/fixtures/vendor/fake-vendor/ux-package1/assets/package.json @@ -0,0 +1,16 @@ +{ + "symfony": { + "controllers": { + "controller_first": { + "main": "dist/package-controller-first.js", + "fetch": "eager", + "enabled": true + }, + "controller_second": { + "main": "dist/package-controller-second.js", + "fetch": "lazy", + "enabled": true + } + } + } +} diff --git a/src/StimulusBundle/tests/fixtures/vendor/fake-vendor/ux-package2/Resources/assets/dist/package-hello-controller.js b/src/StimulusBundle/tests/fixtures/vendor/fake-vendor/ux-package2/Resources/assets/dist/package-hello-controller.js new file mode 100644 index 00000000000..1a9aede520f --- /dev/null +++ b/src/StimulusBundle/tests/fixtures/vendor/fake-vendor/ux-package2/Resources/assets/dist/package-hello-controller.js @@ -0,0 +1 @@ +// package-hello-controller.js diff --git a/src/StimulusBundle/tests/fixtures/vendor/fake-vendor/ux-package2/Resources/assets/package.json b/src/StimulusBundle/tests/fixtures/vendor/fake-vendor/ux-package2/Resources/assets/package.json new file mode 100644 index 00000000000..50975952603 --- /dev/null +++ b/src/StimulusBundle/tests/fixtures/vendor/fake-vendor/ux-package2/Resources/assets/package.json @@ -0,0 +1,11 @@ +{ + "symfony": { + "controllers": { + "hello_controller": { + "main": "dist/package-hello-controller.js", + "fetch": "eager", + "enabled": true + } + } + } +} diff --git a/src/Svelte/CHANGELOG.md b/src/Svelte/CHANGELOG.md new file mode 100644 index 00000000000..2955b707b04 --- /dev/null +++ b/src/Svelte/CHANGELOG.md @@ -0,0 +1,13 @@ +# CHANGELOG + +## 2.9.0 + +- Add support for symfony/asset-mapper + +- Replace `symfony/webpack-encore-bundle` by `symfony/stimulus-bundle` in dependencies + +- Minimum PHP version is now 8.1 + +## 2.8.0 + +- Introduce the package diff --git a/src/Svelte/assets/package.json b/src/Svelte/assets/package.json index 5bd32a03a35..3c9324e4361 100644 --- a/src/Svelte/assets/package.json +++ b/src/Svelte/assets/package.json @@ -12,6 +12,10 @@ "fetch": "eager", "enabled": true } + }, + "importmap": { + "@hotwired/stimulus": "^3.0.0", + "svelte": "^3.0" } }, "peerDependencies": { diff --git a/src/Svelte/composer.json b/src/Svelte/composer.json index fc7c11937db..724c371c617 100644 --- a/src/Svelte/composer.json +++ b/src/Svelte/composer.json @@ -32,7 +32,8 @@ } }, "require": { - "symfony/webpack-encore-bundle": "^1.15" + "php": ">=8.1", + "symfony/stimulus-bundle": "^2.9" }, "require-dev": { "symfony/framework-bundle": "^5.4|^6.2", diff --git a/src/Svelte/src/DependencyInjection/SvelteExtension.php b/src/Svelte/src/DependencyInjection/SvelteExtension.php index 38ac1f8608c..fc58448deae 100644 --- a/src/Svelte/src/DependencyInjection/SvelteExtension.php +++ b/src/Svelte/src/DependencyInjection/SvelteExtension.php @@ -11,8 +11,10 @@ namespace Symfony\UX\Svelte\DependencyInjection; +use Symfony\Component\AssetMapper\AssetMapperInterface; use Symfony\Component\DependencyInjection\ContainerBuilder; use Symfony\Component\DependencyInjection\Definition; +use Symfony\Component\DependencyInjection\Extension\PrependExtensionInterface; use Symfony\Component\DependencyInjection\Reference; use Symfony\Component\HttpKernel\DependencyInjection\Extension; use Symfony\UX\Svelte\Twig\SvelteComponentExtension; @@ -23,15 +25,30 @@ * * @internal */ -class SvelteExtension extends Extension +class SvelteExtension extends Extension implements PrependExtensionInterface { public function load(array $configs, ContainerBuilder $container) { $container ->setDefinition('twig.extension.svelte', new Definition(SvelteComponentExtension::class)) - ->setArgument(0, new Reference('webpack_encore.twig_stimulus_extension')) + ->setArgument(0, new Reference('stimulus.helper')) ->addTag('twig.extension') ->setPublic(false) ; } + + public function prepend(ContainerBuilder $container) + { + if (!interface_exists(AssetMapperInterface::class)) { + return; + } + + $container->prependExtensionConfig('framework', [ + 'asset_mapper' => [ + 'paths' => [ + __DIR__.'/../../assets/dist' => '@symfony/ux-svelte', + ], + ], + ]); + } } diff --git a/src/Svelte/src/Twig/SvelteComponentExtension.php b/src/Svelte/src/Twig/SvelteComponentExtension.php index 50a1725fddc..9c3c4cd924d 100644 --- a/src/Svelte/src/Twig/SvelteComponentExtension.php +++ b/src/Svelte/src/Twig/SvelteComponentExtension.php @@ -11,8 +11,8 @@ namespace Symfony\UX\Svelte\Twig; +use Symfony\UX\StimulusBundle\Helper\StimulusHelper; use Symfony\WebpackEncoreBundle\Twig\StimulusTwigExtension; -use Twig\Environment; use Twig\Extension\AbstractExtension; use Twig\TwigFunction; @@ -24,21 +24,29 @@ */ class SvelteComponentExtension extends AbstractExtension { - private $stimulusExtension; + private $stimulusHelper; - public function __construct(StimulusTwigExtension $stimulusExtension) + /** + * @param $stimulus StimulusHelper + */ + public function __construct(StimulusHelper|StimulusTwigExtension $stimulus) { - $this->stimulusExtension = $stimulusExtension; + if ($stimulus instanceof StimulusTwigExtension) { + trigger_deprecation('symfony/ux-svelte', '2.9', 'Passing an instance of "%s" to "%s" is deprecated, pass an instance of "%s" instead.', StimulusTwigExtension::class, __CLASS__, StimulusHelper::class); + $stimulus = new StimulusHelper(null); + } + + $this->stimulusHelper = $stimulus; } public function getFunctions(): array { return [ - new TwigFunction('svelte_component', [$this, 'renderSvelteComponent'], ['needs_environment' => true, 'is_safe' => ['html_attr']]), + new TwigFunction('svelte_component', [$this, 'renderSvelteComponent'], ['is_safe' => ['html_attr']]), ]; } - public function renderSvelteComponent(Environment $env, string $componentName, array $props = [], bool $intro = false): string + public function renderSvelteComponent(string $componentName, array $props = [], bool $intro = false): string { $params = ['component' => $componentName]; if ($props) { @@ -48,6 +56,9 @@ public function renderSvelteComponent(Environment $env, string $componentName, a $params['intro'] = true; } - return $this->stimulusExtension->renderStimulusController($env, '@symfony/ux-svelte/svelte', $params); + $stimulusAttributes = $this->stimulusHelper->createStimulusAttributes(); + $stimulusAttributes->addController('@symfony/ux-svelte/svelte', $params); + + return (string) $stimulusAttributes; } } diff --git a/src/Svelte/tests/Kernel/AppKernelTrait.php b/src/Svelte/tests/Kernel/AppKernelTrait.php deleted file mode 100644 index 943efbda9f9..00000000000 --- a/src/Svelte/tests/Kernel/AppKernelTrait.php +++ /dev/null @@ -1,42 +0,0 @@ - - * - * For the full copyright and license information, please view the LICENSE - * file that was distributed with this source code. - */ - -namespace Symfony\UX\Svelte\Tests\Kernel; - -/** - * @author Titouan Galopin - * @author Thomas Choquet - * - * @internal - */ -trait AppKernelTrait -{ - public function getCacheDir(): string - { - return $this->createTmpDir('cache'); - } - - public function getLogDir(): string - { - return $this->createTmpDir('logs'); - } - - private function createTmpDir(string $type): string - { - $dir = sys_get_temp_dir().'/svelte_bundle/'.uniqid($type.'_', true); - - if (!file_exists($dir)) { - mkdir($dir, 0777, true); - } - - return $dir; - } -} diff --git a/src/Svelte/tests/Kernel/FrameworkAppKernel.php b/src/Svelte/tests/Kernel/FrameworkAppKernel.php deleted file mode 100644 index 7aabc31a638..00000000000 --- a/src/Svelte/tests/Kernel/FrameworkAppKernel.php +++ /dev/null @@ -1,43 +0,0 @@ - - * - * For the full copyright and license information, please view the LICENSE - * file that was distributed with this source code. - */ - -namespace Symfony\UX\Svelte\Tests\Kernel; - -use Symfony\Bundle\FrameworkBundle\FrameworkBundle; -use Symfony\Component\Config\Loader\LoaderInterface; -use Symfony\Component\DependencyInjection\ContainerBuilder; -use Symfony\Component\HttpKernel\Kernel; -use Symfony\UX\Svelte\SvelteBundle; -use Symfony\WebpackEncoreBundle\WebpackEncoreBundle; - -/** - * @author Titouan Galopin - * @author Thomas Choquet - * - * @internal - */ -class FrameworkAppKernel extends Kernel -{ - use AppKernelTrait; - - public function registerBundles(): iterable - { - return [new WebpackEncoreBundle(), new FrameworkBundle(), new SvelteBundle()]; - } - - public function registerContainerConfiguration(LoaderInterface $loader) - { - $loader->load(function (ContainerBuilder $container) { - $container->loadFromExtension('framework', ['secret' => '$ecret', 'test' => true]); - $container->loadFromExtension('webpack_encore', ['output_path' => '%kernel.project_dir%/public/build']); - }); - } -} diff --git a/src/Svelte/tests/Kernel/TwigAppKernel.php b/src/Svelte/tests/Kernel/TwigAppKernel.php index 5be9594b1e3..105b0f47fc7 100644 --- a/src/Svelte/tests/Kernel/TwigAppKernel.php +++ b/src/Svelte/tests/Kernel/TwigAppKernel.php @@ -16,8 +16,8 @@ use Symfony\Component\Config\Loader\LoaderInterface; use Symfony\Component\DependencyInjection\ContainerBuilder; use Symfony\Component\HttpKernel\Kernel; +use Symfony\UX\StimulusBundle\StimulusBundle; use Symfony\UX\Svelte\SvelteBundle; -use Symfony\WebpackEncoreBundle\WebpackEncoreBundle; /** * @author Titouan Galopin @@ -27,22 +27,40 @@ */ class TwigAppKernel extends Kernel { - use AppKernelTrait; - public function registerBundles(): iterable { - return [new WebpackEncoreBundle(), new FrameworkBundle(), new TwigBundle(), new SvelteBundle()]; + return [new StimulusBundle(), new FrameworkBundle(), new TwigBundle(), new SvelteBundle()]; } public function registerContainerConfiguration(LoaderInterface $loader) { $loader->load(function (ContainerBuilder $container) { $container->loadFromExtension('framework', ['secret' => '$ecret', 'test' => true]); - $container->loadFromExtension('webpack_encore', ['output_path' => '%kernel.project_dir%/public/build']); $container->loadFromExtension('twig', ['default_path' => __DIR__.'/templates', 'strict_variables' => true, 'exception_controller' => null]); $container->setAlias('test.twig', 'twig')->setPublic(true); $container->setAlias('test.twig.extension.svelte', 'twig.extension.svelte')->setPublic(true); }); } + + public function getCacheDir(): string + { + return $this->createTmpDir('cache'); + } + + public function getLogDir(): string + { + return $this->createTmpDir('logs'); + } + + private function createTmpDir(string $type): string + { + $dir = sys_get_temp_dir().'/svelte_bundle/'.uniqid($type.'_', true); + + if (!file_exists($dir)) { + mkdir($dir, 0777, true); + } + + return $dir; + } } diff --git a/src/Svelte/tests/SvelteBundleTest.php b/src/Svelte/tests/SvelteBundleTest.php index 7e2eeb39d9e..01460d4eee1 100644 --- a/src/Svelte/tests/SvelteBundleTest.php +++ b/src/Svelte/tests/SvelteBundleTest.php @@ -12,8 +12,6 @@ namespace Symfony\UX\Symfony\Tests; use PHPUnit\Framework\TestCase; -use Symfony\Component\HttpKernel\Kernel; -use Symfony\UX\Svelte\Tests\Kernel\FrameworkAppKernel; use Symfony\UX\Svelte\Tests\Kernel\TwigAppKernel; /** @@ -24,17 +22,9 @@ */ class SvelteBundleTest extends TestCase { - public function provideKernels() - { - yield 'framework' => [new FrameworkAppKernel('test', true)]; - yield 'twig' => [new TwigAppKernel('test', true)]; - } - - /** - * @dataProvider provideKernels - */ - public function testBootKernel(Kernel $kernel) + public function testBootKernel() { + $kernel = new TwigAppKernel('test', true); $kernel->boot(); $this->assertArrayHasKey('SvelteBundle', $kernel->getBundles()); } diff --git a/src/Svelte/tests/Twig/SvelteComponentExtensionTest.php b/src/Svelte/tests/Twig/SvelteComponentExtensionTest.php index 53486f9650a..8231c0282db 100644 --- a/src/Svelte/tests/Twig/SvelteComponentExtensionTest.php +++ b/src/Svelte/tests/Twig/SvelteComponentExtensionTest.php @@ -32,7 +32,6 @@ public function testRenderComponent() $extension = $kernel->getContainer()->get('test.twig.extension.svelte'); $rendered = $extension->renderSvelteComponent( - $kernel->getContainer()->get('test.twig'), 'SubDir/MyComponent', ['fullName' => 'Titouan Galopin'] ); @@ -51,7 +50,7 @@ public function testRenderComponentWithoutProps() /** @var SvelteComponentExtension $extension */ $extension = $kernel->getContainer()->get('test.twig.extension.svelte'); - $rendered = $extension->renderSvelteComponent($kernel->getContainer()->get('test.twig'), 'SubDir/MyComponent'); + $rendered = $extension->renderSvelteComponent('SubDir/MyComponent'); $this->assertSame( 'data-controller="symfony--ux-svelte--svelte" data-symfony--ux-svelte--svelte-component-value="SubDir/MyComponent"', @@ -68,7 +67,6 @@ public function testRenderComponentWithIntro() $extension = $kernel->getContainer()->get('test.twig.extension.svelte'); $rendered = $extension->renderSvelteComponent( - $kernel->getContainer()->get('test.twig'), 'SubDir/MyComponent', ['fullName' => 'Titouan Galopin'], true diff --git a/src/Swup/CHANGELOG.md b/src/Swup/CHANGELOG.md index 40e408418cf..63c67d18d2b 100644 --- a/src/Swup/CHANGELOG.md +++ b/src/Swup/CHANGELOG.md @@ -1,5 +1,11 @@ # CHANGELOG +## 2.9.0 + +- A SwupBundle was added - which allows for integration with symfony/asset-mapper. + +- Add support for symfony/asset-mapper + ## 2.7.0 - The JavaScript events now bubble up. diff --git a/src/Swup/assets/package.json b/src/Swup/assets/package.json index 379ffb11f0e..7a49911c1a7 100644 --- a/src/Swup/assets/package.json +++ b/src/Swup/assets/package.json @@ -13,6 +13,14 @@ "fetch": "eager", "enabled": true } + }, + "importmap": { + "@swup/fade-theme": "^1.0", + "@swup/slide-theme": "^1.0", + "@swup/forms-plugin": "^1.0", + "@swup/debug-plugin": "^1.0", + "swup": "^2.0", + "@hotwired/stimulus": "^3.0.0" } }, "peerDependencies": { diff --git a/src/Swup/composer.json b/src/Swup/composer.json index 3c5b9fb3aa3..7b98214ca7b 100644 --- a/src/Swup/composer.json +++ b/src/Swup/composer.json @@ -17,6 +17,11 @@ "homepage": "https://symfony.com/contributors" } ], + "autoload": { + "psr-4": { + "Symfony\\UX\\Swup\\": "src/" + } + }, "conflict": { "symfony/flex": "<1.13" }, diff --git a/src/Swup/doc/index.rst b/src/Swup/doc/index.rst index 672160facbb..8416f875388 100644 --- a/src/Swup/doc/index.rst +++ b/src/Swup/doc/index.rst @@ -64,7 +64,7 @@ initialize Swup: .. note:: - The ``stimulus_controller()`` function comes from `WebpackEncoreBundle v1.10`_. + The ``stimulus_controller()`` function comes from `StimulusBundle`_. That's it! Swup now reacts to a link click and run the default fade-in transition. @@ -200,6 +200,6 @@ https://symfony.com/doc/current/contributing/code/bc.html .. _`Swup`: https://swup.js.org/ .. _`the Symfony UX initiative`: https://symfony.com/ux .. _`@symfony/stimulus-bridge`: https://github.com/symfony/stimulus-bridge -.. _`WebpackEncoreBundle v1.10`: https://github.com/symfony/webpack-encore-bundle +.. _`StimulusBundle`: https://symfony.com/bundles/StimulusBundle/current/index.html .. _`Swup Options`: https://swup.js.org/options .. _`Symfony UX configured in your app`: https://symfony.com/doc/current/frontend/ux.html diff --git a/src/Swup/src/DependencyInjection/SwupExtension.php b/src/Swup/src/DependencyInjection/SwupExtension.php new file mode 100644 index 00000000000..6c74675a41e --- /dev/null +++ b/src/Swup/src/DependencyInjection/SwupExtension.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\Swup\DependencyInjection; + +use Symfony\Component\AssetMapper\AssetMapperInterface; +use Symfony\Component\DependencyInjection\ContainerBuilder; +use Symfony\Component\DependencyInjection\Extension\PrependExtensionInterface; +use Symfony\Component\HttpKernel\DependencyInjection\Extension; + +/** + * @internal + */ +class SwupExtension extends Extension implements PrependExtensionInterface +{ + public function load(array $configs, ContainerBuilder $container) + { + } + + public function prepend(ContainerBuilder $container) + { + if (!interface_exists(AssetMapperInterface::class)) { + return; + } + + $container->prependExtensionConfig('framework', [ + 'asset_mapper' => [ + 'paths' => [ + __DIR__.'/../../assets/dist' => '@symfony/ux-swup', + ], + ], + ]); + } +} diff --git a/src/Swup/src/SwupBundle.php b/src/Swup/src/SwupBundle.php new file mode 100644 index 00000000000..c03476aa96d --- /dev/null +++ b/src/Swup/src/SwupBundle.php @@ -0,0 +1,25 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\UX\Swup; + +use Symfony\Component\HttpKernel\Bundle\Bundle; + +/** + * @final + */ +class SwupBundle extends Bundle +{ + public function getPath(): string + { + return \dirname(__DIR__); + } +} diff --git a/src/Translator/CHANGELOG.md b/src/Translator/CHANGELOG.md index 9603bd3c5f1..97b7eb0ca1f 100644 --- a/src/Translator/CHANGELOG.md +++ b/src/Translator/CHANGELOG.md @@ -1,5 +1,9 @@ # CHANGELOG -## Unreleased +## 2.9.0 + +- Add support for symfony/asset-mapper + +## 2.8.0 - Component added diff --git a/src/Translator/assets/package.json b/src/Translator/assets/package.json index 890a20d2054..09bc7cbedb8 100644 --- a/src/Translator/assets/package.json +++ b/src/Translator/assets/package.json @@ -5,6 +5,11 @@ "version": "1.0.0", "main": "dist/translator_controller.js", "types": "dist/translator_controller.d.ts", + "symfony": { + "importmap": { + "intl-messageformat": "^10.2.5" + } + }, "peerDependencies": { "intl-messageformat": "^10.2.5" }, diff --git a/src/Translator/doc/index.rst b/src/Translator/doc/index.rst index 56510c0fb41..aee638ea277 100644 --- a/src/Translator/doc/index.rst +++ b/src/Translator/doc/index.rst @@ -1,6 +1,9 @@ Symfony UX Translator ===================== +**EXPERIMENTAL** This component is currently experimental and is likely +to change, or even change drastically. + Symfony UX Translator is a Symfony bundle providing the same mechanism as `Symfony Translator`_ in JavaScript with a TypeScript integration, in Symfony applications. It is part of `the Symfony UX initiative`_. @@ -116,4 +119,4 @@ https://symfony.com/doc/current/contributing/code/bc.html .. _`Symfony Translator`: https://symfony.com/doc/current/translation.html .. _`the Symfony UX initiative`: https://symfony.com/ux .. _`Symfony UX configured in your app`: https://symfony.com/doc/current/frontend/ux.html -.. _`ICU Message Format`: https://symfony.com/doc/current/translation/message_format.html \ No newline at end of file +.. _`ICU Message Format`: https://symfony.com/doc/current/translation/message_format.html diff --git a/src/Translator/src/DependencyInjection/UxTranslatorExtension.php b/src/Translator/src/DependencyInjection/UxTranslatorExtension.php index f794a8a8dce..bc8e8f20f9d 100644 --- a/src/Translator/src/DependencyInjection/UxTranslatorExtension.php +++ b/src/Translator/src/DependencyInjection/UxTranslatorExtension.php @@ -11,8 +11,10 @@ namespace Symfony\UX\Translator\DependencyInjection; +use Symfony\Component\AssetMapper\AssetMapperInterface; use Symfony\Component\Config\FileLocator; use Symfony\Component\DependencyInjection\ContainerBuilder; +use Symfony\Component\DependencyInjection\Extension\PrependExtensionInterface; use Symfony\Component\DependencyInjection\Loader\PhpFileLoader; use Symfony\Component\HttpKernel\DependencyInjection\Extension; @@ -23,7 +25,7 @@ * * @experimental */ -class UxTranslatorExtension extends Extension +class UxTranslatorExtension extends Extension implements PrependExtensionInterface { public function load(array $configs, ContainerBuilder $container) { @@ -35,4 +37,19 @@ public function load(array $configs, ContainerBuilder $container) $container->getDefinition('ux.translator.translations_dumper')->setArgument(0, $config['dump_directory']); } + + public function prepend(ContainerBuilder $container) + { + if (!interface_exists(AssetMapperInterface::class)) { + return; + } + + $container->prependExtensionConfig('framework', [ + 'asset_mapper' => [ + 'paths' => [ + __DIR__.'/../../assets/dist' => '@symfony/ux-translator', + ], + ], + ]); + } } diff --git a/src/Turbo/CHANGELOG.md b/src/Turbo/CHANGELOG.md index 424fc378d8c..a4e3337e0e7 100644 --- a/src/Turbo/CHANGELOG.md +++ b/src/Turbo/CHANGELOG.md @@ -1,5 +1,13 @@ # CHANGELOG +## 2.9.0 + +- Minimum PHP version is now 8.1 + +- Add support for symfony/asset-mapper + +- Replace `symfony/webpack-encore-bundle` by `symfony/stimulus-bundle` in dependencies + ## 2.7.0 - Add `assets/src` to `.gitattributes` to exclude source TypeScript files from diff --git a/src/Turbo/assets/package.json b/src/Turbo/assets/package.json index 423961ed7bb..7bec2b15d4f 100644 --- a/src/Turbo/assets/package.json +++ b/src/Turbo/assets/package.json @@ -19,6 +19,10 @@ "fetch": "eager", "enabled": false } + }, + "importmap": { + "@hotwired/turbo": "^7.0.1", + "@hotwired/stimulus": "^3.0.0" } }, "peerDependencies": { diff --git a/src/Turbo/composer.json b/src/Turbo/composer.json index 7e63de9da10..ae18f4de0da 100644 --- a/src/Turbo/composer.json +++ b/src/Turbo/composer.json @@ -34,26 +34,26 @@ } }, "require": { - "php": ">=7.2.5", - "symfony/webpack-encore-bundle": "^1.11" + "php": ">=8.1", + "symfony/stimulus-bundle": "^2.9" }, "require-dev": { "doctrine/annotations": "^1.12", "doctrine/doctrine-bundle": "^2.2", "doctrine/orm": "^2.8 | 3.0", "phpstan/phpstan": "^0.12", - "symfony/debug-bundle": "^5.2|^6.0", - "symfony/form": "^5.2|^6.0", - "symfony/framework-bundle": "^5.2|^6.0", + "symfony/debug-bundle": "^5.4|^6.0", + "symfony/form": "^5.4|^6.0", + "symfony/framework-bundle": "^5.4|^6.0", "symfony/mercure-bundle": "^0.3", - "symfony/messenger": "^5.2|^6.0", + "symfony/messenger": "^5.4|^6.0", "symfony/panther": "^1.0|^2.0", - "symfony/phpunit-bridge": "^5.2.1|^6.0", - "symfony/property-access": "^5.2|^6.0", - "symfony/security-core": "^5.2|^6.0", - "symfony/stopwatch": "^5.2|^6.0", - "symfony/twig-bundle": "^5.2|^6.0", - "symfony/web-profiler-bundle": "^5.2|^6.0", + "symfony/phpunit-bridge": "^5.4|^6.0", + "symfony/property-access": "^5.4|^6.0", + "symfony/security-core": "^5.4|^6.0", + "symfony/stopwatch": "^5.4|^6.0", + "symfony/twig-bundle": "^5.4|^6.0", + "symfony/web-profiler-bundle": "^5.4|^6.0", "symfony/expression-language": "^5.4|^6.0" }, "conflict": { diff --git a/src/Turbo/doc/index.rst b/src/Turbo/doc/index.rst index 39e1278d49f..a1e406a96c0 100644 --- a/src/Turbo/doc/index.rst +++ b/src/Turbo/doc/index.rst @@ -823,33 +823,31 @@ transports:: } } -.. code-block:: php +Then a stream listener:: // src/Turbo/TurboStreamListenRenderer.php namespace App\Turbo; use Symfony\Component\DependencyInjection\Attribute\AsTaggedItem; + use Symfony\UX\StimulusBundle\Helper\StimulusHelper; use Symfony\UX\Turbo\Twig\TurboStreamListenRendererInterface; - use Symfony\WebpackEncoreBundle\Twig\StimulusTwigExtension; use Twig\Environment; #[AsTaggedItem(index: 'my-transport')] class TurboStreamListenRenderer implements TurboStreamListenRendererInterface { public function __construct( - private StimulusTwigExtension $stimulusTwigExtension, + private StimulusHelper $stimulusHelper, ) {} - /** - * @param string|object $topic - */ public function renderTurboStreamListen(Environment $env, $topic): string { - return $this->stimulusTwigExtension->renderStimulusController( - $env, - 'your_stimulus_controller', - [/* controller values such as topic */] - ); + $stimulusAttributes = $this->stimulusHelper->createStimulusAttributes(); + $stimulusAttributes->addController('your_stimulus_controller', [ + /* controller values such as topic */ + ]); + + return (string) $stimulusAttributes; } } diff --git a/src/Turbo/src/Bridge/Mercure/TurboStreamListenRenderer.php b/src/Turbo/src/Bridge/Mercure/TurboStreamListenRenderer.php index 768aa702522..5fa690abf42 100644 --- a/src/Turbo/src/Bridge/Mercure/TurboStreamListenRenderer.php +++ b/src/Turbo/src/Bridge/Mercure/TurboStreamListenRenderer.php @@ -12,6 +12,7 @@ namespace Symfony\UX\Turbo\Bridge\Mercure; use Symfony\Component\Mercure\HubInterface; +use Symfony\UX\StimulusBundle\Helper\StimulusHelper; use Symfony\UX\Turbo\Broadcaster\IdAccessor; use Symfony\UX\Turbo\Twig\TurboStreamListenRendererInterface; use Symfony\WebpackEncoreBundle\Twig\StimulusTwigExtension; @@ -25,14 +26,23 @@ final class TurboStreamListenRenderer implements TurboStreamListenRendererInterface { private $hub; - private $stimulusTwigExtension; + private $stimulusHelper; private $idAccessor; - public function __construct(HubInterface $hub, StimulusTwigExtension $stimulusTwigExtension, IdAccessor $idAccessor) + /** + * @param $stimulus StimulusHelper + */ + public function __construct(HubInterface $hub, StimulusHelper|StimulusTwigExtension $stimulus, IdAccessor $idAccessor) { $this->hub = $hub; - $this->stimulusTwigExtension = $stimulusTwigExtension; $this->idAccessor = $idAccessor; + + if ($stimulus instanceof StimulusTwigExtension) { + trigger_deprecation('symfony/ux-turbo', '2.9', 'Passing an instance of "%s" as second argument of "%s" is deprecated, pass an instance of "%s" instead.', StimulusTwigExtension::class, __CLASS__, StimulusHelper::class); + + $stimulus = new StimulusHelper(null); + } + $this->stimulusHelper = $stimulus; } public function renderTurboStreamListen(Environment $env, $topic): string @@ -50,10 +60,12 @@ public function renderTurboStreamListen(Environment $env, $topic): string $topic = sprintf(Broadcaster::TOPIC_PATTERN, rawurlencode($topic), '{id}'); } - return $this->stimulusTwigExtension->renderStimulusController( - $env, + $stimulusAttributes = $this->stimulusHelper->createStimulusAttributes(); + $stimulusAttributes->addController( 'symfony/ux-turbo/mercure-turbo-stream', ['topic' => $topic, 'hub' => $this->hub->getPublicUrl()] ); + + return (string) $stimulusAttributes; } } diff --git a/src/Turbo/src/DependencyInjection/TurboExtension.php b/src/Turbo/src/DependencyInjection/TurboExtension.php index 5c2a01cb04d..28933c78a6c 100644 --- a/src/Turbo/src/DependencyInjection/TurboExtension.php +++ b/src/Turbo/src/DependencyInjection/TurboExtension.php @@ -14,10 +14,12 @@ use Doctrine\Bundle\DoctrineBundle\DoctrineBundle; use Doctrine\ORM\EntityManagerInterface; use Symfony\Bundle\TwigBundle\TwigBundle; +use Symfony\Component\AssetMapper\AssetMapperInterface; use Symfony\Component\Config\Definition\Exception\InvalidConfigurationException; use Symfony\Component\Config\FileLocator; use Symfony\Component\Config\Loader\LoaderInterface; use Symfony\Component\DependencyInjection\ContainerBuilder; +use Symfony\Component\DependencyInjection\Extension\PrependExtensionInterface; use Symfony\Component\DependencyInjection\Loader\PhpFileLoader; use Symfony\Component\HttpKernel\DependencyInjection\Extension; use Symfony\UX\Turbo\Broadcaster\BroadcasterInterface; @@ -26,7 +28,7 @@ /** * @author Kévin Dunglas */ -final class TurboExtension extends Extension +final class TurboExtension extends Extension implements PrependExtensionInterface { /** * @param array $configs @@ -88,4 +90,22 @@ private function registerBroadcast(array $config, ContainerBuilder $container, L throw new InvalidConfigurationException('You cannot use the Doctrine ORM integration as the Doctrine bundle is not installed. Try running "composer require symfony/orm-pack".'); } } + + public function prepend(ContainerBuilder $container): void + { + if (!interface_exists(AssetMapperInterface::class)) { + return; + } + + $container->prependExtensionConfig('framework', [ + 'asset_mapper' => [ + 'paths' => [ + __DIR__.'/../../assets/dist' => '@symfony/ux-turbo', + ], + 'importmap_script_attributes' => [ + 'data-turbo-track' => 'reload', + ], + ], + ]); + } } diff --git a/src/Turbo/tests/app/Kernel.php b/src/Turbo/tests/app/Kernel.php index 648e165bd05..8f5f238de0c 100644 --- a/src/Turbo/tests/app/Kernel.php +++ b/src/Turbo/tests/app/Kernel.php @@ -36,6 +36,7 @@ use Symfony\Component\Mercure\HubInterface; use Symfony\Component\Mercure\Update; use Symfony\Component\Routing\Loader\Configurator\RoutingConfigurator; +use Symfony\UX\StimulusBundle\StimulusBundle; use Symfony\UX\Turbo\TurboBundle; use Symfony\WebpackEncoreBundle\WebpackEncoreBundle; use Twig\Environment; @@ -55,6 +56,7 @@ public function registerBundles(): iterable yield new MercureBundle(); yield new TurboBundle(); yield new WebpackEncoreBundle(); + yield new StimulusBundle(); yield new WebProfilerBundle(); yield new DebugBundle(); } diff --git a/src/TwigComponent/CHANGELOG.md b/src/TwigComponent/CHANGELOG.md index e002093fe87..91659369299 100644 --- a/src/TwigComponent/CHANGELOG.md +++ b/src/TwigComponent/CHANGELOG.md @@ -1,5 +1,11 @@ # CHANGELOG +## 2.9.0 + +- The `ComponentAttributes::defaults()` method now accepts any iterable argument. + The `ComponentAttributes::add()` method has been deprecated. To add a Stimulus + controller to the attributes, use `{{ attributes.defaults(stimulus_controller('...')) }}`. + ## 2.8.0 - Add new HTML syntax for rendering components: `` diff --git a/src/TwigComponent/composer.json b/src/TwigComponent/composer.json index 7bba845508a..4de8d9cd258 100644 --- a/src/TwigComponent/composer.json +++ b/src/TwigComponent/composer.json @@ -36,6 +36,7 @@ "require-dev": { "symfony/framework-bundle": "^5.4|^6.0", "symfony/phpunit-bridge": "^6.0", + "symfony/stimulus-bundle": "^2.9", "symfony/twig-bundle": "^5.4|^6.0", "symfony/webpack-encore-bundle": "^1.15" }, diff --git a/src/TwigComponent/doc/index.rst b/src/TwigComponent/doc/index.rst index 4a86baadb88..3448d603088 100644 --- a/src/TwigComponent/doc/index.rst +++ b/src/TwigComponent/doc/index.rst @@ -599,15 +599,18 @@ Set an attribute's value to ``false`` to exclude the attribute: {# renders as: #} -.. versionadded:: 2.7 +To add a custom `Stimulus controller`_ to your root component element: - The ``add()`` method was introduced in TwigComponents 2.7. +.. versionadded:: 2.9 -To add a custom Stimulus controller to your root component element: + The ability to use ``stimulus_controller()`` with ``attributes.defaults()`` + was added in TwigComponents 2.9 and requires ``symfony/stimulus-bundle``. + Previously, ``stimulus_controller()`` was passed to an ``attributes.add()`` + method. .. code-block:: html+twig -
+
.. note:: @@ -937,3 +940,4 @@ https://symfony.com/doc/current/contributing/code/bc.html .. _`Vue`: https://v3.vuejs.org/guide/computed.html .. _`Live Nested Components`: https://symfony.com/bundles/ux-live-component/current/index.html#nested-components .. _`embed tag`: https://twig.symfony.com/doc/3.x/tags/embed.html +.. _`Stimulus controller`: https://symfony.com/bundles/StimulusBundle/current/index.html diff --git a/src/TwigComponent/src/ComponentAttributes.php b/src/TwigComponent/src/ComponentAttributes.php index 3187f488eb1..62978c541ce 100644 --- a/src/TwigComponent/src/ComponentAttributes.php +++ b/src/TwigComponent/src/ComponentAttributes.php @@ -61,13 +61,25 @@ public function all(): array /** * Set default attributes. These are used if they are not already - * defined. "class" is special, these defaults are prepended to - * the existing "class" attribute (if available). + * defined. + * + * "class" and "data-controller" are special, these defaults are prepended to + * the existing attribute (if available). */ - public function defaults(array $attributes): self + public function defaults(iterable $attributes): self { + if ($attributes instanceof \Traversable) { + $attributes = iterator_to_array($attributes); + } + foreach ($this->attributes as $key => $value) { - $attributes[$key] = isset($attributes[$key]) && 'class' === $key ? "{$attributes[$key]} {$value}" : $value; + if (\in_array($key, ['class', 'data-controller'], true) && isset($attributes[$key])) { + $attributes[$key] = "{$attributes[$key]} {$value}"; + + continue; + } + + $attributes[$key] = $value; } return new self($attributes); @@ -105,6 +117,8 @@ public function without(string ...$keys): self public function add(AbstractStimulusDto $stimulusDto): self { + trigger_deprecation('symfony/ux-twig-component', '2.9.0', 'Passing a StimulusDto to ComponentAttributes::add() is deprecated. Run "composer require symfony/stimulus-bundle" then use "attributes.defaults(stimulus_controller(\'...\'))".'); + $controllersAttributes = $stimulusDto->toArray(); $attributes = $this->attributes; diff --git a/src/TwigComponent/tests/Unit/ComponentAttributesTest.php b/src/TwigComponent/tests/Unit/ComponentAttributesTest.php index 960ed1d6d1b..8f3ad38d520 100644 --- a/src/TwigComponent/tests/Unit/ComponentAttributesTest.php +++ b/src/TwigComponent/tests/Unit/ComponentAttributesTest.php @@ -12,8 +12,11 @@ namespace Symfony\UX\TwigComponent\Tests\Unit; use PHPUnit\Framework\TestCase; +use Symfony\UX\StimulusBundle\Dto\StimulusAttributes; use Symfony\UX\TwigComponent\ComponentAttributes; use Symfony\WebpackEncoreBundle\Dto\AbstractStimulusDto; +use Twig\Environment; +use Twig\Loader\ArrayLoader; /** * @author Kevin Bond @@ -62,6 +65,9 @@ public function testCanGetWithout(): void $this->assertSame(['class' => 'foo'], $attributes->without('style')->all()); } + /** + * @group legacy + */ public function testCanAddStimulusController(): void { $attributes = new ComponentAttributes([ @@ -88,6 +94,9 @@ public function testCanAddStimulusController(): void ], $attributes->all()); } + /** + * @group legacy + */ public function testCanAddStimulusControllerIfNoneAlreadyPresent(): void { $attributes = new ComponentAttributes([ @@ -111,6 +120,31 @@ public function testCanAddStimulusControllerIfNoneAlreadyPresent(): void ], $attributes->all()); } + public function testCanAddStimulusControllerViaStimulusAttributes(): void + { + // if PHP less than 8.1, skip + if (version_compare(\PHP_VERSION, '8.1.0', '<')) { + $this->markTestSkipped('PHP 8.1+ required'); + } + + $attributes = new ComponentAttributes([ + 'class' => 'foo', + 'data-controller' => 'live', + 'data-live-data-value' => '{}', + ]); + + $stimulusAttributes = new StimulusAttributes(new Environment(new ArrayLoader())); + $stimulusAttributes->addController('foo', ['name' => 'ryan']); + $attributes = $attributes->defaults($stimulusAttributes); + + $this->assertEquals([ + 'class' => 'foo', + 'data-controller' => 'foo live', + 'data-live-data-value' => '{}', + 'data-foo-name-value' => 'ryan', + ], $attributes->all()); + } + public function testBooleanBehaviour(): void { $attributes = new ComponentAttributes(['disabled' => true]); diff --git a/src/Typed/CHANGELOG.md b/src/Typed/CHANGELOG.md index cdbffdeda92..0049d858a12 100644 --- a/src/Typed/CHANGELOG.md +++ b/src/Typed/CHANGELOG.md @@ -1,5 +1,11 @@ # CHANGELOG +## 2.9.0 + +- A TypedBundle was added - which allows for integration with symfony/asset-mapper. + +- Add support for symfony/asset-mapper + ## 2.7.0 - Add `assets/src` to `.gitattributes` to exclude source TypeScript files from diff --git a/src/Typed/assets/package.json b/src/Typed/assets/package.json index 08f95f471fb..238fda8f1db 100644 --- a/src/Typed/assets/package.json +++ b/src/Typed/assets/package.json @@ -14,6 +14,10 @@ "fetch": "eager", "enabled": true } + }, + "importmap": { + "typed.js": "^2.0", + "@hotwired/stimulus": "^3.0.0" } }, "peerDependencies": { diff --git a/src/Typed/composer.json b/src/Typed/composer.json index 5a838fc29f7..2f13075f0ec 100644 --- a/src/Typed/composer.json +++ b/src/Typed/composer.json @@ -17,6 +17,11 @@ "homepage": "https://symfony.com/contributors" } ], + "autoload": { + "psr-4": { + "Symfony\\UX\\Typed\\": "src/" + } + }, "conflict": { "symfony/flex": "<1.13" }, diff --git a/src/Typed/src/DependencyInjection/TypedExtension.php b/src/Typed/src/DependencyInjection/TypedExtension.php new file mode 100644 index 00000000000..0e3483094ae --- /dev/null +++ b/src/Typed/src/DependencyInjection/TypedExtension.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\Swup\DependencyInjection; + +use Symfony\Component\AssetMapper\AssetMapperInterface; +use Symfony\Component\DependencyInjection\ContainerBuilder; +use Symfony\Component\DependencyInjection\Extension\PrependExtensionInterface; +use Symfony\Component\HttpKernel\DependencyInjection\Extension; + +/** + * @internal + */ +class TypedExtension extends Extension implements PrependExtensionInterface +{ + public function load(array $configs, ContainerBuilder $container) + { + } + + public function prepend(ContainerBuilder $container) + { + if (!interface_exists(AssetMapperInterface::class)) { + return; + } + + $container->prependExtensionConfig('framework', [ + 'asset_mapper' => [ + 'paths' => [ + __DIR__.'/../../assets/dist' => '@symfony/ux-typed', + ], + ], + ]); + } +} diff --git a/src/Typed/src/TypedBundle.php b/src/Typed/src/TypedBundle.php new file mode 100644 index 00000000000..a559d5993c1 --- /dev/null +++ b/src/Typed/src/TypedBundle.php @@ -0,0 +1,25 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\UX\Typed; + +use Symfony\Component\HttpKernel\Bundle\Bundle; + +/** + * @final + */ +class TypedBundle extends Bundle +{ + public function getPath(): string + { + return \dirname(__DIR__); + } +} diff --git a/src/Vue/CHANGELOG.md b/src/Vue/CHANGELOG.md index ac52c9e2991..3b510a351de 100644 --- a/src/Vue/CHANGELOG.md +++ b/src/Vue/CHANGELOG.md @@ -1,5 +1,13 @@ # CHANGELOG +## 2.9.0 + +- Replace `symfony/webpack-encore-bundle` by `symfony/stimulus-bundle` in dependencies + +- Add support for symfony/asset-mapper + +- Minimum PHP version is now 8.1 + ## 2.7.0 - Add `assets/src` to `.gitattributes` to exclude source TypeScript files from diff --git a/src/Vue/assets/package.json b/src/Vue/assets/package.json index 1e7cdf8f469..cd02dbdc556 100644 --- a/src/Vue/assets/package.json +++ b/src/Vue/assets/package.json @@ -13,6 +13,10 @@ "fetch": "eager", "enabled": true } + }, + "importmap": { + "@hotwired/stimulus": "^3.0.0", + "vue": "^3.0" } }, "peerDependencies": { diff --git a/src/Vue/composer.json b/src/Vue/composer.json index 96bed921096..57c618f805d 100644 --- a/src/Vue/composer.json +++ b/src/Vue/composer.json @@ -32,13 +32,14 @@ } }, "require": { - "symfony/webpack-encore-bundle": "^1.11" + "php": ">=8.1", + "symfony/stimulus-bundle": "^2.9" }, "require-dev": { - "symfony/framework-bundle": "^4.4|^5.0|^6.0", - "symfony/phpunit-bridge": "^5.2|^6.0", - "symfony/twig-bundle": "^4.4|^5.0|^6.0", - "symfony/var-dumper": "^4.4|^5.0|^6.0" + "symfony/framework-bundle": "^5.4|^6.0", + "symfony/phpunit-bridge": "^5.4|^6.0", + "symfony/twig-bundle": "^5.4|^6.0", + "symfony/var-dumper": "^5.4|^6.0" }, "extra": { "thanks": { diff --git a/src/Vue/src/DependencyInjection/VueExtension.php b/src/Vue/src/DependencyInjection/VueExtension.php index 8465fe3f39b..7112ecef067 100644 --- a/src/Vue/src/DependencyInjection/VueExtension.php +++ b/src/Vue/src/DependencyInjection/VueExtension.php @@ -11,8 +11,10 @@ namespace Symfony\UX\Vue\DependencyInjection; +use Symfony\Component\AssetMapper\AssetMapperInterface; use Symfony\Component\DependencyInjection\ContainerBuilder; use Symfony\Component\DependencyInjection\Definition; +use Symfony\Component\DependencyInjection\Extension\PrependExtensionInterface; use Symfony\Component\DependencyInjection\Reference; use Symfony\Component\HttpKernel\DependencyInjection\Extension; use Symfony\UX\Vue\Twig\VueComponentExtension; @@ -23,15 +25,30 @@ * * @internal */ -class VueExtension extends Extension +class VueExtension extends Extension implements PrependExtensionInterface { public function load(array $configs, ContainerBuilder $container) { $container ->setDefinition('twig.extension.vue', new Definition(VueComponentExtension::class)) - ->setArgument(0, new Reference('webpack_encore.twig_stimulus_extension')) + ->setArgument(0, new Reference('stimulus.helper')) ->addTag('twig.extension') ->setPublic(false) ; } + + public function prepend(ContainerBuilder $container) + { + if (!interface_exists(AssetMapperInterface::class)) { + return; + } + + $container->prependExtensionConfig('framework', [ + 'asset_mapper' => [ + 'paths' => [ + __DIR__.'/../../assets/dist' => '@symfony/ux-vue', + ], + ], + ]); + } } diff --git a/src/Vue/src/Twig/VueComponentExtension.php b/src/Vue/src/Twig/VueComponentExtension.php index ed1c324e51e..322a143340e 100644 --- a/src/Vue/src/Twig/VueComponentExtension.php +++ b/src/Vue/src/Twig/VueComponentExtension.php @@ -11,8 +11,8 @@ namespace Symfony\UX\Vue\Twig; +use Symfony\UX\StimulusBundle\Helper\StimulusHelper; use Symfony\WebpackEncoreBundle\Twig\StimulusTwigExtension; -use Twig\Environment; use Twig\Extension\AbstractExtension; use Twig\TwigFunction; @@ -24,27 +24,38 @@ */ class VueComponentExtension extends AbstractExtension { - private $stimulusExtension; + private $stimulusHelper; - public function __construct(StimulusTwigExtension $stimulusExtension) + /** + * @param $stimulus StimulusHelper + */ + public function __construct(StimulusHelper|StimulusTwigExtension $stimulus) { - $this->stimulusExtension = $stimulusExtension; + if ($stimulus instanceof StimulusTwigExtension) { + trigger_deprecation('symfony/ux-vue', '2.9', 'Passing an instance of "%s" to "%s" is deprecated, pass an instance of "%s" instead.', StimulusTwigExtension::class, __CLASS__, StimulusHelper::class); + $stimulus = new StimulusHelper(null); + } + + $this->stimulusHelper = $stimulus; } public function getFunctions(): array { return [ - new TwigFunction('vue_component', [$this, 'renderVueComponent'], ['needs_environment' => true, 'is_safe' => ['html_attr']]), + new TwigFunction('vue_component', [$this, 'renderVueComponent'], ['is_safe' => ['html_attr']]), ]; } - public function renderVueComponent(Environment $env, string $componentName, array $props = []): string + public function renderVueComponent(string $componentName, array $props = []): string { $params = ['component' => $componentName]; if ($props) { $params['props'] = $props; } - return $this->stimulusExtension->renderStimulusController($env, '@symfony/ux-vue/vue', $params); + $stimulusAttributes = $this->stimulusHelper->createStimulusAttributes(); + $stimulusAttributes->addController('@symfony/ux-vue/vue', $params); + + return (string) $stimulusAttributes; } } diff --git a/src/Vue/tests/Kernel/AppKernelTrait.php b/src/Vue/tests/Kernel/AppKernelTrait.php deleted file mode 100644 index e901df1bfad..00000000000 --- a/src/Vue/tests/Kernel/AppKernelTrait.php +++ /dev/null @@ -1,42 +0,0 @@ - - * - * For the full copyright and license information, please view the LICENSE - * file that was distributed with this source code. - */ - -namespace Symfony\UX\Vue\Tests\Kernel; - -/** - * @author Titouan Galopin - * @author Thibault RICHARD - * - * @internal - */ -trait AppKernelTrait -{ - public function getCacheDir(): string - { - return $this->createTmpDir('cache'); - } - - public function getLogDir(): string - { - return $this->createTmpDir('logs'); - } - - private function createTmpDir(string $type): string - { - $dir = sys_get_temp_dir().'/vue_bundle/'.uniqid($type.'_', true); - - if (!file_exists($dir)) { - mkdir($dir, 0777, true); - } - - return $dir; - } -} diff --git a/src/Vue/tests/Kernel/FrameworkAppKernel.php b/src/Vue/tests/Kernel/FrameworkAppKernel.php deleted file mode 100644 index 99a280ac95c..00000000000 --- a/src/Vue/tests/Kernel/FrameworkAppKernel.php +++ /dev/null @@ -1,43 +0,0 @@ - - * - * For the full copyright and license information, please view the LICENSE - * file that was distributed with this source code. - */ - -namespace Symfony\UX\Vue\Tests\Kernel; - -use Symfony\Bundle\FrameworkBundle\FrameworkBundle; -use Symfony\Component\Config\Loader\LoaderInterface; -use Symfony\Component\DependencyInjection\ContainerBuilder; -use Symfony\Component\HttpKernel\Kernel; -use Symfony\UX\Vue\VueBundle; -use Symfony\WebpackEncoreBundle\WebpackEncoreBundle; - -/** - * @author Titouan Galopin - * @author Thibault RICHARD - * - * @internal - */ -class FrameworkAppKernel extends Kernel -{ - use AppKernelTrait; - - public function registerBundles(): iterable - { - return [new WebpackEncoreBundle(), new FrameworkBundle(), new VueBundle()]; - } - - public function registerContainerConfiguration(LoaderInterface $loader) - { - $loader->load(function (ContainerBuilder $container) { - $container->loadFromExtension('framework', ['secret' => '$ecret', 'test' => true]); - $container->loadFromExtension('webpack_encore', ['output_path' => '%kernel.project_dir%/public/build']); - }); - } -} diff --git a/src/Vue/tests/Kernel/TwigAppKernel.php b/src/Vue/tests/Kernel/TwigAppKernel.php index 0e805f0ece4..10e1378f1d8 100644 --- a/src/Vue/tests/Kernel/TwigAppKernel.php +++ b/src/Vue/tests/Kernel/TwigAppKernel.php @@ -16,8 +16,8 @@ use Symfony\Component\Config\Loader\LoaderInterface; use Symfony\Component\DependencyInjection\ContainerBuilder; use Symfony\Component\HttpKernel\Kernel; +use Symfony\UX\StimulusBundle\StimulusBundle; use Symfony\UX\Vue\VueBundle; -use Symfony\WebpackEncoreBundle\WebpackEncoreBundle; /** * @author Titouan Galopin @@ -27,22 +27,40 @@ */ class TwigAppKernel extends Kernel { - use AppKernelTrait; - public function registerBundles(): iterable { - return [new WebpackEncoreBundle(), new FrameworkBundle(), new TwigBundle(), new VueBundle()]; + return [new StimulusBundle(), new FrameworkBundle(), new TwigBundle(), new VueBundle()]; } public function registerContainerConfiguration(LoaderInterface $loader) { $loader->load(function (ContainerBuilder $container) { $container->loadFromExtension('framework', ['secret' => '$ecret', 'test' => true]); - $container->loadFromExtension('webpack_encore', ['output_path' => '%kernel.project_dir%/public/build']); $container->loadFromExtension('twig', ['default_path' => __DIR__.'/templates', 'strict_variables' => true, 'exception_controller' => null]); $container->setAlias('test.twig', 'twig')->setPublic(true); $container->setAlias('test.twig.extension.vue', 'twig.extension.vue')->setPublic(true); }); } + + public function getCacheDir(): string + { + return $this->createTmpDir('cache'); + } + + public function getLogDir(): string + { + return $this->createTmpDir('logs'); + } + + private function createTmpDir(string $type): string + { + $dir = sys_get_temp_dir().'/vue_bundle/'.uniqid($type.'_', true); + + if (!file_exists($dir)) { + mkdir($dir, 0777, true); + } + + return $dir; + } } diff --git a/src/Vue/tests/Twig/VueComponentExtensionTest.php b/src/Vue/tests/Twig/VueComponentExtensionTest.php index 1bbba1b89eb..3c618cca2e6 100644 --- a/src/Vue/tests/Twig/VueComponentExtensionTest.php +++ b/src/Vue/tests/Twig/VueComponentExtensionTest.php @@ -32,7 +32,6 @@ public function testRenderComponent() $extension = $kernel->getContainer()->get('test.twig.extension.vue'); $rendered = $extension->renderVueComponent( - $kernel->getContainer()->get('test.twig'), 'SubDir/MyComponent', ['fullName' => 'Titouan Galopin'] ); @@ -51,7 +50,7 @@ public function testRenderComponentWithoutProps() /** @var VueComponentExtension $extension */ $extension = $kernel->getContainer()->get('test.twig.extension.vue'); - $rendered = $extension->renderVueComponent($kernel->getContainer()->get('test.twig'), 'SubDir/MyComponent'); + $rendered = $extension->renderVueComponent('SubDir/MyComponent'); $this->assertSame( 'data-controller="symfony--ux-vue--vue" data-symfony--ux-vue--vue-component-value="SubDir/MyComponent"', diff --git a/src/Vue/tests/VueBundleTest.php b/src/Vue/tests/VueBundleTest.php index 9e36d101708..29009c3e727 100644 --- a/src/Vue/tests/VueBundleTest.php +++ b/src/Vue/tests/VueBundleTest.php @@ -12,8 +12,6 @@ namespace Symfony\UX\Vue\Tests; use PHPUnit\Framework\TestCase; -use Symfony\Component\HttpKernel\Kernel; -use Symfony\UX\Vue\Tests\Kernel\FrameworkAppKernel; use Symfony\UX\Vue\Tests\Kernel\TwigAppKernel; /** @@ -24,17 +22,9 @@ */ class VueBundleTest extends TestCase { - public function provideKernels() - { - yield 'framework' => [new FrameworkAppKernel('test', true)]; - yield 'twig' => [new TwigAppKernel('test', true)]; - } - - /** - * @dataProvider provideKernels - */ - public function testBootKernel(Kernel $kernel) + public function testBootKernel() { + $kernel = new TwigAppKernel('test', true); $kernel->boot(); $this->assertArrayHasKey('VueBundle', $kernel->getBundles()); }