diff --git a/.github/workflows/test.yaml b/.github/workflows/test.yaml index 4addb1688eb..6f832d55fb8 100644 --- a/.github/workflows/test.yaml +++ b/.github/workflows/test.yaml @@ -110,6 +110,15 @@ jobs: 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: @@ -218,6 +227,14 @@ 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: diff --git a/src/Svelte/.gitattributes b/src/Svelte/.gitattributes new file mode 100644 index 00000000000..025f5ea4446 --- /dev/null +++ b/src/Svelte/.gitattributes @@ -0,0 +1,8 @@ +/.gitattributes export-ignore +/.gitignore export-ignore +/.symfony.bundle.yaml export-ignore +/assets/.gitignore export-ignore +/assets/jest.config.js export-ignore +/assets/test export-ignore +/tests export-ignore +/assets/src/*.ts export-ignore diff --git a/src/Svelte/.gitignore b/src/Svelte/.gitignore new file mode 100644 index 00000000000..1fb4eccb7e2 --- /dev/null +++ b/src/Svelte/.gitignore @@ -0,0 +1,4 @@ +/composer.lock +/phpunit.xml +/vendor/ +/.phpunit.result.cache \ No newline at end of file diff --git a/src/Svelte/.symfony.bundle.yaml b/src/Svelte/.symfony.bundle.yaml new file mode 100644 index 00000000000..6d9a74acb76 --- /dev/null +++ b/src/Svelte/.symfony.bundle.yaml @@ -0,0 +1,3 @@ +branches: ["2.x"] +maintained_branches: ["2.x"] +doc_dir: "doc" diff --git a/src/Svelte/LICENSE b/src/Svelte/LICENSE new file mode 100644 index 00000000000..45c069b323b --- /dev/null +++ b/src/Svelte/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2021 Fabien Potencier + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is furnished +to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. diff --git a/src/Svelte/README.md b/src/Svelte/README.md new file mode 100644 index 00000000000..6be9dbfc556 --- /dev/null +++ b/src/Svelte/README.md @@ -0,0 +1,14 @@ +# Symfony UX Svelte + +Symfony UX Svelte integrates [Svelte](https://svelte.dev/) into Symfony applications. +It provides tools to render Svelte 3 components from Twig. + +**This repository is a READ-ONLY sub-tree split**. See +https://github.com/symfony/ux to create issues or submit pull requests. + +## Resources + +- [Documentation](https://symfony.com/bundles/ux-svelte/current/index.html) +- [Report issues](https://github.com/symfony/ux/issues) and + [send Pull Requests](https://github.com/symfony/ux/pulls) + in the [main Symfony UX repository](https://github.com/symfony/ux) diff --git a/src/Svelte/assets/.gitignore b/src/Svelte/assets/.gitignore new file mode 100644 index 00000000000..2ccbe4656c6 --- /dev/null +++ b/src/Svelte/assets/.gitignore @@ -0,0 +1 @@ +/node_modules/ diff --git a/src/Svelte/assets/dist/register_controller.d.ts b/src/Svelte/assets/dist/register_controller.d.ts new file mode 100644 index 00000000000..dc9fbfeea44 --- /dev/null +++ b/src/Svelte/assets/dist/register_controller.d.ts @@ -0,0 +1,9 @@ +/// +import type { SvelteComponent } from 'svelte'; +declare global { + function resolveSvelteComponent(name: string): typeof SvelteComponent; + interface Window { + resolveSvelteComponent(name: string): typeof SvelteComponent; + } +} +export declare function registerSvelteControllerComponents(context: __WebpackModuleApi.RequireContext): void; diff --git a/src/Svelte/assets/dist/register_controller.js b/src/Svelte/assets/dist/register_controller.js new file mode 100644 index 00000000000..3370945e5c1 --- /dev/null +++ b/src/Svelte/assets/dist/register_controller.js @@ -0,0 +1,16 @@ +function registerSvelteControllerComponents(context) { + const svelteControllers = {}; + const importAllSvelteComponents = (r) => { + r.keys().forEach((key) => (svelteControllers[key] = r(key).default)); + }; + importAllSvelteComponents(context); + window.resolveSvelteComponent = (name) => { + const component = svelteControllers[`./${name}.svelte`]; + if (typeof component === 'undefined') { + throw new Error(`Svelte controller "${name}" does not exist`); + } + return component; + }; +} + +export { registerSvelteControllerComponents }; diff --git a/src/Svelte/assets/dist/render_controller.d.ts b/src/Svelte/assets/dist/render_controller.d.ts new file mode 100644 index 00000000000..a652b0ef261 --- /dev/null +++ b/src/Svelte/assets/dist/render_controller.d.ts @@ -0,0 +1,21 @@ +import { Controller } from '@hotwired/stimulus'; +import { SvelteComponent } from 'svelte'; +export default class extends Controller { + private app; + readonly componentValue: string; + private props; + private intro; + readonly propsValue: Record | null | undefined; + readonly introValue: boolean | undefined; + static values: { + component: StringConstructor; + props: ObjectConstructor; + intro: BooleanConstructor; + }; + connect(): void; + disconnect(): void; + _destroyIfExists(): void; + private dispatchEvent; +} diff --git a/src/Svelte/assets/dist/render_controller.js b/src/Svelte/assets/dist/render_controller.js new file mode 100644 index 00000000000..f5dd9897957 --- /dev/null +++ b/src/Svelte/assets/dist/render_controller.js @@ -0,0 +1,43 @@ +import { Controller } from '@hotwired/stimulus'; + +class default_1 extends Controller { + connect() { + var _a, _b; + this.element.innerHTML = ''; + this.props = (_a = this.propsValue) !== null && _a !== void 0 ? _a : undefined; + this.intro = (_b = this.introValue) !== null && _b !== void 0 ? _b : undefined; + this.dispatchEvent('connect'); + const Component = window.resolveSvelteComponent(this.componentValue); + this._destroyIfExists(); + this.app = new Component({ + target: this.element, + props: this.props, + intro: this.intro, + }); + this.element.root = this.app; + this.dispatchEvent('mount', { + component: Component, + }); + } + disconnect() { + this._destroyIfExists(); + this.dispatchEvent('unmount'); + } + _destroyIfExists() { + if (this.element.root !== undefined) { + this.element.root.$destroy(); + delete this.element.root; + } + } + dispatchEvent(name, payload = {}) { + const detail = Object.assign({ componentName: this.componentValue, props: this.props, intro: this.intro }, payload); + this.dispatch(name, { detail, prefix: 'svelte' }); + } +} +default_1.values = { + component: String, + props: Object, + intro: Boolean, +}; + +export { default_1 as default }; diff --git a/src/Svelte/assets/jest.config.js b/src/Svelte/assets/jest.config.js new file mode 100644 index 00000000000..c19d371124a --- /dev/null +++ b/src/Svelte/assets/jest.config.js @@ -0,0 +1,7 @@ +const { defaults } = require('jest-config'); +const jestConfig = require('../../../jest.config.js'); + +jestConfig.moduleFileExtensions = [...defaults.moduleFileExtensions, 'svelte']; +jestConfig.transform['^.+\\.svelte$'] = ['svelte-jester']; + +module.exports = jestConfig; diff --git a/src/Svelte/assets/package.json b/src/Svelte/assets/package.json new file mode 100644 index 00000000000..5bd32a03a35 --- /dev/null +++ b/src/Svelte/assets/package.json @@ -0,0 +1,27 @@ +{ + "name": "@symfony/ux-svelte", + "description": "Integration of Svelte in Symfony", + "main": "dist/register_controller.js", + "module": "dist/register_controller.js", + "version": "1.0.0", + "license": "MIT", + "symfony": { + "controllers": { + "svelte": { + "main": "dist/render_controller.js", + "fetch": "eager", + "enabled": true + } + } + }, + "peerDependencies": { + "@hotwired/stimulus": "^3.0.0", + "svelte": "^3.0" + }, + "devDependencies": { + "@hotwired/stimulus": "^3.0.0", + "@types/webpack-env": "^1.16", + "svelte": "^3.0", + "svelte-jester": "^2.3" + } +} diff --git a/src/Svelte/assets/src/register_controller.ts b/src/Svelte/assets/src/register_controller.ts new file mode 100644 index 00000000000..c74f23b929f --- /dev/null +++ b/src/Svelte/assets/src/register_controller.ts @@ -0,0 +1,40 @@ +/* + * This file is part of the Symfony package. + * + * (c) Fabien Potencier + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +'use strict'; + +import type { SvelteComponent } from 'svelte'; + +declare global { + function resolveSvelteComponent(name: string): typeof SvelteComponent; + + interface Window { + resolveSvelteComponent(name: string): typeof SvelteComponent; + } +} + +export function registerSvelteControllerComponents(context: __WebpackModuleApi.RequireContext) { + const svelteControllers: { [key: string]: object } = {}; + + const importAllSvelteComponents = (r: __WebpackModuleApi.RequireContext) => { + r.keys().forEach((key) => (svelteControllers[key] = r(key).default)); + }; + + importAllSvelteComponents(context); + + // Expose a global Svelte loader to allow rendering from the Stimulus controller + (window as any).resolveSvelteComponent = (name: string): object => { + const component = svelteControllers[`./${name}.svelte`]; + if (typeof component === 'undefined') { + throw new Error(`Svelte controller "${name}" does not exist`); + } + + return component; + }; +} diff --git a/src/Svelte/assets/src/render_controller.ts b/src/Svelte/assets/src/render_controller.ts new file mode 100644 index 00000000000..2bbff898be0 --- /dev/null +++ b/src/Svelte/assets/src/render_controller.ts @@ -0,0 +1,69 @@ +'use strict'; + +import { Controller } from '@hotwired/stimulus'; +import { SvelteComponent } from 'svelte'; + +export default class extends Controller { + private app: SvelteComponent; + declare readonly componentValue: string; + + private props: Record | undefined; + private intro: boolean | undefined; + + declare readonly propsValue: Record | null | undefined; + declare readonly introValue: boolean | undefined; + + static values = { + component: String, + props: Object, + intro: Boolean, + }; + + connect() { + this.element.innerHTML = ''; + + this.props = this.propsValue ?? undefined; + this.intro = this.introValue ?? undefined; + + this.dispatchEvent('connect'); + + const Component = window.resolveSvelteComponent(this.componentValue); + + this._destroyIfExists(); + + // @see https://svelte.dev/docs#run-time-client-side-component-api-creating-a-component + this.app = new Component({ + target: this.element, + props: this.props, + intro: this.intro, + }); + + this.element.root = this.app; + + this.dispatchEvent('mount', { + component: Component, + }); + } + + disconnect() { + this._destroyIfExists(); + this.dispatchEvent('unmount'); + } + + _destroyIfExists() { + if (this.element.root !== undefined) { + this.element.root.$destroy(); + delete this.element.root; + } + } + + private dispatchEvent(name: string, payload: object = {}) { + const detail = { + componentName: this.componentValue, + props: this.props, + intro: this.intro, + ...payload, + }; + this.dispatch(name, { detail, prefix: 'svelte' }); + } +} diff --git a/src/Svelte/assets/test/fixtures/MyComponent.svelte b/src/Svelte/assets/test/fixtures/MyComponent.svelte new file mode 100644 index 00000000000..adc0689296d --- /dev/null +++ b/src/Svelte/assets/test/fixtures/MyComponent.svelte @@ -0,0 +1,8 @@ + + +
+
Hello {name}
+
\ No newline at end of file diff --git a/src/Svelte/assets/test/register_controller.test.ts b/src/Svelte/assets/test/register_controller.test.ts new file mode 100644 index 00000000000..5608eff2eba --- /dev/null +++ b/src/Svelte/assets/test/register_controller.test.ts @@ -0,0 +1,27 @@ +/* + * This file is part of the Symfony package. + * + * (c) Fabien Potencier + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +'use strict'; + +import {registerSvelteControllerComponents} from '../src/register_controller'; +import MyComponent from './fixtures/MyComponent.svelte'; +import {createRequireContextPolyfill} from './util/require_context_poylfill'; + +require.context = createRequireContextPolyfill(__dirname); + +describe('registerSvelteControllerComponents', () => { + it('registers controllers from require context', () => { + registerSvelteControllerComponents(require.context('./fixtures', true, /\.svelte$/)); + const resolveComponent = (window as any).resolveSvelteComponent; + + expect(resolveComponent).not.toBeUndefined(); + expect(resolveComponent('MyComponent')).toBe(MyComponent); + expect(resolveComponent('MyComponent')).not.toBeUndefined(); + }); +}); diff --git a/src/Svelte/assets/test/render_controller.test.ts b/src/Svelte/assets/test/render_controller.test.ts new file mode 100644 index 00000000000..4a4957118d8 --- /dev/null +++ b/src/Svelte/assets/test/render_controller.test.ts @@ -0,0 +1,109 @@ +/* + * This file is part of the Symfony package. + * + * (c) Fabien Potencier + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +'use strict'; + +import { Application, Controller } from '@hotwired/stimulus'; +import { getByTestId, waitFor } from '@testing-library/dom'; +import { clearDOM, mountDOM } from '@symfony/stimulus-testing'; +import SvelteController from '../src/render_controller'; +import MyComponent from './fixtures/MyComponent.svelte'; + +// Controller used to check the actual controller was properly booted +class CheckController extends Controller { + connect() { + this.element.addEventListener('svelte:connect', () => { + this.element.classList.add('connected'); + }); + + this.element.addEventListener('svelte:mount', () => { + this.element.classList.add('mounted'); + }); + } +} + +const startStimulus = () => { + const application = Application.start(); + application.register('check', CheckController); + application.register('svelte', SvelteController); + + return application; +}; + +(window as any).resolveSvelteComponent = () => { + return MyComponent; +}; + +describe('SvelteController', () => { + let application: Application; + + afterEach(() => { + clearDOM(); + application.stop(); + }); + + it('connect with props', async () => { + const container = mountDOM(` +
+ `); + + const component = getByTestId(container, 'component'); + expect(component).not.toHaveClass('connected'); + expect(component).not.toHaveClass('mounted'); + + application = startStimulus(); + + await waitFor(() => expect(component).toHaveClass('connected')); + await waitFor(() => expect(component).toHaveClass('mounted')); + await waitFor(() => expect(component.innerHTML).toEqual('
Hello Symfony
')); + }); + + it('connect without props', async () => { + + const container = mountDOM(` +
+ `); + + const component = getByTestId(container, 'component'); + expect(component).not.toHaveClass('connected'); + expect(component).not.toHaveClass('mounted'); + + application = startStimulus(); + + await waitFor(() => expect(component).toHaveClass('connected')); + await waitFor(() => expect(component).toHaveClass('mounted')); + await waitFor(() => expect(component.innerHTML).toEqual('
Hello without props
')); + }); + + it('connect with props and intro', async () => { + const container = mountDOM(` +
+ `); + + const component = getByTestId(container, 'component'); + expect(component).not.toHaveClass('connected'); + expect(component).not.toHaveClass('mounted'); + + application = startStimulus(); + + await waitFor(() => expect(component).toHaveClass('connected')); + await waitFor(() => expect(component).toHaveClass('mounted')); + expect(component.innerHTML).toContain('style="animation:'); + await waitFor(() => expect(component.innerHTML.trim()).toEqual('
Hello Symfony with transition
')); + }); +}); diff --git a/src/Svelte/assets/test/util/require_context_poylfill.ts b/src/Svelte/assets/test/util/require_context_poylfill.ts new file mode 100644 index 00000000000..376746d8f08 --- /dev/null +++ b/src/Svelte/assets/test/util/require_context_poylfill.ts @@ -0,0 +1,39 @@ +import fs from 'fs'; +import path from 'path'; + +export function createRequireContextPolyfill (rootDir: string) { + return (base: string, deep: boolean, filter: RegExp): __WebpackModuleApi.RequireContext => { + const basePrefix = path.resolve(rootDir, base); + const files: { [key: string]: boolean } = {}; + + function readDirectory(directory: string) { + fs.readdirSync(directory).forEach((file: string) => { + const fullPath = path.resolve(directory, file); + + if (fs.statSync(fullPath).isDirectory()) { + if (deep) { + readDirectory(fullPath); + } + + return; + } + + if (!filter.test(fullPath)) { + return; + } + + files[fullPath.replace(basePrefix, '.')] = true; + }); + } + + readDirectory(path.resolve(rootDir, base)); + + function Module(file: string) { + return require(basePrefix + '/' + file); + } + + Module.keys = () => Object.keys(files); + + return (Module as __WebpackModuleApi.RequireContext); + }; +} diff --git a/src/Svelte/composer.json b/src/Svelte/composer.json new file mode 100644 index 00000000000..fc7c11937db --- /dev/null +++ b/src/Svelte/composer.json @@ -0,0 +1,50 @@ +{ + "name": "symfony/ux-svelte", + "type": "symfony-bundle", + "description": "Integration of Svelte in Symfony", + "keywords": [ + "symfony-ux" + ], + "homepage": "https://symfony.com", + "license": "MIT", + "authors": [ + { + "name": "Titouan Galopin", + "email": "galopintitouan@gmail.com" + }, + { + "name": "Thomas Choquet", + "email": "thomas.choquet.pro@gmail.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "autoload": { + "psr-4": { + "Symfony\\UX\\Svelte\\": "src/" + } + }, + "autoload-dev": { + "psr-4": { + "Symfony\\UX\\Svelte\\Tests\\": "tests/" + } + }, + "require": { + "symfony/webpack-encore-bundle": "^1.15" + }, + "require-dev": { + "symfony/framework-bundle": "^5.4|^6.2", + "symfony/phpunit-bridge": "^5.4|^6.2", + "symfony/twig-bundle": "^5.4|^6.2", + "symfony/var-dumper": "^5.4|^6.2" + }, + "extra": { + "thanks": { + "name": "symfony/ux", + "url": "https://github.com/symfony/ux" + } + }, + "minimum-stability": "dev" +} diff --git a/src/Svelte/doc/index.rst b/src/Svelte/doc/index.rst new file mode 100644 index 00000000000..c29b6e3c2ed --- /dev/null +++ b/src/Svelte/doc/index.rst @@ -0,0 +1,130 @@ +Symfony UX Svelte +================= + +Symfony UX Svelte is a Symfony bundle integrating `Svelte`_ in +Symfony applications. It is part of `the Symfony UX initiative`_. + +Svelte is a JavaScript framework for building user interfaces. +Symfony UX Svelte provides tools to render Svelte components from Twig, +handling rendering and data transfers. + +Symfony UX Svelte supports Svelte 3 only. + +Installation +------------ + +Before you start, make sure you have `Symfony UX configured in your app`_. + +Then install the bundle using Composer and Symfony Flex: + +.. code-block:: terminal + + $ composer require symfony/ux-svelte + + # Don't forget to install the JavaScript dependencies as well and compile + $ npm install --force + $ npm run watch + + # or use yarn + $ yarn install --force + $ yarn watch + +You also need to add the following lines at the end to your ``assets/app.js`` file: + +.. code-block:: javascript + + // assets/app.js + import { registerSvelteControllerComponents } from '@symfony/ux-svelte'; + + // Registers Svelte controller components to allow loading them from Twig + // + // Svelte controller components are components that are meant to be rendered + // from Twig. These component can then rely on other components that won't be + // called directly from Twig. + // + // By putting only controller components in `svelte/controllers`, you ensure that + // internal components won't be automatically included in your JS built file if + // they are not necessary. + registerSvelteControllerComponents(require.context('./svelte/controllers', true, /\.svelte$/)); + +To make sure Svelte components can be loaded by Webpack Encore, you need to add and configure +the `svelte-loader`_ library in your project : + +.. code-block:: terminal + + $ npm install svelte svelte-loader --save-dev + + # or use yarn + $ yarn add svelte svelte-loader --dev + +Enable it in your ``webpack.config.js`` file : + +.. code-block:: javascript + + // webpack.config.js + Encore + // ... + .enableSvelte() + ; + +Usage +----- + +UX Svelte works by using a system of **Svelte controller components**: Svelte components that +are registered using ``registerSvelteControllerComponents()`` and that are meant to be rendered +from Twig. + +When using the ``registerSvelteControllerComponents()`` configuration shown previously, all +Svelte components located in the directory ``assets/svelte/controllers`` are registered as +Svelte controller components. + +You can then render any Svelte controller component in Twig using the ``svelte_component()`` function: + +.. code-block:: javascript + + // assets/svelte/controllers/MyComponent.svelte + + +
Hello {name}
+ + +.. code-block:: twig + + {# templates/home.html.twig #} + +
+ +If your Svelte component has a transition that you want to play on initial render, you can use +the third argument ``intro`` of the ``svelte_component()`` function like you would do with the +Svelte client-side component API: + +.. code-block:: javascript + + // assets/svelte/controllers/MyAnimatedComponent.svelte + + +
Hello {name}
+ + +.. code-block:: twig + + {# templates/home.html.twig #} + +
+ +Backward Compatibility promise +------------------------------ + +This bundle aims at following the same Backward Compatibility promise as +the Symfony framework: +https://symfony.com/doc/current/contributing/code/bc.html + +.. _`Svelte`: https://svelte.dev/ +.. _`svelte-loader`: https://github.com/sveltejs/svelte-loader/blob/master/README.md +.. _`the Symfony UX initiative`: https://symfony.com/ux +.. _`Symfony UX configured in your app`: https://symfony.com/doc/current/frontend/ux.html diff --git a/src/Svelte/phpunit.xml.dist b/src/Svelte/phpunit.xml.dist new file mode 100644 index 00000000000..27423a1c9f4 --- /dev/null +++ b/src/Svelte/phpunit.xml.dist @@ -0,0 +1,34 @@ + + + + + + + + + + + + + + + ./tests/ + + + + + + ./src + + + + + + + diff --git a/src/Svelte/src/DependencyInjection/SvelteExtension.php b/src/Svelte/src/DependencyInjection/SvelteExtension.php new file mode 100644 index 00000000000..38ac1f8608c --- /dev/null +++ b/src/Svelte/src/DependencyInjection/SvelteExtension.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\Svelte\DependencyInjection; + +use Symfony\Component\DependencyInjection\ContainerBuilder; +use Symfony\Component\DependencyInjection\Definition; +use Symfony\Component\DependencyInjection\Reference; +use Symfony\Component\HttpKernel\DependencyInjection\Extension; +use Symfony\UX\Svelte\Twig\SvelteComponentExtension; + +/** + * @author Titouan Galopin + * @author Thomas Choquet + * + * @internal + */ +class SvelteExtension extends Extension +{ + 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')) + ->addTag('twig.extension') + ->setPublic(false) + ; + } +} diff --git a/src/Svelte/src/SvelteBundle.php b/src/Svelte/src/SvelteBundle.php new file mode 100644 index 00000000000..c12bbbc1f92 --- /dev/null +++ b/src/Svelte/src/SvelteBundle.php @@ -0,0 +1,24 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\UX\Svelte; + +use Symfony\Component\HttpKernel\Bundle\Bundle; + +/** + * @author Titouan Galopin + * @author Thomas Choquet + * + * @final + */ +class SvelteBundle extends Bundle +{ +} diff --git a/src/Svelte/src/Twig/SvelteComponentExtension.php b/src/Svelte/src/Twig/SvelteComponentExtension.php new file mode 100644 index 00000000000..50a1725fddc --- /dev/null +++ b/src/Svelte/src/Twig/SvelteComponentExtension.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\Svelte\Twig; + +use Symfony\WebpackEncoreBundle\Twig\StimulusTwigExtension; +use Twig\Environment; +use Twig\Extension\AbstractExtension; +use Twig\TwigFunction; + +/** + * @author Titouan Galopin + * @author Thomas Choquet + * + * @final + */ +class SvelteComponentExtension extends AbstractExtension +{ + private $stimulusExtension; + + public function __construct(StimulusTwigExtension $stimulusExtension) + { + $this->stimulusExtension = $stimulusExtension; + } + + public function getFunctions(): array + { + return [ + new TwigFunction('svelte_component', [$this, 'renderSvelteComponent'], ['needs_environment' => true, 'is_safe' => ['html_attr']]), + ]; + } + + public function renderSvelteComponent(Environment $env, string $componentName, array $props = [], bool $intro = false): string + { + $params = ['component' => $componentName]; + if ($props) { + $params['props'] = $props; + } + if ($intro) { + $params['intro'] = true; + } + + return $this->stimulusExtension->renderStimulusController($env, '@symfony/ux-svelte/svelte', $params); + } +} diff --git a/src/Svelte/tests/Kernel/AppKernelTrait.php b/src/Svelte/tests/Kernel/AppKernelTrait.php new file mode 100644 index 00000000000..943efbda9f9 --- /dev/null +++ b/src/Svelte/tests/Kernel/AppKernelTrait.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\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 new file mode 100644 index 00000000000..7aabc31a638 --- /dev/null +++ b/src/Svelte/tests/Kernel/FrameworkAppKernel.php @@ -0,0 +1,43 @@ + + * + * 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 new file mode 100644 index 00000000000..5be9594b1e3 --- /dev/null +++ b/src/Svelte/tests/Kernel/TwigAppKernel.php @@ -0,0 +1,48 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\UX\Svelte\Tests\Kernel; + +use Symfony\Bundle\FrameworkBundle\FrameworkBundle; +use Symfony\Bundle\TwigBundle\TwigBundle; +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 TwigAppKernel extends Kernel +{ + use AppKernelTrait; + + public function registerBundles(): iterable + { + return [new WebpackEncoreBundle(), 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); + }); + } +} diff --git a/src/Svelte/tests/SvelteBundleTest.php b/src/Svelte/tests/SvelteBundleTest.php new file mode 100644 index 00000000000..7e2eeb39d9e --- /dev/null +++ b/src/Svelte/tests/SvelteBundleTest.php @@ -0,0 +1,41 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +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; + +/** + * @author Titouan Galopin + * @author Thomas Choquet + * + * @internal + */ +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) + { + $kernel->boot(); + $this->assertArrayHasKey('SvelteBundle', $kernel->getBundles()); + } +} diff --git a/src/Svelte/tests/Twig/SvelteComponentExtensionTest.php b/src/Svelte/tests/Twig/SvelteComponentExtensionTest.php new file mode 100644 index 00000000000..53486f9650a --- /dev/null +++ b/src/Svelte/tests/Twig/SvelteComponentExtensionTest.php @@ -0,0 +1,82 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\UX\Svelte\Tests\Twig; + +use PHPUnit\Framework\TestCase; +use Symfony\UX\Svelte\Tests\Kernel\TwigAppKernel; +use Symfony\UX\Svelte\Twig\SvelteComponentExtension; + +/** + * @author Titouan Galopin + * @author Thomas Choquet + * + * @internal + */ +class SvelteComponentExtensionTest extends TestCase +{ + public function testRenderComponent() + { + $kernel = new TwigAppKernel('test', true); + $kernel->boot(); + + /** @var SvelteComponentExtension $extension */ + $extension = $kernel->getContainer()->get('test.twig.extension.svelte'); + + $rendered = $extension->renderSvelteComponent( + $kernel->getContainer()->get('test.twig'), + 'SubDir/MyComponent', + ['fullName' => 'Titouan Galopin'] + ); + + $this->assertSame( + 'data-controller="symfony--ux-svelte--svelte" data-symfony--ux-svelte--svelte-component-value="SubDir/MyComponent" data-symfony--ux-svelte--svelte-props-value="{"fullName":"Titouan Galopin"}"', + $rendered + ); + } + + public function testRenderComponentWithoutProps() + { + $kernel = new TwigAppKernel('test', true); + $kernel->boot(); + + /** @var SvelteComponentExtension $extension */ + $extension = $kernel->getContainer()->get('test.twig.extension.svelte'); + + $rendered = $extension->renderSvelteComponent($kernel->getContainer()->get('test.twig'), 'SubDir/MyComponent'); + + $this->assertSame( + 'data-controller="symfony--ux-svelte--svelte" data-symfony--ux-svelte--svelte-component-value="SubDir/MyComponent"', + $rendered + ); + } + + public function testRenderComponentWithIntro() + { + $kernel = new TwigAppKernel('test', true); + $kernel->boot(); + + /** @var SvelteComponentExtension $extension */ + $extension = $kernel->getContainer()->get('test.twig.extension.svelte'); + + $rendered = $extension->renderSvelteComponent( + $kernel->getContainer()->get('test.twig'), + 'SubDir/MyComponent', + ['fullName' => 'Titouan Galopin'], + true + ); + + $this->assertSame( + 'data-controller="symfony--ux-svelte--svelte" data-symfony--ux-svelte--svelte-component-value="SubDir/MyComponent" data-symfony--ux-svelte--svelte-props-value="{"fullName":"Titouan Galopin"}" data-symfony--ux-svelte--svelte-intro-value="true"', + $rendered + ); + } +}