diff --git a/splitsh.json b/splitsh.json index 774d00626ef..8fa4f9d7ce6 100644 --- a/splitsh.json +++ b/splitsh.json @@ -6,6 +6,7 @@ "ux-dropzone": "src/Dropzone", "ux-chartjs": "src/Chartjs", "ux-cropperjs": "src/Cropperjs", + "ux-design-tokens": "src/DesignTokens", "ux-icons": "src/Icons", "ux-live-component": "src/LiveComponent", "ux-map": { diff --git a/src/DesignTokens/.gitattributes b/src/DesignTokens/.gitattributes new file mode 100644 index 00000000000..f9a8f2adcb4 --- /dev/null +++ b/src/DesignTokens/.gitattributes @@ -0,0 +1,6 @@ +/.git* export-ignore +/.symfony.bundle.yaml export-ignore +/doc export-ignore +/phpstan.dist.neon export-ignore +/phpunit.dist.xml export-ignore +/tests export-ignore diff --git a/src/DesignTokens/.gitignore b/src/DesignTokens/.gitignore new file mode 100644 index 00000000000..9e5e4be323d --- /dev/null +++ b/src/DesignTokens/.gitignore @@ -0,0 +1,8 @@ +/config/reference.php +/config/schema.json +/vendor/ +/composer.lock +/phpunit.xml +/.phpunit.cache + +/var diff --git a/src/DesignTokens/.symfony.bundle.yaml b/src/DesignTokens/.symfony.bundle.yaml new file mode 100644 index 00000000000..9e3566394b9 --- /dev/null +++ b/src/DesignTokens/.symfony.bundle.yaml @@ -0,0 +1,3 @@ +branches: ['3.x'] +maintained_branches: ['3.x'] +doc_dir: 'doc' diff --git a/src/DesignTokens/CHANGELOG.md b/src/DesignTokens/CHANGELOG.md new file mode 100644 index 00000000000..dc0aeb30bd7 --- /dev/null +++ b/src/DesignTokens/CHANGELOG.md @@ -0,0 +1,5 @@ +# CHANGELOG + +## 3.6.0 + +- Add the component. diff --git a/src/DesignTokens/LICENSE b/src/DesignTokens/LICENSE new file mode 100644 index 00000000000..94b768f8ae8 --- /dev/null +++ b/src/DesignTokens/LICENSE @@ -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. diff --git a/src/DesignTokens/README.md b/src/DesignTokens/README.md new file mode 100644 index 00000000000..04b7f64a73b --- /dev/null +++ b/src/DesignTokens/README.md @@ -0,0 +1,140 @@ +# Symfony UX Design Tokens + +**EXPERIMENTAL** This bundle is currently experimental and is likely to change, +possibly significantly, before its first stable release. + +A design token is a named design decision. Write it once in a +[DTCG 2025.10](https://www.designtokens.org/tr/2025.10/) file and this bundle hands +the resolved value to CSS, Twig, PHP and your other build tools. Resolution +happens on the server, so a theme per user, per brand or per tenant is a +Resolver input rather than a rebuild. + +## Installation + +```bash +composer require symfony/ux-design-tokens +``` + +## Usage + +Write the tokens in a DTCG file, `design/brand.tokens.json`. Keep it outside +`assets/`, where AssetMapper would publish it: + +```json +{ + "color": { + "action": { + "$type": "color", + "primary": { + "$value": { "colorSpace": "oklch", "components": [0.62, 0.18, 255] } + } + } + }, + "dimension": { + "radius": { + "$type": "dimension", + "control": { + "$value": { "value": 0.375, "unit": "rem" } + } + } + } +} +``` + +Point the bundle at it. The configuration is required: without it, +`ux_token_css()` renders an empty `:root {}` block. + +```yaml +# config/packages/ux_design_tokens.yaml +ux_design_tokens: + paths: + - '%kernel.project_dir%/design/brand.tokens.json' +``` + +Render the variables once, in the document head. With TwigBundle enabled: + +```twig +{# templates/base.html.twig #} +{{ ux_token_css() }} +``` + +```html + +``` + +Stylesheets read those variables and never mention a token file. Changing the +source changes the interface without touching CSS. When the sheet is the same +for every visitor, `ux_token_stylesheet()` links it as a versioned AssetMapper +asset instead of inlining it. + +Custom properties do not reach everywhere: an email, a PDF, an SVG attribute, +or PHP that needs the value itself. The registry serves the same tokens, typed: + +```php +// src/Mailer/BrandMailer.php +namespace App\Mailer; + +use Symfony\UX\DesignTokens\TokenRegistryInterface; + +final class BrandMailer +{ + public function __construct(private readonly TokenRegistryInterface $tokens) + { + } + + public function accent(): string + { + // "oklch(62% 0.18 255)" + return (string) $this->tokens->get('color.action.primary'); + } +} +``` + +Casting is the output edge. Before it the value keeps its DTCG structure, so a +consumer reads the color space and the components instead of parsing a string. + +## What it provides + +- DTCG Format, Color and Resolver Module 2025.10 support +- Typed PHP value objects and a lazy, cached `TokenRegistryInterface` service +- Twig functions, generated CSS custom properties and versioned AssetMapper + assets +- Light and dark color schemes in one stylesheet: the dark block holds only + the custom properties that change +- Lint, fix, import and export commands, and a debug command + naming the source file behind every resolved value +- DTCG, CSS, JavaScript, Tailwind CSS v4 and DESIGN.md exports +- Tailwind CSS v4 `@theme` import that lists what DTCG cannot represent +- Executable conformance fixtures derived from the reports + +## Interoperability + +The reference is the [DTCG 2025.10](https://www.designtokens.org/tr/2025.10/) +specification, in its Format, Color and Resolver modules. Other design tools +and build pipelines read the same files. + +## Documentation + +Read the [documentation](doc/index.rst) in this repository. + +> [!IMPORTANT] +> **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-design-tokens/current/index.html) +- [Report issues](https://github.com/symfony/ux/issues) and + [send Pull Requests](https://github.com/symfony/ux/pulls) + in the [main Symfony UX repository](https://github.com/symfony/ux) diff --git a/src/DesignTokens/composer.json b/src/DesignTokens/composer.json new file mode 100644 index 00000000000..431bb34cca9 --- /dev/null +++ b/src/DesignTokens/composer.json @@ -0,0 +1,68 @@ +{ + "name": "symfony/ux-design-tokens", + "description": "Renders DTCG design tokens as CSS variables, Twig and PHP values.", + "type": "symfony-bundle", + "license": "MIT", + "keywords": [ + "symfony-ux", + "design-tokens", + "dtcg", + "css", + "twig" + ], + "authors": [ + { + "name": "Simon André", + "email": "smn.andre@gmail.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "homepage": "https://symfony.com", + "require": { + "php": ">=8.4", + "psr/log": "^1.1|^2|^3", + "symfony/cache-contracts": "^3.5", + "symfony/config": "^7.4|^8.0", + "symfony/console": "^7.4|^8.0", + "symfony/dependency-injection": "^7.4|^8.0", + "symfony/filesystem": "^7.4|^8.0", + "symfony/http-kernel": "^7.4|^8.0", + "symfony/service-contracts": "^3.5" + }, + "require-dev": { + "phpstan/phpstan": "^2.1", + "phpunit/phpunit": "^11.1|^12.0", + "symfony/asset-mapper": "^7.4|^8.0", + "symfony/cache": "^7.4|^8.0", + "symfony/framework-bundle": "^7.4|^8.0", + "symfony/twig-bundle": "^7.4|^8.0", + "symfony/yaml": "^7.4|^8.0", + "twig/twig": "^3.24|^4.0" + }, + "conflict": { + "twig/twig": "<3.24" + }, + "autoload": { + "psr-4": { + "Symfony\\UX\\DesignTokens\\": "src/" + } + }, + "autoload-dev": { + "psr-4": { + "Symfony\\UX\\DesignTokens\\Tests\\": "tests/" + } + }, + "config": { + "sort-packages": true + }, + "extra": { + "thanks": { + "name": "symfony/ux", + "url": "https://github.com/symfony/ux" + } + }, + "minimum-stability": "dev" +} diff --git a/src/DesignTokens/config/asset_mapper.php b/src/DesignTokens/config/asset_mapper.php new file mode 100644 index 00000000000..9236d7fbc7b --- /dev/null +++ b/src/DesignTokens/config/asset_mapper.php @@ -0,0 +1,29 @@ + + * + * 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\Component\AssetMapper\Event\PreAssetsCompileEvent; +use Symfony\UX\DesignTokens\EventListener\WarmDesignTokensOnAssetCompileListener; + +return static function (ContainerConfigurator $container): void { + $container->services() + ->set('.ux_design_tokens.asset_compile_listener', WarmDesignTokensOnAssetCompileListener::class) + ->args([ + service('.ux_design_tokens.cache_warmer'), + param('kernel.build_dir'), + ]) + ->tag('kernel.event_listener', [ + 'event' => PreAssetsCompileEvent::class, + 'method' => '__invoke', + ]) + ; +}; diff --git a/src/DesignTokens/config/cache.php b/src/DesignTokens/config/cache.php new file mode 100644 index 00000000000..8991682960e --- /dev/null +++ b/src/DesignTokens/config/cache.php @@ -0,0 +1,21 @@ + + * + * 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; + +return static function (ContainerConfigurator $container): void { + $container->services() + ->set('.ux_design_tokens.cache') + ->parent('cache.system') + ->private() + ->tag('cache.pool') + ; +}; diff --git a/src/DesignTokens/config/services.php b/src/DesignTokens/config/services.php new file mode 100644 index 00000000000..55e10235acd --- /dev/null +++ b/src/DesignTokens/config/services.php @@ -0,0 +1,172 @@ + + * + * 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\Component\Yaml\Yaml; +use Symfony\UX\DesignTokens\Bridge\GoogleDesignMd\DesignMdGenerator; +use Symfony\UX\DesignTokens\Bridge\Tailwind\ThemeGenerator; +use Symfony\UX\DesignTokens\Bridge\Tailwind\ThemeImporter; +use Symfony\UX\DesignTokens\CacheWarmer\DesignTokensCacheWarmer; +use Symfony\UX\DesignTokens\CacheWarmer\StylesheetCache; +use Symfony\UX\DesignTokens\Command\DebugTokensCommand; +use Symfony\UX\DesignTokens\Command\ExportCommand; +use Symfony\UX\DesignTokens\Command\ImportCommand; +use Symfony\UX\DesignTokens\Command\LintDesignTokensCommand; +use Symfony\UX\DesignTokens\Generator\ColorScheme; +use Symfony\UX\DesignTokens\Generator\CssGenerator; +use Symfony\UX\DesignTokens\Generator\DtcgGenerator; +use Symfony\UX\DesignTokens\Generator\JavaScriptGenerator; +use Symfony\UX\DesignTokens\Resolver\ConfiguredTokenResolver; +use Symfony\UX\DesignTokens\Resolver\DocumentLoaderInterface; +use Symfony\UX\DesignTokens\Resolver\JsonDocumentLoader; +use Symfony\UX\DesignTokens\Resolver\TokenResolverInterface; +use Symfony\UX\DesignTokens\Resolver\TokenTreeBuilder; +use Symfony\UX\DesignTokens\TokenRegistry; +use Symfony\UX\DesignTokens\TokenRegistryInterface; +use Symfony\UX\DesignTokens\Validation\DtcgValidator; +use Symfony\UX\DesignTokens\Validation\Normalizer; + +return static function (ContainerConfigurator $container): void { + $container->services() + ->set('.ux_design_tokens.document_loader', JsonDocumentLoader::class) + ->args([ + param('kernel.project_dir'), + param('.ux_design_tokens.allowed_roots'), + ]) + + ->alias(DocumentLoaderInterface::class, '.ux_design_tokens.document_loader') + + // Stateful: each consumer needs its own instance. + ->set('.ux_design_tokens.token_tree_builder', TokenTreeBuilder::class) + ->share(false) + ->args([ + service(DocumentLoaderInterface::class), + ]) + + ->set('.ux_design_tokens.validator', DtcgValidator::class) + ->args([ + service('.ux_design_tokens.token_tree_builder'), + null, + service(DocumentLoaderInterface::class), + ]) + + ->set('.ux_design_tokens.normalizer', Normalizer::class) + ->args([ + service('.ux_design_tokens.validator'), + ]) + + ->set('.ux_design_tokens.resolver.configured', ConfiguredTokenResolver::class) + ->args([ + service(DocumentLoaderInterface::class), + param('.ux_design_tokens.paths'), + param('.ux_design_tokens.resolver_path'), + service('.ux_design_tokens.cache')->nullOnInvalid(), + param('kernel.debug'), + ]) + + ->alias('.ux_design_tokens.resolver', '.ux_design_tokens.resolver.configured') + + ->alias(TokenResolverInterface::class, '.ux_design_tokens.resolver') + + ->set('.ux_design_tokens.registry', TokenRegistry::class) + ->args([ + service('.ux_design_tokens.resolver'), + param('.ux_design_tokens.resolver_inputs'), + ]) + ->tag('kernel.reset', ['method' => 'reset']) + + ->alias(TokenRegistryInterface::class, '.ux_design_tokens.registry') + + ->set('.ux_design_tokens.importer.tailwind', ThemeImporter::class) + ->tag('ux_design_tokens.importer', ['format' => 'tailwind']) + + ->set('.ux_design_tokens.generator.dtcg', DtcgGenerator::class) + ->tag('ux_design_tokens.generator', ['format' => 'dtcg']) + + ->set('.ux_design_tokens.generator.css', CssGenerator::class) + ->args([ + param('.ux_design_tokens.css_prefix'), + ]) + ->tag('ux_design_tokens.generator', ['format' => 'css']) + + ->set('.ux_design_tokens.generator.javascript', JavaScriptGenerator::class) + ->tag('ux_design_tokens.generator', ['format' => 'javascript']) + + ->set('.ux_design_tokens.generator.tailwind', ThemeGenerator::class) + ->tag('ux_design_tokens.generator', ['format' => 'tailwind']) + + ->set('.ux_design_tokens.color_scheme', ColorScheme::class) + ->args([ + param('.ux_design_tokens.color_scheme.modifier'), + param('.ux_design_tokens.color_scheme.light'), + param('.ux_design_tokens.color_scheme.dark'), + ]) + + ->set('.ux_design_tokens.stylesheet_cache', StylesheetCache::class) + ->args([ + service('.ux_design_tokens.resolver'), + service('.ux_design_tokens.generator.css'), + param('kernel.build_dir'), + param('.ux_design_tokens.resolver_inputs'), + service('.ux_design_tokens.color_scheme'), + param('kernel.debug'), + abstract_arg('fingerprint of the configuration, set by the bundle'), + ]) + + ->set('.ux_design_tokens.cache_warmer', DesignTokensCacheWarmer::class) + ->args([ + service('.ux_design_tokens.stylesheet_cache'), + ]) + ->tag('kernel.cache_warmer') + + ->set('.ux_design_tokens.command.export', ExportCommand::class) + ->args([ + service('.ux_design_tokens.registry'), + tagged_locator('ux_design_tokens.generator', 'format'), + param('.ux_design_tokens.css_prefix'), + service('.ux_design_tokens.color_scheme'), + ]) + ->tag('console.command') + + ->set('.ux_design_tokens.command.import', ImportCommand::class) + ->args([ + tagged_locator('ux_design_tokens.importer', 'format'), + service('.ux_design_tokens.token_tree_builder'), + ]) + ->tag('console.command') + + ->set('.ux_design_tokens.command.lint', LintDesignTokensCommand::class) + ->args([ + service('.ux_design_tokens.validator'), + service('.ux_design_tokens.normalizer'), + service('.ux_design_tokens.registry'), + param('.ux_design_tokens.paths'), + param('.ux_design_tokens.resolver_path'), + ]) + ->tag('console.command') + + ->set('.ux_design_tokens.command.debug', DebugTokensCommand::class) + ->args([ + service('.ux_design_tokens.registry'), + service('.ux_design_tokens.resolver.configured'), + param('.ux_design_tokens.resolver_inputs'), + ]) + ->tag('console.command') + ; + + if (class_exists(Yaml::class)) { + $container->services() + ->set('.ux_design_tokens.generator.design_md', DesignMdGenerator::class) + ->tag('ux_design_tokens.generator', ['format' => 'design.md']) + ; + } +}; diff --git a/src/DesignTokens/config/twig.php b/src/DesignTokens/config/twig.php new file mode 100644 index 00000000000..5adec17b5e1 --- /dev/null +++ b/src/DesignTokens/config/twig.php @@ -0,0 +1,33 @@ + + * + * 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\DesignTokens\Twig\DesignTokenExtension; +use Symfony\UX\DesignTokens\Twig\DesignTokenRuntime; + +return static function (ContainerConfigurator $container): void { + $container->services() + ->set('.ux_design_tokens.twig_extension', DesignTokenExtension::class) + ->tag('twig.extension') + + ->set('.ux_design_tokens.twig_runtime', DesignTokenRuntime::class) + ->args([ + service('.ux_design_tokens.registry'), + service('.ux_design_tokens.color_scheme'), + service('.ux_design_tokens.stylesheet_cache'), + service('asset_mapper')->nullOnInvalid(), + service('.ux_design_tokens.generator.css'), + ]) + ->tag('kernel.reset', ['method' => 'reset']) + ->tag('twig.runtime') + ; +}; diff --git a/src/DesignTokens/doc/index.rst b/src/DesignTokens/doc/index.rst new file mode 100644 index 00000000000..ca48f48b1ef --- /dev/null +++ b/src/DesignTokens/doc/index.rst @@ -0,0 +1,618 @@ +Symfony UX Design Tokens +======================== + +.. caution:: + + **EXPERIMENTAL** This bundle is currently experimental and is likely to + change, possibly significantly, before its first stable release. + +A design token is a named design decision, such as a brand color or a spacing +step. This bundle reads tokens written in the `DTCG 2025.10`_ format of the +W3C Design Tokens Community Group, and hands their resolved values to CSS, +Twig, PHP and other build tools. + +* **One source.** A brand color used in a stylesheet, a template, an email and + a design file lives in the token file, and everything else reads it. +* **Values, not strings.** In PHP, a token keeps its DTCG structure: a color + has a color space and components. CSS is one projection among several. +* **Resolution on the server.** A theme per user, brand or tenant is a Resolver + input, not a rebuild. + +Installation +------------ + +.. code-block:: terminal + + $ composer require symfony/ux-design-tokens + +With Symfony Flex, the bundle is registered for you. Without Flex, register it +in ``config/bundles.php``:: + + // config/bundles.php + return [ + // ... + Symfony\UX\DesignTokens\UXDesignTokensBundle::class => ['all' => true], + ]; + +Writing a token file +-------------------- + +A DTCG file is JSON: a tree of groups, where a node with ``$value`` is a token +and its path is its position in the tree. Values are objects, not CSS strings: + +.. code-block:: json + + { + "color": { + "palette": { + "$type": "color", + "blue-500": { "$value": { "colorSpace": "oklch", "components": [0.62, 0.18, 255] } }, + "slate-900": { "$value": { "colorSpace": "oklch", "components": [0.24, 0.02, 255] } } + }, + "action": { + "primary": { "$value": "{color.palette.blue-500}" } + }, + "content": { + "default": { "$value": "{color.palette.slate-900}" } + } + }, + "dimension": { + "$type": "dimension", + "radius": { "control": { "$value": { "value": 0.375, "unit": "rem" } } }, + "spacing": { "md": { "$value": { "value": 1, "unit": "rem" } } } + } + } + +``$type`` set on a group applies to every token under it. +``color.action.primary`` is an alias: it names a role and points at a palette +token instead of repeating its value. A ``$ref`` JSON Pointer can also address +a token, a group or a member of a value, in the same file or in another local +file. Cycles, missing targets and values that do not match their type fail +with the path of the token. + +The bundle supports the 13 DTCG token types: ``color``, ``dimension``, +``number``, ``duration``, ``fontFamily``, ``fontWeight``, ``cubicBezier``, +``strokeStyle``, ``border``, ``transition``, ``shadow``, ``gradient`` and +``typography``. The last six are composite values made of the others. + +Keep token files outside ``assets/``, since AssetMapper publishes every file +under that directory. The examples use a ``design/`` directory at the project +root. + +Starting from a design tool +~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +Tokens Studio exports DTCG. Figma exports a variable collection as DTCG from +the context menu of the collection in the variables panel, with these limits: + +* one file per mode, and nothing that ties the modes together; +* an alias is exported as the value it points to; the link survives only in + ``$extensions.com.figma.aliasData``; +* a number variable is a ``number`` without unit, even for a spacing or a + radius; +* a string variable has the ``string`` type, which DTCG does not define, so the + file fails validation until that variable is removed; +* numbers keep the 32-bit precision Figma stores, such as + ``0.699999988079071`` for ``0.7``. + +A Resolver document then ties the files together: a collection with one mode +becomes a set, a collection with several modes a modifier. See +`Themes with a Resolver`_. + +A Tailwind CSS v4 theme converts to DTCG with ``ux:design-tokens:import``; see +`Importing a Tailwind theme`_. + +Configuration +------------- + +.. code-block:: yaml + + # config/packages/ux_design_tokens.yaml + ux_design_tokens: + paths: + - '%kernel.project_dir%/design/base.tokens.json' + - '%kernel.project_dir%/design/brand.tokens.json' + color_scheme: + modifier: scheme + light: light + dark: dark + css_prefix: dt + +========================= ================ =========== =============================== +Option Type Default Purpose +========================= ================ =========== =============================== +``paths`` list of strings ``[]`` Token files, merged in order +``resolver.path`` string or null ``null`` Resolver document +``resolver.inputs`` map ``{}`` Context selected per modifier +``color_scheme.modifier`` string ``scheme`` Modifier of the color schemes +``color_scheme.light`` string ``light`` Context written to ``:root`` +``color_scheme.dark`` string ``dark`` Context written for dark +``css_prefix`` string ``dt`` Prefix of the CSS variables +========================= ================ =========== =============================== + +The bundle reads nothing until ``paths`` or ``resolver.path`` is set. + +Files in ``paths`` are merged in order: a later file overrides the tokens it +redefines and keeps the others. Aliases are resolved after the merge, so an +alias sees the final value. A list value, such as a ``fontFamily``, is +replaced as a whole. + +A path is absolute or relative to the project directory, and may use container +parameters. Every configured file is tracked, so editing it rebuilds the +container. Environment variables work in paths and Resolver inputs: + +.. code-block:: yaml + + # config/packages/ux_design_tokens.yaml + ux_design_tokens: + resolver: + path: '%env(resolve:DESIGN_TOKENS_RESOLVER)%' + inputs: + scheme: '%env(DESIGN_TOKENS_SCHEME)%' + +.. caution:: + + A path built from an environment variable must be **absolute**, or start + with ``%kernel.project_dir%``: its value is unknown while the container + compiles. Such a file is not tracked either, so run ``cache:clear`` after + changing it. + +A ``$ref`` can point at another document, and that document is read as data. +Reads are confined to the project directory and the directories of the +configured files, symlinks resolved. To read documents from elsewhere, such as +an object store, decorate the service aliased on +``Symfony\UX\DesignTokens\Resolver\DocumentLoaderInterface``. + +Each resolution is stored in the system cache the first time it is needed, and +``cache:clear`` empties it. In debug mode, a resolution is computed again when +one of the documents it read changes. + +Rendering the tokens as CSS +--------------------------- + +With TwigBundle installed, call ``ux_token_css()`` once in the document head: + +.. code-block:: twig + + {# templates/base.html.twig #} + {% block stylesheets %} + {{ ux_token_css() }} + {% endblock %} + +It returns a complete `` + +Stylesheets read the variables and never mention the token file: + +.. code-block:: css + + /* assets/styles/app.css */ + .button { + background: var(--dt-color-action-primary); + color: var(--dt-color-content-default); + border-radius: var(--dt-dimension-radius-control); + padding: var(--dt-dimension-spacing-md); + } + +A few rules shape the output: + +* Aliases are resolved first: ``--dt-color-action-primary`` holds the value, + not a ``var()`` chain. +* Color, duration, number and ``px`` dimension tokens get an ``@property`` + rule, which types the variable and makes it animatable. A ``rem`` dimension + gets none, since an ``@property`` initial value must not depend on a font + size. +* ``\3C`` is the CSS escape of ``<``, so no token value can close the + `` + +The page follows the operating system until ``data-theme="light"`` or +``data-theme="dark"`` is set on ````. The comparison runs variable by +variable, after composite tokens are split: a border whose color alone changes +writes its value and its color, not its width. + +Linking a stylesheet instead of inlining it +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +``ux_token_css()`` writes the CSS into every response. When the stylesheet is +the same for every visitor, ``ux_token_stylesheet()`` links it instead, so the +browser caches it. It needs AssetMapper: + +.. code-block:: twig + + {# templates/base.html.twig #} + {{ ux_token_stylesheet() }} + +.. code-block:: html + + + +The file lives in the build directory. ``cache:warmup`` writes it, a debug +kernel writes it again when a source changes, and ``asset-map:compile`` picks +it up like any other asset. ``ux_token_css()`` inlines the same file while it +is up to date. + +To produce the CSS in PHP, without the ``'; + } + + public function reset(): void + { + $this->stylesheetContents = false; + } + + /** + * An inline tag never writes to the build directory, which may be read-only. + * + * @param array $inputs + */ + private function css(array $inputs): string + { + if ([] === $inputs) { + if (false === $this->stylesheetContents) { + $this->stylesheetContents = $this->stylesheets?->read(); + } + if (null !== $this->stylesheetContents) { + return $this->stylesheetContents; + } + } + + $contexts = $this->colorScheme->contexts($this->tokenRegistry->getModifiers(), $inputs); + if (null === $contexts) { + return $this->css->generate($this->tokenRegistry->all($inputs)); + } + + return $this->css->generate($this->tokenRegistry->all($contexts[0]), [ + GeneratorInterface::DARK_TOKENS => $this->tokenRegistry->all($contexts[1]), + ]); + } +} diff --git a/src/DesignTokens/src/UXDesignTokensBundle.php b/src/DesignTokens/src/UXDesignTokensBundle.php new file mode 100644 index 00000000000..fbd8a05e578 --- /dev/null +++ b/src/DesignTokens/src/UXDesignTokensBundle.php @@ -0,0 +1,282 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\UX\DesignTokens; + +use Symfony\Component\AssetMapper\AssetMapperInterface; +use Symfony\Component\Config\Definition\Configurator\DefinitionConfigurator; +use Symfony\Component\Config\Resource\FileResource; +use Symfony\Component\DependencyInjection\ContainerBuilder; +use Symfony\Component\DependencyInjection\Loader\Configurator\ContainerConfigurator; +use Symfony\Component\Filesystem\Exception\IOExceptionInterface; +use Symfony\Component\Filesystem\Filesystem; +use Symfony\Component\Filesystem\Path; +use Symfony\Component\HttpKernel\Bundle\AbstractBundle; +use Symfony\UX\DesignTokens\CacheWarmer\StylesheetCache; +use Symfony\UX\DesignTokens\Exception\InvalidArgumentException; +use Symfony\UX\DesignTokens\Exception\RuntimeException; +use Symfony\UX\DesignTokens\Generator\GeneratorInterface; +use Symfony\UX\DesignTokens\Importer\ImporterInterface; + +/** + * @author Simon André + */ +final class UXDesignTokensBundle extends AbstractBundle +{ + public const ASSET_NAMESPACE = 'design-tokens'; + + protected string $extensionAlias = 'ux_design_tokens'; + + public function getPath(): string + { + return \dirname(__DIR__); + } + + public function configure(DefinitionConfigurator $definition): void + { + $definition->rootNode() + ->children() + ->arrayNode('paths') + ->info('DTCG token files, merged in order.') + ->scalarPrototype()->cannotBeEmpty()->end() + ->defaultValue([]) + ->end() + ->arrayNode('resolver') + ->info('A DTCG 2025.10 Resolver document and the contexts it selects by default.') + ->addDefaultsIfNotSet() + ->children() + ->scalarNode('path') + ->defaultNull() + ->cannotBeEmpty() + ->info('Path to a .resolver.json document.') + ->end() + ->arrayNode('inputs') + ->info('Context selected for each modifier, such as {scheme: dark}.') + ->normalizeKeys(false) + ->variablePrototype() + ->validate() + ->ifTrue(static fn (mixed $value): bool => !\is_string($value) && !\is_int($value) && !\is_float($value)) + ->thenInvalid('Resolver input values must be strings or numbers.') + ->end() + ->end() + ->defaultValue([]) + ->end() + ->end() + ->validate() + ->ifTrue(static fn (array $resolver): bool => [] !== $resolver['inputs'] && null === $resolver['path']) + ->thenInvalid('"resolver.inputs" requires "resolver.path": inputs select contexts of a Resolver document.') + ->end() + ->end() + ->arrayNode('color_scheme') + ->info('The Resolver modifier whose contexts the CSS output writes as the light and dark color schemes.') + ->addDefaultsIfNotSet() + ->children() + ->scalarNode('modifier') + ->defaultValue('scheme') + ->cannotBeEmpty() + ->info('Name of the modifier.') + ->end() + ->scalarNode('light') + ->defaultValue('light') + ->cannotBeEmpty() + ->info('Context written to :root.') + ->end() + ->scalarNode('dark') + ->defaultValue('dark') + ->cannotBeEmpty() + ->info('Context whose differences are written for prefers-color-scheme: dark and [data-theme="dark"].') + ->end() + ->end() + ->end() + ->scalarNode('css_prefix') + ->defaultValue('dt') + ->cannotBeEmpty() + ->validate() + ->ifTrue(static fn (mixed $value): bool => !\is_string($value) || !preg_match('/^[A-Za-z_][A-Za-z0-9_-]*$/', $value)) + ->thenInvalid('The CSS prefix must start with a letter or underscore and contain only letters, digits, underscores, or hyphens.') + ->end() + ->info('Application-owned prefix for generated CSS custom properties.') + ->end() + ->end() + ; + } + + /** + * @param array $config + */ + public function loadExtension(array $config, ContainerConfigurator $container, ContainerBuilder $builder): void + { + $projectDir = $builder->getParameter('kernel.project_dir'); + \assert(\is_string($projectDir)); + + $pathsConfig = $config['paths'] ?? []; + \assert(\is_array($pathsConfig)); + /** @var list $pathsConfig */ + $paths = array_map( + fn (string $path): string => $this->resolveProjectPath($path, $projectDir, $builder), + $pathsConfig, + ); + $resolver = $config['resolver']; + \assert(\is_array($resolver)); + $colorScheme = $config['color_scheme']; + \assert(\is_array($colorScheme)); + $resolverPath = \is_string($resolver['path'] ?? null) + ? $this->resolveProjectPath($resolver['path'], $projectDir, $builder) + : null; + + $container->parameters() + ->set('.ux_design_tokens.paths', $paths) + ->set('.ux_design_tokens.resolver_path', $resolverPath) + ->set('.ux_design_tokens.allowed_roots', $this->allowedRoots($paths, $resolverPath, $projectDir)) + ->set('.ux_design_tokens.resolver_inputs', $resolver['inputs']) + ->set('.ux_design_tokens.color_scheme.modifier', $colorScheme['modifier']) + ->set('.ux_design_tokens.color_scheme.light', $colorScheme['light']) + ->set('.ux_design_tokens.color_scheme.dark', $colorScheme['dark']) + ->set('.ux_design_tokens.css_prefix', $config['css_prefix']) + ; + + // A path built from an environment variable is unknown until runtime. + foreach ([...$paths, $resolverPath] as $path) { + if (!\is_string($path) || $this->holdsEnvPlaceholder($path, $builder)) { + continue; + } + if (is_file($path)) { + $builder->addResource(new FileResource($path)); + } else { + $builder->fileExists($path); + } + } + + $builder->registerForAutoconfiguration(GeneratorInterface::class) + ->addTag('ux_design_tokens.generator'); + + $builder->registerForAutoconfiguration(ImporterInterface::class) + ->addTag('ux_design_tokens.importer'); + + $container->import('../config/services.php'); + + // Invalidates a stylesheet written with another configuration. + $builder->getDefinition('.ux_design_tokens.stylesheet_cache')->replaceArgument(6, hash('xxh128', serialize([ + $paths, + $resolverPath, + $resolver['inputs'], + $colorScheme, + $config['css_prefix'], + ]))); + + $bundles = $builder->hasParameter('kernel.bundles') ? $builder->getParameter('kernel.bundles') : []; + + if (\is_array($bundles) && isset($bundles['FrameworkBundle'])) { + $container->import('../config/cache.php'); + } + + if (\is_array($bundles) && isset($bundles['TwigBundle'])) { + $container->import('../config/twig.php'); + } + + if ($this->isAssetMapperAvailable($builder)) { + $container->import('../config/asset_mapper.php'); + } + } + + /** + * @param list $paths + * + * @return list + */ + private function allowedRoots(array $paths, ?string $resolverPath, string $projectDir): array + { + $roots = []; + foreach ([$projectDir, ...array_map('dirname', $paths), null !== $resolverPath ? \dirname($resolverPath) : null] as $directory) { + if (\is_string($directory) && false !== $real = realpath($directory)) { + $roots[$real] = true; + } + } + + return array_keys($roots); + } + + private function holdsEnvPlaceholder(string $value, ContainerBuilder $builder): bool + { + if (str_contains($value, '%env(')) { + return true; + } + + $envs = []; + $builder->resolveEnvPlaceholders($value, null, $envs); + + return [] !== $envs; + } + + public function prependExtension(ContainerConfigurator $container, ContainerBuilder $builder): void + { + if (!$this->isAssetMapperAvailable($builder)) { + return; + } + + $buildDir = $builder->getParameter('kernel.build_dir'); + \assert(\is_string($buildDir)); + + // AssetMapper refuses to start on a mapped directory that does not exist. + $directory = StylesheetCache::directory($buildDir); + try { + new Filesystem()->mkdir($directory); + } catch (IOExceptionInterface $e) { + throw new RuntimeException(\sprintf('Could not create the design token stylesheet directory "%s".', $directory), previous: $e); + } + + $builder->prependExtensionConfig('framework', [ + 'asset_mapper' => [ + 'paths' => [ + $directory => self::ASSET_NAMESPACE, + ], + ], + ]); + } + + private function isAssetMapperAvailable(ContainerBuilder $builder): bool + { + if (!interface_exists(AssetMapperInterface::class)) { + return false; + } + + // Before Symfony 8.2, FrameworkBundle provided the AssetMapper configuration. + if (!$builder->hasParameter('kernel.bundles_metadata')) { + return false; + } + $bundlesMetadata = $builder->getParameter('kernel.bundles_metadata'); + if (!\is_array($bundlesMetadata)) { + return false; + } + if (isset($bundlesMetadata['AssetMapperBundle'])) { + return true; + } + + $framework = $bundlesMetadata['FrameworkBundle'] ?? null; + $path = \is_array($framework) ? $framework['path'] ?? null : null; + + return \is_string($path) && is_file($path.'/Resources/config/asset_mapper.php'); + } + + private function resolveProjectPath(string $path, string $projectDir, ContainerBuilder $builder): string + { + if ($this->holdsEnvPlaceholder($path, $builder)) { + return $path; + } + + $resolved = $builder->getParameterBag()->resolveValue($path); + if (!\is_string($resolved)) { + throw new InvalidArgumentException(\sprintf('The design token path "%s" must resolve to a string.', $path)); + } + + return Path::isAbsolute($resolved) ? $resolved : Path::join($projectDir, $resolved); + } +} diff --git a/src/DesignTokens/src/Validation/ColorRangeInspector.php b/src/DesignTokens/src/Validation/ColorRangeInspector.php new file mode 100644 index 00000000000..551120a15a6 --- /dev/null +++ b/src/DesignTokens/src/Validation/ColorRangeInspector.php @@ -0,0 +1,79 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\UX\DesignTokens\Validation; + +use Symfony\UX\DesignTokens\Token\ColorToken; +use Symfony\UX\DesignTokens\TokenTree; + +/** + * Color 4.2 component ranges are advice: they are reported, not enforced. + * + * @author Simon André + * + * @internal + */ +final class ColorRangeInspector +{ + /** + * @param array $resolvedTokens + * + * @return list one human-readable line per component out of range + */ + public function inspect(array $resolvedTokens): array + { + $warnings = []; + + foreach (TokenTree::flatten($resolvedTokens) as $path => $token) { + if (!$token instanceof ColorToken) { + continue; + } + $value = $token->getValue(); + if (!\is_array($value) || !\is_string($space = $value['colorSpace'] ?? null)) { + continue; + } + $components = $value['components'] ?? null; + if (!\is_array($components)) { + continue; + } + + foreach (array_values($components) as $index => $component) { + if (!\is_int($component) && !\is_float($component)) { + continue; + } + if (null !== $range = self::outOfRange($space, $index, $component)) { + $warnings[] = \sprintf('%s: component %d is %s, outside the usual range %s for "%s".', $path, $index, $component, $range, $space); + } + } + } + + return $warnings; + } + + /** @return string|null the expected range when the component falls outside it */ + private static function outOfRange(string $space, int $index, int|float $component): ?string + { + return match (true) { + \in_array($space, ['srgb', 'srgb-linear', 'display-p3', 'a98-rgb', 'prophoto-rgb', 'rec2020', 'xyz-d65', 'xyz-d50'], true) => self::between($component, 0, 1), + \in_array($space, ['hsl', 'hwb'], true) && 0 === $index => null, + \in_array($space, ['hsl', 'hwb'], true) => self::between($component, 0, 100), + \in_array($space, ['lab', 'lch'], true) && 0 === $index => self::between($component, 0, 100), + \in_array($space, ['oklab', 'oklch'], true) && 0 === $index => self::between($component, 0, 1), + \in_array($space, ['lch', 'oklch'], true) && 1 === $index => $component < 0 ? '[0, ∞)' : null, + default => null, + }; + } + + private static function between(int|float $component, int $min, int $max): ?string + { + return $component < $min || $component > $max ? \sprintf('[%d, %d]', $min, $max) : null; + } +} diff --git a/src/DesignTokens/src/Validation/DtcgValidator.php b/src/DesignTokens/src/Validation/DtcgValidator.php new file mode 100644 index 00000000000..109f15b7d83 --- /dev/null +++ b/src/DesignTokens/src/Validation/DtcgValidator.php @@ -0,0 +1,136 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\UX\DesignTokens\Validation; + +use Symfony\Component\Filesystem\Exception\IOExceptionInterface; +use Symfony\Component\Filesystem\Filesystem; +use Symfony\UX\DesignTokens\Exception\InvalidArgumentException; +use Symfony\UX\DesignTokens\Exception\RuntimeException; +use Symfony\UX\DesignTokens\Resolver\DocumentLoaderInterface; +use Symfony\UX\DesignTokens\Resolver\ResolverDocument; +use Symfony\UX\DesignTokens\Resolver\ResolverSource; +use Symfony\UX\DesignTokens\Resolver\TokenTreeBuilder; + +/** + * @author Simon André + * + * @internal + */ +final class DtcgValidator +{ + private readonly ColorRangeInspector $colorRanges; + + public function __construct( + private readonly TokenTreeBuilder $resolver, + ?ColorRangeInspector $colorRanges = null, + private readonly ?DocumentLoaderInterface $documentLoader = null, + ) { + $this->colorRanges = $colorRanges ?? new ColorRangeInspector(); + } + + /** + * @param bool $partial whether a Resolver completes the document with other + * sources, so that its references may point outside it + * + * @return list warnings worth surfacing, the document is valid either way + */ + public function validateFile(string $path, bool $partial = false): array + { + if (!is_file($path)) { + throw new RuntimeException(\sprintf('Design token document not found: "%s".', $path)); + } + try { + $json = new Filesystem()->readFile($path); + } catch (IOExceptionInterface $e) { + throw new RuntimeException(\sprintf('Could not read design token document: "%s".', $path), previous: $e); + } + + $kind = match (true) { + str_ends_with($path, '.resolver.json') => 'resolver', + str_ends_with($path, '.tokens.json'), str_ends_with($path, '.tokens') => 'tokens', + default => null, + }; + if (null === $kind) { + throw new InvalidArgumentException(\sprintf('Expected a .tokens.json, .tokens, or .resolver.json file, got "%s".', $path)); + } + + return $this->validateJson($json, $path, \dirname($path), $kind, $partial); + } + + /** + * @param 'tokens'|'resolver'|null $kind + * @param bool $partial see validateFile() + * + * @return list warnings worth surfacing, the document is valid either way + */ + public function validateJson(string $json, string $source = 'stdin', string $basePath = '', ?string $kind = null, bool $partial = false): array + { + try { + // Decoded as objects only to tell a top-level {} from a []. + $object = json_decode($json, false, 512, \JSON_THROW_ON_ERROR); + $data = json_decode($json, true, 512, \JSON_THROW_ON_ERROR); + } catch (\JsonException $error) { + throw new RuntimeException(\sprintf('Invalid JSON in "%s": %s', $source, $error->getMessage()), 0, $error); + } + if (!$object instanceof \stdClass || !\is_array($data)) { + throw new RuntimeException(\sprintf('DTCG document "%s" must be a JSON object at the top level.', $source)); + } + + return $this->validate($data, $source, $basePath, $kind, $partial); + } + + /** + * @param array $data + * @param 'tokens'|'resolver'|null $kind + * @param bool $partial see validateFile() + * + * @return list warnings worth surfacing, the document is valid either way + */ + public function validate(array $data, string $source = 'memory', string $basePath = '', ?string $kind = null, bool $partial = false): array + { + $kind ??= $this->looksLikeResolver($data) ? 'resolver' : 'tokens'; + if ('resolver' === $kind) { + return $this->validateResolver($data, $basePath); + } + + $source = new ResolverSource($data, $basePath, \in_array($source, ['stdin', 'memory'], true) ? null : $source); + + return $this->colorRanges->inspect($partial ? $this->resolver->resolvePartial($source) : $this->resolver->resolveSources([$source])); + } + + /** + * @param array $data + * + * @return list + */ + private function validateResolver(array $data, string $basePath): array + { + $warnings = []; + $document = new ResolverDocument($data, $basePath, $this->documentLoader); + foreach ($document->getPermutations() as $inputs) { + $resolved = $this->resolver->resolveSources($document->sourceDescriptors($inputs)); + foreach ($this->colorRanges->inspect($resolved) as $warning) { + $warnings[$warning] = true; + } + } + + return array_keys($warnings); + } + + /** @param array $data */ + private function looksLikeResolver(array $data): bool + { + return '2025.10' === ($data['version'] ?? null) + && \is_array($data['resolutionOrder'] ?? null) + && (\array_key_exists('sets', $data) || \array_key_exists('modifiers', $data)); + } +} diff --git a/src/DesignTokens/src/Validation/Normalizer.php b/src/DesignTokens/src/Validation/Normalizer.php new file mode 100644 index 00000000000..dccb672cf6d --- /dev/null +++ b/src/DesignTokens/src/Validation/Normalizer.php @@ -0,0 +1,65 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\UX\DesignTokens\Validation; + +use Symfony\Component\Filesystem\Exception\IOExceptionInterface; +use Symfony\Component\Filesystem\Filesystem; +use Symfony\UX\DesignTokens\Exception\InvalidArgumentException; +use Symfony\UX\DesignTokens\Exception\RuntimeException; + +/** + * @author Simon André + * + * @internal + */ +final class Normalizer +{ + public function __construct(private readonly DtcgValidator $validator) + { + } + + /** + * @param 'tokens'|'resolver'|null $kind + * @param bool $partial see DtcgValidator::validateFile() + */ + public function normalize(string $json, string $source = 'stdin', string $basePath = '', ?string $kind = null, bool $partial = false): string + { + $this->validator->validateJson($json, $source, $basePath, $kind, $partial); + + $document = json_decode($json, false, 512, \JSON_THROW_ON_ERROR); + + return json_encode( + $document, + \JSON_PRETTY_PRINT | \JSON_PRESERVE_ZERO_FRACTION | \JSON_UNESCAPED_SLASHES | \JSON_UNESCAPED_UNICODE | \JSON_THROW_ON_ERROR, + )."\n"; + } + + /** @param bool $partial see DtcgValidator::validateFile() */ + public function normalizeFile(string $path, bool $partial = false): string + { + if (!is_file($path)) { + throw new RuntimeException(\sprintf('Design token document not found: "%s".', $path)); + } + try { + $json = new Filesystem()->readFile($path); + } catch (IOExceptionInterface $e) { + throw new RuntimeException(\sprintf('Could not read design token document: "%s".', $path), previous: $e); + } + $kind = match (true) { + str_ends_with($path, '.resolver.json') => 'resolver', + str_ends_with($path, '.tokens.json'), str_ends_with($path, '.tokens') => 'tokens', + default => throw new InvalidArgumentException(\sprintf('Expected a .tokens.json, .tokens, or .resolver.json file, got "%s".', $path)), + }; + + return $this->normalize($json, $path, \dirname($path), $kind, $partial); + } +} diff --git a/src/DesignTokens/src/Validation/TokenValueValidator.php b/src/DesignTokens/src/Validation/TokenValueValidator.php new file mode 100644 index 00000000000..da3e84759ca --- /dev/null +++ b/src/DesignTokens/src/Validation/TokenValueValidator.php @@ -0,0 +1,306 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\UX\DesignTokens\Validation; + +use Symfony\UX\DesignTokens\Exception\InvalidArgumentException; + +/** + * @author Simon André + * + * @internal + */ +final class TokenValueValidator +{ + /** @var list */ + public const TYPES = [ + 'color', 'dimension', 'fontFamily', 'fontWeight', 'duration', + 'cubicBezier', 'number', 'strokeStyle', 'border', 'transition', + 'shadow', 'gradient', 'typography', + ]; + + private const COLOR_SPACES = [ + 'srgb', 'srgb-linear', 'hsl', 'hwb', 'lab', 'lch', 'oklab', + 'oklch', 'display-p3', 'a98-rgb', 'prophoto-rgb', 'rec2020', + 'xyz-d65', 'xyz-d50', + ]; + + public const FONT_WEIGHTS = [ + 'thin', 'hairline', 'extra-light', 'ultra-light', 'light', 'normal', + 'regular', 'book', 'medium', 'semi-bold', 'demi-bold', 'bold', + 'extra-bold', 'ultra-bold', 'black', 'heavy', 'extra-black', + 'ultra-black', + ]; + + public function validate(string $type, mixed $value, string $path = ''): void + { + if (!\in_array($type, self::TYPES, true)) { + throw new InvalidArgumentException(\sprintf('Unknown DTCG token type "%s" at "%s".', $type, $path)); + } + + match ($type) { + 'color' => $this->color($value, $path), + 'dimension' => $this->unit($value, ['px', 'rem'], $path, 'dimension'), + 'duration' => $this->unit($value, ['ms', 's'], $path, 'duration'), + 'number' => $this->number($value, $path), + 'fontFamily' => $this->fontFamily($value, $path), + 'fontWeight' => $this->fontWeight($value, $path), + 'cubicBezier' => $this->cubicBezier($value, $path), + 'strokeStyle' => $this->strokeStyle($value, $path), + 'gradient' => $this->gradient($value, $path), + 'border' => $this->border($value, $path), + 'shadow' => $this->shadow($value, $path), + 'transition' => $this->transition($value, $path), + 'typography' => $this->typography($value, $path), + }; + } + + private function color(mixed $value, string $path): void + { + if (!\is_array($value) || array_is_list($value)) { + $this->fail('a structured color', $path); + } + $this->keys($value, ['colorSpace', 'components'], ['alpha', 'hex'], $path); + + $space = $value['colorSpace'] ?? null; + if (!\is_string($space) || !\in_array($space, self::COLOR_SPACES, true)) { + $this->fail('a supported color space', $path.'.colorSpace'); + } + + $components = $value['components'] ?? null; + if (!\is_array($components) || !array_is_list($components) || 3 !== \count($components)) { + $this->fail('exactly three color components', $path.'.components'); + } + foreach ($components as $index => $component) { + if ('none' === $component) { + continue; + } + if ((!\is_int($component) && !\is_float($component)) || (\is_float($component) && !is_finite($component))) { + $this->fail(\sprintf('a numeric color component at index %d', $index), $path.'.components'); + } + // Component ranges are advice (Color 4.2); only the hue has a hard rule. + if (!$this->validHue($space, $index, $component)) { + $this->fail(\sprintf('a hue below 360 at index %d', $index), $path.'.components'); + } + } + + if (\array_key_exists('alpha', $value) + && (!$this->isNumber($value['alpha']) || $value['alpha'] < 0 || $value['alpha'] > 1)) { + $this->fail('an alpha value between 0 and 1', $path.'.alpha'); + } + if (\array_key_exists('hex', $value) + && (!\is_string($value['hex']) || 1 !== preg_match('/^#[0-9a-fA-F]{6}$/D', $value['hex']))) { + $this->fail('a six-digit hexadecimal color fallback', $path.'.hex'); + } + } + + /** Color 4.2: "360 MUST NOT be used" for a hue. */ + private function validHue(string $space, int $index, int|float $component): bool + { + return match (true) { + \in_array($space, ['hsl', 'hwb'], true) && 0 === $index, + \in_array($space, ['lch', 'oklch'], true) && 2 === $index => $component >= 0 && $component < 360, + default => true, + }; + } + + /** @param list $units */ + private function unit(mixed $value, array $units, string $path, string $type): void + { + if (!\is_array($value) || array_is_list($value)) { + $this->fail(\sprintf('a structured %s', $type), $path); + } + $this->keys($value, ['value', 'unit'], [], $path); + if (!$this->isNumber($value['value'])) { + $this->fail('a numeric value', $path.'.value'); + } + if (!\in_array($value['unit'], $units, true)) { + $this->fail('unit '.implode('|', $units), $path.'.unit'); + } + } + + private function number(mixed $value, string $path): void + { + if (!$this->isNumber($value)) { + $this->fail('a finite number', $path); + } + } + + private function fontFamily(mixed $value, string $path): void + { + if (\is_string($value) && !$this->isCurlyReference($value)) { + return; + } + if (!\is_array($value) || !array_is_list($value) || [] === $value) { + $this->fail('a string or non-empty list of strings', $path); + } + foreach ($value as $index => $family) { + if (!\is_string($family) || $this->isCurlyReference($family)) { + $this->fail('a string font family', $path.'['.$index.']'); + } + } + } + + private function fontWeight(mixed $value, string $path): void + { + if ($this->isNumber($value) && $value >= 1 && $value <= 1000) { + return; + } + if (\is_string($value) && \in_array($value, self::FONT_WEIGHTS, true)) { + return; + } + $this->fail('a font weight from 1 to 1000 or a standard keyword', $path); + } + + private function cubicBezier(mixed $value, string $path): void + { + if (!\is_array($value) || !array_is_list($value) || 4 !== \count($value)) { + $this->fail('a list of four cubic Bezier coordinates', $path); + } + foreach ($value as $index => $coordinate) { + if (!$this->isNumber($coordinate) || (0 === $index % 2 && ($coordinate < 0 || $coordinate > 1))) { + $this->fail('a valid cubic Bezier coordinate', $path.'['.$index.']'); + } + } + } + + private function strokeStyle(mixed $value, string $path): void + { + if (\is_string($value)) { + if (!\in_array($value, ['solid', 'dashed', 'dotted', 'double', 'groove', 'ridge', 'outset', 'inset'], true)) { + $this->fail('a valid stroke style keyword', $path); + } + + return; + } + if (!\is_array($value) || array_is_list($value)) { + $this->fail('a structured stroke style', $path); + } + $this->keys($value, ['dashArray', 'lineCap'], [], $path); + if (!\is_array($value['dashArray']) || !array_is_list($value['dashArray']) || [] === $value['dashArray']) { + $this->fail('a non-empty stroke dash array', $path.'.dashArray'); + } + foreach ($value['dashArray'] as $index => $dimension) { + $this->unit($dimension, ['px', 'rem'], $path.'.dashArray['.$index.']', 'dimension'); + } + if (!\in_array($value['lineCap'], ['butt', 'round', 'square'], true)) { + $this->fail('a valid line cap', $path.'.lineCap'); + } + } + + private function gradient(mixed $value, string $path): void + { + if (!\is_array($value) || !array_is_list($value) || [] === $value) { + $this->fail('a non-empty list of gradient stops', $path); + } + foreach ($value as $index => $stop) { + if (!\is_array($stop) || array_is_list($stop)) { + $this->fail('a gradient stop', $path.'['.$index.']'); + } + $this->keys($stop, ['color', 'position'], [], $path.'['.$index.']'); + $this->color($stop['color'], $path.'['.$index.'].color'); + if (!$this->isNumber($stop['position'])) { + $this->fail('a finite gradient position', $path.'['.$index.'].position'); + } + } + } + + private function border(mixed $value, string $path): void + { + if (!\is_array($value) || array_is_list($value)) { + $this->fail('a border object', $path); + } + $this->keys($value, ['color', 'width', 'style'], [], $path); + $this->color($value['color'], $path.'.color'); + $this->unit($value['width'], ['px', 'rem'], $path.'.width', 'dimension'); + $this->strokeStyle($value['style'], $path.'.style'); + } + + private function shadow(mixed $value, string $path): void + { + if (!\is_array($value) || [] === $value) { + $this->fail('a shadow object or non-empty list of shadows', $path); + } + $shadows = array_is_list($value) ? $value : [$value]; + foreach ($shadows as $index => $shadow) { + $itemPath = array_is_list($value) ? $path.'['.$index.']' : $path; + if (!\is_array($shadow) || array_is_list($shadow)) { + $this->fail('a shadow object', $itemPath); + } + $this->keys($shadow, ['color', 'offsetX', 'offsetY', 'blur', 'spread'], ['inset'], $itemPath); + $this->color($shadow['color'], $itemPath.'.color'); + foreach (['offsetX', 'offsetY', 'blur', 'spread'] as $field) { + $this->unit($shadow[$field], ['px', 'rem'], $itemPath.'.'.$field, 'dimension'); + } + if (\array_key_exists('inset', $shadow) && !\is_bool($shadow['inset'])) { + $this->fail('a boolean inset value', $itemPath.'.inset'); + } + } + } + + private function transition(mixed $value, string $path): void + { + if (!\is_array($value) || array_is_list($value)) { + $this->fail('a transition object', $path); + } + $this->keys($value, ['duration', 'delay', 'timingFunction'], [], $path); + foreach (['duration', 'delay'] as $field) { + $this->unit($value[$field], ['ms', 's'], $path.'.'.$field, 'duration'); + } + $this->cubicBezier($value['timingFunction'], $path.'.timingFunction'); + } + + private function typography(mixed $value, string $path): void + { + if (!\is_array($value) || array_is_list($value)) { + $this->fail('a typography object', $path); + } + $this->keys($value, ['fontFamily', 'fontSize', 'fontWeight', 'letterSpacing', 'lineHeight'], [], $path); + $this->fontFamily($value['fontFamily'], $path.'.fontFamily'); + $this->unit($value['fontSize'], ['px', 'rem'], $path.'.fontSize', 'dimension'); + $this->fontWeight($value['fontWeight'], $path.'.fontWeight'); + $this->unit($value['letterSpacing'], ['px', 'rem'], $path.'.letterSpacing', 'dimension'); + $this->number($value['lineHeight'], $path.'.lineHeight'); + } + + /** + * @param array $value + * @param list $required + * @param list $optional + */ + private function keys(array $value, array $required, array $optional, string $path): void + { + foreach ($required as $key) { + if (!\array_key_exists($key, $value)) { + $this->fail(\sprintf('required property "%s"', $key), $path); + } + } + $unknown = array_diff(array_keys($value), $required, $optional); + if ([] !== $unknown) { + $this->fail('no unknown properties ('.implode(', ', $unknown).')', $path); + } + } + + private function isNumber(mixed $value): bool + { + return (\is_int($value) || \is_float($value)) && (!\is_float($value) || is_finite($value)); + } + + private function isCurlyReference(mixed $value): bool + { + return \is_string($value) && 1 === preg_match('/^\{[^{}]+\}$/D', $value); + } + + private function fail(string $expected, string $path): never + { + throw new InvalidArgumentException(\sprintf('Expected %s for DTCG token at "%s".', $expected, $path)); + } +} diff --git a/src/DesignTokens/tests/Documentation/DocumentationExamplesTest.php b/src/DesignTokens/tests/Documentation/DocumentationExamplesTest.php new file mode 100644 index 00000000000..6b427775bdb --- /dev/null +++ b/src/DesignTokens/tests/Documentation/DocumentationExamplesTest.php @@ -0,0 +1,152 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\UX\DesignTokens\Tests\Documentation; + +use PHPUnit\Framework\Attributes\DataProvider; +use PHPUnit\Framework\TestCase; +use Symfony\Component\Yaml\Yaml; +use Symfony\UX\DesignTokens\Resolver\TokenTreeBuilder; +use Symfony\UX\DesignTokens\TokenRegistry; +use Symfony\UX\DesignTokens\Twig\DesignTokenExtension; +use Twig\Environment; +use Twig\Error\SyntaxError; +use Twig\Loader\ArrayLoader; +use Twig\Source; + +final class DocumentationExamplesTest extends TestCase +{ + /** @return iterable */ + public static function examples(): iterable + { + $root = \dirname(__DIR__, 2); + $files = [$root.'/README.md']; + + foreach ($files as $file) { + $contents = (string) file_get_contents($file); + preg_match_all('/^```(json|yaml|php|twig|css)\h*\R(.*?)^```\h*$/ms', $contents, $matches, \PREG_OFFSET_CAPTURE); + + foreach ($matches[1] as $index => [$language]) { + [$example, $offset] = $matches[2][$index]; + $line = 1 + substr_count(substr($contents, 0, $offset), "\n"); + $name = \sprintf('%s:%d (%s)', basename($file), $line, $language); + + yield $name => [$language, $example, $name]; + } + } + + foreach (glob($root.'/doc/*.rst') ?: [] as $file) { + $contents = (string) file_get_contents($file); + + foreach (self::rstExamples($contents) as [$language, $example, $line]) { + $name = \sprintf('%s:%d (%s)', basename($file), $line, $language); + + yield $name => [$language, $example, $name]; + } + } + } + + #[DataProvider('examples')] + public function testDisplayedExampleIsSyntacticallyExecutable(string $language, string $example, string $name): void + { + try { + match ($language) { + 'json' => self::validateJson($example), + 'yaml' => Yaml::parse($example), + 'php' => \PhpToken::tokenize(str_starts_with(ltrim($example), ' self::parseTwig($example, $name), + 'css' => self::assertBalancedCss($example), + }; + } catch (\JsonException|\ParseError|SyntaxError $error) { + self::fail($name.': '.$error->getMessage()); + } + + self::addToAssertionCount(1); + } + + /** @return iterable */ + private static function rstExamples(string $contents): iterable + { + $lines = preg_split('/\R/', $contents); + \assert(\is_array($lines)); + + for ($index = 0; $index < \count($lines); ++$index) { + $literalPhp = str_ends_with($lines[$index], '::'); + if (!$literalPhp && 1 !== preg_match('/^\.\. code-block:: (json|yaml|php|twig|html\+twig|css)$/', $lines[$index], $matches)) { + continue; + } + + $line = $index + 1; + while (isset($lines[$index + 1]) && ('' === $lines[$index + 1] || 1 === preg_match('/^ :[\w.-]+:(\s|$)/', $lines[$index + 1]))) { + ++$index; + } + + $example = []; + while (isset($lines[$index + 1]) && ('' === $lines[$index + 1] || str_starts_with($lines[$index + 1], ' '))) { + ++$index; + $example[] = '' === $lines[$index] ? '' : substr($lines[$index], 4); + } + + while ([] !== $example && '' === $example[array_key_last($example)]) { + array_pop($example); + } + + if ($literalPhp && !str_ends_with($example[0] ?? '', '.php')) { + continue; + } + + yield [$literalPhp ? 'php' : $matches[1], implode("\n", $example)."\n", $line]; + } + } + + private static function assertBalancedCss(string $css): null + { + self::assertSame(substr_count($css, '{'), substr_count($css, '}'), 'CSS braces are not balanced.'); + + return null; + } + + private static function parseTwig(string $twig, string $name): null + { + $environment = new Environment(new ArrayLoader()); + $environment->addExtension(new DesignTokenExtension(new TokenRegistry())); + $environment->parse($environment->tokenize(new Source($twig, $name))); + + return null; + } + + private static function validateJson(string $json): null + { + $document = json_decode($json, true, 512, \JSON_THROW_ON_ERROR); + + if (\is_array($document) && !isset($document['$schema']) && self::containsToken($document)) { + new TokenTreeBuilder()->resolve($document); + } + + return null; + } + + /** @param array $node */ + private static function containsToken(array $node): bool + { + if (\array_key_exists('$value', $node)) { + return true; + } + + foreach ($node as $value) { + if (\is_array($value) && self::containsToken($value)) { + return true; + } + } + + return false; + } +} diff --git a/src/DesignTokens/tests/Fixtures/RecordingLogger.php b/src/DesignTokens/tests/Fixtures/RecordingLogger.php new file mode 100644 index 00000000000..180d5205e7b --- /dev/null +++ b/src/DesignTokens/tests/Fixtures/RecordingLogger.php @@ -0,0 +1,25 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\UX\DesignTokens\Tests\Fixtures; + +use Psr\Log\AbstractLogger; + +final class RecordingLogger extends AbstractLogger +{ + /** @var list}> */ + public array $records = []; + + public function log($level, \Stringable|string $message, array $context = []): void + { + $this->records[] = ['level' => (string) $level, 'message' => (string) $message, 'context' => $context]; + } +} diff --git a/src/DesignTokens/tests/Fixtures/Registries.php b/src/DesignTokens/tests/Fixtures/Registries.php new file mode 100644 index 00000000000..3650ab45115 --- /dev/null +++ b/src/DesignTokens/tests/Fixtures/Registries.php @@ -0,0 +1,52 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\UX\DesignTokens\Tests\Fixtures; + +use Symfony\Contracts\Cache\CacheInterface; +use Symfony\UX\DesignTokens\Resolver\ArrayDocumentLoader; +use Symfony\UX\DesignTokens\Resolver\ConfiguredTokenResolver; +use Symfony\UX\DesignTokens\Resolver\JsonDocumentLoader; +use Symfony\UX\DesignTokens\TokenRegistry; + +final class Registries +{ + /** + * @param array $document + * @param array $defaultInputs + */ + public static function fromArray(array $document, array $defaultInputs = []): TokenRegistry + { + return new TokenRegistry(new ConfiguredTokenResolver(new ArrayDocumentLoader(['memory.tokens.json' => $document]), ['memory.tokens.json']), $defaultInputs); + } + + public static function fromJson(string $json): TokenRegistry + { + $document = json_decode($json, true, 512, \JSON_THROW_ON_ERROR); + \assert(\is_array($document)); + + return self::fromArray($document); + } + + /** + * @param list $paths + * @param array $resolverInputs + */ + public static function fromFiles(array $paths = [], ?string $resolverPath = null, array $resolverInputs = [], ?CacheInterface $cache = null, bool $debug = false): TokenRegistry + { + return new TokenRegistry(new ConfiguredTokenResolver(new JsonDocumentLoader(), $paths, $resolverPath, $cache, $debug), $resolverInputs); + } + + public static function colorScheme(string $resolver = 'theme.resolver.json'): TokenRegistry + { + return self::fromFiles(resolverPath: __DIR__.'/color-scheme/'.$resolver); + } +} diff --git a/src/DesignTokens/tests/Fixtures/ResolverDocuments.php b/src/DesignTokens/tests/Fixtures/ResolverDocuments.php new file mode 100644 index 00000000000..e648ac263b8 --- /dev/null +++ b/src/DesignTokens/tests/Fixtures/ResolverDocuments.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\DesignTokens\Tests\Fixtures; + +use Symfony\UX\DesignTokens\Resolver\ResolverDocument; +use Symfony\UX\DesignTokens\Resolver\ResolverSource; + +final class ResolverDocuments +{ + /** + * @param array $inputs + * + * @return list> + */ + public static function sources(ResolverDocument $document, array $inputs = []): array + { + return array_map(static fn (ResolverSource $source): array => $source->tokens, $document->sourceDescriptors($inputs)); + } + + /** + * @param array $inputs + * + * @return array + */ + public static function merged(ResolverDocument $document, array $inputs = []): array + { + $merged = []; + foreach (self::sources($document, $inputs) as $source) { + $merged = self::merge($merged, $source); + } + + return $merged; + } + + /** + * @param array $base + * @param array $override + * + * @return array + */ + private static function merge(array $base, array $override): array + { + foreach ($override as $key => $value) { + $base[$key] = isset($base[$key]) && \is_array($base[$key]) && \is_array($value) && !self::isToken($base[$key]) && !self::isToken($value) && !array_is_list($value) + ? self::merge($base[$key], $value) + : $value; + } + + return $base; + } + + /** @param array $value */ + private static function isToken(array $value): bool + { + return \array_key_exists('$value', $value) || \array_key_exists('$ref', $value); + } +} diff --git a/src/DesignTokens/tests/Fixtures/TemporaryDirectory.php b/src/DesignTokens/tests/Fixtures/TemporaryDirectory.php new file mode 100644 index 00000000000..c54909996ef --- /dev/null +++ b/src/DesignTokens/tests/Fixtures/TemporaryDirectory.php @@ -0,0 +1,54 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\UX\DesignTokens\Tests\Fixtures; + +use Symfony\Component\Filesystem\Filesystem; + +final class TemporaryDirectory +{ + private readonly string $root; + private readonly Filesystem $filesystem; + + public function __construct() + { + $this->filesystem = new Filesystem(); + $this->root = sys_get_temp_dir().'/dt-tests-'.bin2hex(random_bytes(5)); + $this->filesystem->mkdir($this->root); + } + + public function path(string $name = ''): string + { + return '' === $name ? $this->root : $this->root.'/'.$name; + } + + /** @param string|array $contents */ + public function write(string $name, string|array $contents): string + { + $path = $this->path($name); + $this->filesystem->dumpFile($path, \is_array($contents) ? json_encode($contents, \JSON_THROW_ON_ERROR) : $contents); + + return $path; + } + + public function mkdir(string $name): string + { + $path = $this->path($name); + $this->filesystem->mkdir($path); + + return $path; + } + + public function remove(): void + { + $this->filesystem->remove($this->root); + } +} diff --git a/src/DesignTokens/tests/Fixtures/TokenValues.php b/src/DesignTokens/tests/Fixtures/TokenValues.php new file mode 100644 index 00000000000..d8533c3dc1b --- /dev/null +++ b/src/DesignTokens/tests/Fixtures/TokenValues.php @@ -0,0 +1,73 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\UX\DesignTokens\Tests\Fixtures; + +final class TokenValues +{ + /** @return array{colorSpace: string, components: list} */ + public static function color(float $red = 0.2, float $green = 0.4, float $blue = 0.8): array + { + return ['colorSpace' => 'srgb', 'components' => [$red, $green, $blue]]; + } + + /** @return array{value: int|float, unit: string} */ + public static function dimension(int|float $value, string $unit = 'px'): array + { + return ['value' => $value, 'unit' => $unit]; + } + + /** @return array */ + public static function shadow(bool $inset = false): array + { + return [ + 'color' => self::color(), + 'offsetX' => self::dimension(0), + 'offsetY' => self::dimension(2), + 'blur' => self::dimension(4), + 'spread' => self::dimension(0), + 'inset' => $inset, + ]; + } + + /** @return array */ + public static function border(): array + { + return ['width' => self::dimension(1), 'style' => 'solid', 'color' => self::color()]; + } + + /** @return array */ + public static function transition(): array + { + return [ + 'duration' => self::dimension(250, 'ms'), + 'timingFunction' => [0.4, 0, 0.2, 1], + 'delay' => self::dimension(0, 'ms'), + ]; + } + + /** + * @param array $overrides + * + * @return array + */ + public static function typography(array $overrides = []): array + { + return [ + 'fontFamily' => ['Inter', 'sans-serif'], + 'fontSize' => self::dimension(32), + 'fontWeight' => 700, + 'letterSpacing' => self::dimension(0), + 'lineHeight' => 1.2, + ...$overrides, + ]; + } +} diff --git a/src/DesignTokens/tests/Fixtures/base.tokens.json b/src/DesignTokens/tests/Fixtures/base.tokens.json new file mode 100644 index 00000000000..5b4ac796221 --- /dev/null +++ b/src/DesignTokens/tests/Fixtures/base.tokens.json @@ -0,0 +1,34 @@ +{ + "$description": "Fixture token file for tests", + "color": { + "$type": "color", + "brand": { + "primary": { "$value": { "colorSpace": "srgb", "components": [0.231, 0.51, 0.965] }, "$description": "Brand blue" }, + "secondary": { "$value": { "colorSpace": "srgb", "components": [0.388, 0.4, 0.945] } } + }, + "neutral": { + "white": { "$value": { "colorSpace": "srgb", "components": [1, 1, 1] } }, + "black": { "$value": { "colorSpace": "srgb", "components": [0, 0, 0] } } + } + }, + "size": { + "$type": "dimension", + "base": { "$value": { "value": 8, "unit": "px" } }, + "large": { "$value": { "value": 16, "unit": "px" } } + }, + "typography": { + "heading": { + "$type": "typography", + "$value": { + "fontFamily": ["Inter", "sans-serif"], + "fontSize": "2rem", + "fontWeight": "700", + "lineHeight": "1.2" + } + } + }, + "alias": { + "$type": "color", + "accent": { "$value": "{color.brand.primary}" } + } +} diff --git a/src/DesignTokens/tests/Fixtures/color-scheme/dark.tokens.json b/src/DesignTokens/tests/Fixtures/color-scheme/dark.tokens.json new file mode 100644 index 00000000000..fc1aa9ec0ac --- /dev/null +++ b/src/DesignTokens/tests/Fixtures/color-scheme/dark.tokens.json @@ -0,0 +1,27 @@ +{ + "color": { + "action": { + "$type": "color", + "primary": { "$value": "{color.palette.primary-dark}" }, + "primary-hover": { "$value": "{color.palette.primary-dark}" }, + "on-primary": { "$value": "{color.palette.ink}" } + }, + "content": { + "$type": "color", + "default": { "$value": "{color.palette.surface-light}" }, + "muted": { "$value": "{color.palette.muted-dark}" } + }, + "surface": { + "$type": "color", + "canvas": { "$value": "{color.palette.ink}" }, + "subtle": { "$value": "{color.palette.surface-dark}" } + }, + "border": { "$type": "color", "default": { "$value": "{color.palette.muted-light}" } }, + "focus": { "$type": "color", "ring": { "$value": "{color.palette.primary-dark}" } }, + "selection": { + "$type": "color", + "background": { "$value": "{color.palette.primary-dark}" }, + "foreground": { "$value": "{color.palette.ink}" } + } + } +} diff --git a/src/DesignTokens/tests/Fixtures/color-scheme/foundation.tokens.json b/src/DesignTokens/tests/Fixtures/color-scheme/foundation.tokens.json new file mode 100644 index 00000000000..642665e009c --- /dev/null +++ b/src/DesignTokens/tests/Fixtures/color-scheme/foundation.tokens.json @@ -0,0 +1,34 @@ +{ + "color": { + "palette": { + "$type": "color", + "white": { "$value": { "colorSpace": "srgb", "components": [1, 1, 1] } }, + "ink": { "$value": { "colorSpace": "srgb", "components": [0.01, 0.02, 0.05] } }, + "muted-light": { "$value": { "colorSpace": "srgb", "components": [0.4, 0.45, 0.55] } }, + "muted-dark": { "$value": { "colorSpace": "srgb", "components": [0.6, 0.65, 0.75] } }, + "surface-light": { "$value": { "colorSpace": "srgb", "components": [0.95, 0.96, 0.98] } }, + "surface-dark": { "$value": { "colorSpace": "srgb", "components": [0.1, 0.15, 0.2] } }, + "border": { "$value": { "colorSpace": "srgb", "components": [0.7, 0.75, 0.8] } }, + "primary-light": { "$value": { "colorSpace": "srgb", "components": [0.2, 0.4, 0.8] } }, + "primary-dark": { "$value": { "colorSpace": "srgb", "components": [0.5, 0.6, 0.9] } } + } + }, + "font": { + "family": { "ui": { "$type": "fontFamily", "$value": ["Inter", "sans-serif"] } }, + "size": { "body": { "$type": "dimension", "$value": { "value": 1, "unit": "rem" } } } + }, + "number": { + "line-height": { "body": { "$type": "number", "$value": 1.5 } } + }, + "dimension": { + "spacing": { + "$type": "dimension", + "xs": { "$value": { "value": 0.25, "unit": "rem" } }, + "sm": { "$value": { "value": 0.5, "unit": "rem" } }, + "md": { "$value": { "value": 0.75, "unit": "rem" } }, + "lg": { "$value": { "value": 1, "unit": "rem" } } + }, + "radius": { "control": { "$type": "dimension", "$value": { "value": 0.5, "unit": "rem" } } }, + "border": { "width": { "$type": "dimension", "$value": { "value": 1, "unit": "px" } } } + } +} diff --git a/src/DesignTokens/tests/Fixtures/color-scheme/light.tokens.json b/src/DesignTokens/tests/Fixtures/color-scheme/light.tokens.json new file mode 100644 index 00000000000..60ac9ab38f3 --- /dev/null +++ b/src/DesignTokens/tests/Fixtures/color-scheme/light.tokens.json @@ -0,0 +1,27 @@ +{ + "color": { + "action": { + "$type": "color", + "primary": { "$value": "{color.palette.primary-light}" }, + "primary-hover": { "$value": "{color.palette.primary-light}" }, + "on-primary": { "$value": "{color.palette.white}" } + }, + "content": { + "$type": "color", + "default": { "$value": "{color.palette.ink}" }, + "muted": { "$value": "{color.palette.muted-light}" } + }, + "surface": { + "$type": "color", + "canvas": { "$value": "{color.palette.white}" }, + "subtle": { "$value": "{color.palette.surface-light}" } + }, + "border": { "$type": "color", "default": { "$value": "{color.palette.border}" } }, + "focus": { "$type": "color", "ring": { "$value": "{color.palette.primary-light}" } }, + "selection": { + "$type": "color", + "background": { "$value": "{color.palette.primary-light}" }, + "foreground": { "$value": "{color.palette.white}" } + } + } +} diff --git a/src/DesignTokens/tests/Fixtures/color-scheme/named-theme.resolver.json b/src/DesignTokens/tests/Fixtures/color-scheme/named-theme.resolver.json new file mode 100644 index 00000000000..076d8d57091 --- /dev/null +++ b/src/DesignTokens/tests/Fixtures/color-scheme/named-theme.resolver.json @@ -0,0 +1,61 @@ +{ + "version": "2025.10", + "sets": { + "foundation": { + "sources": [ + { "$ref": "foundation.tokens.json" } + ] + } + }, + "modifiers": { + "mode": { + "contexts": { + "day": [ + { "$ref": "light.tokens.json" } + ], + "night": [ + { "$ref": "dark.tokens.json" } + ] + }, + "default": "day" + }, + "package": { + "contexts": { + "default": [{ "$ref": "foundation.tokens.json" }], + "map": [{ + "color": { + "action": { + "primary": { + "$type": "color", + "$value": { "colorSpace": "srgb", "components": [0.1, 0.7, 0.5] } + } + } + } + }] + }, + "default": "default" + }, + "surface": { + "contexts": { + "showcase": [{ "$ref": "foundation.tokens.json" }], + "docs": [{ + "font": { + "size": { + "body": { + "$type": "dimension", + "$value": { "value": 1.125, "unit": "rem" } + } + } + } + }] + }, + "default": "showcase" + } + }, + "resolutionOrder": [ + { "$ref": "#/sets/foundation" }, + { "$ref": "#/modifiers/mode" }, + { "$ref": "#/modifiers/package" }, + { "$ref": "#/modifiers/surface" } + ] +} diff --git a/src/DesignTokens/tests/Fixtures/color-scheme/theme.resolver.json b/src/DesignTokens/tests/Fixtures/color-scheme/theme.resolver.json new file mode 100644 index 00000000000..e56d5365cb9 --- /dev/null +++ b/src/DesignTokens/tests/Fixtures/color-scheme/theme.resolver.json @@ -0,0 +1,61 @@ +{ + "version": "2025.10", + "sets": { + "foundation": { + "sources": [ + { "$ref": "foundation.tokens.json" } + ] + } + }, + "modifiers": { + "scheme": { + "contexts": { + "light": [ + { "$ref": "light.tokens.json" } + ], + "dark": [ + { "$ref": "dark.tokens.json" } + ] + }, + "default": "light" + }, + "package": { + "contexts": { + "default": [{ "$ref": "foundation.tokens.json" }], + "map": [{ + "color": { + "action": { + "primary": { + "$type": "color", + "$value": { "colorSpace": "srgb", "components": [0.1, 0.7, 0.5] } + } + } + } + }] + }, + "default": "default" + }, + "surface": { + "contexts": { + "showcase": [{ "$ref": "foundation.tokens.json" }], + "docs": [{ + "font": { + "size": { + "body": { + "$type": "dimension", + "$value": { "value": 1.125, "unit": "rem" } + } + } + } + }] + }, + "default": "showcase" + } + }, + "resolutionOrder": [ + { "$ref": "#/sets/foundation" }, + { "$ref": "#/modifiers/scheme" }, + { "$ref": "#/modifiers/package" }, + { "$ref": "#/modifiers/surface" } + ] +} diff --git a/src/DesignTokens/tests/Fixtures/dtcg/format-color-invalid-values.json b/src/DesignTokens/tests/Fixtures/dtcg/format-color-invalid-values.json new file mode 100644 index 00000000000..d2af37112f3 --- /dev/null +++ b/src/DesignTokens/tests/Fixtures/dtcg/format-color-invalid-values.json @@ -0,0 +1,40 @@ +{ + "color/legacy-string": ["color", "#000000"], + "color/unknown-space": ["color", {"colorSpace": "rgb", "components": [0, 0, 0]}], + "color/missing-components": ["color", {"colorSpace": "srgb"}], + "color/two-components": ["color", {"colorSpace": "srgb", "components": [0, 0]}], + "color/component-type": ["color", {"colorSpace": "srgb", "components": [true, 0, 0]}], + "color/srgb-below": ["color", {"colorSpace": "srgb", "components": [-0.001, 0, 0]}], + "color/srgb-above": ["color", {"colorSpace": "srgb", "components": [1.001, 0, 0]}], + "color/hue-negative": ["color", {"colorSpace": "hsl", "components": [-0.001, 0, 0]}], + "color/hue-360-exclusive": ["color", {"colorSpace": "hsl", "components": [360, 0, 0]}], + "color/hsl-percent-above": ["color", {"colorSpace": "hsl", "components": [0, 100.001, 0]}], + "color/lab-lightness-above": ["color", {"colorSpace": "lab", "components": [100.001, 0, 0]}], + "color/lch-negative-chroma": ["color", {"colorSpace": "lch", "components": [50, -0.001, 0]}], + "color/oklab-lightness-above": ["color", {"colorSpace": "oklab", "components": [1.001, 0, 0]}], + "color/alpha-below": ["color", {"colorSpace": "srgb", "components": [0, 0, 0], "alpha": -0.001}], + "color/alpha-above": ["color", {"colorSpace": "srgb", "components": [0, 0, 0], "alpha": 1.001}], + "color/hex-short": ["color", {"colorSpace": "srgb", "components": [0, 0, 0], "hex": "#000"}], + "color/hex-alpha": ["color", {"colorSpace": "srgb", "components": [0, 0, 0], "hex": "#000000ff"}], + "dimension/string": ["dimension", "1px"], + "dimension/unknown-unit": ["dimension", {"value": 1, "unit": "em"}], + "duration/unknown-unit": ["duration", {"value": 1, "unit": "min"}], + "font-family/empty-list": ["fontFamily", []], + "font-family/non-string": ["fontFamily", ["Inter", 42]], + "font-weight/below": ["fontWeight", 0], + "font-weight/above": ["fontWeight", 1001], + "font-weight/case": ["fontWeight", "Bold"], + "cubic-bezier/arity": ["cubicBezier", [0, 0, 1]], + "cubic-bezier/x-below": ["cubicBezier", [-0.001, 0, 1, 1]], + "number/string": ["number", "1"], + "stroke-style/keyword": ["strokeStyle", "hidden"], + "stroke-style/empty-dashes": ["strokeStyle", {"dashArray": [], "lineCap": "round"}], + "border/missing-style": ["border", {"color": {"colorSpace": "srgb", "components": [0, 0, 0]}, "width": {"value": 1, "unit": "px"}}], + "transition/extra-property": ["transition", {"duration": {"value": 100, "unit": "ms"}, "delay": {"value": 0, "unit": "ms"}, "timingFunction": [0, 0, 1, 1], "property": "opacity"}], + "shadow/empty-list": ["shadow", []], + "shadow/inset-number": ["shadow", {"color": {"colorSpace": "srgb", "components": [0, 0, 0]}, "offsetX": {"value": 0, "unit": "px"}, "offsetY": {"value": 1, "unit": "px"}, "blur": {"value": 2, "unit": "px"}, "spread": {"value": 0, "unit": "px"}, "inset": 1}], + "gradient/empty-list": ["gradient", []], + "gradient/non-finite-position-surrogate": ["gradient", [{"color": {"colorSpace": "srgb", "components": [0, 0, 0]}, "position": "Infinity"}]], + "typography/missing-letter-spacing": ["typography", {"fontFamily": "Inter", "fontSize": {"value": 16, "unit": "px"}, "fontWeight": 400, "lineHeight": 1.5}], + "unknown-type": ["string", "legacy"] +} diff --git a/src/DesignTokens/tests/Fixtures/dtcg/format-color-valid-values.json b/src/DesignTokens/tests/Fixtures/dtcg/format-color-valid-values.json new file mode 100644 index 00000000000..536f122b853 --- /dev/null +++ b/src/DesignTokens/tests/Fixtures/dtcg/format-color-valid-values.json @@ -0,0 +1,35 @@ +{ + "color/srgb-min": ["color", {"colorSpace": "srgb", "components": [0, 0, 0], "alpha": 0, "hex": "#000000"}], + "color/srgb-max": ["color", {"colorSpace": "srgb", "components": [1, 1, 1], "alpha": 1, "hex": "#FFFFFF"}], + "color/srgb-linear": ["color", {"colorSpace": "srgb-linear", "components": [0.1, 0.5, 0.9]}], + "color/hsl-none": ["color", {"colorSpace": "hsl", "components": ["none", 0, 100]}], + "color/hsl-upper-exclusive": ["color", {"colorSpace": "hsl", "components": [359.999, 100, 100]}], + "color/hwb": ["color", {"colorSpace": "hwb", "components": [0, 100, 0]}], + "color/lab-unbounded": ["color", {"colorSpace": "lab", "components": [100, -1000000, 1000000]}], + "color/lch-unbounded-chroma": ["color", {"colorSpace": "lch", "components": [50, 1000000, 359.999]}], + "color/oklab-unbounded": ["color", {"colorSpace": "oklab", "components": [1, -1000000, 1000000]}], + "color/oklch-unbounded-chroma": ["color", {"colorSpace": "oklch", "components": [0.5, 1000000, 359.999]}], + "color/display-p3": ["color", {"colorSpace": "display-p3", "components": [0, 0.5, 1]}], + "color/a98-rgb": ["color", {"colorSpace": "a98-rgb", "components": [0, 0.5, 1]}], + "color/prophoto-rgb": ["color", {"colorSpace": "prophoto-rgb", "components": [0, 0.5, 1]}], + "color/rec2020": ["color", {"colorSpace": "rec2020", "components": [0, 0.5, 1]}], + "color/xyz-d65": ["color", {"colorSpace": "xyz-d65", "components": [0, 0.5, 1]}], + "color/xyz-d50": ["color", {"colorSpace": "xyz-d50", "components": [0, 0.5, 1]}], + "dimension/negative": ["dimension", {"value": -1.5, "unit": "rem"}], + "dimension/zero-unit-required": ["dimension", {"value": 0, "unit": "px"}], + "duration/negative-allowed": ["duration", {"value": -1.5, "unit": "s"}], + "font-family/string": ["fontFamily", "Inter"], + "font-family/list": ["fontFamily", ["Inter", "sans-serif"]], + "font-weight/min": ["fontWeight", 1], + "font-weight/max": ["fontWeight", 1000], + "font-weight/keyword": ["fontWeight", "extra-black"], + "cubic-bezier/unbounded-y": ["cubicBezier", [0, -1000000, 1, 1000000]], + "number/negative": ["number", -1.5], + "stroke-style/keyword": ["strokeStyle", "inset"], + "stroke-style/object": ["strokeStyle", {"dashArray": [{"value": 1, "unit": "px"}], "lineCap": "square"}], + "border/object": ["border", {"color": {"colorSpace": "srgb", "components": [0, 0, 0]}, "width": {"value": 1, "unit": "px"}, "style": "solid"}], + "transition/object": ["transition", {"duration": {"value": 100, "unit": "ms"}, "delay": {"value": 0, "unit": "ms"}, "timingFunction": [0, 0, 1, 1]}], + "shadow/single": ["shadow", {"color": {"colorSpace": "srgb", "components": [0, 0, 0]}, "offsetX": {"value": 0, "unit": "px"}, "offsetY": {"value": 1, "unit": "px"}, "blur": {"value": 2, "unit": "px"}, "spread": {"value": 0, "unit": "px"}, "inset": false}], + "gradient/out-of-range-is-clamped": ["gradient", [{"color": {"colorSpace": "srgb", "components": [0, 0, 0]}, "position": -99}, {"color": {"colorSpace": "srgb", "components": [1, 1, 1]}, "position": 42}]], + "typography/object": ["typography", {"fontFamily": "Inter", "fontSize": {"value": 16, "unit": "px"}, "fontWeight": 400, "letterSpacing": {"value": 0, "unit": "px"}, "lineHeight": 1.5}] +} diff --git a/src/DesignTokens/tests/Fixtures/dtcg/format-invalid-legacy-values.tokens.json b/src/DesignTokens/tests/Fixtures/dtcg/format-invalid-legacy-values.tokens.json new file mode 100644 index 00000000000..68c5f542a23 --- /dev/null +++ b/src/DesignTokens/tests/Fixtures/dtcg/format-invalid-legacy-values.tokens.json @@ -0,0 +1,12 @@ +{ + "legacy": { + "dimension": { + "$type": "dimension", + "$value": "16px" + }, + "color": { + "$type": "color", + "$value": "#3366ff" + } + } +} diff --git a/src/DesignTokens/tests/Fixtures/dtcg/format-valid.tokens.json b/src/DesignTokens/tests/Fixtures/dtcg/format-valid.tokens.json new file mode 100644 index 00000000000..93df9710eed --- /dev/null +++ b/src/DesignTokens/tests/Fixtures/dtcg/format-valid.tokens.json @@ -0,0 +1,37 @@ +{ + "$description": "Minimal DTCG 2025.10 format fixture", + "brand": { + "$type": "color", + "primary": { + "$value": { + "colorSpace": "srgb", + "components": [0.2, 0.4, 0.8], + "alpha": 1 + } + } + }, + "space": { + "$type": "dimension", + "$root": { + "$value": { + "value": 16, + "unit": "px" + } + }, + "large": { + "$value": { + "value": 2, + "unit": "rem" + } + } + }, + "motion": { + "duration": { + "$type": "duration", + "$value": { + "value": 250, + "unit": "ms" + } + } + } +} diff --git a/src/DesignTokens/tests/Fixtures/dtcg/resolver-complete.resolver.json b/src/DesignTokens/tests/Fixtures/dtcg/resolver-complete.resolver.json new file mode 100644 index 00000000000..6977f4c5a1f --- /dev/null +++ b/src/DesignTokens/tests/Fixtures/dtcg/resolver-complete.resolver.json @@ -0,0 +1,44 @@ +{ + "$schema": "https://www.designtokens.org/schemas/2025.10/resolver.json", + "version": "2025.10", + "sets": { + "foundation": { + "sources": [ + { + "color": { + "surface": { "$type": "number", "$value": 0 } + }, + "space": { + "control": { "$type": "dimension", "$value": { "value": 8, "unit": "px" } } + } + } + ] + } + }, + "modifiers": { + "theme": { + "contexts": { + "light": [{ "color": { "surface": { "$type": "number", "$value": 1 } } }], + "dark": [{ "color": { "surface": { "$type": "number", "$value": 2 } } }] + }, + "default": "light" + } + }, + "resolutionOrder": [ + { "$ref": "#/sets/foundation" }, + { "$ref": "#/modifiers/theme" }, + { + "type": "modifier", + "name": "density", + "contexts": { + "comfortable": [{ "space": { "control": { "$type": "dimension", "$value": { "value": 8, "unit": "px" } } } }], + "compact": [{ "space": { "control": { "$type": "dimension", "$value": { "value": 4, "unit": "px" } } } }] + } + }, + { + "type": "set", + "name": "component", + "sources": [{ "component": { "precedence": { "$type": "number", "$value": 3 } } }] + } + ] +} diff --git a/src/DesignTokens/tests/Fixtures/dtcg/resolver-invalid-cases.json b/src/DesignTokens/tests/Fixtures/dtcg/resolver-invalid-cases.json new file mode 100644 index 00000000000..31b9ea10582 --- /dev/null +++ b/src/DesignTokens/tests/Fixtures/dtcg/resolver-invalid-cases.json @@ -0,0 +1,181 @@ +{ + "missing version": { + "message": "must declare version", + "document": { + "resolutionOrder": [{ "type": "set", "name": "base", "sources": [] }] + } + }, + "report table date is not the schema identifier": { + "message": "must declare version", + "document": { + "version": "2025-10-01", + "resolutionOrder": [{ "type": "set", "name": "base", "sources": [] }] + } + }, + "report prose date is not the schema identifier": { + "message": "must declare version", + "document": { + "version": "2025-11-01", + "resolutionOrder": [{ "type": "set", "name": "base", "sources": [] }] + } + }, + "unknown root property": { + "message": "unsupported property", + "document": { + "version": "2025.10", + "unknown": true, + "resolutionOrder": [{ "type": "set", "name": "base", "sources": [] }] + } + }, + "set without sources": { + "message": "sources array", + "document": { + "version": "2025.10", + "sets": { "base": { "description": "Missing sources." } }, + "resolutionOrder": [{ "$ref": "#/sets/base" }] + } + }, + "set with unknown property": { + "message": "unsupported property", + "document": { + "version": "2025.10", + "sets": { "base": { "sources": [], "unknown": true } }, + "resolutionOrder": [{ "$ref": "#/sets/base" }] + } + }, + "set description must be text": { + "message": "description", + "document": { + "version": "2025.10", + "sets": { "base": { "description": 42, "sources": [] } }, + "resolutionOrder": [{ "$ref": "#/sets/base" }] + } + }, + "set extensions must be object": { + "message": "$extensions", + "document": { + "version": "2025.10", + "sets": { "base": { "$extensions": ["invalid"], "sources": [] } }, + "resolutionOrder": [{ "$ref": "#/sets/base" }] + } + }, + "source must be object": { + "message": "must be an object", + "document": { + "version": "2025.10", + "sets": { "base": { "sources": [42] } }, + "resolutionOrder": [{ "$ref": "#/sets/base" }] + } + }, + "modifier contexts cannot be empty": { + "message": "non-empty contexts", + "document": { + "version": "2025.10", + "modifiers": { "theme": { "contexts": {} } }, + "resolutionOrder": [{ "$ref": "#/modifiers/theme" }] + } + }, + "single-context modifier should be a set": { + "message": "at least two contexts", + "document": { + "version": "2025.10", + "modifiers": { "theme": { "contexts": { "light": [] } } }, + "resolutionOrder": [{ "$ref": "#/modifiers/theme" }] + } + }, + "modifier context source must be array": { + "message": "array of token sources", + "document": { + "version": "2025.10", + "modifiers": { "theme": { "contexts": { "light": { "unexpected": true }, "dark": [] } } }, + "resolutionOrder": [{ "$ref": "#/modifiers/theme" }] + } + }, + "modifier default must select a context": { + "message": "must match a context key", + "document": { + "version": "2025.10", + "modifiers": { "theme": { "contexts": { "light": [], "dark": [] }, "default": "other" } }, + "resolutionOrder": [{ "$ref": "#/modifiers/theme" }] + } + }, + "modifier with unknown property": { + "message": "unsupported property", + "document": { + "version": "2025.10", + "modifiers": { "theme": { "contexts": { "light": [], "dark": [] }, "unknown": true } }, + "resolutionOrder": [{ "$ref": "#/modifiers/theme" }] + } + }, + "case-insensitive contexts must not be ambiguous": { + "message": "differ only by case", + "document": { + "version": "2025.10", + "modifiers": { "theme": { "contexts": { "light": [], "LIGHT": [] } } }, + "resolutionOrder": [{ "$ref": "#/modifiers/theme" }] + } + }, + "resolution order names must be unique": { + "message": "name \"base\" is duplicated", + "document": { + "version": "2025.10", + "sets": { "base": { "sources": [] } }, + "resolutionOrder": [ + { "$ref": "#/sets/base" }, + { "type": "set", "name": "base", "sources": [] } + ] + } + }, + "referenced resolution order names must be unique": { + "message": "name \"base\" is duplicated", + "document": { + "version": "2025.10", + "sets": { "base": { "sources": [] } }, + "resolutionOrder": [ + { "$ref": "#/sets/base" }, + { "$ref": "#/sets/base" } + ] + } + }, + "generic resolution order reference needs a type": { + "message": "does not identify a set or modifier", + "document": { + "version": "2025.10", + "$defs": { "item": { "name": "generic", "sources": [] } }, + "resolutionOrder": [{ "$ref": "#/$defs/item" }] + } + }, + "generic resolution order reference needs a name": { + "message": "must have a name", + "document": { + "version": "2025.10", + "$defs": { "item": { "type": "set", "sources": [] } }, + "resolutionOrder": [{ "$ref": "#/$defs/item" }] + } + }, + "same-document fragment must be a JSON Pointer": { + "message": "must be a JSON Pointer", + "document": { + "version": "2025.10", + "sets": { "base": { "sources": [] } }, + "resolutionOrder": [{ "$ref": "#sets/base" }] + } + }, + "set cannot reference modifier": { + "message": "must not reference modifiers", + "document": { + "version": "2025.10", + "sets": { "base": { "sources": [{ "$ref": "#/modifiers/theme" }] } }, + "modifiers": { "theme": { "contexts": { "light": [], "dark": [] } } }, + "resolutionOrder": [{ "$ref": "#/sets/base" }] + } + }, + "nothing can reference resolution order": { + "message": "must not point into resolutionOrder", + "document": { + "version": "2025.10", + "sets": { "base": { "sources": [{ "$ref": "#/resolutionOrder/0" }] } }, + "resolutionOrder": [{ "$ref": "#/sets/base" }] + } + } +} diff --git a/src/DesignTokens/tests/Fixtures/dtcg/resolver-valid.resolver.json b/src/DesignTokens/tests/Fixtures/dtcg/resolver-valid.resolver.json new file mode 100644 index 00000000000..b31ef9fa15c --- /dev/null +++ b/src/DesignTokens/tests/Fixtures/dtcg/resolver-valid.resolver.json @@ -0,0 +1,38 @@ +{ + "$schema": "https://www.designtokens.org/schemas/2025.10/resolver.json", + "version": "2025.10", + "sets": { + "base": { + "sources": [ + { + "$ref": "./format-valid.tokens.json" + } + ] + } + }, + "modifiers": { + "theme": { + "contexts": { + "light": [ + { + "$ref": "./format-valid.tokens.json" + } + ], + "dark": [ + { + "$ref": "./format-valid.tokens.json" + } + ] + }, + "default": "light" + } + }, + "resolutionOrder": [ + { + "$ref": "#/sets/base" + }, + { + "$ref": "#/modifiers/theme" + } + ] +} diff --git a/src/DesignTokens/tests/Fixtures/theme/brand-sky.tokens.json b/src/DesignTokens/tests/Fixtures/theme/brand-sky.tokens.json new file mode 100644 index 00000000000..5ab632c97b1 --- /dev/null +++ b/src/DesignTokens/tests/Fixtures/theme/brand-sky.tokens.json @@ -0,0 +1,27 @@ +{ + "color": { + "palette": { + "$type": "color", + "brand": { + "$value": { + "colorSpace": "srgb", + "components": [ + 0.3255, + 0.5176, + 0.9294 + ] + } + }, + "brand-strong": { + "$value": { + "colorSpace": "srgb", + "components": [ + 0.2275, + 0.3882, + 0.7686 + ] + } + } + } + } +} diff --git a/src/DesignTokens/tests/Fixtures/theme/brand-symfony.tokens.json b/src/DesignTokens/tests/Fixtures/theme/brand-symfony.tokens.json new file mode 100644 index 00000000000..70a9b967681 --- /dev/null +++ b/src/DesignTokens/tests/Fixtures/theme/brand-symfony.tokens.json @@ -0,0 +1,27 @@ +{ + "color": { + "palette": { + "$type": "color", + "brand": { + "$value": { + "colorSpace": "srgb", + "components": [ + 0.4157, + 0.1765, + 0.7294 + ] + } + }, + "brand-strong": { + "$value": { + "colorSpace": "srgb", + "components": [ + 0.3059, + 0.1216, + 0.549 + ] + } + } + } + } +} diff --git a/src/DesignTokens/tests/Fixtures/theme/foundation.tokens.json b/src/DesignTokens/tests/Fixtures/theme/foundation.tokens.json new file mode 100644 index 00000000000..86ba95ca9d0 --- /dev/null +++ b/src/DesignTokens/tests/Fixtures/theme/foundation.tokens.json @@ -0,0 +1,616 @@ +{ + "color": { + "palette": { + "$type": "color", + "n-50": { + "$value": { + "colorSpace": "srgb", + "components": [ + 0.949, + 0.949, + 0.949 + ] + } + }, + "n-100": { + "$value": { + "colorSpace": "srgb", + "components": [ + 0.851, + 0.851, + 0.851 + ] + } + }, + "n-200": { + "$value": { + "colorSpace": "srgb", + "components": [ + 0.749, + 0.749, + 0.749 + ] + } + }, + "n-300": { + "$value": { + "colorSpace": "srgb", + "components": [ + 0.651, + 0.651, + 0.651 + ] + } + }, + "n-400": { + "$value": { + "colorSpace": "srgb", + "components": [ + 0.549, + 0.549, + 0.549 + ] + } + }, + "n-500": { + "$value": { + "colorSpace": "srgb", + "components": [ + 0.451, + 0.451, + 0.451 + ] + } + }, + "n-600": { + "$value": { + "colorSpace": "srgb", + "components": [ + 0.349, + 0.349, + 0.349 + ] + } + }, + "n-700": { + "$value": { + "colorSpace": "srgb", + "components": [ + 0.251, + 0.251, + 0.251 + ] + } + }, + "n-800": { + "$value": { + "colorSpace": "srgb", + "components": [ + 0.149, + 0.149, + 0.149 + ] + } + }, + "n-900": { + "$value": { + "colorSpace": "srgb", + "components": [ + 0.051, + 0.051, + 0.051 + ] + } + } + }, + "signal": { + "$type": "color", + "red": { + "$value": { + "colorSpace": "srgb", + "components": [ + 0.9843, + 0.251, + 0.251 + ] + } + }, + "yellow": { + "$value": { + "colorSpace": "srgb", + "components": [ + 0.9843, + 0.7765, + 0.251 + ] + } + }, + "green": { + "$value": { + "colorSpace": "srgb", + "components": [ + 0.5176, + 0.8706, + 0.1725 + ] + } + } + }, + "action": { + "primary": { + "$value": "{color.palette.brand}" + }, + "primary-hover": { + "$value": "{color.palette.brand-strong}" + }, + "on-primary": { + "$value": "{color.palette.n-50}" + } + }, + "content": { + "default": { + "$value": "{color.palette.text}" + }, + "muted": { + "$value": "{color.palette.text-muted}" + }, + "faint": { + "$value": "{color.palette.text-faint}" + }, + "code": { + "$value": "{color.palette.code}" + } + }, + "surface": { + "canvas": { + "$value": "{color.palette.surface}" + }, + "subtle": { + "$value": "{color.palette.surface-subtle}" + }, + "raised": { + "$value": "{color.palette.surface-raised}" + } + }, + "border": { + "default": { + "$value": "{color.palette.line}" + }, + "subtle": { + "$value": "{color.palette.line-subtle}" + } + }, + "focus": { + "ring": { + "$value": "{color.palette.brand}" + } + }, + "selection": { + "background": { + "$value": "{color.palette.brand}" + }, + "foreground": { + "$value": "{color.palette.n-50}" + } + } + }, + "font": { + "family": { + "$type": "fontFamily", + "title": { + "$value": [ + "Ubuntu Sans", + "Ubuntu", + "system-ui", + "sans-serif" + ] + }, + "ui": { + "$value": [ + "system-ui", + "sans-serif" + ] + }, + "mono": { + "$value": [ + "ui-monospace", + "SF Mono", + "SFMono-Regular", + "Menlo", + "Consolas", + "monospace" + ] + } + }, + "weight": { + "$type": "fontWeight", + "regular": { + "$value": 400 + }, + "medium": { + "$value": 500 + }, + "bold": { + "$value": 700 + } + }, + "size": { + "$type": "dimension", + "small": { + "$value": { + "value": 0.875, + "unit": "rem" + } + }, + "body": { + "$value": { + "value": 1, + "unit": "rem" + } + }, + "h5": { + "$value": { + "value": 1.25, + "unit": "rem" + } + }, + "h4": { + "$value": { + "value": 1.5, + "unit": "rem" + } + }, + "h3": { + "$value": { + "value": 1.75, + "unit": "rem" + } + }, + "h2": { + "$value": { + "value": 2, + "unit": "rem" + } + }, + "h1": { + "$value": { + "value": 3.25, + "unit": "rem" + } + } + } + }, + "number": { + "line-height": { + "$type": "number", + "heading": { + "$value": 1.2 + }, + "body": { + "$value": 1.5 + } + } + }, + "dimension": { + "$type": "dimension", + "spacing": { + "xs": { + "$value": { + "value": 0.5, + "unit": "rem" + } + }, + "sm": { + "$value": { + "value": 0.75, + "unit": "rem" + } + }, + "md": { + "$value": { + "value": 1, + "unit": "rem" + } + }, + "lg": { + "$value": { + "value": 1.5, + "unit": "rem" + } + }, + "xl": { + "$value": { + "value": 2, + "unit": "rem" + } + } + }, + "radius": { + "control": { + "$value": { + "value": 0.4, + "unit": "rem" + } + }, + "card": { + "$value": { + "value": 0.75, + "unit": "rem" + } + } + }, + "border": { + "width": { + "$value": { + "value": 1, + "unit": "px" + } + } + } + }, + "typography": { + "$type": "typography", + "body": { + "$value": { + "fontFamily": "{font.family.ui}", + "fontSize": "{font.size.body}", + "fontWeight": "{font.weight.regular}", + "letterSpacing": { + "value": 0, + "unit": "px" + }, + "lineHeight": "{number.line-height.body}" + } + }, + "heading": { + "$value": { + "fontFamily": "{font.family.title}", + "fontSize": "{font.size.h2}", + "fontWeight": "{font.weight.medium}", + "letterSpacing": { + "value": -0.01, + "unit": "rem" + }, + "lineHeight": "{number.line-height.heading}" + } + }, + "display": { + "$value": { + "fontFamily": "{font.family.title}", + "fontSize": "{font.size.h1}", + "fontWeight": "{font.weight.bold}", + "letterSpacing": { + "value": -0.02, + "unit": "rem" + }, + "lineHeight": "{number.line-height.heading}" + } + }, + "code": { + "$value": { + "fontFamily": "{font.family.mono}", + "fontSize": "{font.size.small}", + "fontWeight": "{font.weight.regular}", + "letterSpacing": { + "value": 0, + "unit": "px" + }, + "lineHeight": "{number.line-height.body}" + } + } + }, + "elevation": { + "$type": "shadow", + "raised": { + "$value": { + "color": { + "colorSpace": "srgb", + "components": [ + 0.051, + 0.051, + 0.051 + ], + "alpha": 0.08 + }, + "offsetX": { + "value": 0, + "unit": "px" + }, + "offsetY": { + "value": 1, + "unit": "px" + }, + "blur": { + "value": 3, + "unit": "px" + }, + "spread": { + "value": 0, + "unit": "px" + } + } + }, + "overlay": { + "$value": [ + { + "color": { + "colorSpace": "srgb", + "components": [ + 0.051, + 0.051, + 0.051 + ], + "alpha": 0.1 + }, + "offsetX": { + "value": 0, + "unit": "px" + }, + "offsetY": { + "value": 4, + "unit": "px" + }, + "blur": { + "value": 6, + "unit": "px" + }, + "spread": { + "value": -1, + "unit": "px" + } + }, + { + "color": { + "colorSpace": "srgb", + "components": [ + 0.051, + 0.051, + 0.051 + ], + "alpha": 0.06 + }, + "offsetX": { + "value": 0, + "unit": "px" + }, + "offsetY": { + "value": 10, + "unit": "px" + }, + "blur": { + "value": 15, + "unit": "px" + }, + "spread": { + "value": -3, + "unit": "px" + } + } + ] + } + }, + "stroke": { + "$type": "strokeStyle", + "solid": { + "$value": "solid" + }, + "dashed": { + "$value": "dashed" + } + }, + "border": { + "$type": "border", + "default": { + "$value": { + "color": "{color.border.default}", + "width": "{dimension.border.width}", + "style": "{stroke.solid}" + } + }, + "subtle": { + "$value": { + "color": "{color.border.subtle}", + "width": "{dimension.border.width}", + "style": "{stroke.solid}" + } + } + }, + "gradient": { + "$type": "gradient", + "rainbow": { + "$value": [ + { + "color": { + "colorSpace": "srgb", + "components": [ + 0.8392, + 0.3451, + 0.1922 + ] + }, + "position": 0 + }, + { + "color": { + "colorSpace": "srgb", + "components": [ + 0.8235, + 0.8392, + 0.1922 + ] + }, + "position": 0.3652 + }, + { + "color": { + "colorSpace": "srgb", + "components": [ + 0.1922, + 0.8392, + 0.451 + ] + }, + "position": 0.7183 + }, + { + "color": { + "colorSpace": "srgb", + "components": [ + 0.2275, + 0.6392, + 1.0 + ] + }, + "position": 1 + } + ] + } + }, + "duration": { + "$type": "duration", + "quick": { + "$value": { + "value": 150, + "unit": "ms" + } + }, + "base": { + "$value": { + "value": 250, + "unit": "ms" + } + } + }, + "easing": { + "$type": "cubicBezier", + "standard": { + "$value": [ + 0.2, + 0, + 0, + 1 + ] + }, + "decelerate": { + "$value": [ + 0, + 0, + 0.2, + 1 + ] + } + }, + "motion": { + "$type": "transition", + "control": { + "$value": { + "duration": "{duration.quick}", + "delay": { + "value": 0, + "unit": "ms" + }, + "timingFunction": "{easing.standard}" + } + } + } +} diff --git a/src/DesignTokens/tests/Fixtures/theme/scheme-dark.tokens.json b/src/DesignTokens/tests/Fixtures/theme/scheme-dark.tokens.json new file mode 100644 index 00000000000..c84b9f4d767 --- /dev/null +++ b/src/DesignTokens/tests/Fixtures/theme/scheme-dark.tokens.json @@ -0,0 +1,98 @@ +{ + "color": { + "palette": { + "$type": "color", + "surface": { + "$value": { + "colorSpace": "srgb", + "components": [ + 0.1294, + 0.1451, + 0.1608 + ] + } + }, + "surface-subtle": { + "$value": { + "colorSpace": "srgb", + "components": [ + 0.1686, + 0.1882, + 0.2078 + ] + } + }, + "surface-raised": { + "$value": { + "colorSpace": "srgb", + "components": [ + 0.2039, + 0.2275, + 0.251 + ] + } + }, + "text": { + "$value": { + "colorSpace": "srgb", + "components": [ + 0.8706, + 0.8863, + 0.902 + ] + } + }, + "text-muted": { + "$value": { + "colorSpace": "srgb", + "components": [ + 0.6784, + 0.7098, + 0.7412 + ] + } + }, + "text-faint": { + "$value": { + "colorSpace": "srgb", + "components": [ + 0.4235, + 0.4588, + 0.4902 + ] + } + }, + "line": { + "$value": { + "colorSpace": "srgb", + "components": [ + 0.0, + 0.0, + 0.0 + ], + "alpha": 0.35 + } + }, + "line-subtle": { + "$value": { + "colorSpace": "srgb", + "components": [ + 0.2039, + 0.2275, + 0.251 + ] + } + }, + "code": { + "$value": { + "colorSpace": "srgb", + "components": [ + 1.0, + 0.4745, + 0.7765 + ] + } + } + } + } +} diff --git a/src/DesignTokens/tests/Fixtures/theme/scheme-light.tokens.json b/src/DesignTokens/tests/Fixtures/theme/scheme-light.tokens.json new file mode 100644 index 00000000000..c339e1cc063 --- /dev/null +++ b/src/DesignTokens/tests/Fixtures/theme/scheme-light.tokens.json @@ -0,0 +1,97 @@ +{ + "color": { + "palette": { + "$type": "color", + "surface": { + "$value": { + "colorSpace": "srgb", + "components": [ + 1.0, + 1.0, + 1.0 + ] + } + }, + "surface-subtle": { + "$value": { + "colorSpace": "srgb", + "components": [ + 0.9725, + 0.9765, + 0.9804 + ] + } + }, + "surface-raised": { + "$value": { + "colorSpace": "srgb", + "components": [ + 0.9137, + 0.9255, + 0.9373 + ] + } + }, + "text": { + "$value": { + "colorSpace": "srgb", + "components": [ + 0.1294, + 0.1451, + 0.1608 + ] + } + }, + "text-muted": { + "$value": { + "colorSpace": "srgb", + "components": [ + 0.4235, + 0.4588, + 0.4902 + ] + } + }, + "text-faint": { + "$value": { + "colorSpace": "srgb", + "components": [ + 0.6784, + 0.7098, + 0.7412 + ] + } + }, + "line": { + "$value": { + "colorSpace": "srgb", + "components": [ + 0.8549, + 0.8706, + 0.8824 + ] + } + }, + "line-subtle": { + "$value": { + "colorSpace": "srgb", + "components": [ + 0.9137, + 0.9255, + 0.9373 + ] + } + }, + "code": { + "$value": { + "colorSpace": "srgb", + "components": [ + 0.8118, + 0.0, + 0.4039 + ] + } + } + } + } +} diff --git a/src/DesignTokens/tests/Fixtures/theme/theme.resolver.json b/src/DesignTokens/tests/Fixtures/theme/theme.resolver.json new file mode 100644 index 00000000000..dda52cdcc29 --- /dev/null +++ b/src/DesignTokens/tests/Fixtures/theme/theme.resolver.json @@ -0,0 +1,56 @@ +{ + "$schema": "https://www.designtokens.org/schemas/2025.10/resolver.json", + "version": "2025.10", + "sets": { + "foundation": { + "sources": [ + { + "$ref": "foundation.tokens.json" + } + ] + } + }, + "modifiers": { + "brand": { + "contexts": { + "symfony": [ + { + "$ref": "brand-symfony.tokens.json" + } + ], + "sky": [ + { + "$ref": "brand-sky.tokens.json" + } + ] + }, + "default": "symfony" + }, + "scheme": { + "contexts": { + "light": [ + { + "$ref": "scheme-light.tokens.json" + } + ], + "dark": [ + { + "$ref": "scheme-dark.tokens.json" + } + ] + }, + "default": "light" + } + }, + "resolutionOrder": [ + { + "$ref": "#/sets/foundation" + }, + { + "$ref": "#/modifiers/brand" + }, + { + "$ref": "#/modifiers/scheme" + } + ] +} diff --git a/src/DesignTokens/tests/Integration/BundleIntegrationTest.php b/src/DesignTokens/tests/Integration/BundleIntegrationTest.php new file mode 100644 index 00000000000..80430fcec78 --- /dev/null +++ b/src/DesignTokens/tests/Integration/BundleIntegrationTest.php @@ -0,0 +1,365 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\UX\DesignTokens\Tests\Integration; + +use PHPUnit\Framework\Attributes\CoversClass; +use PHPUnit\Framework\Attributes\DataProvider; +use PHPUnit\Framework\TestCase; +use Symfony\Bundle\FrameworkBundle\Console\Application; +use Symfony\Component\Console\Tester\CommandTester; +use Symfony\Component\Filesystem\Filesystem; +use Symfony\UX\DesignTokens\Bridge\Tailwind\ThemeImporter; +use Symfony\UX\DesignTokens\CacheWarmer\DesignTokensCacheWarmer; +use Symfony\UX\DesignTokens\Command\ExportCommand; +use Symfony\UX\DesignTokens\Command\ImportCommand; +use Symfony\UX\DesignTokens\Command\LintDesignTokensCommand; +use Symfony\UX\DesignTokens\TokenRegistry; +use Symfony\UX\DesignTokens\TokenRegistryInterface; +use Symfony\UX\DesignTokens\Twig\DesignTokenExtension; +use Symfony\UX\DesignTokens\UXDesignTokensBundle; +use Symfony\UX\DesignTokens\Validation\DtcgValidator; +use Symfony\UX\DesignTokens\Validation\Normalizer; +use Twig\Environment; + +#[CoversClass(UXDesignTokensBundle::class)] +#[CoversClass(TokenRegistry::class)] +#[CoversClass(DesignTokenExtension::class)] +#[CoversClass(ExportCommand::class)] +#[CoversClass(ImportCommand::class)] +#[CoversClass(LintDesignTokensCommand::class)] +#[CoversClass(ThemeImporter::class)] +#[CoversClass(DtcgValidator::class)] +#[CoversClass(Normalizer::class)] +#[CoversClass(DesignTokensCacheWarmer::class)] +final class BundleIntegrationTest extends TestCase +{ + /** @var list */ + private array $kernels = []; + + protected function tearDown(): void + { + $filesystem = new Filesystem(); + foreach ($this->kernels as $kernel) { + $cacheDir = $kernel->getCacheDir(); + $kernel->shutdown(); + $filesystem->remove($cacheDir); + } + $this->kernels = []; + } + + public function testConfiguredFilesLoadThroughTheRegistryService(): void + { + $kernel = $this->boot([ + 'paths' => [$this->fixture('app.tokens.json')], + ]); + + $registry = $kernel->getContainer()->get(TokenRegistryInterface::class); + + self::assertInstanceOf(TokenRegistryInterface::class, $registry); + self::assertSame('color(srgb 0.2 0.4 0.8)', (string) $registry->get('color.brand')); + } + + public function testEveryBundleServiceIsInstantiable(): void + { + $container = $this->boot([ + 'paths' => [$this->fixture('app.tokens.json')], + ])->getContainer(); + + $ids = array_filter($container->getServiceIds(), static fn (string $id): bool => str_starts_with($id, '.ux_design_tokens.')); + + self::assertNotEmpty($ids); + foreach ($ids as $id) { + self::assertIsObject($container->get($id), $id); + } + } + + public function testResolverSourcesStayInsideTheAllowedRoots(): void + { + $filesystem = new Filesystem(); + $outside = sys_get_temp_dir().'/dt-outside-'.bin2hex(random_bytes(4)).'.tokens.json'; + $filesystem->dumpFile($outside, '{"x":{"$type":"number","$value":1}}'); + $directory = $this->fixture('').'escape-'.bin2hex(random_bytes(4)); + $filesystem->dumpFile($directory.'/theme.resolver.json', json_encode([ + 'version' => '2025.10', + 'sets' => ['s' => ['sources' => [['$ref' => $outside]]]], + 'resolutionOrder' => [['$ref' => '#/sets/s']], + ], \JSON_THROW_ON_ERROR)); + + try { + $registry = $this->boot(['resolver' => ['path' => $directory.'/theme.resolver.json']])->getContainer()->get(TokenRegistryInterface::class); + \assert($registry instanceof TokenRegistryInterface); + + $this->expectExceptionMessage('Refusing to read design token document'); + $registry->all(); + } finally { + $filesystem->remove([$outside, $directory]); + } + } + + public function testConfiguredFileReferencesResolveRelativeToTheirSource(): void + { + $kernel = $this->boot([ + 'paths' => [$this->fixture('reference.tokens.json')], + ]); + + $registry = $kernel->getContainer()->get(TokenRegistryInterface::class); + + self::assertSame('color(srgb 0.1 0.3 0.7)', (string) $registry->get('semantic')); + } + + public function testResolverInputsSelectTheConfiguredContext(): void + { + $kernel = $this->boot([ + 'resolver' => ['path' => $this->fixture('theme.resolver.json'), 'inputs' => ['theme' => 'dark']], + ]); + + $registry = $kernel->getContainer()->get(TokenRegistryInterface::class); + + self::assertSame('dark', (string) $registry->get('theme')); + } + + public function testTheRegistryRejectsAnInvalidDocument(): void + { + $kernel = $this->boot([ + 'paths' => [\dirname(__DIR__).'/Fixtures/dtcg/format-invalid-legacy-values.tokens.json'], + ]); + + $registry = $kernel->getContainer()->get(TokenRegistryInterface::class); + + $this->expectException(\InvalidArgumentException::class); + $registry->all(); + } + + public function testTwigTokenOutputIsAutoEscaped(): void + { + $kernel = $this->boot([ + 'paths' => [$this->fixture('app.tokens.json')], + ]); + $twig = $kernel->getContainer()->get('test.twig'); + self::assertInstanceOf(Environment::class, $twig); + + $output = $twig->createTemplate('{{ ux_token("unsafe") }}')->render(); + + self::assertSame('"<strong>token</strong>"', $output); + } + + public function testExportCommandIsDiscoveredAndUsesConfiguredTokens(): void + { + $kernel = $this->boot([ + 'paths' => [$this->fixture('app.tokens.json')], + ]); + $command = new Application($kernel)->find('ux:design-tokens:export'); + + $tester = new CommandTester($command); + $status = $tester->execute(['format' => 'css']); + + self::assertSame(0, $status); + self::assertStringContainsString('--dt-color-brand', $tester->getDisplay()); + } + + #[DataProvider('shippedFormats')] + public function testEveryShippedFormatExportsThroughTheContainer(string $format): void + { + $kernel = $this->boot([ + 'resolver' => ['path' => \dirname(__DIR__).'/Fixtures/color-scheme/theme.resolver.json'], + ]); + $tester = new CommandTester(new Application($kernel)->find('ux:design-tokens:export')); + + self::assertSame(0, $tester->execute(['format' => $format]), $tester->getDisplay()); + self::assertNotSame('', trim($tester->getDisplay())); + } + + /** @return iterable */ + public static function shippedFormats(): iterable + { + foreach (['dtcg', 'css', 'javascript', 'design.md', 'tailwind'] as $format) { + yield $format => [$format]; + } + } + + public function testADebugKernelServesAStylesheetRebuiltAfterAnEdit(): void + { + $directory = sys_get_temp_dir().'/ux-design-tokens-edit-'.bin2hex(random_bytes(4)); + new Filesystem()->mirror(\dirname(__DIR__).'/Fixtures/color-scheme', $directory); + $config = ['resolver' => ['path' => $directory.'/theme.resolver.json']]; + $cacheDir = sys_get_temp_dir().'/ux-design-tokens-kernel-'.bin2hex(random_bytes(6)); + + try { + self::assertStringContainsString('--dt-color-action-primary: color(srgb 0.2 0.4 0.8);', $this->renderCss($config, $cacheDir)); + + $light = $directory.'/light.tokens.json'; + new Filesystem()->dumpFile($light, str_replace('"primary": { "$value": "{color.palette.primary-light}" }', '"primary": { "$value": { "colorSpace": "srgb", "components": [0.9, 0.1, 0.1] } }', (string) file_get_contents($light))); + new Filesystem()->touch($light, time() + 10); + + self::assertStringContainsString('--dt-color-action-primary: color(srgb 0.9 0.1 0.1);', $this->renderCss($config, $cacheDir)); + } finally { + new Filesystem()->remove($directory); + } + } + + public function testThePreloadScriptHoldsNoStylesheet(): void + { + $kernel = $this->boot([ + 'resolver' => ['path' => \dirname(__DIR__).'/Fixtures/color-scheme/theme.resolver.json'], + ]); + $tester = new CommandTester(new Application($kernel)->find('cache:warmup')); + + self::assertSame(0, $tester->execute([])); + self::assertFileExists($kernel->getBuildDir().'/ux_design_tokens/tokens.css'); + + $preload = glob($kernel->getBuildDir().'/*.preload.php') ?: []; + self::assertNotSame([], $preload); + foreach ($preload as $file) { + self::assertStringNotContainsString('.css', (string) file_get_contents($file)); + } + } + + public function testTheDarkColorSchemeWorksThroughTheCommandAndTwig(): void + { + $kernel = $this->boot([ + 'resolver' => ['path' => \dirname(__DIR__).'/Fixtures/color-scheme/theme.resolver.json'], + ]); + + $command = new CommandTester(new Application($kernel)->find('ux:design-tokens:export')); + self::assertSame(0, $command->execute(['format' => 'css'])); + self::assertDarkScheme($command->getDisplay()); + + $twig = $kernel->getContainer()->get('test.twig'); + self::assertInstanceOf(Environment::class, $twig); + self::assertDarkScheme($twig->createTemplate('{{ ux_token_css() }}')->render()); + + $contextualHtml = $twig->createTemplate('{{ ux_token_css({package: "map"}) }}')->render(); + self::assertStringContainsString('--dt-color-action-primary: color(srgb 0.1 0.7 0.5);', $contextualHtml); + + $contextualToken = $twig->createTemplate('{{ ux_token("font.size.body", {surface: "docs"}) }}')->render(); + self::assertSame('1.125rem', $contextualToken); + } + + public function testTailwindImporterServiceAndCommandAreDiscovered(): void + { + $kernel = $this->boot([]); + $importer = $kernel->getContainer()->get('.ux_design_tokens.importer.tailwind'); + self::assertInstanceOf(ThemeImporter::class, $importer); + self::assertSame('color', $importer->import('@theme { --color-brand: #336699; }')['color']['brand']['$type']); + + $command = new Application($kernel)->find('ux:design-tokens:import'); + self::assertSame('ux:design-tokens:import', $command->getName()); + } + + public function testLintServicesAndCommandsAreDiscovered(): void + { + $kernel = $this->boot([ + 'paths' => [$this->fixture('app.tokens.json')], + ]); + $container = $kernel->getContainer(); + self::assertInstanceOf(DtcgValidator::class, $container->get('.ux_design_tokens.validator')); + + $application = new Application($kernel); + $lint = new CommandTester($application->find('lint:design-tokens')); + self::assertSame(0, $lint->execute([])); + self::assertStringContainsString('document is valid', $lint->getDisplay()); + + self::assertStringContainsString('The configured resolution holds', $lint->getDisplay()); + + $debug = new CommandTester($application->find('debug:design-tokens')); + self::assertSame(0, $debug->execute(['path' => 'color.brand'])); + self::assertStringContainsString('color.brand', $debug->getDisplay()); + } + + public function testTheColorSchemeModifierIsConfigurable(): void + { + $kernel = $this->boot([ + 'resolver' => ['path' => \dirname(__DIR__).'/Fixtures/color-scheme/named-theme.resolver.json'], + 'color_scheme' => ['modifier' => 'mode', 'light' => 'day', 'dark' => 'night'], + ]); + + $twig = $kernel->getContainer()->get('test.twig'); + self::assertInstanceOf(Environment::class, $twig); + self::assertDarkScheme($twig->createTemplate('{{ ux_token_css() }}')->render()); + + $command = new CommandTester(new Application($kernel)->find('ux:design-tokens:export')); + self::assertSame(0, $command->execute(['format' => 'css'])); + self::assertDarkScheme($command->getDisplay()); + } + + public function testCssPrefixIsApplicationOwned(): void + { + $kernel = $this->boot([ + 'resolver' => ['path' => \dirname(__DIR__).'/Fixtures/color-scheme/theme.resolver.json'], + 'css_prefix' => 'my', + ]); + + $command = new CommandTester(new Application($kernel)->find('ux:design-tokens:export')); + self::assertSame(0, $command->execute(['format' => 'css'])); + self::assertStringContainsString('--my-color-action-primary:', $command->getDisplay()); + + $twig = $kernel->getContainer()->get('test.twig'); + self::assertInstanceOf(Environment::class, $twig); + $html = $twig->createTemplate('{{ ux_token_css() }}')->render(); + self::assertStringContainsString('--my-color-action-primary:', $html); + } + + public function testTheWarmedStylesheetIsServedFromTheBuildDirectory(): void + { + $kernel = $this->boot([ + 'resolver' => ['path' => \dirname(__DIR__).'/Fixtures/color-scheme/theme.resolver.json'], + ]); + + $warmer = $kernel->getContainer()->get('test.cache_warmer'); + self::assertInstanceOf(DesignTokensCacheWarmer::class, $warmer); + $warmer->warmUp($kernel->getCacheDir(), $kernel->getBuildDir()); + + $stylesheet = $kernel->getBuildDir().'/ux_design_tokens/tokens.css'; + self::assertFileExists($stylesheet); + self::assertDarkScheme((string) file_get_contents($stylesheet)); + + $twig = $kernel->getContainer()->get('test.twig'); + self::assertInstanceOf(Environment::class, $twig); + self::assertStringContainsString('--dt-color-action-primary: color(srgb 0.2 0.4 0.8);', $twig->createTemplate('{{ ux_token_css() }}')->render()); + } + + private function boot(array $config): IntegrationTestKernel + { + $cacheDir = sys_get_temp_dir().'/ux-design-tokens-kernel-'.bin2hex(random_bytes(6)); + $kernel = new IntegrationTestKernel($config, $cacheDir); + $this->kernels[] = $kernel; + $kernel->boot(); + + return $kernel; + } + + /** @param array $config */ + private function renderCss(array $config, string $cacheDir): string + { + $kernel = new IntegrationTestKernel($config, $cacheDir, true); + $this->kernels[] = $kernel; + $kernel->boot(); + $twig = $kernel->getContainer()->get('test.twig'); + \assert($twig instanceof Environment); + + return $twig->createTemplate('{{ ux_token_css() }}')->render(); + } + + private static function assertDarkScheme(string $css): void + { + [$root, $dark] = explode('@media (prefers-color-scheme: dark)', $css, 2) + [1 => '']; + + self::assertStringContainsString('--dt-color-action-primary: color(srgb 0.2 0.4 0.8);', $root); + self::assertStringContainsString('--dt-color-action-primary: color(srgb 0.5 0.6 0.9);', $dark); + self::assertStringNotContainsString('--dt-dimension-spacing-md', $dark); + } + + private function fixture(string $name): string + { + return __DIR__.'/Fixtures/'.$name; + } +} diff --git a/src/DesignTokens/tests/Integration/Fixtures/app.tokens.json b/src/DesignTokens/tests/Integration/Fixtures/app.tokens.json new file mode 100644 index 00000000000..df7e8710877 --- /dev/null +++ b/src/DesignTokens/tests/Integration/Fixtures/app.tokens.json @@ -0,0 +1,15 @@ +{ + "color": { + "$type": "color", + "brand": { + "$value": { + "colorSpace": "srgb", + "components": [0.2, 0.4, 0.8] + } + } + }, + "unsafe": { + "$type": "fontFamily", + "$value": "token" + } +} diff --git a/src/DesignTokens/tests/Integration/Fixtures/primitive.tokens.json b/src/DesignTokens/tests/Integration/Fixtures/primitive.tokens.json new file mode 100644 index 00000000000..202db14bbdc --- /dev/null +++ b/src/DesignTokens/tests/Integration/Fixtures/primitive.tokens.json @@ -0,0 +1,9 @@ +{ + "base": { + "$type": "color", + "$value": { + "colorSpace": "srgb", + "components": [0.1, 0.3, 0.7] + } + } +} diff --git a/src/DesignTokens/tests/Integration/Fixtures/reference.tokens.json b/src/DesignTokens/tests/Integration/Fixtures/reference.tokens.json new file mode 100644 index 00000000000..cd1439bd760 --- /dev/null +++ b/src/DesignTokens/tests/Integration/Fixtures/reference.tokens.json @@ -0,0 +1,6 @@ +{ + "semantic": { + "$type": "color", + "$ref": "./primitive.tokens.json#/base/$value" + } +} diff --git a/src/DesignTokens/tests/Integration/Fixtures/theme.resolver.json b/src/DesignTokens/tests/Integration/Fixtures/theme.resolver.json new file mode 100644 index 00000000000..d98279d5c2e --- /dev/null +++ b/src/DesignTokens/tests/Integration/Fixtures/theme.resolver.json @@ -0,0 +1,31 @@ +{ + "version": "2025.10", + "modifiers": { + "theme": { + "contexts": { + "light": [ + { + "theme": { + "$type": "fontFamily", + "$value": "light" + } + } + ], + "dark": [ + { + "theme": { + "$type": "fontFamily", + "$value": "dark" + } + } + ] + }, + "default": "light" + } + }, + "resolutionOrder": [ + { + "$ref": "#/modifiers/theme" + } + ] +} diff --git a/src/DesignTokens/tests/Integration/IntegrationTestKernel.php b/src/DesignTokens/tests/Integration/IntegrationTestKernel.php new file mode 100644 index 00000000000..59cda69e83d --- /dev/null +++ b/src/DesignTokens/tests/Integration/IntegrationTestKernel.php @@ -0,0 +1,93 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\UX\DesignTokens\Tests\Integration; + +use Symfony\Bundle\FrameworkBundle\FrameworkBundle; +use Symfony\Bundle\FrameworkBundle\Kernel\MicroKernelTrait; +use Symfony\Bundle\TwigBundle\TwigBundle; +use Symfony\Component\DependencyInjection\Compiler\CompilerPassInterface; +use Symfony\Component\DependencyInjection\Compiler\PassConfig; +use Symfony\Component\DependencyInjection\ContainerBuilder; +use Symfony\Component\DependencyInjection\Loader\Configurator\ContainerConfigurator; +use Symfony\Component\HttpKernel\Kernel; +use Symfony\UX\DesignTokens\UXDesignTokensBundle; + +/** @internal */ +final class IntegrationTestKernel extends Kernel +{ + use MicroKernelTrait; + + /** @param array $bundleConfig */ + public function __construct( + private readonly array $bundleConfig, + private readonly string $testCacheDir, + bool $debug = false, + ) { + parent::__construct('test', $debug); + } + + public function registerBundles(): iterable + { + yield new FrameworkBundle(); + yield new TwigBundle(); + yield new UXDesignTokensBundle(); + } + + public function getProjectDir(): string + { + return \dirname(__DIR__, 2); + } + + public function getCacheDir(): string + { + return $this->testCacheDir; + } + + public function getLogDir(): string + { + return $this->testCacheDir.'/log'; + } + + protected function build(ContainerBuilder $container): void + { + $container->addCompilerPass(new class implements CompilerPassInterface { + public function process(ContainerBuilder $container): void + { + foreach ($container->getDefinitions() as $id => $definition) { + if (str_starts_with($id, '.ux_design_tokens.')) { + $definition->setPublic(true); + } + } + foreach ($container->getAliases() as $id => $alias) { + if (str_starts_with($id, 'Symfony\\UX\\DesignTokens\\')) { + $alias->setPublic(true); + } + } + } + }, PassConfig::TYPE_BEFORE_REMOVING); + } + + protected function configureContainer(ContainerConfigurator $container): void + { + $container->extension('framework', [ + 'secret' => 'ux-design-tokens-test', + 'test' => true, + ]); + $container->extension('ux_design_tokens', $this->bundleConfig); + + $services = $container->services(); + $services->alias('test.cache_warmer', '.ux_design_tokens.cache_warmer') + ->public() + ; + $services->alias('test.twig', 'twig')->public(); + } +} diff --git a/src/DesignTokens/tests/Integration/LayeredThemeTest.php b/src/DesignTokens/tests/Integration/LayeredThemeTest.php new file mode 100644 index 00000000000..484293305ea --- /dev/null +++ b/src/DesignTokens/tests/Integration/LayeredThemeTest.php @@ -0,0 +1,77 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\UX\DesignTokens\Tests\Integration; + +use PHPUnit\Framework\TestCase; +use Symfony\UX\DesignTokens\Generator\CssGenerator; +use Symfony\UX\DesignTokens\Generator\GeneratorInterface; +use Symfony\UX\DesignTokens\Generator\JavaScriptGenerator; +use Symfony\UX\DesignTokens\Resolver\ConfiguredTokenResolver; +use Symfony\UX\DesignTokens\TokenRegistry; + +final class LayeredThemeTest extends TestCase +{ + private const RESOLVER = __DIR__.'/../Fixtures/theme/theme.resolver.json'; + + public function testTheStylesheetWritesOnlyTheDarkDifferences(): void + { + $registry = new TokenRegistry(new ConfiguredTokenResolver(resolverPath: self::RESOLVER)); + $light = $registry->flatten(['scheme' => 'light']); + $dark = $registry->flatten(['scheme' => 'dark']); + + self::assertCount(78, $light); + self::assertCount(20, array_filter($light, static fn ($token, string $path): bool => (string) $token !== (string) $dark[$path], \ARRAY_FILTER_USE_BOTH)); + + $css = new CssGenerator('dt')->generate($registry->all(['scheme' => 'light']), [GeneratorInterface::DARK_TOKENS => $registry->all(['scheme' => 'dark'])]); + [$root, $darkBlock] = explode(':root[data-theme="dark"] {', $css, 2); + + self::assertStringContainsString('--dt-dimension-spacing-md: 1rem;', $root); + + // 18 colors, plus the shorthand and the color of the 2 borders. + self::assertSame(22, substr_count($darkBlock, '--dt-')); + self::assertStringContainsString('--dt-color-palette-surface: color(srgb 0.1294 0.1451 0.1608);', $darkBlock); + self::assertStringContainsString('--dt-border-default-color:', $darkBlock); + self::assertStringNotContainsString('--dt-dimension-spacing-md', $darkBlock); + } + + public function testTheRegistryListsTheModifiersOfTheTheme(): void + { + $registry = new TokenRegistry(new ConfiguredTokenResolver(resolverPath: self::RESOLVER)); + + self::assertSame([ + 'brand' => ['contexts' => ['symfony', 'sky'], 'default' => 'symfony'], + 'scheme' => ['contexts' => ['light', 'dark'], 'default' => 'light'], + ], $registry->getModifiers()); + } + + public function testATraceNamesTheBrandFileBehindAToken(): void + { + $resolution = new ConfiguredTokenResolver(resolverPath: self::RESOLVER)->trace(['brand' => 'sky', 'scheme' => 'dark']); + + self::assertSame('color(srgb 0.3255 0.5176 0.9294)', (string) $resolution->getTokens()['color']['action']['primary']); + self::assertSame('system-ui, sans-serif', (string) $resolution->getTokens()['font']['family']['ui']); + self::assertSame('brand-sky.tokens.json', basename($resolution->getSource('color.palette.brand')?->uri ?? '')); + } + + public function testAnotherSelectionExportsAsJavaScript(): void + { + $registry = new TokenRegistry(new ConfiguredTokenResolver(resolverPath: self::RESOLVER)); + $module = new JavaScriptGenerator()->generate($registry->all(['brand' => 'sky', 'scheme' => 'dark'])); + + self::assertStringContainsString('export const tokens = Object.freeze(JSON.parse(', $module); + self::assertStringContainsString('\\"color.action.primary\\":\\"color(srgb 0.3255 0.5176 0.9294)\\"', $module); + self::assertStringContainsString('\\"color.surface.canvas\\":\\"color(srgb 0.1294 0.1451 0.1608)\\"', $module); + self::assertStringContainsString('\\"font.size.h1\\":\\"3.25rem\\"', $module); + self::assertStringContainsString('\\"dimension.spacing.lg\\":\\"1.5rem\\"', $module); + self::assertStringContainsString('\\"motion.control\\":\\"150ms cubic-bezier(0.2, 0, 0, 1) 0ms\\"', $module); + } +} diff --git a/src/DesignTokens/tests/Unit/Bridge/GoogleDesignMd/DesignMdGeneratorTest.php b/src/DesignTokens/tests/Unit/Bridge/GoogleDesignMd/DesignMdGeneratorTest.php new file mode 100644 index 00000000000..6d713bfff1e --- /dev/null +++ b/src/DesignTokens/tests/Unit/Bridge/GoogleDesignMd/DesignMdGeneratorTest.php @@ -0,0 +1,216 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\UX\DesignTokens\Tests\Unit\Bridge\GoogleDesignMd; + +use PHPUnit\Framework\Attributes\CoversClass; +use PHPUnit\Framework\Attributes\DataProvider; +use PHPUnit\Framework\TestCase; +use Symfony\Component\Yaml\Yaml; +use Symfony\UX\DesignTokens\Bridge\GoogleDesignMd\DesignMdGenerator; +use Symfony\UX\DesignTokens\Exception\InvalidArgumentException; +use Symfony\UX\DesignTokens\Generator\GeneratorInterface; +use Symfony\UX\DesignTokens\Resolver\TokenTreeBuilder; +use Symfony\UX\DesignTokens\Tests\Fixtures\TokenValues; + +#[CoversClass(DesignMdGenerator::class)] +final class DesignMdGeneratorTest extends TestCase +{ + private DesignMdGenerator $generator; + private TokenTreeBuilder $resolver; + + protected function setUp(): void + { + $this->generator = new DesignMdGenerator(); + $this->resolver = new TokenTreeBuilder(); + } + + public function testGenerateContainsTitleAndSections(): void + { + $md = $this->generate([ + 'color' => ['primary' => ['$type' => 'color', '$value' => TokenValues::color()]], + 'spacing' => ['md' => ['$type' => 'dimension', '$value' => ['value' => 16, 'unit' => 'px']]], + 'motion' => ['fast' => ['$type' => 'duration', '$value' => ['value' => 100, 'unit' => 'ms']]], + 'opacity' => ['disabled' => ['$type' => 'number', '$value' => 0.5]], + 'elevation' => ['sm' => ['$type' => 'shadow', '$value' => [ + 'offsetX' => ['value' => 0, 'unit' => 'px'], + 'offsetY' => ['value' => 1, 'unit' => 'px'], + 'blur' => ['value' => 2, 'unit' => 'px'], + 'spread' => ['value' => 0, 'unit' => 'px'], + 'color' => TokenValues::color(), + ]]], + ]); + + self::assertStringContainsString('version: alpha', $md); + self::assertStringContainsString('name: \'Design System\'', $md); + self::assertStringContainsString('# Design System', $md); + self::assertStringNotContainsString('tokens across', $md); + self::assertStringContainsString('## Colors', $md); + self::assertStringContainsString("primary: 'rgb(20% 40% 80%)'", $md); + self::assertStringContainsString('16px', $md); + self::assertStringContainsString('## Elevation & Depth', $md); + self::assertStringContainsString('`elevation.sm`', $md); + } + + public function testGenerateRendersStructuredCompositeValues(): void + { + $md = $this->generate([ + 'heading' => ['$type' => 'typography', '$value' => TokenValues::typography()], + 'border' => ['$type' => 'border', '$value' => TokenValues::border()], + 'transition' => ['$type' => 'transition', '$value' => TokenValues::transition()], + ]); + + self::assertStringContainsString('## Typography', $md); + self::assertStringContainsString('Inter, sans-serif', $md); + self::assertStringContainsString('32px', $md); + self::assertStringContainsString('## Additional Tokens', $md); + self::assertStringContainsString('| `border` | `border` |', $md); + self::assertStringContainsString('color(srgb 0.2 0.4 0.8)', $md); + self::assertStringContainsString('| `transition` | `transition` |', $md); + self::assertStringContainsString('250ms', $md); + } + + public function testGenerateUsesStableSectionOrderAndCustomTitle(): void + { + $generator = new DesignMdGenerator(); + $md = $generator->generate($this->resolver->resolve([ + 'spacing' => ['md' => ['$type' => 'dimension', '$value' => ['value' => 8, 'unit' => 'px']]], + 'color' => ['primary' => ['$type' => 'color', '$value' => TokenValues::color()]], + 'motion' => ['fast' => ['$type' => 'duration', '$value' => ['value' => 100, 'unit' => 'ms']]], + ]), [GeneratorInterface::TITLE => 'Acme']); + + self::assertStringContainsString('# Acme', $md); + self::assertLessThan(mb_strpos($md, '## Layout'), mb_strpos($md, '## Colors')); + self::assertLessThan(mb_strpos($md, '## Additional Tokens'), mb_strpos($md, '## Layout')); + } + + public function testGenerateEmptyTokensProducesHeader(): void + { + $md = $this->generator->generate([]); + + self::assertStringContainsString('# Design System', $md); + self::assertStringContainsString('## Overview', $md); + } + + public function testGenerateHasGoogleDesignMdFrontMatter(): void + { + $md = $this->generate([ + 'color' => ['primary' => ['$type' => 'color', '$value' => TokenValues::color()]], + 'spacing' => ['md' => ['$type' => 'dimension', '$value' => ['value' => 16, 'unit' => 'px']]], + 'radius' => ['md' => ['$type' => 'dimension', '$value' => ['value' => 8, 'unit' => 'px']]], + 'components' => ['button-primary' => [ + 'backgroundColor' => ['$type' => 'color', '$value' => TokenValues::color()], + 'padding' => ['$type' => 'dimension', '$value' => ['value' => 12, 'unit' => 'px']], + ]], + ]); + + self::assertSame(1, preg_match('/\A---\n(.*?)\n---\n/s', $md, $matches)); + + /** @var array $frontMatter */ + $frontMatter = Yaml::parse($matches[1]); + self::assertSame('alpha', $frontMatter['version']); + self::assertSame('Design System', $frontMatter['name']); + self::assertSame('rgb(20% 40% 80%)', $frontMatter['colors']['primary']); + self::assertSame('16px', $frontMatter['spacing']['md']); + self::assertSame('8px', $frontMatter['rounded']['md']); + self::assertSame('rgb(20% 40% 80%)', $frontMatter['components']['button-primary']['backgroundColor']); + self::assertSame('12px', $frontMatter['components']['button-primary']['padding']); + } + + public function testNamesKeepTheirWholePath(): void + { + $md = $this->generate(['color' => [ + '$type' => 'color', + 'palette' => ['code' => ['$value' => TokenValues::color()]], + 'content' => ['code' => ['$value' => TokenValues::color()]], + ]]); + + self::assertStringContainsString("palette-code: 'rgb(20% 40% 80%)'", $md); + self::assertStringContainsString("content-code: 'rgb(20% 40% 80%)'", $md); + } + + public function testTwoPathsGivingTheSameNameAreRefused(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Design tokens "color.a.b-c" and "color.a-b.c" both export to the DESIGN.md name "a-b-c".'); + + $this->generate(['color' => [ + '$type' => 'color', + 'a' => ['b-c' => ['$value' => TokenValues::color()]], + 'a-b' => ['c' => ['$value' => TokenValues::color()]], + ]]); + } + + /** + * @param array $value + */ + #[DataProvider('colors')] + public function testColorsUseAFormDesignMdAccepts(array $value, string $expected): void + { + $md = $this->generate(['color' => ['$type' => 'color', 'x' => ['$value' => $value]]]); + + self::assertStringContainsString("x: '{$expected}'", $md); + } + + /** @return iterable, string}> */ + public static function colors(): iterable + { + yield 'srgb' => [['colorSpace' => 'srgb', 'components' => [1, 0, 0]], 'rgb(100% 0% 0%)']; + yield 'srgb with alpha' => [['colorSpace' => 'srgb', 'components' => [1, 0, 0], 'alpha' => 0.5], 'rgb(100% 0% 0% / 0.5)']; + yield 'oklch' => [['colorSpace' => 'oklch', 'components' => [0.62, 0.18, 255]], 'oklch(62% 0.18 255)']; + yield 'hsl' => [['colorSpace' => 'hsl', 'components' => [120, 50, 50]], 'hsl(120 50% 50%)']; + yield 'display-p3 with hex fallback' => [['colorSpace' => 'display-p3', 'components' => [1, 0, 0], 'hex' => '#ff0000'], '#ff0000']; + } + + public function testASpaceDesignMdCannotExpressNeedsAHexFallback(): void + { + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('Design token "color.x" uses the rec2020 color space, which DESIGN.md cannot express. Add a "hex" fallback.'); + + $this->generate(['color' => ['$type' => 'color', 'x' => ['$value' => ['colorSpace' => 'rec2020', 'components' => [1, 0, 0]]]]]); + } + + public function testTheBodyDoesNotRepeatTheFrontMatter(): void + { + $md = $this->generate([ + 'color' => ['$type' => 'color', 'x' => ['$value' => TokenValues::color()]], + 'spacing' => ['md' => ['$type' => 'dimension', '$value' => ['value' => 16, 'unit' => 'px']]], + 'components' => ['button' => ['background' => ['$type' => 'color', '$value' => TokenValues::color()]]], + ]); + [, , $body] = explode("---\n", $md, 3); + + self::assertStringNotContainsString('| Token | Value |', $body); + self::assertStringNotContainsString('| Property | Value |', $body); + self::assertStringNotContainsString('`components.button.background`', $body); + self::assertStringContainsString('## Components', $body); + } + + public function testSectionsFollowTheSpecificationOrder(): void + { + $md = $this->generate([ + 'radius' => ['md' => ['$type' => 'dimension', '$value' => ['value' => 8, 'unit' => 'px']]], + 'elevation' => ['sm' => ['$type' => 'shadow', '$value' => [ + 'offsetX' => ['value' => 0, 'unit' => 'px'], + 'offsetY' => ['value' => 1, 'unit' => 'px'], + 'blur' => ['value' => 2, 'unit' => 'px'], + 'spread' => ['value' => 0, 'unit' => 'px'], + 'color' => TokenValues::color(), + ]]], + ]); + + self::assertLessThan(mb_strpos($md, '## Shapes'), mb_strpos($md, '## Elevation & Depth')); + } + + private function generate(array $raw): string + { + return $this->generator->generate($this->resolver->resolve($raw)); + } +} diff --git a/src/DesignTokens/tests/Unit/Bridge/Tailwind/ThemeGeneratorTest.php b/src/DesignTokens/tests/Unit/Bridge/Tailwind/ThemeGeneratorTest.php new file mode 100644 index 00000000000..96464eb52e7 --- /dev/null +++ b/src/DesignTokens/tests/Unit/Bridge/Tailwind/ThemeGeneratorTest.php @@ -0,0 +1,161 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\UX\DesignTokens\Tests\Unit\Bridge\Tailwind; + +use PHPUnit\Framework\Attributes\CoversClass; +use PHPUnit\Framework\TestCase; +use Symfony\UX\DesignTokens\Bridge\Tailwind\ThemeGenerator; +use Symfony\UX\DesignTokens\Resolver\TokenTreeBuilder; +use Symfony\UX\DesignTokens\Tests\Fixtures\TokenValues; + +#[CoversClass(ThemeGenerator::class)] +final class ThemeGeneratorTest extends TestCase +{ + private ThemeGenerator $generator; + private TokenTreeBuilder $resolver; + + protected function setUp(): void + { + $this->generator = new ThemeGenerator(); + $this->resolver = new TokenTreeBuilder(); + } + + public function testGeneratesOfficialTailwindNamespaces(): void + { + $css = $this->generate([ + 'color' => ['brand' => ['primary' => ['$type' => 'color', '$value' => TokenValues::color()]]], + 'font' => ['body' => ['$type' => 'fontFamily', '$value' => ['Inter', 'sans-serif']]], + 'weight' => ['strong' => ['$type' => 'fontWeight', '$value' => 700]], + 'spacing' => ['md' => ['$type' => 'dimension', '$value' => TokenValues::dimension(16)]], + 'radius' => ['card' => ['$type' => 'dimension', '$value' => TokenValues::dimension(12)]], + 'breakpoints' => ['wide' => ['$type' => 'dimension', '$value' => ['value' => 80, 'unit' => 'rem']]], + 'tracking' => ['tight' => ['$type' => 'number', '$value' => -0.02]], + 'easing' => ['snappy' => ['$type' => 'cubicBezier', '$value' => [0.2, 0, 0, 1]]], + 'elevation' => ['card' => ['$type' => 'shadow', '$value' => TokenValues::shadow()]], + 'inset-shadow' => ['control' => ['$type' => 'shadow', '$value' => TokenValues::shadow(true)]], + ]); + + self::assertStringStartsWith("@theme static {\n", $css); + self::assertStringContainsString('--color-brand-primary: color(srgb 0.2 0.4 0.8);', $css); + self::assertStringContainsString('--font-body: Inter, sans-serif;', $css); + self::assertStringContainsString('--font-weight-strong: 700;', $css); + self::assertStringContainsString('--spacing-md: 16px;', $css); + self::assertStringContainsString('--radius-card: 12px;', $css); + self::assertStringContainsString('--breakpoint-wide: 80rem;', $css); + self::assertStringContainsString('--tracking-tight: -0.02;', $css); + self::assertStringContainsString('--ease-snappy: cubic-bezier(0.2, 0, 0, 1);', $css); + self::assertStringContainsString('--shadow-card:', $css); + self::assertStringContainsString('--inset-shadow-control:', $css); + self::assertStringEndsWith("}\n", $css); + } + + public function testExpandsTypographyIntoTailwindThemeVariables(): void + { + $css = $this->generate(['typography' => ['heading' => ['$type' => 'typography', '$value' => [ + 'fontFamily' => ['Inter', 'sans-serif'], + 'fontSize' => ['value' => 2, 'unit' => 'rem'], + 'fontWeight' => 700, + 'letterSpacing' => ['value' => -0.02, 'unit' => 'rem'], + 'lineHeight' => 1.2, + ]]]]); + + self::assertStringContainsString('--text-heading: 2rem;', $css); + self::assertStringContainsString('--text-heading--line-height: 1.2;', $css); + self::assertStringContainsString('--text-heading--letter-spacing: -0.02rem;', $css); + self::assertStringContainsString('--text-heading--font-weight: 700;', $css); + self::assertStringNotContainsString('--leading-heading', $css); + } + + public function testUsesSpacingForAnUnclassifiedDimensionAndOmitsUnsupportedTypes(): void + { + $css = $this->generate([ + 'size' => ['icon' => ['$type' => 'dimension', '$value' => TokenValues::dimension(24)]], + 'motion' => ['fast' => ['$type' => 'duration', '$value' => ['value' => 100, 'unit' => 'ms']]], + 'opacity' => ['muted' => ['$type' => 'number', '$value' => 0.5]], + ]); + + self::assertStringContainsString('--spacing-icon: 24px;', $css); + self::assertStringNotContainsString('motion', $css); + self::assertStringNotContainsString('opacity', $css); + self::assertSame("@theme static {\n}\n", $this->generator->generate([])); + } + + public function testWritesTheSpacingScaleAndTextSizesUnderTheirOwnNames(): void + { + $css = $this->generate([ + 'spacing' => ['$root' => ['$type' => 'dimension', '$value' => ['value' => 0.25, 'unit' => 'rem']]], + 'text' => ['sm' => ['$type' => 'dimension', '$value' => ['value' => 0.875, 'unit' => 'rem']]], + ]); + + self::assertStringContainsString(' --spacing: 0.25rem;', $css); + self::assertStringContainsString(' --text-sm: 0.875rem;', $css); + } + + public function testRejectsNormalizedNameCollisionsAndUnsafeCssValues(): void + { + try { + $this->generate([ + 'color' => [ + 'Brand primary' => ['$type' => 'color', '$value' => TokenValues::color()], + 'brand-primary' => ['$type' => 'color', '$value' => ['colorSpace' => 'srgb', 'components' => [1, 0, 0]]], + ], + ]); + self::fail('Expected a Tailwind variable collision.'); + } catch (\InvalidArgumentException $error) { + self::assertStringContainsString('same Tailwind theme variable', $error->getMessage()); + } + + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('unsafe Tailwind theme value'); + $this->generate(['font' => ['bad' => ['$type' => 'fontFamily', '$value' => 'Inter; color: red']]]); + } + + public function testNamespacesComeFromAnySegmentOfThePath(): void + { + $css = $this->generate([ + 'dimension' => ['$type' => 'dimension', 'radius' => ['control' => ['$value' => TokenValues::dimension(6)]], 'space' => ['md' => ['$value' => TokenValues::dimension(16)]]], + 'font' => [ + 'size' => ['$type' => 'dimension', 'body' => ['$value' => ['value' => 1, 'unit' => 'rem']]], + 'weight' => ['$type' => 'fontWeight', 'regular' => ['$value' => 400]], + 'body' => ['$type' => 'fontFamily', '$value' => ['Inter', 'sans-serif']], + ], + 'number' => ['$type' => 'number', 'tab-size' => ['code' => ['$value' => 4]], 'zoom' => ['compact' => ['$value' => 0.9]]], + ]); + + self::assertStringContainsString('--radius-control: 6px;', $css); + self::assertStringContainsString('--spacing-md: 16px;', $css); + self::assertStringContainsString('--text-body: 1rem;', $css); + self::assertStringContainsString('--font-weight-regular: 400;', $css); + self::assertStringContainsString('--font-body: Inter, sans-serif;', $css); + self::assertStringContainsString('--tab-size-code: 4;', $css); + self::assertStringContainsString('--zoom-compact: 0.9;', $css); + } + + public function testATypographyNextToAFontFamilyOfTheSameNameDoesNotCollide(): void + { + $css = $this->generate([ + 'font' => ['body' => ['$type' => 'fontFamily', '$value' => ['Inter']]], + 'typography' => ['body' => ['$type' => 'typography', '$value' => [ + 'fontFamily' => ['Inter'], 'fontSize' => ['value' => 1, 'unit' => 'rem'], 'fontWeight' => 400, + 'letterSpacing' => ['value' => 0, 'unit' => 'px'], 'lineHeight' => 1.5, + ]]], + ]); + + self::assertStringContainsString('--font-body: Inter;', $css); + self::assertStringContainsString('--text-body--line-height: 1.5;', $css); + } + + private function generate(array $tokens): string + { + return $this->generator->generate($this->resolver->resolve($tokens)); + } +} diff --git a/src/DesignTokens/tests/Unit/Bridge/Tailwind/ThemeImporterTest.php b/src/DesignTokens/tests/Unit/Bridge/Tailwind/ThemeImporterTest.php new file mode 100644 index 00000000000..92a5f2c1038 --- /dev/null +++ b/src/DesignTokens/tests/Unit/Bridge/Tailwind/ThemeImporterTest.php @@ -0,0 +1,292 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\UX\DesignTokens\Tests\Unit\Bridge\Tailwind; + +use PHPUnit\Framework\Attributes\CoversClass; +use PHPUnit\Framework\Attributes\DataProvider; +use PHPUnit\Framework\TestCase; +use Psr\Log\LogLevel; +use Symfony\UX\DesignTokens\Bridge\Tailwind\ThemeGenerator; +use Symfony\UX\DesignTokens\Bridge\Tailwind\ThemeImporter; +use Symfony\UX\DesignTokens\Resolver\TokenTreeBuilder; +use Symfony\UX\DesignTokens\Tests\Fixtures\RecordingLogger; + +#[CoversClass(ThemeImporter::class)] +final class ThemeImporterTest extends TestCase +{ + private ThemeImporter $importer; + private RecordingLogger $logger; + + protected function setUp(): void + { + $this->importer = new ThemeImporter(); + $this->logger = new RecordingLogger(); + $this->importer->setLogger($this->logger); + } + + public function testImportsSupportedNamespacesAndKeepsFlatNames(): void + { + $tokens = $this->importer->import(<<<'CSS' + @import "tailwindcss"; + @theme static inline { + --*: initial; + --color-*: initial; + --color-brand-primary: oklch(72% 0.11 221.19 / 80%); + --font-body: "Inter var", sans-serif; + --font-weight-strong: 700; + --text-title: 2rem; + --tracking-tight: -0.02rem; + --leading-copy: 1.5; + --breakpoint-wide: 80rem; + --container-card: 40rem; + --spacing: 4px; + --radius-card: 12px; + --blur-soft: 8px; + --perspective-near: 500px; + --zoom-compact: 0.9; + --aspect-photo: 1.5; + --ease-snappy: cubic-bezier(0.2, 0, 0, 1); + } + CSS); + + self::assertSame('color', $tokens['color']['brand-primary']['$type']); + self::assertSame([ + 'colorSpace' => 'oklch', + 'components' => [0.72, 0.11, 221.19], + 'alpha' => 0.8, + ], $tokens['color']['brand-primary']['$value']); + self::assertSame(['Inter var', 'sans-serif'], $tokens['font']['body']['$value']); + self::assertSame(700, $tokens['font-weight']['strong']['$value']); + self::assertSame(['value' => 2, 'unit' => 'rem'], $tokens['text']['title']['$value']); + self::assertSame(['value' => -0.02, 'unit' => 'rem'], $tokens['tracking']['tight']['$value']); + self::assertSame(1.5, $tokens['leading']['copy']['$value']); + self::assertSame(['value' => 4, 'unit' => 'px'], $tokens['spacing']['$root']['$value']); + self::assertSame([0.2, 0, 0, 1], $tokens['ease']['snappy']['$value']); + + new TokenTreeBuilder()->resolve($tokens); + } + + public function testImportsColorsShadowsAliasesAndMultipleThemeBlocks(): void + { + $tokens = $this->importer->import(<<<'CSS' + /* @theme { --color-fake: red; } */ + @theme { + --color-base: #369c; + --color-rgb: rgb(51 102 204 / 50%); + --color-p3: color(display-p3 20% 0.4 0.8); + --shadow-card: 0 2px 4px #336699, 0 1px 2px 0 rgb(0 0 0 / 10%); + --inset-shadow-control: inset 0 0 2px color(srgb 0.2 0.4 0.8); + @keyframes ignored { from { opacity: 0; } to { opacity: 1; } } + } + @theme inline { + --color-brand: var(--color-base); + --color-base: #336699; + } + CSS); + + self::assertSame('#336699', $tokens['color']['base']['$value']['hex']); + self::assertSame('{color.base}', $tokens['color']['brand']['$value']); + self::assertSame([0.2, 0.4, 0.8], $tokens['color']['rgb']['$value']['components']); + self::assertSame([0.2, 0.4, 0.8], $tokens['color']['p3']['$value']['components']); + self::assertCount(2, $tokens['shadow']['card']['$value']); + self::assertTrue($tokens['inset-shadow']['control']['$value']['inset']); + + $resolved = new TokenTreeBuilder()->resolve($tokens); + self::assertSame($resolved['color']['base']->getValue(), $resolved['color']['brand']->getValue()); + } + + public function testImportsColorFormsFontWeightsAndEscapedStrings(): void + { + $tokens = $this->importer->import(<<<'CSS' + @theme { + /* declarations may contain comments */ + --color-short: #abc; + --color-alpha: #1234; + --color-long-alpha: #11223344; + --color-percent-rgb: rgb(20% 40% 80%); + --color-hsl: hsl(120 50% 25%); + --color-none: oklab(none 0.1 -0.1); + --font-escaped: "A\" B", sans-serif; + --font-weight-semi: semibold; + --font-weight-heavy: heavy; + --tracking-zero: 0; + --spacing-zero: 0; + @custom-media --ignored; + } + CSS); + + self::assertSame('#aabbcc', $tokens['color']['short']['$value']['hex']); + self::assertArrayHasKey('alpha', $tokens['color']['alpha']['$value']); + self::assertArrayHasKey('alpha', $tokens['color']['long-alpha']['$value']); + self::assertSame([0.2, 0.4, 0.8], $tokens['color']['percent-rgb']['$value']['components']); + self::assertSame([120, 50, 25], $tokens['color']['hsl']['$value']['components']); + self::assertSame('none', $tokens['color']['none']['$value']['components'][0]); + self::assertSame(['A" B', 'sans-serif'], $tokens['font']['escaped']['$value']); + self::assertSame('semi-bold', $tokens['font-weight']['semi']['$value']); + self::assertSame('heavy', $tokens['font-weight']['heavy']['$value']); + self::assertSame(['value' => 0, 'unit' => 'px'], $tokens['tracking']['zero']['$value']); + self::assertSame(['value' => 0, 'unit' => 'px'], $tokens['spacing']['zero']['$value']); + + new TokenTreeBuilder()->resolve($tokens); + } + + public function testRoundTripsTheTailwindExporterSubset(): void + { + $source = [ + 'color' => ['brand' => ['$type' => 'color', '$value' => ['colorSpace' => 'srgb', 'components' => [0.2, 0.4, 0.8]]]], + 'font' => ['body' => ['$type' => 'fontFamily', '$value' => ['Inter', 'sans-serif']]], + 'weight' => ['strong' => ['$type' => 'fontWeight', '$value' => 700]], + 'spacing' => [ + '$root' => ['$type' => 'dimension', '$value' => ['value' => 0.25, 'unit' => 'rem']], + 'base' => ['$type' => 'dimension', '$value' => ['value' => 8, 'unit' => 'px']], + 'md' => ['$type' => 'dimension', '$value' => ['value' => 16, 'unit' => 'px']], + ], + 'text' => ['sm' => ['$type' => 'dimension', '$value' => ['value' => 0.875, 'unit' => 'rem']]], + 'tracking' => ['tight' => ['$type' => 'number', '$value' => -0.02]], + 'easing' => ['snappy' => ['$type' => 'cubicBezier', '$value' => [0.2, 0, 0, 1]]], + 'elevation' => ['card' => ['$type' => 'shadow', '$value' => [ + 'color' => ['colorSpace' => 'srgb', 'components' => [0.1, 0.2, 0.3]], + 'offsetX' => ['value' => 0, 'unit' => 'px'], + 'offsetY' => ['value' => 2, 'unit' => 'px'], + 'blur' => ['value' => 4, 'unit' => 'px'], + 'spread' => ['value' => 0, 'unit' => 'px'], + 'inset' => false, + ]]], + ]; + $resolver = new TokenTreeBuilder(); + $css = new ThemeGenerator()->generate($resolver->resolve($source)); + $roundTrip = $resolver->resolve($this->importer->import($css)); + + self::assertSame($source['color']['brand']['$value'], $roundTrip['color']['brand']->getValue()); + self::assertSame($source['font']['body']['$value'], $roundTrip['font']['body']->getValue()); + self::assertSame(700, $roundTrip['font-weight']['strong']->getValue()); + self::assertSame(['value' => 16, 'unit' => 'px'], $roundTrip['spacing']['md']->getValue()); + self::assertSame(['value' => 0.25, 'unit' => 'rem'], $roundTrip['spacing']['$root']->getValue()); + self::assertSame(['value' => 8, 'unit' => 'px'], $roundTrip['spacing']['base']->getValue()); + self::assertSame(['value' => 0.875, 'unit' => 'rem'], $roundTrip['text']['sm']->getValue()); + self::assertSame(-0.02, $roundTrip['tracking']['tight']->getValue()); + self::assertSame([0.2, 0, 0, 1], $roundTrip['ease']['snappy']->getValue()); + self::assertSame($source['elevation']['card']['$value'], $roundTrip['shadow']['card']->getValue()); + } + + /** @return iterable */ + public static function invalidCss(): iterable + { + yield 'no theme' => [':root { --color-brand: red; }', 'No top-level']; + yield 'nested theme' => ['@media (width > 10px) { @theme { --color-brand: #fff; } }', 'No top-level']; + yield 'unterminated leading comment' => ['/* comment', 'Unterminated CSS comment']; + yield 'bad options' => ['@theme prefix(tw) { --color-brand: #fff; }', 'Unsupported @theme options']; + yield 'theme without block' => ['@theme;', 'Malformed Tailwind @theme block']; + yield 'unterminated block' => ['@theme { --color-brand: #fff;', 'Unterminated']; + yield 'unterminated block comment' => ['@theme { /* comment', 'Unterminated CSS comment']; + yield 'unterminated string' => ['@theme { --font-body: "Inter; }', 'Unterminated CSS string']; + yield 'unexpected rule' => ['@theme { color: red; }', 'Unexpected content']; + yield 'missing colon' => ['@theme { --color-brand }', 'Malformed Tailwind theme declaration']; + yield 'invalid variable name' => ['@theme { --color.bad: #fff; }', 'Invalid Tailwind theme variable']; + yield 'brace in value' => ['@theme { --font-body: Inter{}; }', 'Malformed Tailwind theme declaration']; + yield 'unterminated inner at-rule' => ['@theme { @custom-media --narrow }', 'Unterminated at-rule']; + } + + #[DataProvider('skippedVariables')] + public function testLogsAVariableItCannotImport(string $css, string $reason, string $level = LogLevel::WARNING): void + { + $tokens = $this->importer->import($css); + + self::assertSame([], $tokens); + self::assertCount(1, $this->logger->records); + self::assertSame($level, $this->logger->records[0]['level']); + self::assertStringContainsString($reason, $this->logger->records[0]['message']); + } + + /** @return iterable */ + public static function skippedVariables(): iterable + { + yield 'unknown namespace' => ['@theme { --animate-spin: spin 1s; }', 'has no DTCG type', LogLevel::NOTICE]; + yield 'non-reset wildcard' => ['@theme { --color-*: #fff; }', 'Unsupported Tailwind theme variable', LogLevel::NOTICE]; + yield 'modifier' => ['@theme { --text-xs--line-height: 1; }', 'modifies another variable', LogLevel::NOTICE]; + yield 'unknown alias' => ['@theme { --color-brand: var(--external); }', 'references unknown']; + yield 'expression' => ['@theme { --spacing-fluid: calc(1rem + 1vw); }', 'cannot be represented losslessly']; + yield 'unsupported unit' => ['@theme { --spacing-fluid: 1vw; }', 'allows px and rem']; + yield 'bad number' => ['@theme { --zoom-fluid: infinite; }', 'finite number']; + yield 'named color' => ['@theme { --color-brand: red; }', 'structured CSS color']; + yield 'bad hex length' => ['@theme { --color-brand: #12345; }', 'invalid hexadecimal']; + yield 'empty color' => ['@theme { --color-brand: oklch(); }', 'has no value']; + yield 'short rgb' => ['@theme { --color-brand: rgb(1 2); }', 'three color components']; + yield 'unknown color space' => ['@theme { --color-brand: device-cmyk(0 0 0); }', 'unsupported color space']; + yield 'short structured color' => ['@theme { --color-brand: oklch(0.5 0.2); }', 'three color components']; + yield 'too many alpha separators' => ['@theme { --color-brand: oklch(50% 0.2 20 / 1 / 1); }', 'one alpha separator']; + yield 'malformed color function' => ['@theme { --color-brand: color(srgb); }', 'malformed color()']; + yield 'font function' => ['@theme { --font-body: env(font); }', 'unsupported font family']; + yield 'empty font family' => ['@theme { --font-body: Inter, ; }', 'empty font family']; + yield 'bad font weight' => ['@theme { --font-weight-heavy: 1200; }', 'not a DTCG font weight']; + yield 'bad bezier' => ['@theme { --ease-snap: ease-in; }', 'cubic-bezier']; + yield 'short bezier' => ['@theme { --ease-snap: cubic-bezier(0, 1); }', 'four cubic Bezier']; + yield 'bad shadow' => ['@theme { --shadow-card: 0 2px red; }', 'px or rem']; + yield 'shadow without color' => ['@theme { --shadow-card: 0 2px 4px; }', 'not a lossless CSS box shadow']; + yield 'shadow with two colors' => ['@theme { --shadow-card: 0 2px #fff #000; }', 'multiple shadow colors']; + yield 'shadow with too many dimensions' => ['@theme { --shadow-card: 0 1px 2px 3px 4px #000; }', 'not a lossless CSS box shadow']; + } + + #[DataProvider('invalidCss')] + public function testRejectsAFileThatIsNotATailwindTheme(string $css, string $message): void + { + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage($message); + $this->importer->import($css); + } + + public function testAcceptsEveryThemeOption(): void + { + foreach (['default', 'reference', 'static', 'inline', 'default inline reference'] as $options) { + $tokens = $this->importer->import("@theme {$options} { --color-a: #fff; }"); + + self::assertArrayHasKey('a', $tokens['color'], $options); + } + } + + public function testTheLastDeclarationMayOmitItsSemicolon(): void + { + self::assertArrayHasKey('b', $this->importer->import('@theme { --color-a: #fff; --color-b: #000 }')['color']); + } + + public function testInsetShadowsKeepTheirOwnGroup(): void + { + $tokens = $this->importer->import('@theme { --shadow-xs: 0 1px 2px #0000000d; --inset-shadow-xs: inset 0 1px 1px #0000000d; }'); + + self::assertArrayHasKey('xs', $tokens['shadow']); + self::assertArrayHasKey('xs', $tokens['inset-shadow']); + } + + public function testImportsTabSizes(): void + { + self::assertSame(4, $this->importer->import('@theme { --tab-size-code: 4; }')['tab-size']['code']['$value']); + } + + public function testSkippedVariablesAreLoggedByName(): void + { + $tokens = $this->importer->import('@theme default { --color-a: #fff; --animate-spin: spin 1s linear infinite; --text-xs--line-height: calc(1 / 0.75); --font-sans--font-feature-settings: normal; --text-shadow-xs: 0 1px 0 #000; --tracking-tight: -0.025em; --default-font-family: --theme(--font-sans, initial); }'); + + self::assertSame(['a'], array_keys($tokens['color'])); + self::assertSame( + ['--animate-spin', '--text-xs--line-height', '--font-sans--font-feature-settings', '--text-shadow-xs', '--default-font-family', '--tracking-tight'], + array_map(static fn (array $record): mixed => $record['context']['variable'] ?? null, $this->logger->records), + ); + } + + public function testImportsWithoutALogger(): void + { + $tokens = new ThemeImporter()->import('@theme { --color-a: #fff; --font-sans--font-feature-settings: normal; }'); + + self::assertSame(['color'], array_keys($tokens)); + self::assertSame(['a'], array_keys($tokens['color'])); + } +} diff --git a/src/DesignTokens/tests/Unit/CacheWarmer/DesignTokensCacheWarmerTest.php b/src/DesignTokens/tests/Unit/CacheWarmer/DesignTokensCacheWarmerTest.php new file mode 100644 index 00000000000..bef119ef533 --- /dev/null +++ b/src/DesignTokens/tests/Unit/CacheWarmer/DesignTokensCacheWarmerTest.php @@ -0,0 +1,62 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\UX\DesignTokens\Tests\Unit\CacheWarmer; + +use PHPUnit\Framework\Attributes\CoversClass; +use PHPUnit\Framework\TestCase; +use Symfony\Component\Filesystem\Filesystem; +use Symfony\UX\DesignTokens\CacheWarmer\DesignTokensCacheWarmer; +use Symfony\UX\DesignTokens\CacheWarmer\StylesheetCache; +use Symfony\UX\DesignTokens\Generator\CssGenerator; +use Symfony\UX\DesignTokens\Resolver\ConfiguredTokenResolver; + +#[CoversClass(DesignTokensCacheWarmer::class)] +final class DesignTokensCacheWarmerTest extends TestCase +{ + private string $buildDir; + + protected function setUp(): void + { + $this->buildDir = sys_get_temp_dir().'/ux-design-tokens-warmer-'.bin2hex(random_bytes(6)); + } + + protected function tearDown(): void + { + new Filesystem()->remove($this->buildDir); + } + + public function testWarmupIsOptional(): void + { + self::assertTrue($this->warmer()->isOptional()); + } + + public function testWritesTheStylesheetAndReturnsNoFileToPreload(): void + { + self::assertSame([], $this->warmer()->warmUp($this->buildDir, $this->buildDir)); + + self::assertStringContainsString('--dt-color-action-primary:', (string) file_get_contents($this->directory().'/tokens.css')); + } + + private function directory(): string + { + return StylesheetCache::directory($this->buildDir); + } + + private function warmer(): DesignTokensCacheWarmer + { + return new DesignTokensCacheWarmer(new StylesheetCache( + new ConfiguredTokenResolver(resolverPath: \dirname(__DIR__, 2).'/Fixtures/color-scheme/theme.resolver.json'), + new CssGenerator('dt'), + $this->buildDir, + )); + } +} diff --git a/src/DesignTokens/tests/Unit/CacheWarmer/StylesheetCacheTest.php b/src/DesignTokens/tests/Unit/CacheWarmer/StylesheetCacheTest.php new file mode 100644 index 00000000000..5e2f923f4ce --- /dev/null +++ b/src/DesignTokens/tests/Unit/CacheWarmer/StylesheetCacheTest.php @@ -0,0 +1,136 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\UX\DesignTokens\Tests\Unit\CacheWarmer; + +use PHPUnit\Framework\Attributes\CoversClass; +use PHPUnit\Framework\TestCase; +use Symfony\Component\Filesystem\Filesystem; +use Symfony\UX\DesignTokens\CacheWarmer\StylesheetCache; +use Symfony\UX\DesignTokens\Generator\CssGenerator; +use Symfony\UX\DesignTokens\Resolver\ConfiguredTokenResolver; + +#[CoversClass(StylesheetCache::class)] +final class StylesheetCacheTest extends TestCase +{ + private string $directory; + + protected function setUp(): void + { + $this->directory = sys_get_temp_dir().'/dt-stylesheets-'.bin2hex(random_bytes(4)); + $filesystem = new Filesystem(); + $filesystem->dumpFile($this->directory.'/a.tokens.json', '{"c":{"$type":"number","$value":1},"d":{"$ref":"b.json#/d"}}'); + $filesystem->dumpFile($this->directory.'/b.json', '{"d":{"$type":"number","$value":10}}'); + } + + protected function tearDown(): void + { + new Filesystem()->remove($this->directory); + } + + public function testWritesAMissingStylesheet(): void + { + $path = $this->cache(debug: false)->path(); + + self::assertFileExists($path); + self::assertStringContainsString('--dt-c: 1;', (string) file_get_contents($path)); + } + + public function testRebuildsInDebugWhenADocumentChanges(): void + { + $cache = $this->cache(debug: true); + $cache->path(); + $this->rewrite('a.tokens.json', '{"c":{"$type":"number","$value":2}}'); + + self::assertStringContainsString('--dt-c: 2;', (string) file_get_contents($cache->path())); + } + + public function testRebuildsInDebugWhenAFileReachedByAReferenceChanges(): void + { + $cache = $this->cache(debug: true); + $cache->path(); + $this->rewrite('b.json', '{"d":{"$type":"number","$value":20}}'); + + self::assertStringContainsString('--dt-d: 20;', (string) file_get_contents($cache->path())); + } + + public function testKeepsTheStylesheetOutsideDebug(): void + { + $cache = $this->cache(debug: false); + $cache->path(); + $this->rewrite('a.tokens.json', '{"c":{"$type":"number","$value":2}}'); + + self::assertStringContainsString('--dt-c: 1;', (string) file_get_contents($cache->path())); + } + + public function testRebuildsInDebugWhenTheConfigurationChanges(): void + { + $this->cache(debug: true, fingerprint: 'a')->path(); + + $path = $this->cache(debug: true, prefix: 'app', fingerprint: 'b')->path(); + + self::assertStringContainsString('--app-c: 1;', (string) file_get_contents($path)); + } + + public function testReadsNothingWithoutWritingAMissingStylesheet(): void + { + self::assertNull($this->cache(debug: true)->read()); + self::assertDirectoryDoesNotExist($this->directory.'/build'); + } + + public function testReadsAStylesheetThatIsFresh(): void + { + $cache = $this->cache(debug: true); + $cache->path(); + + self::assertStringContainsString('--dt-c: 1;', (string) $cache->read()); + } + + public function testReadsNothingOnceAStylesheetIsStale(): void + { + $cache = $this->cache(debug: true); + $cache->path(); + $this->rewrite('a.tokens.json', '{"c":{"$type":"number","$value":2}}'); + + self::assertNull($cache->read()); + } + + public function testWritesTheDarkSchemeOfTheDefaultSelection(): void + { + $css = (string) file_get_contents(new StylesheetCache( + new ConfiguredTokenResolver(resolverPath: \dirname(__DIR__, 2).'/Fixtures/color-scheme/theme.resolver.json'), + new CssGenerator('dt'), + $this->directory.'/build', + )->path()); + + self::assertStringContainsString('--dt-color-action-primary: color(srgb 0.2 0.4 0.8);', $css); + self::assertStringContainsString(':root[data-theme="dark"]', $css); + self::assertStringContainsString('--dt-color-action-primary: color(srgb 0.5 0.6 0.9);', $css); + } + + private function rewrite(string $file, string $json): void + { + $filesystem = new Filesystem(); + $filesystem->dumpFile($this->directory.'/'.$file, $json); + $filesystem->touch($this->directory.'/'.$file, time() + 10); + } + + private function cache(bool $debug, string $prefix = 'dt', string $fingerprint = ''): StylesheetCache + { + return new StylesheetCache( + new ConfiguredTokenResolver(paths: [$this->directory.'/a.tokens.json']), + new CssGenerator($prefix), + $this->directory.'/build', + debug: $debug, + fingerprint: $fingerprint, + ); + } +} diff --git a/src/DesignTokens/tests/Unit/Command/CommandNamingTest.php b/src/DesignTokens/tests/Unit/Command/CommandNamingTest.php new file mode 100644 index 00000000000..ecbcc7618ff --- /dev/null +++ b/src/DesignTokens/tests/Unit/Command/CommandNamingTest.php @@ -0,0 +1,63 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\UX\DesignTokens\Tests\Unit\Command; + +use PHPUnit\Framework\Attributes\DataProvider; +use PHPUnit\Framework\TestCase; +use Symfony\Component\Console\Attribute\AsCommand; +use Symfony\Component\Console\Command\Command; +use Symfony\UX\DesignTokens\Command\DebugTokensCommand; +use Symfony\UX\DesignTokens\Command\ExportCommand; +use Symfony\UX\DesignTokens\Command\ImportCommand; +use Symfony\UX\DesignTokens\Command\LintDesignTokensCommand; + +final class CommandNamingTest extends TestCase +{ + /** @return iterable, string}> */ + public static function commands(): iterable + { + yield 'debug' => [DebugTokensCommand::class, 'debug:design-tokens']; + yield 'export' => [ExportCommand::class, 'ux:design-tokens:export']; + yield 'import' => [ImportCommand::class, 'ux:design-tokens:import']; + yield 'lint' => [LintDesignTokensCommand::class, 'lint:design-tokens']; + } + + /** @param class-string $class */ + #[DataProvider('commands')] + public function testEveryCommandHasOneNameAndNoAlias(string $class, string $name): void + { + $attributes = new \ReflectionClass($class)->getAttributes(AsCommand::class); + self::assertCount(1, $attributes, \sprintf('"%s" should carry one #[AsCommand].', $class)); + + $declared = $attributes[0]->getArguments(); + + self::assertSame($name, $declared['name'] ?? null); + self::assertArrayNotHasKey('aliases', $declared); + } + + public function testEveryCommandInThePackageIsCovered(): void + { + $declared = array_map( + static fn (string $file): string => basename($file, '.php'), + glob(\dirname(__DIR__, 3).'/src/Command/*Command.php') ?: [], + ); + $covered = array_map( + static fn (array $case): string => new \ReflectionClass($case[0])->getShortName(), + iterator_to_array(self::commands(), false), + ); + + sort($declared); + sort($covered); + + self::assertSame($declared, $covered, 'A new command must be added to the data provider.'); + } +} diff --git a/src/DesignTokens/tests/Unit/Command/DebugTokensCommandTest.php b/src/DesignTokens/tests/Unit/Command/DebugTokensCommandTest.php new file mode 100644 index 00000000000..7917fb4e99d --- /dev/null +++ b/src/DesignTokens/tests/Unit/Command/DebugTokensCommandTest.php @@ -0,0 +1,134 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\UX\DesignTokens\Tests\Unit\Command; + +use PHPUnit\Framework\Attributes\CoversClass; +use PHPUnit\Framework\TestCase; +use Symfony\Component\Console\Command\Command; +use Symfony\Component\Console\Tester\CommandTester; +use Symfony\UX\DesignTokens\Command\DebugTokensCommand; +use Symfony\UX\DesignTokens\Resolver\ArrayDocumentLoader; +use Symfony\UX\DesignTokens\Resolver\ConfiguredTokenResolver; +use Symfony\UX\DesignTokens\Resolver\JsonDocumentLoader; +use Symfony\UX\DesignTokens\Tests\Fixtures\Registries; +use Symfony\UX\DesignTokens\Tests\Fixtures\TemporaryDirectory; +use Symfony\UX\DesignTokens\TokenRegistry; + +#[CoversClass(DebugTokensCommand::class)] +final class DebugTokensCommandTest extends TestCase +{ + private ConfiguredTokenResolver $resolver; + private TokenRegistry $registry; + + protected function setUp(): void + { + $this->resolver = new ConfiguredTokenResolver(new ArrayDocumentLoader(['memory.tokens.json' => json_decode('{"color":{"brand":{"$type":"color","$description":"Brand color","$value":{"colorSpace":"srgb","components":[0.2,0.4,0.8]}}},"spacing":{"sm":{"$type":"dimension","$value":{"value":4,"unit":"px"}}}}', true, 512, \JSON_THROW_ON_ERROR)]), ['memory.tokens.json']); + $this->registry = new TokenRegistry($this->resolver); + } + + public function testDisplaysResolvedTokensAsTable(): void + { + $tester = new CommandTester(new DebugTokensCommand($this->registry)); + $tester->execute([]); + + self::assertSame(Command::SUCCESS, $tester->getStatusCode()); + self::assertStringContainsString('color.brand', $tester->getDisplay()); + self::assertStringContainsString('Brand color', $tester->getDisplay()); + self::assertStringContainsString('spacing.sm', $tester->getDisplay()); + } + + public function testFiltersByTokenOrGroupAndReportsJson(): void + { + $tester = new CommandTester(new DebugTokensCommand($this->registry)); + $tester->execute(['path' => 'color', '--format' => 'json']); + + self::assertSame(Command::SUCCESS, $tester->getStatusCode()); + $result = json_decode($tester->getDisplay(), true, 512, \JSON_THROW_ON_ERROR); + self::assertSame('color', $result['tokens']['color.brand']['type']); + self::assertSame([0.2, 0.4, 0.8], $result['tokens']['color.brand']['value']['components']); + self::assertArrayNotHasKey('spacing.sm', $result['tokens']); + + $tester->execute(['path' => 'spacing.sm']); + self::assertSame(Command::SUCCESS, $tester->getStatusCode()); + self::assertStringContainsString('spacing.sm', $tester->getDisplay()); + } + + public function testReportsMissingPathAndEmptyRegistry(): void + { + $tester = new CommandTester(new DebugTokensCommand($this->registry)); + $tester->execute(['path' => 'missing']); + self::assertSame(Command::FAILURE, $tester->getStatusCode()); + self::assertStringContainsString('No design tokens found', $tester->getDisplay()); + + $tester = new CommandTester(new DebugTokensCommand(new TokenRegistry())); + $tester->execute([]); + self::assertSame(Command::FAILURE, $tester->getStatusCode()); + self::assertStringContainsString('No design tokens are configured', $tester->getDisplay()); + } + + public function testReportsRegistryFailureAndInvalidInput(): void + { + $tester = new CommandTester(new DebugTokensCommand(Registries::fromFiles(paths: ['/missing.tokens.json']))); + $tester->execute([]); + self::assertSame(Command::FAILURE, $tester->getStatusCode()); + self::assertStringContainsString('not found', $tester->getDisplay()); + + $tester = new CommandTester(new DebugTokensCommand($this->registry)); + $tester->execute(['--format' => 'xml']); + self::assertSame(Command::INVALID, $tester->getStatusCode()); + self::assertStringContainsString('--format', $tester->getDisplay()); + + $tester->execute(['path' => ['invalid']]); + self::assertSame(Command::INVALID, $tester->getStatusCode()); + self::assertStringContainsString('must be a string', $tester->getDisplay()); + } + + public function testSourcesNamesTheFileThatWonAndTheOnesItReplaced(): void + { + $directory = new TemporaryDirectory(); + $base = $directory->write('base.tokens.json', '{"color":{"accent":{"$type":"color","$value":{"colorSpace":"srgb","components":[0,0,1]}}}}'); + $brand = $directory->write('brand.tokens.json', '{"color":{"accent":{"$type":"color","$value":{"colorSpace":"srgb","components":[1,0,0]}}}}'); + + try { + $resolver = new ConfiguredTokenResolver(new JsonDocumentLoader(), [$base, $brand]); + $tester = new CommandTester(new DebugTokensCommand(new TokenRegistry($resolver), $resolver)); + $tester->execute(['--sources' => true, '--format' => 'json']); + + self::assertSame(Command::SUCCESS, $tester->getStatusCode()); + $accent = json_decode($tester->getDisplay(), true)['tokens']['color.accent']; + + self::assertSame($brand, $accent['source']); + self::assertSame([$base], $accent['overrides']); + } finally { + $directory->remove(); + } + } + + public function testSourcesAddsTwoColumnsToTheTable(): void + { + $tester = new CommandTester(new DebugTokensCommand($this->registry, $this->resolver)); + $tester->execute(['--sources' => true]); + + self::assertSame(Command::SUCCESS, $tester->getStatusCode()); + self::assertStringContainsString('Source', $tester->getDisplay()); + self::assertStringContainsString('Replaced', $tester->getDisplay()); + } + + public function testProvenanceIsOffByDefault(): void + { + $tester = new CommandTester(new DebugTokensCommand($this->registry)); + $tester->execute([]); + + self::assertStringNotContainsString('Source', $tester->getDisplay()); + self::assertStringNotContainsString('Replaced', $tester->getDisplay()); + } +} diff --git a/src/DesignTokens/tests/Unit/Command/ExportCommandTest.php b/src/DesignTokens/tests/Unit/Command/ExportCommandTest.php new file mode 100644 index 00000000000..53b5c65c988 --- /dev/null +++ b/src/DesignTokens/tests/Unit/Command/ExportCommandTest.php @@ -0,0 +1,334 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\UX\DesignTokens\Tests\Unit\Command; + +use PHPUnit\Framework\Attributes\CoversClass; +use PHPUnit\Framework\Attributes\DataProvider; +use PHPUnit\Framework\TestCase; +use Symfony\Component\Console\Command\Command; +use Symfony\Component\Console\Tester\CommandCompletionTester; +use Symfony\Component\Console\Tester\CommandTester; +use Symfony\Component\DependencyInjection\ServiceLocator; +use Symfony\UX\DesignTokens\Bridge\GoogleDesignMd\DesignMdGenerator; +use Symfony\UX\DesignTokens\Bridge\Tailwind\ThemeGenerator; +use Symfony\UX\DesignTokens\Command\ExportCommand; +use Symfony\UX\DesignTokens\Generator\ColorScheme; +use Symfony\UX\DesignTokens\Generator\CssGenerator; +use Symfony\UX\DesignTokens\Generator\DtcgGenerator; +use Symfony\UX\DesignTokens\Generator\GeneratorInterface; +use Symfony\UX\DesignTokens\Generator\JavaScriptGenerator; +use Symfony\UX\DesignTokens\Tests\Fixtures\Registries; +use Symfony\UX\DesignTokens\Tests\Fixtures\TemporaryDirectory; +use Symfony\UX\DesignTokens\Tests\Fixtures\TokenValues; +use Symfony\UX\DesignTokens\TokenRegistry; + +#[CoversClass(ExportCommand::class)] +final class ExportCommandTest extends TestCase +{ + private TokenRegistry $tokenRegistry; + private CommandTester $tester; + private TemporaryDirectory $directory; + + protected function setUp(): void + { + $this->tokenRegistry = Registries::fromJson(json_encode([ + 'color' => [ + 'brand' => [ + '$type' => 'color', + '$value' => TokenValues::color(), + ], + ], + 'spacing' => [ + '$type' => 'dimension', + 'md' => ['$value' => TokenValues::dimension(16)], + ], + ], \JSON_THROW_ON_ERROR)); + + $this->tester = new CommandTester(self::command($this->tokenRegistry)); + $this->directory = new TemporaryDirectory(); + } + + protected function tearDown(): void + { + $this->directory->remove(); + } + + /** + * @param array $input + * @param list $expected + */ + #[DataProvider('stdoutExports')] + public function testExportsToStdout(array $input, array $expected): void + { + $this->tester->execute($input); + + self::assertSame(Command::SUCCESS, $this->tester->getStatusCode()); + foreach ($expected as $fragment) { + self::assertStringContainsString($fragment, $this->tester->getDisplay()); + } + } + + /** @return iterable, list}> */ + public static function stdoutExports(): iterable + { + yield 'css' => [['format' => 'css'], [':root {', '--dt-color-brand']]; + yield 'css with an explicit application prefix' => [['format' => 'css', '--css-prefix' => 'my'], ['--my-color-brand']]; + yield 'tailwind theme' => [['format' => 'tailwind'], ['@theme static {', '--color-brand:', '--spacing-md: 16px;']]; + yield 'javascript' => [['format' => 'javascript'], [ + 'export const tokens = Object.freeze(JSON.parse(', + '\\"color.brand\\":\\"color(srgb 0.2 0.4 0.8)\\"', + 'export function token(path)', + ]]; + yield 'design.md' => [['format' => 'design.md'], ['version: alpha', '# Design System']]; + yield 'design.md with a custom title' => [['format' => 'design.md', '--title' => 'Acme Corp'], ['Acme Corp']]; + } + + /** @param array $input */ + #[DataProvider('rejectedExports')] + public function testRejectsAnExportItCannotRun(array $input, int $status, string $message): void + { + $this->tester->execute($input); + + self::assertSame($status, $this->tester->getStatusCode()); + self::assertStringContainsString($message, $this->tester->getDisplay()); + } + + /** @return iterable, int, string}> */ + public static function rejectedExports(): iterable + { + yield 'invalid css prefix' => [['format' => 'css', '--css-prefix' => '--my'], Command::FAILURE, 'CSS prefix']; + yield 'unknown format' => [['format' => 'xml'], Command::INVALID, 'Unknown format']; + yield 'malformed input' => [['format' => 'dtcg', '--input' => ['scheme']], Command::FAILURE, 'name=value']; + yield 'permutations without a resolver document' => [['format' => 'dtcg', 'output' => '/tmp/unused.json', '--all-permutations' => true], Command::FAILURE, 'Resolver document']; + yield 'permutations without an output path' => [['format' => 'dtcg', '--all-permutations' => true], Command::FAILURE, 'output path']; + } + + public function testExportCssWritesTheDarkColorScheme(): void + { + $tester = new CommandTester(self::command(Registries::colorScheme())); + $tester->execute(['format' => 'css', '--css-prefix' => 'my']); + + self::assertSame(Command::SUCCESS, $tester->getStatusCode()); + [$root, $dark] = explode('@media (prefers-color-scheme: dark)', $tester->getDisplay(), 2); + self::assertStringContainsString('--my-color-action-primary: color(srgb 0.2 0.4 0.8);', $root); + self::assertStringContainsString('--my-color-action-primary: color(srgb 0.5 0.6 0.9);', $dark); + self::assertStringContainsString(':root[data-theme="dark"]', $dark); + self::assertStringNotContainsString('--my-dimension-spacing-md', $dark); + } + + public function testExportCssOfASelectedSchemeHasNoDarkBlock(): void + { + $tester = new CommandTester(self::command(Registries::colorScheme())); + $tester->execute(['format' => 'css', '--input' => ['scheme=dark']]); + + self::assertSame(Command::SUCCESS, $tester->getStatusCode()); + self::assertStringContainsString('--dt-color-action-primary: color(srgb 0.5 0.6 0.9);', $tester->getDisplay()); + self::assertStringNotContainsString('prefers-color-scheme', $tester->getDisplay()); + } + + public function testExportCssUsesTheConfiguredColorScheme(): void + { + $generators = new ServiceLocator(['css' => static fn (): GeneratorInterface => new CssGenerator()]); + $configured = new CommandTester(new ExportCommand(Registries::colorScheme('named-theme.resolver.json'), $generators, colorScheme: new ColorScheme('mode', 'day', 'night'))); + $default = new CommandTester(new ExportCommand(Registries::colorScheme('named-theme.resolver.json'), $generators)); + + $configured->execute(['format' => 'css']); + $default->execute(['format' => 'css']); + + self::assertStringContainsString('prefers-color-scheme', $configured->getDisplay()); + self::assertStringNotContainsString('prefers-color-scheme', $default->getDisplay()); + } + + public function testExportDtcgToStdout(): void + { + $this->tester->execute(['format' => 'dtcg']); + + self::assertSame(Command::SUCCESS, $this->tester->getStatusCode()); + + $data = json_decode($this->tester->getDisplay(), true, 512, \JSON_THROW_ON_ERROR); + self::assertSame('color', $data['color']['brand']['$type']); + self::assertSame(TokenValues::color(), $data['color']['brand']['$value']); + self::assertSame('dimension', $data['spacing']['md']['$type']); + } + + public function testExportToFile(): void + { + $outputPath = $this->directory->path('tokens.css'); + + $this->tester->execute(['format' => 'css', 'output' => $outputPath]); + + self::assertSame(Command::SUCCESS, $this->tester->getStatusCode()); + self::assertFileExists($outputPath); + self::assertStringContainsString(':root {', file_get_contents($outputPath)); + } + + public function testExportCreatesTheOutputDirectory(): void + { + $outputPath = $this->directory->path('export/nested/tokens.css'); + + $this->tester->execute(['format' => 'css', 'output' => $outputPath]); + + self::assertSame(Command::SUCCESS, $this->tester->getStatusCode()); + self::assertFileExists($outputPath); + } + + public function testExportReportsAnUnwritableOutputPath(): void + { + $blocker = $this->directory->write('blocker', ''); + + $this->tester->execute(['format' => 'css', 'output' => $blocker.'/tokens.css']); + + self::assertSame(Command::FAILURE, $this->tester->getStatusCode()); + self::assertStringContainsString('Could not write export', $this->tester->getDisplay()); + } + + public function testExportSelectsAResolverContextWithInput(): void + { + $tester = $this->resolverTester(); + + $tester->execute(['format' => 'dtcg', '--input' => ['scheme=dark']]); + + self::assertSame(Command::SUCCESS, $tester->getStatusCode()); + self::assertStringContainsString('"$value": 2', $tester->getDisplay()); + } + + public function testExportWritesOneFilePerPermutation(): void + { + $tester = $this->resolverTester(); + + $tester->execute([ + 'format' => 'dtcg', + 'output' => $this->directory->path('permutations/theme.tokens.json'), + '--all-permutations' => true, + ]); + + self::assertSame(Command::SUCCESS, $tester->getStatusCode()); + self::assertFileExists($light = $this->directory->path('permutations/theme.scheme-light.tokens.json')); + self::assertFileExists($dark = $this->directory->path('permutations/theme.scheme-dark.tokens.json')); + self::assertStringContainsString('"$value": 1', (string) file_get_contents($light)); + self::assertStringContainsString('"$value": 2', (string) file_get_contents($dark)); + } + + public function testExportRefusesTwoPermutationsSharingAFileName(): void + { + $path = $this->directory->write('export.resolver.json', [ + 'version' => '2025.10', + 'modifiers' => ['size' => ['contexts' => [ + 'a b' => [['n' => ['$type' => 'number', '$value' => 1]]], + 'a-b' => [['n' => ['$type' => 'number', '$value' => 2]]], + ], 'default' => 'a b']], + 'resolutionOrder' => [['$ref' => '#/modifiers/size']], + ]); + $directory = $this->directory->path('permutations'); + + $tester = new CommandTester(self::command(Registries::fromFiles(resolverPath: $path))); + $tester->execute(['format' => 'dtcg', 'output' => $directory.'/theme.tokens.json', '--all-permutations' => true]); + + self::assertSame(Command::FAILURE, $tester->getStatusCode()); + self::assertStringContainsString('same file', $tester->getDisplay()); + self::assertDirectoryDoesNotExist($directory); + } + + public function testAnyGeneratorReceivesTheExportContext(): void + { + $generator = new class implements GeneratorInterface { + /** @var array */ + public array $context = []; + + public function generate(array $resolvedTokens, array $context = []): string + { + $this->context = $context; + + return 'custom'; + } + }; + + $tester = new CommandTester(new ExportCommand(Registries::fromFiles(resolverPath: $this->writeSchemeResolver()), new ServiceLocator(['custom' => static fn (): GeneratorInterface => $generator]))); + $tester->execute(['format' => 'custom', '--title' => 'Acme', '--css-prefix' => 'app']); + + self::assertSame('Acme', $generator->context[GeneratorInterface::TITLE]); + self::assertSame('app', $generator->context[GeneratorInterface::CSS_PREFIX]); + self::assertSame(2, $generator->context[GeneratorInterface::DARK_TOKENS]['theme']->getValue()); + } + + public function testAFormatKeyedByAServiceIdIsFound(): void + { + $generator = new class implements GeneratorInterface { + public function generate(array $resolvedTokens, array $context = []): string + { + return 'custom'; + } + }; + $tester = new CommandTester(new ExportCommand($this->tokenRegistry, new ServiceLocator(['App\\Export\\Scss' => static fn (): GeneratorInterface => $generator]))); + + self::assertSame(Command::SUCCESS, $tester->execute(['format' => 'App\\Export\\Scss'])); + self::assertSame('custom', $tester->getDisplay()); + } + + public function testAnApplicationCssGeneratorIsUsedAsIs(): void + { + $generator = new class implements GeneratorInterface { + public function generate(array $resolvedTokens, array $context = []): string + { + return 'custom css'; + } + }; + $tester = new CommandTester(new ExportCommand($this->tokenRegistry, new ServiceLocator(['css' => static fn (): GeneratorInterface => $generator]))); + + $tester->execute(['format' => 'css']); + + self::assertSame('custom css', $tester->getDisplay()); + } + + public function testCompletesFormats(): void + { + $tester = new CommandCompletionTester(self::command($this->tokenRegistry)); + + self::assertContains('css', $tester->complete([''])); + self::assertContains('dtcg', $tester->complete([''])); + } + + private function resolverTester(): CommandTester + { + return new CommandTester(self::command(Registries::fromFiles(resolverPath: $this->writeSchemeResolver()))); + } + + private function writeSchemeResolver(): string + { + return $this->directory->write('theme.resolver.json', [ + 'version' => '2025.10', + 'modifiers' => [ + 'scheme' => [ + 'contexts' => [ + 'light' => [['theme' => ['$type' => 'number', '$value' => 1]]], + 'dark' => [['theme' => ['$type' => 'number', '$value' => 2]]], + ], + 'default' => 'light', + ], + ], + 'resolutionOrder' => [['$ref' => '#/modifiers/scheme']], + ]); + } + + private static function command(TokenRegistry $registry): ExportCommand + { + $generators = new ServiceLocator([ + 'dtcg' => static fn (): GeneratorInterface => new DtcgGenerator(), + 'css' => static fn (): GeneratorInterface => new CssGenerator(), + 'javascript' => static fn (): GeneratorInterface => new JavaScriptGenerator(), + 'design.md' => static fn (): GeneratorInterface => new DesignMdGenerator(), + 'tailwind' => static fn (): GeneratorInterface => new ThemeGenerator(), + ]); + + return new ExportCommand($registry, $generators); + } +} diff --git a/src/DesignTokens/tests/Unit/Command/ImportCommandTest.php b/src/DesignTokens/tests/Unit/Command/ImportCommandTest.php new file mode 100644 index 00000000000..19e65664655 --- /dev/null +++ b/src/DesignTokens/tests/Unit/Command/ImportCommandTest.php @@ -0,0 +1,165 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\UX\DesignTokens\Tests\Unit\Command; + +use PHPUnit\Framework\Attributes\CoversClass; +use PHPUnit\Framework\TestCase; +use Symfony\Component\Console\Command\Command; +use Symfony\Component\Console\Output\OutputInterface; +use Symfony\Component\Console\Tester\CommandCompletionTester; +use Symfony\Component\Console\Tester\CommandTester; +use Symfony\Component\DependencyInjection\ServiceLocator; +use Symfony\Component\Filesystem\Filesystem; +use Symfony\UX\DesignTokens\Bridge\Tailwind\ThemeImporter; +use Symfony\UX\DesignTokens\Command\ImportCommand; +use Symfony\UX\DesignTokens\Importer\ImporterInterface; +use Symfony\UX\DesignTokens\Resolver\TokenTreeBuilder; +use Symfony\UX\DesignTokens\Tests\Fixtures\TemporaryDirectory; + +#[CoversClass(ImportCommand::class)] +final class ImportCommandTest extends TestCase +{ + private TemporaryDirectory $directory; + private string $input; + private CommandTester $tester; + + protected function setUp(): void + { + $this->directory = new TemporaryDirectory(); + $this->input = $this->directory->write('theme.css', '@theme { --color-brand: #336699; --spacing-md: 1rem; }'); + $this->tester = new CommandTester(self::command()); + } + + protected function tearDown(): void + { + $this->directory->remove(); + } + + public function testImportsToStdout(): void + { + $this->tester->execute(['format' => 'tailwind', 'input' => $this->input]); + + self::assertSame(Command::SUCCESS, $this->tester->getStatusCode()); + $tokens = json_decode($this->tester->getDisplay(), true, 512, \JSON_THROW_ON_ERROR); + self::assertSame('color', $tokens['color']['brand']['$type']); + self::assertSame('dimension', $tokens['spacing']['md']['$type']); + } + + public function testImportsToAFile(): void + { + $output = $this->directory->path('tokens.json'); + + $this->tester->execute(['format' => 'tailwind', 'input' => $this->input, 'output' => $output]); + self::assertSame(Command::SUCCESS, $this->tester->getStatusCode()); + self::assertFileExists($output); + self::assertStringContainsString('Imported tailwind tokens', $this->tester->getDisplay()); + } + + public function testReportsUnknownFormatInvalidInputAndWriteFailure(): void + { + $this->tester->execute(['format' => 'json', 'input' => $this->input]); + self::assertSame(Command::INVALID, $this->tester->getStatusCode()); + self::assertStringContainsString('Unknown import format "json". Available: tailwind', $this->tester->getDisplay()); + + $this->tester->execute(['format' => 'tailwind', 'input' => $this->input.'.missing']); + self::assertSame(Command::FAILURE, $this->tester->getStatusCode()); + self::assertStringContainsString('not found', $this->tester->getDisplay()); + + $blocker = $this->directory->write('blocker', ''); + $this->tester->execute(['format' => 'tailwind', 'input' => $this->input, 'output' => $blocker.'/tokens.json']); + self::assertSame(Command::FAILURE, $this->tester->getStatusCode()); + self::assertStringContainsString('Could not write imported tokens', $this->tester->getDisplay()); + } + + public function testReportsAnUnreadableInput(): void + { + new Filesystem()->chmod($this->input, 0o000); + + try { + $this->tester->execute(['format' => 'tailwind', 'input' => $this->input]); + + self::assertSame(Command::FAILURE, $this->tester->getStatusCode()); + self::assertStringContainsString('Could not read', $this->tester->getDisplay()); + } finally { + new Filesystem()->chmod($this->input, 0o600); + } + } + + public function testAnyImporterJoinsTheCommandUnderItsFormat(): void + { + $importer = new class implements ImporterInterface { + public function import(string $contents, array $context = []): array + { + return ['imported' => ['$type' => 'number', '$value' => \strlen($contents)]]; + } + }; + $tester = new CommandTester(new ImportCommand(new ServiceLocator(['css' => static fn (): ImporterInterface => $importer]), new TokenTreeBuilder())); + + self::assertSame(Command::SUCCESS, $tester->execute(['format' => 'css', 'input' => $this->input])); + self::assertStringContainsString('"imported"', $tester->getDisplay()); + } + + public function testCompletesFormats(): void + { + self::assertSame(['tailwind'], new CommandCompletionTester(self::command())->complete([''])); + } + + public function testRejectsNonStringArgumentsWhenInvokedProgrammatically(): void + { + $this->tester->execute(['format' => [], 'input' => $this->input]); + self::assertSame(Command::INVALID, $this->tester->getStatusCode()); + self::assertStringContainsString('format must be a string', $this->tester->getDisplay()); + + $this->tester->execute(['format' => 'tailwind', 'input' => []]); + self::assertSame(Command::FAILURE, $this->tester->getStatusCode()); + self::assertStringContainsString('input path must be a string', $this->tester->getDisplay()); + + $this->tester->execute(['format' => 'tailwind', 'input' => $this->input, 'output' => []]); + self::assertSame(Command::INVALID, $this->tester->getStatusCode()); + self::assertStringContainsString('output path must be a string', $this->tester->getDisplay()); + } + + public function testLogsSkippedVariablesToStderrByVerbosity(): void + { + $this->directory->write('theme.css', '@theme { --color-brand: #336699; --animate-spin: spin 1s linear infinite; --spacing-fluid: 1vw; }'); + + $this->tester->execute(['format' => 'tailwind', 'input' => $this->input], ['capture_stderr_separately' => true]); + + self::assertSame(Command::SUCCESS, $this->tester->getStatusCode()); + self::assertArrayHasKey('color', json_decode($this->tester->getDisplay(), true, 512, \JSON_THROW_ON_ERROR)); + self::assertStringContainsString('--spacing-fluid', $this->tester->getErrorOutput()); + self::assertStringNotContainsString('--animate-spin', $this->tester->getErrorOutput()); + + $this->tester->execute(['format' => 'tailwind', 'input' => $this->input], ['capture_stderr_separately' => true, 'verbosity' => OutputInterface::VERBOSITY_VERBOSE]); + + self::assertStringContainsString('--animate-spin', $this->tester->getErrorOutput()); + } + + public function testDoesNotReplaceAnExistingFileWithoutForce(): void + { + $output = $this->directory->write('tokens.json', 'kept'); + + $this->tester->execute(['format' => 'tailwind', 'input' => $this->input, 'output' => $output]); + self::assertSame(Command::FAILURE, $this->tester->getStatusCode()); + self::assertStringContainsString('--force', $this->tester->getDisplay()); + self::assertSame('kept', file_get_contents($output)); + + $this->tester->execute(['format' => 'tailwind', 'input' => $this->input, 'output' => $output, '--force' => true]); + self::assertSame(Command::SUCCESS, $this->tester->getStatusCode()); + self::assertStringContainsString('"brand"', (string) file_get_contents($output)); + } + + private static function command(): ImportCommand + { + return new ImportCommand(new ServiceLocator(['tailwind' => static fn (): ImporterInterface => new ThemeImporter()]), new TokenTreeBuilder()); + } +} diff --git a/src/DesignTokens/tests/Unit/Command/LintDesignTokensCommandTest.php b/src/DesignTokens/tests/Unit/Command/LintDesignTokensCommandTest.php new file mode 100644 index 00000000000..24fcf15adb0 --- /dev/null +++ b/src/DesignTokens/tests/Unit/Command/LintDesignTokensCommandTest.php @@ -0,0 +1,345 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\UX\DesignTokens\Tests\Unit\Command; + +use PHPUnit\Framework\Attributes\CoversClass; +use PHPUnit\Framework\Attributes\DataProvider; +use PHPUnit\Framework\TestCase; +use Symfony\Component\Console\Command\Command; +use Symfony\Component\Console\Tester\CommandTester; +use Symfony\Component\Filesystem\Filesystem; +use Symfony\UX\DesignTokens\Command\LintDesignTokensCommand; +use Symfony\UX\DesignTokens\Resolver\JsonDocumentLoader; +use Symfony\UX\DesignTokens\Resolver\TokenTreeBuilder; +use Symfony\UX\DesignTokens\Tests\Fixtures\Registries; +use Symfony\UX\DesignTokens\Tests\Fixtures\TemporaryDirectory; +use Symfony\UX\DesignTokens\TokenRegistry; +use Symfony\UX\DesignTokens\Validation\DtcgValidator; +use Symfony\UX\DesignTokens\Validation\Normalizer; + +#[CoversClass(LintDesignTokensCommand::class)] +final class LintDesignTokensCommandTest extends TestCase +{ + private TemporaryDirectory $directory; + private string $valid; + + protected function setUp(): void + { + $this->directory = new TemporaryDirectory(); + $this->valid = $this->directory->write('valid.tokens.json', self::tokens()); + $this->directory->write('nested/invalid.tokens.json', '{"bad":{"$type":"dimension","$value":"12px"}}'); + $this->directory->write('ignored.json', '{}'); + } + + protected function tearDown(): void + { + $this->directory->remove(); + } + + public function testLintsConfiguredFilesByDefault(): void + { + $tester = $this->tester([$this->valid]); + $tester->execute([]); + + self::assertSame(Command::SUCCESS, $tester->getStatusCode()); + self::assertStringContainsString('The design token document is valid', $tester->getDisplay()); + } + + public function testLintsDirectoriesAndReportsJson(): void + { + $tester = $this->tester(); + $tester->execute(['filename' => [$this->directory->path()], '--format' => 'json']); + + self::assertSame(Command::FAILURE, $tester->getStatusCode()); + $result = json_decode($tester->getDisplay(), true, 512, \JSON_THROW_ON_ERROR); + self::assertFalse($result['valid']); + self::assertCount(2, $result['files']); + self::assertStringContainsString('structured dimension', $result['files'][0]['error'] ?? $result['files'][1]['error']); + } + + public function testReportsGithubAnnotationsAndEscapesValues(): void + { + $path = $this->directory->write('bad,file.tokens.json', '{'); + + $tester = $this->tester(); + $tester->execute(['filename' => [$path], '--format' => 'github']); + + self::assertSame(Command::FAILURE, $tester->getStatusCode()); + self::assertStringContainsString('::error file=', $tester->getDisplay()); + self::assertStringContainsString('bad%2Cfile.tokens.json', $tester->getDisplay()); + self::assertStringContainsString('::Invalid JSON in "', $tester->getDisplay()); + self::assertStringContainsString('": Syntax error', $tester->getDisplay()); + } + + public function testSupportsStdinAndReportsReadFailure(): void + { + $tester = $this->stdinTester(self::tokens()); + $tester->execute(['filename' => ['-']]); + self::assertSame(Command::SUCCESS, $tester->getStatusCode()); + + $tester = $this->stdinTester(''); + $tester->execute(['filename' => ['-']]); + self::assertSame(Command::FAILURE, $tester->getStatusCode()); + self::assertStringContainsString('Could not read', $tester->getDisplay()); + } + + public function testValidatesTheConfiguredResolver(): void + { + $tester = $this->tester(resolver: \dirname(__DIR__, 2).'/Integration/Fixtures/theme.resolver.json'); + + self::assertSame(Command::SUCCESS, $tester->execute([])); + } + + /** + * @param array $input a "filename" list names entries of the temporary directory + */ + #[DataProvider('rejectedRunProvider')] + public function testRejectsARunItCannotLint(array $input, int $status, string $message): void + { + $this->directory->mkdir('empty'); + if (\is_array($input['filename'] ?? null)) { + $input['filename'] = array_map($this->directory->path(...), $input['filename']); + } + + $tester = $this->tester(); + + self::assertSame($status, $tester->execute($input)); + self::assertStringContainsString($message, $tester->getDisplay()); + } + + /** @return iterable, int, string}> */ + public static function rejectedRunProvider(): iterable + { + yield 'no file given or configured' => [[], Command::INVALID, 'No design token files']; + yield 'unknown format' => [['filename' => ['valid.tokens.json'], '--format' => 'xml'], Command::INVALID, '--format']; + yield 'filename that is not a list' => [['filename' => 'not-a-list'], Command::INVALID, 'must be a list']; + yield 'missing path' => [['filename' => ['missing']], Command::FAILURE, 'path not found']; + yield 'empty directory' => [['filename' => ['empty']], Command::FAILURE, 'No .tokens.json']; + yield 'file without the .tokens.json suffix' => [['filename' => ['ignored.json']], Command::FAILURE, 'Expected a .tokens.json']; + } + + public function testFixRewritesADocumentInItsNormalizedForm(): void + { + $compact = $this->directory->write('compact.tokens.json', self::tokens()); + + $tester = $this->tester(); + self::assertSame(Command::SUCCESS, $tester->execute(['filename' => [$compact], '--fix' => true])); + + $rewritten = (string) file_get_contents($compact); + self::assertNotSame(self::tokens(), $rewritten); + self::assertStringContainsString("\n", $rewritten); + self::assertStringContainsString('Normalized '.$compact, $tester->getDisplay()); + self::assertSame(json_decode(self::tokens(), true), json_decode($rewritten, true)); + + $second = $this->tester(); + $second->execute(['filename' => [$compact], '--fix' => true]); + self::assertStringNotContainsString('Normalized', $second->getDisplay()); + self::assertSame($rewritten, file_get_contents($compact)); + } + + public function testWithoutFixANonNormalizedDocumentIsStillValid(): void + { + $tester = $this->tester(); + + self::assertSame(Command::SUCCESS, $tester->execute(['filename' => [$this->valid]])); + self::assertStringNotContainsString('Normalized', $tester->getDisplay()); + self::assertSame(self::tokens(), file_get_contents($this->valid)); + } + + public function testFixOnStdinWritesTheNormalizedDocumentToOutput(): void + { + $tester = $this->stdinTester(self::tokens()); + + self::assertSame(Command::SUCCESS, $tester->execute(['filename' => ['-'], '--fix' => true])); + self::assertStringContainsString('"color"', $tester->getDisplay()); + self::assertStringNotContainsString('Normalized -', $tester->getDisplay()); + } + + public function testLintingTheConfiguredFilesAlsoChecksTheApplicationResolution(): void + { + $registry = Registries::fromFiles(paths: [$this->valid]); + $tester = $this->tester([$this->valid], registry: $registry); + + self::assertSame(Command::SUCCESS, $tester->execute([])); + self::assertStringContainsString('configured resolution holds', $tester->getDisplay()); + + $json = $this->tester([$this->valid], registry: Registries::fromFiles(paths: [$this->valid])); + $json->execute(['--format' => 'json']); + self::assertSame( + ['valid' => true, 'tokens' => 1, 'error' => null], + json_decode($json->getDisplay(), true)['resolution'], + ); + } + + /** @param array $input */ + #[DataProvider('brokenResolutionOutputProvider')] + public function testAResolutionThatFailsIsReportedEvenWhenEveryDocumentIsValid(array $input, string $expected): void + { + $tester = $this->danglingTester(); + + self::assertSame(Command::FAILURE, $tester->execute($input)); + self::assertStringContainsString($expected, $tester->getDisplay()); + } + + /** @return iterable, string}> */ + public static function brokenResolutionOutputProvider(): iterable + { + yield 'text' => [[], 'The configured design tokens do not resolve']; + yield 'GitHub annotation' => [['--format' => 'github'], '::error::']; + } + + /** @param list $configured */ + private function tester(array $configured = [], ?string $resolver = null, ?TokenRegistry $registry = null): CommandTester + { + $validator = new DtcgValidator(new TokenTreeBuilder(new JsonDocumentLoader())); + + return new CommandTester(new LintDesignTokensCommand($validator, new Normalizer($validator), $registry, $configured, $resolver)); + } + + private function stdinTester(string $stdin): CommandTester + { + $tester = $this->tester(); + $tester->setInputs([$stdin]); + + return $tester; + } + + private function danglingTester(): CommandTester + { + $dangling = $this->directory->write('dangling.tokens.json', '{"color":{"alias":{"$type":"color","$value":"{color.missing}"}}}'); + + return $this->tester([$dangling], registry: Registries::fromFiles(paths: [$dangling])); + } + + private static function tokens(): string + { + return '{"color":{"brand":{"$type":"color","$value":{"colorSpace":"srgb","components":[0.2,0.4,0.8]}}}}'; + } + + public function testADocumentALintedResolverCompletesIsLintedAndFixed(): void + { + $theme = $this->writeLayeredTheme('{"color":{"text":{"$type":"color","$value":"{color.brand}"}}}'); + + $tester = $this->tester(); + + self::assertSame(Command::SUCCESS, $tester->execute(['filename' => [$theme], '--fix' => true]), $tester->getDisplay()); + self::assertStringContainsString('Normalized '.$theme.'/foundation.tokens.json', $tester->getDisplay()); + self::assertStringContainsString("\n", (string) file_get_contents($theme.'/foundation.tokens.json')); + } + + public function testTheResolverReportsAReferenceNoSourceDefines(): void + { + $theme = $this->writeLayeredTheme('{"color":{"text":{"$type":"color","$value":"{color.brnad}"}}}'); + + $tester = $this->tester(); + $tester->execute(['filename' => [$theme], '--format' => 'json']); + + self::assertSame(Command::FAILURE, $tester->getStatusCode()); + $files = array_column(json_decode($tester->getDisplay(), true, 512, \JSON_THROW_ON_ERROR)['files'], null, 'file'); + self::assertTrue($files[$theme.'/foundation.tokens.json']['valid']); + self::assertStringContainsString('{color.brnad}', (string) $files[$theme.'/theme.resolver.json']['error']); + } + + public function testAnInvalidValueALaterSourceOverridesIsStillReported(): void + { + $theme = $this->writeLayeredTheme('{"color":{"brand":{"$type":"dimension","$value":"12px"}}}'); + + $tester = $this->tester(); + $tester->execute(['filename' => [$theme], '--format' => 'json']); + + $files = array_column(json_decode($tester->getDisplay(), true, 512, \JSON_THROW_ON_ERROR)['files'], null, 'file'); + self::assertStringContainsString('structured dimension', (string) $files[$theme.'/foundation.tokens.json']['error']); + } + + public function testADocumentLintedWithoutItsResolverSaysWhatToLint(): void + { + $theme = $this->writeLayeredTheme('{"color":{"text":{"$type":"color","$value":"{color.brand}"}}}'); + + $tester = $this->tester(); + + self::assertSame(Command::FAILURE, $tester->execute(['filename' => [$theme.'/foundation.tokens.json']])); + self::assertStringContainsString('lint it with that Resolver', $tester->getDisplay()); + } + + private function writeLayeredTheme(string $foundation): string + { + $this->directory->write('theme/foundation.tokens.json', $foundation); + $this->directory->write('theme/brand.tokens.json', '{"color":{"brand":{"$type":"color","$value":{"colorSpace":"srgb","components":[0,0,1]}}}}'); + $this->directory->write('theme/theme.resolver.json', '{"version":"2025.10","sets":{"foundation":{"sources":[{"$ref":"foundation.tokens.json"}]},"brand":{"sources":[{"$ref":"brand.tokens.json"}]}},"resolutionOrder":[{"$ref":"#/sets/foundation"},{"$ref":"#/sets/brand"}]}'); + + return $this->directory->path().'/theme'; + } + + public function testFixReportsADocumentItCannotRewrite(): void + { + $file = $this->directory->write('locked/theme.tokens.json', self::tokens()); + new Filesystem()->chmod($locked = \dirname($file), 0o500); + + try { + $tester = $this->tester(); + + self::assertSame(Command::FAILURE, $tester->execute(['filename' => [$file], '--fix' => true])); + self::assertStringContainsString('Could not write the normalized document', $tester->getDisplay()); + self::assertSame(self::tokens(), file_get_contents($file)); + } finally { + new Filesystem()->chmod($locked, 0o700); + } + } + + public function testABrokenResolutionIsReportedInJson(): void + { + $tester = $this->danglingTester(); + + self::assertSame(Command::FAILURE, $tester->execute(['--format' => 'json'])); + $payload = json_decode($tester->getDisplay(), true); + self::assertFalse($payload['valid']); + self::assertFalse($payload['resolution']['valid']); + self::assertNotNull($payload['resolution']['error']); + } + + public function testAnUnreadableDocumentIsReported(): void + { + $unreadable = $this->directory->write('unreadable.tokens.json', self::tokens()); + new Filesystem()->chmod($unreadable, 0o000); + + $tester = $this->tester(); + $tester->execute(['filename' => [$unreadable]]); + + self::assertSame(Command::FAILURE, $tester->getStatusCode()); + } + + public function testFixingStdinWritesOnlyTheDocumentToStdout(): void + { + $tester = $this->stdinTester(self::tokens()); + $tester->execute(['filename' => ['-'], '--fix' => true, '--format' => 'json'], ['capture_stderr_separately' => true]); + + self::assertSame(Command::SUCCESS, $tester->getStatusCode()); + self::assertArrayHasKey('color', json_decode($tester->getDisplay(), true, 512, \JSON_THROW_ON_ERROR)); + self::assertTrue(json_decode($tester->getErrorOutput(), true, 512, \JSON_THROW_ON_ERROR)['valid']); + } + + public function testDefaultsToGithubAnnotationsOnGithubActions(): void + { + $previous = getenv('GITHUB_ACTIONS'); + putenv('GITHUB_ACTIONS=true'); + $broken = $this->directory->write('broken.tokens.json', '{'); + + try { + $tester = $this->tester(); + $tester->execute(['filename' => [$broken]]); + + self::assertStringContainsString('::error file=', $tester->getDisplay()); + } finally { + false === $previous ? putenv('GITHUB_ACTIONS') : putenv('GITHUB_ACTIONS='.$previous); + } + } +} diff --git a/src/DesignTokens/tests/Unit/Conformance/FormatColorConformanceTest.php b/src/DesignTokens/tests/Unit/Conformance/FormatColorConformanceTest.php new file mode 100644 index 00000000000..5ef75a5be4a --- /dev/null +++ b/src/DesignTokens/tests/Unit/Conformance/FormatColorConformanceTest.php @@ -0,0 +1,96 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\UX\DesignTokens\Tests\Unit\Conformance; + +use PHPUnit\Framework\Attributes\CoversClass; +use PHPUnit\Framework\Attributes\DataProvider; +use PHPUnit\Framework\TestCase; +use Symfony\UX\DesignTokens\Token\ColorToken; +use Symfony\UX\DesignTokens\Token\GradientToken; +use Symfony\UX\DesignTokens\Validation\ColorRangeInspector; +use Symfony\UX\DesignTokens\Validation\TokenValueValidator; + +#[CoversClass(TokenValueValidator::class)] +#[CoversClass(GradientToken::class)] +final class FormatColorConformanceTest extends TestCase +{ + #[DataProvider('validValueProvider')] + public function testAcceptsEveryPinnedPositiveFormatAndColorCase(string $type, mixed $value): void + { + new TokenValueValidator()->validate($type, $value, '/fixture'); + $this->addToAssertionCount(1); + } + + private const array OUT_OF_RANGE = [ + 'color/srgb-below', + 'color/srgb-above', + 'color/hsl-percent-above', + 'color/lab-lightness-above', + 'color/oklab-lightness-above', + 'color/lch-negative-chroma', + ]; + + #[DataProvider('invalidValueProvider')] + public function testRejectsEveryPinnedNegativeFormatAndColorCase(string $type, mixed $value, string $case): void + { + if (\in_array($case, self::OUT_OF_RANGE, true)) { + new TokenValueValidator()->validate($type, $value, '/fixture'); + self::assertNotSame([], new ColorRangeInspector()->inspect([ + 'fixture' => new ColorToken($value), + ]), \sprintf('"%s" should be reported as out of range.', $case)); + + return; + } + + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('/fixture'); + + new TokenValueValidator()->validate($type, $value, '/fixture'); + } + + public function testAppliesNormativeGradientClamping(): void + { + $token = new GradientToken([ + ['color' => ['colorSpace' => 'srgb', 'components' => [0, 0, 0]], 'position' => -99], + ['color' => ['colorSpace' => 'srgb', 'components' => [1, 1, 1]], 'position' => 42], + ]); + + self::assertSame(0, $token->getValue()[0]['position']); + self::assertSame(1, $token->getValue()[1]['position']); + } + + /** @return iterable */ + public static function validValueProvider(): iterable + { + yield from self::fixture('format-color-valid-values.json'); + } + + /** @return iterable */ + public static function invalidValueProvider(): iterable + { + foreach (self::fixture('format-color-invalid-values.json') as $case => [$type, $value]) { + yield $case => [$type, $value, $case]; + } + } + + /** @return array */ + private static function fixture(string $filename): array + { + $contents = file_get_contents(\dirname(__DIR__, 2).'/Fixtures/dtcg/'.$filename); + self::assertNotFalse($contents); + + /** @var array $cases */ + $cases = json_decode($contents, true, 512, \JSON_THROW_ON_ERROR); + + return $cases; + } +} diff --git a/src/DesignTokens/tests/Unit/DependencyInjection/ConfigurationTest.php b/src/DesignTokens/tests/Unit/DependencyInjection/ConfigurationTest.php new file mode 100644 index 00000000000..552fe1b3584 --- /dev/null +++ b/src/DesignTokens/tests/Unit/DependencyInjection/ConfigurationTest.php @@ -0,0 +1,94 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\UX\DesignTokens\Tests\Unit\DependencyInjection; + +use PHPUnit\Framework\Attributes\CoversClass; +use PHPUnit\Framework\Attributes\DataProvider; +use PHPUnit\Framework\TestCase; +use Symfony\Component\Config\Definition\Exception\InvalidConfigurationException; +use Symfony\Component\Config\Definition\Processor; +use Symfony\Component\DependencyInjection\ContainerBuilder; +use Symfony\Component\DependencyInjection\Extension\ExtensionInterface; +use Symfony\UX\DesignTokens\UXDesignTokensBundle; + +#[CoversClass(UXDesignTokensBundle::class)] +final class ConfigurationTest extends TestCase +{ + private function getExtension(): ExtensionInterface + { + $bundle = new UXDesignTokensBundle(); + $extension = $bundle->getContainerExtension(); + self::assertNotNull($extension); + + return $extension; + } + + private function processConfig(array $config = []): array + { + $extension = $this->getExtension(); + $configuration = $extension->getConfiguration([], new ContainerBuilder()); + self::assertNotNull($configuration); + + return new Processor()->processConfiguration($configuration, [$config]); + } + + public function testDefaultConfigurationHasExpectedKeys(): void + { + $config = $this->processConfig(); + + self::assertSame([], $config['paths']); + self::assertSame(['path' => null, 'inputs' => []], $config['resolver']); + self::assertSame(['modifier' => 'scheme', 'light' => 'light', 'dark' => 'dark'], $config['color_scheme']); + self::assertSame('dt', $config['css_prefix']); + } + + public function testConfigurationAcceptsCustomValues(): void + { + $config = $this->processConfig([ + 'paths' => ['/tokens/base.json'], + 'resolver' => ['path' => '/theme.resolver.json', 'inputs' => ['theme' => 'dark']], + 'color_scheme' => ['modifier' => 'mode', 'light' => 'day', 'dark' => 'night'], + ]); + + self::assertSame(['/tokens/base.json'], $config['paths']); + self::assertSame(['path' => '/theme.resolver.json', 'inputs' => ['theme' => 'dark']], $config['resolver']); + self::assertSame(['modifier' => 'mode', 'light' => 'day', 'dark' => 'night'], $config['color_scheme']); + } + + public function testResolverInputNamesKeepTheirHyphens(): void + { + $config = $this->processConfig(['resolver' => ['path' => '/theme.resolver.json', 'inputs' => ['color-scheme' => 'dark']]]); + + self::assertSame(['color-scheme' => 'dark'], $config['resolver']['inputs']); + } + + /** @param array $config */ + #[DataProvider('invalidConfigurations')] + public function testConfigurationRejectsAnInvalidValue(array $config, ?string $message): void + { + $this->expectException(InvalidConfigurationException::class); + if (null !== $message) { + $this->expectExceptionMessage($message); + } + + $this->processConfig($config); + } + + /** @return iterable, ?string}> */ + public static function invalidConfigurations(): iterable + { + yield 'nested resolver input value' => [['resolver' => ['path' => '/theme.resolver.json', 'inputs' => ['theme' => ['dark']]]], 'Resolver input values must be strings or numbers.']; + yield 'null resolver input value' => [['resolver' => ['path' => '/theme.resolver.json', 'inputs' => ['theme' => null]]], 'Resolver input values must be strings or numbers.']; + yield 'resolver inputs without a resolver document' => [['resolver' => ['inputs' => ['theme' => 'dark']]], '"resolver.inputs" requires "resolver.path"']; + yield 'empty path' => [['paths' => ['']], null]; + } +} diff --git a/src/DesignTokens/tests/Unit/DependencyInjection/DesignTokensExtensionTest.php b/src/DesignTokens/tests/Unit/DependencyInjection/DesignTokensExtensionTest.php new file mode 100644 index 00000000000..e9671107d2c --- /dev/null +++ b/src/DesignTokens/tests/Unit/DependencyInjection/DesignTokensExtensionTest.php @@ -0,0 +1,360 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\UX\DesignTokens\Tests\Unit\DependencyInjection; + +use PHPUnit\Framework\Attributes\CoversClass; +use PHPUnit\Framework\Attributes\DataProvider; +use PHPUnit\Framework\TestCase; +use Symfony\Bundle\FrameworkBundle\FrameworkBundle; +use Symfony\Bundle\TwigBundle\TwigBundle; +use Symfony\Component\AssetMapper\Event\PreAssetsCompileEvent; +use Symfony\Component\Config\FileLocator; +use Symfony\Component\Config\Resource\DirectoryResource; +use Symfony\Component\Config\Resource\FileExistenceResource; +use Symfony\Component\Config\Resource\FileResource; +use Symfony\Component\DependencyInjection\ChildDefinition; +use Symfony\Component\DependencyInjection\ContainerBuilder; +use Symfony\Component\DependencyInjection\ContainerInterface; +use Symfony\Component\DependencyInjection\Loader\Configurator\ContainerConfigurator; +use Symfony\Component\DependencyInjection\Loader\PhpFileLoader; +use Symfony\Component\DependencyInjection\ParameterBag\EnvPlaceholderParameterBag; +use Symfony\Component\Filesystem\Path; +use Symfony\UX\DesignTokens\Bridge\Tailwind\ThemeImporter; +use Symfony\UX\DesignTokens\CacheWarmer\StylesheetCache; +use Symfony\UX\DesignTokens\Generator\GeneratorInterface; +use Symfony\UX\DesignTokens\Importer\ImporterInterface; +use Symfony\UX\DesignTokens\Resolver\DocumentLoaderInterface; +use Symfony\UX\DesignTokens\Resolver\TokenResolverInterface; +use Symfony\UX\DesignTokens\TokenRegistry; +use Symfony\UX\DesignTokens\TokenRegistryInterface; +use Symfony\UX\DesignTokens\UXDesignTokensBundle; +use Symfony\UX\DesignTokens\Validation\DtcgValidator; + +#[CoversClass(UXDesignTokensBundle::class)] +final class DesignTokensExtensionTest extends TestCase +{ + public function testPathsResolveContainerParameters(): void + { + $container = $this->createContainer(); + $container->setParameter('app.tokens_dir', '/srv/tokens'); + $this->load(['paths' => ['%app.tokens_dir%/a.tokens.json', 'relative/b.tokens.json']], $container); + + self::assertSame(['/srv/tokens/a.tokens.json', Path::join(sys_get_temp_dir(), 'relative/b.tokens.json')], $container->getParameter('.ux_design_tokens.paths')); + } + + /** @param array $expected */ + #[DataProvider('parameters')] + public function testExposesTheConfigurationAsParameters(array $config, array $expected): void + { + $container = $this->load($config); + + foreach ($expected as $name => $value) { + self::assertSame($value, $container->getParameter($name)); + } + } + + /** @return iterable, array}> */ + public static function parameters(): iterable + { + yield 'default paths' => [[], ['.ux_design_tokens.paths' => []]]; + yield 'default resolver' => [[], ['.ux_design_tokens.resolver_path' => null, '.ux_design_tokens.resolver_inputs' => []]]; + yield 'custom paths' => [['paths' => ['/tokens/base.json']], ['.ux_design_tokens.paths' => ['/tokens/base.json']]]; + yield 'custom resolver' => [ + ['resolver' => ['path' => 'theme.resolver.json', 'inputs' => ['theme' => 'dark']]], + ['.ux_design_tokens.resolver_path' => sys_get_temp_dir().'/theme.resolver.json', '.ux_design_tokens.resolver_inputs' => ['theme' => 'dark']], + ]; + yield 'default color scheme' => [[], ['.ux_design_tokens.color_scheme.modifier' => 'scheme', '.ux_design_tokens.color_scheme.light' => 'light', '.ux_design_tokens.color_scheme.dark' => 'dark']]; + yield 'custom color scheme' => [ + ['color_scheme' => ['modifier' => 'mode', 'light' => 'day', 'dark' => 'night']], + ['.ux_design_tokens.color_scheme.modifier' => 'mode', '.ux_design_tokens.color_scheme.light' => 'day', '.ux_design_tokens.color_scheme.dark' => 'night'], + ]; + yield 'paths built from an environment variable' => [ + ['paths' => ['%env(TOKENS_FILE)%'], 'resolver' => ['path' => '%env(resolve:RESOLVER_FILE)%']], + ['.ux_design_tokens.paths' => ['%env(TOKENS_FILE)%'], '.ux_design_tokens.resolver_path' => '%env(resolve:RESOLVER_FILE)%'], + ]; + } + + public function testDeclaresItsOwnCachePoolWithFrameworkBundle(): void + { + $container = $this->createContainer(); + $container->setParameter('kernel.bundles', ['FrameworkBundle' => FrameworkBundle::class]); + $this->load([], $container); + + $pool = $container->getDefinition('.ux_design_tokens.cache'); + self::assertInstanceOf(ChildDefinition::class, $pool); + self::assertSame('cache.system', $pool->getParent()); + self::assertTrue($pool->hasTag('cache.pool')); + } + + public function testResolvesWithoutACachePoolWithoutFrameworkBundle(): void + { + $container = $this->load(); + + self::assertFalse($container->has('.ux_design_tokens.cache')); + self::assertSame(ContainerInterface::NULL_ON_INVALID_REFERENCE, $container->getDefinition('.ux_design_tokens.resolver.configured')->getArgument(3)->getInvalidBehavior()); + } + + public function testTheTwigHelpersNeedTwigBundle(): void + { + $container = $this->createContainer(); + $container->setParameter('kernel.bundles', []); + $this->load([], $container); + + self::assertFalse($container->has('.ux_design_tokens.twig_runtime')); + } + + public function testExposesTheConsumableServicesUnderTheirClassName(): void + { + $container = $this->load(); + + foreach ([TokenRegistryInterface::class, TokenResolverInterface::class, DocumentLoaderInterface::class] as $id) { + self::assertTrue($container->hasAlias($id), \sprintf('"%s" should be aliased.', $id)); + } + foreach ([TokenRegistry::class, DtcgValidator::class, ThemeImporter::class] as $id) { + self::assertFalse($container->hasAlias($id), \sprintf('"%s" is internal and should not be aliased.', $id)); + } + } + + /** @param list> $attributes */ + #[DataProvider('builtInTaggedServiceProvider')] + public function testRegistersABuiltInTaggedService(string $id, string $tag, array $attributes): void + { + $container = $this->load(); + + self::assertTrue($container->has($id)); + self::assertSame($attributes, $container->getDefinition($id)->getTag($tag)); + } + + /** @return iterable>}> */ + public static function builtInTaggedServiceProvider(): iterable + { + yield 'Twig extension' => ['.ux_design_tokens.twig_extension', 'twig.extension', [[]]]; + yield 'Twig runtime' => ['.ux_design_tokens.twig_runtime', 'twig.runtime', [[]]]; + yield 'AssetMapper compile listener' => ['.ux_design_tokens.asset_compile_listener', 'kernel.event_listener', [['event' => PreAssetsCompileEvent::class, 'method' => '__invoke']]]; + yield 'Tailwind importer' => ['.ux_design_tokens.importer.tailwind', 'ux_design_tokens.importer', [['format' => 'tailwind']]]; + } + + /** @param class-string $class */ + #[DataProvider('autoconfiguredImplementationProvider')] + public function testTagsAnyImplementationOfAnExtensionPoint(string $class, string $tag): void + { + $container = $this->load(); + + $container->register('app.extension_point', $class) + ->setAutoconfigured(true) + ->setPublic(true); + new UXDesignTokensBundle()->build($container); + $container->compile(); + + self::assertTrue($container->getDefinition('app.extension_point')->hasTag($tag)); + } + + /** @return iterable */ + public static function autoconfiguredImplementationProvider(): iterable + { + yield 'generator for the export command' => [ScssGeneratorStub::class, 'ux_design_tokens.generator']; + yield 'importer for the import command' => [CssImporterStub::class, 'ux_design_tokens.importer']; + } + + public function testKeepsInternalServicesOutOfReach(): void + { + $container = $this->load(); + + foreach ($container->getDefinitions() as $id => $definition) { + if (str_contains($id, 'DesignTokens')) { + self::assertStringStartsWith('.ux_design_tokens.', $id); + } + } + } + + public function testRegistersOnlyConfiguredFilesAsContainerResources(): void + { + $tokenPath = \dirname(__DIR__, 2).'/Fixtures/dtcg/format-valid.tokens.json'; + $resolverPath = \dirname(__DIR__, 2).'/Fixtures/dtcg/resolver-valid.resolver.json'; + + $container = $this->load(['paths' => [$tokenPath], 'resolver' => ['path' => $resolverPath]]); + + $fileResources = array_map( + static fn (FileResource $resource): string => $resource->getResource(), + self::resources($container, FileResource::class), + ); + self::assertContains($tokenPath, $fileResources); + self::assertContains($resolverPath, $fileResources); + + self::assertSame([], self::resources($container, DirectoryResource::class)); + } + + public function testFingerprintsTheConfigurationTheStylesheetsDependOn(): void + { + $fingerprint = fn (array $config): string => $this->load($config)->getDefinition('.ux_design_tokens.stylesheet_cache')->getArgument(6); + + $default = $fingerprint(['paths' => ['/tokens/a.tokens.json']]); + + self::assertSame($default, $fingerprint(['paths' => ['/tokens/a.tokens.json']])); + self::assertNotSame($default, $fingerprint(['paths' => ['/tokens/a.tokens.json'], 'css_prefix' => 'app'])); + self::assertNotSame($default, $fingerprint(['paths' => ['/tokens/a.tokens.json'], 'color_scheme' => ['modifier' => 'mode']])); + self::assertNotSame($default, $fingerprint(['paths' => ['/tokens/a.tokens.json', '/tokens/b.tokens.json']])); + } + + public function testMissingConfiguredFileIsTrackedForCacheInvalidation(): void + { + $path = sys_get_temp_dir().'/missing-design-tokens-'.bin2hex(random_bytes(6)).'.tokens.json'; + $container = $this->load(['paths' => [$path]]); + + $resources = array_values(array_filter( + self::resources($container, FileExistenceResource::class), + static fn (FileExistenceResource $resource): bool => $resource->getResource() === $path, + )); + self::assertCount(1, $resources); + self::assertTrue($resources[0]->isFresh(time())); + } + + public function testPathsBuiltFromAnEnvironmentVariableAreNotTrackedAsContainerResources(): void + { + $container = $this->load(['paths' => ['%env(TOKENS_FILE)%']]); + + foreach ($container->getResources() as $resource) { + self::assertStringNotContainsString('env(', (string) $resource); + } + } + + public function testAPathCarryingAResolvedEnvPlaceholderIsPassedThroughUntouched(): void + { + $bag = new EnvPlaceholderParameterBag(); + $placeholder = $bag->get('env(TOKENS_FILE)'); + self::assertIsString($placeholder); + + $container = new ContainerBuilder($bag); + $container->setParameter('kernel.project_dir', sys_get_temp_dir()); + $container->setParameter('kernel.environment', 'test'); + $container->setParameter('kernel.build_dir', sys_get_temp_dir()); + $container->setParameter('kernel.debug', true); + + $this->load(['paths' => [$placeholder]], $container); + + self::assertSame([$placeholder], $container->getParameter('.ux_design_tokens.paths')); + } + + public function testNothingOutsideConfigTwigTouchesTwig(): void + { + $root = \dirname(__DIR__, 3); + $offenders = []; + foreach (['config/services.php', 'config/asset_mapper.php'] as $file) { + if (str_contains((string) file_get_contents($root.'/'.$file), 'Twig')) { + $offenders[] = $file; + } + } + + foreach (new \RecursiveIteratorIterator(new \RecursiveDirectoryIterator($root.'/src')) as $php) { + if (!$php->isFile() || 'php' !== $php->getExtension()) { + continue; + } + $path = str_replace($root.'/', '', $php->getPathname()); + if (str_starts_with($path, 'src/Twig/')) { + continue; + } + if (preg_match('/^use Twig\\\\/m', (string) file_get_contents($php->getPathname()))) { + $offenders[] = $path; + } + } + + self::assertSame([], $offenders); + } + + public function testTheRenderedStylesheetsAreMappedForAssetMapper(): void + { + $container = $this->createContainer(); + $bundle = new UXDesignTokensBundle(); + + $instanceof = []; + $bundle->prependExtension(new ContainerConfigurator($container, new PhpFileLoader($container, new FileLocator()), $instanceof, __FILE__, __FILE__), $container); + + $directory = sys_get_temp_dir().'/'.StylesheetCache::DIRECTORY; + self::assertSame( + [$directory => UXDesignTokensBundle::ASSET_NAMESPACE], + $container->getExtensionConfig('framework')[0]['asset_mapper']['paths'], + ); + self::assertDirectoryExists($directory); + } + + public function testAllowedRootsCoverTheProjectAndEveryConfiguredSourceDirectory(): void + { + $container = $this->load(['paths' => [\dirname(__DIR__, 2).'/Fixtures/base.tokens.json']]); + + $roots = $container->getParameter('.ux_design_tokens.allowed_roots'); + self::assertIsArray($roots); + self::assertContains(realpath(\dirname(__DIR__, 2).'/Fixtures'), $roots); + self::assertContains(realpath(sys_get_temp_dir()), $roots); + } + + public function testTheResolverIsNotSharedBetweenItsConsumers(): void + { + $container = $this->load(); + + self::assertFalse($container->getDefinition('.ux_design_tokens.token_tree_builder')->isShared()); + } + + /** @param array $config */ + private function load(array $config = [], ?ContainerBuilder $container = null): ContainerBuilder + { + $container ??= $this->createContainer(); + $extension = new UXDesignTokensBundle()->getContainerExtension(); + self::assertNotNull($extension); + $extension->load([] === $config ? [] : [$config], $container); + + return $container; + } + + private function createContainer(): ContainerBuilder + { + $container = new ContainerBuilder(); + $container->setParameter('kernel.project_dir', sys_get_temp_dir()); + $container->setParameter('kernel.environment', 'test'); + $container->setParameter('kernel.build_dir', sys_get_temp_dir()); + $container->setParameter('kernel.debug', true); + $container->setParameter('kernel.bundles', ['TwigBundle' => TwigBundle::class]); + $container->setParameter('kernel.bundles_metadata', [ + 'FrameworkBundle' => ['path' => \dirname(__DIR__, 3).'/vendor/symfony/framework-bundle'], + 'AssetMapperBundle' => ['path' => \dirname(__DIR__, 3).'/vendor/symfony/asset-mapper'], + ]); + + return $container; + } + + /** + * @template T of object + * + * @param class-string $class + * + * @return list + */ + private static function resources(ContainerBuilder $container, string $class): array + { + return array_values(array_filter($container->getResources(), static fn (object $resource): bool => $resource instanceof $class)); + } +} + +final class ScssGeneratorStub implements GeneratorInterface +{ + public function generate(array $resolvedTokens, array $context = []): string + { + return ''; + } +} + +final class CssImporterStub implements ImporterInterface +{ + public function import(string $contents, array $context = []): array + { + return []; + } +} diff --git a/src/DesignTokens/tests/Unit/EventListener/WarmDesignTokensOnAssetCompileListenerTest.php b/src/DesignTokens/tests/Unit/EventListener/WarmDesignTokensOnAssetCompileListenerTest.php new file mode 100644 index 00000000000..9329bc220b3 --- /dev/null +++ b/src/DesignTokens/tests/Unit/EventListener/WarmDesignTokensOnAssetCompileListenerTest.php @@ -0,0 +1,56 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\UX\DesignTokens\Tests\Unit\EventListener; + +use PHPUnit\Framework\Attributes\CoversClass; +use PHPUnit\Framework\TestCase; +use Symfony\Component\AssetMapper\Event\PreAssetsCompileEvent; +use Symfony\Component\Console\Output\BufferedOutput; +use Symfony\UX\DesignTokens\CacheWarmer\DesignTokensCacheWarmer; +use Symfony\UX\DesignTokens\CacheWarmer\StylesheetCache; +use Symfony\UX\DesignTokens\EventListener\WarmDesignTokensOnAssetCompileListener; +use Symfony\UX\DesignTokens\Generator\CssGenerator; +use Symfony\UX\DesignTokens\Resolver\ConfiguredTokenResolver; +use Symfony\UX\DesignTokens\Tests\Fixtures\TemporaryDirectory; + +#[CoversClass(WarmDesignTokensOnAssetCompileListener::class)] +final class WarmDesignTokensOnAssetCompileListenerTest extends TestCase +{ + private TemporaryDirectory $directory; + private string $buildDir; + + protected function setUp(): void + { + $this->directory = new TemporaryDirectory(); + $this->buildDir = $this->directory->path(); + } + + protected function tearDown(): void + { + $this->directory->remove(); + } + + public function testRendersTheStylesheetBeforeTheAssetsAreCompiled(): void + { + $warmer = new DesignTokensCacheWarmer(new StylesheetCache( + new ConfiguredTokenResolver(resolverPath: \dirname(__DIR__, 2).'/Fixtures/color-scheme/theme.resolver.json'), + new CssGenerator('dt'), + $this->buildDir, + )); + $output = new BufferedOutput(); + + new WarmDesignTokensOnAssetCompileListener($warmer, $this->buildDir)(new PreAssetsCompileEvent($output)); + + self::assertFileExists(StylesheetCache::directory($this->buildDir).'/tokens.css'); + self::assertStringContainsString('Rendering the design token stylesheet...', $output->fetch()); + } +} diff --git a/src/DesignTokens/tests/Unit/Exception/ExceptionTest.php b/src/DesignTokens/tests/Unit/Exception/ExceptionTest.php new file mode 100644 index 00000000000..f80a820c992 --- /dev/null +++ b/src/DesignTokens/tests/Unit/Exception/ExceptionTest.php @@ -0,0 +1,99 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\UX\DesignTokens\Tests\Unit\Exception; + +use PHPUnit\Framework\Attributes\CoversClass; +use PHPUnit\Framework\Attributes\DataProvider; +use PHPUnit\Framework\TestCase; +use Symfony\UX\DesignTokens\Exception\ExceptionInterface; +use Symfony\UX\DesignTokens\Exception\InvalidArgumentException; +use Symfony\UX\DesignTokens\Exception\LogicException; +use Symfony\UX\DesignTokens\Exception\ResolverException; +use Symfony\UX\DesignTokens\Exception\RuntimeException; +use Symfony\UX\DesignTokens\Exception\TokenNotFoundException; +use Symfony\UX\DesignTokens\Exception\UnexpectedValueException; +use Symfony\UX\DesignTokens\Exception\UnresolvedReferenceException; + +#[CoversClass(InvalidArgumentException::class)] +#[CoversClass(LogicException::class)] +#[CoversClass(ResolverException::class)] +#[CoversClass(RuntimeException::class)] +#[CoversClass(TokenNotFoundException::class)] +#[CoversClass(UnexpectedValueException::class)] +#[CoversClass(UnresolvedReferenceException::class)] +final class ExceptionTest extends TestCase +{ + /** @return iterable, class-string<\Throwable>}> */ + public static function exceptions(): iterable + { + yield 'invalid argument' => [InvalidArgumentException::class, \InvalidArgumentException::class]; + yield 'logic' => [LogicException::class, \LogicException::class]; + yield 'runtime' => [RuntimeException::class, \RuntimeException::class]; + yield 'unexpected value' => [UnexpectedValueException::class, \UnexpectedValueException::class]; + yield 'resolver' => [ResolverException::class, \InvalidArgumentException::class]; + yield 'token not found' => [TokenNotFoundException::class, \InvalidArgumentException::class]; + yield 'unresolved reference' => [UnresolvedReferenceException::class, \RuntimeException::class]; + } + + /** + * @param class-string<\Throwable> $class + * @param class-string<\Throwable> $spl + */ + #[DataProvider('exceptions')] + public function testEveryExceptionIsCatchableThroughTheMarkerInterface(string $class, string $spl): void + { + self::assertTrue(is_a($class, ExceptionInterface::class, true), \sprintf('"%s" should implement the package marker interface.', $class)); + self::assertTrue(is_a($class, $spl, true), \sprintf('"%s" should stay a "%s".', $class, $spl)); + } + + public function testEveryExceptionInTheNamespaceIsCovered(): void + { + $declared = array_map( + static fn (string $file): string => basename($file, '.php'), + glob(\dirname(__DIR__, 3).'/src/Exception/*.php') ?: [], + ); + $tested = array_map( + static fn (array $case): string => new \ReflectionClass($case[0])->getShortName(), + iterator_to_array(self::exceptions(), false), + ); + + sort($declared); + $tested[] = 'ExceptionInterface'; + sort($tested); + + self::assertSame($declared, $tested, 'A new exception class must be added to the data provider.'); + } + + public function testTokenNotFoundExceptionCarriesThePathThatDidNotResolve(): void + { + $exception = new TokenNotFoundException('color.action.primary'); + + self::assertSame('color.action.primary', $exception->getPath()); + self::assertSame('Design token not found: "color.action.primary".', $exception->getMessage()); + } + + public function testTokenNotFoundExceptionAcceptsAMoreSpecificMessage(): void + { + $exception = new TokenNotFoundException('color', '"color" is a token group, not a token.'); + + self::assertSame('color', $exception->getPath()); + self::assertSame('"color" is a token group, not a token.', $exception->getMessage()); + } + + public function testResolverExceptionKeepsEveryErrorItWasGiven(): void + { + $exception = new ResolverException(['first problem', 'second problem']); + + self::assertSame(['first problem', 'second problem'], $exception->getErrors()); + self::assertSame("first problem\nsecond problem", $exception->getMessage()); + } +} diff --git a/src/DesignTokens/tests/Unit/Generator/ColorSchemeTest.php b/src/DesignTokens/tests/Unit/Generator/ColorSchemeTest.php new file mode 100644 index 00000000000..eefb96fbac2 --- /dev/null +++ b/src/DesignTokens/tests/Unit/Generator/ColorSchemeTest.php @@ -0,0 +1,54 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\UX\DesignTokens\Tests\Unit\Generator; + +use PHPUnit\Framework\Attributes\CoversClass; +use PHPUnit\Framework\Attributes\DataProvider; +use PHPUnit\Framework\TestCase; +use Symfony\UX\DesignTokens\Generator\ColorScheme; + +#[CoversClass(ColorScheme::class)] +final class ColorSchemeTest extends TestCase +{ + /** + * @param array, default: string|null}> $modifiers + * @param array $inputs + * @param array{array, array}|null $expected + */ + #[DataProvider('pairs')] + public function testPairsTheLightAndDarkContexts(array $modifiers, array $inputs, ?array $expected): void + { + self::assertSame($expected, new ColorScheme()->contexts($modifiers, $inputs)); + } + + /** @return iterable, default: string|null}>, array, array{array, array}|null}> */ + public static function pairs(): iterable + { + $scheme = ['scheme' => ['contexts' => ['light', 'dark'], 'default' => 'light']]; + + yield 'both contexts declared' => [$scheme, [], [['scheme' => 'light'], ['scheme' => 'dark']]]; + yield 'other inputs kept' => [$scheme + ['brand' => ['contexts' => ['a', 'b'], 'default' => 'a']], ['brand' => 'b'], [['brand' => 'b', 'scheme' => 'light'], ['brand' => 'b', 'scheme' => 'dark']]]; + yield 'names compared without case' => [['Scheme' => ['contexts' => ['Light', 'DARK'], 'default' => null]], [], [['scheme' => 'light'], ['scheme' => 'dark']]]; + yield 'no Resolver document' => [[], [], null]; + yield 'no such modifier' => [['brand' => ['contexts' => ['a', 'b'], 'default' => 'a']], [], null]; + yield 'a context missing' => [['scheme' => ['contexts' => ['light', 'dim'], 'default' => 'light']], [], null]; + yield 'the scheme already selected' => [$scheme, ['Scheme' => 'dark'], null]; + } + + public function testUsesTheConfiguredNames(): void + { + self::assertSame( + [['mode' => 'day'], ['mode' => 'night']], + new ColorScheme('mode', 'day', 'night')->contexts(['mode' => ['contexts' => ['day', 'night'], 'default' => 'day']], []), + ); + } +} diff --git a/src/DesignTokens/tests/Unit/Generator/CssGeneratorTest.php b/src/DesignTokens/tests/Unit/Generator/CssGeneratorTest.php new file mode 100644 index 00000000000..721fad58f7b --- /dev/null +++ b/src/DesignTokens/tests/Unit/Generator/CssGeneratorTest.php @@ -0,0 +1,188 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\UX\DesignTokens\Tests\Unit\Generator; + +use PHPUnit\Framework\Attributes\CoversClass; +use PHPUnit\Framework\Attributes\DataProvider; +use PHPUnit\Framework\TestCase; +use Symfony\UX\DesignTokens\Generator\CssGenerator; +use Symfony\UX\DesignTokens\Generator\GeneratorInterface; +use Symfony\UX\DesignTokens\Resolver\TokenTreeBuilder; +use Symfony\UX\DesignTokens\Tests\Fixtures\TokenValues; + +#[CoversClass(CssGenerator::class)] +final class CssGeneratorTest extends TestCase +{ + /** + * @param array $document + * @param list $contains + * @param list $leavesOut + */ + #[DataProvider('generatedCssProvider')] + public function testGeneratesTheStylesheetOfADocument(array $document, array $contains, array $leavesOut = []): void + { + $css = new CssGenerator()->generate(new TokenTreeBuilder()->resolve($document)); + + foreach ($contains as $fragment) { + self::assertStringContainsString($fragment, $css); + } + foreach ($leavesOut as $fragment) { + self::assertStringNotContainsString($fragment, $css); + } + } + + /** @return iterable, 1: list, 2?: list}> */ + public static function generatedCssProvider(): iterable + { + $brand = ['$type' => 'color', '$value' => TokenValues::color()]; + + yield 'an empty tree still has a :root block' => [[], [':root {']]; + yield 'one token in the :root block' => [ + ['ratio' => ['$type' => 'number', '$value' => 1.5]], + [':root {', '--ratio: 1.5;'], + ]; + yield 'several tokens' => [ + ['a' => ['$type' => 'number', '$value' => 1], 'b' => ['$type' => 'number', '$value' => 2]], + ['--a: 1;', '--b: 2;'], + ]; + yield '@property for typed tokens' => [ + [ + 'color' => $brand, + 'size' => ['$type' => 'dimension', '$value' => TokenValues::dimension(16)], + 'duration' => ['$type' => 'duration', '$value' => TokenValues::dimension(300, 'ms')], + 'ratio' => ['$type' => 'number', '$value' => 1.5], + ], + ["syntax: ''", "syntax: ''", "syntax: '