├── CHANGELOG.md ├── LICENSE ├── README.md ├── assets ├── LICENSE ├── README.md ├── dist │ ├── turbo_controller.d.ts │ ├── turbo_controller.js │ ├── turbo_stream_controller.d.ts │ └── turbo_stream_controller.js ├── package.json └── vitest.config.mjs ├── composer.json ├── config └── services.php ├── docker-compose.yml ├── src ├── Attribute │ └── Broadcast.php ├── Bridge │ └── Mercure │ │ ├── Broadcaster.php │ │ ├── TopicSet.php │ │ └── TurboStreamListenRenderer.php ├── Broadcaster │ ├── BroadcasterInterface.php │ ├── IdAccessor.php │ ├── ImuxBroadcaster.php │ └── TwigBroadcaster.php ├── DependencyInjection │ ├── Compiler │ │ └── RegisterMercureHubsPass.php │ ├── Configuration.php │ └── TurboExtension.php ├── Doctrine │ ├── BroadcastListener.php │ └── ClassUtil.php ├── Helper │ └── TurboStream.php ├── Request │ └── RequestListener.php ├── TurboBundle.php ├── TurboStreamResponse.php └── Twig │ ├── TurboRuntime.php │ ├── TurboStreamListenRendererInterface.php │ ├── TurboStreamListenRendererWithOptionsInterface.php │ └── TwigExtension.php └── templates └── components ├── Frame.html.twig ├── Stream.html.twig └── Stream ├── After.html.twig ├── Append.html.twig ├── Before.html.twig ├── Prepend.html.twig ├── Refresh.html.twig ├── Remove.html.twig ├── Replace.html.twig └── Update.html.twig /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # CHANGELOG 2 | 3 | ## 2.24.0 4 | 5 | - Add Twig Extensions for `meta` tags 6 | - Add support for authentication to the EventSource via `turbo_stream_listen` 7 | 8 | ## 2.22.0 9 | 10 | - Add `` component 11 | - Add `` component 12 | - Add support for custom actions in `TurboStream` and `TurboStreamResponse` 13 | - Add support for providing multiple mercure topics to `turbo_stream_listen` 14 | 15 | ## 2.21.0 16 | 17 | - Add `Helper/TurboStream::append()` et al. methods 18 | - Add `TurboStreamResponse` 19 | - Add `` components 20 | 21 | ## 2.19.0 22 | 23 | - Fix Doctrine proxies are not Broadcasted #3139 24 | 25 | ## 2.15.0 26 | 27 | - Add Turbo 8 support #1476 28 | - Fix missing `use` statement used during broadcast #1475 29 | 30 | ## 2.14.2 31 | 32 | - Fix using old `ClassUtils` class that's not used in newer versions of Doctrine 33 | 34 | ## 2.13.2 35 | 36 | - Revert "Change JavaScript package to `type: module`" 37 | 38 | ## 2.13.0 39 | 40 | - Add Symfony 7 support. 41 | - Change JavaScript package to `type: module` 42 | 43 | ## 2.9.0 44 | 45 | - Minimum PHP version is now 8.1 46 | 47 | - Add support for symfony/asset-mapper 48 | 49 | - Replace `symfony/webpack-encore-bundle` by `symfony/stimulus-bundle` in dependencies 50 | 51 | ## 2.7.0 52 | 53 | - Add `assets/src` to `.gitattributes` to exclude source TypeScript files from 54 | installing. 55 | 56 | - TypeScript types are now included. 57 | 58 | ## 2.6.1 59 | 60 | - The `symfony/ux-turbo-mercure` package was abandoned and moved into this package. 61 | If you were previously using `symfony/ux-turbo-mercure`, you can remove it 62 | and only install mecure-bundle: 63 | 64 | ``` 65 | composer require symfony/mercure-bundle 66 | composer remove symfony/ux-turbo-mercure 67 | ``` 68 | 69 | After upgrading this package to 2.6.1, you should have a new entry in 70 | `assets/controllers.json` called `mercure-turbo-stream`. Change 71 | `enabled: false` to `enabled: true`. 72 | 73 | ## 2.6.0 74 | 75 | - [BC BREAK] The `assets/` directory was moved from `Resources/assets/` to `assets/`. Make 76 | sure the path in your `package.json` file is updated accordingly. 77 | 78 | - The directory structure of the bundle was updated to match modern best-practices. 79 | 80 | ## 2.3 81 | 82 | - The `Broadcast` attribute can now be repeated, this is convenient to render several Turbo Streams Twig templates for the same change 83 | 84 | ## 2.2 85 | 86 | - The topics defined in the `Broadcast` attribute now support expression language when prefixed with `@=`. 87 | 88 | ## 2.1 89 | 90 | - `TurboStreamResponse` and `AddTurboStreamFormatSubscriber` have been removed, use native content negotiation instead: 91 | 92 | ```php 93 | use Symfony\UX\Turbo\TurboBundle; 94 | 95 | class TaskController extends AbstractController 96 | { 97 | public function new(Request $request): Response 98 | { 99 | // ... 100 | if (TurboBundle::STREAM_FORMAT === $request->getPreferredFormat()) { 101 | $request->setRequestFormat(TurboBundle::STREAM_FORMAT); 102 | $response = $this->render('task/success.stream.html.twig', ['task' => $task]); 103 | } else { 104 | $response = $this->render('task/success.html.twig', ['task' => $task]); 105 | } 106 | 107 | return $response->setVary('Accept'); 108 | } 109 | } 110 | ``` 111 | 112 | ## 2.0 113 | 114 | - Support for `stimulus` version 2 was removed and support for `@hotwired/stimulus` 115 | version 3 was added. See the [@symfony/stimulus-bridge CHANGELOG](https://github.com/symfony/stimulus-bridge/blob/main/CHANGELOG.md#300) 116 | for more details. 117 | - Support added for Symfony 6 118 | - `@hotwired/turbo` version bumped to stable 7.0. 119 | 120 | ## 1.3 121 | 122 | - Package introduced! The new `symfony/ux-turbo` and `symfony/ux-turbo-mercure` 123 | were introduced. 124 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2021-present Fabien Potencier 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy 4 | of this software and associated documentation files (the "Software"), to deal 5 | in the Software without restriction, including without limitation the rights 6 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 7 | copies of the Software, and to permit persons to whom the Software is furnished 8 | to do so, subject to the following conditions: 9 | 10 | The above copyright notice and this permission notice shall be included in all 11 | copies or substantial portions of the Software. 12 | 13 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 14 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 15 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 16 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 17 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 18 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 19 | THE SOFTWARE. 20 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Symfony UX Turbo 2 | 3 | Symfony UX Turbo is a Symfony bundle integrating the [Hotwire Turbo](https://turbo.hotwired.dev) 4 | library in Symfony applications. It is part of [the Symfony UX initiative](https://ux.symfony.com/). 5 | 6 | Symfony UX Turbo allows having the same user experience as with [Single Page Apps](https://en.wikipedia.org/wiki/Single-page_application) 7 | but without having to write a single line of JavaScript! 8 | 9 | Symfony UX Turbo also integrates with [Symfony Mercure](https://symfony.com/doc/current/mercure.html) 10 | or any other transports to broadcast DOM changes to all currently connected users! 11 | 12 | You're in a hurry? Take a look at [the chat example](https://symfony.com/bundles/ux-turbo/current/index.html#chat-example) 13 | to discover the full potential of Symfony UX Turbo. 14 | 15 | Or watch the [Turbo Screencast on SymfonyCasts](https://symfonycasts.com/screencast/turbo). 16 | 17 | **This repository is a READ-ONLY sub-tree split**. See 18 | https://github.com/symfony/ux to create issues or submit pull requests. 19 | 20 | ## Sponsor 21 | 22 | The Symfony UX packages are [backed][1] by [Mercure.rocks][2]. 23 | 24 | Create real-time experiences in minutes! Mercure.rocks provides a realtime API service 25 | that is tightly integrated with Symfony: create UIs that update in live with UX Turbo, 26 | send notifications with the Notifier component, expose async APIs with API Platform and 27 | create low level stuffs with the Mercure component. We maintain and scale the complex 28 | infrastructure for you! 29 | 30 | Help Symfony by [sponsoring][3] its development! 31 | 32 | ## Running the Tests 33 | 34 | Configure test environment (working directory: `src/Turbo`): 35 | 36 | ```bash 37 | composer update 38 | docker compose up -d 39 | cd tests/app 40 | php public/index.php doctrine:schema:create 41 | ``` 42 | 43 | Run tests (working directory: `src/Turbo`): 44 | 45 | ```bash 46 | vendor/bin/simple-phpunit 47 | ``` 48 | 49 | ## Resources 50 | 51 | - [Documentation](https://symfony.com/bundles/ux-turbo/current/index.html) 52 | - [Report issues](https://github.com/symfony/ux/issues) and 53 | [send Pull Requests](https://github.com/symfony/ux/pulls) 54 | in the [main Symfony UX repository](https://github.com/symfony/ux) 55 | 56 | [1]: https://symfony.com/backers 57 | [2]: https://mercure.rocks 58 | [3]: https://symfony.com/sponsor 59 | -------------------------------------------------------------------------------- /assets/LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2021-present Fabien Potencier 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy 4 | of this software and associated documentation files (the "Software"), to deal 5 | in the Software without restriction, including without limitation the rights 6 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 7 | copies of the Software, and to permit persons to whom the Software is furnished 8 | to do so, subject to the following conditions: 9 | 10 | The above copyright notice and this permission notice shall be included in all 11 | copies or substantial portions of the Software. 12 | 13 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 14 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 15 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 16 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 17 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 18 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 19 | THE SOFTWARE. 20 | -------------------------------------------------------------------------------- /assets/README.md: -------------------------------------------------------------------------------- 1 | # @symfony/ux-turbo 2 | 3 | JavaScript assets of the [symfony/ux-turbo](https://packagist.org/packages/symfony/ux-turbo) PHP package. 4 | 5 | ## Installation 6 | 7 | This npm package is **reserved for advanced users** who want to decouple their JavaScript dependencies from their PHP dependencies (e.g., when building Docker images, running JavaScript-only pipelines, etc.). 8 | 9 | We **strongly recommend not installing this package directly**, but instead install the PHP package [symfony/ux-turbo](https://packagist.org/packages/symfony/ux-turbo) in your Symfony application with [Flex](https://github.com/symfony/flex) enabled. 10 | 11 | If you still want to install this package directly, please make sure its version exactly matches [symfony/ux-turbo](https://packagist.org/packages/symfony/ux-turbo) PHP package version: 12 | ```shell 13 | composer require symfony/ux-turbo:2.23.0 14 | npm add @symfony/ux-turbo@2.23.0 15 | ``` 16 | 17 | **Tip:** Your `package.json` file will be automatically modified by [Flex](https://github.com/symfony/flex) when installing or upgrading a PHP package. To prevent this behavior, ensure to **use at least Flex 1.22.0 or 2.5.0**, and run `composer config extra.symfony.flex.synchronize_package_json false`. 18 | 19 | ## Resources 20 | 21 | - [Documentation](https://symfony.com/bundles/ux-turbo/current/index.html) 22 | - [Report issues](https://github.com/symfony/ux/issues) and 23 | [send Pull Requests](https://github.com/symfony/ux/pulls) 24 | in the [main Symfony UX repository](https://github.com/symfony/ux) 25 | -------------------------------------------------------------------------------- /assets/dist/turbo_controller.d.ts: -------------------------------------------------------------------------------- 1 | import { Controller } from '@hotwired/stimulus'; 2 | import '@hotwired/turbo'; 3 | export default class extends Controller { 4 | } 5 | -------------------------------------------------------------------------------- /assets/dist/turbo_controller.js: -------------------------------------------------------------------------------- 1 | import { Controller } from '@hotwired/stimulus'; 2 | import '@hotwired/turbo'; 3 | 4 | class turbo_controller extends Controller { 5 | } 6 | 7 | export { turbo_controller as default }; 8 | -------------------------------------------------------------------------------- /assets/dist/turbo_stream_controller.d.ts: -------------------------------------------------------------------------------- 1 | import { Controller } from '@hotwired/stimulus'; 2 | export default class extends Controller { 3 | static values: { 4 | topic: StringConstructor; 5 | topics: ArrayConstructor; 6 | hub: StringConstructor; 7 | withCredentials: BooleanConstructor; 8 | }; 9 | es: EventSource | undefined; 10 | url: string | undefined; 11 | readonly topicValue: string; 12 | readonly topicsValue: string[]; 13 | readonly withCredentialsValue: boolean; 14 | readonly hubValue: string; 15 | readonly hasHubValue: boolean; 16 | readonly hasTopicValue: boolean; 17 | readonly hasTopicsValue: boolean; 18 | initialize(): void; 19 | connect(): void; 20 | disconnect(): void; 21 | } 22 | -------------------------------------------------------------------------------- /assets/dist/turbo_stream_controller.js: -------------------------------------------------------------------------------- 1 | import { Controller } from '@hotwired/stimulus'; 2 | import { connectStreamSource, disconnectStreamSource } from '@hotwired/turbo'; 3 | 4 | class default_1 extends Controller { 5 | initialize() { 6 | const errorMessages = []; 7 | if (!this.hasHubValue) 8 | errorMessages.push('A "hub" value pointing to the Mercure hub must be provided.'); 9 | if (!this.hasTopicValue && !this.hasTopicsValue) 10 | errorMessages.push('Either "topic" or "topics" value must be provided.'); 11 | if (errorMessages.length) 12 | throw new Error(errorMessages.join(' ')); 13 | const u = new URL(this.hubValue); 14 | if (this.hasTopicValue) { 15 | u.searchParams.append('topic', this.topicValue); 16 | } 17 | else { 18 | this.topicsValue.forEach((topic) => { 19 | u.searchParams.append('topic', topic); 20 | }); 21 | } 22 | this.url = u.toString(); 23 | } 24 | connect() { 25 | if (this.url) { 26 | this.es = new EventSource(this.url, { withCredentials: this.withCredentialsValue }); 27 | connectStreamSource(this.es); 28 | } 29 | } 30 | disconnect() { 31 | if (this.es) { 32 | this.es.close(); 33 | disconnectStreamSource(this.es); 34 | } 35 | } 36 | } 37 | default_1.values = { 38 | topic: String, 39 | topics: Array, 40 | hub: String, 41 | withCredentials: Boolean, 42 | }; 43 | 44 | export { default_1 as default }; 45 | -------------------------------------------------------------------------------- /assets/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@symfony/ux-turbo", 3 | "description": "Hotwire Turbo integration for Symfony", 4 | "license": "MIT", 5 | "version": "2.26.1", 6 | "keywords": [ 7 | "symfony-ux", 8 | "turbo", 9 | "hotwire", 10 | "javascript", 11 | "turbo-stream", 12 | "mercure" 13 | ], 14 | "homepage": "https://ux.symfony.com/turbo", 15 | "repository": "https://github.com/symfony/ux-turbo", 16 | "type": "module", 17 | "files": [ 18 | "dist" 19 | ], 20 | "main": "dist/turbo_controller.js", 21 | "types": "dist/turbo_controller.d.ts", 22 | "scripts": { 23 | "build": "node ../../../bin/build_package.js .", 24 | "watch": "node ../../../bin/build_package.js . --watch", 25 | "test": "../../../bin/test_package.sh .", 26 | "check": "biome check", 27 | "ci": "biome ci" 28 | }, 29 | "symfony": { 30 | "controllers": { 31 | "turbo-core": { 32 | "main": "dist/turbo_controller.js", 33 | "webpackMode": "eager", 34 | "fetch": "eager", 35 | "enabled": true 36 | }, 37 | "mercure-turbo-stream": { 38 | "main": "dist/turbo_stream_controller.js", 39 | "fetch": "eager", 40 | "enabled": false 41 | } 42 | }, 43 | "importmap": { 44 | "@hotwired/turbo": "^7.1.0 || ^8.0", 45 | "@hotwired/stimulus": "^3.0.0" 46 | } 47 | }, 48 | "peerDependencies": { 49 | "@hotwired/stimulus": "^3.0.0", 50 | "@hotwired/turbo": "^7.1.1 || ^8.0" 51 | }, 52 | "devDependencies": { 53 | "@hotwired/stimulus": "^3.0.0", 54 | "@hotwired/turbo": "^7.1.0 || ^8.0", 55 | "@types/hotwired__turbo": "^8.0.4" 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /assets/vitest.config.mjs: -------------------------------------------------------------------------------- 1 | import { defineConfig, mergeConfig } from 'vitest/config'; 2 | import configShared from '../../../vitest.config.mjs' 3 | import path from 'path'; 4 | 5 | export default mergeConfig( 6 | configShared, 7 | defineConfig({ 8 | test: { 9 | setupFiles: [path.join(__dirname, 'test', 'setup.js')], 10 | } 11 | }) 12 | ); 13 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "symfony/ux-turbo", 3 | "type": "symfony-bundle", 4 | "description": "Hotwire Turbo integration for Symfony", 5 | "keywords": [ 6 | "symfony-ux", 7 | "turbo", 8 | "hotwire", 9 | "javascript", 10 | "turbo-stream", 11 | "mercure" 12 | ], 13 | "homepage": "https://symfony.com", 14 | "license": "MIT", 15 | "authors": [ 16 | { 17 | "name": "Kévin Dunglas", 18 | "email": "kevin@dunglas.fr" 19 | }, 20 | { 21 | "name": "Symfony Community", 22 | "homepage": "https://symfony.com/contributors" 23 | } 24 | ], 25 | "autoload": { 26 | "psr-4": { 27 | "Symfony\\UX\\Turbo\\": "src/" 28 | } 29 | }, 30 | "autoload-dev": { 31 | "psr-4": { 32 | "App\\": "tests/app/", 33 | "Symfony\\UX\\Turbo\\Tests\\": "tests/" 34 | } 35 | }, 36 | "require": { 37 | "php": ">=8.1", 38 | "symfony/stimulus-bundle": "^2.9.1" 39 | }, 40 | "require-dev": { 41 | "doctrine/doctrine-bundle": "^2.4.3", 42 | "doctrine/orm": "^2.8 | 3.0", 43 | "phpstan/phpstan": "^2.1.17", 44 | "symfony/asset-mapper": "^6.4|^7.0", 45 | "symfony/debug-bundle": "^5.4|^6.0|^7.0", 46 | "symfony/form": "^5.4|^6.0|^7.0", 47 | "symfony/framework-bundle": "^6.4|^7.0", 48 | "symfony/mercure-bundle": "^0.3.7", 49 | "symfony/messenger": "^5.4|^6.0|^7.0", 50 | "symfony/panther": "^2.2", 51 | "symfony/phpunit-bridge": "^5.4|^6.0|^7.0", 52 | "symfony/process": "^5.4|6.3.*|^7.0", 53 | "symfony/property-access": "^5.4|^6.0|^7.0", 54 | "symfony/security-core": "^5.4|^6.0|^7.0", 55 | "symfony/stopwatch": "^5.4|^6.0|^7.0", 56 | "symfony/ux-twig-component": "^2.21", 57 | "symfony/twig-bundle": "^6.4|^7.0", 58 | "symfony/web-profiler-bundle": "^5.4|^6.0|^7.0", 59 | "symfony/expression-language": "^5.4|^6.0|^7.0", 60 | "dbrekelmans/bdi": "dev-main", 61 | "php-webdriver/webdriver": "^1.15" 62 | }, 63 | "conflict": { 64 | "symfony/flex": "<1.13" 65 | }, 66 | "extra": { 67 | "thanks": { 68 | "name": "symfony/ux", 69 | "url": "https://github.com/symfony/ux" 70 | } 71 | }, 72 | "minimum-stability": "dev", 73 | "config": { 74 | "allow-plugins": { 75 | "composer/package-versions-deprecated": true 76 | } 77 | } 78 | } 79 | -------------------------------------------------------------------------------- /config/services.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | namespace Symfony\Component\DependencyInjection\Loader\Configurator; 13 | 14 | use Symfony\Component\HttpKernel\KernelEvents; 15 | use Symfony\UX\Turbo\Broadcaster\BroadcasterInterface; 16 | use Symfony\UX\Turbo\Broadcaster\IdAccessor; 17 | use Symfony\UX\Turbo\Broadcaster\ImuxBroadcaster; 18 | use Symfony\UX\Turbo\Broadcaster\TwigBroadcaster; 19 | use Symfony\UX\Turbo\Doctrine\BroadcastListener; 20 | use Symfony\UX\Turbo\Request\RequestListener; 21 | use Symfony\UX\Turbo\Twig\TurboRuntime; 22 | use Symfony\UX\Turbo\Twig\TwigExtension; 23 | 24 | /* 25 | * @author Kévin Dunglas 26 | */ 27 | return static function (ContainerConfigurator $container): void { 28 | $container->services() 29 | 30 | ->set('turbo.broadcaster.imux', ImuxBroadcaster::class) 31 | ->args([tagged_iterator('turbo.broadcaster')]) 32 | 33 | ->alias(BroadcasterInterface::class, 'turbo.broadcaster.imux') 34 | 35 | ->set('turbo.id_accessor', IdAccessor::class) 36 | ->args([ 37 | service('property_accessor')->nullOnInvalid(), 38 | service('doctrine')->nullOnInvalid(), 39 | ]) 40 | 41 | ->set('turbo.broadcaster.action_renderer', TwigBroadcaster::class) 42 | ->args([ 43 | service('.inner'), 44 | service('twig'), 45 | abstract_arg('entity template prefixes'), 46 | service('turbo.id_accessor'), 47 | ]) 48 | ->decorate('turbo.broadcaster.imux') 49 | 50 | ->set('turbo.twig.extension', TwigExtension::class) 51 | ->tag('twig.extension') 52 | 53 | ->set('turbo.twig.runtime', TurboRuntime::class) 54 | ->args([ 55 | tagged_locator('turbo.renderer.stream_listen', 'transport'), 56 | abstract_arg('default_transport'), 57 | ]) 58 | ->tag('twig.runtime') 59 | 60 | ->set('turbo.doctrine.event_listener', BroadcastListener::class) 61 | ->args([ 62 | service('turbo.broadcaster.imux'), 63 | service('annotation_reader')->nullOnInvalid(), 64 | ]) 65 | ->tag('doctrine.event_listener', ['event' => 'onFlush']) 66 | ->tag('doctrine.event_listener', ['event' => 'postFlush']) 67 | 68 | ->set('turbo.kernel.request_listener', RequestListener::class) 69 | ->tag('kernel.event_listener', ['event' => KernelEvents::REQUEST, 'priority' => 256]) 70 | ; 71 | }; 72 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | services: 2 | ###> symfony/mercure-bundle ### 3 | mercure: 4 | image: dunglas/mercure 5 | ports: 6 | - 3000:3000 7 | environment: 8 | SERVER_NAME: ':3000' 9 | MERCURE_PUBLISHER_JWT_KEY: '!ChangeMe!' 10 | MERCURE_SUBSCRIBER_JWT_KEY: '!ChangeMe!' 11 | MERCURE_EXTRA_DIRECTIVES: | 12 | anonymous 13 | cors_origins * 14 | ###< symfony/mercure-bundle ### 15 | -------------------------------------------------------------------------------- /src/Attribute/Broadcast.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | namespace Symfony\UX\Turbo\Attribute; 13 | 14 | use Symfony\UX\Turbo\Bridge\Mercure\Broadcaster; 15 | 16 | /** 17 | * Marks the entity as broadcastable. 18 | * 19 | * @Annotation 20 | * @Target({"CLASS"}) 21 | * 22 | * @author Kévin Dunglas 23 | */ 24 | #[\Attribute(\Attribute::TARGET_CLASS | \Attribute::IS_REPEATABLE)] 25 | final class Broadcast 26 | { 27 | public const ACTION_CREATE = 'create'; 28 | public const ACTION_UPDATE = 'update'; 29 | public const ACTION_REMOVE = 'remove'; 30 | 31 | /** 32 | * @var array 33 | */ 34 | public array $options; 35 | 36 | /** 37 | * Options can be any option supported by the broadcaster. 38 | * 39 | * @see Broadcaster for the default options when using Mercure 40 | * 41 | * @param mixed[] ...$options 42 | */ 43 | public function __construct(...$options) 44 | { 45 | // @phpstan-ignore function.alreadyNarrowedType 46 | if ([0] === array_keys($options) && \is_array($options[0]) && \is_string(key($options[0]))) { 47 | $options = $options[0]; 48 | } 49 | 50 | $this->options = $options; 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /src/Bridge/Mercure/Broadcaster.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | namespace Symfony\UX\Turbo\Bridge\Mercure; 13 | 14 | use Symfony\Component\ExpressionLanguage\ExpressionLanguage; 15 | use Symfony\Component\Mercure\HubInterface; 16 | use Symfony\Component\Mercure\Update; 17 | use Symfony\UX\Turbo\Broadcaster\BroadcasterInterface; 18 | use Symfony\UX\Turbo\Doctrine\ClassUtil; 19 | 20 | /** 21 | * Broadcasts updates rendered using Twig with Mercure. 22 | * 23 | * Supported options are: 24 | * 25 | * * id (string[]) The (potentially composite) identifier of the broadcasted entity 26 | * * transports (string[]) The name of the transports to broadcast to 27 | * * topics (string[]) The topics to use; the default topic is derived from the FQCN of the entity and from its id 28 | * * rendered_action (string) The turbo-stream action rendered as HTML 29 | * * private (bool) Marks Mercure updates as private 30 | * * sse_id (string) ID field of the SSE 31 | * * sse_type (string) type field of the SSE 32 | * * sse_retry (int) retry field of the SSE 33 | * 34 | * @author Kévin Dunglas 35 | */ 36 | final class Broadcaster implements BroadcasterInterface 37 | { 38 | /** 39 | * @internal 40 | */ 41 | public const TOPIC_PATTERN = 'https://symfony.com/ux-turbo/%s/%s'; 42 | 43 | private ?ExpressionLanguage $expressionLanguage; 44 | 45 | public function __construct( 46 | private string $name, 47 | private HubInterface $hub, 48 | ) { 49 | $this->expressionLanguage = class_exists(ExpressionLanguage::class) ? new ExpressionLanguage() : null; 50 | } 51 | 52 | public function broadcast(object $entity, string $action, array $options): void 53 | { 54 | if (isset($options['transports']) && !\in_array($this->name, (array) $options['transports'], true)) { 55 | return; 56 | } 57 | 58 | $entityClass = ClassUtil::getEntityClass($entity); 59 | 60 | if (!isset($options['rendered_action'])) { 61 | throw new \InvalidArgumentException(\sprintf('Cannot broadcast entity of class "%s" as option "rendered_action" is missing.', $entityClass)); 62 | } 63 | 64 | if (!isset($options['topics']) && !isset($options['id'])) { 65 | throw new \InvalidArgumentException(\sprintf('Cannot broadcast entity of class "%s": either option "topics" or "id" is missing, or the PropertyAccess component is not installed. Try running "composer require property-access".', $entityClass)); 66 | } 67 | 68 | $topics = []; 69 | 70 | foreach ((array) ($options['topics'] ?? []) as $topic) { 71 | // @phpstan-ignore function.alreadyNarrowedType ($topic should always be a string given the PHPDoc... could be removed in 3.x) 72 | if (!\is_string($topic)) { 73 | $topics[] = $topic; 74 | continue; 75 | } 76 | 77 | if (!str_starts_with($topic, '@=')) { 78 | $topics[] = $topic; 79 | continue; 80 | } 81 | 82 | if (null === $this->expressionLanguage) { 83 | throw new \LogicException('The "@=" expression syntax cannot be used without the Expression Language component. Try running "composer require symfony/expression-language".'); 84 | } 85 | 86 | $topics[] = $this->expressionLanguage->evaluate(substr($topic, 2), ['entity' => $entity]); 87 | } 88 | 89 | $options['topics'] = $topics; 90 | 91 | if (0 === \count($options['topics'])) { 92 | if (!isset($options['id'])) { 93 | throw new \InvalidArgumentException(\sprintf('Cannot broadcast entity of class "%s": the option "topics" is empty and "id" is missing.', $entityClass)); 94 | } 95 | 96 | $options['topics'] = (array) \sprintf(self::TOPIC_PATTERN, rawurlencode($entityClass), rawurlencode(implode('-', (array) $options['id']))); 97 | } 98 | 99 | $update = new Update( 100 | $options['topics'], 101 | $options['rendered_action'], 102 | $options['private'] ?? false, 103 | $options['sse_id'] ?? null, 104 | $options['sse_type'] ?? null, 105 | $options['sse_retry'] ?? null 106 | ); 107 | 108 | $this->hub->publish($update); 109 | } 110 | } 111 | -------------------------------------------------------------------------------- /src/Bridge/Mercure/TopicSet.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | namespace Symfony\UX\Turbo\Bridge\Mercure; 13 | 14 | /** 15 | * @internal 16 | */ 17 | final class TopicSet 18 | { 19 | /** 20 | * @param array $topics 21 | */ 22 | public function __construct( 23 | private array $topics, 24 | ) { 25 | } 26 | 27 | /** 28 | * @return array 29 | */ 30 | public function getTopics(): array 31 | { 32 | return $this->topics; 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /src/Bridge/Mercure/TurboStreamListenRenderer.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | namespace Symfony\UX\Turbo\Bridge\Mercure; 13 | 14 | use Symfony\Component\Mercure\HubInterface; 15 | use Symfony\Component\Mercure\Twig\MercureExtension; 16 | use Symfony\UX\StimulusBundle\Helper\StimulusHelper; 17 | use Symfony\UX\Turbo\Broadcaster\IdAccessor; 18 | use Symfony\UX\Turbo\Twig\TurboStreamListenRendererWithOptionsInterface; 19 | use Symfony\WebpackEncoreBundle\Twig\StimulusTwigExtension; 20 | use Twig\Environment; 21 | use Twig\Error\RuntimeError; 22 | 23 | /** 24 | * Renders the attributes to load the "mercure-turbo-stream" controller. 25 | * 26 | * @author Kévin Dunglas 27 | */ 28 | final class TurboStreamListenRenderer implements TurboStreamListenRendererWithOptionsInterface 29 | { 30 | private StimulusHelper $stimulusHelper; 31 | 32 | public function __construct( 33 | private HubInterface $hub, 34 | StimulusHelper|StimulusTwigExtension $stimulus, 35 | private IdAccessor $idAccessor, 36 | private Environment $twig, 37 | ) { 38 | if ($stimulus instanceof StimulusTwigExtension) { 39 | 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); 40 | 41 | $stimulus = new StimulusHelper(null); 42 | } 43 | 44 | /* @var StimulusHelper $stimulus */ 45 | $this->stimulusHelper = $stimulus; 46 | } 47 | 48 | public function renderTurboStreamListen(Environment $env, $topic /* array $eventSourceOptions = [] */): string 49 | { 50 | if (\func_num_args() > 2) { 51 | $eventSourceOptions = func_get_arg(2); 52 | } 53 | 54 | $topics = $topic instanceof TopicSet 55 | ? array_map($this->resolveTopic(...), $topic->getTopics()) 56 | : [$this->resolveTopic($topic)]; 57 | 58 | $controllerAttributes = ['hub' => $this->hub->getPublicUrl()]; 59 | if (1 < \count($topics)) { 60 | $controllerAttributes['topics'] = $topics; 61 | } else { 62 | $controllerAttributes['topic'] = current($topics); 63 | } 64 | 65 | if (isset($eventSourceOptions)) { 66 | try { 67 | $mercure = $this->twig->getExtension(MercureExtension::class); 68 | 69 | if ($eventSourceOptions['withCredentials'] ?? false) { 70 | $eventSourceOptions['subscribe'] ??= $topics; 71 | $controllerAttributes['withCredentials'] = true; 72 | } 73 | 74 | $mercure->mercure($topics, $eventSourceOptions); 75 | } catch (RuntimeError $e) { 76 | } 77 | } 78 | 79 | $stimulusAttributes = $this->stimulusHelper->createStimulusAttributes(); 80 | $stimulusAttributes->addController( 81 | 'symfony/ux-turbo/mercure-turbo-stream', 82 | $controllerAttributes, 83 | ); 84 | 85 | return (string) $stimulusAttributes; 86 | } 87 | 88 | private function resolveTopic(object|string $topic): string 89 | { 90 | if (\is_object($topic)) { 91 | $class = $topic::class; 92 | 93 | if (!$id = $this->idAccessor->getEntityId($topic)) { 94 | throw new \LogicException(\sprintf('Cannot listen to entity of class "%s" as the PropertyAccess component is not installed. Try running "composer require symfony/property-access".', $class)); 95 | } 96 | 97 | return \sprintf(Broadcaster::TOPIC_PATTERN, rawurlencode($class), rawurlencode(implode('-', $id))); 98 | } 99 | 100 | if (!preg_match('/[^a-zA-Z0-9_\x7f-\xff\\\\]/', $topic) && class_exists($topic)) { 101 | // Generate a URI template to subscribe to updates for all objects of this class 102 | return \sprintf(Broadcaster::TOPIC_PATTERN, rawurlencode($topic), '{id}'); 103 | } 104 | 105 | return $topic; 106 | } 107 | } 108 | -------------------------------------------------------------------------------- /src/Broadcaster/BroadcasterInterface.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | namespace Symfony\UX\Turbo\Broadcaster; 13 | 14 | /** 15 | * Broadcasts an update of an entity. 16 | * 17 | * @author Kévin Dunglas 18 | */ 19 | interface BroadcasterInterface 20 | { 21 | /** 22 | * @param array{id?: string|string[], transports?: string|string[], topics?: string|string[], template?: string, rendered_action?: string, private?: bool, sse_id?: string, sse_type?: string, sse_retry?: int} $options 23 | */ 24 | public function broadcast(object $entity, string $action, array $options): void; 25 | } 26 | -------------------------------------------------------------------------------- /src/Broadcaster/IdAccessor.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | namespace Symfony\UX\Turbo\Broadcaster; 13 | 14 | use Doctrine\Persistence\ManagerRegistry; 15 | use Symfony\Component\PropertyAccess\PropertyAccess; 16 | use Symfony\Component\PropertyAccess\PropertyAccessorInterface; 17 | 18 | class IdAccessor 19 | { 20 | private ?PropertyAccessorInterface $propertyAccessor; 21 | 22 | public function __construct( 23 | ?PropertyAccessorInterface $propertyAccessor = null, 24 | private ?ManagerRegistry $doctrine = null, 25 | ) { 26 | $this->propertyAccessor = $propertyAccessor ?? (class_exists(PropertyAccess::class) ? PropertyAccess::createPropertyAccessor() : null); 27 | } 28 | 29 | /** 30 | * @return string[]|null 31 | */ 32 | public function getEntityId(object $entity): ?array 33 | { 34 | $entityClass = $entity::class; 35 | 36 | if ($this->doctrine && $em = $this->doctrine->getManagerForClass($entityClass)) { 37 | return $em->getClassMetadata($entityClass)->getIdentifierValues($entity); 38 | } 39 | 40 | if ($this->propertyAccessor) { 41 | return (array) $this->propertyAccessor->getValue($entity, 'id'); 42 | } 43 | 44 | return null; 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /src/Broadcaster/ImuxBroadcaster.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | namespace Symfony\UX\Turbo\Broadcaster; 13 | 14 | /** 15 | * Passes the incoming updates to all registered broadcasters (inverse multiplexing). 16 | * 17 | * @author Kévin Dunglas 18 | */ 19 | final class ImuxBroadcaster implements BroadcasterInterface 20 | { 21 | /** 22 | * @param BroadcasterInterface[] $broadcasters 23 | */ 24 | public function __construct( 25 | private iterable $broadcasters, 26 | ) { 27 | } 28 | 29 | public function broadcast(object $entity, string $action, array $options): void 30 | { 31 | foreach ($this->broadcasters as $broadcaster) { 32 | $broadcaster->broadcast($entity, $action, $options); 33 | } 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /src/Broadcaster/TwigBroadcaster.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | namespace Symfony\UX\Turbo\Broadcaster; 13 | 14 | use Symfony\UX\Turbo\Doctrine\ClassUtil; 15 | use Twig\Environment; 16 | 17 | /** 18 | * Renders the incoming entity using Twig before passing it to a broadcaster. 19 | * 20 | * @author Kévin Dunglas 21 | */ 22 | final class TwigBroadcaster implements BroadcasterInterface 23 | { 24 | private IdAccessor $idAccessor; 25 | 26 | /** 27 | * @param array $templatePrefixes 28 | */ 29 | public function __construct( 30 | private BroadcasterInterface $broadcaster, 31 | private Environment $twig, 32 | private array $templatePrefixes = [], 33 | ?IdAccessor $idAccessor = null, 34 | ) { 35 | $this->idAccessor = $idAccessor ?? new IdAccessor(); 36 | } 37 | 38 | public function broadcast(object $entity, string $action, array $options): void 39 | { 40 | if (!isset($options['id']) && null !== $id = $this->idAccessor->getEntityId($entity)) { 41 | $options['id'] = $id; 42 | } 43 | 44 | $class = ClassUtil::getEntityClass($entity); 45 | 46 | if (null === $template = $options['template'] ?? null) { 47 | $template = $class; 48 | foreach ($this->templatePrefixes as $namespace => $prefix) { 49 | if (str_starts_with($template, $namespace)) { 50 | $template = substr_replace($template, $prefix, 0, \strlen($namespace)); 51 | break; 52 | } 53 | } 54 | 55 | $template = str_replace('\\', '/', $template).'.stream.html.twig'; 56 | } 57 | 58 | // Will throw if the template or the block doesn't exist 59 | $options['rendered_action'] = $this->twig 60 | ->load($template) 61 | ->renderBlock($action, [ 62 | 'entity' => $entity, 63 | 'action' => $action, 64 | 'id' => implode('-', (array) ($options['id'] ?? [])), 65 | ] + $options); 66 | 67 | $this->broadcaster->broadcast($entity, $action, $options); 68 | } 69 | } 70 | -------------------------------------------------------------------------------- /src/DependencyInjection/Compiler/RegisterMercureHubsPass.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | namespace Symfony\UX\Turbo\DependencyInjection\Compiler; 13 | 14 | use Symfony\Component\DependencyInjection\Compiler\CompilerPassInterface; 15 | use Symfony\Component\DependencyInjection\ContainerBuilder; 16 | use Symfony\Component\DependencyInjection\Reference; 17 | use Symfony\UX\Turbo\Bridge\Mercure\Broadcaster; 18 | use Symfony\UX\Turbo\Bridge\Mercure\TurboStreamListenRenderer; 19 | 20 | /** 21 | * This compiler pass ensures that TurboStreamListenRenderer 22 | * and Broadcast are registered per Mercure hub. 23 | * 24 | * @author Pierre Ambroise 25 | */ 26 | final class RegisterMercureHubsPass implements CompilerPassInterface 27 | { 28 | public function process(ContainerBuilder $container) 29 | { 30 | foreach ($container->findTaggedServiceIds('mercure.hub') as $hubId => $tags) { 31 | $name = str_replace('mercure.hub.', '', $hubId); 32 | 33 | $container->register("turbo.mercure.$name.renderer", TurboStreamListenRenderer::class) 34 | ->addArgument(new Reference($hubId)) 35 | ->addArgument(new Reference('turbo.mercure.stimulus_helper')) 36 | ->addArgument(new Reference('turbo.id_accessor')) 37 | ->addArgument(new Reference('twig')) 38 | ->addTag('turbo.renderer.stream_listen', ['transport' => $name]); 39 | 40 | foreach ($tags as $tag) { 41 | if (isset($tag['default']) && $tag['default'] && 'default' !== $name) { 42 | $container->getDefinition("turbo.mercure.$name.renderer") 43 | ->addTag('turbo.renderer.stream_listen', ['transport' => 'default']); 44 | } 45 | } 46 | 47 | $container->register("turbo.mercure.$name.broadcaster", Broadcaster::class) 48 | ->addArgument($name) 49 | ->addArgument(new Reference($hubId)) 50 | ->addTag('turbo.broadcaster'); 51 | } 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /src/DependencyInjection/Configuration.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | namespace Symfony\UX\Turbo\DependencyInjection; 13 | 14 | use Doctrine\Bundle\DoctrineBundle\DoctrineBundle; 15 | use Doctrine\ORM\EntityManagerInterface; 16 | use Symfony\Component\Config\Definition\Builder\TreeBuilder; 17 | use Symfony\Component\Config\Definition\ConfigurationInterface; 18 | 19 | /** 20 | * Turbo configuration structure. 21 | * 22 | * @author Kévin Dunglas 23 | */ 24 | final class Configuration implements ConfigurationInterface 25 | { 26 | public function getConfigTreeBuilder(): TreeBuilder 27 | { 28 | $treeBuilder = new TreeBuilder('turbo'); 29 | $rootNode = $treeBuilder->getRootNode(); 30 | $rootNode 31 | ->children() 32 | ->arrayNode('broadcast') 33 | ->canBeDisabled() 34 | ->children() 35 | ->arrayNode('entity_template_prefixes') 36 | ->fixXmlConfig('entity_template_prefix') 37 | ->defaultValue(['App\Entity\\' => 'broadcast/']) 38 | ->scalarPrototype()->end() 39 | ->end() 40 | ->arrayNode('doctrine_orm') 41 | ->info('Enable the Doctrine ORM integration') 42 | ->{class_exists(DoctrineBundle::class) && interface_exists(EntityManagerInterface::class) ? 'canBeDisabled' : 'canBeEnabled'}() 43 | ->end() 44 | ->end() 45 | ->end() 46 | ->scalarNode('default_transport')->defaultValue('default')->end() 47 | ->end() 48 | ; 49 | 50 | return $treeBuilder; 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /src/DependencyInjection/TurboExtension.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | namespace Symfony\UX\Turbo\DependencyInjection; 13 | 14 | use Doctrine\Bundle\DoctrineBundle\DoctrineBundle; 15 | use Doctrine\ORM\EntityManagerInterface; 16 | use Symfony\Bundle\TwigBundle\TwigBundle; 17 | use Symfony\Component\AssetMapper\AssetMapperInterface; 18 | use Symfony\Component\Config\Definition\Exception\InvalidConfigurationException; 19 | use Symfony\Component\Config\FileLocator; 20 | use Symfony\Component\Config\Loader\LoaderInterface; 21 | use Symfony\Component\DependencyInjection\ContainerBuilder; 22 | use Symfony\Component\DependencyInjection\Extension\PrependExtensionInterface; 23 | use Symfony\Component\DependencyInjection\Loader\PhpFileLoader; 24 | use Symfony\Component\HttpKernel\DependencyInjection\Extension; 25 | use Symfony\UX\Turbo\Broadcaster\BroadcasterInterface; 26 | use Symfony\UX\Turbo\Twig\TurboStreamListenRendererInterface; 27 | 28 | /** 29 | * @author Kévin Dunglas 30 | */ 31 | final class TurboExtension extends Extension implements PrependExtensionInterface 32 | { 33 | public function load(array $configs, ContainerBuilder $container): void 34 | { 35 | $configuration = new Configuration(); 36 | $config = $this->processConfiguration($configuration, $configs); 37 | 38 | $loader = (new PhpFileLoader($container, new FileLocator(\dirname(__DIR__).'/../config'))); 39 | $loader->load('services.php'); 40 | $container->getDefinition('turbo.twig.runtime')->replaceArgument(1, $config['default_transport']); 41 | 42 | $this->registerTwig($config, $container); 43 | $this->registerBroadcast($config, $container, $loader); 44 | } 45 | 46 | /** 47 | * @param array $config 48 | */ 49 | private function registerTwig(array $config, ContainerBuilder $container): void 50 | { 51 | if (!class_exists(TwigBundle::class)) { 52 | return; 53 | } 54 | 55 | $container->getDefinition('turbo.broadcaster.action_renderer') 56 | ->replaceArgument(2, $config['broadcast']['entity_template_prefixes']); 57 | 58 | $container 59 | ->registerForAutoconfiguration(TurboStreamListenRendererInterface::class) 60 | ->addTag('turbo.renderer.stream_listen'); 61 | } 62 | 63 | /** 64 | * @param array $config 65 | */ 66 | private function registerBroadcast(array $config, ContainerBuilder $container, LoaderInterface $loader): void 67 | { 68 | if (!$config['broadcast']['enabled']) { 69 | $container->removeDefinition('turbo.twig.extension'); 70 | $container->removeDefinition('turbo.doctrine.event_listener'); 71 | 72 | return; 73 | } 74 | 75 | $container 76 | ->registerForAutoconfiguration(BroadcasterInterface::class) 77 | ->addTag('turbo.broadcaster') 78 | ; 79 | 80 | if (!$config['broadcast']['doctrine_orm']['enabled']) { 81 | $container->removeDefinition('turbo.doctrine.event_listener'); 82 | 83 | return; 84 | } 85 | 86 | if (!class_exists(DoctrineBundle::class) || !interface_exists(EntityManagerInterface::class)) { 87 | throw new InvalidConfigurationException('You cannot use the Doctrine ORM integration as the Doctrine bundle is not installed. Try running "composer require symfony/orm-pack".'); 88 | } 89 | } 90 | 91 | public function prepend(ContainerBuilder $container): void 92 | { 93 | if (!$this->isAssetMapperAvailable($container)) { 94 | return; 95 | } 96 | 97 | $container->prependExtensionConfig('framework', [ 98 | 'asset_mapper' => [ 99 | 'paths' => [ 100 | __DIR__.'/../../assets/dist' => '@symfony/ux-turbo', 101 | ], 102 | 'importmap_script_attributes' => [ 103 | 'data-turbo-track' => 'reload', 104 | ], 105 | ], 106 | ]); 107 | } 108 | 109 | private function isAssetMapperAvailable(ContainerBuilder $container): bool 110 | { 111 | if (!interface_exists(AssetMapperInterface::class)) { 112 | return false; 113 | } 114 | 115 | // check that FrameworkBundle 6.3 or higher is installed 116 | $bundlesMetadata = $container->getParameter('kernel.bundles_metadata'); 117 | if (!\is_array($bundlesMetadata) || !isset($bundlesMetadata['FrameworkBundle'])) { 118 | return false; 119 | } 120 | 121 | return is_file($bundlesMetadata['FrameworkBundle']['path'].'/Resources/config/asset_mapper.php'); 122 | } 123 | } 124 | -------------------------------------------------------------------------------- /src/Doctrine/BroadcastListener.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | namespace Symfony\UX\Turbo\Doctrine; 13 | 14 | use Doctrine\Common\Annotations\Reader; 15 | use Doctrine\Common\EventArgs; 16 | use Doctrine\ORM\EntityManagerInterface; 17 | use Doctrine\ORM\Event\OnFlushEventArgs; 18 | use Doctrine\ORM\Event\PostFlushEventArgs; 19 | use Symfony\Contracts\Service\ResetInterface; 20 | use Symfony\UX\Turbo\Attribute\Broadcast; 21 | use Symfony\UX\Turbo\Broadcaster\BroadcasterInterface; 22 | 23 | /** 24 | * Detects changes made from Doctrine entities and broadcasts updates to the broadcasters. 25 | * 26 | * @author Kévin Dunglas 27 | */ 28 | final class BroadcastListener implements ResetInterface 29 | { 30 | /** 31 | * @var array> 32 | */ 33 | private $broadcastedClasses; 34 | 35 | /** 36 | * @var \SplObjectStorage> 37 | */ 38 | private $createdEntities; 39 | /** 40 | * @var \SplObjectStorage> 41 | */ 42 | private $updatedEntities; 43 | /** 44 | * @var \SplObjectStorage> 45 | */ 46 | private $removedEntities; 47 | 48 | public function __construct( 49 | private BroadcasterInterface $broadcaster, 50 | private ?Reader $annotationReader = null, 51 | ) { 52 | $this->reset(); 53 | } 54 | 55 | /** 56 | * Collects created, updated and removed entities. 57 | */ 58 | public function onFlush(EventArgs $eventArgs): void 59 | { 60 | if (!$eventArgs instanceof OnFlushEventArgs) { 61 | return; 62 | } 63 | 64 | // @phpstan-ignore function.alreadyNarrowedType, method.notFound (`getEntityManager()` has been removed in Doctrine 3.0) 65 | $em = method_exists($eventArgs, 'getObjectManager') ? $eventArgs->getObjectManager() : $eventArgs->getEntityManager(); 66 | $uow = $em->getUnitOfWork(); 67 | foreach ($uow->getScheduledEntityInsertions() as $entity) { 68 | $this->storeEntitiesToPublish($em, $entity, 'createdEntities'); 69 | } 70 | 71 | foreach ($uow->getScheduledEntityUpdates() as $entity) { 72 | $this->storeEntitiesToPublish($em, $entity, 'updatedEntities'); 73 | } 74 | 75 | foreach ($uow->getScheduledEntityDeletions() as $entity) { 76 | $this->storeEntitiesToPublish($em, $entity, 'removedEntities'); 77 | } 78 | } 79 | 80 | /** 81 | * Publishes updates for changes collected on flush, and resets the store. 82 | */ 83 | public function postFlush(EventArgs $eventArgs): void 84 | { 85 | if (!$eventArgs instanceof PostFlushEventArgs) { 86 | return; 87 | } 88 | 89 | // @phpstan-ignore function.alreadyNarrowedType, method.notFound (`getEntityManager()` has been removed in Doctrine 3.0) 90 | $em = method_exists($eventArgs, 'getObjectManager') ? $eventArgs->getObjectManager() : $eventArgs->getEntityManager(); 91 | 92 | try { 93 | foreach ($this->createdEntities as $entity) { 94 | $options = $this->createdEntities[$entity]; 95 | $id = $em->getClassMetadata($entity::class)->getIdentifierValues($entity); 96 | foreach ($options as $option) { 97 | $option['id'] = $id; 98 | $this->broadcaster->broadcast($entity, Broadcast::ACTION_CREATE, $option); 99 | } 100 | } 101 | 102 | foreach ($this->updatedEntities as $entity) { 103 | foreach ($this->updatedEntities[$entity] as $option) { 104 | $this->broadcaster->broadcast($entity, Broadcast::ACTION_UPDATE, $option); 105 | } 106 | } 107 | 108 | foreach ($this->removedEntities as $entity) { 109 | foreach ($this->removedEntities[$entity] as $option) { 110 | $this->broadcaster->broadcast($entity, Broadcast::ACTION_REMOVE, $option); 111 | } 112 | } 113 | } finally { 114 | $this->reset(); 115 | } 116 | } 117 | 118 | public function reset(): void 119 | { 120 | $this->createdEntities = new \SplObjectStorage(); 121 | $this->updatedEntities = new \SplObjectStorage(); 122 | $this->removedEntities = new \SplObjectStorage(); 123 | } 124 | 125 | private function storeEntitiesToPublish(EntityManagerInterface $em, object $entity, string $property): void 126 | { 127 | $class = ClassUtil::getEntityClass($entity); 128 | 129 | if (!isset($this->broadcastedClasses[$class])) { 130 | $this->broadcastedClasses[$class] = []; 131 | $r = new \ReflectionClass($class); 132 | 133 | if ($options = $r->getAttributes(Broadcast::class)) { 134 | foreach ($options as $option) { 135 | $this->broadcastedClasses[$class][] = $option->newInstance()->options; 136 | } 137 | } elseif ($this->annotationReader && $options = $this->annotationReader->getClassAnnotations($r)) { 138 | foreach ($options as $option) { 139 | if ($option instanceof Broadcast) { 140 | $this->broadcastedClasses[$class][] = $option->options; 141 | } 142 | } 143 | } 144 | } 145 | 146 | if ($options = $this->broadcastedClasses[$class]) { 147 | if ('createdEntities' !== $property) { 148 | $id = $em->getClassMetadata($class)->getIdentifierValues($entity); 149 | foreach ($options as $k => $option) { 150 | $options[$k]['id'] = $id; 151 | } 152 | } 153 | 154 | $this->{$property}->attach($entity, $options); 155 | } 156 | } 157 | } 158 | -------------------------------------------------------------------------------- /src/Doctrine/ClassUtil.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | namespace Symfony\UX\Turbo\Doctrine; 13 | 14 | use Symfony\Component\VarExporter\LazyObjectInterface; 15 | 16 | /** 17 | * @internal 18 | */ 19 | final class ClassUtil 20 | { 21 | /** 22 | * @return class-string 23 | */ 24 | public static function getEntityClass(object $entity): string 25 | { 26 | // Doctrine proxies (old versions) 27 | if (str_contains($entity::class, 'Proxies\\__CG__')) { 28 | return get_parent_class($entity) ?: $entity::class; 29 | } 30 | 31 | if ($entity instanceof LazyObjectInterface) { 32 | return get_parent_class($entity) ?: $entity::class; 33 | } 34 | 35 | return $entity::class; 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /src/Helper/TurboStream.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | namespace Symfony\UX\Turbo\Helper; 13 | 14 | /** 15 | * @see https://turbo.hotwired.dev/reference/streams 16 | */ 17 | final class TurboStream 18 | { 19 | /** 20 | * Appends to the element(s) designated by the target CSS selector. 21 | */ 22 | public static function append(string $target, string $html): string 23 | { 24 | return self::wrap('append', $target, $html); 25 | } 26 | 27 | /** 28 | * Prepends to the element(s) designated by the target CSS selector. 29 | */ 30 | public static function prepend(string $target, string $html): string 31 | { 32 | return self::wrap('prepend', $target, $html); 33 | } 34 | 35 | /** 36 | * Replaces the element(s) designated by the target CSS selector. 37 | */ 38 | public static function replace(string $target, string $html, bool $morph = false): string 39 | { 40 | return self::wrap('replace', $target, $html, $morph ? ' method="morph"' : ''); 41 | } 42 | 43 | /** 44 | * Updates the content of the element(s) designated by the target CSS selector. 45 | */ 46 | public static function update(string $target, string $html, bool $morph = false): string 47 | { 48 | return self::wrap('update', $target, $html, $morph ? ' method="morph"' : ''); 49 | } 50 | 51 | /** 52 | * Removes the element(s) designated by the target CSS selector. 53 | */ 54 | public static function remove(string $target): string 55 | { 56 | return \sprintf('', htmlspecialchars($target)); 57 | } 58 | 59 | /** 60 | * Inserts before the element(s) designated by the target CSS selector. 61 | */ 62 | public static function before(string $target, string $html): string 63 | { 64 | return self::wrap('before', $target, $html); 65 | } 66 | 67 | /** 68 | * Inserts after the element(s) designated by the target CSS selector. 69 | */ 70 | public static function after(string $target, string $html): string 71 | { 72 | return self::wrap('after', $target, $html); 73 | } 74 | 75 | /** 76 | * Initiates a Page Refresh to render new content with morphing. 77 | * 78 | * @see Initiates a Page Refresh to render new content with morphing. 79 | */ 80 | public static function refresh(?string $requestId = null): string 81 | { 82 | if (null === $requestId) { 83 | return ''; 84 | } 85 | 86 | return \sprintf('', htmlspecialchars($requestId)); 87 | } 88 | 89 | /** 90 | * Custom action and attributes. 91 | * 92 | * Set boolean attributes (e.g., `disabled`) by providing the attribute name as key with `null` as value. 93 | * 94 | * @param array $attr 95 | */ 96 | public static function action(string $action, string $target, string $html, array $attr = []): string 97 | { 98 | if (\array_key_exists('action', $attr) || \array_key_exists('targets', $attr)) { 99 | throw new \InvalidArgumentException('The "action" and "targets" attributes are reserved and cannot be used.'); 100 | } 101 | 102 | $attrString = ''; 103 | foreach ($attr as $key => $value) { 104 | $key = htmlspecialchars($key); 105 | if (null === $value) { 106 | $attrString .= \sprintf(' %s', $key); 107 | } elseif (\is_int($value) || \is_float($value)) { 108 | $attrString .= \sprintf(' %s="%s"', $key, $value); 109 | } else { 110 | $attrString .= \sprintf(' %s="%s"', $key, htmlspecialchars($value)); 111 | } 112 | } 113 | 114 | return self::wrap(htmlspecialchars($action), $target, $html, $attrString); 115 | } 116 | 117 | private static function wrap(string $action, string $target, string $html, string $attr = ''): string 118 | { 119 | return \sprintf(<< 121 | 122 | 123 | EOHTML, $action, htmlspecialchars($target), $attr, $html); 124 | } 125 | } 126 | -------------------------------------------------------------------------------- /src/Request/RequestListener.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | namespace Symfony\UX\Turbo\Request; 13 | 14 | use Symfony\Component\HttpFoundation\Request; 15 | use Symfony\UX\Turbo\TurboBundle; 16 | 17 | /** 18 | * Registers the Turbo request format for all requests. 19 | * 20 | * @author Alexander Hofbauer 21 | */ 22 | final class RequestListener 23 | { 24 | public function __invoke(): void 25 | { 26 | (new Request())->setFormat(TurboBundle::STREAM_FORMAT, TurboBundle::STREAM_MEDIA_TYPE); 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /src/TurboBundle.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | namespace Symfony\UX\Turbo; 13 | 14 | use Symfony\Component\DependencyInjection\Compiler\CompilerPassInterface; 15 | use Symfony\Component\DependencyInjection\Compiler\PassConfig; 16 | use Symfony\Component\DependencyInjection\ContainerBuilder; 17 | use Symfony\Component\HttpKernel\Bundle\Bundle; 18 | use Symfony\UX\Turbo\DependencyInjection\Compiler\RegisterMercureHubsPass; 19 | 20 | /** 21 | * @author Kévin Dunglas 22 | */ 23 | final class TurboBundle extends Bundle 24 | { 25 | public const STREAM_FORMAT = 'turbo_stream'; 26 | public const STREAM_MEDIA_TYPE = 'text/vnd.turbo-stream.html'; 27 | 28 | public function build(ContainerBuilder $container): void 29 | { 30 | parent::build($container); 31 | 32 | $container->addCompilerPass(new RegisterMercureHubsPass()); 33 | 34 | $container->addCompilerPass(new class implements CompilerPassInterface { 35 | public function process(ContainerBuilder $container): void 36 | { 37 | if (!$container->hasDefinition('turbo.broadcaster.imux')) { 38 | return; 39 | } 40 | if (!$container->getDefinition('turbo.broadcaster.imux')->getArgument(0)->getValues()) { 41 | $container->removeDefinition('turbo.doctrine.event_listener'); 42 | } 43 | } 44 | }, PassConfig::TYPE_BEFORE_REMOVING); 45 | } 46 | 47 | public function getPath(): string 48 | { 49 | return \dirname(__DIR__); 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /src/TurboStreamResponse.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | namespace Symfony\UX\Turbo; 13 | 14 | use Symfony\Component\HttpFoundation\Response; 15 | use Symfony\UX\Turbo\Helper\TurboStream; 16 | 17 | class TurboStreamResponse extends Response 18 | { 19 | public function __construct(?string $content = '', int $status = 200, array $headers = []) 20 | { 21 | parent::__construct($content, $status, $headers); 22 | 23 | if (!$this->headers->has('Content-Type')) { 24 | $this->headers->set('Content-Type', TurboBundle::STREAM_MEDIA_TYPE); 25 | } 26 | } 27 | 28 | /** 29 | * @return $this 30 | */ 31 | public function append(string $target, string $html): static 32 | { 33 | $this->setContent($this->getContent().TurboStream::append($target, $html)); 34 | 35 | return $this; 36 | } 37 | 38 | /** 39 | * @return $this 40 | */ 41 | public function prepend(string $target, string $html): static 42 | { 43 | $this->setContent($this->getContent().TurboStream::prepend($target, $html)); 44 | 45 | return $this; 46 | } 47 | 48 | /** 49 | * @return $this 50 | */ 51 | public function replace(string $target, string $html, bool $morph = false): static 52 | { 53 | $this->setContent($this->getContent().TurboStream::replace($target, $html, $morph)); 54 | 55 | return $this; 56 | } 57 | 58 | /** 59 | * @return $this 60 | */ 61 | public function update(string $target, string $html, bool $morph = false): static 62 | { 63 | $this->setContent($this->getContent().TurboStream::update($target, $html, $morph)); 64 | 65 | return $this; 66 | } 67 | 68 | /** 69 | * @return $this 70 | */ 71 | public function remove(string $target): static 72 | { 73 | $this->setContent($this->getContent().TurboStream::remove($target)); 74 | 75 | return $this; 76 | } 77 | 78 | /** 79 | * @return $this 80 | */ 81 | public function before(string $target, string $html): static 82 | { 83 | $this->setContent($this->getContent().TurboStream::before($target, $html)); 84 | 85 | return $this; 86 | } 87 | 88 | /** 89 | * @return $this 90 | */ 91 | public function after(string $target, string $html): static 92 | { 93 | $this->setContent($this->getContent().TurboStream::after($target, $html)); 94 | 95 | return $this; 96 | } 97 | 98 | /** 99 | * @return $this 100 | */ 101 | public function refresh(?string $requestId = null): static 102 | { 103 | $this->setContent($this->getContent().TurboStream::refresh($requestId)); 104 | 105 | return $this; 106 | } 107 | 108 | /** 109 | * Custom action and attributes. 110 | * 111 | * Set boolean attributes (e.g., `disabled`) by providing the attribute name as key with `null` as value. 112 | * 113 | * @param array $attr 114 | * 115 | * @return $this 116 | */ 117 | public function action(string $action, string $target, string $html, array $attr = []): static 118 | { 119 | $this->setContent($this->getContent().TurboStream::action($action, $target, $html, $attr)); 120 | 121 | return $this; 122 | } 123 | } 124 | -------------------------------------------------------------------------------- /src/Twig/TurboRuntime.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | namespace Symfony\UX\Turbo\Twig; 13 | 14 | use Psr\Container\ContainerInterface; 15 | use Symfony\UX\Turbo\Bridge\Mercure\TopicSet; 16 | use Twig\Environment; 17 | use Twig\Extension\RuntimeExtensionInterface; 18 | 19 | /** 20 | * @author Kévin Dunglas 21 | * @author Pierre Ambroise 22 | * 23 | * @internal 24 | */ 25 | final class TurboRuntime implements RuntimeExtensionInterface 26 | { 27 | public function __construct( 28 | private ContainerInterface $turboStreamListenRenderers, 29 | private readonly string $defaultTransport, 30 | ) { 31 | } 32 | 33 | /** 34 | * @param object|string|array $topic 35 | * @param array $options 36 | */ 37 | public function renderTurboStreamListen(Environment $env, $topic, ?string $transport = null, array $options = []): string 38 | { 39 | $options['transport'] = $transport ??= $this->defaultTransport; 40 | 41 | if (!$this->turboStreamListenRenderers->has($transport)) { 42 | throw new \InvalidArgumentException(\sprintf('The Turbo stream transport "%s" does not exist.', $transport)); 43 | } 44 | 45 | if (\is_array($topic)) { 46 | $topic = new TopicSet($topic); 47 | } 48 | 49 | $renderer = $this->turboStreamListenRenderers->get($transport); 50 | 51 | return $renderer instanceof TurboStreamListenRendererWithOptionsInterface 52 | ? $renderer->renderTurboStreamListen($env, $topic, $options) // @phpstan-ignore-line 53 | : $renderer->renderTurboStreamListen($env, $topic); 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /src/Twig/TurboStreamListenRendererInterface.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | namespace Symfony\UX\Turbo\Twig; 13 | 14 | use Twig\Environment; 15 | 16 | /** 17 | * Render turbo stream attributes. 18 | * 19 | * @author Kévin Dunglas 20 | */ 21 | interface TurboStreamListenRendererInterface 22 | { 23 | /** 24 | * @param string|object $topic 25 | */ 26 | public function renderTurboStreamListen(Environment $env, $topic /* , array $eventSourceOptions = [] */): string; 27 | } 28 | -------------------------------------------------------------------------------- /src/Twig/TurboStreamListenRendererWithOptionsInterface.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | namespace Symfony\UX\Turbo\Twig; 13 | 14 | /** 15 | * @internal 16 | */ 17 | interface TurboStreamListenRendererWithOptionsInterface extends TurboStreamListenRendererInterface 18 | { 19 | } 20 | -------------------------------------------------------------------------------- /src/Twig/TwigExtension.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | namespace Symfony\UX\Turbo\Twig; 13 | 14 | use Twig\Extension\AbstractExtension; 15 | use Twig\TwigFunction; 16 | 17 | /** 18 | * @author Kévin Dunglas 19 | */ 20 | final class TwigExtension extends AbstractExtension 21 | { 22 | private const REFRESH_METHOD_REPLACE = 'replace'; 23 | private const REFRESH_METHOD_MORPH = 'morph'; 24 | 25 | private const REFRESH_SCROLL_RESET = 'reset'; 26 | private const REFRESH_SCROLL_PRESERVE = 'preserve'; 27 | 28 | public function getFunctions(): array 29 | { 30 | return [ 31 | new TwigFunction('turbo_stream_listen', [TurboRuntime::class, 'renderTurboStreamListen'], ['needs_environment' => true, 'is_safe' => ['html']]), 32 | new TwigFunction('turbo_exempts_page_from_cache', $this->turboExemptsPageFromCache(...), ['is_safe' => ['html']]), 33 | new TwigFunction('turbo_exempts_page_from_preview', $this->turboExemptsPageFromPreview(...), ['is_safe' => ['html']]), 34 | new TwigFunction('turbo_page_requires_reload', $this->turboPageRequiresReload(...), ['is_safe' => ['html']]), 35 | new TwigFunction('turbo_refreshes_with', $this->turboRefreshesWith(...), ['is_safe' => ['html']]), 36 | new TwigFunction('turbo_refresh_method', $this->turboRefreshMethod(...), ['is_safe' => ['html']]), 37 | new TwigFunction('turbo_refresh_scroll', $this->turboRefreshScroll(...), ['is_safe' => ['html']]), 38 | ]; 39 | } 40 | 41 | /** 42 | * Generates a tag to disable caching of a page. 43 | * 44 | * Inspired by Turbo Rails 45 | * ({@see https://github.com/hotwired/turbo-rails/blob/main/app/helpers/turbo/drive_helper.rb}). 46 | */ 47 | public function turboExemptsPageFromCache(): string 48 | { 49 | return ''; 50 | } 51 | 52 | /** 53 | * Generates a tag to specify cached version of the page should not be shown as a preview on regular navigation visits. 54 | * 55 | * Inspired by Turbo Rails 56 | * ({@see https://github.com/hotwired/turbo-rails/blob/main/app/helpers/turbo/drive_helper.rb}). 57 | */ 58 | public function turboExemptsPageFromPreview(): string 59 | { 60 | return ''; 61 | } 62 | 63 | /** 64 | * Generates a tag to force a full page reload. 65 | * 66 | * Inspired by Turbo Rails 67 | * ({@see https://github.com/hotwired/turbo-rails/blob/main/app/helpers/turbo/drive_helper.rb}). 68 | */ 69 | public function turboPageRequiresReload(): string 70 | { 71 | return ''; 72 | } 73 | 74 | /** 75 | * Generates tags to configure both the refresh method and scroll behavior for page refreshes. 76 | * 77 | * Inspired by Turbo Rails 78 | * ({@see https://github.com/hotwired/turbo-rails/blob/main/app/helpers/turbo/drive_helper.rb}). 79 | * 80 | * @param string $method The refresh method. Must be either 'replace' or 'morph'. 81 | * @param string $scroll The scroll behavior. Must be either 'reset' or 'preserve'. 82 | * 83 | * @return string The tags for the specified refresh method and scroll behavior 84 | */ 85 | public function turboRefreshesWith(string $method = self::REFRESH_METHOD_REPLACE, string $scroll = self::REFRESH_SCROLL_RESET): string 86 | { 87 | return $this->turboRefreshMethod($method).$this->turboRefreshScroll($scroll); 88 | } 89 | 90 | /** 91 | * Generates a tag to configure the refresh method for page refreshes. 92 | * 93 | * Inspired by Turbo Rails 94 | * ({@see https://github.com/hotwired/turbo-rails/blob/main/app/helpers/turbo/drive_helper.rb}). 95 | * 96 | * @param string $method The refresh method. Must be either 'replace' or 'morph'. 97 | * 98 | * @return string The tag for the specified refresh method 99 | * 100 | * @throws \InvalidArgumentException If an invalid refresh method is provided 101 | */ 102 | public function turboRefreshMethod(string $method = self::REFRESH_METHOD_REPLACE): string 103 | { 104 | if (!\in_array($method, [self::REFRESH_METHOD_REPLACE, self::REFRESH_METHOD_MORPH], true)) { 105 | throw new \InvalidArgumentException(\sprintf('Invalid refresh option "%s".', $method)); 106 | } 107 | 108 | return \sprintf('', $method); 109 | } 110 | 111 | /** 112 | * Generates a tag to configure the scroll behavior for page refreshes. 113 | * 114 | * Inspired by Turbo Rails 115 | * ({@see https://github.com/hotwired/turbo-rails/blob/main/app/helpers/turbo/drive_helper.rb}). 116 | * 117 | * @param string $scroll The scroll behavior. Must be either 'reset' or 'preserve'. 118 | * 119 | * @return string The tag for the specified scroll behavior 120 | * 121 | * @throws \InvalidArgumentException If an invalid scroll behavior is provided 122 | */ 123 | public function turboRefreshScroll(string $scroll = self::REFRESH_SCROLL_RESET): string 124 | { 125 | if (!\in_array($scroll, [self::REFRESH_SCROLL_RESET, self::REFRESH_SCROLL_PRESERVE], true)) { 126 | throw new \InvalidArgumentException(\sprintf('Invalid scroll option "%s".', $scroll)); 127 | } 128 | 129 | return \sprintf('', $scroll); 130 | } 131 | } 132 | -------------------------------------------------------------------------------- /templates/components/Frame.html.twig: -------------------------------------------------------------------------------- 1 | {% props id -%} 2 | 3 | 4 | {%- block content %}{% endblock -%} 5 | 6 | -------------------------------------------------------------------------------- /templates/components/Stream.html.twig: -------------------------------------------------------------------------------- 1 | {% props action -%} 2 | 3 | 4 | -------------------------------------------------------------------------------- /templates/components/Stream/After.html.twig: -------------------------------------------------------------------------------- 1 | {% props target -%} 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /templates/components/Stream/Append.html.twig: -------------------------------------------------------------------------------- 1 | {% props target -%} 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /templates/components/Stream/Before.html.twig: -------------------------------------------------------------------------------- 1 | {% props target -%} 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /templates/components/Stream/Prepend.html.twig: -------------------------------------------------------------------------------- 1 | {% props target -%} 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /templates/components/Stream/Refresh.html.twig: -------------------------------------------------------------------------------- 1 | {% props requestId = null -%} 2 | 3 | 4 | -------------------------------------------------------------------------------- /templates/components/Stream/Remove.html.twig: -------------------------------------------------------------------------------- 1 | {% props target -%} 2 | 3 | 4 | -------------------------------------------------------------------------------- /templates/components/Stream/Replace.html.twig: -------------------------------------------------------------------------------- 1 | {% props target, morph = false -%} 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /templates/components/Stream/Update.html.twig: -------------------------------------------------------------------------------- 1 | {% props target, morph = false -%} 2 | 3 | 4 | 5 | 6 | --------------------------------------------------------------------------------