[Native] Create the component

This commit is contained in:
imad
2026-02-07 19:09:52 +01:00
committed by Hugo Alliaume
parent 0e6dbd3884
commit b55b0ba30b
41 changed files with 2322 additions and 0 deletions

View File

@@ -13,6 +13,7 @@
},
"ux-google-map": "src/Map/src/Bridge/Google",
"ux-leaflet-map": "src/Map/src/Bridge/Leaflet",
"ux-native": "src/Native",
"ux-notify": "src/Notify",
"ux-react": "src/React",
"ux-svelte": "src/Svelte",

5
src/Native/.gitattributes vendored Normal file
View File

@@ -0,0 +1,5 @@
/.git* export-ignore
/.symfony.bundle.yaml export-ignore
/doc export-ignore
/phpunit.xml.dist export-ignore
/tests export-ignore

6
src/Native/.gitignore vendored Normal file
View File

@@ -0,0 +1,6 @@
/var
/vendor
/composer.lock
/.phpunit.cache
/.php-cs-fixer.cache
/phpunit.xml

View File

@@ -0,0 +1,3 @@
branches: ['2.x']
maintained_branches: ['2.x']
doc_dir: 'doc'

5
src/Native/CHANGELOG.md Normal file
View File

@@ -0,0 +1,5 @@
# CHANGELOG
## 2.33
- Create the component

19
src/Native/LICENCE Normal file
View File

@@ -0,0 +1,19 @@
Copyright (c) 2026-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.

25
src/Native/README.md Normal file
View File

@@ -0,0 +1,25 @@
# Symfony UX Native
**EXPERIMENTAL** This component is currently experimental and is
likely to change, or even change drastically.
Symfony UX Native integrates [Hotwire Native](https://native.hotwired.dev/) into Symfony applications.
It is part of [the Symfony UX initiative](https://ux.symfony.com/).
**This repository is a READ-ONLY sub-tree split**. See
https://github.com/symfony/ux to create issues or submit pull requests.
## Configuration
```yaml
# config/packages/ux_native.yaml
ux_native:
output_dir: '%kernel.project_dir%/public'
```
## Resources
- [Documentation](https://symfony.com/bundles/ux-native/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)

48
src/Native/composer.json Normal file
View File

@@ -0,0 +1,48 @@
{
"name": "symfony/ux-native",
"description": "Hotwire Native integration for Symfony",
"license": "MIT",
"type": "symfony-bundle",
"keywords": ["symfony-ux", "hotwire", "native"],
"authors": [
{
"name": "Symfony Community",
"homepage": "https://symfony.com/contributors"
}
],
"homepage": "https://symfony.com",
"require": {
"php": ">=8.1",
"symfony/asset": "^6.4|^7.0|^8.0",
"symfony/filesystem": "^6.4|^7.0|^8.0",
"symfony/process": "^6.4|^7.0|^8.0",
"symfony/stimulus-bundle": "^2.18.1"
},
"require-dev": {
"phpunit/phpunit": "^9.6.22",
"symfony/phpunit-bridge": "^7.2|^8.0",
"symfony/browser-kit": "^6.4|^7.0|^8.0",
"symfony/framework-bundle": "^6.4|^7.0|^8.0",
"symfony/maker-bundle": "^1.0"
},
"autoload": {
"psr-4": {
"Symfony\\UX\\Native\\": "src/"
}
},
"autoload-dev": {
"psr-4": {
"Symfony\\UX\\Native\\Tests\\": "tests/"
}
},
"config": {
"sort-packages": true
},
"extra": {
"thanks": {
"name": "symfony/ux",
"url": "https://github.com/symfony/ux"
}
},
"minimum-stability": "dev"
}

View File

@@ -0,0 +1,26 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Symfony\Component\DependencyInjection\Loader\Configurator;
use Symfony\UX\Native\ConfigurationBuilder;
use Symfony\UX\Native\EventListener\DevServerEventListener;
return static function (ContainerConfigurator $container): void {
$services = $container->services();
$services->set('.ux_native.listener.dev_server_listener', DevServerEventListener::class)
->args([
service('.ux_native.configuration_builder'),
])
->tag('kernel.event_listener', ['event' => 'kernel.request', 'method' => 'onKernelRequest'])
;
};

View File

@@ -0,0 +1,42 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Symfony\Component\DependencyInjection\Loader\Configurator;
use Symfony\UX\Native\EventListener\NativeListener;
use Symfony\UX\Native\Twig\NativeExtension;
use Symfony\UX\Native\Command\ConfigurationDumper;
use Symfony\UX\Native\ConfigurationBuilder;
return static function (ContainerConfigurator $container): void {
$services = $container->services();
$container->services()
->set('.ux_native.listener.native_listener', NativeListener::class)
->tag('kernel.event_listener', ['event' => 'kernel.request', 'method' => 'onKernelRequest'])
;
$container->services()
->set('ux_native.twig.native_extension', NativeExtension::class)
->args([service('request_stack')])
->tag('twig.extension')
;
$services->set('.ux_native.configuration_builder', ConfigurationBuilder::class)
->args([
'%ux_native.output_dir%',
])
;
$services->set('.ux_native.command.configuration_dumper', ConfigurationDumper::class)
->args([service('.ux_native.configuration_builder')])
->tag('console.command', ['command' => 'ux-native:dump'])
;
};

360
src/Native/doc/index.rst Normal file
View File

@@ -0,0 +1,360 @@
Symfony UX Native
=================
**EXPERIMENTAL** This component is currently experimental and is likely
to change, or even change drastically.
Symfony UX Native is a Symfony bundle integrating `Hotwire Native`_ into Symfony
applications. It is part of `the Symfony UX initiative`_.
`Hotwire Native`_ is a framework for building native mobile applications (iOS and Android)
that wrap your web application in a native shell. This bundle provides tools to:
- **Detect native requests** automatically, based on the ``User-Agent`` header;
- **Conditionally render content** in Twig templates depending on whether the
request comes from a native app or a browser;
- **Generate JSON configuration files** consumed by Hotwire Native mobile clients
(path rules, settings, etc.);
- **Scaffold Bridge controllers** to enable communication between your web app
and the native shell via Stimulus.
Installation
------------
.. 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-native
If you're using WebpackEncore, install your assets and restart Encore (not
needed if you're using AssetMapper):
.. code-block:: terminal
$ npm install --force
$ npm run watch
Configuration
-------------
The bundle exposes a single configuration option:
.. code-block:: yaml
# config/packages/ux_native.yaml
ux_native:
output_dir: '%kernel.project_dir%/public' # default
The ``output_dir`` option defines the directory where JSON configuration files
are written when you run the ``ux-native:dump`` command (see
`Dumping Configurations for Production`_).
Usage
-----
Detecting Native Requests
~~~~~~~~~~~~~~~~~~~~~~~~~
The bundle automatically listens to every incoming request and inspects the
``User-Agent`` header. If it contains the string ``Hotwire Native`` (which is
sent by all Hotwire Native iOS and Android clients), the request is flagged as
a native request.
You do not need any additional configuration for this to work -- it is enabled
by default.
In Twig templates, use the ``ux_is_native()`` function to conditionally render
content:
.. code-block:: html+twig
{% if ux_is_native() %}
{# Content only visible in the native app #}
<p>Welcome to our mobile app!</p>
{% else %}
{# Content only visible in the browser #}
<nav>
<a href="/">Home</a>
<a href="/about">About</a>
</nav>
{% endif %}
This is useful for hiding navigation bars, footers, or any elements that the
native shell already provides.
In PHP, you can also check the request attribute directly::
use Symfony\UX\Native\EventListener\NativeListener;
// In a controller
public function index(Request $request): Response
{
$isNative = $request->attributes->get(NativeListener::NATIVE_ATTRIBUTE, false);
// ...
}
Creating Configurations
~~~~~~~~~~~~~~~~~~~~~~~
Hotwire Native mobile clients can consume JSON configuration files to control
their behavior (e.g. path rules, pull-to-refresh, local database usage, etc.).
This bundle lets you define those configurations as PHP objects and register
them as Symfony services.
A configuration is built using two classes:
``Symfony\UX\Native\Configuration\Configuration`` and
``Symfony\UX\Native\Configuration\Rule``.
Using PHP Attributes
^^^^^^^^^^^^^^^^^^^^
The easiest way to register configurations is to use the
``#[AsNativeConfigurationProvider]`` and ``#[AsNativeConfiguration]`` attributes.
Create a class annotated with ``#[AsNativeConfigurationProvider]``, and add
``#[AsNativeConfiguration('/path/to/config.json')]`` on each method that returns
a ``Configuration`` object::
// src/Native/AppNativeConfiguration.php
namespace App\Native;
use Symfony\UX\Native\Attribute\AsNativeConfiguration;
use Symfony\UX\Native\Attribute\AsNativeConfigurationProvider;
use Symfony\UX\Native\Configuration\Configuration;
use Symfony\UX\Native\Configuration\Rule;
#[AsNativeConfigurationProvider]
final class AppNativeConfiguration
{
#[AsNativeConfiguration('/config/ios_v1.json')]
public function iosV1(): Configuration
{
return new Configuration(
settings: [
'use_local_db' => true,
'cable' => [
'script_url' => 'https://example.com/cable.js',
],
],
rules: [
new Rule(
patterns: ['.*'],
properties: [
'context' => 'default',
'pull_to_refresh_enabled' => true,
],
),
],
);
}
#[AsNativeConfiguration('/config/android_v1.json')]
public function androidV1(): Configuration
{
return new Configuration(
settings: [
'use_local_db' => false,
],
rules: [
new Rule(
patterns: ['/articles/.*'],
properties: [
'context' => 'default',
'pull_to_refresh_enabled' => false,
],
),
],
);
}
}
That's it! The class is automatically discovered and each annotated method is
registered as a configuration at the given path.
.. note::
The annotated methods must be **public** and **non-static**, and must
declare a return type of ``Configuration``.
Using Service Tags
^^^^^^^^^^^^^^^^^^
Alternatively, you can register configurations manually using service tags. This
is useful if you need to use a factory pattern or if you prefer YAML
configuration::
// src/Native/IosConfigurationFactory.php
namespace App\Native;
use Symfony\UX\Native\Configuration\Configuration;
use Symfony\UX\Native\Configuration\Rule;
final class IosConfigurationFactory
{
public static function v1(): Configuration
{
return new Configuration(
settings: [
'use_local_db' => true,
'cable' => [
'script_url' => 'https://example.com/cable.js',
],
],
rules: [
new Rule(
patterns: ['.*'],
properties: [
'context' => 'default',
'pull_to_refresh_enabled' => true,
],
),
],
);
}
}
Then register it as a service tagged with ``ux_native.configuration``, and
specify the ``path`` at which it should be served:
.. code-block:: yaml
# config/services.yaml
services:
App\Native\IosConfigurationFactory: ~
app.native.ios_v1:
class: Symfony\UX\Native\Configuration\Configuration
factory: ['App\Native\IosConfigurationFactory', 'v1']
tags:
- { name: 'ux_native.configuration', path: '/config/ios_v1.json' }
You can register as many configurations as you need (e.g. one per platform, one
per version, etc.), each with a different ``path``.
The resulting JSON file will look like this:
.. code-block:: json
{
"settings": {
"use_local_db": true,
"cable": {
"script_url": "https://example.com/cable.js"
}
},
"rules": [
{
"patterns": [
".*"
],
"properties": {
"context": "default",
"pull_to_refresh_enabled": true
}
}
]
}
Serving Configurations in Development
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
In **debug mode** (i.e. the ``dev`` environment), the bundle automatically
serves configuration JSON responses dynamically. When a request matches a
registered configuration path (e.g. ``/config/ios_v1.json``), the response is
generated on the fly from the ``Configuration`` object -- no need to dump files
to disk.
This means you can iterate on your configurations without running any command.
Simply change your factory code and refresh.
Dumping Configurations for Production
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
In **production**, you should dump the configuration files to disk so they can
be served as static files by your web server, without going through the Symfony
kernel.
Run the following command:
.. code-block:: terminal
$ php bin/console ux-native:dump
This writes all registered configurations as JSON files into the ``output_dir``
directory (defaults to ``public/``). For example, a configuration registered at
path ``/config/ios_v1.json`` will be written to ``public/config/ios_v1.json``.
.. tip::
Add this command to your deployment pipeline (e.g. in your ``composer.json``
post-install scripts or your CI/CD configuration) to ensure the configuration
files are always up to date.
Creating Bridge Controllers
~~~~~~~~~~~~~~~~~~~~~~~~~~~
`Hotwire Native Bridge`_ allows your web application and the native shell to
communicate via Stimulus controllers. The bundle provides a Maker command to
quickly scaffold a Bridge controller.
.. note::
This command requires ``symfony/maker-bundle`` to be installed.
.. code-block:: terminal
$ php bin/console make:native-bridge-controller
The command will ask you for the controller name and generate a JavaScript file
in ``assets/controllers/``. For example, creating a controller named
``contact_form`` will generate the following file:
.. code-block:: javascript
// assets/controllers/contact_form_controller.js
import { BridgeComponent } from "@hotwired/hotwire-native-bridge"
export default class extends BridgeComponent {
static component = "contact_form"
connect() {
super.connect()
// The bridge is now ready and will handle communication
// between your web app and the native application
}
disconnect() {
super.disconnect()
// Clean up any resources or event listeners here
}
}
You can then use this controller in your Twig templates:
.. code-block:: html+twig
<form data-controller="contact-form">
{# ... #}
</form>
For more information on how Bridge components work, refer to the
`Hotwire Native Bridge documentation`_.
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
.. _`the Symfony UX initiative`: https://ux.symfony.com/
.. _`Hotwire Native`: https://native.hotwired.dev/
.. _`Hotwire Native Bridge`: https://native.hotwired.dev/reference/bridge-installation
.. _`Hotwire Native Bridge documentation`: https://native.hotwired.dev/reference/bridge-installation
.. _`StimulusBundle configured in your app`: https://symfony.com/bundles/StimulusBundle/current/index.html

View File

@@ -0,0 +1,30 @@
<?xml version="1.0" encoding="UTF-8"?>
<!-- https://phpunit.de/manual/current/en/appendixes.configuration.html -->
<phpunit
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:noNamespaceSchemaLocation="vendor/bin/.phpunit/phpunit.xsd"
colors="true"
bootstrap="tests/autoload.php"
failOnRisky="true"
failOnWarning="true"
>
<php>
<ini name="error_reporting" value="-1"/>
<env name="SHELL_VERBOSITY" value="-1"/>
<env name="SYMFONY_DEPRECATIONS_HELPER" value="max[self]=0&amp;max[direct]=0"/>
<env name="KERNEL_CLASS" value="Symfony\UX\Native\Tests\AppKernel"/>
</php>
<testsuites>
<testsuite name="Symfony UX Native Test Suite">
<directory>./tests</directory>
</testsuite>
</testsuites>
<coverage>
<include>
<directory>./src</directory>
</include>
</coverage>
<listeners>
<listener class="Symfony\Bridge\PhpUnit\SymfonyTestsListener"/>
</listeners>
</phpunit>

View File

@@ -0,0 +1,39 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Symfony\UX\Native\Attribute;
/**
* Marks a method as a Hotwire Native configuration factory.
*
* The method must return a {@see \Symfony\UX\Native\Configuration\Configuration} instance.
* The containing class must be annotated with {@see AsNativeConfigurationProvider}.
*
* @example
*
* #[AsNativeConfigurationProvider]
* class AppNativeConfiguration
* {
* #[AsNativeConfiguration('/config/ios_v1.json')]
* public function iosV1(): Configuration
* {
* return new Configuration(...);
* }
* }
*/
#[\Attribute(\Attribute::TARGET_METHOD)]
final class AsNativeConfiguration
{
public function __construct(
public readonly string $path,
) {
}
}

View File

@@ -0,0 +1,35 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Symfony\UX\Native\Attribute;
/**
* Marks a class as a Hotwire Native configuration provider.
*
* The class should contain one or more methods annotated with
* {@see AsNativeConfiguration} that return Configuration instances.
*
* @example
*
* #[AsNativeConfigurationProvider]
* class AppNativeConfiguration
* {
* #[AsNativeConfiguration('/config/ios_v1.json')]
* public function iosV1(): Configuration
* {
* return new Configuration(...);
* }
* }
*/
#[\Attribute(\Attribute::TARGET_CLASS)]
final class AsNativeConfigurationProvider
{
}

View File

@@ -0,0 +1,40 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Symfony\UX\Native\Command;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Style\SymfonyStyle;
use Symfony\UX\Native\ConfigurationBuilder;
#[AsCommand('ux-native:dump')]
final class ConfigurationDumper extends Command
{
public function __construct(
private readonly ConfigurationBuilder $configurationBuilder,
) {
parent::__construct();
}
protected function execute(InputInterface $input, OutputInterface $output): int
{
$io = new SymfonyStyle($input, $output);
$io->comment('Dumping UX Native configuration files...');
$this->configurationBuilder->build();
$io->success('UX Native configuration files dumped successfully.');
return Command::SUCCESS;
}
}

View File

@@ -0,0 +1,36 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Symfony\UX\Native\Configuration;
class Configuration
{
/**
* @param ?array<string, mixed> $settings
* @param ?array<Rule> $rules
*/
public function __construct(
public ?array $settings = null,
public ?array $rules = null,
) {
}
/**
* @return array<string, mixed>
*/
public function toArray(): array
{
return array_filter([
'settings' => $this->settings,
'rules' => null === $this->rules ? null : array_map(static fn (Rule $rule) => $rule->toArray(), $this->rules),
], static fn ($value) => null !== $value);
}
}

View File

@@ -0,0 +1,36 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Symfony\UX\Native\Configuration;
class Rule
{
/**
* @param array<string> $patterns
* @param array<string, mixed> $properties
*/
public function __construct(
public ?array $patterns = null,
public ?array $properties = null,
) {
}
/**
* @return array<string, mixed>
*/
public function toArray(): array
{
return array_filter([
'patterns' => $this->patterns,
'properties' => $this->properties,
], static fn ($value) => null !== $value);
}
}

View File

@@ -0,0 +1,61 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Symfony\UX\Native;
use Symfony\Component\Filesystem\Filesystem;
use Symfony\Component\HttpFoundation\File\Exception\FileNotFoundException;
use Symfony\UX\Native\Configuration\Configuration;
final class ConfigurationBuilder
{
/**
* @var array<string, Configuration>
*/
private array $configs = [];
public function __construct(
private readonly string $outputDir,
) {
}
public function add(string $url, Configuration $configuration): void
{
$this->configs[$url] = $configuration;
}
public function has(string $url): bool
{
return \array_key_exists($url, $this->configs);
}
public function get(string $pathInfo): Configuration
{
return $this->configs[$pathInfo] ?? throw new FileNotFoundException(\sprintf('Configuration not found for path: "%s".', $pathInfo));
}
public function build(): void
{
$filesystem = new Filesystem();
foreach ($this->configs as $url => $configuration) {
$path = rtrim($this->outputDir, '/').'/'.ltrim($url, '/');
$filesystem->dumpFile($path, $this->encode($configuration));
}
}
private function encode(Configuration $configuration): string
{
return json_encode(
$configuration->toArray(),
\JSON_UNESCAPED_SLASHES | \JSON_UNESCAPED_UNICODE | \JSON_THROW_ON_ERROR | \JSON_PRETTY_PRINT,
);
}
}

View File

@@ -0,0 +1,96 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Symfony\UX\Native;
use Symfony\Component\DependencyInjection\ChildDefinition;
use Symfony\Component\DependencyInjection\Compiler\CompilerPassInterface;
use Symfony\Component\DependencyInjection\ContainerBuilder;
use Symfony\Component\DependencyInjection\Reference;
use Symfony\UX\Native\Attribute\AsNativeConfiguration;
use Symfony\UX\Native\Configuration\Configuration;
final class ConfigurationCompilerPass implements CompilerPassInterface
{
private const TAG = 'ux_native.configuration';
private const PROVIDER_TAG = 'ux_native.configuration_provider';
public function process(ContainerBuilder $container): void
{
if (!$container->hasDefinition('.ux_native.configuration_builder')) {
return;
}
$this->registerAttributeConfigurations($container);
$definition = $container->getDefinition('.ux_native.configuration_builder');
foreach ($container->findTaggedServiceIds(self::TAG) as $id => $tags) {
foreach ($tags as $attributes) {
if (!\array_key_exists('path', $attributes)) {
throw new \InvalidArgumentException(\sprintf('The path is missing for "%s"', $id));
}
$definition->addMethodCall('add', [$attributes['path'], new Reference($id)]);
}
}
}
/**
* Scans classes tagged with #[AsNativeConfigurationProvider] for methods
* annotated with #[AsNativeConfiguration] and registers them as tagged
* Configuration services.
*/
private function registerAttributeConfigurations(ContainerBuilder $container): void
{
$providerIds = $container->findTaggedServiceIds(self::PROVIDER_TAG);
foreach ($providerIds as $serviceId => $tags) {
$definition = $container->getDefinition($serviceId);
$class = $definition->getClass() ?? $serviceId;
try {
$reflectionClass = new \ReflectionClass($class);
} catch (\ReflectionException) {
continue;
}
foreach ($reflectionClass->getMethods(\ReflectionMethod::IS_PUBLIC) as $method) {
$attributes = $method->getAttributes(AsNativeConfiguration::class);
if ([] === $attributes) {
continue;
}
if ($method->isStatic()) {
throw new \InvalidArgumentException(\sprintf('The method "%s::%s()" annotated with #[AsNativeConfiguration] must not be static.', $class, $method->getName()));
}
$returnType = $method->getReturnType();
if (!$returnType instanceof \ReflectionNamedType || Configuration::class !== $returnType->getName()) {
throw new \InvalidArgumentException(\sprintf('The method "%s::%s()" annotated with #[AsNativeConfiguration] must have a return type of "%s".', $class, $method->getName(), Configuration::class));
}
foreach ($attributes as $attribute) {
$asNativeConfiguration = $attribute->newInstance();
$childDefinition = new ChildDefinition($serviceId);
$childDefinition->setClass(Configuration::class);
$childDefinition->setFactory([new Reference($serviceId), $method->getName()]);
$childDefinition->setShared(false);
$childDefinition->addTag(self::TAG, ['path' => $asNativeConfiguration->path]);
$childServiceId = \sprintf('.ux_native.configuration.%s.%s', $reflectionClass->getShortName(), $method->getName());
$container->setDefinition($childServiceId, $childDefinition);
}
}
}
}
}

View File

@@ -0,0 +1,41 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Symfony\UX\Native\EventListener;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpKernel\Event\RequestEvent;
use Symfony\UX\Native\ConfigurationBuilder;
final class DevServerEventListener
{
public function __construct(
private ConfigurationBuilder $configurationBuilder,
) {
}
public function onKernelRequest(RequestEvent $event): void
{
if (!$event->isMainRequest()) {
return;
}
$request = $event->getRequest();
$pathInfo = $request->getPathInfo();
if (!$this->configurationBuilder->has($pathInfo)) {
return;
}
$configuration = $this->configurationBuilder->get($pathInfo);
$event->setResponse(new JsonResponse($configuration->toArray()));
$event->stopPropagation();
}
}

View File

@@ -0,0 +1,25 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Symfony\UX\Native\EventListener;
use Symfony\Component\HttpKernel\Event\RequestEvent;
final class NativeListener
{
public const NATIVE_ATTRIBUTE = '_ux_is_native';
public function onKernelRequest(RequestEvent $event): void
{
$request = $event->getRequest();
$request->attributes->set(self::NATIVE_ATTRIBUTE, str_contains($request->headers->get('User-Agent', ''), 'Hotwire Native'));
}
}

View File

@@ -0,0 +1,111 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Symfony\UX\Native\Maker;
use Symfony\Bundle\MakerBundle\ConsoleStyle;
use Symfony\Bundle\MakerBundle\DependencyBuilder;
use Symfony\Bundle\MakerBundle\Generator;
use Symfony\Bundle\MakerBundle\InputConfiguration;
use Symfony\Bundle\MakerBundle\Maker\AbstractMaker;
use Symfony\Bundle\MakerBundle\Str;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Exception\RuntimeException;
use Symfony\Component\Console\Input\InputArgument;
use Symfony\Component\Console\Input\InputInterface;
/**
* @author Imad Zairig <imadzairig@gmail.com>
*/
final class MakeNativeBridgeController extends AbstractMaker
{
private const REQUIRED_PACKAGES = [
'@symfony/stimulus-bundle',
'@hotwired/hotwire-native-bridge',
];
public function __construct(
private string $projectDir,
) {
}
public static function getCommandName(): string
{
return 'make:native-bridge-controller';
}
public static function getCommandDescription(): string
{
return 'Creates a new Native Bridge controller';
}
public function configureCommand(Command $command, InputConfiguration $inputConfig): void
{
$command
->addArgument('name', InputArgument::OPTIONAL, 'The name of the controller (e.g. <fg=yellow>bridge</>)')
->setHelp(<<<'EOT'
The <info>%command.name%</info> command creates a new Hotwire Native Bridge Stimulus controller.
<info>php %command.full_name% ContactForm</info>
This will create a new <info>contact_form_controller.js</info> file in your <info>assets/controllers</info> directory
that extends the Hotwire Native Bridge component.
<info>php %command.full_name%</info> (interactive)
EOT
);
}
public function generate(InputInterface $input, ConsoleStyle $io, Generator $generator): void
{
$this->ensurePackagesExist($io);
$controllerName = $input->getArgument('name');
if (!$controllerName) {
$controllerName = $io->ask('What name do you want for your Hotwire Native Bridge controller?', 'bridge', static function ($name) {
if (!$name) {
throw new RuntimeException('Controller name cannot be empty.');
}
return $name;
});
}
$controllerName = Str::asSnakeCase($controllerName);
$controllerPath = \sprintf('%s/assets/controllers/%s_controller.js', $this->projectDir, $controllerName);
if (file_exists($controllerPath) && !$io->confirm(\sprintf('The controller "%s" already exists. Overwrite it?', $controllerName), false)) {
$io->comment('Command aborted.');
return;
}
$generator->generateFile(
$controllerPath,
__DIR__.'/../Resources/skeleton/bridge_controller.tpl.php',
['name' => $controllerName]
);
$generator->writeChanges();
$io->success('Hotwire Native Bridge controller created successfully!');
$io->text([
'Next: Add the data-controller attribute to your HTML element:',
\sprintf('<info>data-controller="%s"</info>', str_replace('_', '-', $controllerName)),
'',
'Read the documentation: https://native.hotwired.dev/reference/bridge-installation',
]);
}
public function configureDependencies(DependencyBuilder $dependencies): void
{
}
}

View File

@@ -0,0 +1,30 @@
import { BridgeComponent } from "@hotwired/hotwire-native-bridge"
/**
* Hotwire Native Bridge Controller
*
* This controller connects your web application to the Hotwire Native app
* by extending the BridgeComponent from hotwire-native-bridge.
*
* For more information, visit: https://native.hotwired.dev/reference/bridge-installation
*/
export default class extends BridgeComponent {
static component = "<?php echo $name; ?>"
connect() {
super.connect()
console.debug("Hotwire Native Bridge connected")
// The bridge is now ready and will handle communication
// between your web app and the native application
// You can add custom event handlers or additional setup here
}
disconnect() {
super.disconnect()
console.debug("Hotwire Native Bridge disconnected")
// Clean up any resources or event listeners here
}
}

View File

@@ -0,0 +1,43 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Symfony\UX\Native\Twig;
use Symfony\Component\HttpFoundation\RequestStack;
use Symfony\UX\Native\EventListener\NativeListener;
use Twig\Extension\AbstractExtension;
use Twig\TwigFunction;
class NativeExtension extends AbstractExtension
{
private RequestStack $requestStack;
public function __construct(RequestStack $requestStack)
{
$this->requestStack = $requestStack;
}
public function getFunctions(): array
{
return [
new TwigFunction('ux_is_native', [$this, 'isNative']),
];
}
public function isNative(): bool
{
if (null === $request = $this->requestStack->getCurrentRequest()) {
return false;
}
return $request->attributes->get(NativeListener::NATIVE_ATTRIBUTE, false);
}
}

View File

@@ -0,0 +1,71 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Symfony\UX\Native;
use Symfony\Component\Config\Definition\Configurator\DefinitionConfigurator;
use Symfony\Component\DependencyInjection\ChildDefinition;
use Symfony\Component\DependencyInjection\ContainerBuilder;
use Symfony\Component\DependencyInjection\Loader\Configurator\ContainerConfigurator;
use Symfony\Component\HttpKernel\Bundle\AbstractBundle;
use Symfony\UX\Native\Attribute\AsNativeConfigurationProvider;
use Symfony\UX\Native\Maker\MakeNativeBridgeController;
class UXNativeBundle extends AbstractBundle
{
protected string $extensionAlias = 'ux_native';
public function getPath(): string
{
return \dirname(__DIR__);
}
public function configure(DefinitionConfigurator $definition): void
{
$definition->rootNode()
->children()
->scalarNode('output_dir')
->defaultNull()
->info('Directory where configuration JSON files are written. Defaults to %kernel.project_dir%/public.')
->end()
->end()
;
}
public function build(ContainerBuilder $container): void
{
parent::build($container);
$container->addCompilerPass(new ConfigurationCompilerPass());
$container->registerAttributeForAutoconfiguration(AsNativeConfigurationProvider::class, static function (ChildDefinition $definition): void {
$definition->addTag('ux_native.configuration_provider');
});
}
public function loadExtension(array $config, ContainerConfigurator $container, ContainerBuilder $builder): void
{
$outputDir = $config['output_dir'] ?? $builder->getParameter('kernel.project_dir').'/public';
$builder->setParameter('ux_native.output_dir', $outputDir);
$container->import('../config/services.php');
if ($builder->hasParameter('kernel.debug') && $builder->getParameter('kernel.debug')) {
$container->import('../config/debug.php');
}
$container->services()
->set('ux.native.make_native_bridge_controller', MakeNativeBridgeController::class)
->tag('maker.command')
->args(['%kernel.project_dir%'])
;
}
}

View File

@@ -0,0 +1,39 @@
<?php
declare(strict_types=1);
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Symfony\UX\Native\Tests;
use Symfony\UX\Native\Attribute\AsNativeConfiguration;
use Symfony\UX\Native\Attribute\AsNativeConfigurationProvider;
use Symfony\UX\Native\Configuration\Configuration;
use Symfony\UX\Native\Configuration\Rule;
#[AsNativeConfigurationProvider]
final class AndroidConfigurationFactory
{
#[AsNativeConfiguration('/config/android_v1.json')]
public function v1(): Configuration
{
return new Configuration(
settings: [
'use_local_db' => false,
],
rules: [
new Rule(
patterns: ['/articles/.*'],
properties: ['context' => 'default', 'pull_to_refresh_enabled' => false],
),
],
);
}
}

View File

@@ -0,0 +1,44 @@
<?php
declare(strict_types=1);
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Symfony\UX\Native\Tests;
use Symfony\Bundle\FrameworkBundle\FrameworkBundle;
use Symfony\Component\Config\Loader\LoaderInterface;
use Symfony\Component\HttpKernel\Bundle\BundleInterface;
use Symfony\Component\HttpKernel\Kernel;
use Symfony\UX\Native\UXNativeBundle;
/**
* @internal
*/
final class AppKernel extends Kernel
{
public function __construct(string $environment)
{
parent::__construct($environment, false);
}
/**
* @return BundleInterface[]
*/
public function registerBundles(): array
{
return [new FrameworkBundle(), new UXNativeBundle()];
}
public function registerContainerConfiguration(LoaderInterface $loader): void
{
$loader->load(__DIR__.'/config.php');
}
}

View File

@@ -0,0 +1,68 @@
<?php
declare(strict_types=1);
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Symfony\UX\Native\Tests\Command;
use PHPUnit\Framework\TestCase;
use Symfony\Component\Console\Tester\CommandTester;
use Symfony\Component\Filesystem\Filesystem;
use Symfony\UX\Native\Command\ConfigurationDumper;
use Symfony\UX\Native\Configuration\Configuration;
use Symfony\UX\Native\Configuration\Rule;
use Symfony\UX\Native\ConfigurationBuilder;
final class ConfigurationDumperTest extends TestCase
{
private string $outputDir;
protected function setUp(): void
{
$this->outputDir = sys_get_temp_dir().'/ux_native_dumper_test_'.bin2hex(random_bytes(4));
}
protected function tearDown(): void
{
(new Filesystem())->remove($this->outputDir);
}
public function testExecuteCallsBuildAndReturnsSuccess()
{
$builder = new ConfigurationBuilder($this->outputDir);
$builder->add('/config/test.json', new Configuration(
settings: ['debug' => true],
rules: [new Rule(['.*'], ['context' => 'default'])],
));
$command = new ConfigurationDumper($builder);
$commandTester = new CommandTester($command);
$commandTester->execute([]);
$commandTester->assertCommandIsSuccessful();
self::assertFileExists($this->outputDir.'/config/test.json');
self::assertStringContainsString('dumped successfully', $commandTester->getDisplay());
}
public function testExecuteWithNoConfigurations()
{
$builder = new ConfigurationBuilder($this->outputDir);
$command = new ConfigurationDumper($builder);
$commandTester = new CommandTester($command);
$commandTester->execute([]);
$commandTester->assertCommandIsSuccessful();
self::assertStringContainsString('dumped successfully', $commandTester->getDisplay());
}
}

View File

@@ -0,0 +1,104 @@
<?php
declare(strict_types=1);
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Symfony\UX\Native\Tests\Configuration;
use PHPUnit\Framework\TestCase;
use Symfony\UX\Native\Configuration\Configuration;
use Symfony\UX\Native\Configuration\Rule;
final class ConfigurationTest extends TestCase
{
public function testToArrayWithAllValues()
{
$configuration = new Configuration(
settings: ['use_local_db' => true, 'cable' => ['script_url' => 'https://example.com/cable.js']],
rules: [
new Rule(['.*'], ['context' => 'default', 'pull_to_refresh_enabled' => true]),
],
);
self::assertSame([
'settings' => ['use_local_db' => true, 'cable' => ['script_url' => 'https://example.com/cable.js']],
'rules' => [
[
'patterns' => ['.*'],
'properties' => ['context' => 'default', 'pull_to_refresh_enabled' => true],
],
],
], $configuration->toArray());
}
public function testToArrayWithSettingsOnly()
{
$configuration = new Configuration(
settings: ['use_local_db' => false],
);
self::assertSame([
'settings' => ['use_local_db' => false],
], $configuration->toArray());
}
public function testToArrayWithRulesOnly()
{
$configuration = new Configuration(
rules: [
new Rule(['.*'], ['context' => 'default']),
],
);
self::assertSame([
'rules' => [
[
'patterns' => ['.*'],
'properties' => ['context' => 'default'],
],
],
], $configuration->toArray());
}
public function testToArrayWithNoValues()
{
$configuration = new Configuration();
self::assertSame([], $configuration->toArray());
}
public function testToArrayWithMultipleRules()
{
$configuration = new Configuration(
rules: [
new Rule(['/articles/.*'], ['context' => 'default']),
new Rule(['/admin/.*'], ['context' => 'modal']),
new Rule(properties: ['pull_to_refresh_enabled' => false]),
],
);
self::assertSame([
'rules' => [
[
'patterns' => ['/articles/.*'],
'properties' => ['context' => 'default'],
],
[
'patterns' => ['/admin/.*'],
'properties' => ['context' => 'modal'],
],
[
'properties' => ['pull_to_refresh_enabled' => false],
],
],
], $configuration->toArray());
}
}

View File

@@ -0,0 +1,62 @@
<?php
declare(strict_types=1);
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Symfony\UX\Native\Tests\Configuration;
use PHPUnit\Framework\TestCase;
use Symfony\UX\Native\Configuration\Rule;
final class RuleTest extends TestCase
{
public function testToArrayWithAllValues()
{
$rule = new Rule(
patterns: ['/articles/.*', '/pages/.*'],
properties: ['context' => 'default', 'pull_to_refresh_enabled' => true],
);
self::assertSame([
'patterns' => ['/articles/.*', '/pages/.*'],
'properties' => ['context' => 'default', 'pull_to_refresh_enabled' => true],
], $rule->toArray());
}
public function testToArrayWithPatternsOnly()
{
$rule = new Rule(
patterns: ['.*'],
);
self::assertSame([
'patterns' => ['.*'],
], $rule->toArray());
}
public function testToArrayWithPropertiesOnly()
{
$rule = new Rule(
properties: ['context' => 'modal'],
);
self::assertSame([
'properties' => ['context' => 'modal'],
], $rule->toArray());
}
public function testToArrayWithNoValues()
{
$rule = new Rule();
self::assertSame([], $rule->toArray());
}
}

View File

@@ -0,0 +1,123 @@
<?php
declare(strict_types=1);
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Symfony\UX\Native\Tests;
use PHPUnit\Framework\TestCase;
use Symfony\Component\Filesystem\Filesystem;
use Symfony\Component\HttpFoundation\File\Exception\FileNotFoundException;
use Symfony\UX\Native\Configuration\Configuration;
use Symfony\UX\Native\Configuration\Rule;
use Symfony\UX\Native\ConfigurationBuilder;
final class ConfigurationBuilderTest extends TestCase
{
private string $outputDir;
protected function setUp(): void
{
$this->outputDir = sys_get_temp_dir().'/ux_native_test_'.bin2hex(random_bytes(4));
}
protected function tearDown(): void
{
(new Filesystem())->remove($this->outputDir);
}
public function testAddAndHas()
{
$builder = new ConfigurationBuilder($this->outputDir);
$configuration = new Configuration(settings: ['use_local_db' => true]);
self::assertFalse($builder->has('/config/ios_v1.json'));
$builder->add('/config/ios_v1.json', $configuration);
self::assertTrue($builder->has('/config/ios_v1.json'));
}
public function testHasReturnsFalseForUnknownPath()
{
$builder = new ConfigurationBuilder($this->outputDir);
self::assertFalse($builder->has('/config/unknown.json'));
}
public function testGet()
{
$builder = new ConfigurationBuilder($this->outputDir);
$configuration = new Configuration(settings: ['use_local_db' => true]);
$builder->add('/config/ios_v1.json', $configuration);
$result = $builder->get('/config/ios_v1.json');
self::assertSame($configuration, $result);
}
public function testGetThrowsExceptionForUnknownPath()
{
$builder = new ConfigurationBuilder($this->outputDir);
$this->expectException(FileNotFoundException::class);
$this->expectExceptionMessage('Configuration not found for path: "/config/unknown.json".');
$builder->get('/config/unknown.json');
}
public function testBuild()
{
$builder = new ConfigurationBuilder($this->outputDir);
$builder->add('/config/ios_v1.json', new Configuration(
settings: ['use_local_db' => true],
rules: [new Rule(['.*'], ['context' => 'default'])],
));
$builder->add('/config/android_v1.json', new Configuration(
settings: ['use_local_db' => false],
));
$builder->build();
self::assertFileExists($this->outputDir.'/config/ios_v1.json');
self::assertFileExists($this->outputDir.'/config/android_v1.json');
}
public function testBuildEncodesJsonCorrectly()
{
$builder = new ConfigurationBuilder($this->outputDir);
$builder->add('/config/ios_v1.json', new Configuration(
settings: ['url' => 'https://example.com/path', 'label' => 'été'],
rules: [new Rule(['.*'], ['context' => 'default'])],
));
$builder->build();
self::assertStringEqualsFile($this->outputDir.'/config/ios_v1.json', <<<JSON
{
"settings": {
"url": "https://example.com/path",
"label": "été"
},
"rules": [
{
"patterns": [
".*"
],
"properties": {
"context": "default"
}
}
]
}
JSON);
}
}

View File

@@ -0,0 +1,135 @@
<?php
declare(strict_types=1);
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Symfony\UX\Native\Tests;
use PHPUnit\Framework\TestCase;
use Symfony\Component\DependencyInjection\ContainerBuilder;
use Symfony\Component\DependencyInjection\Definition;
use Symfony\UX\Native\ConfigurationBuilder;
use Symfony\UX\Native\ConfigurationCompilerPass;
use Symfony\UX\Native\Tests\Fixtures\InvalidReturnTypeConfigurationProvider;
use Symfony\UX\Native\Tests\Fixtures\StaticMethodConfigurationProvider;
final class ConfigurationCompilerPassTest extends TestCase
{
public function testEarlyReturnWhenNoConfigurationBuilder()
{
$container = new ContainerBuilder();
$pass = new ConfigurationCompilerPass();
// Should not throw any exception
$pass->process($container);
self::assertFalse($container->hasDefinition('.ux_native.configuration_builder'));
}
public function testThrowsExceptionWhenPathIsMissing()
{
$container = new ContainerBuilder();
$container->setDefinition('.ux_native.configuration_builder', new Definition(ConfigurationBuilder::class, ['/tmp']));
$definition = new Definition(\stdClass::class);
$definition->addTag('ux_native.configuration', []);
$container->setDefinition('test.config_without_path', $definition);
$pass = new ConfigurationCompilerPass();
$this->expectException(\InvalidArgumentException::class);
$this->expectExceptionMessage('The path is missing for "test.config_without_path"');
$pass->process($container);
}
public function testThrowsExceptionForStaticMethod()
{
$container = new ContainerBuilder();
$container->setDefinition('.ux_native.configuration_builder', new Definition(ConfigurationBuilder::class, ['/tmp']));
$definition = new Definition(StaticMethodConfigurationProvider::class);
$definition->addTag('ux_native.configuration_provider');
$container->setDefinition('test.static_provider', $definition);
$pass = new ConfigurationCompilerPass();
$this->expectException(\InvalidArgumentException::class);
$this->expectExceptionMessage('The method "Symfony\UX\Native\Tests\Fixtures\StaticMethodConfigurationProvider::v1()" annotated with #[AsNativeConfiguration] must not be static.');
$pass->process($container);
}
public function testThrowsExceptionForWrongReturnType()
{
$container = new ContainerBuilder();
$container->setDefinition('.ux_native.configuration_builder', new Definition(ConfigurationBuilder::class, ['/tmp']));
$definition = new Definition(InvalidReturnTypeConfigurationProvider::class);
$definition->addTag('ux_native.configuration_provider');
$container->setDefinition('test.invalid_return_type_provider', $definition);
$pass = new ConfigurationCompilerPass();
$this->expectException(\InvalidArgumentException::class);
$this->expectExceptionMessage('The method "Symfony\UX\Native\Tests\Fixtures\InvalidReturnTypeConfigurationProvider::v1()" annotated with #[AsNativeConfiguration] must have a return type of "Symfony\UX\Native\Configuration\Configuration".');
$pass->process($container);
}
public function testRegistersAttributeConfigurations()
{
$container = new ContainerBuilder();
$container->setDefinition('.ux_native.configuration_builder', new Definition(ConfigurationBuilder::class, ['/tmp']));
$definition = new Definition(AndroidConfigurationFactory::class);
$definition->addTag('ux_native.configuration_provider');
$container->setDefinition('test.android_provider', $definition);
$pass = new ConfigurationCompilerPass();
$pass->process($container);
// The child definition should be registered
$childId = '.ux_native.configuration.AndroidConfigurationFactory.v1';
self::assertTrue($container->hasDefinition($childId));
$childDefinition = $container->getDefinition($childId);
self::assertTrue($childDefinition->hasTag('ux_native.configuration'));
$tags = $childDefinition->getTag('ux_native.configuration');
self::assertSame('/config/android_v1.json', $tags[0]['path']);
// The builder should have a method call registered for the tag
$builderDefinition = $container->getDefinition('.ux_native.configuration_builder');
$methodCalls = $builderDefinition->getMethodCalls();
self::assertNotEmpty($methodCalls);
self::assertSame('add', $methodCalls[0][0]);
}
public function testRegistersManualTaggedConfigurations()
{
$container = new ContainerBuilder();
$container->setDefinition('.ux_native.configuration_builder', new Definition(ConfigurationBuilder::class, ['/tmp']));
$definition = new Definition(\stdClass::class);
$definition->addTag('ux_native.configuration', ['path' => '/config/ios_v1.json']);
$container->setDefinition('test.ios_config', $definition);
$pass = new ConfigurationCompilerPass();
$pass->process($container);
$builderDefinition = $container->getDefinition('.ux_native.configuration_builder');
$methodCalls = $builderDefinition->getMethodCalls();
self::assertCount(1, $methodCalls);
self::assertSame('add', $methodCalls[0][0]);
self::assertSame('/config/ios_v1.json', $methodCalls[0][1][0]);
}
}

View File

@@ -0,0 +1,86 @@
<?php
declare(strict_types=1);
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Symfony\UX\Native\Tests\EventListener;
use PHPUnit\Framework\TestCase;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpKernel\Event\RequestEvent;
use Symfony\Component\HttpKernel\HttpKernelInterface;
use Symfony\UX\Native\Configuration\Configuration;
use Symfony\UX\Native\Configuration\Rule;
use Symfony\UX\Native\ConfigurationBuilder;
use Symfony\UX\Native\EventListener\DevServerEventListener;
final class DevServerEventListenerTest extends TestCase
{
public function testServesConfigurationForMatchingPath()
{
$builder = new ConfigurationBuilder(sys_get_temp_dir());
$builder->add('/config/ios_v1.json', new Configuration(
settings: ['use_local_db' => true],
rules: [new Rule(['.*'], ['context' => 'default'])],
));
$listener = new DevServerEventListener($builder);
$request = Request::create('/config/ios_v1.json');
$event = $this->createRequestEvent($request, HttpKernelInterface::MAIN_REQUEST);
$listener->onKernelRequest($event);
self::assertTrue($event->hasResponse());
$response = $event->getResponse();
self::assertInstanceOf(JsonResponse::class, $response);
$data = json_decode($response->getContent(), true, 512, \JSON_THROW_ON_ERROR);
self::assertSame(['use_local_db' => true], $data['settings']);
self::assertTrue($event->isPropagationStopped());
}
public function testDoesNothingForNonMatchingPath()
{
$builder = new ConfigurationBuilder(sys_get_temp_dir());
$builder->add('/config/ios_v1.json', new Configuration(settings: ['use_local_db' => true]));
$listener = new DevServerEventListener($builder);
$request = Request::create('/config/unknown.json');
$event = $this->createRequestEvent($request, HttpKernelInterface::MAIN_REQUEST);
$listener->onKernelRequest($event);
self::assertFalse($event->hasResponse());
self::assertFalse($event->isPropagationStopped());
}
public function testIgnoresSubRequests()
{
$builder = new ConfigurationBuilder(sys_get_temp_dir());
$builder->add('/config/ios_v1.json', new Configuration(settings: ['use_local_db' => true]));
$listener = new DevServerEventListener($builder);
$request = Request::create('/config/ios_v1.json');
$event = $this->createRequestEvent($request, HttpKernelInterface::SUB_REQUEST);
$listener->onKernelRequest($event);
self::assertFalse($event->hasResponse());
self::assertFalse($event->isPropagationStopped());
}
private function createRequestEvent(Request $request, int $requestType): RequestEvent
{
$kernel = $this->createMock(HttpKernelInterface::class);
return new RequestEvent($kernel, $request, $requestType);
}
}

View File

@@ -0,0 +1,75 @@
<?php
declare(strict_types=1);
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Symfony\UX\Native\Tests\EventListener;
use PHPUnit\Framework\TestCase;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpKernel\Event\RequestEvent;
use Symfony\Component\HttpKernel\HttpKernelInterface;
use Symfony\UX\Native\EventListener\NativeListener;
final class NativeListenerTest extends TestCase
{
public function testSetsNativeAttributeToTrueWithHotwireNativeUserAgent()
{
$listener = new NativeListener();
$request = Request::create('/', server: ['HTTP_USER_AGENT' => 'Hotwire Native']);
$event = $this->createRequestEvent($request);
$listener->onKernelRequest($event);
self::assertTrue($request->attributes->get(NativeListener::NATIVE_ATTRIBUTE));
}
public function testSetsNativeAttributeToTrueWithHotwireNativeInLongerUserAgent()
{
$listener = new NativeListener();
$request = Request::create('/', server: ['HTTP_USER_AGENT' => 'Turbo Native iOS; Hotwire Native Android']);
$event = $this->createRequestEvent($request);
$listener->onKernelRequest($event);
self::assertTrue($request->attributes->get(NativeListener::NATIVE_ATTRIBUTE));
}
public function testSetsNativeAttributeToFalseWithStandardUserAgent()
{
$listener = new NativeListener();
$request = Request::create('/', server: ['HTTP_USER_AGENT' => 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7)']);
$event = $this->createRequestEvent($request);
$listener->onKernelRequest($event);
self::assertFalse($request->attributes->get(NativeListener::NATIVE_ATTRIBUTE));
}
public function testSetsNativeAttributeToFalseWithEmptyUserAgent()
{
$listener = new NativeListener();
$request = Request::create('/');
$request->headers->remove('User-Agent');
$event = $this->createRequestEvent($request);
$listener->onKernelRequest($event);
self::assertFalse($request->attributes->get(NativeListener::NATIVE_ATTRIBUTE));
}
private function createRequestEvent(Request $request, int $requestType = HttpKernelInterface::MAIN_REQUEST): RequestEvent
{
$kernel = $this->createMock(HttpKernelInterface::class);
return new RequestEvent($kernel, $request, $requestType);
}
}

View File

@@ -0,0 +1,27 @@
<?php
declare(strict_types=1);
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Symfony\UX\Native\Tests\Fixtures;
use Symfony\UX\Native\Attribute\AsNativeConfiguration;
use Symfony\UX\Native\Attribute\AsNativeConfigurationProvider;
#[AsNativeConfigurationProvider]
final class InvalidReturnTypeConfigurationProvider
{
#[AsNativeConfiguration('/config/invalid.json')]
public function v1(): array
{
return ['invalid' => true];
}
}

View File

@@ -0,0 +1,28 @@
<?php
declare(strict_types=1);
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Symfony\UX\Native\Tests\Fixtures;
use Symfony\UX\Native\Attribute\AsNativeConfiguration;
use Symfony\UX\Native\Attribute\AsNativeConfigurationProvider;
use Symfony\UX\Native\Configuration\Configuration;
#[AsNativeConfigurationProvider]
final class StaticMethodConfigurationProvider
{
#[AsNativeConfiguration('/config/static.json')]
public static function v1(): Configuration
{
return new Configuration(settings: ['static' => true]);
}
}

View File

@@ -0,0 +1,105 @@
<?php
declare(strict_types=1);
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Symfony\UX\Native\Tests\Functional;
use Symfony\Bundle\FrameworkBundle\Console\Application;
use Symfony\Bundle\FrameworkBundle\Test\WebTestCase;
use Symfony\Component\Console\Tester\CommandTester;
use Symfony\Component\Filesystem\Filesystem;
use Symfony\Component\HttpFoundation\Request;
final class CompileCommandTest extends WebTestCase
{
public static function testTheFilesAreCompiledWithTheCommand(): void
{
// Given
static::cleanup();
$application = new Application(self::bootKernel());
$command = $application->find('ux-native:dump');
$commandTester = new CommandTester($command);
// When
$commandTester->execute([]);
// Then
$commandTester->assertCommandIsSuccessful();
static::assertFileExists(self::$kernel->getCacheDir().'/output/config/ios_v1.json');
static::cleanup();
}
public static function testTheFilesAreCompiledFromAttribute(): void
{
// Given
static::cleanup();
$application = new Application(self::bootKernel());
$command = $application->find('ux-native:dump');
$commandTester = new CommandTester($command);
// When
$commandTester->execute([]);
// Then
$commandTester->assertCommandIsSuccessful();
static::assertFileExists(self::$kernel->getCacheDir().'/output/config/android_v1.json');
$content = file_get_contents(self::$kernel->getCacheDir().'/output/config/android_v1.json');
static::assertStringContainsString('"use_local_db": false', $content);
static::assertStringContainsString('/articles/.*', $content);
static::cleanup();
}
public static function testTheFilesAreServedInDev(): void
{
// Given
$client = self::createClient();
static::cleanup();
$application = new Application($client->getKernel());
$command = $application->find('ux-native:dump');
$commandTester = new CommandTester($command);
$commandTester->execute([]);
// When
$client->request(Request::METHOD_GET, '/config/ios_v1.json');
// Then
$response = $client->getResponse();
static::assertResponseIsSuccessful();
static::assertSame('application/json', $response->headers->get('Content-Type'));
static::assertStringContainsString('"use_local_db":true', $response->getContent());
}
public static function testTheAttributeConfigurationsAreServedInDev(): void
{
// Given
$client = self::createClient();
static::cleanup();
// When
$client->request(Request::METHOD_GET, '/config/android_v1.json');
// Then
$response = $client->getResponse();
static::assertResponseIsSuccessful();
static::assertSame('application/json', $response->headers->get('Content-Type'));
static::assertStringContainsString('"use_local_db":false', $response->getContent());
static::assertStringContainsString('pull_to_refresh_enabled', $response->getContent());
}
private static function cleanup(): void
{
/** @var Filesystem $filesystem */
$filesystem = self::getContainer()->get(Filesystem::class);
$filesystem->remove(\sprintf('%s/output', self::$kernel->getCacheDir()));
}
}

View File

@@ -0,0 +1,36 @@
<?php
declare(strict_types=1);
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Symfony\UX\Native\Tests;
use Symfony\UX\Native\Configuration\Configuration;
use Symfony\UX\Native\Configuration\Rule;
final class IosConfigurationFactory
{
public static function v1(): Configuration
{
return new Configuration(
[
'use_local_db' => true,
'cable' => ['script_url' => 'https://hotwire-native-demo.dev/configurations/action_cable.js'],
],
[
new Rule(
['.*'],
['context' => 'default', 'pull_to_refresh_enabled' => true]
),
]
);
}
}

View File

@@ -0,0 +1,79 @@
<?php
declare(strict_types=1);
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Symfony\UX\Native\Tests\Twig;
use PHPUnit\Framework\TestCase;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\RequestStack;
use Symfony\UX\Native\EventListener\NativeListener;
use Symfony\UX\Native\Twig\NativeExtension;
final class NativeExtensionTest extends TestCase
{
public function testIsNativeReturnsTrueWhenAttributeIsTrue()
{
$request = Request::create('/');
$request->attributes->set(NativeListener::NATIVE_ATTRIBUTE, true);
$extension = $this->createExtension($request);
self::assertTrue($extension->isNative());
}
public function testIsNativeReturnsFalseWhenAttributeIsFalse()
{
$request = Request::create('/');
$request->attributes->set(NativeListener::NATIVE_ATTRIBUTE, false);
$extension = $this->createExtension($request);
self::assertFalse($extension->isNative());
}
public function testIsNativeReturnsFalseWhenNoRequest()
{
$extension = $this->createExtension(null);
self::assertFalse($extension->isNative());
}
public function testIsNativeReturnsFalseWhenAttributeNotSet()
{
$request = Request::create('/');
$extension = $this->createExtension($request);
self::assertFalse($extension->isNative());
}
public function testGetFunctionsRegistersUxIsNative()
{
$extension = $this->createExtension(null);
$functions = $extension->getFunctions();
$functionNames = array_map(static fn ($f) => $f->getName(), $functions);
self::assertContains('ux_is_native', $functionNames);
}
private function createExtension(?Request $request): NativeExtension
{
$requestStack = new RequestStack();
if (null !== $request) {
$requestStack->push($request);
}
return new NativeExtension($requestStack);
}
}

View File

@@ -0,0 +1,19 @@
<?php
declare(strict_types=1);
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
use Symfony\Component\ErrorHandler\ErrorHandler;
require_once __DIR__.'/../vendor/autoload.php';
// @see https://github.com/symfony/symfony/issues/53812
ErrorHandler::register(null, false);

View File

@@ -0,0 +1,58 @@
<?php
declare(strict_types=1);
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Symfony\Component\DependencyInjection\Loader\Configurator;
use Symfony\UX\Native\Configuration\Configuration;
use Symfony\UX\Native\Tests\AndroidConfigurationFactory;
use Symfony\UX\Native\Tests\IosConfigurationFactory;
return static function (ContainerConfigurator $container) {
$services = $container
->services()
->defaults()
->autowire()
->autoconfigure()
;
$container->parameters()->set('kernel.debug', true);
$container->import(__DIR__.'/../config/services.php');
$container->import(__DIR__.'/../config/debug.php');
$container->extension('ux_native', [
'output_dir' => '%kernel.cache_dir%/output',
]);
$container->extension('framework', [
'test' => true,
'secret' => 'test',
'http_method_override' => true,
'handle_all_throwables' => true,
'assets' => [
'enabled' => true,
],
'default_locale' => 'en',
'php_errors' => [
'log' => true,
],
]);
$services->set(IosConfigurationFactory::class);
$services->set('.ux_native.config.ios_v1', Configuration::class)
->factory([IosConfigurationFactory::class, 'v1'])
->tag('ux_native.configuration', ['path' => '/config/ios_v1.json'])
;
// AndroidConfigurationFactory uses #[AsNativeConfiguration] attribute on methods
$services->set(AndroidConfigurationFactory::class);
};