diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..d49b3ec --- /dev/null +++ b/.gitattributes @@ -0,0 +1,9 @@ +/.gitattributes export-ignore +/.gitignore export-ignore +/.symfony.bundle.yaml export-ignore +/assets/src export-ignore +/assets/test export-ignore +/assets/vitest.config.js export-ignore +/doc export-ignore +/phpunit.xml.dist export-ignore +/tests export-ignore diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..2cc9f02 --- /dev/null +++ b/.gitignore @@ -0,0 +1,5 @@ +/assets/node_modules/ +/vendor/ +/composer.lock +/phpunit.xml +/.phpunit.result.cache diff --git a/.symfony.bundle.yaml b/.symfony.bundle.yaml new file mode 100644 index 0000000..6d9a74a --- /dev/null +++ b/.symfony.bundle.yaml @@ -0,0 +1,3 @@ +branches: ["2.x"] +maintained_branches: ["2.x"] +doc_dir: "doc" diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..ef7204e --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,23 @@ +# CHANGELOG + +## 2.13.2 + +- Revert "Change JavaScript package to `type: module`" + +## 2.13.0 + +- Add support for Svelte 4. +- Add Symfony 7 support. +- Change JavaScript package to `type: module` + +## 2.9.0 + +- Add support for symfony/asset-mapper + +- Replace `symfony/webpack-encore-bundle` by `symfony/stimulus-bundle` in dependencies + +- Minimum PHP version is now 8.1 + +## 2.8.0 + +- Introduce the package diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..99c6bdf --- /dev/null +++ b/LICENSE @@ -0,0 +1,19 @@ +Copyright (c) 2021-present 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/README.md b/README.md new file mode 100644 index 0000000..f548e34 --- /dev/null +++ b/README.md @@ -0,0 +1,19 @@ +# Fork by ChqThomas + +https://github.com/symfony/ux/pull/2288 + +# 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/assets/dist/components.d.ts b/assets/dist/components.d.ts new file mode 100644 index 0000000..25fc4a3 --- /dev/null +++ b/assets/dist/components.d.ts @@ -0,0 +1,5 @@ +import type { SvelteComponent } from 'svelte'; +export interface ComponentCollection { + [key: string]: SvelteComponent; +} +export declare const components: ComponentCollection; diff --git a/assets/dist/components.js b/assets/dist/components.js new file mode 100644 index 0000000..2ea8342 --- /dev/null +++ b/assets/dist/components.js @@ -0,0 +1,3 @@ +const components = {}; + +export { components }; diff --git a/assets/dist/loader.d.ts b/assets/dist/loader.d.ts new file mode 100644 index 0000000..747c715 --- /dev/null +++ b/assets/dist/loader.d.ts @@ -0,0 +1,9 @@ +import type { SvelteComponent } from 'svelte'; +import { type ComponentCollection } from './components.js'; +declare global { + function resolveSvelteComponent(name: string): typeof SvelteComponent; + interface Window { + resolveSvelteComponent(name: string): typeof SvelteComponent; + } +} +export declare function registerSvelteControllerComponents(svelteComponents?: ComponentCollection): void; diff --git a/assets/dist/loader.js b/assets/dist/loader.js new file mode 100644 index 0000000..c73b3f2 --- /dev/null +++ b/assets/dist/loader.js @@ -0,0 +1,14 @@ +import { components } from './components.js'; + +function registerSvelteControllerComponents(svelteComponents = components) { + window.resolveSvelteComponent = (name) => { + const component = svelteComponents[name]; + if (typeof component === 'undefined') { + const possibleValues = Object.keys(svelteComponents).length > 0 ? Object.keys(svelteComponents).join(', ') : 'none'; + throw new Error(`Svelte controller "${name}" does not exist. Possible values: ${possibleValues}`); + } + return component; + }; +} + +export { registerSvelteControllerComponents }; diff --git a/assets/dist/register_controller.d.ts b/assets/dist/register_controller.d.ts new file mode 100644 index 0000000..6adf6b1 --- /dev/null +++ b/assets/dist/register_controller.d.ts @@ -0,0 +1,8 @@ +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/assets/dist/register_controller.js b/assets/dist/register_controller.js new file mode 100644 index 0000000..23abca4 --- /dev/null +++ b/assets/dist/register_controller.js @@ -0,0 +1,18 @@ +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/assets/dist/render_controller.d.ts b/assets/dist/render_controller.d.ts new file mode 100644 index 0000000..ae4130b --- /dev/null +++ b/assets/dist/render_controller.d.ts @@ -0,0 +1,21 @@ +import { Controller } from '@hotwired/stimulus'; +import type { 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/assets/dist/render_controller.js b/assets/dist/render_controller.js new file mode 100644 index 0000000..4eba984 --- /dev/null +++ b/assets/dist/render_controller.js @@ -0,0 +1,47 @@ +import { Controller } from '@hotwired/stimulus'; + +class default_1 extends Controller { + 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(); + 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 = { + 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/assets/package.json b/assets/package.json new file mode 100644 index 0000000..95eabf5 --- /dev/null +++ b/assets/package.json @@ -0,0 +1,31 @@ +{ + "name": "@symfony/ux-svelte", + "description": "Integration of Svelte in Symfony", + "main": "dist/register_controller.js", + "version": "1.0.0", + "license": "MIT", + "symfony": { + "controllers": { + "svelte": { + "main": "dist/render_controller.js", + "fetch": "eager", + "enabled": true + } + }, + "importmap": { + "@hotwired/stimulus": "^3.0.0", + "svelte/internal": "^3.0", + "@symfony/ux-svelte": "path:%PACKAGE%/dist/loader.js" + } + }, + "peerDependencies": { + "@hotwired/stimulus": "^3.0.0", + "svelte": "^3.0 || ^4.0" + }, + "devDependencies": { + "@hotwired/stimulus": "^3.0.0", + "@sveltejs/vite-plugin-svelte": "^2.4.6", + "@types/webpack-env": "^1.16", + "svelte": "^3.0 || ^4.0" + } +} diff --git a/assets/src/components.ts b/assets/src/components.ts new file mode 100644 index 0000000..c85f225 --- /dev/null +++ b/assets/src/components.ts @@ -0,0 +1,17 @@ +/* + * 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. + */ + +// This file is dynamically rewritten by ux-svelte + AssetMapper. +import type { SvelteComponent } from 'svelte'; + +export interface ComponentCollection { + [key: string]: SvelteComponent; +} + +export const components: ComponentCollection = {}; diff --git a/assets/src/loader.ts b/assets/src/loader.ts new file mode 100644 index 0000000..81282ff --- /dev/null +++ b/assets/src/loader.ts @@ -0,0 +1,34 @@ +/* + * 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. + */ + +import type { SvelteComponent } from 'svelte'; +import { type ComponentCollection, components } from './components.js'; + +declare global { + function resolveSvelteComponent(name: string): typeof SvelteComponent; + + interface Window { + resolveSvelteComponent(name: string): typeof SvelteComponent; + } +} + +export function registerSvelteControllerComponents(svelteComponents: ComponentCollection = components): void { + // Expose a global Svelte loader to allow rendering from the Stimulus controller + (window as any).resolveSvelteComponent = (name: string): SvelteComponent => { + const component = svelteComponents[name]; + if (typeof component === 'undefined') { + const possibleValues: string = + Object.keys(svelteComponents).length > 0 ? Object.keys(svelteComponents).join(', ') : 'none'; + + throw new Error(`Svelte controller "${name}" does not exist. Possible values: ${possibleValues}`); + } + + return component; + }; +} diff --git a/assets/src/register_controller.ts b/assets/src/register_controller.ts new file mode 100644 index 0000000..737b495 --- /dev/null +++ b/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. + */ + +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/assets/src/render_controller.ts b/assets/src/render_controller.ts new file mode 100644 index 0000000..70852eb --- /dev/null +++ b/assets/src/render_controller.ts @@ -0,0 +1,67 @@ +import { Controller } from '@hotwired/stimulus'; +import type { 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/assets/test/fixtures/MyComponent.svelte b/assets/test/fixtures/MyComponent.svelte new file mode 100644 index 0000000..c5f9d4e --- /dev/null +++ b/assets/test/fixtures/MyComponent.svelte @@ -0,0 +1,8 @@ + + +
+
Hello {name}
+
\ No newline at end of file diff --git a/assets/test/register_controller.test.ts b/assets/test/register_controller.test.ts new file mode 100644 index 0000000..35eb6e3 --- /dev/null +++ b/assets/test/register_controller.test.ts @@ -0,0 +1,36 @@ +/* + * 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. + */ + +import { registerSvelteControllerComponents } from '../src/register_controller'; +import MyComponent from './fixtures/MyComponent.svelte'; +import RequireContext = __WebpackModuleApi.RequireContext; + +const createFakeFixturesContext = (): RequireContext => { + const files: any = { + './MyComponent.svelte': { default: MyComponent }, + }; + + const context = (id: string): any => files[id]; + context.keys = () => Object.keys(files); + context.resolve = (id: string) => id; + context.id = './fixtures'; + + return context; +}; + +describe('registerSvelteControllerComponents', () => { + it('registers controllers from require context', () => { + registerSvelteControllerComponents(createFakeFixturesContext()); + const resolveComponent = (window as any).resolveSvelteComponent; + + expect(resolveComponent).not.toBeUndefined(); + expect(resolveComponent('MyComponent')).toBe(MyComponent); + expect(resolveComponent('MyComponent')).not.toBeUndefined(); + }); +}); diff --git a/assets/test/render_controller.test.ts b/assets/test/render_controller.test.ts new file mode 100644 index 0000000..b2d3fbe --- /dev/null +++ b/assets/test/render_controller.test.ts @@ -0,0 +1,106 @@ +/* + * 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. + */ + +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).toContain('
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).toContain('
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()).toContain('
Hello Symfony with transition
')); + }); +}); diff --git a/assets/vitest.config.mjs b/assets/vitest.config.mjs new file mode 100644 index 0000000..2400e2a --- /dev/null +++ b/assets/vitest.config.mjs @@ -0,0 +1,11 @@ +// vitest.config.ts +import { defineConfig, mergeConfig } from 'vitest/config'; +import { svelte } from '@sveltejs/vite-plugin-svelte'; +import configShared from '../../../vitest.config.mjs' + +export default mergeConfig( + configShared, + defineConfig({ + plugins: [svelte()], + }) +); diff --git a/composer.json b/composer.json new file mode 100644 index 0000000..8d19662 --- /dev/null +++ b/composer.json @@ -0,0 +1,53 @@ +{ + "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": { + "php": ">=8.1", + "symfony/stimulus-bundle": "^2.9.1" + }, + "require-dev": { + "symfony/asset-mapper": "^6.3|^7.0", + "symfony/finder": "^5.4|^6.2|^7.0", + "symfony/framework-bundle": "^5.4|^6.2|^7.0", + "symfony/phpunit-bridge": "^5.4|^6.2|^7.0", + "symfony/twig-bundle": "^5.4|^6.2|^7.0", + "symfony/var-dumper": "^5.4|^6.2|^7.0" + }, + "extra": { + "thanks": { + "name": "symfony/ux", + "url": "https://github.com/symfony/ux" + } + }, + "minimum-stability": "dev" +} diff --git a/doc/index.rst b/doc/index.rst new file mode 100644 index 0000000..3dc0a92 --- /dev/null +++ b/doc/index.rst @@ -0,0 +1,164 @@ +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 and Svelte 4. + +Installation +------------ + +.. note:: + + This package works best with WebpackEncore. To use it with AssetMapper, see + :ref:`Using with AssetMapper `. + +.. caution:: + + Before you start, make sure you have `StimulusBundle configured in your app`_. + +Install the bundle using Composer and Symfony Flex: + +.. code-block:: terminal + + $ composer require symfony/ux-svelte + + $ npm install --force + $ npm run watch + + # or use yarn + $ yarn install --force + $ yarn watch + +The Flex recipe will automatically set things up for you, like adding +``.enableSvelte()`` to your ``webpack.config.js`` file and adding code +to load your Svelte components inside ``assets/app.js``. + +Next, install a package to help Svelte: + +.. code-block:: terminal + + $ npm install svelte-loader --save-dev + + # or use yarn + $ yarn add svelte-loader --dev + +That's it! Any files inside ``assets/svelte/controllers/`` can now be rendered as +Svelte components. + +If you are using Svelte 4, you will have to add ``browser``, ``import`` and ``svelte`` +to the ``conditionNames`` array. This is necessary as per `the Svelte 4 migration guide`_ +for bundlers such as webpack, to ensure that lifecycle callbacks are internally invoked. + +To modify the ``conditionNames`` array, append the following changes to the bottom +of your ``webpack.config.js`` file: + +.. code-block:: diff + + // webpack.config.js + - module.exports = Encore.getWebpackConfig(); + + const config = Encore.getWebpackConfig(); + + config.resolve.conditionNames = ['browser', 'import', 'svelte']; + + module.exports = config; + +Usage +----- + +The Flex recipe will have already added the ``registerSvelteControllerComponents()`` +code to your ``assets/app.js`` file: + +.. code-block:: javascript + + // assets/app.js + import { registerSvelteControllerComponents } from '@symfony/ux-svelte'; + + registerSvelteControllerComponents(require.context('./svelte/controllers', true, /\.svelte$/)); + +This will load all Svelte components located in the ``assets/svelte/controllers`` +directory. These are known as **Svelte controller components**: top-level +components that are meant to be rendered from Twig. + +You can render any Svelte controller component in Twig using the ``svelte_component()``. + +For example: + +.. code-block:: javascript + + // assets/svelte/controllers/Hello.svelte + + +
Hello {name}
+ + +.. code-block:: html+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:: html+twig + + {# templates/home.html.twig #} +
+ +.. _using-with-asset-mapper: + +Using with AssetMapper +---------------------- + +Because the ``.svelte`` file format isn't pure JavaScript, using this library with +AssetMapper requires some extra steps. + +#. Compile your ``.svelte`` files to pure JavaScript files. This can be done by + using the ``svelte/compiler`` library, but is a bit of a non-standard process. + For an example, see https://github.com/symfony/ux/blob/2.x/ux.symfony.com/bin/compile_svelte.js. + +#. Point this library at the "built" controllers directory that contains the final + JavaScript files: + +.. code-block:: yaml + + # config/packages/svelte.yaml + svelte: + controllers_path: '%kernel.project_dir%/assets/build/svelte/controllers' + +Also, inside of your ``.svelte`` files, when importing another component, use the +``.js`` extension: + +.. code-block:: javascript + + // use PackageList.js even though the file is named PackageList.svelte + import PackageList from '../components/PackageList.js'; + +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/ +.. _`the Symfony UX initiative`: https://ux.symfony.com/ +.. _StimulusBundle configured in your app: https://symfony.com/bundles/StimulusBundle/current/index.html +.. _the Svelte 4 migration guide: https://svelte.dev/docs/v4-migration-guide#browser-conditions-for-bundlers diff --git a/phpunit.xml.dist b/phpunit.xml.dist new file mode 100644 index 0000000..e02a3dc --- /dev/null +++ b/phpunit.xml.dist @@ -0,0 +1,35 @@ + + + + + + + + + + + + + + + + ./tests/ + + + + + + ./src + + + + + + + diff --git a/src/AssetMapper/SvelteControllerLoaderAssetCompiler.php b/src/AssetMapper/SvelteControllerLoaderAssetCompiler.php new file mode 100644 index 0000000..20ac809 --- /dev/null +++ b/src/AssetMapper/SvelteControllerLoaderAssetCompiler.php @@ -0,0 +1,106 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\UX\Svelte\AssetMapper; + +use Symfony\Component\AssetMapper\AssetDependency; +use Symfony\Component\AssetMapper\AssetMapperInterface; +use Symfony\Component\AssetMapper\Compiler\AssetCompilerInterface; +use Symfony\Component\AssetMapper\MappedAsset; +use Symfony\Component\Filesystem\Path; +use Symfony\Component\Finder\Finder; + +/** + * Compiles the components.js file to dynamically import the Svelte controller components. + * + * @author Ryan Weaver + */ +class SvelteControllerLoaderAssetCompiler implements AssetCompilerInterface +{ + public function __construct( + private string $controllerPath, + private array $nameGlobs, + ) { + } + + public function supports(MappedAsset $asset): bool + { + return $asset->sourcePath === realpath(__DIR__.'/../../assets/dist/components.js'); + } + + public function compile(string $content, MappedAsset $asset, AssetMapperInterface $assetMapper): string + { + $importLines = []; + $componentParts = []; + foreach ($this->findControllerAssets($assetMapper) as $name => $mappedAsset) { + // @legacy: backwards compatibility with Symfony 6.3 + if (class_exists(AssetDependency::class)) { + $controllerPublicPath = $mappedAsset->publicPathWithoutDigest; + $loaderPublicPath = $asset->publicPathWithoutDigest; + $relativeImportPath = Path::makeRelative($controllerPublicPath, \dirname($loaderPublicPath)); + } else { + $relativeImportPath = Path::makeRelative($mappedAsset->sourcePath, \dirname($asset->sourcePath)); + } + + $controllerNameForVariable = \sprintf('component_%s', \count($componentParts)); + + $importLines[] = \sprintf( + "import %s from '%s';", + $controllerNameForVariable, + $relativeImportPath + ); + $componentParts[] = \sprintf('"%s": %s', $name, $controllerNameForVariable); + } + + $importCode = implode("\n", $importLines); + $componentsJson = \sprintf('{%s}', implode(', ', $componentParts)); + + return <<controllerPath)) { + return []; + } + + $finder = new Finder(); + $finder->in($this->controllerPath) + ->files() + ->name($this->nameGlobs) + ; + $assets = []; + foreach ($finder as $file) { + $asset = $assetMapper->getAssetFromSourcePath($file->getRealPath()); + + if (null === $asset) { + throw new \LogicException(\sprintf('Could not find an asset mapper path for the Svelte controller file "%s".', $file->getRealPath())); + } + + $name = $file->getRelativePathname(); + $name = substr($name, 0, -\strlen($file->getExtension()) - 1); + + $assets[$name] = $asset; + } + + return $assets; + } +} diff --git a/src/DependencyInjection/SvelteExtension.php b/src/DependencyInjection/SvelteExtension.php new file mode 100644 index 0000000..1b4af03 --- /dev/null +++ b/src/DependencyInjection/SvelteExtension.php @@ -0,0 +1,112 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\UX\Svelte\DependencyInjection; + +use Symfony\Component\AssetMapper\AssetMapperInterface; +use Symfony\Component\Config\Definition\Builder\ArrayNodeDefinition; +use Symfony\Component\Config\Definition\Builder\TreeBuilder; +use Symfony\Component\Config\Definition\ConfigurationInterface; +use Symfony\Component\DependencyInjection\ContainerBuilder; +use Symfony\Component\DependencyInjection\Definition; +use Symfony\Component\DependencyInjection\Extension\PrependExtensionInterface; +use Symfony\Component\DependencyInjection\Reference; +use Symfony\Component\HttpKernel\DependencyInjection\Extension; +use Symfony\UX\Svelte\AssetMapper\SvelteControllerLoaderAssetCompiler; +use Symfony\UX\Svelte\Twig\SvelteComponentExtension; + +/** + * @author Titouan Galopin + * @author Thomas Choquet + * + * @internal + */ +class SvelteExtension extends Extension implements PrependExtensionInterface, ConfigurationInterface +{ + public function load(array $configs, ContainerBuilder $container) + { + $configuration = $this->getConfiguration($configs, $container); + $config = $this->processConfiguration($configuration, $configs); + + $container + ->setDefinition('twig.extension.svelte', new Definition(SvelteComponentExtension::class)) + ->setArgument(0, new Reference('stimulus.helper')) + ->addTag('twig.extension') + ->setPublic(false) + ; + + $container->setDefinition('svelte.asset_mapper.svelte_controller_loader_compiler', new Definition(SvelteControllerLoaderAssetCompiler::class)) + ->setArguments([ + $config['controllers_path'], + $config['name_glob'], + ]) + // run before the core JavaScript compiler + ->addTag('asset_mapper.compiler', ['priority' => 100]); + } + + public function prepend(ContainerBuilder $container) + { + if (!$this->isAssetMapperAvailable($container)) { + return; + } + + $container->prependExtensionConfig('framework', [ + 'asset_mapper' => [ + 'paths' => [ + __DIR__.'/../../assets/dist' => '@symfony/ux-svelte', + ], + ], + ]); + } + + public function getConfiguration(array $config, ContainerBuilder $container): ConfigurationInterface + { + return $this; + } + + public function getConfigTreeBuilder(): TreeBuilder + { + $treeBuilder = new TreeBuilder('svelte'); + $rootNode = $treeBuilder->getRootNode(); + \assert($rootNode instanceof ArrayNodeDefinition); + + $rootNode + ->children() + ->scalarNode('controllers_path') + ->info('The path to the directory where Svelte controller components are stored - relevant only when using symfony/asset-mapper.') + ->defaultValue('%kernel.project_dir%/assets/svelte/controllers') + ->end() + ->arrayNode('name_glob') + ->info('The glob patterns to use to find Svelte controller components inside of controllers_path') + // find .js (already compiled) or .svelte, in case the user will have an asset compiler to do the .svelte -> .js compilation + ->defaultValue(['*.js', '*.svelte']) + ->scalarPrototype()->end() + ->end() + ->end(); + + return $treeBuilder; + } + + private function isAssetMapperAvailable(ContainerBuilder $container): bool + { + if (!interface_exists(AssetMapperInterface::class)) { + return false; + } + + // check that FrameworkBundle 6.3 or higher is installed + $bundlesMetadata = $container->getParameter('kernel.bundles_metadata'); + if (!isset($bundlesMetadata['FrameworkBundle'])) { + return false; + } + + return is_file($bundlesMetadata['FrameworkBundle']['path'].'/Resources/config/asset_mapper.php'); + } +} diff --git a/src/SvelteBundle.php b/src/SvelteBundle.php new file mode 100644 index 0000000..c12bbbc --- /dev/null +++ b/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/Twig/SvelteComponentExtension.php b/src/Twig/SvelteComponentExtension.php new file mode 100644 index 0000000..9c3c4cd --- /dev/null +++ b/src/Twig/SvelteComponentExtension.php @@ -0,0 +1,64 @@ + + * + * 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\UX\StimulusBundle\Helper\StimulusHelper; +use Symfony\WebpackEncoreBundle\Twig\StimulusTwigExtension; +use Twig\Extension\AbstractExtension; +use Twig\TwigFunction; + +/** + * @author Titouan Galopin + * @author Thomas Choquet + * + * @final + */ +class SvelteComponentExtension extends AbstractExtension +{ + private $stimulusHelper; + + /** + * @param $stimulus StimulusHelper + */ + public function __construct(StimulusHelper|StimulusTwigExtension $stimulus) + { + if ($stimulus instanceof StimulusTwigExtension) { + trigger_deprecation('symfony/ux-svelte', '2.9', 'Passing an instance of "%s" to "%s" is deprecated, pass an instance of "%s" instead.', StimulusTwigExtension::class, __CLASS__, StimulusHelper::class); + $stimulus = new StimulusHelper(null); + } + + $this->stimulusHelper = $stimulus; + } + + public function getFunctions(): array + { + return [ + new TwigFunction('svelte_component', [$this, 'renderSvelteComponent'], ['is_safe' => ['html_attr']]), + ]; + } + + public function renderSvelteComponent(string $componentName, array $props = [], bool $intro = false): string + { + $params = ['component' => $componentName]; + if ($props) { + $params['props'] = $props; + } + if ($intro) { + $params['intro'] = true; + } + + $stimulusAttributes = $this->stimulusHelper->createStimulusAttributes(); + $stimulusAttributes->addController('@symfony/ux-svelte/svelte', $params); + + return (string) $stimulusAttributes; + } +} diff --git a/tests/AssetMapper/SvelteControllerLoaderAssetCompilerTest.php b/tests/AssetMapper/SvelteControllerLoaderAssetCompilerTest.php new file mode 100644 index 0000000..1faf1eb --- /dev/null +++ b/tests/AssetMapper/SvelteControllerLoaderAssetCompilerTest.php @@ -0,0 +1,76 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\UX\Svelte\Tests\AssetMapper; + +use PHPUnit\Framework\TestCase; +use Symfony\Component\AssetMapper\AssetMapperInterface; +use Symfony\Component\AssetMapper\MappedAsset; +use Symfony\UX\Svelte\AssetMapper\SvelteControllerLoaderAssetCompiler; + +class SvelteControllerLoaderAssetCompilerTest extends TestCase +{ + public function testCompileDynamicallyAddsContents() + { + $assetMapper = $this->createMock(AssetMapperInterface::class); + $assetMapper->expects($this->exactly(2)) + ->method('getAssetFromSourcePath') + ->with($this->logicalOr( + $this->equalTo(realpath(__DIR__.'/../fixtures/svelte/controllers/MySvelteController.js')), + $this->equalTo(realpath(__DIR__.'/../fixtures/svelte/controllers/subdir/DeeperSvelteController.js')), + )) + ->willReturnCallback(function ($sourcePath) { + if (str_contains($sourcePath, 'MySvelteController')) { + return new MappedAsset( + 'MySvelteController.js', + '/project/assets/svelte/controllers/MySvelteController.js', + publicPathWithoutDigest: '/assets/svelte/controllers/MySvelteController.js', + ); + } + + if (str_contains($sourcePath, 'DeeperSvelteController')) { + return new MappedAsset( + 'subdir/DeeperSvelteController.js', + '/project/assets/svelte/controllers/subdir/DeeperSvelteController.js', + publicPathWithoutDigest: '/assets/svelte/controllers/subdir/DeeperSvelteController.js', + ); + } + + throw new \Exception('Unexpected source path: '.$sourcePath); + }); + + $compiler = new SvelteControllerLoaderAssetCompiler( + __DIR__.'/../fixtures/svelte/controllers', + ['*.js'] + ); + + $loaderAsset = new MappedAsset('loader.js', '/project/assets/vendor/StimulusBundle/loader.js', publicPathWithoutDigest: '/assets/symfony/ux-svelte/loader.js'); + $startingContents = file_get_contents(__DIR__.'/../../assets/dist/loader.js'); + + $compiledContents = $compiler->compile($startingContents, $loaderAsset, $assetMapper); + $this->assertStringContainsString( + "from '../../svelte/controllers/subdir/DeeperSvelteController.js';", + $compiledContents, + ); + $this->assertStringContainsString( + "from '../../svelte/controllers/MySvelteController.js';", + $compiledContents, + ); + $this->assertStringContainsString( + 'export const components = {"', + $compiledContents, + ); + $this->assertStringContainsString( + '"subdir/DeeperSvelteController": component_', + $compiledContents, + ); + } +} diff --git a/tests/Kernel/TwigAppKernel.php b/tests/Kernel/TwigAppKernel.php new file mode 100644 index 0000000..e61a638 --- /dev/null +++ b/tests/Kernel/TwigAppKernel.php @@ -0,0 +1,66 @@ + + * + * 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\StimulusBundle\StimulusBundle; +use Symfony\UX\Svelte\SvelteBundle; + +/** + * @author Titouan Galopin + * @author Thomas Choquet + * + * @internal + */ +class TwigAppKernel extends Kernel +{ + public function registerBundles(): iterable + { + return [new StimulusBundle(), new FrameworkBundle(), new TwigBundle(), new SvelteBundle()]; + } + + public function registerContainerConfiguration(LoaderInterface $loader) + { + $loader->load(function (ContainerBuilder $container) { + $container->loadFromExtension('framework', ['secret' => '$ecret', 'test' => true, 'http_method_override' => false]); + $container->loadFromExtension('twig', ['default_path' => __DIR__.'/templates', 'strict_variables' => true, 'exception_controller' => null]); + + $container->setAlias('test.twig', 'twig')->setPublic(true); + $container->setAlias('test.twig.extension.svelte', 'twig.extension.svelte')->setPublic(true); + }); + } + + public function getCacheDir(): string + { + return $this->createTmpDir('cache'); + } + + public function getLogDir(): string + { + return $this->createTmpDir('logs'); + } + + private function createTmpDir(string $type): string + { + $dir = sys_get_temp_dir().'/svelte_bundle/'.uniqid($type.'_', true); + + if (!file_exists($dir)) { + mkdir($dir, 0777, true); + } + + return $dir; + } +} diff --git a/tests/SvelteBundleTest.php b/tests/SvelteBundleTest.php new file mode 100644 index 0000000..01460d4 --- /dev/null +++ b/tests/SvelteBundleTest.php @@ -0,0 +1,31 @@ + + * + * 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\UX\Svelte\Tests\Kernel\TwigAppKernel; + +/** + * @author Titouan Galopin + * @author Thomas Choquet + * + * @internal + */ +class SvelteBundleTest extends TestCase +{ + public function testBootKernel() + { + $kernel = new TwigAppKernel('test', true); + $kernel->boot(); + $this->assertArrayHasKey('SvelteBundle', $kernel->getBundles()); + } +} diff --git a/tests/Twig/SvelteComponentExtensionTest.php b/tests/Twig/SvelteComponentExtensionTest.php new file mode 100644 index 0000000..7a9a6d0 --- /dev/null +++ b/tests/Twig/SvelteComponentExtensionTest.php @@ -0,0 +1,80 @@ + + * + * 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( + '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('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( + '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 + ); + } +} diff --git a/tests/fixtures/svelte/components/MySvelteComponent.js b/tests/fixtures/svelte/components/MySvelteComponent.js new file mode 100644 index 0000000..8ab0a74 --- /dev/null +++ b/tests/fixtures/svelte/components/MySvelteComponent.js @@ -0,0 +1 @@ +console.log('MySvelteComponent.js') diff --git a/tests/fixtures/svelte/controllers/MySvelteController.js b/tests/fixtures/svelte/controllers/MySvelteController.js new file mode 100644 index 0000000..9aa76ec --- /dev/null +++ b/tests/fixtures/svelte/controllers/MySvelteController.js @@ -0,0 +1 @@ +console.log('MySvelteController.js') diff --git a/tests/fixtures/svelte/controllers/other-file.txt b/tests/fixtures/svelte/controllers/other-file.txt new file mode 100644 index 0000000..75fec7b --- /dev/null +++ b/tests/fixtures/svelte/controllers/other-file.txt @@ -0,0 +1 @@ +Other file - not a Svelte file. diff --git a/tests/fixtures/svelte/controllers/subdir/DeeperSvelteController.js b/tests/fixtures/svelte/controllers/subdir/DeeperSvelteController.js new file mode 100644 index 0000000..9ee08d0 --- /dev/null +++ b/tests/fixtures/svelte/controllers/subdir/DeeperSvelteController.js @@ -0,0 +1 @@ +console.log('DeeperSvelteController.js')