From 2bc664274964407e87a3e616d8004843b10f3499 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Simon=20Andr=C3=A9?= Date: Sat, 26 Sep 2026 21:22:31 +0200 Subject: [PATCH 1/4] [DesignTokens] Add the DTCG token model and Resolver Parse and validate DTCG 2025.10 token documents (Format and Color modules), resolve aliases, $ref JSON Pointers, $extends and type inheritance, and assemble themes from Resolver documents: sets, modifiers, contexts and resolutionOrder. TokenRegistry exposes the resolved tokens by path, for any set of Resolver inputs. --- splitsh.json | 1 + src/DesignTokens/.gitattributes | 6 + src/DesignTokens/.gitignore | 8 + src/DesignTokens/LICENSE | 19 + src/DesignTokens/composer.json | 68 ++ src/DesignTokens/phpstan.dist.neon | 6 + src/DesignTokens/phpunit.dist.xml | 40 + .../src/Exception/ExceptionInterface.php | 19 + .../Exception/InvalidArgumentException.php | 19 + .../src/Exception/LogicException.php | 19 + .../src/Exception/ResolverException.php | 32 + .../src/Exception/RuntimeException.php | 19 + .../src/Exception/TokenNotFoundException.php | 28 + .../Exception/UnexpectedValueException.php | 19 + .../UnresolvedReferenceException.php | 21 + .../src/Resolver/ArrayDocumentLoader.php | 30 + .../src/Resolver/ConfiguredTokenResolver.php | 179 ++++ .../src/Resolver/DocumentLoaderInterface.php | 31 + .../src/Resolver/JsonDocumentLoader.php | 94 ++ src/DesignTokens/src/Resolver/JsonPointer.php | 56 ++ .../src/Resolver/ResolverDocument.php | 682 +++++++++++++ .../src/Resolver/ResolverInputs.php | 67 ++ .../src/Resolver/ResolverSource.php | 28 + .../src/Resolver/TokenResolution.php | 65 ++ .../src/Resolver/TokenResolverInterface.php | 41 + .../src/Resolver/TokenTreeBuilder.php | 898 ++++++++++++++++++ src/DesignTokens/src/Token/AbstractToken.php | 85 ++ src/DesignTokens/src/Token/BorderToken.php | 35 + src/DesignTokens/src/Token/ColorToken.php | 32 + src/DesignTokens/src/Token/Css/CssValue.php | 139 +++ .../src/Token/CubicBezierToken.php | 56 ++ src/DesignTokens/src/Token/DimensionToken.php | 34 + src/DesignTokens/src/Token/DurationToken.php | 32 + .../src/Token/FontFamilyToken.php | 47 + .../src/Token/FontWeightToken.php | 49 + src/DesignTokens/src/Token/GradientToken.php | 74 ++ src/DesignTokens/src/Token/NumberToken.php | 32 + src/DesignTokens/src/Token/ShadowToken.php | 44 + .../src/Token/StrokeStyleToken.php | 52 + src/DesignTokens/src/Token/TokenFactory.php | 93 ++ src/DesignTokens/src/Token/TokenInterface.php | 39 + .../src/Token/TransitionToken.php | 35 + .../src/Token/TypographyToken.php | 36 + src/DesignTokens/src/TokenPath.php | 48 + src/DesignTokens/src/TokenRegistry.php | 109 +++ .../src/TokenRegistryInterface.php | 89 ++ src/DesignTokens/src/TokenTree.php | 143 +++ .../src/Validation/ColorRangeInspector.php | 79 ++ .../src/Validation/DtcgValidator.php | 136 +++ .../src/Validation/Normalizer.php | 65 ++ .../src/Validation/TokenValueValidator.php | 306 ++++++ .../tests/Fixtures/RecordingLogger.php | 25 + .../tests/Fixtures/Registries.php | 52 + .../tests/Fixtures/ResolverDocuments.php | 66 ++ .../tests/Fixtures/TemporaryDirectory.php | 54 ++ .../tests/Fixtures/TokenValues.php | 73 ++ .../tests/Fixtures/base.tokens.json | 34 + .../Fixtures/color-scheme/dark.tokens.json | 27 + .../color-scheme/foundation.tokens.json | 34 + .../Fixtures/color-scheme/light.tokens.json | 27 + .../color-scheme/named-theme.resolver.json | 61 ++ .../Fixtures/color-scheme/theme.resolver.json | 61 ++ .../dtcg/format-color-invalid-values.json | 40 + .../dtcg/format-color-valid-values.json | 35 + .../format-invalid-legacy-values.tokens.json | 12 + .../Fixtures/dtcg/format-valid.tokens.json | 37 + .../dtcg/resolver-complete.resolver.json | 44 + .../Fixtures/dtcg/resolver-invalid-cases.json | 181 ++++ .../dtcg/resolver-valid.resolver.json | 38 + .../Integration/Fixtures/app.tokens.json | 15 + .../Fixtures/primitive.tokens.json | 9 + .../Fixtures/reference.tokens.json | 6 + .../Integration/Fixtures/theme.resolver.json | 31 + .../FormatColorConformanceTest.php | 96 ++ .../tests/Unit/Exception/ExceptionTest.php | 99 ++ .../ConfiguredTokenResolverCacheTest.php | 228 +++++ .../Resolver/ConfiguredTokenResolverTest.php | 248 +++++ .../Resolver/DtcgTokenTreeBuilderTest.php | 59 ++ .../Unit/Resolver/JsonDocumentLoaderTest.php | 168 ++++ .../tests/Unit/Resolver/JsonPointerTest.php | 67 ++ .../Unit/Resolver/ResolverConformanceTest.php | 240 +++++ .../Unit/Resolver/ResolverDocumentTest.php | 615 ++++++++++++ .../Unit/Resolver/TokenTreeBuilderTest.php | 746 +++++++++++++++ .../tests/Unit/Token/AbstractTokenTest.php | 109 +++ .../tests/Unit/Token/BorderTokenTest.php | 51 + .../tests/Unit/Token/ColorTokenTest.php | 46 + .../tests/Unit/Token/Css/CssValueTest.php | 70 ++ .../tests/Unit/Token/CubicBezierTokenTest.php | 44 + .../tests/Unit/Token/DimensionTokenTest.php | 43 + .../tests/Unit/Token/DurationTokenTest.php | 41 + .../tests/Unit/Token/FontFamilyTokenTest.php | 53 ++ .../tests/Unit/Token/FontWeightTokenTest.php | 84 ++ .../tests/Unit/Token/GradientTokenTest.php | 49 + .../tests/Unit/Token/NumberTokenTest.php | 45 + .../tests/Unit/Token/ShadowTokenTest.php | 54 ++ .../tests/Unit/Token/StrokeStyleTokenTest.php | 59 ++ .../tests/Unit/Token/TokenFactoryTest.php | 92 ++ .../tests/Unit/Token/TransitionTokenTest.php | 50 + .../tests/Unit/Token/TypographyTokenTest.php | 62 ++ src/DesignTokens/tests/Unit/TokenPathTest.php | 43 + .../tests/Unit/TokenRegistryTest.php | 267 ++++++ src/DesignTokens/tests/Unit/TokenTreeTest.php | 95 ++ .../Validation/ColorRangeInspectorTest.php | 127 +++ .../Unit/Validation/DtcgValidatorTest.php | 151 +++ .../tests/Unit/Validation/NormalizerTest.php | 84 ++ .../Validation/TokenValueValidatorTest.php | 139 +++ src/DesignTokens/tests/bootstrap.php | 16 + 107 files changed, 9786 insertions(+) create mode 100644 src/DesignTokens/.gitattributes create mode 100644 src/DesignTokens/.gitignore create mode 100644 src/DesignTokens/LICENSE create mode 100644 src/DesignTokens/composer.json create mode 100644 src/DesignTokens/phpstan.dist.neon create mode 100644 src/DesignTokens/phpunit.dist.xml create mode 100644 src/DesignTokens/src/Exception/ExceptionInterface.php create mode 100644 src/DesignTokens/src/Exception/InvalidArgumentException.php create mode 100644 src/DesignTokens/src/Exception/LogicException.php create mode 100644 src/DesignTokens/src/Exception/ResolverException.php create mode 100644 src/DesignTokens/src/Exception/RuntimeException.php create mode 100644 src/DesignTokens/src/Exception/TokenNotFoundException.php create mode 100644 src/DesignTokens/src/Exception/UnexpectedValueException.php create mode 100644 src/DesignTokens/src/Exception/UnresolvedReferenceException.php create mode 100644 src/DesignTokens/src/Resolver/ArrayDocumentLoader.php create mode 100644 src/DesignTokens/src/Resolver/ConfiguredTokenResolver.php create mode 100644 src/DesignTokens/src/Resolver/DocumentLoaderInterface.php create mode 100644 src/DesignTokens/src/Resolver/JsonDocumentLoader.php create mode 100644 src/DesignTokens/src/Resolver/JsonPointer.php create mode 100644 src/DesignTokens/src/Resolver/ResolverDocument.php create mode 100644 src/DesignTokens/src/Resolver/ResolverInputs.php create mode 100644 src/DesignTokens/src/Resolver/ResolverSource.php create mode 100644 src/DesignTokens/src/Resolver/TokenResolution.php create mode 100644 src/DesignTokens/src/Resolver/TokenResolverInterface.php create mode 100644 src/DesignTokens/src/Resolver/TokenTreeBuilder.php create mode 100644 src/DesignTokens/src/Token/AbstractToken.php create mode 100644 src/DesignTokens/src/Token/BorderToken.php create mode 100644 src/DesignTokens/src/Token/ColorToken.php create mode 100644 src/DesignTokens/src/Token/Css/CssValue.php create mode 100644 src/DesignTokens/src/Token/CubicBezierToken.php create mode 100644 src/DesignTokens/src/Token/DimensionToken.php create mode 100644 src/DesignTokens/src/Token/DurationToken.php create mode 100644 src/DesignTokens/src/Token/FontFamilyToken.php create mode 100644 src/DesignTokens/src/Token/FontWeightToken.php create mode 100644 src/DesignTokens/src/Token/GradientToken.php create mode 100644 src/DesignTokens/src/Token/NumberToken.php create mode 100644 src/DesignTokens/src/Token/ShadowToken.php create mode 100644 src/DesignTokens/src/Token/StrokeStyleToken.php create mode 100644 src/DesignTokens/src/Token/TokenFactory.php create mode 100644 src/DesignTokens/src/Token/TokenInterface.php create mode 100644 src/DesignTokens/src/Token/TransitionToken.php create mode 100644 src/DesignTokens/src/Token/TypographyToken.php create mode 100644 src/DesignTokens/src/TokenPath.php create mode 100644 src/DesignTokens/src/TokenRegistry.php create mode 100644 src/DesignTokens/src/TokenRegistryInterface.php create mode 100644 src/DesignTokens/src/TokenTree.php create mode 100644 src/DesignTokens/src/Validation/ColorRangeInspector.php create mode 100644 src/DesignTokens/src/Validation/DtcgValidator.php create mode 100644 src/DesignTokens/src/Validation/Normalizer.php create mode 100644 src/DesignTokens/src/Validation/TokenValueValidator.php create mode 100644 src/DesignTokens/tests/Fixtures/RecordingLogger.php create mode 100644 src/DesignTokens/tests/Fixtures/Registries.php create mode 100644 src/DesignTokens/tests/Fixtures/ResolverDocuments.php create mode 100644 src/DesignTokens/tests/Fixtures/TemporaryDirectory.php create mode 100644 src/DesignTokens/tests/Fixtures/TokenValues.php create mode 100644 src/DesignTokens/tests/Fixtures/base.tokens.json create mode 100644 src/DesignTokens/tests/Fixtures/color-scheme/dark.tokens.json create mode 100644 src/DesignTokens/tests/Fixtures/color-scheme/foundation.tokens.json create mode 100644 src/DesignTokens/tests/Fixtures/color-scheme/light.tokens.json create mode 100644 src/DesignTokens/tests/Fixtures/color-scheme/named-theme.resolver.json create mode 100644 src/DesignTokens/tests/Fixtures/color-scheme/theme.resolver.json create mode 100644 src/DesignTokens/tests/Fixtures/dtcg/format-color-invalid-values.json create mode 100644 src/DesignTokens/tests/Fixtures/dtcg/format-color-valid-values.json create mode 100644 src/DesignTokens/tests/Fixtures/dtcg/format-invalid-legacy-values.tokens.json create mode 100644 src/DesignTokens/tests/Fixtures/dtcg/format-valid.tokens.json create mode 100644 src/DesignTokens/tests/Fixtures/dtcg/resolver-complete.resolver.json create mode 100644 src/DesignTokens/tests/Fixtures/dtcg/resolver-invalid-cases.json create mode 100644 src/DesignTokens/tests/Fixtures/dtcg/resolver-valid.resolver.json create mode 100644 src/DesignTokens/tests/Integration/Fixtures/app.tokens.json create mode 100644 src/DesignTokens/tests/Integration/Fixtures/primitive.tokens.json create mode 100644 src/DesignTokens/tests/Integration/Fixtures/reference.tokens.json create mode 100644 src/DesignTokens/tests/Integration/Fixtures/theme.resolver.json create mode 100644 src/DesignTokens/tests/Unit/Conformance/FormatColorConformanceTest.php create mode 100644 src/DesignTokens/tests/Unit/Exception/ExceptionTest.php create mode 100644 src/DesignTokens/tests/Unit/Resolver/ConfiguredTokenResolverCacheTest.php create mode 100644 src/DesignTokens/tests/Unit/Resolver/ConfiguredTokenResolverTest.php create mode 100644 src/DesignTokens/tests/Unit/Resolver/DtcgTokenTreeBuilderTest.php create mode 100644 src/DesignTokens/tests/Unit/Resolver/JsonDocumentLoaderTest.php create mode 100644 src/DesignTokens/tests/Unit/Resolver/JsonPointerTest.php create mode 100644 src/DesignTokens/tests/Unit/Resolver/ResolverConformanceTest.php create mode 100644 src/DesignTokens/tests/Unit/Resolver/ResolverDocumentTest.php create mode 100644 src/DesignTokens/tests/Unit/Resolver/TokenTreeBuilderTest.php create mode 100644 src/DesignTokens/tests/Unit/Token/AbstractTokenTest.php create mode 100644 src/DesignTokens/tests/Unit/Token/BorderTokenTest.php create mode 100644 src/DesignTokens/tests/Unit/Token/ColorTokenTest.php create mode 100644 src/DesignTokens/tests/Unit/Token/Css/CssValueTest.php create mode 100644 src/DesignTokens/tests/Unit/Token/CubicBezierTokenTest.php create mode 100644 src/DesignTokens/tests/Unit/Token/DimensionTokenTest.php create mode 100644 src/DesignTokens/tests/Unit/Token/DurationTokenTest.php create mode 100644 src/DesignTokens/tests/Unit/Token/FontFamilyTokenTest.php create mode 100644 src/DesignTokens/tests/Unit/Token/FontWeightTokenTest.php create mode 100644 src/DesignTokens/tests/Unit/Token/GradientTokenTest.php create mode 100644 src/DesignTokens/tests/Unit/Token/NumberTokenTest.php create mode 100644 src/DesignTokens/tests/Unit/Token/ShadowTokenTest.php create mode 100644 src/DesignTokens/tests/Unit/Token/StrokeStyleTokenTest.php create mode 100644 src/DesignTokens/tests/Unit/Token/TokenFactoryTest.php create mode 100644 src/DesignTokens/tests/Unit/Token/TransitionTokenTest.php create mode 100644 src/DesignTokens/tests/Unit/Token/TypographyTokenTest.php create mode 100644 src/DesignTokens/tests/Unit/TokenPathTest.php create mode 100644 src/DesignTokens/tests/Unit/TokenRegistryTest.php create mode 100644 src/DesignTokens/tests/Unit/TokenTreeTest.php create mode 100644 src/DesignTokens/tests/Unit/Validation/ColorRangeInspectorTest.php create mode 100644 src/DesignTokens/tests/Unit/Validation/DtcgValidatorTest.php create mode 100644 src/DesignTokens/tests/Unit/Validation/NormalizerTest.php create mode 100644 src/DesignTokens/tests/Unit/Validation/TokenValueValidatorTest.php create mode 100644 src/DesignTokens/tests/bootstrap.php 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/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/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/phpstan.dist.neon b/src/DesignTokens/phpstan.dist.neon new file mode 100644 index 00000000000..d339a2d29aa --- /dev/null +++ b/src/DesignTokens/phpstan.dist.neon @@ -0,0 +1,6 @@ +parameters: + level: 10 + paths: + - src + treatPhpDocTypesAsCertain: false + inferPrivatePropertyTypeFromConstructor: true diff --git a/src/DesignTokens/phpunit.dist.xml b/src/DesignTokens/phpunit.dist.xml new file mode 100644 index 00000000000..cf1ef34f23b --- /dev/null +++ b/src/DesignTokens/phpunit.dist.xml @@ -0,0 +1,40 @@ + + + + + + + + + + + ./tests + + + + + + src + + + + trigger_deprecation + + + diff --git a/src/DesignTokens/src/Exception/ExceptionInterface.php b/src/DesignTokens/src/Exception/ExceptionInterface.php new file mode 100644 index 00000000000..2678f457b79 --- /dev/null +++ b/src/DesignTokens/src/Exception/ExceptionInterface.php @@ -0,0 +1,19 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\UX\DesignTokens\Exception; + +/** + * @author Simon André + */ +interface ExceptionInterface extends \Throwable +{ +} diff --git a/src/DesignTokens/src/Exception/InvalidArgumentException.php b/src/DesignTokens/src/Exception/InvalidArgumentException.php new file mode 100644 index 00000000000..7379f9b4190 --- /dev/null +++ b/src/DesignTokens/src/Exception/InvalidArgumentException.php @@ -0,0 +1,19 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\UX\DesignTokens\Exception; + +/** + * @author Simon André + */ +class InvalidArgumentException extends \InvalidArgumentException implements ExceptionInterface +{ +} diff --git a/src/DesignTokens/src/Exception/LogicException.php b/src/DesignTokens/src/Exception/LogicException.php new file mode 100644 index 00000000000..487aa75220d --- /dev/null +++ b/src/DesignTokens/src/Exception/LogicException.php @@ -0,0 +1,19 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\UX\DesignTokens\Exception; + +/** + * @author Simon André + */ +class LogicException extends \LogicException implements ExceptionInterface +{ +} diff --git a/src/DesignTokens/src/Exception/ResolverException.php b/src/DesignTokens/src/Exception/ResolverException.php new file mode 100644 index 00000000000..10b067e5dcf --- /dev/null +++ b/src/DesignTokens/src/Exception/ResolverException.php @@ -0,0 +1,32 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\UX\DesignTokens\Exception; + +/** + * @author Simon André + */ +class ResolverException extends InvalidArgumentException +{ + /** + * @param non-empty-list $errors + */ + public function __construct(private readonly array $errors) + { + parent::__construct(implode("\n", $errors)); + } + + /** @return non-empty-list */ + public function getErrors(): array + { + return $this->errors; + } +} diff --git a/src/DesignTokens/src/Exception/RuntimeException.php b/src/DesignTokens/src/Exception/RuntimeException.php new file mode 100644 index 00000000000..ba95ad7b0ca --- /dev/null +++ b/src/DesignTokens/src/Exception/RuntimeException.php @@ -0,0 +1,19 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\UX\DesignTokens\Exception; + +/** + * @author Simon André + */ +class RuntimeException extends \RuntimeException implements ExceptionInterface +{ +} diff --git a/src/DesignTokens/src/Exception/TokenNotFoundException.php b/src/DesignTokens/src/Exception/TokenNotFoundException.php new file mode 100644 index 00000000000..b01953579bb --- /dev/null +++ b/src/DesignTokens/src/Exception/TokenNotFoundException.php @@ -0,0 +1,28 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\UX\DesignTokens\Exception; + +/** + * @author Simon André + */ +class TokenNotFoundException extends InvalidArgumentException +{ + public function __construct(private readonly string $path, ?string $message = null) + { + parent::__construct($message ?? \sprintf('Design token not found: "%s".', $path)); + } + + public function getPath(): string + { + return $this->path; + } +} diff --git a/src/DesignTokens/src/Exception/UnexpectedValueException.php b/src/DesignTokens/src/Exception/UnexpectedValueException.php new file mode 100644 index 00000000000..9e0dcb13d07 --- /dev/null +++ b/src/DesignTokens/src/Exception/UnexpectedValueException.php @@ -0,0 +1,19 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\UX\DesignTokens\Exception; + +/** + * @author Simon André + */ +class UnexpectedValueException extends \UnexpectedValueException implements ExceptionInterface +{ +} diff --git a/src/DesignTokens/src/Exception/UnresolvedReferenceException.php b/src/DesignTokens/src/Exception/UnresolvedReferenceException.php new file mode 100644 index 00000000000..b472e023035 --- /dev/null +++ b/src/DesignTokens/src/Exception/UnresolvedReferenceException.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\UX\DesignTokens\Exception; + +/** + * @author Simon André + * + * @internal + */ +final class UnresolvedReferenceException extends RuntimeException +{ +} diff --git a/src/DesignTokens/src/Resolver/ArrayDocumentLoader.php b/src/DesignTokens/src/Resolver/ArrayDocumentLoader.php new file mode 100644 index 00000000000..7952d7fb7e5 --- /dev/null +++ b/src/DesignTokens/src/Resolver/ArrayDocumentLoader.php @@ -0,0 +1,30 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\UX\DesignTokens\Resolver; + +use Symfony\UX\DesignTokens\Exception\RuntimeException; + +/** + * @author Simon André + */ +final class ArrayDocumentLoader implements DocumentLoaderInterface +{ + /** @param array> $documents decoded documents keyed by URI */ + public function __construct(private readonly array $documents) + { + } + + public function load(string $uri): array + { + return $this->documents[$uri] ?? throw new RuntimeException(\sprintf('Design token document not found: "%s".', $uri)); + } +} diff --git a/src/DesignTokens/src/Resolver/ConfiguredTokenResolver.php b/src/DesignTokens/src/Resolver/ConfiguredTokenResolver.php new file mode 100644 index 00000000000..7f525cab29a --- /dev/null +++ b/src/DesignTokens/src/Resolver/ConfiguredTokenResolver.php @@ -0,0 +1,179 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\UX\DesignTokens\Resolver; + +use Symfony\Contracts\Cache\CacheInterface; +use Symfony\UX\DesignTokens\Exception\LogicException; +use Symfony\UX\DesignTokens\Exception\UnexpectedValueException; +use Symfony\UX\DesignTokens\TokenTree; + +/** + * @author Simon André + */ +final class ConfiguredTokenResolver implements TokenResolverInterface +{ + /** Bump when the cached payload changes. */ + private const int CACHE_FORMAT = 3; + + /** + * @param list $paths token files merged in order, after the Resolver selection + */ + public function __construct( + private readonly DocumentLoaderInterface $loader = new JsonDocumentLoader(), + private readonly array $paths = [], + private readonly ?string $resolverPath = null, + private readonly ?CacheInterface $cache = null, + private readonly bool $debug = false, + ) { + } + + public function resolve(array $inputs): TokenResolution + { + if (null === $this->cache) { + return $this->build($inputs); + } + + ksort($inputs); + // A configuration change must not reuse an older entry. + $key = 'ux_design_tokens.'.self::CACHE_FORMAT.'.'.hash('xxh128', serialize([$this->paths, $this->resolverPath])).'.'.ResolverInputs::key($inputs); + $compute = function () use ($inputs): array { + $resolution = $this->build($inputs); + + return [ + 'format' => self::CACHE_FORMAT, + 'documents' => $resolution->getDocuments(), + 'signature' => $this->signature($resolution->getDocuments()), + 'tokens' => TokenTree::export($resolution->getTokens()), + ]; + }; + + [$documents, $signature, $tokens] = $this->entry($this->cache->get($key, $compute)); + if ($this->debug && $signature !== $this->signature($documents)) { + [$documents, , $tokens] = $this->entry($this->cache->get($key, $compute, \INF)); + } + + return new TokenResolution(TokenTree::hydrate($tokens), documents: $documents); + } + + /** + * Resolve again, recording which source won each path and which ones it replaced. + * + * @param array $inputs + */ + public function trace(array $inputs): TokenResolution + { + $document = $this->document(); + $builder = new TokenTreeBuilder($this->loader); + + return $builder->resolveWithProvenance($this->sourcesFor($document, $inputs))->withDocuments($this->documents($builder, $document)); + } + + public function getPermutations(): array + { + return $this->document()?->getPermutations() ?? []; + } + + public function getModifiers(): array + { + return $this->document()?->getModifiers() ?? []; + } + + /** + * @param array $inputs + * + * @return list + */ + private function sourcesFor(?ResolverDocument $document, array $inputs): array + { + if (null === $document && [] !== $inputs) { + throw new LogicException('Resolver inputs cannot be selected because no Resolver document is configured ("ux_design_tokens.resolver.path").'); + } + + $sources = array_map( + fn (string $path): ResolverSource => new ResolverSource($this->loader->load($path), '.' === \dirname($path) ? '' : \dirname($path), $path), + $this->paths, + ); + + return [...($document?->sourceDescriptors($inputs) ?? []), ...$sources]; + } + + private function document(): ?ResolverDocument + { + if (null === $this->resolverPath) { + return null; + } + + $directory = \dirname($this->resolverPath); + + return new ResolverDocument($this->loader->load($this->resolverPath), '.' === $directory ? '' : $directory, $this->loader); + } + + /** @return list */ + private function documents(TokenTreeBuilder $builder, ?ResolverDocument $document): array + { + $uris = [...$this->paths, $this->resolverPath, ...$builder->loadedUris(), ...($document?->loadedUris() ?? [])]; + + return array_values(array_unique(array_filter($uris, static fn (?string $uri): bool => null !== $uri && '' !== $uri))); + } + + /** @param array $inputs */ + private function build(array $inputs): TokenResolution + { + $document = $this->document(); + $builder = new TokenTreeBuilder($this->loader); + $tokens = $builder->resolveSources($this->sourcesFor($document, $inputs)); + + return new TokenResolution($tokens, documents: $this->documents($builder, $document)); + } + + /** + * @return array{list, string, array} + */ + private function entry(mixed $data): array + { + if (!\is_array($data) + || self::CACHE_FORMAT !== ($data['format'] ?? null) + || !\is_string($signature = $data['signature'] ?? null) + || !\is_array($rawDocuments = $data['documents'] ?? null) + || !\is_array($tokens = $data['tokens'] ?? null) + ) { + throw new UnexpectedValueException('The Design Tokens cache holds an entry this version cannot read. Clear the cache to rebuild it.'); + } + + $documents = []; + foreach ($rawDocuments as $document) { + if (!\is_string($document)) { + throw new UnexpectedValueException('The Design Tokens cache must record documents as strings.'); + } + $documents[] = $document; + } + + return [$documents, $signature, $tokens]; + } + + /** @param list $documents */ + private function signature(array $documents): string + { + if (!$this->debug) { + return ''; + } + + $stats = []; + foreach ($documents as $document) { + if (is_file($document)) { + $stats[] = [$document, @filemtime($document), @filesize($document)]; + } + } + + return hash('xxh128', serialize($stats)); + } +} diff --git a/src/DesignTokens/src/Resolver/DocumentLoaderInterface.php b/src/DesignTokens/src/Resolver/DocumentLoaderInterface.php new file mode 100644 index 00000000000..b275f30b21c --- /dev/null +++ b/src/DesignTokens/src/Resolver/DocumentLoaderInterface.php @@ -0,0 +1,31 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\UX\DesignTokens\Resolver; + +use Symfony\UX\DesignTokens\Exception\RuntimeException; + +/** + * Loads a DTCG document from a URI, with its references left in place. + * + * @author Simon André + */ +interface DocumentLoaderInterface +{ + /** + * @param string $uri absolute file path or URL of a DTCG document + * + * @return array the decoded document, with its references left in place + * + * @throws RuntimeException when the URI cannot be read, or does not decode to a JSON object + */ + public function load(string $uri): array; +} diff --git a/src/DesignTokens/src/Resolver/JsonDocumentLoader.php b/src/DesignTokens/src/Resolver/JsonDocumentLoader.php new file mode 100644 index 00000000000..77edb6d68c5 --- /dev/null +++ b/src/DesignTokens/src/Resolver/JsonDocumentLoader.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\Resolver; + +use Symfony\Component\Filesystem\Exception\IOExceptionInterface; +use Symfony\Component\Filesystem\Filesystem; +use Symfony\Component\Filesystem\Path; +use Symfony\UX\DesignTokens\Exception\RuntimeException; + +/** + * @author Simon André + */ +final class JsonDocumentLoader implements DocumentLoaderInterface +{ + /** @var list */ + private readonly array $allowedRoots; + + /** + * @param string $basePath directory prepended to relative URIs + * @param list $allowedRoots directories a document may be read from, or an empty list to read anywhere + */ + public function __construct( + private readonly string $basePath = '', + array $allowedRoots = [], + ) { + $this->allowedRoots = array_map(static fn (string $root): string => Path::canonicalize(realpath($root) ?: $root), $allowedRoots); + } + + public function load(string $uri): array + { + $path = $this->guard($this->resolvePath($uri)); + + if (!is_file($path)) { + throw new RuntimeException(\sprintf('Design token file not found: "%s".', $path)); + } + + try { + $json = new Filesystem()->readFile($path); + } catch (IOExceptionInterface $e) { + throw new RuntimeException(\sprintf('Could not read design token file: "%s".', $path), previous: $e); + } + + try { + $data = json_decode($json, true, 512, \JSON_THROW_ON_ERROR); + } catch (\JsonException $e) { + throw new RuntimeException(\sprintf('Invalid JSON in design token file "%s": %s', $path, $e->getMessage()), 0, $e); + } + // Decoded into arrays, {"0": ...} and [...] look alike, so the JSON text decides. + if (!\is_array($data) || !str_starts_with(ltrim($json), '{')) { + throw new RuntimeException(\sprintf('Design token file must contain a JSON object: "%s".', $path)); + } + + return $data; + } + + private function resolvePath(string $uri): string + { + if ('' !== $this->basePath && !Path::isAbsolute($uri)) { + return Path::join($this->basePath, $uri); + } + + return $uri; + } + + /** Keeps a `$ref` inside the allowed roots, symlinks resolved. */ + private function guard(string $path): string + { + if ([] === $this->allowedRoots) { + return $path; + } + + if (false === $real = realpath($path)) { + return $path; + } + + $real = Path::canonicalize($real); + foreach ($this->allowedRoots as $root) { + if (Path::isBasePath($root, $real)) { + return $path; + } + } + + throw new RuntimeException(\sprintf('Refusing to read design token document "%s": it resolves outside %s. Decorate "%s" to read documents from elsewhere.', $path, implode(', ', array_map(static fn (string $root): string => '"'.$root.'"', $this->allowedRoots)), DocumentLoaderInterface::class)); + } +} diff --git a/src/DesignTokens/src/Resolver/JsonPointer.php b/src/DesignTokens/src/Resolver/JsonPointer.php new file mode 100644 index 00000000000..2f98079ac5e --- /dev/null +++ b/src/DesignTokens/src/Resolver/JsonPointer.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\Resolver; + +use Symfony\UX\DesignTokens\Exception\InvalidArgumentException; + +/** + * @author Simon André + * + * @internal + */ +final class JsonPointer +{ + /** + * @param string $fragment the part after "#": empty for the whole document, or starting with "/" + * + * @return list + * + * @throws InvalidArgumentException when the fragment is not a JSON Pointer + */ + public static function segments(string $fragment): array + { + if (1 === preg_match('/%(?![0-9a-f]{2})/i', $fragment)) { + throw new InvalidArgumentException(\sprintf('Invalid percent-encoding in JSON Pointer fragment: "%s".', $fragment)); + } + $pointer = rawurldecode($fragment); + if ('' === $pointer) { + return []; + } + if (!str_starts_with($pointer, '/')) { + throw new InvalidArgumentException(\sprintf('Invalid JSON Pointer fragment: "%s".', $fragment)); + } + + return array_map(static function (string $segment): string { + if (1 === preg_match('/~(?![01])/', $segment)) { + throw new InvalidArgumentException(\sprintf('Invalid JSON Pointer escape in segment "%s".', $segment)); + } + + return str_replace(['~1', '~0'], ['/', '~'], $segment); + }, explode('/', substr($pointer, 1))); + } + + public static function escape(string $segment): string + { + return str_replace(['~', '/'], ['~0', '~1'], $segment); + } +} diff --git a/src/DesignTokens/src/Resolver/ResolverDocument.php b/src/DesignTokens/src/Resolver/ResolverDocument.php new file mode 100644 index 00000000000..a42d2a15954 --- /dev/null +++ b/src/DesignTokens/src/Resolver/ResolverDocument.php @@ -0,0 +1,682 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\UX\DesignTokens\Resolver; + +use Symfony\Component\Filesystem\Path; +use Symfony\UX\DesignTokens\Exception\InvalidArgumentException; +use Symfony\UX\DesignTokens\Exception\ResolverException; + +/** + * @author Simon André + * + * @internal + */ +final class ResolverDocument +{ + public const VERSION = '2025.10'; + + /** @var array> */ + private array $documents = []; + + /** @var list}>|null */ + private ?array $order = null; + + /** @var array */ + private readonly array $data; + + /** + * @param array $data the decoded document + * @param string $basePath directory its relative sources are read from + * @param DocumentLoaderInterface|null $loader reads those sources; the same loader must read the document itself + */ + public function __construct( + array $data, + private readonly string $basePath = '', + private readonly ?DocumentLoaderInterface $loader = null, + ) { + $document = []; + foreach ($data as $key => $value) { + if (!\is_string($key)) { + throw new InvalidArgumentException('A Resolver document must use string property names.'); + } + $document[$key] = $value; + } + $this->data = $document; + $this->validateDocument(); + } + + /** + * @return list URIs of the external documents read so far + * + * @internal + */ + public function loadedUris(): array + { + return array_keys($this->documents); + } + + /** + * @param array $inputs + * + * @return list + * + * @internal + */ + public function sourceDescriptors(array $inputs = []): array + { + $sources = []; + foreach ($this->resolutionPlan($inputs) as $selection) { + foreach ($selection['sources'] as $source) { + $sources[] = $source; + } + } + + return $sources; + } + + /** @return array, default: string|null}> */ + public function getModifiers(): array + { + $modifiers = []; + foreach ($this->modifiersIn($this->resolutionOrder()) as $name => $modifier) { + $modifiers[$name] = [ + 'contexts' => array_map(strval(...), array_keys($this->contexts($modifier, $name))), + 'default' => \array_key_exists('default', $modifier) ? $this->defaultContext($modifier, $name) : null, + ]; + } + + return $modifiers; + } + + /** + * @param array $inputs + * + * @return list}> + * + * @internal + */ + public function resolutionPlan(array $inputs = []): array + { + $order = $this->resolutionOrder(); + $modifiers = $this->modifiersIn($order); + $inputs = $this->validateInputs($inputs, $modifiers); + $plan = []; + + foreach ($order as $position => $entry) { + $item = $entry['item']; + $context = null; + + if ('modifier' === $entry['type']) { + $name = $entry['name']; + $contexts = $this->contexts($item, $name); + $context = $inputs[$name] ?? $this->defaultContext($item, $name); + $sources = $this->expandSources($contexts[$context], 'modifier', ["#/resolutionOrder/{$position}"], $this->basePath); + } else { + /** @var list $rawSources */ + $rawSources = $item['sources']; + $sources = $this->expandSources($rawSources, 'set', ["#/resolutionOrder/{$position}"], $this->basePath); + } + + $plan[] = [ + 'type' => $entry['type'], + 'name' => $entry['name'], + 'context' => $context, + 'sources' => $sources, + ]; + } + + return $plan; + } + + /** @return list> */ + public function getPermutations(): array + { + $modifiers = $this->modifiersIn($this->resolutionOrder()); + $permutations = [[]]; + + foreach ($modifiers as $name => $modifier) { + $next = []; + foreach ($permutations as $permutation) { + foreach (array_keys($this->contexts($modifier, $name)) as $context) { + $next[] = array_replace($permutation, [$name => $context]); + } + } + $permutations = $next; + } + + return $permutations; + } + + /** @return list}> */ + private function resolutionOrder(): array + { + return $this->order ??= $this->computeResolutionOrder(); + } + + /** + * @phpstan-impure Resolving the order validates references and may load files. + * + * @return list}> + */ + private function computeResolutionOrder(): array + { + /** @var list $rawOrder */ + $rawOrder = $this->data['resolutionOrder']; + $order = []; + $orderNames = []; + + foreach ($rawOrder as $position => $rawItem) { + if (!\is_array($rawItem) || array_is_list($rawItem)) { + throw new InvalidArgumentException(\sprintf('resolutionOrder[%d] must be an object.', $position)); + } + /** @var array $rawItem */ + if (\array_key_exists('$ref', $rawItem)) { + $ref = $this->referenceString($rawItem, "resolutionOrder[{$position}]"); + $target = $this->resolveReference($rawItem, 'resolutionOrder', ["#/resolutionOrder/{$position}"], $this->basePath, $this->data); + $type = $this->referencedItemType($ref, $target); + $name = $this->referencedItemName($ref, $target); + $this->validateItem($target, $type, $name, !preg_match('~^#/(?:sets|modifiers)/[^/]+$~', $ref)); + if (isset($orderNames[$name])) { + throw new InvalidArgumentException(\sprintf('Resolution order name "%s" is duplicated.', $name)); + } + $orderNames[$name] = true; + $order[] = ['type' => $type, 'name' => $name, 'item' => $target]; + + continue; + } + + $type = $rawItem['type'] ?? null; + $name = $rawItem['name'] ?? null; + if (!\is_string($type)) { + throw new InvalidArgumentException(\sprintf('Inline resolutionOrder item %d must declare type "set" or "modifier".', $position)); + } + $itemType = $this->itemType($type, $position); + if (!\is_string($name) || '' === $name) { + throw new InvalidArgumentException(\sprintf('Inline resolutionOrder item %d must declare a non-empty name.', $position)); + } + if (isset($orderNames[$name])) { + throw new InvalidArgumentException(\sprintf('Resolution order name "%s" is duplicated.', $name)); + } + $orderNames[$name] = true; + $this->validateItem($rawItem, $itemType, $name, true); + $order[] = ['type' => $itemType, 'name' => $name, 'item' => $rawItem]; + } + + return $order; + } + + /** @return 'set'|'modifier' */ + private function itemType(string $type, int $position): string + { + return match ($type) { + 'set' => 'set', + 'modifier' => 'modifier', + default => throw new InvalidArgumentException(\sprintf('Inline resolutionOrder item %d must declare type "set" or "modifier".', $position)), + }; + } + + /** + * @param list}> $order + * + * @return array> + */ + private function modifiersIn(array $order): array + { + $modifiers = []; + foreach ($order as $entry) { + if ('modifier' !== $entry['type']) { + continue; + } + $modifiers[$entry['name']] = $entry['item']; + } + + return $modifiers; + } + + /** + * @param array $inputs + * @param array> $modifiers + * + * @return array + */ + private function validateInputs(array $inputs, array $modifiers): array + { + $normalizedModifiers = []; + foreach ($modifiers as $name => $modifier) { + $key = strtolower($name); + if (isset($normalizedModifiers[$key])) { + throw new InvalidArgumentException(\sprintf('Modifier names "%s" and "%s" differ only by case.', $normalizedModifiers[$key]['name'], $name)); + } + $normalizedModifiers[$key] = ['name' => $name, 'item' => $modifier]; + } + + $selected = []; + $provided = []; + $seenInputs = []; + $errors = []; + foreach ($inputs as $inputName => $context) { + $normalizedInputName = strtolower($inputName); + if (isset($seenInputs[$normalizedInputName])) { + $errors[] = \sprintf('Modifier input "%s" is provided more than once with different casing.', $inputName); + continue; + } + $seenInputs[$normalizedInputName] = true; + if (\is_int($context) || \is_float($context)) { + $context = (string) $context; + } + if (!\is_string($context)) { + $errors[] = \sprintf('Input "%s" must be a string or a number.', $inputName); + continue; + } + $modifier = $normalizedModifiers[$normalizedInputName] ?? null; + if (null === $modifier) { + $errors[] = \sprintf('Unknown modifier "%s".', $inputName); + continue; + } + $provided[$modifier['name']] = true; + $contexts = $this->contexts($modifier['item'], $modifier['name']); + $contextName = $this->caseInsensitiveKey($contexts, $context); + if (null === $contextName) { + $errors[] = \sprintf('Invalid context "%s" for modifier "%s".', $context, $modifier['name']); + continue; + } + $selected[$modifier['name']] = $contextName; + } + + foreach ($modifiers as $name => $modifier) { + if (!isset($provided[$name]) && !\array_key_exists('default', $modifier)) { + $errors[] = \sprintf('Missing required modifier "%s".', $name); + } + } + + if ([] !== $errors) { + throw new ResolverException($errors); + } + + return $selected; + } + + /** + * @param array $item + * + * @return array> + */ + private function contexts(array $item, string $name): array + { + $contexts = $item['contexts'] ?? null; + if (!\is_array($contexts) || [] === $contexts || array_is_list($contexts)) { + throw new InvalidArgumentException(\sprintf('Modifier "%s" must declare a non-empty contexts object.', $name)); + } + $normalizedContexts = []; + $validContexts = []; + foreach ($contexts as $context => $sources) { + // PHP turns a context named "320" into the int key 320. + $context = (string) $context; + if ('' === $context || !\is_array($sources) || !array_is_list($sources)) { + throw new InvalidArgumentException(\sprintf('Context "%s" of modifier "%s" must be an array of token sources.', $context, $name)); + } + $normalizedContext = strtolower($context); + if (isset($normalizedContexts[$normalizedContext])) { + throw new InvalidArgumentException(\sprintf('Context names "%s" and "%s" of modifier "%s" differ only by case.', $normalizedContexts[$normalizedContext], $context, $name)); + } + $normalizedContexts[$normalizedContext] = $context; + $this->validateSources($sources, 'modifier', $name.'.'.$context); + $validContexts[$context] = $sources; + } + if (1 === \count($contexts)) { + throw new InvalidArgumentException(\sprintf('Modifier "%s" must declare at least two contexts; use a set for unconditional tokens.', $name)); + } + + return $validContexts; + } + + /** @param array $item */ + private function defaultContext(array $item, string $name): string + { + $default = $item['default'] ?? null; + \assert(\is_string($default)); + + return $default; + } + + /** + * @param list $rawSources + * @param 'set'|'modifier' $ownerType + * @param list $stack + * + * @return list + */ + private function expandSources(array $rawSources, string $ownerType, array $stack, string $basePath): array + { + $result = []; + foreach ($rawSources as $index => $source) { + if (!\is_array($source) || array_is_list($source)) { + throw new InvalidArgumentException(\sprintf('Token source %d must be an object.', $index)); + } + /** @var array $source */ + if (!\array_key_exists('$ref', $source)) { + $result[] = new ResolverSource($source, $basePath); + continue; + } + + $ref = $this->referenceString($source, 'token source'); + $this->assertAllowedSourceReference($ref, $ownerType); + $resolved = $this->resolveReference($source, $ownerType, $stack, $basePath, $this->data); + $sourceBasePath = $this->referenceBasePath($ref, $basePath); + + if (isset($resolved['sources']) && \is_array($resolved['sources']) && array_is_list($resolved['sources'])) { + foreach ($this->expandSources($resolved['sources'], $ownerType, [...$stack, $this->referenceKey($ref, $basePath)], $sourceBasePath) as $nested) { + $result[] = $nested; + } + continue; + } + + $result[] = new ResolverSource($resolved, $sourceBasePath, $ref); + } + + return $result; + } + + /** + * @param array $reference + * @param 'set'|'modifier'|'resolutionOrder' $ownerType + * @param list $stack + * @param array $documentData + * + * @return array + */ + private function resolveReference(array $reference, string $ownerType, array $stack, string $basePath, array $documentData, string $documentUri = ''): array + { + $ref = $this->referenceString($reference, 'reference object'); + if (str_starts_with($ref, '#/resolutionOrder/')) { + throw new InvalidArgumentException(\sprintf('Resolver references must not point into resolutionOrder: "%s".', $ref)); + } + if ('resolutionOrder' !== $ownerType && str_starts_with($ref, '#/modifiers/')) { + throw new InvalidArgumentException(\sprintf('%ss must not reference modifiers: "%s".', ucfirst($ownerType), $ref)); + } + $parts = explode('#', $ref, 2); + $uri = $parts[0]; + $fragment = $parts[1] ?? null; + $targetUri = '' === $uri ? $documentUri : $this->absoluteUri($uri, $basePath); + $key = $this->referenceKey($ref, $basePath, $documentUri); + if (\in_array($key, $stack, true)) { + throw new InvalidArgumentException(\sprintf('Circular resolver reference detected: %s -> %s.', implode(' -> ', $stack), $key)); + } + if ('' === $uri) { + $target = $this->pointer($documentData, $fragment, $ref); + $targetDocument = $documentData; + $targetBasePath = $basePath; + } else { + $targetDocument = $this->loadDocument($this->absoluteUri($uri, $basePath)); + $target = $targetDocument; + if (null !== $fragment && '' !== $fragment) { + $target = $this->pointer($target, $fragment, $ref); + } + $targetBasePath = $this->referenceBasePath($ref, $basePath); + } + + if (\array_key_exists('$ref', $target)) { + $target = $this->resolveReference($target, $ownerType, [...$stack, $key], $targetBasePath, $targetDocument, $targetUri); + } + + $overrides = $reference; + unset($overrides['$ref']); + + return array_replace($target, $overrides); + } + + /** + * @param array $data + * + * @return array + */ + private function pointer(array $data, ?string $fragment, string $ref): array + { + if (null === $fragment || '' === $fragment) { + return $data; + } + if (!str_starts_with($fragment, '/')) { + throw new InvalidArgumentException(\sprintf('Resolver reference fragment must be a JSON Pointer: "%s".', $ref)); + } + + $current = $data; + foreach (JsonPointer::segments($fragment) as $segment) { + if (!\array_key_exists($segment, $current) || !\is_array($current[$segment]) || ([] !== $current[$segment] && array_is_list($current[$segment]))) { + throw new InvalidArgumentException(\sprintf('Resolver pointer not found: "%s".', $ref)); + } + /** @var array $next */ + $next = $current[$segment]; + $current = $next; + } + + return $current; + } + + /** @param array $reference */ + private function referenceString(array $reference, string $location): string + { + $ref = $reference['$ref'] ?? null; + if (!\is_string($ref) || '' === $ref) { + throw new InvalidArgumentException(\sprintf('The $ref in %s must be a non-empty string.', $location)); + } + + return $ref; + } + + /** + * @param array $target + * + * @return 'set'|'modifier' + */ + private function referencedItemType(string $ref, array $target): string + { + if (preg_match('~(?:^|#)/sets/[^/]+$~', $ref)) { + return 'set'; + } + if (preg_match('~(?:^|#)/modifiers/[^/]+$~', $ref)) { + return 'modifier'; + } + $type = $target['type'] ?? null; + if (!\is_string($type) || !\in_array($type, ['set', 'modifier'], true)) { + throw new InvalidArgumentException(\sprintf('resolutionOrder reference "%s" does not identify a set or modifier.', $ref)); + } + + return $type; + } + + /** @param array $target */ + private function referencedItemName(string $ref, array $target): string + { + if (preg_match('~(?:^|#)/(?:sets|modifiers)/([^/]+)$~', $ref, $matches)) { + return JsonPointer::segments('/'.$matches[1])[0]; + } + $name = $target['name'] ?? null; + if (!\is_string($name) || '' === $name) { + throw new InvalidArgumentException(\sprintf('Referenced resolutionOrder item "%s" must have a name.', $ref)); + } + + return $name; + } + + /** @param array $item @param 'set'|'modifier' $type */ + private function validateItem(array $item, string $type, string $name, bool $allowIdentity = false): void + { + $this->validateOptionalMetadata($item, ucfirst($type).' "'.$name.'"'); + $identityProperties = $allowIdentity ? ['name', 'type'] : []; + + if ('set' === $type) { + $this->assertOnlyProperties($item, ['description', 'sources', '$extensions', ...$identityProperties], \sprintf('Set "%s"', $name)); + if (!isset($item['sources']) || !\is_array($item['sources']) || !array_is_list($item['sources'])) { + throw new InvalidArgumentException(\sprintf('Set "%s" must declare a sources array.', $name)); + } + /** @var list $sources */ + $sources = $item['sources']; + $this->validateSources($sources, 'set', $name); + + return; + } + + $this->assertOnlyProperties($item, ['description', 'contexts', 'default', '$extensions', ...$identityProperties], \sprintf('Modifier "%s"', $name)); + $contexts = $this->contexts($item, $name); + if (\array_key_exists('default', $item)) { + $default = $item['default']; + if (!\is_string($default) || !\array_key_exists($default, $contexts)) { + throw new InvalidArgumentException(\sprintf('Default context for modifier "%s" must match a context key.', $name)); + } + } + } + + private function assertAllowedSourceReference(string $ref, string $ownerType): void + { + $fragment = str_contains($ref, '#') ? '#'.explode('#', $ref, 2)[1] : ''; + if (str_starts_with($fragment, '#/resolutionOrder/')) { + throw new InvalidArgumentException(\sprintf('Resolver references must not point into resolutionOrder: "%s".', $ref)); + } + if (str_starts_with($fragment, '#/modifiers/')) { + throw new InvalidArgumentException(\sprintf('%ss must not reference modifiers: "%s".', ucfirst($ownerType), $ref)); + } + } + + private function referenceBasePath(string $ref, string $currentBasePath): string + { + $uri = explode('#', $ref, 2)[0]; + if ('' === $uri) { + return $currentBasePath; + } + $directory = \dirname($this->absoluteUri($uri, $currentBasePath)); + + return '.' === $directory ? '' : $directory; + } + + private function referenceKey(string $ref, string $basePath, string $documentUri = ''): string + { + $parts = explode('#', $ref, 2); + + return ('' === $parts[0] ? $documentUri : $this->absoluteUri($parts[0], $basePath)).'#'.($parts[1] ?? ''); + } + + /** @return array */ + private function loadDocument(string $uri): array + { + return $this->documents[$uri] ??= ($this->loader ?? new JsonDocumentLoader())->load($uri); + } + + private function absoluteUri(string $uri, string $basePath): string + { + if ('' === $basePath || Path::isAbsolute($uri) || 1 === preg_match('#^[a-z][a-z0-9+.-]*://#i', $uri)) { + return $uri; + } + + return Path::join($basePath, $uri); + } + + /** @param array $contexts */ + private function caseInsensitiveKey(array $contexts, string $input): ?string + { + foreach (array_keys($contexts) as $context) { + if (0 === strcasecmp($context, $input)) { + return $context; + } + } + + return null; + } + + private function validateDocument(): void + { + if (($this->data['version'] ?? null) !== self::VERSION) { + throw new InvalidArgumentException('A DTCG Resolver document must declare version "2025.10".'); + } + if (!\is_array($this->data['resolutionOrder'] ?? null) || !array_is_list($this->data['resolutionOrder']) || [] === $this->data['resolutionOrder']) { + throw new InvalidArgumentException('A DTCG Resolver document must define a non-empty resolutionOrder array.'); + } + $this->assertOnlyProperties($this->data, ['$schema', 'name', 'version', 'description', 'sets', 'modifiers', 'resolutionOrder', '$defs'], 'Resolver document'); + foreach (['name', 'description', '$schema'] as $property) { + if (isset($this->data[$property]) && !\is_string($this->data[$property])) { + throw new InvalidArgumentException(\sprintf('Resolver property "%s" must be a string.', $property)); + } + } + foreach (['sets', 'modifiers', '$defs'] as $property) { + if (isset($this->data[$property]) && (!\is_array($this->data[$property]) || ([] !== $this->data[$property] && array_is_list($this->data[$property])))) { + throw new InvalidArgumentException(\sprintf('Resolver property "%s" must be an object.', $property)); + } + } + + /** @var array $sets */ + $sets = $this->data['sets'] ?? []; + foreach ($sets as $name => $set) { + $name = (string) $name; + if ('' === $name || !\is_array($set) || array_is_list($set)) { + throw new InvalidArgumentException('Every resolver set must be a named object.'); + } + $this->validateItem($set, 'set', $name); + } + /** @var array $modifiers */ + $modifiers = $this->data['modifiers'] ?? []; + foreach ($modifiers as $name => $modifier) { + $name = (string) $name; + if ('' === $name || !\is_array($modifier) || array_is_list($modifier)) { + throw new InvalidArgumentException('Every resolver modifier must be a named object.'); + } + $this->validateItem($modifier, 'modifier', $name); + } + + $this->resolutionOrder(); + } + + /** + * @param list $sources + * @param 'set'|'modifier' $ownerType + */ + private function validateSources(array $sources, string $ownerType, string $ownerName): void + { + foreach ($sources as $position => $source) { + if (!\is_array($source) || array_is_list($source)) { + throw new InvalidArgumentException(\sprintf('Token source %d of %s "%s" must be an object.', $position, $ownerType, $ownerName)); + } + /** @var array $source */ + if (\array_key_exists('$ref', $source)) { + $ref = $this->referenceString($source, \sprintf('token source %d of %s "%s"', $position, $ownerType, $ownerName)); + $this->assertAllowedSourceReference($ref, $ownerType); + + // Resolver 4.2.1: an invalid pointer is an error even where nothing selects it. + if (str_starts_with($ref, '#')) { + $this->pointer($this->data, substr($ref, 1), $ref); + } + } + } + } + + /** @param array $item */ + private function validateOptionalMetadata(array $item, string $location): void + { + if (\array_key_exists('description', $item) && !\is_string($item['description'])) { + throw new InvalidArgumentException(\sprintf('%s property "description" must be a string.', $location)); + } + if (\array_key_exists('$extensions', $item) && (!\is_array($item['$extensions']) || ([] !== $item['$extensions'] && array_is_list($item['$extensions'])))) { + throw new InvalidArgumentException(\sprintf('%s property "$extensions" must be an object.', $location)); + } + } + + /** + * @param array $item + * @param list $allowed + */ + private function assertOnlyProperties(array $item, array $allowed, string $location): void + { + foreach (array_keys($item) as $property) { + if (!\in_array($property, $allowed, true)) { + throw new InvalidArgumentException(\sprintf('%s contains unsupported property "%s".', $location, $property)); + } + } + } +} diff --git a/src/DesignTokens/src/Resolver/ResolverInputs.php b/src/DesignTokens/src/Resolver/ResolverInputs.php new file mode 100644 index 00000000000..67a44dc1965 --- /dev/null +++ b/src/DesignTokens/src/Resolver/ResolverInputs.php @@ -0,0 +1,67 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\UX\DesignTokens\Resolver; + +use Symfony\UX\DesignTokens\Exception\ResolverException; + +/** + * @author Simon André + * + * @internal + */ +final class ResolverInputs +{ + /** + * @param array $defaults + * @param array $overrides + * + * @return array + * + * @throws ResolverException when the overrides name one modifier twice + */ + public static function merge(array $defaults, array $overrides): array + { + $seen = []; + foreach ($overrides as $name => $value) { + $name = (string) $name; + $normalized = strtolower($name); + if (isset($seen[$normalized])) { + throw new ResolverException([\sprintf('Modifier input "%s" is provided more than once with different casing.', $name)]); + } + $seen[$normalized] = true; + + foreach (array_keys($defaults) as $default) { + if (0 === strcasecmp((string) $default, $name)) { + unset($defaults[$default]); + } + } + $defaults[$name] = $value; + } + + return $defaults; + } + + /** @param array $inputs */ + public static function key(array $inputs): string + { + $canonical = []; + foreach ($inputs as $name => $value) { + if (\is_int($value) || \is_float($value)) { + $value = (string) $value; + } + $canonical[strtolower((string) $name)] = \is_string($value) ? strtolower($value) : $value; + } + ksort($canonical); + + return hash('xxh128', serialize($canonical)); + } +} diff --git a/src/DesignTokens/src/Resolver/ResolverSource.php b/src/DesignTokens/src/Resolver/ResolverSource.php new file mode 100644 index 00000000000..917d70b245b --- /dev/null +++ b/src/DesignTokens/src/Resolver/ResolverSource.php @@ -0,0 +1,28 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\UX\DesignTokens\Resolver; + +/** + * A token source together with the directory its relative references use. + * + * @author Simon André + */ +final readonly class ResolverSource +{ + /** @param array $tokens */ + public function __construct( + public array $tokens, + public string $basePath, + public ?string $uri = null, + ) { + } +} diff --git a/src/DesignTokens/src/Resolver/TokenResolution.php b/src/DesignTokens/src/Resolver/TokenResolution.php new file mode 100644 index 00000000000..f439a22e2b2 --- /dev/null +++ b/src/DesignTokens/src/Resolver/TokenResolution.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\Resolver; + +/** + * @author Simon André + */ +final class TokenResolution +{ + /** + * @param array $tokens + * @param array $sources winning source of each path, when traced + * @param array> $overrides sources each path replaced, when traced + * @param list $documents URIs of every document the resolution read + */ + public function __construct( + private readonly array $tokens, + private readonly array $sources = [], + private readonly array $overrides = [], + private readonly array $documents = [], + ) { + } + + /** @return array */ + public function getTokens(): array + { + return $this->tokens; + } + + /** @return list */ + public function getDocuments(): array + { + return $this->documents; + } + + /** + * @param list $documents + * + * @internal + */ + public function withDocuments(array $documents): self + { + return new self($this->tokens, $this->sources, $this->overrides, $documents); + } + + public function getSource(string $path): ?ResolverSource + { + return $this->sources[$path] ?? null; + } + + /** @return list */ + public function getOverrides(string $path): array + { + return $this->overrides[$path] ?? []; + } +} diff --git a/src/DesignTokens/src/Resolver/TokenResolverInterface.php b/src/DesignTokens/src/Resolver/TokenResolverInterface.php new file mode 100644 index 00000000000..312d30ff628 --- /dev/null +++ b/src/DesignTokens/src/Resolver/TokenResolverInterface.php @@ -0,0 +1,41 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\UX\DesignTokens\Resolver; + +use Symfony\UX\DesignTokens\Exception\LogicException; + +/** + * @author Simon André + */ +interface TokenResolverInterface +{ + /** + * @param array $inputs + * + * @throws LogicException when inputs are given but no Resolver document is configured + */ + public function resolve(array $inputs): TokenResolution; + + /** + * Every combination of inputs the Resolver document can produce; empty without one. + * + * @return list> + */ + public function getPermutations(): array; + + /** + * Every modifier, with its contexts in authored order and its default; empty without a Resolver document. + * + * @return array, default: string|null}> + */ + public function getModifiers(): array; +} diff --git a/src/DesignTokens/src/Resolver/TokenTreeBuilder.php b/src/DesignTokens/src/Resolver/TokenTreeBuilder.php new file mode 100644 index 00000000000..cff13792ef5 --- /dev/null +++ b/src/DesignTokens/src/Resolver/TokenTreeBuilder.php @@ -0,0 +1,898 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\UX\DesignTokens\Resolver; + +use Symfony\UX\DesignTokens\Exception\InvalidArgumentException; +use Symfony\UX\DesignTokens\Exception\RuntimeException; +use Symfony\UX\DesignTokens\Exception\UnresolvedReferenceException; +use Symfony\UX\DesignTokens\Token\TokenFactory; +use Symfony\UX\DesignTokens\Token\TokenInterface; +use Symfony\UX\DesignTokens\TokenTree; +use Symfony\UX\DesignTokens\Validation\TokenValueValidator; + +/** + * @author Simon André + * + * @internal + */ +final class TokenTreeBuilder +{ + /** Reserved token name a group uses to expose its own value (Format 6.2). */ + private const string ROOT = '$root'; + + /** @var array */ + private array $visiting = []; + + /** @var list */ + private array $referenceStack = []; + + /** @var array> */ + private array $documents = []; + + /** @var array JSON Pointer path to source URI */ + private array $origins = []; + + /** @var array origins of the documents merged into the tree */ + private array $merged = []; + + /** @var array JSON Pointer path to the index of the source that last defined it */ + private array $winners = []; + + /** @var array> JSON Pointer path to every source index that defined it, in order */ + private array $history = []; + + /** @var array> group path to direct local token names */ + private array $localTokens = []; + + private bool $partial = false; + + private readonly TokenValueValidator $values; + + public function __construct(private readonly ?DocumentLoaderInterface $loader = null) + { + $this->values = new TokenValueValidator(); + } + + /** @return list */ + public function loadedUris(): array + { + return array_values(array_filter( + array_keys($this->documents), + static fn (string $uri): bool => '' !== $uri, + )); + } + + /** + * @param array $raw + * + * @return array + */ + public function resolve(array $raw): array + { + $this->reset(); + $this->documents[''] = $raw; + $this->recordOrigins($raw, '', '', 0); + + return $this->resolveMerged($raw); + } + + /** + * @param list $sources + * + * @return array + */ + public function resolveSources(array $sources): array + { + $this->reset(); + if ([] === $sources) { + return []; + } + + $merged = []; + foreach ($sources as $index => $source) { + if (!$source instanceof ResolverSource) { + throw new InvalidArgumentException(\sprintf('Resolver source %d must be a ResolverSource.', $index)); + } + $merged = $this->mergeGroups($merged, $source->tokens); + $this->recordOrigins($source->tokens, '', $this->sourceOrigin($source, $index), $index); + } + $this->documents[''] = $merged; + + return $this->resolveMerged($merged); + } + + /** + * Validates a document a Resolver completes: a token whose reference points outside it is left out. + * + * @return array the tokens the document resolves on its own + */ + public function resolvePartial(ResolverSource $source): array + { + $this->partial = true; + try { + return $this->resolveSources([$source]); + } finally { + $this->partial = false; + } + } + + /** @param list $sources */ + public function resolveWithProvenance(array $sources): TokenResolution + { + $tokens = $this->resolveSources($sources); + + $winningSources = []; + $overrides = []; + foreach (TokenTree::flatten($tokens) as $path => $_token) { + $pointer = implode('/', array_map(JsonPointer::escape(...), explode('.', $path))); + $winner = $this->winners[$pointer] ?? null; + if (null === $winner || !isset($sources[$winner])) { + continue; + } + $winningSources[$path] = $sources[$winner]; + foreach (array_unique($this->history[$pointer] ?? []) as $candidate) { + if ($candidate !== $winner && isset($sources[$candidate])) { + $overrides[$path][] = $sources[$candidate]; + } + } + } + + return new TokenResolution($tokens, $winningSources, $overrides); + } + + private function reset(): void + { + $this->visiting = []; + $this->referenceStack = []; + $this->documents = []; + $this->origins = []; + $this->merged = []; + $this->winners = []; + $this->history = []; + $this->localTokens = []; + } + + /** + * @param array $raw + * + * @return array + */ + private function resolveMerged(array $raw): array + { + $raw = $this->applyExtends($raw); + $this->documents[''] = $raw; + + return $this->processNode($raw, '', null, null); + } + + /** + * @param array $node + * + * @return array + */ + private function processNode( + array $node, + string $path, + ?string $inheritedType, + bool|string|null $inheritedDeprecated, + ): array { + $this->validateGroup($node, $path); + $type = \is_string($node['$type'] ?? null) ? $node['$type'] : $inheritedType; + $deprecated = $this->deprecated($node, $inheritedDeprecated); + $result = []; + + // Format 5.2.3: a tool keeps the extension data it does not understand. + if (\is_string($node['$description'] ?? null)) { + $result['$description'] = $node['$description']; + } + if (\is_array($node['$extensions'] ?? null)) { + $result['$extensions'] = $this->object($node['$extensions']); + } + + $directTokens = []; + $nestedGroups = []; + foreach ($node as $name => $content) { + if (str_starts_with((string) $name, '$')) { + continue; + } + $this->validateName((string) $name, $this->join($path, (string) $name)); + if (!\is_array($content)) { + throw new InvalidArgumentException(\sprintf('DTCG entry "%s" must be a token or group object.', self::display($this->join($path, (string) $name)))); + } + $content = $this->object($content); + if ($this->isToken($content)) { + $directTokens[(string) $name] = $content; + } else { + $nestedGroups[(string) $name] = $content; + } + } + + $localNames = $this->localTokens[$path] ?? array_fill_keys(array_keys($directTokens), true); + foreach ($directTokens as $name => $content) { + if (isset($localNames[$name]) && null !== $token = $this->createToken($content, $this->join($path, (string) $name), $type, $deprecated)) { + $result[$name] = $token; + } + } + + if (\array_key_exists(self::ROOT, $node)) { + if (!\is_array($node[self::ROOT])) { + throw new InvalidArgumentException(\sprintf('DTCG $root at "%s" must be a token object.', self::display($path))); + } + $root = $this->object($node[self::ROOT]); + if (null !== $token = $this->createToken($root, $this->join($path, self::ROOT), $type, $deprecated)) { + $result[self::ROOT] = $token; + } + } + + foreach ($directTokens as $name => $content) { + if (!isset($localNames[$name]) && null !== $token = $this->createToken($content, $this->join($path, (string) $name), $type, $deprecated)) { + $result[$name] = $token; + } + } + + foreach ($nestedGroups as $name => $content) { + $result[$name] = $this->processNode($content, $this->join($path, (string) $name), $type, $deprecated); + } + + return $result; + } + + /** + * @param array $data + * + * @return TokenInterface|null null for a partial document's token that needs another source + */ + private function createToken( + array $data, + string $path, + ?string $inheritedType, + bool|string|null $inheritedDeprecated, + ): ?TokenInterface { + $this->validateToken($data, $path); + $origin = $this->origins[$path] ?? ''; + + try { + if (\array_key_exists('$ref', $data)) { + \assert(\is_string($data['$ref'])); + $value = $this->resolvePointer($data['$ref'], $origin); + } else { + $value = $this->resolveAliases($data['$value'], $origin); + } + } catch (UnresolvedReferenceException $e) { + if ($this->partial) { + return null; + } + + throw $e; + } + + $type = \is_string($data['$type'] ?? null) ? $data['$type'] : $this->referencedType($data, $origin); + $type ??= $inheritedType; + if (null === $type) { + throw new InvalidArgumentException(\sprintf('Every DTCG token must declare, inherit, or reference a $type at "%s".', self::display($path))); + } + $this->values->validate($type, $value, self::display($path)); + + $description = \is_string($data['$description'] ?? null) ? $data['$description'] : null; + $extensions = \is_array($data['$extensions'] ?? null) ? $this->object($data['$extensions']) : []; + $deprecated = $this->deprecated($data, $inheritedDeprecated); + + return TokenFactory::create($type, $value, $description, $extensions, $deprecated); + } + + /** + * @param array $value + * + * @return array + */ + private function object(array $value): array + { + $object = []; + foreach ($value as $name => $item) { + $object[(string) $name] = $item; + } + + return $object; + } + + private function resolveAliases(mixed $value, string $uri): mixed + { + if (\is_array($value)) { + /** @var array $value */ + if ($this->isReferenceObject($value)) { + \assert(\is_string($value['$ref'])); + + return $this->resolvePointer($value['$ref'], $uri); + } + + return array_map(fn (mixed $item): mixed => $this->resolveAliases($item, $uri), $value); + } + if (\is_string($value) && 1 === preg_match('/^\{([^{}]+)\}$/D', $value, $matches)) { + return $this->resolveCurly($matches[1]); + } + + return $value; + } + + private function resolveCurly(string $path): mixed + { + $segments = explode('.', $path); + foreach ($segments as $segment) { + $this->validateReferenceSegment($segment, $path); + } + $node = $this->navigate($this->documents[''], $segments, '{'.$path.'}', mergedTree: true); + if (!\is_array($node) || !$this->isToken($node)) { + throw new RuntimeException(\sprintf('Curly brace reference must target a token: "%s".', $path)); + } + $pointerPath = implode('/', array_map(JsonPointer::escape(...), $segments)); + $key = 'curly:'.$path; + + return $this->withCycle($key, fn (): mixed => $this->resolveTokenNodeValue($node, $this->origins[$pointerPath] ?? '')); + } + + /** A same-document pointer addresses the merged tree (Format 7, Resolver 6.3). */ + private function resolvePointer(string $reference, string $currentUri): mixed + { + if (1 !== preg_match('/^(?[^#]*)#(?(?:\/.*)?)$/D', $reference, $matches)) { + throw new RuntimeException(\sprintf('Invalid JSON Pointer reference: "%s".', $reference)); + } + $uri = '' === $matches['uri'] ? $this->sameDocument($currentUri) : $this->resolveUri($currentUri, $matches['uri']); + $document = '' === $uri ? $this->documents[''] : $this->loadDocument($uri); + $segments = JsonPointer::segments($matches['fragment']); + $key = 'pointer:'.$uri.'#'.implode('/', array_map(JsonPointer::escape(...), $segments)); + + return $this->withCycle($key, function () use ($document, $segments, $reference, $uri): mixed { + $value = $this->navigate($document, $segments, $reference, 'pointer', '' === $uri); + if (\is_array($value) && $this->isToken($value)) { + return $this->resolveTokenNodeValue($value, $uri); + } + + return $this->resolveAliases($value, $uri); + }); + } + + /** @param array $node */ + private function resolveTokenNodeValue(array $node, string $uri): mixed + { + if (\array_key_exists('$ref', $node)) { + \assert(\is_string($node['$ref'])); + + return $this->resolvePointer($node['$ref'], $uri); + } + + return $this->resolveAliases($node['$value'], $uri); + } + + /** @param array $data */ + private function referencedType(array $data, string $uri): ?string + { + if (\array_key_exists('$ref', $data)) { + \assert(\is_string($data['$ref'])); + + return $this->typeAtPointer($data['$ref'], $uri); + } + + return $this->typeOfValueReference($data['$value'] ?? null, $uri); + } + + private function typeOfValueReference(mixed $value, string $uri): ?string + { + if (\is_string($value) && 1 === preg_match('/^\{([^{}]+)\}$/D', $value, $matches)) { + return $this->typeAtCurly($matches[1]); + } + if (\is_array($value) && $this->isReferenceObject($value)) { + \assert(\is_string($value['$ref'])); + + return $this->typeAtPointer($value['$ref'], $uri); + } + + return null; + } + + private function typeAtCurly(string $path): ?string + { + $current = $this->documents['']; + $inherited = null; + foreach (explode('.', $path) as $segment) { + if (\is_array($current) && \is_string($current['$type'] ?? null)) { + $inherited = $current['$type']; + } + \assert(\is_array($current) && \array_key_exists($segment, $current)); + $current = $current[$segment]; + } + \assert(\is_array($current) && $this->isToken($current)); + + return $this->resolvedTokenType($current, $inherited, $this->origins[implode('/', array_map(JsonPointer::escape(...), explode('.', $path)))] ?? ''); + } + + private function typeAtPointer(string $reference, string $currentUri): ?string + { + $matched = preg_match('/^(?[^#]*)#(?(?:\/.*)?)$/D', $reference, $matches); + \assert(1 === $matched); + $uri = '' === $matches['uri'] ? $this->sameDocument($currentUri) : $this->resolveUri($currentUri, $matches['uri']); + $document = '' === $uri ? $this->documents[''] : $this->loadDocument($uri); + $current = $document; + $inherited = null; + $segments = JsonPointer::segments($matches['fragment']); + foreach ($segments as $index => $segment) { + // "#/token/$value" is the token; deeper segments point inside its value. + if (\is_array($current) && $this->isToken($current)) { + return '$value' === $segment && $index === array_key_last($segments) + ? $this->resolvedTokenType($current, $inherited, $uri) + : null; + } + if (\is_array($current) && \is_string($current['$type'] ?? null)) { + $inherited = $current['$type']; + } + \assert(\is_array($current) && \array_key_exists($segment, $current)); + $current = $current[$segment]; + } + + if (!\is_array($current) || !$this->isToken($current)) { + return $inherited; + } + + return $this->resolvedTokenType($current, $inherited, $uri); + } + + /** @param array $token */ + private function resolvedTokenType(array $token, ?string $inheritedType, string $uri): ?string + { + if (\is_string($token['$type'] ?? null)) { + return $token['$type']; + } + if (\array_key_exists('$ref', $token)) { + \assert(\is_string($token['$ref'])); + + return $this->typeAtPointer($token['$ref'], $uri); + } + + return $this->typeOfValueReference($token['$value'] ?? null, $uri) ?? $inheritedType; + } + + /** + * @param array $tree + * + * @return array + */ + private function applyExtends(array $tree): array + { + return $this->inheritNode($tree, $tree, '', []); + } + + /** + * @param array $node + * @param array $root + * @param list $stack + * + * @return array + */ + private function inheritNode(array $node, array $root, string $path, array $stack): array + { + $referenceProperty = \array_key_exists('$extends', $node) ? '$extends' : null; + if (null === $referenceProperty && $this->isGroupReference($node, $root)) { + $referenceProperty = '$ref'; + } + + if (null !== $referenceProperty) { + $localTokenNames = []; + foreach ($node as $name => $value) { + if (!str_starts_with((string) $name, '$') && \is_array($value) + && $this->isToken($value) && !$this->isGroupReference($value, $root)) { + $localTokenNames[(string) $name] = true; + } + } + $this->localTokens[$path] = $localTokenNames; + $extends = $node[$referenceProperty]; + if (!\is_string($extends)) { + throw new InvalidArgumentException(\sprintf('%s must be a reference string at "%s".', $referenceProperty, self::display($path))); + } + if (\in_array($path, $stack, true)) { + throw new RuntimeException(\sprintf('Circular group extension detected: "%s".', implode(' -> ', array_map(self::display(...), [...$stack, $path])))); + } + $stack[] = $path; + $segments = $this->extendsSegments($extends); + $basePath = implode('/', array_map(JsonPointer::escape(...), $segments)); + try { + $base = $this->navigate($root, $segments, $extends, 'extends', true); + } catch (UnresolvedReferenceException $e) { + if (!$this->partial) { + throw $e; + } + $base = null; + } + unset($node[$referenceProperty]); + if (null !== $base) { + if (!\is_array($base) || !$this->rawNodeIsGroup($base, $root, [])) { + throw new InvalidArgumentException(\sprintf('$extends target must be a group: "%s".', $extends)); + } + $base = $this->inheritNode($base, $root, $basePath, $stack); + $node = $this->mergeGroups($base, $node); + $this->copyOrigins($basePath, $path, $base); + } + } + + foreach ($node as $name => $value) { + if (\is_array($value) && !str_starts_with((string) $name, '$') + && (!$this->isToken($value) || $this->isGroupReference($value, $root))) { + $node[$name] = $this->inheritNode($value, $root, $this->join($path, (string) $name), $stack); + } + } + + return $node; + } + + /** + * @param array $node + * @param array $root + */ + private function isGroupReference(array $node, array $root): bool + { + if (!\array_key_exists('$ref', $node) || \array_key_exists('$value', $node) || !\is_string($node['$ref'])) { + return false; + } + + foreach ($node as $name => $_value) { + if (!str_starts_with((string) $name, '$')) { + return true; + } + } + + if (!str_starts_with($node['$ref'], '#')) { + return false; + } + + $segments = JsonPointer::segments(substr($node['$ref'], 1)); + + // A pointer through `$value` lands inside a token, never on a group. + if (\in_array('$value', $segments, true)) { + return false; + } + + try { + $target = $this->navigate($root, $segments, $node['$ref'], 'pointer'); + } catch (\RuntimeException) { + return false; + } + + return \is_array($target) && $this->rawNodeIsGroup($target, $root, []); + } + + /** + * @param array $node + * @param array $root + * @param array $seen + */ + private function rawNodeIsGroup(array $node, array $root, array $seen): bool + { + if (\array_key_exists('$value', $node)) { + return false; + } + if (\array_key_exists('$extends', $node)) { + return true; + } + foreach ($node as $name => $_value) { + if (!str_starts_with((string) $name, '$')) { + return true; + } + } + if (!\array_key_exists('$ref', $node)) { + return true; + } + if (!\is_string($node['$ref']) || !str_starts_with($node['$ref'], '#') || isset($seen[$node['$ref']])) { + return false; + } + $segments = JsonPointer::segments(substr($node['$ref'], 1)); + if (\in_array('$value', $segments, true)) { + return false; + } + $seen[$node['$ref']] = true; + + try { + $target = $this->navigate($root, $segments, $node['$ref'], 'pointer'); + } catch (\RuntimeException) { + return false; + } + + return \is_array($target) && $this->rawNodeIsGroup($target, $root, $seen); + } + + /** @return list */ + private function extendsSegments(string $extends): array + { + if (1 === preg_match('/^\{([^{}]+)\}$/D', $extends, $matches)) { + return explode('.', $matches[1]); + } + if (str_starts_with($extends, '#')) { + return JsonPointer::segments(substr($extends, 1)); + } + throw new InvalidArgumentException(\sprintf('$extends must be a curly brace reference or a JSON Pointer, got "%s".', $extends)); + } + + /** + * @param array $base + * @param array $override + * + * @return array + */ + private function mergeGroups(array $base, array $override): array + { + foreach ($override as $key => $value) { + if (\is_array($value) && \is_array($base[$key] ?? null) + && !$this->isToken($value) && !$this->isToken($base[$key])) { + $base[$key] = $this->mergeGroups($base[$key], $value); + } else { + $base[$key] = $value; + } + } + + return $base; + } + + /** @param array $node */ + private function validateGroup(array $node, string $path): void + { + $allowed = ['$type', '$description', '$extensions', '$extends', '$deprecated', '$root']; + foreach ($node as $key => $value) { + if (str_starts_with((string) $key, '$') && !\in_array($key, $allowed, true)) { + throw new InvalidArgumentException(\sprintf('Unknown DTCG group property "%s" at "%s".', $key, self::display($path))); + } + } + $this->validateMetadata($node, $path); + } + + /** @param array $node */ + private function validateToken(array $node, string $path): void + { + $allowed = ['$value', '$type', '$ref', '$description', '$extensions', '$deprecated']; + foreach ($node as $key => $value) { + if (\in_array($key, $allowed, true)) { + continue; + } + if (!str_starts_with((string) $key, '$')) { + throw new InvalidArgumentException(\sprintf('DTCG token "%s" cannot hold the child "%s": a node defining $value or $ref is a token, not a group.', self::display($path), $key)); + } + + throw new InvalidArgumentException(\sprintf('Unknown DTCG token property "%s" at "%s".', $key, self::display($path))); + } + if (\array_key_exists('$value', $node) === \array_key_exists('$ref', $node)) { + throw new InvalidArgumentException(\sprintf('DTCG token "%s" must define exactly one of $value or $ref.', self::display($path))); + } + if (\array_key_exists('$ref', $node) && !\is_string($node['$ref'])) { + throw new InvalidArgumentException(\sprintf('DTCG token $ref at "%s" must be a JSON Pointer string.', self::display($path))); + } + $this->validateMetadata($node, $path); + } + + /** @param array $node */ + private function validateMetadata(array $node, string $path): void + { + if (\array_key_exists('$type', $node) + && (!\is_string($node['$type']) || !\in_array($node['$type'], TokenValueValidator::TYPES, true))) { + throw new InvalidArgumentException(\sprintf('%s at "%s" is not a DTCG type. Expected one of: %s.', \is_string($node['$type']) ? '"'.$node['$type'].'"' : 'A non-string $type', self::display($path), implode(', ', TokenValueValidator::TYPES))); + } + if (\array_key_exists('$description', $node) && !\is_string($node['$description'])) { + throw new InvalidArgumentException(\sprintf('DTCG $description at "%s" must be a string.', self::display($path))); + } + if (\array_key_exists('$extensions', $node) && (!\is_array($node['$extensions']) || array_is_list($node['$extensions']))) { + throw new InvalidArgumentException(\sprintf('DTCG $extensions at "%s" must be an object.', self::display($path))); + } + if (\array_key_exists('$deprecated', $node) && !\is_bool($node['$deprecated']) && !\is_string($node['$deprecated'])) { + throw new InvalidArgumentException(\sprintf('DTCG $deprecated at "%s" must be a boolean or string.', self::display($path))); + } + } + + /** @param array $node */ + private function deprecated(array $node, bool|string|null $inherited): bool|string|null + { + if (!\array_key_exists('$deprecated', $node)) { + return $inherited; + } + $value = $node['$deprecated']; + \assert(\is_bool($value) || \is_string($value)); + + return $value; + } + + private function validateName(string $name, string $path): void + { + if ('' === $name || str_starts_with($name, '$') || str_contains($name, '.') || str_contains($name, '{') || str_contains($name, '}')) { + throw new InvalidArgumentException(\sprintf('Invalid DTCG token or group name "%s" at "%s".', $name, self::display($path))); + } + } + + /** Format 6.7.2: `$root` is the one `$` name a reference may address. */ + private function validateReferenceSegment(string $segment, string $path): void + { + if (self::ROOT === $segment) { + return; + } + + $this->validateName($segment, $path); + } + + /** @param array $node */ + private function isToken(array $node): bool + { + return \array_key_exists('$value', $node) || \array_key_exists('$ref', $node); + } + + /** + * @param array $document + * @param list $segments + * @param 'curly'|'extends'|'pointer' $mode the syntax being walked, which decides what a + * failed step means and how it is reported + * @param bool $mergedTree whether $document is the merged tree, where a + * missing entry may come from another source + */ + private function navigate(array $document, array $segments, string $reference, string $mode = 'curly', bool $mergedTree = false): mixed + { + $current = $document; + $walked = []; + foreach ($segments as $segment) { + // Only a JSON Pointer can address a location inside a value. + if ('pointer' !== $mode && [] !== $walked && \is_array($current) && $this->isToken($current)) { + $hint = 'curly' === $mode ? '; use a $ref JSON Pointer' : ''; + + throw new RuntimeException(\sprintf('Reference "%s" cannot address "%s" inside the value of token "%s"%s.', $reference, $segment, implode('.', $walked), $hint)); + } + if (!\is_array($current) || !\array_key_exists($segment, $current)) { + $message = \sprintf('Reference target not found: "%s".', $reference); + + throw $mergedTree && \is_array($current) ? new UnresolvedReferenceException($message) : new RuntimeException($message); + } + if ('pointer' !== $mode && array_is_list($current)) { + $syntax = 'curly' === $mode ? 'Curly brace references' : '$extends'; + + throw new RuntimeException(\sprintf('%s cannot access array elements: "%s".', $syntax, $reference)); + } + $walked[] = $segment; + $current = $current[$segment]; + } + + return $current; + } + + /** The merged tree for a merged source, the file itself for a referenced one. */ + private function sameDocument(string $currentUri): string + { + return isset($this->merged[$currentUri]) ? '' : $currentUri; + } + + private function resolveUri(string $baseUri, string $reference): string + { + if (1 === preg_match('#^[a-z][a-z0-9+.-]*:#i', $reference) || str_starts_with($reference, '/')) { + return $reference; + } + $authority = ''; + if (1 === preg_match('#^([a-z][a-z0-9+.-]*://[^/]*)(/.*)?$#i', $baseUri, $matches)) { + $authority = $matches[1]; + $baseUri = $matches[2] ?? '/'; + } + $directory = '' === $baseUri ? '' : \dirname($baseUri); + $path = ('.' === $directory ? '' : $directory.'/').$reference; + $absolute = str_starts_with($path, '/') || '' !== $authority; + $parts = []; + foreach (explode('/', $path) as $part) { + if ('' === $part || '.' === $part) { + continue; + } + if ('..' === $part && [] !== $parts && '..' !== $parts[array_key_last($parts)]) { + array_pop($parts); + } else { + $parts[] = $part; + } + } + + return $authority.($absolute ? '/' : '').implode('/', $parts); + } + + /** @return array */ + private function loadDocument(string $uri): array + { + if (isset($this->documents[$uri])) { + return $this->documents[$uri]; + } + if (null === $this->loader) { + throw new RuntimeException(\sprintf('Cannot load referenced token document "%s" without a loader.', $uri)); + } + $document = $this->loader->load($uri); + $this->documents[$uri] = $document; + + return $document; + } + + /** + * @template T + * + * @param callable(): T $resolve + * + * @return T + */ + private function withCycle(string $key, callable $resolve): mixed + { + if (isset($this->visiting[$key])) { + $offset = array_search($key, $this->referenceStack, true); + $chain = false === $offset ? [$key, $key] : [...\array_slice($this->referenceStack, $offset), $key]; + + throw new RuntimeException(\sprintf('Circular reference detected: "%s".', implode(' -> ', $chain))); + } + $this->visiting[$key] = true; + $this->referenceStack[] = $key; + try { + return $resolve(); + } finally { + unset($this->visiting[$key]); + array_pop($this->referenceStack); + } + } + + /** @param array $node */ + private function recordOrigins(array $node, string $path, string $uri, int $source): void + { + $this->merged[$uri] = true; + $this->origins[$path] = $uri; + $this->winners[$path] = $source; + $this->history[$path][] = $source; + foreach ($node as $name => $value) { + if (\is_array($value)) { + $this->recordOrigins($value, $this->join($path, (string) $name), $uri, $source); + } + } + } + + /** @param array $node */ + private function copyOrigins(string $from, string $to, array $node): void + { + foreach ($node as $name => $value) { + $source = $this->join($from, (string) $name); + $target = $this->join($to, (string) $name); + if (!isset($this->origins[$target]) && isset($this->origins[$source])) { + $this->origins[$target] = $this->origins[$source]; + } + if (!isset($this->winners[$target]) && isset($this->winners[$source])) { + $this->winners[$target] = $this->winners[$source]; + } + if (!isset($this->history[$target]) && isset($this->history[$source])) { + $this->history[$target] = $this->history[$source]; + } + if (\is_array($value)) { + $this->copyOrigins($source, $target, $value); + } + } + } + + private static function display(string $path): string + { + return str_replace(['~1', '~0'], ['/', '~'], str_replace('/', '.', $path)); + } + + private function join(string $path, string $segment): string + { + $escaped = JsonPointer::escape($segment); + + return '' === $path ? $escaped : $path.'/'.$escaped; + } + + private function sourceOrigin(ResolverSource $source, int $index): string + { + $filename = null === $source->uri ? '.inline-'.$index.'.tokens.json' : basename(parse_url($source->uri, \PHP_URL_PATH) ?: $source->uri); + + return '' === $source->basePath ? $filename : rtrim($source->basePath, '/').'/'.$filename; + } + + /** @param array $value */ + private function isReferenceObject(array $value): bool + { + return ['$ref'] === array_keys($value) && \is_string($value['$ref']); + } +} diff --git a/src/DesignTokens/src/Token/AbstractToken.php b/src/DesignTokens/src/Token/AbstractToken.php new file mode 100644 index 00000000000..4afed634cd1 --- /dev/null +++ b/src/DesignTokens/src/Token/AbstractToken.php @@ -0,0 +1,85 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\UX\DesignTokens\Token; + +/** + * @template TValue + * + * @author Simon André + * + * @internal + */ +abstract class AbstractToken implements TokenInterface +{ + /** + * @var TValue + */ + protected readonly mixed $value; + + /** @var array */ + protected readonly array $extensions; + + protected readonly ?string $description; + + protected readonly bool|string|null $deprecated; + + /** + * @param array $extensions + */ + public function __construct( + mixed $value, + ?string $description = null, + array $extensions = [], + bool|string|null $deprecated = null, + ) { + $this->value = $value; + $this->description = $description; + $this->extensions = $extensions; + $this->deprecated = $deprecated; + } + + /** + * @return TValue + */ + public function getValue(): mixed + { + return $this->value; + } + + public function getDescription(): ?string + { + return $this->description; + } + + public function isDeprecated(): bool + { + return false !== $this->deprecated && null !== $this->deprecated; + } + + public function getDeprecationMessage(): ?string + { + return \is_string($this->deprecated) && '' !== $this->deprecated ? $this->deprecated : null; + } + + /** + * @return array + */ + public function getExtensions(): array + { + return $this->extensions; + } + + protected static function member(string $type, mixed $value, string $fallback = ''): string + { + return TokenFactory::project($type, $value, $fallback); + } +} diff --git a/src/DesignTokens/src/Token/BorderToken.php b/src/DesignTokens/src/Token/BorderToken.php new file mode 100644 index 00000000000..9bb12ee610b --- /dev/null +++ b/src/DesignTokens/src/Token/BorderToken.php @@ -0,0 +1,35 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\UX\DesignTokens\Token; + +/** + * @extends AbstractToken> + * + * @author Simon André + */ +final class BorderToken extends AbstractToken +{ + public function getType(): string + { + return 'border'; + } + + public function __toString(): string + { + return \sprintf( + '%s %s %s', + self::member('dimension', $this->value['width'] ?? null), + self::member('strokeStyle', $this->value['style'] ?? null), + self::member('color', $this->value['color'] ?? null), + ); + } +} diff --git a/src/DesignTokens/src/Token/ColorToken.php b/src/DesignTokens/src/Token/ColorToken.php new file mode 100644 index 00000000000..32794710265 --- /dev/null +++ b/src/DesignTokens/src/Token/ColorToken.php @@ -0,0 +1,32 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\UX\DesignTokens\Token; + +use Symfony\UX\DesignTokens\Token\Css\CssValue; + +/** + * @extends AbstractToken> + * + * @author Simon André + */ +final class ColorToken extends AbstractToken +{ + public function getType(): string + { + return 'color'; + } + + public function __toString(): string + { + return CssValue::stringify($this->value); + } +} diff --git a/src/DesignTokens/src/Token/Css/CssValue.php b/src/DesignTokens/src/Token/Css/CssValue.php new file mode 100644 index 00000000000..063d09c3ba1 --- /dev/null +++ b/src/DesignTokens/src/Token/Css/CssValue.php @@ -0,0 +1,139 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\UX\DesignTokens\Token\Css; + +/** + * @author Simon André + * + * @internal + */ +final class CssValue +{ + public static function stringify(mixed $value): string + { + if (\is_array($value)) { + if (\array_key_exists('value', $value) && \array_key_exists('unit', $value)) { + $numeric = $value['value']; + $unit = $value['unit']; + if ((\is_int($numeric) || \is_float($numeric)) && \is_string($unit)) { + return self::number($numeric).$unit; + } + } + + if (\array_key_exists('colorSpace', $value) && \array_key_exists('components', $value)) { + return self::color($value); + } + + return implode(', ', array_map(self::stringify(...), array_values($value))); + } + + if (\is_bool($value)) { + return $value ? 'true' : 'false'; + } + + if (\is_int($value) || \is_float($value)) { + return self::number($value); + } + + return \is_string($value) ? $value : ''; + } + + /** PHP writes 0.00001 as "1.0E-5", which CSS rejects. */ + public static function number(int|float $number): string + { + $string = (string) $number; + if (\is_int($number) || !preg_match('/^(-?)(\d+)(?:\.(\d+))?E([+-]\d+)$/', $string, $parts)) { + return $string; + } + + [, $sign, $integer, $fraction, $exponent] = $parts; + $digits = $integer.$fraction; + $point = \strlen($integer) + (int) $exponent; + if ($point <= 0) { + return $sign.'0.'.str_repeat('0', -$point).rtrim($digits, '0'); + } + if ($point >= \strlen($digits)) { + return $sign.$digits.str_repeat('0', $point - \strlen($digits)); + } + + return $sign.substr($digits, 0, $point).'.'.rtrim(substr($digits, $point), '0'); + } + + /** Control characters are escaped: a raw CR or FF would end the string. */ + public static function string(string $value): string + { + $escaped = preg_replace_callback( + '/[\\\\"\x00-\x1F\x7F]/', + static fn (array $match): string => match ($match[0]) { + '\\', '"' => '\\'.$match[0], + default => \sprintf('\\%X ', \ord($match[0])), + }, + $value, + ); + + return '"'.$escaped.'"'; + } + + /** @param array $value */ + private static function color(array $value): string + { + $space = $value['colorSpace']; + $componentsValue = $value['components']; + \assert(\is_string($space)); + \assert(\is_array($componentsValue)); + if (!array_is_list($componentsValue)) { + return \sprintf('color(%s %s)', $space, self::stringify($componentsValue)); + } + /** @var list $componentsValue */ + $components = match ($space) { + 'hsl', 'hwb' => self::percentageComponents($componentsValue, [1, 2]), + 'lab', 'lch' => self::percentageComponents($componentsValue, [0]), + 'oklab', 'oklch' => self::okPercentageComponents($componentsValue), + default => implode(' ', array_map(self::stringify(...), $componentsValue)), + }; + $alpha = isset($value['alpha']) && 1 != $value['alpha'] ? ' / '.self::stringify($value['alpha']) : ''; + + return match ($space) { + 'srgb', 'srgb-linear', 'display-p3', 'a98-rgb', 'prophoto-rgb', 'rec2020', 'xyz-d65', 'xyz-d50' => \sprintf('color(%s %s%s)', $space, $components, $alpha), + 'hsl' => \sprintf('hsl(%s%s)', $components, $alpha), + 'hwb' => \sprintf('hwb(%s%s)', $components, $alpha), + default => \sprintf('%s(%s%s)', $space, $components, $alpha), + }; + } + + /** + * @param list $components + * @param list $percentIndexes + */ + private static function percentageComponents(array $components, array $percentIndexes): string + { + return implode(' ', array_map( + static fn (mixed $component, int $index): string => \in_array($index, $percentIndexes, true) && is_numeric($component) + ? self::number(0 + $component).'%' + : self::stringify($component), + $components, + array_keys($components), + )); + } + + /** @param list $components */ + private static function okPercentageComponents(array $components): string + { + return implode(' ', array_map( + static fn (mixed $component, int $index): string => 0 === $index && is_numeric($component) + ? self::number(100 * $component).'%' + : self::stringify($component), + $components, + array_keys($components), + )); + } +} diff --git a/src/DesignTokens/src/Token/CubicBezierToken.php b/src/DesignTokens/src/Token/CubicBezierToken.php new file mode 100644 index 00000000000..0eb565bee20 --- /dev/null +++ b/src/DesignTokens/src/Token/CubicBezierToken.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\Token; + +use Symfony\UX\DesignTokens\Exception\InvalidArgumentException; +use Symfony\UX\DesignTokens\Token\Css\CssValue; + +/** + * @extends AbstractToken> + * + * @author Simon André + */ +final class CubicBezierToken extends AbstractToken +{ + /** + * @param array $extensions + * + * @throws InvalidArgumentException when the value is not four numbers + */ + public function __construct( + mixed $value, + ?string $description = null, + array $extensions = [], + bool|string|null $deprecated = null, + ) { + if (!\is_array($value) || !array_is_list($value) || 4 !== \count($value)) { + throw new InvalidArgumentException('A cubicBezier token value must be a list of four numbers.'); + } + foreach ($value as $number) { + if (!\is_int($number) && (!\is_float($number) || !is_finite($number))) { + throw new InvalidArgumentException('A cubicBezier token value must be a list of four numbers.'); + } + } + + parent::__construct($value, $description, $extensions, $deprecated); + } + + public function getType(): string + { + return 'cubicBezier'; + } + + public function __toString(): string + { + return \sprintf('cubic-bezier(%s)', implode(', ', array_map(CssValue::number(...), $this->value))); + } +} diff --git a/src/DesignTokens/src/Token/DimensionToken.php b/src/DesignTokens/src/Token/DimensionToken.php new file mode 100644 index 00000000000..9d63f7aa510 --- /dev/null +++ b/src/DesignTokens/src/Token/DimensionToken.php @@ -0,0 +1,34 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\UX\DesignTokens\Token; + +use Symfony\UX\DesignTokens\Token\Css\CssValue; + +/** + * A DTCG dimension: a number with a px or rem unit (Format 8.2). + * + * @extends AbstractToken + * + * @author Simon André + */ +final class DimensionToken extends AbstractToken +{ + public function getType(): string + { + return 'dimension'; + } + + public function __toString(): string + { + return CssValue::stringify($this->value); + } +} diff --git a/src/DesignTokens/src/Token/DurationToken.php b/src/DesignTokens/src/Token/DurationToken.php new file mode 100644 index 00000000000..d34adbc1179 --- /dev/null +++ b/src/DesignTokens/src/Token/DurationToken.php @@ -0,0 +1,32 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\UX\DesignTokens\Token; + +use Symfony\UX\DesignTokens\Token\Css\CssValue; + +/** + * @extends AbstractToken + * + * @author Simon André + */ +final class DurationToken extends AbstractToken +{ + public function getType(): string + { + return 'duration'; + } + + public function __toString(): string + { + return CssValue::stringify($this->value); + } +} diff --git a/src/DesignTokens/src/Token/FontFamilyToken.php b/src/DesignTokens/src/Token/FontFamilyToken.php new file mode 100644 index 00000000000..04d1f8d5e4b --- /dev/null +++ b/src/DesignTokens/src/Token/FontFamilyToken.php @@ -0,0 +1,47 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\UX\DesignTokens\Token; + +use Symfony\UX\DesignTokens\Token\Css\CssValue; + +/** + * @extends AbstractToken> + * + * @author Simon André + */ +final class FontFamilyToken extends AbstractToken +{ + private const CSS_WIDE_KEYWORDS = ['inherit', 'initial', 'unset', 'revert', 'revert-layer', 'default']; + + public function getType(): string + { + return 'fontFamily'; + } + + public function __toString(): string + { + $names = \is_array($this->value) ? $this->value : [$this->value]; + + return implode(', ', array_map(self::family(...), $names)); + } + + private static function family(string $name): string + { + $words = explode(' ', $name); + $identifiers = array_filter($words, static fn (string $word): bool => 1 === preg_match('/^-?[A-Za-z_][A-Za-z0-9_-]*$/D', $word)); + if (\count($identifiers) === \count($words) && !\in_array(strtolower($name), self::CSS_WIDE_KEYWORDS, true)) { + return $name; + } + + return CssValue::string($name); + } +} diff --git a/src/DesignTokens/src/Token/FontWeightToken.php b/src/DesignTokens/src/Token/FontWeightToken.php new file mode 100644 index 00000000000..39bea2147e7 --- /dev/null +++ b/src/DesignTokens/src/Token/FontWeightToken.php @@ -0,0 +1,49 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\UX\DesignTokens\Token; + +/** + * A DTCG font weight, printed as a number: CSS knows two of the DTCG keywords. + * + * @extends AbstractToken + * + * @author Simon André + */ +final class FontWeightToken extends AbstractToken +{ + /** The DTCG keyword aliases, per Format 8.4. */ + private const WEIGHTS = [ + 'thin' => 100, 'hairline' => 100, + 'extra-light' => 200, 'ultra-light' => 200, + 'light' => 300, + 'normal' => 400, 'regular' => 400, 'book' => 400, + 'medium' => 500, + 'semi-bold' => 600, 'demi-bold' => 600, + 'bold' => 700, + 'extra-bold' => 800, 'ultra-bold' => 800, + 'black' => 900, 'heavy' => 900, 'extra-black' => 950, 'ultra-black' => 950, + ]; + + public function getType(): string + { + return 'fontWeight'; + } + + public function __toString(): string + { + if (\is_string($this->value) && isset(self::WEIGHTS[strtolower($this->value)])) { + return (string) self::WEIGHTS[strtolower($this->value)]; + } + + return (string) $this->value; + } +} diff --git a/src/DesignTokens/src/Token/GradientToken.php b/src/DesignTokens/src/Token/GradientToken.php new file mode 100644 index 00000000000..704cbae3d0a --- /dev/null +++ b/src/DesignTokens/src/Token/GradientToken.php @@ -0,0 +1,74 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\UX\DesignTokens\Token; + +use Symfony\UX\DesignTokens\Token\Css\CssValue; + +/** + * @extends AbstractToken>> + * + * @author Simon André + */ +final class GradientToken extends AbstractToken +{ + public function getType(): string + { + return 'gradient'; + } + + /** + * Positions outside [0, 1] are clamped, as DTCG 2025.10 requires. + * + * @param list> $value + * @param array $extensions + */ + public function __construct( + array $value, + ?string $description = null, + array $extensions = [], + bool|string|null $deprecated = null, + ) { + foreach ($value as $index => $stop) { + $position = $stop['position'] ?? null; + if (\is_int($position) || \is_float($position)) { + $value[$index]['position'] = min(1, max(0, $position)); + } + } + + parent::__construct($value, $description, $extensions, $deprecated); + } + + public function __toString(): string + { + /** @var list> $value */ + $value = $this->value; + $stops = array_map(static fn (array $stop): string => \sprintf( + '%s %s', + CssValue::stringify($stop['color'] ?? null), + self::position($stop['position'] ?? null), + ), $value); + + return 'linear-gradient('.implode(', ', $stops).')'; + } + + /** CSS wants a percentage: a bare number is not a valid stop position. */ + private static function position(mixed $position): string + { + if (!\is_int($position) && !\is_float($position)) { + return CssValue::stringify($position); + } + + $percentage = rtrim(rtrim(number_format($position * 100, 4, '.', ''), '0'), '.'); + + return ('' === $percentage ? '0' : $percentage).'%'; + } +} diff --git a/src/DesignTokens/src/Token/NumberToken.php b/src/DesignTokens/src/Token/NumberToken.php new file mode 100644 index 00000000000..3d7137f96ac --- /dev/null +++ b/src/DesignTokens/src/Token/NumberToken.php @@ -0,0 +1,32 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\UX\DesignTokens\Token; + +use Symfony\UX\DesignTokens\Token\Css\CssValue; + +/** + * @extends AbstractToken + * + * @author Simon André + */ +final class NumberToken extends AbstractToken +{ + public function getType(): string + { + return 'number'; + } + + public function __toString(): string + { + return CssValue::number($this->value); + } +} diff --git a/src/DesignTokens/src/Token/ShadowToken.php b/src/DesignTokens/src/Token/ShadowToken.php new file mode 100644 index 00000000000..0603c756f91 --- /dev/null +++ b/src/DesignTokens/src/Token/ShadowToken.php @@ -0,0 +1,44 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\UX\DesignTokens\Token; + +/** + * @extends AbstractToken|list>> + * + * @author Simon André + */ +final class ShadowToken extends AbstractToken +{ + public function getType(): string + { + return 'shadow'; + } + + public function __toString(): string + { + /** @var list> $shadows */ + $shadows = isset($this->value['color']) ? [$this->value] : array_values((array) $this->value); + + return implode(', ', array_map( + static fn (array $s): string => \sprintf( + '%s%s %s %s %s %s', + true === ($s['inset'] ?? false) ? 'inset ' : '', + self::member('dimension', $s['offsetX'] ?? null, '0px'), + self::member('dimension', $s['offsetY'] ?? null, '0px'), + self::member('dimension', $s['blur'] ?? null, '0px'), + self::member('dimension', $s['spread'] ?? null, '0px'), + self::member('color', $s['color'] ?? null, '#000'), + ), + $shadows, + )); + } +} diff --git a/src/DesignTokens/src/Token/StrokeStyleToken.php b/src/DesignTokens/src/Token/StrokeStyleToken.php new file mode 100644 index 00000000000..74e8ebb3375 --- /dev/null +++ b/src/DesignTokens/src/Token/StrokeStyleToken.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\Token; + +use Symfony\UX\DesignTokens\Exception\InvalidArgumentException; + +/** + * A DTCG stroke style; a dash definition prints as `dashed`. + * + * @extends AbstractToken> + * + * @author Simon André + */ +final class StrokeStyleToken extends AbstractToken +{ + /** + * @param array $extensions + * + * @throws InvalidArgumentException when the value is neither a keyword nor a dash definition + */ + public function __construct( + mixed $value, + ?string $description = null, + array $extensions = [], + bool|string|null $deprecated = null, + ) { + if (!\is_string($value) && !\is_array($value)) { + throw new InvalidArgumentException('A strokeStyle token value must be a keyword or a dash definition.'); + } + + parent::__construct($value, $description, $extensions, $deprecated); + } + + public function getType(): string + { + return 'strokeStyle'; + } + + public function __toString(): string + { + return \is_string($this->value) ? $this->value : 'dashed'; + } +} diff --git a/src/DesignTokens/src/Token/TokenFactory.php b/src/DesignTokens/src/Token/TokenFactory.php new file mode 100644 index 00000000000..463c1f00332 --- /dev/null +++ b/src/DesignTokens/src/Token/TokenFactory.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\Token; + +use Symfony\UX\DesignTokens\Exception\InvalidArgumentException; +use Symfony\UX\DesignTokens\Token\Css\CssValue; + +/** + * @author Simon André + * + * @internal + */ +final class TokenFactory +{ + /** + * @param array $extensions + * + * @throws InvalidArgumentException on an unknown DTCG token type + */ + public static function create( + string $type, + mixed $value, + ?string $description = null, + array $extensions = [], + bool|string|null $deprecated = null, + ): TokenInterface { + if ([] !== $extensions) { + $extensions = array_combine(array_map(strval(...), array_keys($extensions)), $extensions); + } + + return match ($type) { + 'color' => new ColorToken($value, $description, $extensions, $deprecated), + 'dimension' => new DimensionToken($value, $description, $extensions, $deprecated), + 'number' => new NumberToken($value, $description, $extensions, $deprecated), + 'duration' => new DurationToken($value, $description, $extensions, $deprecated), + 'fontWeight' => new FontWeightToken($value, $description, $extensions, $deprecated), + 'fontFamily' => new FontFamilyToken($value, $description, $extensions, $deprecated), + 'cubicBezier' => new CubicBezierToken($value, $description, $extensions, $deprecated), + 'strokeStyle' => new StrokeStyleToken($value, $description, $extensions, $deprecated), + 'gradient' => new GradientToken(self::stops($value), $description, $extensions, $deprecated), + 'typography' => new TypographyToken($value, $description, $extensions, $deprecated), + 'border' => new BorderToken($value, $description, $extensions, $deprecated), + 'shadow' => new ShadowToken($value, $description, $extensions, $deprecated), + 'transition' => new TransitionToken($value, $description, $extensions, $deprecated), + default => throw new InvalidArgumentException(\sprintf('Unknown DTCG token type "%s".', $type)), + }; + } + + /** The declared type of a member decides how it is written, inline or through an alias. */ + public static function project(string $type, mixed $value, string $fallback = ''): string + { + if (null === $value) { + return $fallback; + } + + try { + $string = (string) self::create($type, $value); + } catch (\Throwable) { + $string = CssValue::stringify($value); + } + + return '' !== $string ? $string : $fallback; + } + + /** + * @return list> + */ + private static function stops(mixed $value): array + { + if (!\is_array($value) || !array_is_list($value)) { + throw new InvalidArgumentException('A gradient token value must be a list of color stops.'); + } + + $stops = []; + foreach ($value as $stop) { + if (!\is_array($stop)) { + throw new InvalidArgumentException('A gradient color stop must be an object.'); + } + $stops[] = array_combine(array_map(strval(...), array_keys($stop)), $stop); + } + + return $stops; + } +} diff --git a/src/DesignTokens/src/Token/TokenInterface.php b/src/DesignTokens/src/Token/TokenInterface.php new file mode 100644 index 00000000000..67dd9a8a04b --- /dev/null +++ b/src/DesignTokens/src/Token/TokenInterface.php @@ -0,0 +1,39 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\UX\DesignTokens\Token; + +/** + * A resolved design token: getValue() holds the DTCG value, (string) its CSS representation. + * + * @author Simon André + */ +interface TokenInterface extends \Stringable +{ + public function getType(): string; + + /** The DTCG value, with every alias and reference resolved. */ + public function getValue(): mixed; + + public function getDescription(): ?string; + + /** Whether the token or its closest group declares `$deprecated`. */ + public function isDeprecated(): bool; + + public function getDeprecationMessage(): ?string; + + /** + * The token's own `$extensions`, not inherited from its groups. + * + * @return array + */ + public function getExtensions(): array; +} diff --git a/src/DesignTokens/src/Token/TransitionToken.php b/src/DesignTokens/src/Token/TransitionToken.php new file mode 100644 index 00000000000..c16802221af --- /dev/null +++ b/src/DesignTokens/src/Token/TransitionToken.php @@ -0,0 +1,35 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\UX\DesignTokens\Token; + +/** + * @extends AbstractToken> + * + * @author Simon André + */ +final class TransitionToken extends AbstractToken +{ + public function getType(): string + { + return 'transition'; + } + + public function __toString(): string + { + return \sprintf( + '%s %s %s', + self::member('duration', $this->value['duration'] ?? null), + self::member('cubicBezier', $this->value['timingFunction'] ?? null), + self::member('duration', $this->value['delay'] ?? null), + ); + } +} diff --git a/src/DesignTokens/src/Token/TypographyToken.php b/src/DesignTokens/src/Token/TypographyToken.php new file mode 100644 index 00000000000..e2a737f056f --- /dev/null +++ b/src/DesignTokens/src/Token/TypographyToken.php @@ -0,0 +1,36 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\UX\DesignTokens\Token; + +/** + * @extends AbstractToken> + * + * @author Simon André + */ +final class TypographyToken extends AbstractToken +{ + public function getType(): string + { + return 'typography'; + } + + public function __toString(): string + { + return \sprintf( + '%s %s/%s %s', + self::member('fontWeight', $this->value['fontWeight'] ?? null), + self::member('dimension', $this->value['fontSize'] ?? null), + self::member('number', $this->value['lineHeight'] ?? null), + self::member('fontFamily', $this->value['fontFamily'] ?? null), + ); + } +} diff --git a/src/DesignTokens/src/TokenPath.php b/src/DesignTokens/src/TokenPath.php new file mode 100644 index 00000000000..f8451e0eb3c --- /dev/null +++ b/src/DesignTokens/src/TokenPath.php @@ -0,0 +1,48 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\UX\DesignTokens; + +use Symfony\UX\DesignTokens\Exception\InvalidArgumentException; + +/** + * @author Simon André + */ +final class TokenPath +{ + public static function toCssVariable(string $path, ?string $prefix = null): string + { + $name = preg_replace('/[^\p{L}\p{N}_-]+/u', '-', str_replace('.', '-', $path)); + $name = trim($name ?? '', '-'); + + if (null !== $prefix) { + self::validateCssPrefix($prefix); + $name = $prefix.'-'.('' !== $name ? $name : 'token'); + } + + return '--'.('' !== $name ? $name : 'token'); + } + + public static function toCssReference(string $path, ?string $fallback = null, ?string $prefix = null): string + { + $variable = self::toCssVariable($path, $prefix); + + return null === $fallback ? "var({$variable})" : "var({$variable}, {$fallback})"; + } + + /** @internal */ + public static function validateCssPrefix(string $prefix): void + { + if (!preg_match('/^[A-Za-z_][A-Za-z0-9_-]*$/', $prefix)) { + throw new InvalidArgumentException(\sprintf('CSS prefix "%s" must start with a letter or underscore and contain only letters, digits, underscores, or hyphens.', $prefix)); + } + } +} diff --git a/src/DesignTokens/src/TokenRegistry.php b/src/DesignTokens/src/TokenRegistry.php new file mode 100644 index 00000000000..5495b5740ee --- /dev/null +++ b/src/DesignTokens/src/TokenRegistry.php @@ -0,0 +1,109 @@ + + * + * 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\Contracts\Service\ResetInterface; +use Symfony\UX\DesignTokens\Exception\TokenNotFoundException; +use Symfony\UX\DesignTokens\Resolver\ConfiguredTokenResolver; +use Symfony\UX\DesignTokens\Resolver\ResolverInputs; +use Symfony\UX\DesignTokens\Resolver\TokenResolverInterface; +use Symfony\UX\DesignTokens\Token\TokenInterface; + +/** + * Keeps each resolution until reset() is called. + * + * @author Simon André + */ +final class TokenRegistry implements TokenRegistryInterface, ResetInterface +{ + /** @var array> resolved trees keyed by their inputs */ + private array $trees = []; + + /** + * @param array $defaultInputs Resolver inputs used when a call selects none + */ + public function __construct( + private readonly TokenResolverInterface $resolver = new ConfiguredTokenResolver(), + private readonly array $defaultInputs = [], + ) { + } + + public function get(string $path, array $inputs = []): TokenInterface + { + return $this->tokenAt($this->all($inputs), $path); + } + + public function find(string $path, array $inputs = []): ?TokenInterface + { + try { + return $this->tokenAt($this->all($inputs), $path); + } catch (TokenNotFoundException) { + return null; + } + } + + public function has(string $path, array $inputs = []): bool + { + return null !== $this->find($path, $inputs); + } + + public function all(array $inputs = []): array + { + $inputs = ResolverInputs::merge($this->defaultInputs, $inputs); + ksort($inputs); + + return $this->trees[ResolverInputs::key($inputs)] ??= $this->resolver->resolve($inputs)->getTokens(); + } + + public function flatten(array $inputs = []): array + { + return TokenTree::flatten($this->all($inputs)); + } + + public function getPermutations(): array + { + return $this->resolver->getPermutations(); + } + + public function getModifiers(): array + { + return $this->resolver->getModifiers(); + } + + public function reset(): void + { + $this->trees = []; + } + + /** + * @param array $tokens + */ + private function tokenAt(array $tokens, string $path): TokenInterface + { + $current = $tokens; + foreach (explode('.', $path) as $part) { + if (str_starts_with($part, '$') && '$root' !== $part) { + throw new TokenNotFoundException($path, \sprintf('"%s" names the DTCG property "%s", not a token.', $path, $part)); + } + if (!\is_array($current) || !\array_key_exists($part, $current)) { + throw new TokenNotFoundException($path); + } + $current = $current[$part]; + } + + if (!$current instanceof TokenInterface) { + throw new TokenNotFoundException($path, \sprintf('"%s" is a token group, not a token.', $path)); + } + + return $current; + } +} diff --git a/src/DesignTokens/src/TokenRegistryInterface.php b/src/DesignTokens/src/TokenRegistryInterface.php new file mode 100644 index 00000000000..e9e78e9a2fb --- /dev/null +++ b/src/DesignTokens/src/TokenRegistryInterface.php @@ -0,0 +1,89 @@ + + * + * 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\UX\DesignTokens\Exception\LogicException; +use Symfony\UX\DesignTokens\Exception\ResolverException; +use Symfony\UX\DesignTokens\Exception\TokenNotFoundException; +use Symfony\UX\DesignTokens\Token\TokenInterface; + +/** + * @author Simon André + */ +interface TokenRegistryInterface +{ + /** + * Inputs select Resolver contexts for this call only; names are case-insensitive. + * + * @param string $path e.g. `'color.brand.primary'` + * @param array $inputs + * + * @throws TokenNotFoundException when the path does not resolve to a token + * @throws ResolverException when an input names an unknown modifier or context + * @throws LogicException when inputs apply and no Resolver document is configured + */ + public function get(string $path, array $inputs = []): TokenInterface; + + /** + * Returns the token, or null when the path is missing or names a group. + * + * @param array $inputs + * + * @throws ResolverException when an input names an unknown modifier or context + * @throws LogicException when inputs apply and no Resolver document is configured + */ + public function find(string $path, array $inputs = []): ?TokenInterface; + + /** + * Whether the path names a token, not a group. + * + * @param array $inputs + * + * @throws ResolverException when an input names an unknown modifier or context + * @throws LogicException when inputs apply and no Resolver document is configured + */ + public function has(string $path, array $inputs = []): bool; + + /** + * @param array $inputs + * + * @return array + * + * @throws ResolverException when an input names an unknown modifier or context + * @throws LogicException when inputs apply and no Resolver document is configured + */ + public function all(array $inputs = []): array; + + /** + * @param array $inputs + * + * @return array + * + * @throws ResolverException when an input names an unknown modifier or context + * @throws LogicException when inputs apply and no Resolver document is configured + */ + public function flatten(array $inputs = []): array; + + /** + * Every combination of inputs the Resolver document can produce; empty without one. + * + * @return list> + */ + public function getPermutations(): array; + + /** + * Every modifier, with its contexts in authored order and its default; empty without a Resolver document. + * + * @return array, default: string|null}> + */ + public function getModifiers(): array; +} diff --git a/src/DesignTokens/src/TokenTree.php b/src/DesignTokens/src/TokenTree.php new file mode 100644 index 00000000000..edf20594d4e --- /dev/null +++ b/src/DesignTokens/src/TokenTree.php @@ -0,0 +1,143 @@ + + * + * 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\UX\DesignTokens\Exception\UnexpectedValueException; +use Symfony\UX\DesignTokens\Token\TokenFactory; +use Symfony\UX\DesignTokens\Token\TokenInterface; + +/** + * @author Simon André + * + * @internal + */ +final class TokenTree +{ + /** + * @param array $tokens + * + * @return array + */ + public static function flatten(array $tokens): array + { + $flat = []; + self::append($flat, $tokens, ''); + + return $flat; + } + + /** + * @param array $tokens + * + * @return array + */ + public static function export(array $tokens): array + { + $data = []; + foreach ($tokens as $name => $value) { + if (self::isGroupMetadata((string) $name)) { + $data[$name] = $value; + + continue; + } + if ($value instanceof TokenInterface) { + $node = ['$type' => $value->getType(), '$value' => $value->getValue()]; + if (null !== $value->getDescription()) { + $node['$description'] = $value->getDescription(); + } + if ([] !== $value->getExtensions()) { + $node['$extensions'] = $value->getExtensions(); + } + if ($value->isDeprecated()) { + $node['$deprecated'] = $value->getDeprecationMessage() ?? true; + } + $data[$name] = $node; + } elseif (\is_array($value)) { + $data[$name] = self::export($value); + } + } + + return $data; + } + + /** + * @param array $data + * + * @return array + */ + public static function hydrate(array $data): array + { + $tokens = []; + foreach ($data as $name => $node) { + if (self::isGroupMetadata((string) $name)) { + $tokens[$name] = $node; + + continue; + } + if (!\is_array($node)) { + continue; + } + if (!\array_key_exists('$value', $node)) { + $tokens[$name] = self::hydrate($node); + + continue; + } + + // Cache data is checked, not asserted: assertions are off in production. + $type = $node['$type'] ?? null; + if (!\is_string($type)) { + throw new UnexpectedValueException(\sprintf('Exported design token "%s" must carry a string $type.', $name)); + } + $description = $node['$description'] ?? null; + if (null !== $description && !\is_string($description)) { + throw new UnexpectedValueException(\sprintf('Exported design token "%s" must carry a string $description.', $name)); + } + $extensions = $node['$extensions'] ?? []; + if (!\is_array($extensions)) { + throw new UnexpectedValueException(\sprintf('Exported design token "%s" must carry an object of $extensions.', $name)); + } + $deprecated = $node['$deprecated'] ?? null; + if (null !== $deprecated && !\is_bool($deprecated) && !\is_string($deprecated)) { + throw new UnexpectedValueException(\sprintf('Exported design token "%s" must carry a boolean or string $deprecated.', $name)); + } + + $tokens[$name] = TokenFactory::create($type, $node['$value'], $description, $extensions, $deprecated); + } + + return $tokens; + } + + private static function isGroupMetadata(string $name): bool + { + return '$description' === $name || '$extensions' === $name; + } + + /** + * @param array $flat + * @param array $tokens + */ + private static function append(array &$flat, array $tokens, string $prefix): void + { + foreach ($tokens as $name => $value) { + if (self::isGroupMetadata((string) $name)) { + continue; + } + $path = '' === $prefix ? (string) $name : $prefix.'.'.$name; + + if ($value instanceof TokenInterface) { + $flat[$path] = $value; + } elseif (\is_array($value)) { + self::append($flat, $value, $path); + } + } + } +} 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/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/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/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/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/Resolver/ConfiguredTokenResolverCacheTest.php b/src/DesignTokens/tests/Unit/Resolver/ConfiguredTokenResolverCacheTest.php new file mode 100644 index 00000000000..00cff6c7d26 --- /dev/null +++ b/src/DesignTokens/tests/Unit/Resolver/ConfiguredTokenResolverCacheTest.php @@ -0,0 +1,228 @@ + + * + * 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\Resolver; + +use PHPUnit\Framework\Attributes\CoversClass; +use PHPUnit\Framework\Attributes\DataProvider; +use PHPUnit\Framework\TestCase; +use Symfony\Component\Cache\Adapter\ArrayAdapter; +use Symfony\Component\Filesystem\Filesystem; +use Symfony\UX\DesignTokens\Exception\ResolverException; +use Symfony\UX\DesignTokens\Exception\UnexpectedValueException; +use Symfony\UX\DesignTokens\Resolver\ArrayDocumentLoader; +use Symfony\UX\DesignTokens\Resolver\ConfiguredTokenResolver; +use Symfony\UX\DesignTokens\Resolver\DocumentLoaderInterface; +use Symfony\UX\DesignTokens\Resolver\JsonDocumentLoader; +use Symfony\UX\DesignTokens\Tests\Fixtures\TemporaryDirectory; + +#[CoversClass(ConfiguredTokenResolver::class)] +final class ConfiguredTokenResolverCacheTest extends TestCase +{ + private TemporaryDirectory $directory; + private ArrayAdapter $cache; + + protected function setUp(): void + { + $this->directory = new TemporaryDirectory(); + $this->cache = new ArrayAdapter(deepClone: false); + } + + protected function tearDown(): void + { + $this->directory->remove(); + } + + public function testResolvesEveryTimeWithoutACache(): void + { + $loader = self::countingLoader(); + $resolver = new ConfiguredTokenResolver($loader, ['theme.tokens.json']); + + $resolver->resolve([]); + $resolver->resolve([]); + + self::assertSame(2, $loader->reads('theme.tokens.json')); + } + + public function testReusesTheCachedEntry(): void + { + $loader = self::countingLoader(); + + $first = new ConfiguredTokenResolver($loader, ['theme.tokens.json'], cache: $this->cache)->resolve([]); + $second = new ConfiguredTokenResolver($loader, ['theme.tokens.json'], cache: $this->cache)->resolve([]); + + self::assertSame(1, $loader->reads('theme.tokens.json')); + self::assertSame(1, $first->getTokens()['theme']->getValue()); + self::assertSame(1, $second->getTokens()['theme']->getValue()); + self::assertSame(['theme.tokens.json'], $second->getDocuments()); + } + + /** @param list> $inputSets */ + #[DataProvider('inputSets')] + public function testResolvesOncePerDistinctInputSet(array $inputSets, int $resolutions): void + { + $loader = self::countingLoader(); + $resolver = new ConfiguredTokenResolver($loader, resolverPath: 'theme.resolver.json', cache: $this->cache); + + foreach ($inputSets as $inputs) { + $resolver->resolve($inputs); + } + + self::assertSame($resolutions, $loader->reads('theme.resolver.json')); + } + + /** @return iterable>, int}> */ + public static function inputSets(): iterable + { + yield 'each input set apart, whatever the key order' => [[['scheme' => 'dark', 'brand' => 'sky'], ['brand' => 'sky', 'scheme' => 'dark'], ['scheme' => 'light']], 2]; + yield 'inputs spelled with another case once' => [[['scheme' => 'dark'], ['Scheme' => 'dark'], ['scheme' => 'DARK']], 1]; + } + + public function testKeysAnInputThatIsNotUtf8(): void + { + $resolver = new ConfiguredTokenResolver(self::countingLoader(), resolverPath: 'theme.resolver.json', cache: $this->cache); + + $this->expectException(ResolverException::class); + $this->expectExceptionMessage('Invalid context'); + + $resolver->resolve(['scheme' => "\xff"]); + } + + public function testKeepsTwoConfigurationsApart(): void + { + $loader = self::countingLoader(); + + new ConfiguredTokenResolver($loader, ['theme.tokens.json'], cache: $this->cache)->resolve([]); + $other = new ConfiguredTokenResolver($loader, ['theme.tokens.json', 'extra.tokens.json'], cache: $this->cache)->resolve([]); + + self::assertSame(2, $loader->reads('theme.tokens.json')); + self::assertSame(2, $other->getTokens()['extra']->getValue()); + } + + public function testStoresAnExportedTreeWithItsDocuments(): void + { + $path = $this->write('theme.tokens.json', 1); + + $this->fileResolver($path, debug: true)->resolve([]); + + $values = $this->cache->getValues(); + $data = reset($values); + + self::assertIsArray($data); + self::assertSame('number', $data['tokens']['theme']['$type']); + self::assertContains($path, $data['documents']); + self::assertNotSame('', $data['signature']); + } + + public function testResolvesAgainWhenADocumentChangesInDebugMode(): void + { + $path = $this->write('theme.tokens.json', 1); + + self::assertSame(1, $this->fileResolver($path, debug: true)->resolve([])->getTokens()['theme']->getValue()); + + $this->write('theme.tokens.json', 10); + new Filesystem()->touch($path, time() + 5); + + self::assertSame(10, $this->fileResolver($path, debug: true)->resolve([])->getTokens()['theme']->getValue()); + } + + public function testResolvesAgainWhenAReferencedDocumentChangesInDebugMode(): void + { + $entry = $this->directory->write('base.tokens.json', [ + 'space' => ['$type' => 'dimension', 'md' => ['$ref' => 'palette.tokens.json#/scale/md']], + ]); + $writePalette = fn (int $value): string => $this->directory->write('palette.tokens.json', [ + 'scale' => ['$type' => 'dimension', 'md' => ['$value' => ['value' => $value, 'unit' => 'px']]], + ]); + $writePalette(8); + + self::assertSame('8px', (string) $this->fileResolver($entry, debug: true)->resolve([])->getTokens()['space']['md']); + + new Filesystem()->touch($writePalette(16), time() + 5); + + self::assertSame('16px', (string) $this->fileResolver($entry, debug: true)->resolve([])->getTokens()['space']['md']); + } + + public function testKeepsTheCachedEntryOutsideDebugMode(): void + { + $path = $this->write('theme.tokens.json', 1); + + self::assertSame(1, $this->fileResolver($path)->resolve([])->getTokens()['theme']->getValue()); + + $this->write('theme.tokens.json', 10); + new Filesystem()->touch($path, time() + 5); + + self::assertSame(1, $this->fileResolver($path)->resolve([])->getTokens()['theme']->getValue()); + } + + public function testKeepsServingTheEntryOnceItsSourceIsGone(): void + { + $path = $this->write('theme.tokens.json', 1); + $this->fileResolver($path)->resolve([]); + + new Filesystem()->remove($path); + + self::assertSame(1, $this->fileResolver($path)->resolve([])->getTokens()['theme']->getValue()); + } + + public function testRejectsAnEntryItCannotRead(): void + { + $resolver = new ConfiguredTokenResolver(self::countingLoader(), ['theme.tokens.json'], cache: $this->cache); + $resolver->resolve([]); + + foreach (array_keys($this->cache->getValues()) as $key) { + $item = $this->cache->getItem($key); + $this->cache->save($item->set(['format' => 1, 'paths' => [], 'signature' => '', 'tokens' => []])); + } + + $this->expectException(UnexpectedValueException::class); + $this->expectExceptionMessage('Clear the cache to rebuild it'); + + $resolver->resolve([]); + } + + private function write(string $name, int $value): string + { + return $this->directory->write($name, ['theme' => ['$type' => 'number', '$value' => $value]]); + } + + private function fileResolver(string $path, bool $debug = false): ConfiguredTokenResolver + { + return new ConfiguredTokenResolver(new JsonDocumentLoader(), [$path], cache: $this->cache, debug: $debug); + } + + private static function countingLoader(): DocumentLoaderInterface + { + $number = static fn (int $value): array => ['$type' => 'number', '$value' => $value]; + $contexts = static fn (string ...$names): array => array_fill_keys($names, [['theme' => $number(1)]]); + + return new class(new ArrayDocumentLoader(['theme.tokens.json' => ['theme' => $number(1)], 'extra.tokens.json' => ['extra' => $number(2)], 'theme.resolver.json' => ['version' => '2025.10', 'modifiers' => ['scheme' => ['contexts' => $contexts('light', 'dark'), 'default' => 'light'], 'brand' => ['contexts' => $contexts('sky', 'ocean'), 'default' => 'sky']], 'resolutionOrder' => [['$ref' => '#/modifiers/scheme'], ['$ref' => '#/modifiers/brand']]]])) implements DocumentLoaderInterface { + /** @var array */ + private array $reads = []; + + public function __construct(private readonly DocumentLoaderInterface $inner) + { + } + + public function load(string $uri): array + { + $this->reads[$uri] = ($this->reads[$uri] ?? 0) + 1; + + return $this->inner->load($uri); + } + + public function reads(string $uri): int + { + return $this->reads[$uri] ?? 0; + } + }; + } +} diff --git a/src/DesignTokens/tests/Unit/Resolver/ConfiguredTokenResolverTest.php b/src/DesignTokens/tests/Unit/Resolver/ConfiguredTokenResolverTest.php new file mode 100644 index 00000000000..1d63ce4e7d7 --- /dev/null +++ b/src/DesignTokens/tests/Unit/Resolver/ConfiguredTokenResolverTest.php @@ -0,0 +1,248 @@ + + * + * 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\Resolver; + +use PHPUnit\Framework\Attributes\CoversClass; +use PHPUnit\Framework\Attributes\DataProvider; +use PHPUnit\Framework\TestCase; +use Symfony\Component\Filesystem\Filesystem; +use Symfony\UX\DesignTokens\Exception\InvalidArgumentException; +use Symfony\UX\DesignTokens\Exception\LogicException; +use Symfony\UX\DesignTokens\Exception\RuntimeException; +use Symfony\UX\DesignTokens\Resolver\ArrayDocumentLoader; +use Symfony\UX\DesignTokens\Resolver\ConfiguredTokenResolver; +use Symfony\UX\DesignTokens\Resolver\JsonDocumentLoader; +use Symfony\UX\DesignTokens\Tests\Fixtures\TemporaryDirectory; + +#[CoversClass(ConfiguredTokenResolver::class)] +final class ConfiguredTokenResolverTest extends TestCase +{ + public function testMergesConfiguredPathsInOrder(): void + { + $resolver = new ConfiguredTokenResolver(new ArrayDocumentLoader([ + '/tokens/a.tokens.json' => ['x' => ['$type' => 'number', '$value' => 1], 'a' => ['$type' => 'number', '$value' => 3]], + '/tokens/b.tokens.json' => ['x' => ['$type' => 'number', '$value' => 2]], + ]), ['/tokens/a.tokens.json', '/tokens/b.tokens.json']); + + $resolution = $resolver->resolve([]); + + self::assertSame(2, $resolution->getTokens()['x']->getValue()); + self::assertSame(3, $resolution->getTokens()['a']->getValue()); + self::assertSame(['/tokens/a.tokens.json', '/tokens/b.tokens.json'], \array_slice($resolution->getDocuments(), 0, 2)); + } + + public function testSelectsResolverContexts(): void + { + $resolver = self::themeResolver(); + + self::assertSame(1, $resolver->resolve([])->getTokens()['c']->getValue()); + self::assertSame(2, $resolver->resolve(['scheme' => 'dark'])->getTokens()['c']->getValue()); + self::assertSame([['scheme' => 'light'], ['scheme' => 'dark']], $resolver->getPermutations()); + self::assertContains('/tokens/theme.resolver.json', $resolver->resolve([])->getDocuments()); + } + + public function testConfiguredPathsApplyAfterTheResolverSelection(): void + { + $loader = new ArrayDocumentLoader([ + '/tokens/theme.resolver.json' => [ + 'version' => '2025.10', + 'sets' => ['base' => ['sources' => [['c' => ['$type' => 'number', '$value' => 1]]]]], + 'resolutionOrder' => [['$ref' => '#/sets/base']], + ], + '/tokens/app.tokens.json' => ['c' => ['$type' => 'number', '$value' => 5]], + ]); + + $resolver = new ConfiguredTokenResolver($loader, ['/tokens/app.tokens.json'], '/tokens/theme.resolver.json'); + + self::assertSame(5, $resolver->resolve([])->getTokens()['c']->getValue()); + } + + public function testLoadsSourcesSelectedByAResolverDocumentOnDisk(): void + { + $resolver = new ConfiguredTokenResolver( + new JsonDocumentLoader(), + resolverPath: \dirname(__DIR__, 2).'/Fixtures/dtcg/resolver-valid.resolver.json', + ); + + $tokens = $resolver->resolve(['theme' => 'dark'])->getTokens(); + + self::assertSame('16px', (string) $tokens['space']['$root']); + self::assertSame('250ms', (string) $tokens['motion']['duration']); + } + + public function testRecordsDocumentsReachedThroughReferences(): void + { + $resolver = new ConfiguredTokenResolver(new ArrayDocumentLoader([ + '/tokens/base.tokens.json' => ['space' => ['$type' => 'number', 'md' => ['$ref' => 'palette.tokens.json#/scale/md']]], + '/tokens/palette.tokens.json' => ['scale' => ['$type' => 'number', 'md' => ['$value' => 8]]], + ]), ['/tokens/base.tokens.json']); + + $resolution = $resolver->resolve([]); + + self::assertSame(8, $resolution->getTokens()['space']['md']->getValue()); + self::assertContains('/tokens/palette.tokens.json', $resolution->getDocuments()); + } + + public function testTraceNamesTheWinningAndReplacedSources(): void + { + $resolver = new ConfiguredTokenResolver(new ArrayDocumentLoader([ + '/tokens/base.tokens.json' => ['x' => ['$type' => 'number', '$value' => 1]], + '/tokens/brand.tokens.json' => ['x' => ['$type' => 'number', '$value' => 2]], + ]), ['/tokens/base.tokens.json', '/tokens/brand.tokens.json']); + + $resolution = $resolver->trace([]); + + self::assertSame(2, $resolution->getTokens()['x']->getValue()); + self::assertSame('/tokens/brand.tokens.json', $resolution->getSource('x')?->uri); + self::assertSame(['/tokens/base.tokens.json'], array_map(static fn ($source): ?string => $source->uri, $resolution->getOverrides('x'))); + self::assertSame(['/tokens/base.tokens.json', '/tokens/brand.tokens.json'], \array_slice($resolution->getDocuments(), 0, 2)); + } + + public function testRejectsInputsWithoutAResolverDocument(): void + { + $this->expectException(LogicException::class); + $this->expectExceptionMessage('no Resolver document is configured'); + + new ConfiguredTokenResolver(new ArrayDocumentLoader([]))->resolve(['scheme' => 'dark']); + } + + public function testWithoutSourcesTheTreeIsEmpty(): void + { + $resolver = new ConfiguredTokenResolver(new ArrayDocumentLoader([])); + + self::assertSame([], $resolver->resolve([])->getTokens()); + self::assertSame([], $resolver->getPermutations()); + } + + public function testRejectsAMissingConfiguredFile(): void + { + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('Design token file not found'); + + new ConfiguredTokenResolver(new JsonDocumentLoader(), ['/path/that/does/not/exist.tokens.json'])->resolve([]); + } + + public function testRejectsAnUnreadableConfiguredFile(): void + { + $directory = new TemporaryDirectory(); + $path = $directory->write('app.tokens.json', '{}'); + new Filesystem()->chmod($path, 0o000); + + try { + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('Could not read design token file'); + + new ConfiguredTokenResolver(new JsonDocumentLoader(), [$path])->resolve([]); + } finally { + $directory->remove(); + } + } + + #[DataProvider('invalidConfiguredFiles')] + public function testRejectsAConfiguredFileThatIsNotAJsonObject(string $contents, string $message): void + { + $directory = new TemporaryDirectory(); + + try { + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage($message); + + new ConfiguredTokenResolver(new JsonDocumentLoader(), [$directory->write('app.tokens.json', $contents)])->resolve([]); + } finally { + $directory->remove(); + } + } + + /** @return iterable */ + public static function invalidConfiguredFiles(): iterable + { + yield 'JSON scalar' => ['42', 'must contain a JSON object']; + yield 'invalid JSON' => ['{broken json}', 'Invalid JSON in design token file']; + } + + public function testDescribesTheModifiersOfItsResolverDocument(): void + { + $resolver = new ConfiguredTokenResolver(new ArrayDocumentLoader(['theme.resolver.json' => [ + 'version' => '2025.10', + 'modifiers' => [ + 'scheme' => ['contexts' => ['light' => [], 'dark' => []], 'default' => 'light'], + 'brand' => ['contexts' => ['sky' => [], 'ocean' => []]], + ], + 'resolutionOrder' => [['$ref' => '#/modifiers/scheme'], ['$ref' => '#/modifiers/brand']], + ]]), resolverPath: 'theme.resolver.json'); + + self::assertSame([ + 'scheme' => ['contexts' => ['light', 'dark'], 'default' => 'light'], + 'brand' => ['contexts' => ['sky', 'ocean'], 'default' => null], + ], $resolver->getModifiers()); + } + + public function testHasNoModifiersWithoutAResolverDocument(): void + { + self::assertSame([], new ConfiguredTokenResolver(new ArrayDocumentLoader([]))->getModifiers()); + } + + public function testReadsTheResolverDocumentAndItsSourcesThroughTheLoader(): void + { + $resolver = new ConfiguredTokenResolver(new ArrayDocumentLoader([ + 'design/theme.resolver.json' => ['version' => '2025.10', 'sets' => ['base' => ['sources' => [['$ref' => 'base.tokens.json']]]], 'resolutionOrder' => [['$ref' => '#/sets/base']]], + 'design/base.tokens.json' => ['a' => ['$type' => 'number', '$value' => 1]], + ]), resolverPath: 'design/theme.resolver.json'); + + self::assertSame(1, $resolver->resolve([])->getTokens()['a']->getValue()); + } + + /** @param class-string<\Throwable> $exception */ + #[DataProvider('unusableResolverFiles')] + public function testRejectsAResolverFileItCannotUse(?string $contents, string $exception, string $message): void + { + $directory = new TemporaryDirectory(); + $path = null === $contents ? $directory->path('missing.resolver.json') : $directory->write('theme.resolver.json', $contents); + + try { + $this->expectException($exception); + $this->expectExceptionMessage($message); + + new ConfiguredTokenResolver(resolverPath: $path)->getPermutations(); + } finally { + $directory->remove(); + } + } + + /** @return iterable, string}> */ + public static function unusableResolverFiles(): iterable + { + yield 'missing' => [null, RuntimeException::class, 'not found']; + yield 'invalid JSON' => ['{broken json}', RuntimeException::class, 'Invalid JSON in design token file']; + yield 'JSON scalar' => ['42', RuntimeException::class, 'JSON object']; + yield 'numeric property names' => ['{"0":"invalid"}', InvalidArgumentException::class, 'must use string property names']; + } + + private static function themeResolver(): ConfiguredTokenResolver + { + return new ConfiguredTokenResolver(new ArrayDocumentLoader([ + '/tokens/theme.resolver.json' => [ + 'version' => '2025.10', + 'modifiers' => [ + 'scheme' => [ + 'contexts' => [ + 'light' => [['c' => ['$type' => 'number', '$value' => 1]]], + 'dark' => [['c' => ['$type' => 'number', '$value' => 2]]], + ], + 'default' => 'light', + ], + ], + 'resolutionOrder' => [['$ref' => '#/modifiers/scheme']], + ], + ]), resolverPath: '/tokens/theme.resolver.json'); + } +} diff --git a/src/DesignTokens/tests/Unit/Resolver/DtcgTokenTreeBuilderTest.php b/src/DesignTokens/tests/Unit/Resolver/DtcgTokenTreeBuilderTest.php new file mode 100644 index 00000000000..8d9debb4359 --- /dev/null +++ b/src/DesignTokens/tests/Unit/Resolver/DtcgTokenTreeBuilderTest.php @@ -0,0 +1,59 @@ + + * + * 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\Resolver; + +use PHPUnit\Framework\Attributes\CoversClass; +use PHPUnit\Framework\TestCase; +use Symfony\UX\DesignTokens\Resolver\TokenTreeBuilder; +use Symfony\UX\DesignTokens\Token\ColorToken; +use Symfony\UX\DesignTokens\Token\DimensionToken; + +#[CoversClass(TokenTreeBuilder::class)] +final class DtcgTokenTreeBuilderTest extends TestCase +{ + public function testResolvesStructuredValuesAndRootTokens(): void + { + $result = new TokenTreeBuilder()->resolve([ + 'space' => [ + '$type' => 'dimension', + '$root' => ['$value' => ['value' => 16, 'unit' => 'px']], + ], + 'brand' => [ + 'primary' => ['$type' => 'color', '$value' => [ + 'colorSpace' => 'srgb', + 'components' => [0.2, 0.4, 0.8], + ]], + ], + ]); + + self::assertInstanceOf(DimensionToken::class, $result['space']['$root']); + self::assertSame('16px', (string) $result['space']['$root']); + self::assertInstanceOf(ColorToken::class, $result['brand']['primary']); + } + + public function testAppliesGroupExtends(): void + { + $result = new TokenTreeBuilder()->resolve([ + 'base' => [ + '$type' => 'dimension', + 'small' => ['$value' => ['value' => 8, 'unit' => 'px']], + ], + 'derived' => [ + '$extends' => '{base}', + 'large' => ['$value' => ['value' => 2, 'unit' => 'rem']], + ], + ]); + + self::assertSame('8px', (string) $result['derived']['small']); + self::assertSame('2rem', (string) $result['derived']['large']); + } +} diff --git a/src/DesignTokens/tests/Unit/Resolver/JsonDocumentLoaderTest.php b/src/DesignTokens/tests/Unit/Resolver/JsonDocumentLoaderTest.php new file mode 100644 index 00000000000..2ea807dad03 --- /dev/null +++ b/src/DesignTokens/tests/Unit/Resolver/JsonDocumentLoaderTest.php @@ -0,0 +1,168 @@ + + * + * 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\Resolver; + +use PHPUnit\Framework\Attributes\CoversClass; +use PHPUnit\Framework\Attributes\DataProvider; +use PHPUnit\Framework\TestCase; +use Symfony\UX\DesignTokens\Exception\RuntimeException; +use Symfony\UX\DesignTokens\Resolver\JsonDocumentLoader; +use Symfony\UX\DesignTokens\Tests\Fixtures\TemporaryDirectory; + +#[CoversClass(JsonDocumentLoader::class)] +final class JsonDocumentLoaderTest extends TestCase +{ + private string $fixtureDir; + private TemporaryDirectory $directory; + + protected function setUp(): void + { + $this->fixtureDir = \dirname(__DIR__, 2).'/Fixtures'; + $this->directory = new TemporaryDirectory(); + } + + protected function tearDown(): void + { + $this->directory->remove(); + } + + public function testLoadReturnsDecodedArray(): void + { + $loader = new JsonDocumentLoader(); + $data = $loader->load($this->fixtureDir.'/base.tokens.json'); + + self::assertIsArray($data); + self::assertArrayHasKey('color', $data); + } + + public function testLoadPreservesNestedStructure(): void + { + $loader = new JsonDocumentLoader(); + $data = $loader->load($this->fixtureDir.'/base.tokens.json'); + + self::assertArrayHasKey('brand', $data['color']); + self::assertSame(['colorSpace' => 'srgb', 'components' => [0.231, 0.51, 0.965]], $data['color']['brand']['primary']['$value']); + } + + public function testLoadWithBasepathPrependsForRelativeUri(): void + { + $loader = new JsonDocumentLoader($this->fixtureDir); + $data = $loader->load('base.tokens.json'); + + self::assertIsArray($data); + self::assertArrayHasKey('color', $data); + } + + public function testLoadWithAbsolutePathIgnoresBasePath(): void + { + $loader = new JsonDocumentLoader('/some/other/base'); + $data = $loader->load($this->fixtureDir.'/base.tokens.json'); + + self::assertIsArray($data); + } + + public function testLoadThrowsForMissingFile(): void + { + $loader = new JsonDocumentLoader(); + + $this->expectException(\RuntimeException::class); + $this->expectExceptionMessageMatches('/not found/i'); + + $loader->load('/does/not/exist/tokens.json'); + } + + /** @param array $expected */ + #[DataProvider('objectDocuments')] + public function testLoadsAJsonObject(string $contents, array $expected): void + { + self::assertSame($expected, new JsonDocumentLoader()->load($this->directory->write('tokens.json', $contents))); + } + + /** @return iterable}> */ + public static function objectDocuments(): iterable + { + yield 'empty object' => ['{}', []]; + yield 'numeric names' => ['{"0": {"$type": "number", "$value": 1}}', [['$type' => 'number', '$value' => 1]]]; + } + + /** @param class-string<\Throwable> $exception */ + #[DataProvider('invalidDocuments')] + public function testRejectsAFileThatIsNotAJsonObject(string $contents, string $exception, string $message): void + { + $path = $this->directory->write('tokens.json', $contents); + + $this->expectException($exception); + $this->expectExceptionMessage($message); + + new JsonDocumentLoader()->load($path); + } + + /** @return iterable, string}> */ + public static function invalidDocuments(): iterable + { + yield 'invalid JSON' => ['{ invalid json }', RuntimeException::class, 'Invalid JSON in design token file']; + yield 'JSON scalar' => ['42', \RuntimeException::class, 'must contain a JSON object']; + yield 'JSON list' => ['[1, 2]', RuntimeException::class, 'must contain a JSON object']; + } + + public function testWithoutAllowedRootsAnyReadablePathIsLoaded(): void + { + $loader = new JsonDocumentLoader($this->fixtureDir); + + self::assertIsArray($loader->load('color-scheme/../base.tokens.json')); + } + + public function testAllowedRootsRejectADocumentClimbingOutOfThem(): void + { + $loader = new JsonDocumentLoader($this->fixtureDir, [$this->fixtureDir.'/color-scheme']); + + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('resolves outside'); + + $loader->load('color-scheme/../base.tokens.json'); + } + + public function testAllowedRootsAcceptADocumentInsideThem(): void + { + $loader = new JsonDocumentLoader($this->fixtureDir, [$this->fixtureDir]); + + self::assertIsArray($loader->load('base.tokens.json')); + } + + public function testAllowedRootsStillReportAMissingFileAsMissing(): void + { + $loader = new JsonDocumentLoader($this->fixtureDir, [$this->fixtureDir]); + + $this->expectException(RuntimeException::class); + $this->expectExceptionMessageMatches('/not found/i'); + + $loader->load('nope.tokens.json'); + } + + public function testAllowedRootsFollowSymlinksSoAnInstalledPackageStaysReachable(): void + { + symlink($this->fixtureDir, $linked = $this->directory->path('link')); + + $loader = new JsonDocumentLoader('', [realpath($this->fixtureDir)]); + + self::assertIsArray($loader->load($linked.'/base.tokens.json')); + } + + public function testRootsAreComparedThroughTheirRealPath(): void + { + $directory = $this->directory->path(); + $this->directory->write('ok.json', '{}'); + + self::assertSame([], new JsonDocumentLoader('', [$directory])->load(realpath($directory).'/ok.json')); + self::assertSame([], new JsonDocumentLoader('', [realpath($directory) ?: $directory])->load($directory.'/ok.json')); + } +} diff --git a/src/DesignTokens/tests/Unit/Resolver/JsonPointerTest.php b/src/DesignTokens/tests/Unit/Resolver/JsonPointerTest.php new file mode 100644 index 00000000000..ed9233b0870 --- /dev/null +++ b/src/DesignTokens/tests/Unit/Resolver/JsonPointerTest.php @@ -0,0 +1,67 @@ + + * + * 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\Resolver; + +use PHPUnit\Framework\Attributes\CoversClass; +use PHPUnit\Framework\Attributes\DataProvider; +use PHPUnit\Framework\TestCase; +use Symfony\UX\DesignTokens\Exception\InvalidArgumentException; +use Symfony\UX\DesignTokens\Resolver\JsonPointer; + +#[CoversClass(JsonPointer::class)] +final class JsonPointerTest extends TestCase +{ + /** @param list $segments */ + #[DataProvider('fragments')] + public function testSplitsAFragmentIntoDecodedSegments(string $fragment, array $segments): void + { + self::assertSame($segments, JsonPointer::segments($fragment)); + } + + /** @return iterable}> */ + public static function fragments(): iterable + { + yield 'whole document' => ['', []]; + yield 'path' => ['/color/brand', ['color', 'brand']]; + yield 'empty segment' => ['/', ['']]; + yield 'escapes' => ['/a~1b/c~0d', ['a/b', 'c~d']]; + yield 'escape order' => ['/~01', ['~1']]; + yield 'percent-encoded space' => ['/space%20token', ['space token']]; + yield 'percent-encoded escape' => ['/tilde%7E0', ['tilde~']]; + yield 'percent-encoded slash separates' => ['/a%2Fb', ['a', 'b']]; + yield 'numeric segment' => ['/items/0', ['items', '0']]; + } + + #[DataProvider('invalidFragments')] + public function testRejectsAMalformedFragment(string $fragment, string $message): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage($message); + + JsonPointer::segments($fragment); + } + + /** @return iterable */ + public static function invalidFragments(): iterable + { + yield 'no leading slash' => ['color', 'Invalid JSON Pointer fragment']; + yield 'bad escape' => ['/a~2b', 'Invalid JSON Pointer escape']; + yield 'lone tilde' => ['/a~', 'Invalid JSON Pointer escape']; + yield 'bad percent-encoding' => ['/value%2', 'Invalid percent-encoding']; + } + + public function testEscapesASegment(): void + { + self::assertSame('a~1b~0c', JsonPointer::escape('a/b~c')); + self::assertSame(['a/b~c'], JsonPointer::segments('/'.JsonPointer::escape('a/b~c'))); + } +} diff --git a/src/DesignTokens/tests/Unit/Resolver/ResolverConformanceTest.php b/src/DesignTokens/tests/Unit/Resolver/ResolverConformanceTest.php new file mode 100644 index 00000000000..4c9d4f40276 --- /dev/null +++ b/src/DesignTokens/tests/Unit/Resolver/ResolverConformanceTest.php @@ -0,0 +1,240 @@ + + * + * 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\Resolver; + +use PHPUnit\Framework\Attributes\CoversClass; +use PHPUnit\Framework\Attributes\DataProvider; +use PHPUnit\Framework\TestCase; +use Symfony\UX\DesignTokens\Exception\ResolverException; +use Symfony\UX\DesignTokens\Resolver\JsonDocumentLoader; +use Symfony\UX\DesignTokens\Resolver\ResolverDocument; +use Symfony\UX\DesignTokens\Resolver\TokenTreeBuilder; +use Symfony\UX\DesignTokens\Tests\Fixtures\ResolverDocuments; +use Symfony\UX\DesignTokens\Token\NumberToken; + +#[CoversClass(ResolverDocument::class)] +final class ResolverConformanceTest extends TestCase +{ + public function testTheRuntimeUsesTheStableVersionIdentifier(): void + { + self::assertSame('2025.10', ResolverDocument::VERSION); + } + + #[DataProvider('rejectedVersionIdentifiers')] + public function testRejectsTheVersionIdentifiersTheReportContradictsItselfWith(string $version): void + { + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessageMatches('/version "2025\.10"/'); + + ResolverDocuments::merged(new ResolverDocument([ + 'version' => $version, + 'resolutionOrder' => [['$ref' => '#/sets/base']], + 'sets' => ['base' => ['sources' => [['a' => ['$type' => 'number', '$value' => 1]]]]], + ], '')); + } + + /** @return iterable */ + public static function rejectedVersionIdentifiers(): iterable + { + yield 'root property table' => ['2025-10-01']; + yield 'section 4.1.2' => ['2025-11-01']; + } + + public function testAcceptsTheCompleteNormativeDocumentSurface(): void + { + $document = new ResolverDocument([ + '$schema' => 'https://www.designtokens.org/schemas/2025.10/resolver.json', + 'name' => 'Complete resolver', + 'version' => '2025.10', + 'description' => 'Exercises optional metadata.', + '$defs' => ['unused' => ['arbitrary' => true]], + 'sets' => [ + 'empty' => [ + 'description' => 'Empty source arrays are valid.', + '$extensions' => ['example.com/tool' => ['stable' => true]], + 'sources' => [], + ], + ], + 'modifiers' => [ + 'optional' => [ + '$extensions' => ['example.com/tool' => ['stable' => true]], + 'contexts' => ['off' => [], 'on' => []], + 'default' => 'off', + ], + ], + 'resolutionOrder' => [ + ['$ref' => '#/sets/empty'], + ['$ref' => '#/modifiers/optional'], + ], + ]); + + self::assertSame([], ResolverDocuments::merged($document)); + self::assertCount(2, $document->getPermutations()); + } + + public function testResolvesEscapedAndPercentEncodedJsonPointerSegments(): void + { + $document = new ResolverDocument([ + 'version' => '2025.10', + 'sets' => [ + 'a/b~c' => ['sources' => [['escaped' => ['$type' => 'number', '$value' => 1]]]], + 'space name' => ['sources' => [['encoded' => ['$type' => 'number', '$value' => 2]]]], + ], + 'resolutionOrder' => [ + ['$ref' => '#/sets/a~1b~0c'], + ['$ref' => '#/sets/space%20name'], + ], + ]); + + self::assertSame(1, ResolverDocuments::merged($document)['escaped']['$value'] ?? null); + self::assertSame(2, ResolverDocuments::merged($document)['encoded']['$value'] ?? null); + } + + public function testRejectsAReferenceToItsParentNode(): void + { + $document = new ResolverDocument([ + 'version' => '2025.10', + 'sets' => [ + 'parent' => ['sources' => [['$ref' => '#/sets/parent']]], + ], + 'resolutionOrder' => [['$ref' => '#/sets/parent']], + ]); + + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('Circular resolver reference'); + + ResolverDocuments::sources($document); + } + + public function testResolvesAReferenceObjectThatTargetsAnotherReferenceObject(): void + { + $document = new ResolverDocument([ + 'version' => '2025.10', + '$defs' => [ + 'target' => ['type' => 'set', 'name' => 'generic', 'sources' => [ + ['value' => ['$type' => 'number', '$value' => 1]], + ]], + 'alias' => ['$ref' => '#/$defs/target'], + ], + 'resolutionOrder' => [['$ref' => '#/$defs/alias']], + ]); + + self::assertSame(1, ResolverDocuments::merged($document)['value']['$value'] ?? null); + } + + public function testRejectsCaseInsensitiveModifierAndInputAmbiguities(): void + { + $modifier = ['contexts' => ['light' => [], 'dark' => []]]; + $document = new ResolverDocument([ + 'version' => '2025.10', + 'modifiers' => ['theme' => $modifier, 'THEME' => $modifier], + 'resolutionOrder' => [ + ['$ref' => '#/modifiers/theme'], + ['$ref' => '#/modifiers/THEME'], + ], + ]); + + try { + ResolverDocuments::sources($document, ['theme' => 'light']); + self::fail('Case-insensitive modifier names must not be ambiguous.'); + } catch (\InvalidArgumentException $exception) { + self::assertStringContainsString('differ only by case', $exception->getMessage()); + } + + $document = new ResolverDocument([ + 'version' => '2025.10', + 'modifiers' => ['theme' => $modifier], + 'resolutionOrder' => [['$ref' => '#/modifiers/theme']], + ]); + + try { + ResolverDocuments::sources($document, ['theme' => 'light', 'THEME' => 'dark']); + self::fail('The same modifier input must not be accepted twice with different casing.'); + } catch (ResolverException $exception) { + self::assertStringContainsString('provided more than once', $exception->getMessage()); + } + } + + public function testOrderingPrecedesAliasResolutionAndPreservesTokenProperties(): void + { + $document = new ResolverDocument([ + 'version' => '2025.10', + 'sets' => [ + 'base' => ['sources' => [[ + 'source' => ['$type' => 'number', '$value' => 1], + 'alias' => [ + '$type' => 'number', + '$value' => '{source}', + '$description' => 'Resolved after all sources are merged.', + '$deprecated' => 'Use source.', + '$extensions' => ['example.com/tool' => ['preserved' => true]], + ], + ]]], + 'override' => ['sources' => [[ + 'source' => ['$type' => 'number', '$value' => 2], + ]]], + ], + 'resolutionOrder' => [ + ['$ref' => '#/sets/base'], + ['$ref' => '#/sets/override'], + ], + ]); + + $resolved = new TokenTreeBuilder()->resolveSources($document->sourceDescriptors()); + + self::assertInstanceOf(NumberToken::class, $resolved['alias']); + self::assertSame(2, $resolved['alias']->getValue()); + self::assertSame('Resolved after all sources are merged.', $resolved['alias']->getDescription()); + self::assertTrue($resolved['alias']->isDeprecated()); + self::assertSame('Use source.', $resolved['alias']->getDeprecationMessage()); + self::assertSame(['example.com/tool' => ['preserved' => true]], $resolved['alias']->getExtensions()); + } + + public function testEveryPermutationOfThePinnedPositiveFixtureResolvesAsDtcgTokens(): void + { + $path = self::resourcePath('resolver-complete.resolver.json'); + $document = new ResolverDocument(new JsonDocumentLoader()->load($path), \dirname($path)); + $resolver = new TokenTreeBuilder(); + + foreach ($document->getPermutations() as $inputs) { + $resolved = $resolver->resolveSources($document->sourceDescriptors($inputs)); + + self::assertInstanceOf(NumberToken::class, $resolved['color']['surface']); + self::assertInstanceOf(NumberToken::class, $resolved['component']['precedence']); + } + } + + /** @param array $document */ + #[DataProvider('invalidDocumentProvider')] + public function testRejectsNormativeInvalidDocuments(array $document, string $message): void + { + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage($message); + + new ResolverDocument($document); + } + + /** @return iterable, string}> */ + public static function invalidDocumentProvider(): iterable + { + $cases = json_decode((string) file_get_contents(self::resourcePath('resolver-invalid-cases.json')), true, 512, \JSON_THROW_ON_ERROR); + + foreach ($cases as $name => $case) { + yield $name => [$case['document'], $case['message']]; + } + } + + private static function resourcePath(string $file): string + { + return \dirname(__DIR__, 2).'/Fixtures/dtcg/'.$file; + } +} diff --git a/src/DesignTokens/tests/Unit/Resolver/ResolverDocumentTest.php b/src/DesignTokens/tests/Unit/Resolver/ResolverDocumentTest.php new file mode 100644 index 00000000000..02e06eadfa9 --- /dev/null +++ b/src/DesignTokens/tests/Unit/Resolver/ResolverDocumentTest.php @@ -0,0 +1,615 @@ + + * + * 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\Resolver; + +use PHPUnit\Framework\Attributes\CoversClass; +use PHPUnit\Framework\Attributes\DataProvider; +use PHPUnit\Framework\TestCase; +use Symfony\UX\DesignTokens\Exception\InvalidArgumentException; +use Symfony\UX\DesignTokens\Exception\ResolverException; +use Symfony\UX\DesignTokens\Resolver\ArrayDocumentLoader; +use Symfony\UX\DesignTokens\Resolver\DocumentLoaderInterface; +use Symfony\UX\DesignTokens\Resolver\JsonDocumentLoader; +use Symfony\UX\DesignTokens\Resolver\ResolverDocument; +use Symfony\UX\DesignTokens\Resolver\ResolverSource; +use Symfony\UX\DesignTokens\Tests\Fixtures\ResolverDocuments; +use Symfony\UX\DesignTokens\Tests\Fixtures\TemporaryDirectory; + +#[CoversClass(ResolverDocument::class)] +#[CoversClass(ResolverException::class)] +#[CoversClass(ResolverSource::class)] +final class ResolverDocumentTest extends TestCase +{ + private TemporaryDirectory $directory; + + protected function setUp(): void + { + $this->directory = new TemporaryDirectory(); + } + + protected function tearDown(): void + { + $this->directory->remove(); + } + + public function testResolvesOrderedSetsAndModifierContext(): void + { + self::assertCount(2, ResolverDocuments::sources(self::fixture('resolver-valid.resolver.json'), ['theme' => 'dark'])); + } + + public function testResolvesInlineSourcesAndDefaultModifierContext(): void + { + $document = $this->load([ + 'sets' => [], + 'modifiers' => [ + 'theme' => [ + 'contexts' => [ + 'light' => [['$type' => 'string', '$value' => 'light']], + 'dark' => [['$type' => 'string', '$value' => 'dark']], + ], + 'default' => 'dark', + ], + ], + 'resolutionOrder' => [['$ref' => '#/modifiers/theme']], + ]); + + self::assertSame('dark', ResolverDocuments::sources($document)[0]['$value'] ?? null); + } + + /** + * @param array $inputs + * @param class-string<\Throwable> $exception + */ + #[DataProvider('invalidFixtureInputProvider')] + public function testRejectsInvalidInputsForAFixture(string $fixture, array $inputs, string $exception, string $message): void + { + $document = self::fixture($fixture); + + $this->expectException($exception); + $this->expectExceptionMessage($message); + ResolverDocuments::sources($document, $inputs); + } + + /** @return iterable, class-string<\Throwable>, string}> */ + public static function invalidFixtureInputProvider(): iterable + { + yield 'unknown context' => ['resolver-valid.resolver.json', ['theme' => 'unknown'], ResolverException::class, 'Invalid context "unknown" for modifier "theme".']; + yield 'modifier without default left out' => ['resolver-complete.resolver.json', [], ResolverException::class, 'Missing required modifier "density".']; + } + + public function testLoadsExternalSourcesWithAndWithoutFragments(): void + { + $this->directory->write('tokens.json', [ + 'group' => [ + 'token' => ['$type' => 'string', '$value' => 'hello'], + ], + ]); + $document = $this->load([ + 'sets' => [], + 'modifiers' => [], + 'resolutionOrder' => [self::set([['$ref' => 'tokens.json'], ['$ref' => 'tokens.json#/group']])], + ]); + + $sources = ResolverDocuments::sources($document); + + self::assertCount(2, $sources); + self::assertArrayHasKey('group', $sources[0]); + self::assertArrayHasKey('token', $sources[1]); + } + + public function testPreservesSourceReferenceBasePath(): void + { + $this->directory->write('nested/tokens.json', '{"token":{"$type":"string","$value":"ok"}}'); + + $sources = $this->load(['resolutionOrder' => [self::set([['$ref' => 'nested/tokens.json']])]])->sourceDescriptors(); + + self::assertCount(1, $sources); + self::assertSame($this->directory->path('nested'), $sources[0]->basePath); + self::assertSame('nested/tokens.json', $sources[0]->uri); + } + + public function testSupportsVersionAndEnumeratesPermutations(): void + { + $document = self::fixture('resolver-complete.resolver.json'); + + self::assertSame('2025.10', ResolverDocument::VERSION); + self::assertCount(4, $document->getPermutations()); + self::assertSame([ + ['theme' => 'light', 'density' => 'comfortable'], + ['theme' => 'light', 'density' => 'compact'], + ['theme' => 'dark', 'density' => 'comfortable'], + ['theme' => 'dark', 'density' => 'compact'], + ], $document->getPermutations()); + } + + public function testPermutationsKeepNumericModifierNames(): void + { + $document = new ResolverDocument(json_decode('{ + "version": "2025.10", + "modifiers": { + "1": {"contexts": {"a": [], "b": []}, "default": "a"}, + "2": {"contexts": {"a": [], "b": []}, "default": "a"} + }, + "resolutionOrder": [{"$ref": "#/modifiers/1"}, {"$ref": "#/modifiers/2"}] + }', true, 512, \JSON_THROW_ON_ERROR)); + + $permutations = $document->getPermutations(); + + self::assertSame([ + [1 => 'a', 2 => 'a'], + [1 => 'a', 2 => 'b'], + [1 => 'b', 2 => 'a'], + [1 => 'b', 2 => 'b'], + ], $permutations); + } + + public function testExternalFragmentReferencesKeepNumericTokenNames(): void + { + $this->directory->write('palette.json', '{"grey": {"$type": "number", "50": {"$value": 1}, "100": {"$value": 2}}}'); + + $document = new ResolverDocument(json_decode('{ + "version": "2025.10", + "sets": {"grey": {"sources": [{"$ref": "palette.json#/grey"}]}}, + "resolutionOrder": [{"$ref": "#/sets/grey"}] + }', true, 512, \JSON_THROW_ON_ERROR), $this->directory->path()); + + self::assertSame(['$type', 50, 100], array_keys($document->sourceDescriptors()[0]->tokens)); + } + + public function testDescribesModifierContextsDefaultsAndResolutionPlan(): void + { + $document = self::fixture('resolver-complete.resolver.json'); + + self::assertSame([ + 'theme' => ['contexts' => ['light', 'dark'], 'default' => 'light'], + 'density' => ['contexts' => ['comfortable', 'compact'], 'default' => null], + ], $document->getModifiers()); + + $plan = $document->resolutionPlan(['theme' => 'dark', 'density' => 'compact']); + self::assertSame([ + ['type' => 'set', 'name' => 'foundation', 'context' => null], + ['type' => 'modifier', 'name' => 'theme', 'context' => 'dark'], + ['type' => 'modifier', 'name' => 'density', 'context' => 'compact'], + ['type' => 'set', 'name' => 'component', 'context' => null], + ], array_map(static fn (array $selection): array => array_diff_key($selection, ['sources' => true]), $plan)); + self::assertCount(1, $plan[0]['sources']); + self::assertSame(2, $plan[1]['sources'][0]->tokens['color']['surface']['$value'] ?? null); + self::assertSame(4, $plan[2]['sources'][0]->tokens['space']['control']['$value']['value'] ?? null); + } + + public function testAppliesSourcesAndResolutionOrderWithLastValueWinning(): void + { + $resolved = ResolverDocuments::merged(self::fixture('resolver-complete.resolver.json'), ['THEME' => 'DARK', 'Density' => 'COMPACT']); + + self::assertSame(['surface' => ['$type' => 'number', '$value' => 2]], $resolved['color'] ?? null); + self::assertSame(['control' => ['$type' => 'dimension', '$value' => ['value' => 4, 'unit' => 'px']]], $resolved['space'] ?? null); + self::assertSame(['precedence' => ['$type' => 'number', '$value' => 3]], $resolved['component'] ?? null); + } + + public function testUsesDefaultsAndAllowsAnEmptyContextSourceList(): void + { + $document = $this->load([ + 'resolutionOrder' => [[ + 'type' => 'modifier', + 'name' => 'debug', + 'contexts' => [ + 'false' => [], + 'true' => [['debug' => ['$type' => 'number', '$value' => 1]]], + ], + 'default' => 'false', + ]], + ]); + + self::assertSame([], ResolverDocuments::sources($document)); + self::assertSame([['debug' => ['$type' => 'number', '$value' => 1]]], ResolverDocuments::sources($document, ['debug' => 'true'])); + } + + public function testReportsAllInvalidInputsTogether(): void + { + $document = self::fixture('resolver-complete.resolver.json'); + + try { + ResolverDocuments::sources($document, ['theme' => 'blue', 'unknown' => 'value', 'density' => true]); + self::fail('Invalid resolver inputs should be rejected.'); + } catch (ResolverException $exception) { + self::assertSame([ + 'Invalid context "blue" for modifier "theme".', + 'Unknown modifier "unknown".', + 'Input "density" must be a string or a number.', + 'Missing required modifier "density".', + ], $exception->getErrors()); + } + } + + public function testRejectsAnInputProvidedTwiceWithDifferentCasing(): void + { + $document = self::document([ + 'modifiers' => ['theme' => ['contexts' => ['light' => [], 'dark' => []], 'default' => 'light']], + 'resolutionOrder' => [['$ref' => '#/modifiers/theme']], + ]); + + $this->expectException(ResolverException::class); + $this->expectExceptionMessage('provided more than once'); + ResolverDocuments::sources($document, ['Theme' => 'light', 'theme' => 'dark']); + } + + public function testModifierMayReferenceASet(): void + { + $document = $this->load([ + 'sets' => ['base' => ['sources' => [['base' => ['$type' => 'number', '$value' => 1]]]]], + 'modifiers' => ['theme' => [ + 'contexts' => [ + 'light' => [['$ref' => '#/sets/base'], ['theme' => ['$type' => 'string', '$value' => 'light']]], + 'dark' => [['theme' => ['$type' => 'string', '$value' => 'dark']]], + ], + ]], + 'resolutionOrder' => [['$ref' => '#/modifiers/theme']], + ]); + + $resolved = ResolverDocuments::merged($document, ['theme' => 'light']); + self::assertSame(['$type' => 'number', '$value' => 1], $resolved['base'] ?? null); + self::assertSame(['$type' => 'string', '$value' => 'light'], $resolved['theme'] ?? null); + } + + public function testResolvesReferenceOverridesShallowly(): void + { + $document = $this->load([ + '$defs' => [ + 'source' => [ + 'token' => ['$type' => 'string', '$value' => 'base'], + 'nested' => ['kept' => true], + 'list' => ['base'], + ], + ], + 'resolutionOrder' => [self::set([[ + '$ref' => '#/$defs/source', + 'token' => ['$type' => 'string', '$value' => 'override'], + 'nested' => ['replaced' => true], + 'list' => ['override'], + ]])], + ]); + + $resolved = ResolverDocuments::merged($document); + self::assertSame(['$type' => 'string', '$value' => 'override'], $resolved['token'] ?? null); + self::assertSame(['replaced' => true], $resolved['nested'] ?? null); + self::assertSame(['override'], $resolved['list'] ?? null); + } + + /** + * @param array $data + * @param class-string<\Throwable> $exception + */ + #[DataProvider('invalidDocumentProvider')] + public function testRejectsAnInvalidDocument(array $data, string $exception, string $message): void + { + $this->expectException($exception); + $this->expectExceptionMessage($message); + + ResolverDocuments::sources(self::document($data)); + } + + /** @return iterable, class-string<\Throwable>, string}> */ + public static function invalidDocumentProvider(): iterable + { + $order = ['resolutionOrder' => [self::set([])]]; + $empty = ['sets' => [], 'modifiers' => [], '$defs' => [], ...$order]; + $sources = [['size' => ['$type' => 'dimension', '$value' => ['value' => 1, 'unit' => 'px']]]]; + + yield 'other version' => [ + ['version' => '2024.01', 'resolutionOrder' => [['sources' => []]]], + InvalidArgumentException::class, 'A DTCG Resolver document must declare version "2025.10".', + ]; + yield 'empty resolution order' => [ + ['resolutionOrder' => []], + InvalidArgumentException::class, 'A DTCG Resolver document must define a non-empty resolutionOrder array.', + ]; + yield 'unknown property' => [ + [...$empty, 'unknown' => true], + InvalidArgumentException::class, 'Resolver document contains unsupported property "unknown".', + ]; + yield 'integer name' => [ + ['name' => 42, ...$order], + InvalidArgumentException::class, 'Resolver property "name" must be a string.', + ]; + yield 'list name' => [ + [...$empty, 'name' => []], + InvalidArgumentException::class, 'Resolver property "name" must be a string.', + ]; + yield 'list description' => [ + [...$empty, 'description' => []], + InvalidArgumentException::class, 'Resolver property "description" must be a string.', + ]; + yield 'boolean $schema' => [ + [...$empty, '$schema' => false], + InvalidArgumentException::class, 'Resolver property "$schema" must be a string.', + ]; + yield 'sets as a list' => [ + [...$empty, 'sets' => [[]]], + InvalidArgumentException::class, 'Resolver property "sets" must be an object.', + ]; + yield 'set with an empty name' => [ + ['sets' => ['' => ['sources' => []]], ...$order], + InvalidArgumentException::class, 'Every resolver set must be a named object.', + ]; + yield 'modifier with an empty name' => [ + ['modifiers' => ['' => ['contexts' => ['a' => [], 'b' => []]]], ...$order], + InvalidArgumentException::class, 'Every resolver modifier must be a named object.', + ]; + yield 'set with a non-string description' => [ + ['sets' => ['base' => ['sources' => [], 'description' => 42]], 'resolutionOrder' => [['$ref' => '#/sets/base']]], + InvalidArgumentException::class, 'Set "base" property "description" must be a string.', + ]; + + yield 'modifiers differing only by case' => [ + [ + 'modifiers' => [ + 'Theme' => ['contexts' => ['light' => [], 'dark' => []], 'default' => 'light'], + 'theme' => ['contexts' => ['compact' => [], 'comfortable' => []], 'default' => 'compact'], + ], + 'resolutionOrder' => [['$ref' => '#/modifiers/Theme'], ['$ref' => '#/modifiers/theme']], + ], + InvalidArgumentException::class, 'Modifier names "Theme" and "theme" differ only by case.', + ]; + yield 'contexts differing only by case' => [ + [ + 'modifiers' => ['theme' => ['contexts' => ['Light' => [], 'light' => []], 'default' => 'Light']], + 'resolutionOrder' => [['$ref' => '#/modifiers/theme']], + ], + InvalidArgumentException::class, 'Context names "Light" and "light" of modifier "theme" differ only by case.', + ]; + + yield 'scalar item' => [ + ['resolutionOrder' => [42]], + InvalidArgumentException::class, 'resolutionOrder[0] must be an object.', + ]; + yield 'non-string reference' => [ + ['resolutionOrder' => [['$ref' => 42]]], + InvalidArgumentException::class, 'The $ref in resolutionOrder[0] must be a non-empty string.', + ]; + yield 'inline type absent' => [ + ['resolutionOrder' => [['name' => 'x', 'sources' => []]]], + InvalidArgumentException::class, 'Inline resolutionOrder item 0 must declare type "set" or "modifier".', + ]; + yield 'inline set with non-list sources' => [ + ['resolutionOrder' => [['type' => 'set', 'name' => 'x', 'sources' => 'tokens.json']]], + InvalidArgumentException::class, 'Set "x" must declare a sources array.', + ]; + yield 'inline name absent' => [ + ['resolutionOrder' => [['type' => 'set', 'sources' => []]]], + InvalidArgumentException::class, 'Inline resolutionOrder item 0 must declare a non-empty name.', + ]; + yield 'inline name duplicate' => [ + ['resolutionOrder' => [self::set([], 'x'), self::set([], 'x')]], + InvalidArgumentException::class, 'Resolution order name "x" is duplicated.', + ]; + yield 'empty contexts' => [ + ['resolutionOrder' => [['type' => 'modifier', 'name' => 'mode', 'contexts' => []]]], + InvalidArgumentException::class, 'Modifier "mode" must declare a non-empty contexts object.', + ]; + yield 'scalar contexts' => [ + ['resolutionOrder' => [['type' => 'modifier', 'name' => 'theme', 'contexts' => 'dark']]], + InvalidArgumentException::class, 'Modifier "theme" must declare a non-empty contexts object.', + ]; + yield 'default outside the contexts' => [ + ['resolutionOrder' => [['type' => 'modifier', 'name' => 'mode', 'contexts' => ['a' => [], 'b' => []], 'default' => 'c']]], + InvalidArgumentException::class, 'Default context for modifier "mode" must match a context key.', + ]; + + yield 'reference into the resolution order' => [ + ['resolutionOrder' => [['$ref' => '#/resolutionOrder/0']]], + InvalidArgumentException::class, 'Resolver references must not point into resolutionOrder: "#/resolutionOrder/0".', + ]; + $forbidden = [ + 'sets' => ['tokens' => ['sources' => []]], + 'modifiers' => ['mode' => ['contexts' => ['a' => [], 'b' => []]]], + ]; + yield 'set to modifier' => [ + [...$forbidden, 'resolutionOrder' => [self::set([['$ref' => '#/modifiers/mode']], 'inline')]], + InvalidArgumentException::class, 'Sets must not reference modifiers: "#/modifiers/mode".', + ]; + yield 'modifier to modifier' => [ + [...$forbidden, 'resolutionOrder' => [['type' => 'modifier', 'name' => 'inline', 'contexts' => ['a' => [['$ref' => '#/modifiers/mode']], 'b' => []]]]], + InvalidArgumentException::class, 'Modifiers must not reference modifiers: "#/modifiers/mode".', + ]; + yield 'set to resolution order' => [ + [...$forbidden, 'resolutionOrder' => [self::set([['$ref' => '#/resolutionOrder/0']], 'inline')]], + InvalidArgumentException::class, 'Resolver references must not point into resolutionOrder: "#/resolutionOrder/0".', + ]; + yield 'modifier to resolution order' => [ + [...$forbidden, 'resolutionOrder' => [['type' => 'modifier', 'name' => 'inline', 'contexts' => ['a' => [['$ref' => '#/resolutionOrder/0']], 'b' => []]]]], + InvalidArgumentException::class, 'Resolver references must not point into resolutionOrder: "#/resolutionOrder/0".', + ]; + yield 'circular set references' => [ + [ + 'sets' => ['a' => ['sources' => [['$ref' => '#/sets/b']]], 'b' => ['sources' => [['$ref' => '#/sets/a']]]], + 'resolutionOrder' => [['$ref' => '#/sets/a']], + ], + InvalidArgumentException::class, 'Circular resolver reference detected: #/resolutionOrder/0 -> #/sets/b -> #/sets/a -> #/sets/b.', + ]; + yield 'invalid pointer escape' => [ + ['sets' => ['a~b' => ['sources' => []]], 'resolutionOrder' => [['$ref' => '#/sets/a~2b']]], + InvalidArgumentException::class, 'Invalid JSON Pointer escape in segment "a~2b".', + ]; + yield 'broken pointer in a set left out of the resolution order' => [ + [ + 'sets' => ['used' => ['sources' => $sources], 'unused' => ['sources' => [['$ref' => '#/sets/missing']]]], + 'resolutionOrder' => [['$ref' => '#/sets/used']], + ], + InvalidArgumentException::class, 'Resolver pointer not found: "#/sets/missing".', + ]; + yield 'broken pointer in a context no input selects' => [ + [ + 'modifiers' => ['scheme' => ['contexts' => ['light' => $sources, 'dark' => [['$ref' => '#/sets/missing']]], 'default' => 'light']], + 'resolutionOrder' => [['$ref' => '#/modifiers/scheme']], + ], + InvalidArgumentException::class, 'Resolver pointer not found: "#/sets/missing".', + ]; + } + + /** + * @param string|array $contents + * @param class-string<\Throwable> $exception + */ + #[DataProvider('invalidExternalSourceProvider')] + public function testRejectsAnInvalidExternalSource(string|array $contents, string $reference, string $exception, string $message): void + { + $this->directory->write('tokens.json', $contents); + $document = $this->load(['resolutionOrder' => [self::set([['$ref' => $reference]], 'external')]]); + + $this->expectException($exception); + $this->expectExceptionMessage($message); + ResolverDocuments::sources($document); + } + + /** @return iterable, string, class-string<\Throwable>, string}> */ + public static function invalidExternalSourceProvider(): iterable + { + yield 'missing pointer' => [ + '{"group":{}}', 'tokens.json#/missing', + InvalidArgumentException::class, 'Resolver pointer not found: "tokens.json#/missing".', + ]; + yield 'chained reference to a modifier' => [ + ['source' => ['$ref' => '#/modifiers/theme'], 'modifiers' => ['theme' => ['contexts' => ['a' => [], 'b' => []]]]], 'tokens.json#/source', + InvalidArgumentException::class, 'Sets must not reference modifiers: "#/modifiers/theme".', + ]; + yield 'nested set with a scalar source' => [ + '{"set":{"sources":[42]}}', 'tokens.json#/set', + InvalidArgumentException::class, 'Token source 0 must be an object', + ]; + } + + public function testSupportsWholeDocumentAndAbsoluteSourceReferences(): void + { + $tokensPath = $this->directory->write('tokens.json', '{"token":{"$type":"number","$value":1}}'); + + $sources = $this->load([ + '$defs' => ['whole' => ['$ref' => '#']], + 'resolutionOrder' => [self::set([['$ref' => $tokensPath]], 'absolute')], + ])->sourceDescriptors(); + + self::assertSame($this->directory->path(), $sources[0]->basePath); + self::assertSame(1, $sources[0]->tokens['token']['$value'] ?? null); + } + + public function testExpandsWholeDocumentReferences(): void + { + $whole = self::document([ + 'sets' => ['base' => ['sources' => [['$ref' => '#']]]], + 'resolutionOrder' => [['$ref' => '#/sets/base']], + ]); + + self::assertSame('2025.10', ResolverDocuments::sources($whole)[0]['version'] ?? null); + } + + /** @param array $document */ + #[DataProvider('numericNameProvider')] + public function testAcceptsNamesThatLookLikeIntegers(array $document): void + { + self::assertNotSame([], ResolverDocuments::merged(self::document($document))); + } + + /** @return iterable}> */ + public static function numericNameProvider(): iterable + { + $sources = [['size' => ['$type' => 'dimension', '$value' => ['value' => 1, 'unit' => 'px']]]]; + + yield 'context' => [[ + 'modifiers' => ['breakpoint' => [ + 'contexts' => ['320' => $sources, 'wide' => $sources], + 'default' => '320', + ]], + 'resolutionOrder' => [['$ref' => '#/modifiers/breakpoint']], + ]]; + yield 'set' => [[ + 'sets' => ['2024' => ['sources' => $sources]], + 'resolutionOrder' => [['$ref' => '#/sets/2024']], + ]]; + yield 'modifier' => [[ + 'modifiers' => ['320' => ['contexts' => ['a' => $sources, 'b' => $sources], 'default' => 'a']], + 'resolutionOrder' => [['$ref' => '#/modifiers/320']], + ]]; + } + + public function testExternalSourcesAreReadOnceThroughTheLoader(): void + { + $loader = new class implements DocumentLoaderInterface { + /** @var list */ + public array $uris = []; + + public function load(string $uri): array + { + $this->uris[] = $uri; + + return ['a' => ['$type' => 'number', '$value' => 1]]; + } + }; + $document = self::document(['resolutionOrder' => [self::set([['$ref' => 'sets/base.json']])]], '/project/tokens', $loader); + + $document->sourceDescriptors(); + $document->sourceDescriptors(); + + self::assertSame(['/project/tokens/sets/base.json'], $loader->uris); + self::assertSame(['/project/tokens/sets/base.json'], $document->loadedUris()); + } + + public function testAnEmptyBasePathKeepsReferencesRelative(): void + { + $loader = new ArrayDocumentLoader([ + 'sub/a.json' => ['sources' => [['$ref' => 'b.json']]], + 'sub/b.json' => ['b' => ['$type' => 'number', '$value' => 1]], + ]); + + $sources = self::document(['resolutionOrder' => [self::set([['$ref' => 'sub/a.json']])]], '', $loader)->sourceDescriptors(); + + self::assertSame(1, $sources[0]->tokens['b']['$value']); + } + + public function testNumericInputsSelectTheirContext(): void + { + $document = new ResolverDocument(json_decode('{ + "version": "2025.10", + "modifiers": {"breakpoint": {"contexts": {"320": [{"a": {"$type": "number", "$value": 1}}], "768": [{"a": {"$type": "number", "$value": 2}}]}, "default": "320"}}, + "resolutionOrder": [{"$ref": "#/modifiers/breakpoint"}] + }', true, 512, \JSON_THROW_ON_ERROR)); + + self::assertSame(2, $document->sourceDescriptors(['breakpoint' => 768])[0]->tokens['a']['$value']); + } + + /** @param array $data */ + private function load(array $data): ResolverDocument + { + return self::document($data, $this->directory->path()); + } + + /** @param array $data */ + private static function document(array $data, string $basePath = '', ?DocumentLoaderInterface $loader = null): ResolverDocument + { + return new ResolverDocument(['version' => ResolverDocument::VERSION, ...$data], $basePath, $loader); + } + + /** + * @param list> $sources + * + * @return array{type: 'set', name: string, sources: list>} + */ + private static function set(array $sources, string $name = 'base'): array + { + return ['type' => 'set', 'name' => $name, 'sources' => $sources]; + } + + private static function fixture(string $name): ResolverDocument + { + $path = \dirname(__DIR__, 2).'/Fixtures/dtcg/'.$name; + + return new ResolverDocument(new JsonDocumentLoader()->load($path), \dirname($path)); + } +} diff --git a/src/DesignTokens/tests/Unit/Resolver/TokenTreeBuilderTest.php b/src/DesignTokens/tests/Unit/Resolver/TokenTreeBuilderTest.php new file mode 100644 index 00000000000..67271cbd4db --- /dev/null +++ b/src/DesignTokens/tests/Unit/Resolver/TokenTreeBuilderTest.php @@ -0,0 +1,746 @@ + + * + * 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\Resolver; + +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\RuntimeException; +use Symfony\UX\DesignTokens\Resolver\ArrayDocumentLoader; +use Symfony\UX\DesignTokens\Resolver\JsonDocumentLoader; +use Symfony\UX\DesignTokens\Resolver\ResolverSource; +use Symfony\UX\DesignTokens\Resolver\TokenResolution; +use Symfony\UX\DesignTokens\Resolver\TokenTreeBuilder; +use Symfony\UX\DesignTokens\Tests\Fixtures\TemporaryDirectory; +use Symfony\UX\DesignTokens\Tests\Fixtures\TokenValues; +use Symfony\UX\DesignTokens\Token\BorderToken; +use Symfony\UX\DesignTokens\Token\ColorToken; +use Symfony\UX\DesignTokens\Token\CubicBezierToken; +use Symfony\UX\DesignTokens\Token\DimensionToken; +use Symfony\UX\DesignTokens\Token\DurationToken; +use Symfony\UX\DesignTokens\Token\FontFamilyToken; +use Symfony\UX\DesignTokens\Token\FontWeightToken; +use Symfony\UX\DesignTokens\Token\GradientToken; +use Symfony\UX\DesignTokens\Token\NumberToken; +use Symfony\UX\DesignTokens\Token\ShadowToken; +use Symfony\UX\DesignTokens\Token\StrokeStyleToken; +use Symfony\UX\DesignTokens\Token\TransitionToken; +use Symfony\UX\DesignTokens\Token\TypographyToken; + +#[CoversClass(TokenTreeBuilder::class)] +#[CoversClass(TokenResolution::class)] +final class TokenTreeBuilderTest extends TestCase +{ + private TemporaryDirectory $directory; + + protected function setUp(): void + { + $this->directory = new TemporaryDirectory(); + } + + protected function tearDown(): void + { + $this->directory->remove(); + } + + public function testResolvesAllDtcgTypesAndComposites(): void + { + $result = new TokenTreeBuilder()->resolve([ + 'color' => ['$type' => 'color', '$value' => TokenValues::color()], + 'dimension' => ['$type' => 'dimension', '$value' => TokenValues::dimension(16)], + 'duration' => ['$type' => 'duration', '$value' => TokenValues::dimension(300, 'ms')], + 'number' => ['$type' => 'number', '$value' => 42], + 'weight' => ['$type' => 'fontWeight', '$value' => 700], + 'family' => ['$type' => 'fontFamily', '$value' => ['Inter', 'sans-serif']], + 'bezier' => ['$type' => 'cubicBezier', '$value' => [0.4, -0.2, 0.2, 1.2]], + 'stroke' => ['$type' => 'strokeStyle', '$value' => ['dashArray' => [TokenValues::dimension(2)], 'lineCap' => 'round']], + 'gradient' => ['$type' => 'gradient', '$value' => [ + ['color' => TokenValues::color(), 'position' => 0], + ['color' => TokenValues::color(1, 1, 1), 'position' => 1], + ]], + 'border' => ['$type' => 'border', '$value' => TokenValues::border()], + 'shadow' => ['$type' => 'shadow', '$value' => TokenValues::shadow(inset: true)], + 'transition' => ['$type' => 'transition', '$value' => TokenValues::transition()], + 'typography' => ['$type' => 'typography', '$value' => TokenValues::typography()], + ]); + + self::assertInstanceOf(ColorToken::class, $result['color']); + self::assertInstanceOf(DimensionToken::class, $result['dimension']); + self::assertInstanceOf(DurationToken::class, $result['duration']); + self::assertInstanceOf(NumberToken::class, $result['number']); + self::assertInstanceOf(FontWeightToken::class, $result['weight']); + self::assertInstanceOf(FontFamilyToken::class, $result['family']); + self::assertInstanceOf(CubicBezierToken::class, $result['bezier']); + self::assertInstanceOf(StrokeStyleToken::class, $result['stroke']); + self::assertInstanceOf(GradientToken::class, $result['gradient']); + self::assertInstanceOf(BorderToken::class, $result['border']); + self::assertInstanceOf(ShadowToken::class, $result['shadow']); + self::assertStringStartsWith('inset ', (string) $result['shadow']); + self::assertInstanceOf(TransitionToken::class, $result['transition']); + self::assertInstanceOf(TypographyToken::class, $result['typography']); + } + + public function testResolvesExactAliasesPointersComponentsArraysAndInfersAliasType(): void + { + $result = new TokenTreeBuilder()->resolve([ + 'palette' => ['blue' => ['$type' => 'color', '$value' => TokenValues::color()]], + 'alias' => ['$value' => '{palette.blue}'], + 'dimension' => ['$type' => 'dimension', '$value' => [ + 'value' => ['$ref' => '#/numbers/value/$value'], + 'unit' => ['$ref' => '#/units/pixel/$value'], + ]], + 'numbers' => ['value' => ['$type' => 'number', '$value' => 12]], + 'units' => ['pixel' => ['$type' => 'fontFamily', '$value' => 'px']], + 'gradient' => ['$type' => 'gradient', '$value' => [[ + 'color' => ['$ref' => '#/palette/blue/$value'], + 'position' => ['$ref' => '#/positions/$value/0'], + ]]], + 'positions' => ['$type' => 'cubicBezier', '$value' => [0, 0, 1, 1]], + ]); + + self::assertInstanceOf(ColorToken::class, $result['alias']); + self::assertSame('12px', (string) $result['dimension']); + self::assertSame(0, $result['gradient']->getValue()[0]['position']); + } + + public function testJsonPointerSupportsRfc6901UriFragmentEncoding(): void + { + $result = new TokenTreeBuilder()->resolve([ + 'space token' => ['$type' => 'number', '$value' => 12], + 'tilde~slash/name' => ['$type' => 'number', '$value' => 24], + 'encodedSpace' => ['$ref' => '#/space%20token/$value', '$type' => 'number'], + 'encodedEscapes' => ['$ref' => '#/tilde%7E0slash%7E1name/$value', '$type' => 'number'], + ]); + + self::assertSame(12, $result['encodedSpace']->getValue()); + self::assertSame(24, $result['encodedEscapes']->getValue()); + } + + public function testResolvesRootExtendsMetadataAndDeprecationInheritance(): void + { + $extensions = ['org.example.tool' => ['source' => 'test']]; + $result = new TokenTreeBuilder()->resolve([ + 'base' => [ + '$type' => 'dimension', + '$deprecated' => 'Use space.new.', + '$root' => ['$value' => TokenValues::dimension(4)], + 'small' => ['$value' => TokenValues::dimension(8)], + ], + 'derived' => [ + '$extends' => '{base}', + 'small' => ['$type' => 'dimension', '$value' => TokenValues::dimension(12), '$deprecated' => false], + 'large' => ['$type' => 'dimension', '$value' => TokenValues::dimension(16), '$description' => 'Large space', '$extensions' => $extensions], + ], + ]); + + self::assertSame('4px', (string) $result['derived']['$root']); + self::assertFalse($result['derived']['small']->isDeprecated()); + self::assertTrue($result['derived']['large']->isDeprecated()); + self::assertSame('Use space.new.', $result['derived']['large']->getDeprecationMessage()); + self::assertSame('Large space', $result['derived']['large']->getDescription()); + self::assertSame($extensions, $result['derived']['large']->getExtensions()); + } + + public function testGroupRefHasTheSameDeepMergeSemanticsAsExtends(): void + { + $result = new TokenTreeBuilder()->resolve([ + 'base' => [ + '$type' => 'dimension', + '$description' => 'Base group', + 'field' => [ + 'width' => ['$value' => TokenValues::dimension(12), '$description' => 'Inherited token'], + 'gap' => ['$value' => TokenValues::dimension(4)], + ], + ], + 'derived' => [ + '$ref' => '#/base', + '$description' => 'Derived group', + 'field' => [ + 'width' => ['$value' => TokenValues::dimension(24)], + ], + ], + 'copy' => ['$ref' => '#/base'], + ]); + + self::assertSame('24px', (string) $result['derived']['field']['width']); + self::assertNull($result['derived']['field']['width']->getDescription(), 'A local token replaces the complete inherited token.'); + self::assertSame('4px', (string) $result['derived']['field']['gap']); + self::assertSame('12px', (string) $result['copy']['field']['width']); + } + + public function testProcessesLocalRootInheritedAndNestedEntriesInNormativeOrder(): void + { + $result = new TokenTreeBuilder()->resolve([ + 'base' => [ + '$type' => 'dimension', + '$root' => ['$value' => TokenValues::dimension(1)], + 'inherited' => ['$value' => TokenValues::dimension(2)], + 'nested' => ['token' => ['$value' => TokenValues::dimension(3)]], + ], + 'derived' => [ + '$extends' => '{base}', + 'local' => ['$value' => TokenValues::dimension(4)], + ], + ]); + + self::assertSame(['local', '$root', 'inherited', 'nested'], array_keys($result['derived'])); + } + + public function testResolutionDoesNotDestroyAuthoredReferenceExpressions(): void + { + $document = [ + 'base' => ['$type' => 'number', '$value' => 1], + 'alias' => ['$value' => '{base}'], + ]; + + $result = new TokenTreeBuilder()->resolve($document); + + self::assertSame(1, $result['alias']->getValue()); + self::assertSame('{base}', $document['alias']['$value']); + } + + public function testAliasTypeFollowsTheReferenceChainBeforeParentGroupType(): void + { + $result = new TokenTreeBuilder()->resolve([ + 'base' => ['$type' => 'dimension', '$value' => TokenValues::dimension(8)], + 'intermediate' => ['$value' => '{base}'], + 'semantic' => [ + '$type' => 'color', + 'spacing' => ['$value' => '{intermediate}'], + ], + 'pointer' => ['$ref' => '#/intermediate'], + ]); + + self::assertInstanceOf(DimensionToken::class, $result['intermediate']); + self::assertInstanceOf(DimensionToken::class, $result['semantic']['spacing']); + self::assertInstanceOf(DimensionToken::class, $result['pointer']); + } + + public function testMergesSourcesBeforeResolvingAliasesAndPreservesRelativeSourceUris(): void + { + $directory = $this->directory->path(); + $this->directory->write('base/color.tokens.json', [ + 'palette' => ['blue' => ['$type' => 'color', '$value' => TokenValues::color()]], + ]); + $this->directory->write('theme/theme.tokens.json', [ + 'semantic' => ['accent' => ['$type' => 'color', '$ref' => '../base/color.tokens.json#/palette/blue']], + ]); + + $resolver = new TokenTreeBuilder(new JsonDocumentLoader()); + $result = $resolver->resolveSources([ + new ResolverSource(['base' => ['$type' => 'number', '$value' => 1]], $directory), + new ResolverSource([ + 'base' => ['$type' => 'number', '$value' => 2], + 'alias' => ['$value' => '{base}'], + ], $directory), + new ResolverSource([ + 'semantic' => ['accent' => ['$type' => 'color', '$ref' => '../base/color.tokens.json#/palette/blue']], + ], $directory.'/theme', 'theme/theme.tokens.json'), + ]); + + self::assertSame(2, $result['alias']->getValue()); + self::assertInstanceOf(ColorToken::class, $result['semantic']['accent']); + } + + /** + * @param array $document + * @param class-string<\Throwable> $exception + */ + #[DataProvider('invalidDocumentProvider')] + public function testRejectsAnInvalidDocument(array $document, string $exception, string $message): void + { + $this->expectException($exception); + $this->expectExceptionMessage($message); + + new TokenTreeBuilder()->resolve($document); + } + + /** @return iterable, class-string<\Throwable>, string}> */ + public static function invalidDocumentProvider(): iterable + { + $base = ['$type' => 'number', '$value' => 1]; + $size = ['size' => ['$type' => 'dimension', '$value' => TokenValues::dimension(4)]]; + + yield 'name with a dot' => [ + ['bad.name' => $base], + InvalidArgumentException::class, 'Invalid DTCG token or group name "bad.name" at "bad.name".', + ]; + yield 'unknown token property' => [ + ['token' => [...$base, '$unknown' => true]], + InvalidArgumentException::class, 'Unknown DTCG token property "$unknown" at "token".', + ]; + yield 'unknown group property' => [ + ['$schema' => 'https://example.com/non-standard-schema.json'], + InvalidArgumentException::class, 'Unknown DTCG group property "$schema" at "".', + ]; + yield 'both $value and $ref' => [ + ['token' => [...$base, '$ref' => '#/other']], + InvalidArgumentException::class, 'DTCG token "token" must define exactly one of $value or $ref.', + ]; + yield 'token without type' => [ + ['token' => ['$value' => 1]], + InvalidArgumentException::class, 'Every DTCG token must declare, inherit, or reference a $type at "token".', + ]; + yield 'pointer into a value does not inherit the target token type' => [ + ['base' => ['$type' => 'color', '$value' => TokenValues::color()], 'red' => ['$ref' => '#/base/$value/components/0']], + InvalidArgumentException::class, 'Every DTCG token must declare, inherit, or reference a $type at "red".', + ]; + yield 'legacy string type' => [ + ['token' => ['$type' => 'string', '$value' => 'legacy']], + InvalidArgumentException::class, '"string" at "token" is not a DTCG type. Expected one of: color, dimension, fontFamily, fontWeight, duration, cubicBezier, number, strokeStyle, border, transition, shadow, gradient, typography.', + ]; + yield 'nested unknown type named by its token path' => [ + ['font' => ['family' => ['ui' => ['$type' => 'string', '$value' => 'Inter']]]], + InvalidArgumentException::class, '"string" at "font.family.ui" is not a DTCG type.', + ]; + yield 'nested value error named by its token path' => [ + ['color' => ['brand' => ['$type' => 'color', '$value' => ['colorSpace' => 'cmyk', 'components' => [0, 0, 0]]]]], + InvalidArgumentException::class, 'Expected a supported color space for DTCG token at "color.brand.colorSpace".', + ]; + yield 'nested unknown property named by its token path' => [ + ['a' => ['b' => [...$base, '$unknown' => true]]], + InvalidArgumentException::class, 'Unknown DTCG token property "$unknown" at "a.b".', + ]; + yield 'scalar entry' => [ + ['entry' => 'scalar'], + InvalidArgumentException::class, 'DTCG entry "entry" must be a token or group object.', + ]; + yield 'scalar $root' => [ + ['$root' => 'scalar'], + InvalidArgumentException::class, 'DTCG $root at "" must be a token object.', + ]; + yield 'child under a token' => [ + ['space' => ['$type' => 'dimension', '$value' => TokenValues::dimension(4), 'large' => ['$value' => TokenValues::dimension(8)]]], + InvalidArgumentException::class, 'DTCG token "space" cannot hold the child "large": a node defining $value or $ref is a token, not a group.', + ]; + + yield 'non-string $description' => [ + ['token' => [...$base, '$description' => 42]], + InvalidArgumentException::class, 'DTCG $description at "token" must be a string.', + ]; + yield 'list $extensions' => [ + ['token' => [...$base, '$extensions' => []]], + InvalidArgumentException::class, 'DTCG $extensions at "token" must be an object.', + ]; + yield 'list $deprecated' => [ + ['token' => [...$base, '$deprecated' => []]], + InvalidArgumentException::class, 'DTCG $deprecated at "token" must be a boolean or string.', + ]; + + yield 'missing curly target' => [ + ['alias' => ['$type' => 'number', '$value' => '{missing}']], + RuntimeException::class, 'Reference target not found: "{missing}".', + ]; + yield 'missing curly target without type' => [ + ['base' => $base, 'alias' => ['$value' => '{missing}']], + RuntimeException::class, 'Reference target not found: "{missing}".', + ]; + yield 'curly reference to a group' => [ + ['group' => ['token' => $base], 'alias' => ['$type' => 'number', '$value' => '{group}']], + RuntimeException::class, 'Curly brace reference must target a token: "group".', + ]; + yield 'curly reference to $value' => [ + ['base' => $base, 'alias' => ['$value' => '{base.$value}']], + InvalidArgumentException::class, 'Invalid DTCG token or group name "$value" at "base.$value".', + ]; + yield 'curly reference into $value' => [ + ['base' => $base, 'alias' => ['$type' => 'number', '$value' => '{base.$value.0}']], + InvalidArgumentException::class, 'Invalid DTCG token or group name "$value" at "base.$value.0".', + ]; + yield 'curly reference to $type' => [ + ['space' => ['$type' => 'dimension', 'small' => ['$value' => TokenValues::dimension(4)]], 'gap' => ['$type' => 'dimension', '$value' => '{space.$type}']], + InvalidArgumentException::class, 'Invalid DTCG token or group name "$type" at "space.$type".', + ]; + yield 'curly reference through an array' => [ + ['alias' => ['$type' => 'number', '$value' => '{data.items.0}'], 'data' => ['items' => [1]]], + RuntimeException::class, 'Curly brace references cannot access array elements: "{data.items.0}".', + ]; + yield 'curly reference into a token value' => [ + ['font' => ['stack' => ['$type' => 'fontFamily', '$value' => ['Inter', 'sans-serif']]], 'alias' => ['$type' => 'fontFamily', '$value' => '{font.stack.0}']], + RuntimeException::class, 'Reference "{font.stack.0}" cannot address "0" inside the value of token "font.stack"; use a $ref JSON Pointer.', + ]; + yield 'two-step curly cycle' => [ + ['a' => ['$type' => 'number', '$value' => '{b}'], 'b' => ['$type' => 'number', '$value' => '{a}']], + RuntimeException::class, 'Circular reference detected: "curly:b -> curly:a -> curly:b".', + ]; + yield 'three-step curly cycle names every reference' => [ + ['a' => ['$type' => 'number', '$value' => '{b}'], 'b' => ['$type' => 'number', '$value' => '{c}'], 'c' => ['$type' => 'number', '$value' => '{a}']], + RuntimeException::class, 'Circular reference detected: "curly:b -> curly:c -> curly:a -> curly:b".', + ]; + + yield 'pointer without #' => [ + ['base' => $base, 'alias' => ['$type' => 'number', '$ref' => 'not-a-pointer']], + RuntimeException::class, 'Invalid JSON Pointer reference: "not-a-pointer".', + ]; + yield 'non-string pointer' => [ + ['token' => ['$type' => 'number', '$ref' => 42]], + InvalidArgumentException::class, 'DTCG token $ref at "token" must be a JSON Pointer string.', + ]; + yield 'missing pointer target' => [ + ['base' => $base, 'alias' => ['$ref' => '#/missing']], + RuntimeException::class, 'Reference target not found: "#/missing".', + ]; + yield 'pointer to its own value' => [ + ['token' => ['$type' => 'number', '$ref' => '#/token/$value']], + RuntimeException::class, 'Reference target not found: "#/token/$value".', + ]; + yield 'invalid pointer escape' => [ + ['base' => $base, 'alias' => ['$type' => 'number', '$ref' => '#/base~2/$value']], + InvalidArgumentException::class, 'Invalid JSON Pointer escape in segment "base~2".', + ]; + yield 'invalid percent-encoding' => [ + ['value' => ['$type' => 'number', '$value' => 12], 'invalid' => ['$ref' => '#/value%2/$value', '$type' => 'number']], + InvalidArgumentException::class, 'Invalid percent-encoding in JSON Pointer fragment: "/value%2/$value".', + ]; + yield 'external pointer without a loader' => [ + ['base' => $base, 'alias' => ['$type' => 'number', '$ref' => 'external.json#/base']], + RuntimeException::class, 'Cannot load referenced token document "/external.json" without a loader.', + ]; + yield 'whole-document pointer as a value' => [ + ['alias' => ['$type' => 'number', '$value' => ['$ref' => '#']]], + RuntimeException::class, 'Circular reference detected: "pointer:# -> pointer:#".', + ]; + + yield 'non-string $extends' => [ + ['group' => ['$extends' => 42]], + InvalidArgumentException::class, '$extends must be a reference string at "group".', + ]; + yield 'plain word $extends' => [ + ['group' => ['$extends' => 'invalid']], + InvalidArgumentException::class, '$extends must be a curly brace reference or a JSON Pointer, got "invalid".', + ]; + yield 'dotted path $extends names both accepted forms' => [ + ['base' => $size, 'derived' => ['$extends' => 'base.size']], + InvalidArgumentException::class, '$extends must be a curly brace reference or a JSON Pointer, got "base.size".', + ]; + yield 'invalid pointer fragment in $extends' => [ + ['group' => ['$extends' => '#not-a-pointer']], + InvalidArgumentException::class, 'Invalid JSON Pointer fragment: "not-a-pointer".', + ]; + yield '$extends to a token' => [ + ['token' => $base, 'group' => ['$extends' => '{token}']], + InvalidArgumentException::class, '$extends target must be a group: "{token}".', + ]; + yield '$extends to an external reference' => [ + ['base' => ['$ref' => 'external.tokens.json#/group'], 'derived' => ['$extends' => '{base}']], + InvalidArgumentException::class, '$extends target must be a group: "{base}".', + ]; + yield '$extends into a token value' => [ + ['base' => $size, 'derived' => ['$extends' => '{base.size.inner}']], + RuntimeException::class, 'Reference "{base.size.inner}" cannot address "inner" inside the value of token "base.size".', + ]; + yield 'two-step $extends cycle' => [ + ['a' => ['$extends' => '{b}'], 'b' => ['$extends' => '{a}']], + RuntimeException::class, 'Circular group extension detected: "a -> b -> a".', + ]; + yield 'three-step $extends cycle names every group' => [ + ['groupA' => ['$extends' => '{groupB}'], 'groupB' => ['$extends' => '{groupC}'], 'groupC' => ['$extends' => '{groupA}']], + RuntimeException::class, 'Circular group extension detected: "groupA -> groupB -> groupC -> groupA".', + ]; + } + + public function testMergesFilesBeforeResolvingTheirReferences(): void + { + $base = $this->directory->write('base.tokens.json', [ + 'group' => ['$type' => 'number', 'base' => ['$value' => 1]], + ]); + $alias = $this->directory->write('alias.tokens.json', [ + 'group' => [ + 'alias' => ['$ref' => 'base.tokens.json#/group/base'], + 'component' => ['$ref' => 'base.tokens.json#/group/base/$value'], + ], + ]); + + $resolver = new TokenTreeBuilder(new JsonDocumentLoader()); + $single = $resolver->resolveSources([self::fileSource($base)]); + $merged = $resolver->resolveSources([self::fileSource($base), self::fileSource($alias)]); + + self::assertSame(1, $single['group']['base']->getValue()); + self::assertSame(1, $merged['group']['alias']->getValue()); + self::assertSame(1, $merged['group']['component']->getValue()); + } + + public function testResolveSourcesHandlesEmptyAndRejectsInvalidDescriptors(): void + { + $resolver = new TokenTreeBuilder(); + self::assertSame([], $resolver->resolveSources([])); + + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('must be a ResolverSource'); + $resolver->resolveSources([42]); + } + + public function testReportsWinningAndOverriddenSources(): void + { + $foundation = new ResolverSource([ + 'color' => [ + 'brand' => ['$type' => 'color', '$value' => TokenValues::color()], + 'text' => ['$type' => 'color', '$value' => TokenValues::color(0.1, 0.1, 0.1)], + ], + ], '/tokens', 'foundation.tokens.json'); + $brand = new ResolverSource([ + 'color' => [ + 'brand' => ['$type' => 'color', '$value' => TokenValues::color(0.9, 0.1, 0.1)], + ], + ], '/tokens', 'brand.tokens.json'); + + $resolution = new TokenTreeBuilder()->resolveWithProvenance([$foundation, $brand]); + + self::assertSame('color(srgb 0.9 0.1 0.1)', (string) $resolution->getTokens()['color']['brand']); + self::assertSame($brand, $resolution->getSource('color.brand')); + self::assertSame([$foundation], $resolution->getOverrides('color.brand')); + self::assertSame($foundation, $resolution->getSource('color.text')); + self::assertSame([], $resolution->getOverrides('color.text')); + self::assertNull($resolution->getSource('color.missing')); + self::assertSame([], $resolution->getOverrides('color.missing')); + } + + public function testReportsProvenanceForInlineSources(): void + { + $source = new ResolverSource([ + 'space' => ['$type' => 'dimension', 'md' => ['$value' => TokenValues::dimension(16)]], + ], '/tokens'); + + $resolution = new TokenTreeBuilder()->resolveWithProvenance([$source]); + + self::assertSame($source, $resolution->getSource('space.md')); + } + + public function testProvenanceTellsTwoFragmentsOfOneFileApart(): void + { + $light = new ResolverSource(['fg' => ['$type' => 'number', '$value' => 1]], '/x', 'theme.json#/light'); + $dark = new ResolverSource(['fg' => ['$type' => 'number', '$value' => 2]], '/x', 'theme.json#/dark'); + + $resolution = new TokenTreeBuilder()->resolveWithProvenance([$light, $dark]); + + self::assertSame($dark, $resolution->getSource('fg')); + self::assertSame([$light], $resolution->getOverrides('fg')); + } + + public function testInfersTypesThroughInheritedCurlyAndPointerTargets(): void + { + $result = new TokenTreeBuilder()->resolve([ + 'group' => [ + '$type' => 'number', + 'base' => ['$value' => 1], + ], + 'curly' => ['$value' => '{group.base}'], + 'pointerToken' => ['$ref' => '#/group/base'], + 'pointerComponent' => ['$ref' => '#/group/base/$value'], + 'refChain' => ['$ref' => '#/pointerToken'], + ]); + + self::assertInstanceOf(NumberToken::class, $result['curly']); + self::assertInstanceOf(NumberToken::class, $result['pointerToken']); + self::assertInstanceOf(NumberToken::class, $result['pointerComponent']); + self::assertInstanceOf(NumberToken::class, $result['refChain']); + } + + public function testAValueReferenceObjectTakesTheTypeOfItsTarget(): void + { + $tokens = new TokenTreeBuilder()->resolve([ + 'base' => ['$type' => 'color', '$value' => TokenValues::color()], + 'alias' => ['$value' => ['$ref' => '#/base/$value']], + 'token' => ['$value' => ['$ref' => '#/base']], + ]); + + self::assertInstanceOf(ColorToken::class, $tokens['alias']); + self::assertSame('color(srgb 0.2 0.4 0.8)', (string) $tokens['alias']); + self::assertInstanceOf(ColorToken::class, $tokens['token']); + } + + public function testSupportsAbsoluteExternalPointers(): void + { + $external = $this->directory->write('external.tokens.json', '{"base":{"$type":"number","$value":3}}'); + $source = $this->directory->write('source.tokens.json', [ + 'alias' => ['$ref' => $external.'#/base'], + ]); + + $result = new TokenTreeBuilder(new JsonDocumentLoader())->resolveSources([self::fileSource($source)]); + self::assertSame(3, $result['alias']->getValue()); + } + + public function testRecognizesMetadataOnlyGroups(): void + { + $result = new TokenTreeBuilder()->resolve([ + 'base' => ['$description' => 'Metadata only'], + 'derived' => ['$extends' => '{base}'], + ]); + + self::assertSame(['$description' => 'Metadata only'], $result['derived']); + self::assertSame(['$description' => 'Metadata only'], $result['base']); + } + + /** @param array $document */ + #[DataProvider('referenceTargetProvider')] + public function testAReferenceReachesItsTarget(array $document, string $path, string $expected): void + { + $token = new TokenTreeBuilder()->resolve($document); + foreach (explode('.', $path) as $name) { + $token = $token[$name]; + } + + self::assertSame($expected, (string) $token); + } + + /** @return iterable, string, string}> */ + public static function referenceTargetProvider(): iterable + { + $space = ['$type' => 'dimension', 'base' => ['$value' => TokenValues::dimension(16)]]; + $font = ['$type' => 'fontFamily', 'stack' => ['$value' => ['Inter', 'sans-serif']]]; + + yield 'pointer to a whole token node' => [ + ['space' => $space, 'alias' => ['$ref' => '#/space/base']], + 'alias', '16px', + ]; + yield 'pointer to an object value' => [ + ['space' => $space, 'alias' => ['$type' => 'dimension', '$ref' => '#/space/base/$value']], + 'alias', '16px', + ]; + yield 'pointer to a member of an object value' => [ + ['space' => $space, 'alias' => ['$type' => 'number', '$ref' => '#/space/base/$value/value']], + 'alias', '16', + ]; + yield 'pointer to an element of an array value' => [ + ['font' => $font, 'alias' => ['$type' => 'fontFamily', '$ref' => '#/font/stack/$value/0']], + 'alias', 'Inter', + ]; + yield 'group pointer merges the target group' => [ + ['base' => ['$type' => 'dimension', 'small' => ['$value' => TokenValues::dimension(4)]], 'derived' => ['$ref' => '#/base']], + 'derived.small', '4px', + ]; + yield 'curly reference to the reserved $root token' => [ + [ + 'space' => ['$type' => 'dimension', '$root' => ['$value' => TokenValues::dimension(8)], 'small' => ['$value' => TokenValues::dimension(4)]], + 'gap' => ['$type' => 'dimension', '$value' => '{space.$root}'], + ], + 'gap', '8px', + ]; + } + + public function testSameDocumentPointerSeesTheMergedTree(): void + { + $tokens = new TokenTreeBuilder()->resolveSources([ + new ResolverSource(['brand' => ['$type' => 'number', '$value' => 1]], ''), + new ResolverSource(['link' => ['$ref' => '#/brand']], ''), + new ResolverSource(['brand' => ['$type' => 'number', '$value' => 2]], ''), + ]); + + self::assertSame(2, $tokens['link']->getValue()); + } + + /** + * @param array> $files + * @param list $sources + */ + #[DataProvider('loadedReferenceProvider')] + public function testResolvesAReferenceThroughTheLoader(array $files, array $sources, int $expected): void + { + $tokens = new TokenTreeBuilder(new ArrayDocumentLoader($files))->resolveSources($sources); + + self::assertSame($expected, $tokens['alias']->getValue()); + } + + /** @return iterable>, list, int}> */ + public static function loadedReferenceProvider(): iterable + { + yield 'a pointer inside a referenced file stays in that file' => [ + ['/t/palette.json' => ['a' => ['$ref' => '#/b'], 'b' => ['$type' => 'number', '$value' => 7]]], + [new ResolverSource(['alias' => ['$ref' => 'palette.json#/a'], 'b' => ['$type' => 'number', '$value' => 1]], '/t', '/t/main.json')], + 7, + ]; + + $theme = ['light' => ['fg' => ['$type' => 'number', '$value' => 1]], 'dark' => ['fg' => ['$type' => 'number', '$value' => 2]]]; + yield 'a fragment source does not hide its file' => [ + ['/t/theme.json' => $theme], + [ + new ResolverSource($theme['light'], '/t', 'theme.json#/light'), + new ResolverSource(['alias' => ['$ref' => 'theme.json#/dark/fg']], '/t'), + ], + 2, + ]; + yield 'a relative source keeps its references relative' => [ + ['palette.json' => ['c' => ['$type' => 'number', '$value' => 3]]], + [new ResolverSource(['alias' => ['$ref' => 'palette.json#/c']], '', 'base.tokens.json')], + 3, + ]; + } + + public function testNumericTopLevelNamesSurviveResolution(): void + { + $tokens = new TokenTreeBuilder()->resolveSources([ + new ResolverSource(json_decode('{"100":{"$type":"number","$value":1}}', true, 512, \JSON_THROW_ON_ERROR), ''), + ]); + + self::assertSame([100], array_keys($tokens)); + } + + public function testAPartialDocumentLeavesOutTheTokensThatNeedAnotherSource(): void + { + $tokens = new TokenTreeBuilder()->resolvePartial(new ResolverSource([ + 'color' => [ + '$type' => 'color', + 'local' => ['$value' => ['colorSpace' => 'srgb', 'components' => [0, 0, 1]]], + 'alias' => ['$value' => '{color.local}'], + 'brand' => ['$value' => '{color.palette.brand}'], + 'pointer' => ['$ref' => '#/color/palette/accent'], + 'chained' => ['$value' => '{color.brand}'], + ], + 'button' => ['$extends' => '{component.base}', 'gap' => ['$type' => 'number', '$value' => 2]], + ], '')); + + self::assertSame(['local', 'alias'], array_keys($tokens['color'])); + self::assertSame(['gap'], array_keys($tokens['button'])); + } + + /** @param array $document */ + #[DataProvider('invalidPartialDocumentProvider')] + public function testAPartialDocumentIsStillValidated(array $document, string $message): void + { + $this->expectException(ExceptionInterface::class); + $this->expectExceptionMessage($message); + + new TokenTreeBuilder()->resolvePartial(new ResolverSource($document, '')); + } + + /** @return iterable, string}> */ + public static function invalidPartialDocumentProvider(): iterable + { + yield 'an invalid local value' => [['gap' => ['$type' => 'dimension', '$value' => '12px']], 'structured dimension']; + yield 'an alias to a group it defines' => [['color' => ['palette' => ['a' => ['$type' => 'number', '$value' => 1]], 'alias' => ['$type' => 'number', '$value' => '{color.palette}']]], 'must target a token']; + } + + public function testAPartialDocumentStillRejectsAMissingTargetInAnotherFile(): void + { + $this->directory->write('palette.tokens.json', '{"gap":{"$type":"number","$value":1}}'); + + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('Reference target not found: "palette.tokens.json#/missing"'); + + new TokenTreeBuilder(new JsonDocumentLoader())->resolvePartial(new ResolverSource(['gap' => ['$ref' => 'palette.tokens.json#/missing']], $this->directory->path(), $this->directory->path().'/theme.tokens.json')); + } + + public function testAFullDocumentStillRejectsAMissingTarget(): void + { + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('Reference target not found: "{color.palette.brand}"'); + + new TokenTreeBuilder()->resolveSources([new ResolverSource(['brand' => ['$type' => 'color', '$value' => '{color.palette.brand}']], '')]); + } + + private static function fileSource(string $path): ResolverSource + { + return new ResolverSource(new JsonDocumentLoader()->load($path), \dirname($path), $path); + } +} diff --git a/src/DesignTokens/tests/Unit/Token/AbstractTokenTest.php b/src/DesignTokens/tests/Unit/Token/AbstractTokenTest.php new file mode 100644 index 00000000000..85bd29898af --- /dev/null +++ b/src/DesignTokens/tests/Unit/Token/AbstractTokenTest.php @@ -0,0 +1,109 @@ + + * + * 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\Token; + +use PHPUnit\Framework\Attributes\CoversClass; +use PHPUnit\Framework\Attributes\DataProvider; +use PHPUnit\Framework\TestCase; +use Symfony\UX\DesignTokens\Token\AbstractToken; +use Symfony\UX\DesignTokens\Token\Css\CssValue; + +#[CoversClass(AbstractToken::class)] +final class AbstractTokenTest extends TestCase +{ + public function testConstructorStoresValue(): void + { + $token = new TestToken('hello'); + + self::assertSame('hello', $token->getValue()); + } + + public function testConstructorDefaultsDescriptionToNull(): void + { + $token = new TestToken('hello'); + + self::assertNull($token->getDescription()); + } + + public function testConstructorDefaultsExtensionsToEmptyArray(): void + { + $token = new TestToken('hello'); + + self::assertSame([], $token->getExtensions()); + } + + public function testConstructorStoresDescription(): void + { + $token = new TestToken('hello', 'A description'); + + self::assertSame('A description', $token->getDescription()); + } + + public function testConstructorStoresExtensions(): void + { + $extensions = ['com.figma' => ['styleId' => 'S:abc123']]; + $token = new TestToken('hello', null, $extensions); + + self::assertSame($extensions, $token->getExtensions()); + } + + #[DataProvider('deprecationProvider')] + public function testDeprecationSplitsTheFlagFromTheMessage(bool|string|null $deprecated, bool $expectedFlag, ?string $expectedMessage): void + { + $token = new TestToken(1, deprecated: $deprecated); + + self::assertSame($expectedFlag, $token->isDeprecated()); + self::assertSame($expectedMessage, $token->getDeprecationMessage()); + } + + /** @return iterable */ + public static function deprecationProvider(): iterable + { + yield 'absent' => [null, false, null]; + yield 'undeprecated' => [false, false, null]; + yield 'flag' => [true, true, null]; + yield 'message' => ['Use space.new.', true, 'Use space.new.']; + yield 'empty message' => ['', true, null]; + } + + #[DataProvider('valueProvider')] + public function testConstructorAcceptsAnyValueType(mixed $value): void + { + $token = new TestToken($value); + + self::assertSame($value, $token->getValue()); + } + + /** @return iterable */ + public static function valueProvider(): iterable + { + yield 'string' => ['solid']; + yield 'integer' => [42]; + yield 'float' => [1.618]; + yield 'array' => [['a', 'b']]; + yield 'null' => [null]; + } +} + +/** @extends AbstractToken */ +final class TestToken extends AbstractToken +{ + public function getType(): string + { + return 'test'; + } + + public function __toString(): string + { + return CssValue::stringify($this->getValue()); + } +} diff --git a/src/DesignTokens/tests/Unit/Token/BorderTokenTest.php b/src/DesignTokens/tests/Unit/Token/BorderTokenTest.php new file mode 100644 index 00000000000..3d46eed93c3 --- /dev/null +++ b/src/DesignTokens/tests/Unit/Token/BorderTokenTest.php @@ -0,0 +1,51 @@ + + * + * 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\Token; + +use PHPUnit\Framework\Attributes\CoversClass; +use PHPUnit\Framework\Attributes\DataProvider; +use PHPUnit\Framework\TestCase; +use Symfony\UX\DesignTokens\Token\BorderToken; + +#[CoversClass(BorderToken::class)] +final class BorderTokenTest extends TestCase +{ + public function testTypeIsBorder(): void + { + self::assertSame('border', new BorderToken(self::border(1, 'solid'))->getType()); + } + + /** @param string|array $style */ + #[DataProvider('borders')] + public function testToStringFormatsTheShorthand(int|float $width, string|array $style, string $expected): void + { + self::assertSame($expected, (string) new BorderToken(self::border($width, $style))); + } + + /** @return iterable, string}> */ + public static function borders(): iterable + { + yield 'thin solid' => [1, 'solid', '1px solid color(srgb 0 0 0)']; + yield 'thick dashed' => [4, 'dashed', '4px dashed color(srgb 0 0 0)']; + yield 'structured stroke' => [2, ['dashArray' => [['value' => 4, 'unit' => 'px']], 'lineCap' => 'round'], '2px dashed color(srgb 0 0 0)']; + } + + /** + * @param string|array $style + * + * @return array + */ + private static function border(int|float $width, string|array $style): array + { + return ['width' => ['value' => $width, 'unit' => 'px'], 'style' => $style, 'color' => ['colorSpace' => 'srgb', 'components' => [0, 0, 0]]]; + } +} diff --git a/src/DesignTokens/tests/Unit/Token/ColorTokenTest.php b/src/DesignTokens/tests/Unit/Token/ColorTokenTest.php new file mode 100644 index 00000000000..f4362f0ebb5 --- /dev/null +++ b/src/DesignTokens/tests/Unit/Token/ColorTokenTest.php @@ -0,0 +1,46 @@ + + * + * 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\Token; + +use PHPUnit\Framework\Attributes\CoversClass; +use PHPUnit\Framework\Attributes\DataProvider; +use PHPUnit\Framework\TestCase; +use Symfony\UX\DesignTokens\Token\ColorToken; + +#[CoversClass(ColorToken::class)] +final class ColorTokenTest extends TestCase +{ + public function testTypeIsColor(): void + { + self::assertSame('color', new ColorToken(['colorSpace' => 'srgb', 'components' => [0.2, 0.4, 0.8]])->getType()); + } + + /** @param array $value */ + #[DataProvider('colors')] + public function testToStringIsACssColor(array $value, string $expected): void + { + self::assertSame($expected, (string) new ColorToken($value)); + } + + /** @return iterable, string}> */ + public static function colors(): iterable + { + yield 'srgb' => [['colorSpace' => 'srgb', 'components' => [0.2, 0.4, 0.8]], 'color(srgb 0.2 0.4 0.8)']; + yield 'srgb with alpha' => [['colorSpace' => 'srgb', 'components' => [0.2, 0.4, 0.8], 'alpha' => 0.5], 'color(srgb 0.2 0.4 0.8 / 0.5)']; + yield 'oklch' => [['colorSpace' => 'oklch', 'components' => [0.7, 0.15, 210]], 'oklch(70% 0.15 210)']; + } + + public function testImplementsStringable(): void + { + self::assertInstanceOf(\Stringable::class, new ColorToken(['colorSpace' => 'oklch', 'components' => [0.7, 0.15, 210]])); + } +} diff --git a/src/DesignTokens/tests/Unit/Token/Css/CssValueTest.php b/src/DesignTokens/tests/Unit/Token/Css/CssValueTest.php new file mode 100644 index 00000000000..7587dddf413 --- /dev/null +++ b/src/DesignTokens/tests/Unit/Token/Css/CssValueTest.php @@ -0,0 +1,70 @@ + + * + * 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\Token\Css; + +use PHPUnit\Framework\Attributes\CoversClass; +use PHPUnit\Framework\TestCase; +use Symfony\UX\DesignTokens\Token\Css\CssValue; + +#[CoversClass(CssValue::class)] +final class CssValueTest extends TestCase +{ + public function testStringifyHandlesPrimitiveAndStructuredValues(): void + { + self::assertSame('16px', CssValue::stringify(['value' => 16, 'unit' => 'px'])); + self::assertSame('true', CssValue::stringify(true)); + self::assertSame('42', CssValue::stringify(42)); + self::assertSame('Inter, sans-serif', CssValue::stringify(['Inter', 'sans-serif'])); + self::assertSame('', CssValue::stringify(null)); + } + + public function testAnOpaqueColorOmitsItsAlpha(): void + { + self::assertSame('color(srgb 0.1 0.3 0.8)', CssValue::stringify(['colorSpace' => 'srgb', 'components' => [0.1, 0.3, 0.8], 'alpha' => 1])); + self::assertSame('oklch(62% 0.18 255)', CssValue::stringify(['colorSpace' => 'oklch', 'components' => [0.62, 0.18, 255], 'alpha' => 1.0])); + self::assertSame('hsl(120 50% 25% / 0.99)', CssValue::stringify(['colorSpace' => 'hsl', 'components' => [120, 50, 25], 'alpha' => 0.99])); + } + + public function testStringifyHandlesColorSpacesAndAlpha(): void + { + self::assertSame('color(srgb 0.2 0.4 0.8 / 0.5)', CssValue::stringify([ + 'colorSpace' => 'srgb', + 'components' => [0.2, 0.4, 0.8], + 'alpha' => 0.5, + ])); + self::assertSame('hsl(120 50% 25%)', CssValue::stringify([ + 'colorSpace' => 'hsl', + 'components' => [120, 50, 25], + ])); + self::assertSame('hwb(120 50% 25% / 0.25)', CssValue::stringify([ + 'colorSpace' => 'hwb', + 'components' => [120, 50, 25], + 'alpha' => 0.25, + ])); + self::assertSame('lab(50% 20 30)', CssValue::stringify([ + 'colorSpace' => 'lab', + 'components' => [50, 20, 30], + ])); + self::assertSame('oklch(72% 0.11 221.19)', CssValue::stringify([ + 'colorSpace' => 'oklch', + 'components' => [0.72, 0.11, 221.19], + ])); + self::assertSame('oklch(0.001% 0.00002 0)', CssValue::stringify([ + 'colorSpace' => 'oklch', + 'components' => [0.00001, 0.00002, 0], + ])); + self::assertSame('color(srgb 0.000001 0 1)', CssValue::stringify([ + 'colorSpace' => 'srgb', + 'components' => [0.000001, 0, 1], + ])); + } +} diff --git a/src/DesignTokens/tests/Unit/Token/CubicBezierTokenTest.php b/src/DesignTokens/tests/Unit/Token/CubicBezierTokenTest.php new file mode 100644 index 00000000000..3bc803a13bd --- /dev/null +++ b/src/DesignTokens/tests/Unit/Token/CubicBezierTokenTest.php @@ -0,0 +1,44 @@ + + * + * 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\Token; + +use PHPUnit\Framework\Attributes\CoversClass; +use PHPUnit\Framework\Attributes\DataProvider; +use PHPUnit\Framework\TestCase; +use Symfony\UX\DesignTokens\Token\CubicBezierToken; + +#[CoversClass(CubicBezierToken::class)] +final class CubicBezierTokenTest extends TestCase +{ + public function testTypeIsCubicBezier(): void + { + self::assertSame('cubicBezier', new CubicBezierToken([0, 0, 1, 1])->getType()); + } + + /** @param list $bezier */ + #[DataProvider('bezierProvider')] + public function testToStringFormatsCubicBezier(array $bezier, string $expected): void + { + self::assertSame($expected, (string) new CubicBezierToken($bezier)); + } + + /** @return iterable, string}> */ + public static function bezierProvider(): iterable + { + yield 'linear' => [[0, 0, 1, 1], 'cubic-bezier(0, 0, 1, 1)']; + yield 'ease' => [[0.25, 0.1, 0.25, 1], 'cubic-bezier(0.25, 0.1, 0.25, 1)']; + yield 'ease-in' => [[0.42, 0, 1, 1], 'cubic-bezier(0.42, 0, 1, 1)']; + yield 'ease-out' => [[0, 0, 0.58, 1], 'cubic-bezier(0, 0, 0.58, 1)']; + yield 'spring' => [[0.5, -0.5, 0.5, 1.5], 'cubic-bezier(0.5, -0.5, 0.5, 1.5)']; + yield 'tiny control point' => [[0.00001, 0, 0.5, 1], 'cubic-bezier(0.00001, 0, 0.5, 1)']; + } +} diff --git a/src/DesignTokens/tests/Unit/Token/DimensionTokenTest.php b/src/DesignTokens/tests/Unit/Token/DimensionTokenTest.php new file mode 100644 index 00000000000..42ed60cf822 --- /dev/null +++ b/src/DesignTokens/tests/Unit/Token/DimensionTokenTest.php @@ -0,0 +1,43 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\UX\DesignTokens\Tests\Unit\Token; + +use PHPUnit\Framework\Attributes\CoversClass; +use PHPUnit\Framework\Attributes\DataProvider; +use PHPUnit\Framework\TestCase; +use Symfony\UX\DesignTokens\Token\DimensionToken; + +#[CoversClass(DimensionToken::class)] +final class DimensionTokenTest extends TestCase +{ + public function testTypeIsDimension(): void + { + self::assertSame('dimension', new DimensionToken(['value' => 16, 'unit' => 'px'])->getType()); + } + + /** @param array{value: int|float, unit: string} $value */ + #[DataProvider('dimensions')] + public function testToStringIsACssLength(array $value, string $expected): void + { + self::assertSame($expected, (string) new DimensionToken($value)); + } + + /** @return iterable */ + public static function dimensions(): iterable + { + yield 'px' => [['value' => 16, 'unit' => 'px'], '16px']; + yield 'rem' => [['value' => 1.5, 'unit' => 'rem'], '1.5rem']; + yield 'negative' => [['value' => -0.02, 'unit' => 'rem'], '-0.02rem']; + yield 'zero' => [['value' => 0, 'unit' => 'px'], '0px']; + yield 'tiny' => [['value' => 0.00001, 'unit' => 'rem'], '0.00001rem']; + } +} diff --git a/src/DesignTokens/tests/Unit/Token/DurationTokenTest.php b/src/DesignTokens/tests/Unit/Token/DurationTokenTest.php new file mode 100644 index 00000000000..465c4b1977a --- /dev/null +++ b/src/DesignTokens/tests/Unit/Token/DurationTokenTest.php @@ -0,0 +1,41 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\UX\DesignTokens\Tests\Unit\Token; + +use PHPUnit\Framework\Attributes\CoversClass; +use PHPUnit\Framework\Attributes\DataProvider; +use PHPUnit\Framework\TestCase; +use Symfony\UX\DesignTokens\Token\DurationToken; + +#[CoversClass(DurationToken::class)] +final class DurationTokenTest extends TestCase +{ + public function testTypeIsDuration(): void + { + self::assertSame('duration', new DurationToken(['value' => 0, 'unit' => 'ms'])->getType()); + } + + /** @param array{value: int|float, unit: string} $value */ + #[DataProvider('durations')] + public function testToStringIsACssTime(array $value, string $expected): void + { + self::assertSame($expected, (string) new DurationToken($value)); + } + + /** @return iterable */ + public static function durations(): iterable + { + yield 'milliseconds' => [['value' => 300, 'unit' => 'ms'], '300ms']; + yield 'seconds' => [['value' => 0.5, 'unit' => 's'], '0.5s']; + yield 'zero' => [['value' => 0, 'unit' => 'ms'], '0ms']; + } +} diff --git a/src/DesignTokens/tests/Unit/Token/FontFamilyTokenTest.php b/src/DesignTokens/tests/Unit/Token/FontFamilyTokenTest.php new file mode 100644 index 00000000000..da4b45d7714 --- /dev/null +++ b/src/DesignTokens/tests/Unit/Token/FontFamilyTokenTest.php @@ -0,0 +1,53 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\UX\DesignTokens\Tests\Unit\Token; + +use PHPUnit\Framework\Attributes\CoversClass; +use PHPUnit\Framework\Attributes\DataProvider; +use PHPUnit\Framework\TestCase; +use Symfony\UX\DesignTokens\Token\FontFamilyToken; + +#[CoversClass(FontFamilyToken::class)] +final class FontFamilyTokenTest extends TestCase +{ + public function testTypeIsFontFamily(): void + { + self::assertSame('fontFamily', new FontFamilyToken('sans-serif')->getType()); + } + + /** @param string|list $value */ + #[DataProvider('fontFamilyProvider')] + public function testToStringRendersCorrectly(string|array $value, string $expected): void + { + self::assertSame($expected, (string) new FontFamilyToken($value)); + } + + /** @return iterable, string}> */ + public static function fontFamilyProvider(): iterable + { + yield 'generic keyword' => ['sans-serif', 'sans-serif']; + yield 'identifier sequence' => ['Helvetica Neue', 'Helvetica Neue']; + yield 'vendor identifier' => ['-apple-system', '-apple-system']; + yield 'digit in a word' => ['Font Awesome 6 Free', '"Font Awesome 6 Free"']; + yield 'leading digit' => ['1Password', '"1Password"']; + yield 'css-wide keyword' => ['inherit', '"inherit"']; + yield 'comma is part of the name' => ['Inter, sans-serif', '"Inter, sans-serif"']; + yield 'single-item array' => [['Inter'], 'Inter']; + yield 'list' => [['Inter', 'Roboto', 'sans-serif'], 'Inter, Roboto, sans-serif']; + yield 'injection' => ['x; } body { display: none', '"x; } body { display: none"']; + yield 'quote and backslash' => ['a"b\\c', '"a\\"b\\\\c"']; + yield 'newline' => ["a\nb", '"a\\A b"']; + yield 'carriage return' => ["a\r} body{background:red}", '"a\\D } body{background:red}"']; + yield 'form feed' => ["a\fb", '"a\\C b"']; + yield 'nul and delete' => ["a\0b\x7F", '"a\\0 b\\7F "']; + } +} diff --git a/src/DesignTokens/tests/Unit/Token/FontWeightTokenTest.php b/src/DesignTokens/tests/Unit/Token/FontWeightTokenTest.php new file mode 100644 index 00000000000..8c78fdf1602 --- /dev/null +++ b/src/DesignTokens/tests/Unit/Token/FontWeightTokenTest.php @@ -0,0 +1,84 @@ + + * + * 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\Token; + +use PHPUnit\Framework\Attributes\CoversClass; +use PHPUnit\Framework\Attributes\DataProvider; +use PHPUnit\Framework\TestCase; +use Symfony\UX\DesignTokens\Token\FontWeightToken; +use Symfony\UX\DesignTokens\Validation\TokenValueValidator; + +#[CoversClass(FontWeightToken::class)] +final class FontWeightTokenTest extends TestCase +{ + public function testTypeIsFontWeight(): void + { + self::assertSame('fontWeight', new FontWeightToken(400)->getType()); + } + + #[DataProvider('fontWeightProvider')] + public function testToStringReturnsValue(int|string $value, string $expected): void + { + self::assertSame($expected, (string) new FontWeightToken($value)); + } + + /** @return iterable */ + public static function fontWeightProvider(): iterable + { + yield 'thin 100' => [100, '100']; + yield 'regular 400' => [400, '400']; + yield 'bold 700' => [700, '700']; + yield 'black 900' => [900, '900']; + yield 'keyword thin' => ['thin', '100']; + yield 'keyword hairline' => ['hairline', '100']; + yield 'keyword extra-light' => ['extra-light', '200']; + yield 'keyword light' => ['light', '300']; + yield 'keyword normal' => ['normal', '400']; + yield 'keyword regular' => ['regular', '400']; + yield 'keyword book' => ['book', '400']; + yield 'keyword medium' => ['medium', '500']; + yield 'keyword semi-bold' => ['semi-bold', '600']; + yield 'keyword demi-bold' => ['demi-bold', '600']; + yield 'keyword bold' => ['bold', '700']; + yield 'keyword extra-bold' => ['extra-bold', '800']; + yield 'keyword ultra-bold' => ['ultra-bold', '800']; + yield 'keyword black' => ['black', '900']; + yield 'keyword heavy' => ['heavy', '900']; + yield 'keyword extra-black' => ['extra-black', '950']; + yield 'keyword ultra-black' => ['ultra-black', '950']; + yield 'mixed case keyword' => ['Semi-Bold', '600']; + yield 'an unknown keyword is left alone' => ['fantasy', 'fantasy']; + } + + public function testTheAuthoredKeywordSurvivesInTheValue(): void + { + $token = new FontWeightToken('extra-bold'); + + self::assertSame('extra-bold', $token->getValue()); + self::assertSame('800', (string) $token); + } + + public function testEveryKeywordTheValidatorAcceptsHasAWeight(): void + { + $accepted = new \ReflectionClass(TokenValueValidator::class)->getConstant('FONT_WEIGHTS'); + self::assertIsArray($accepted); + + foreach ($accepted as $keyword) { + self::assertIsString($keyword); + self::assertMatchesRegularExpression( + '/^\d{3}$/', + (string) new FontWeightToken($keyword), + \sprintf('The validator accepts "%s" but it has no numeric weight.', $keyword), + ); + } + } +} diff --git a/src/DesignTokens/tests/Unit/Token/GradientTokenTest.php b/src/DesignTokens/tests/Unit/Token/GradientTokenTest.php new file mode 100644 index 00000000000..de293cb7b22 --- /dev/null +++ b/src/DesignTokens/tests/Unit/Token/GradientTokenTest.php @@ -0,0 +1,49 @@ + + * + * 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\Token; + +use PHPUnit\Framework\Attributes\CoversClass; +use PHPUnit\Framework\TestCase; +use Symfony\UX\DesignTokens\Token\GradientToken; + +#[CoversClass(GradientToken::class)] +final class GradientTokenTest extends TestCase +{ + public function testTypeAndCssValue(): void + { + $token = new GradientToken([self::stop(0, 0), self::stop(1, 1)]); + + self::assertSame('gradient', $token->getType()); + self::assertSame('linear-gradient(color(srgb 0 0 0) 0%, color(srgb 1 1 1) 100%)', (string) $token); + } + + public function testClampsPositionsToTheDtcgRange(): void + { + $token = new GradientToken([self::stop(0, -99), self::stop(1, 42)]); + + self::assertSame(0, $token->getValue()[0]['position']); + self::assertSame(1, $token->getValue()[1]['position']); + } + + public function testStopPositionsBecomePercentages(): void + { + $token = new GradientToken([self::stop(0, 0), self::stop(0.5, 0.355), self::stop(1, 1)]); + + self::assertSame('linear-gradient(color(srgb 0 0 0) 0%, color(srgb 0.5 0.5 0.5) 35.5%, color(srgb 1 1 1) 100%)', (string) $token); + } + + /** @return array */ + private static function stop(int|float $grey, int|float $position): array + { + return ['color' => ['colorSpace' => 'srgb', 'components' => [$grey, $grey, $grey]], 'position' => $position]; + } +} diff --git a/src/DesignTokens/tests/Unit/Token/NumberTokenTest.php b/src/DesignTokens/tests/Unit/Token/NumberTokenTest.php new file mode 100644 index 00000000000..9da64f0094a --- /dev/null +++ b/src/DesignTokens/tests/Unit/Token/NumberTokenTest.php @@ -0,0 +1,45 @@ + + * + * 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\Token; + +use PHPUnit\Framework\Attributes\CoversClass; +use PHPUnit\Framework\Attributes\DataProvider; +use PHPUnit\Framework\TestCase; +use Symfony\UX\DesignTokens\Token\NumberToken; + +#[CoversClass(NumberToken::class)] +final class NumberTokenTest extends TestCase +{ + public function testTypeIsNumber(): void + { + self::assertSame('number', new NumberToken(0)->getType()); + } + + #[DataProvider('numberProvider')] + public function testToStringCastsToString(int|float $value, string $expected): void + { + self::assertSame($expected, (string) new NumberToken($value)); + } + + /** @return iterable */ + public static function numberProvider(): iterable + { + yield 'zero int' => [0, '0']; + yield 'positive' => [42, '42']; + yield 'negative' => [-1, '-1']; + yield 'float' => [1.618, '1.618']; + yield 'float zero' => [0.0, '0']; + yield 'small float' => [0.0000001, '0.0000001']; + yield 'small negative float' => [-0.00012345, '-0.00012345']; + yield 'large float' => [1.5e20, '150000000000000000000']; + } +} diff --git a/src/DesignTokens/tests/Unit/Token/ShadowTokenTest.php b/src/DesignTokens/tests/Unit/Token/ShadowTokenTest.php new file mode 100644 index 00000000000..c4d4f4664ee --- /dev/null +++ b/src/DesignTokens/tests/Unit/Token/ShadowTokenTest.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\Token; + +use PHPUnit\Framework\Attributes\CoversClass; +use PHPUnit\Framework\TestCase; +use Symfony\UX\DesignTokens\Token\ShadowToken; + +#[CoversClass(ShadowToken::class)] +final class ShadowTokenTest extends TestCase +{ + public function testTypeIsShadow(): void + { + self::assertSame('shadow', new ShadowToken(self::shadow(0, 4, 8))->getType()); + } + + public function testToStringForOneLayer(): void + { + self::assertSame('0px 4px 8px 0px color(srgb 0 0 0 / 0.2)', (string) new ShadowToken(self::shadow(0, 4, 8))); + } + + public function testToStringForSeveralLayers(): void + { + $token = new ShadowToken([self::shadow(0, 2, 4), self::shadow(0, 8, 16), self::shadow(0, 12, 24)]); + + self::assertSame('0px 2px 4px 0px color(srgb 0 0 0 / 0.2), 0px 8px 16px 0px color(srgb 0 0 0 / 0.2), 0px 12px 24px 0px color(srgb 0 0 0 / 0.2)', (string) $token); + } + + public function testAnInsetLayerStartsWithTheKeyword(): void + { + self::assertStringStartsWith('inset ', (string) new ShadowToken([...self::shadow(0, 1, 2), 'inset' => true])); + } + + /** @return array */ + private static function shadow(int $x, int $y, int $blur): array + { + return [ + 'color' => ['colorSpace' => 'srgb', 'components' => [0, 0, 0], 'alpha' => 0.2], + 'offsetX' => ['value' => $x, 'unit' => 'px'], + 'offsetY' => ['value' => $y, 'unit' => 'px'], + 'blur' => ['value' => $blur, 'unit' => 'px'], + 'spread' => ['value' => 0, 'unit' => 'px'], + ]; + } +} diff --git a/src/DesignTokens/tests/Unit/Token/StrokeStyleTokenTest.php b/src/DesignTokens/tests/Unit/Token/StrokeStyleTokenTest.php new file mode 100644 index 00000000000..5ab2542ed3e --- /dev/null +++ b/src/DesignTokens/tests/Unit/Token/StrokeStyleTokenTest.php @@ -0,0 +1,59 @@ + + * + * 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\Token; + +use PHPUnit\Framework\Attributes\CoversClass; +use PHPUnit\Framework\TestCase; +use Symfony\UX\DesignTokens\Exception\InvalidArgumentException; +use Symfony\UX\DesignTokens\Token\StrokeStyleToken; + +#[CoversClass(StrokeStyleToken::class)] +final class StrokeStyleTokenTest extends TestCase +{ + private const DASHED = [ + 'dashArray' => [ + ['value' => 4, 'unit' => 'px'], + ['value' => 2, 'unit' => 'px'], + ], + 'lineCap' => 'round', + ]; + + public function testTypeAndKeywordValue(): void + { + $token = new StrokeStyleToken('solid'); + + self::assertSame('strokeStyle', $token->getType()); + self::assertSame('solid', (string) $token); + } + + public function testAStructuredStrokeProjectsToTheNearestCssKeyword(): void + { + $token = new StrokeStyleToken(self::DASHED); + + self::assertSame('dashed', (string) $token); + } + + public function testTheDashPatternStaysAvailableForConsumersThatCanUseIt(): void + { + $token = new StrokeStyleToken(self::DASHED); + + self::assertSame(['dashArray' => [['value' => 4, 'unit' => 'px'], ['value' => 2, 'unit' => 'px']], 'lineCap' => 'round'], $token->getValue()); + } + + public function testRejectsAValueThatIsNeitherAKeywordNorADashDefinition(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('keyword or a dash definition'); + + new StrokeStyleToken(42); + } +} diff --git a/src/DesignTokens/tests/Unit/Token/TokenFactoryTest.php b/src/DesignTokens/tests/Unit/Token/TokenFactoryTest.php new file mode 100644 index 00000000000..f5bf39d84ab --- /dev/null +++ b/src/DesignTokens/tests/Unit/Token/TokenFactoryTest.php @@ -0,0 +1,92 @@ + + * + * 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\Token; + +use PHPUnit\Framework\Attributes\CoversClass; +use PHPUnit\Framework\Attributes\DataProvider; +use PHPUnit\Framework\TestCase; +use Symfony\UX\DesignTokens\Token\BorderToken; +use Symfony\UX\DesignTokens\Token\ColorToken; +use Symfony\UX\DesignTokens\Token\CubicBezierToken; +use Symfony\UX\DesignTokens\Token\DimensionToken; +use Symfony\UX\DesignTokens\Token\DurationToken; +use Symfony\UX\DesignTokens\Token\FontFamilyToken; +use Symfony\UX\DesignTokens\Token\FontWeightToken; +use Symfony\UX\DesignTokens\Token\GradientToken; +use Symfony\UX\DesignTokens\Token\NumberToken; +use Symfony\UX\DesignTokens\Token\ShadowToken; +use Symfony\UX\DesignTokens\Token\StrokeStyleToken; +use Symfony\UX\DesignTokens\Token\TokenFactory; +use Symfony\UX\DesignTokens\Token\TransitionToken; +use Symfony\UX\DesignTokens\Token\TypographyToken; + +#[CoversClass(TokenFactory::class)] +final class TokenFactoryTest extends TestCase +{ + /** @return iterable */ + public static function types(): iterable + { + $color = ['colorSpace' => 'srgb', 'components' => [0.2, 0.4, 0.8]]; + $dimension = ['value' => 1, 'unit' => 'rem']; + + yield 'color' => ['color', $color, ColorToken::class]; + yield 'dimension' => ['dimension', $dimension, DimensionToken::class]; + yield 'number' => ['number', 1.5, NumberToken::class]; + yield 'duration' => ['duration', ['value' => 100, 'unit' => 'ms'], DurationToken::class]; + yield 'fontWeight' => ['fontWeight', 700, FontWeightToken::class]; + yield 'fontFamily' => ['fontFamily', ['Inter', 'sans-serif'], FontFamilyToken::class]; + yield 'cubicBezier' => ['cubicBezier', [0.0, 0.0, 1.0, 1.0], CubicBezierToken::class]; + yield 'strokeStyle' => ['strokeStyle', 'solid', StrokeStyleToken::class]; + yield 'gradient' => ['gradient', [['color' => $color, 'position' => 0]], GradientToken::class]; + yield 'typography' => ['typography', ['fontFamily' => ['Inter'], 'fontSize' => $dimension, 'fontWeight' => 400, 'letterSpacing' => $dimension, 'lineHeight' => 1.5], TypographyToken::class]; + yield 'border' => ['border', ['color' => $color, 'width' => $dimension, 'style' => 'solid'], BorderToken::class]; + yield 'shadow' => ['shadow', ['color' => $color, 'offsetX' => $dimension, 'offsetY' => $dimension, 'blur' => $dimension, 'spread' => $dimension], ShadowToken::class]; + yield 'transition' => ['transition', ['duration' => ['value' => 100, 'unit' => 'ms'], 'delay' => ['value' => 0, 'unit' => 'ms'], 'timingFunction' => [0.0, 0.0, 1.0, 1.0]], TransitionToken::class]; + } + + /** @param class-string $expected */ + #[DataProvider('types')] + public function testCreatesOneValueObjectPerDtcgType(string $type, mixed $value, string $expected): void + { + $token = TokenFactory::create($type, $value); + + self::assertInstanceOf($expected, $token); + self::assertSame($type, $token->getType()); + } + + public function testKeepsDescriptionExtensionsAndDeprecation(): void + { + $token = TokenFactory::create('number', 1, 'A number', ['com.acme' => true], 'use two'); + + self::assertSame('A number', $token->getDescription()); + self::assertSame(['com.acme' => true], $token->getExtensions()); + self::assertTrue($token->isDeprecated()); + self::assertSame('use two', $token->getDeprecationMessage()); + } + + #[DataProvider('invalidTokens')] + public function testRejectsAValueItCannotBuild(string $type, mixed $value, string $message): void + { + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage($message); + + TokenFactory::create($type, $value); + } + + /** @return iterable */ + public static function invalidTokens(): iterable + { + yield 'unknown type' => ['nope', 'x', 'Unknown DTCG token type "nope"']; + yield 'gradient that is not a list of stops' => ['gradient', ['stop' => []], 'must be a list of color stops']; + yield 'gradient stop that is not an object' => ['gradient', ['red'], 'color stop must be an object']; + } +} diff --git a/src/DesignTokens/tests/Unit/Token/TransitionTokenTest.php b/src/DesignTokens/tests/Unit/Token/TransitionTokenTest.php new file mode 100644 index 00000000000..48e958433d0 --- /dev/null +++ b/src/DesignTokens/tests/Unit/Token/TransitionTokenTest.php @@ -0,0 +1,50 @@ + + * + * 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\Token; + +use PHPUnit\Framework\Attributes\CoversClass; +use PHPUnit\Framework\Attributes\DataProvider; +use PHPUnit\Framework\TestCase; +use Symfony\UX\DesignTokens\Token\TransitionToken; + +#[CoversClass(TransitionToken::class)] +final class TransitionTokenTest extends TestCase +{ + public function testTypeIsTransition(): void + { + self::assertSame('transition', new TransitionToken(self::transition(150, [0, 0, 1, 1], 0))->getType()); + } + + /** @param list $curve */ + #[DataProvider('transitions')] + public function testToStringFormatsTheShorthand(int $duration, array $curve, int $delay, string $expected): void + { + self::assertSame($expected, (string) new TransitionToken(self::transition($duration, $curve, $delay))); + } + + /** @return iterable, int, string}> */ + public static function transitions(): iterable + { + yield 'fast' => [150, [0.2, 0, 0, 1], 0, '150ms cubic-bezier(0.2, 0, 0, 1) 0ms']; + yield 'spring with delay' => [500, [0.5, -0.5, 0.5, 1.5], 100, '500ms cubic-bezier(0.5, -0.5, 0.5, 1.5) 100ms']; + } + + /** + * @param list $curve + * + * @return array + */ + private static function transition(int $duration, array $curve, int $delay): array + { + return ['duration' => ['value' => $duration, 'unit' => 'ms'], 'timingFunction' => $curve, 'delay' => ['value' => $delay, 'unit' => 'ms']]; + } +} diff --git a/src/DesignTokens/tests/Unit/Token/TypographyTokenTest.php b/src/DesignTokens/tests/Unit/Token/TypographyTokenTest.php new file mode 100644 index 00000000000..76792be6fc0 --- /dev/null +++ b/src/DesignTokens/tests/Unit/Token/TypographyTokenTest.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\Token; + +use PHPUnit\Framework\Attributes\CoversClass; +use PHPUnit\Framework\Attributes\DataProvider; +use PHPUnit\Framework\TestCase; +use Symfony\UX\DesignTokens\Token\TypographyToken; + +#[CoversClass(TypographyToken::class)] +final class TypographyTokenTest extends TestCase +{ + public function testTypeIsTypography(): void + { + self::assertSame('typography', new TypographyToken(self::typography(['Inter']))->getType()); + } + + public function testToStringIsTheFontShorthand(): void + { + self::assertSame('700 2rem/1.2 Inter, sans-serif', (string) new TypographyToken(self::typography(['Inter', 'sans-serif'], 700, 2, 1.2))); + } + + /** @param string|list $family */ + #[DataProvider('families')] + public function testToStringQuotesTheFamilyLikeAFontFamilyToken(string|array $family, string $expected): void + { + self::assertStringEndsWith($expected, (string) new TypographyToken(self::typography($family))); + } + + /** @return iterable, string}> */ + public static function families(): iterable + { + yield 'one name' => ['Georgia', 'Georgia']; + yield 'list' => [['Inter', 'sans-serif'], 'Inter, sans-serif']; + yield 'name needing quotes' => ['Font Awesome 6 Free', '"Font Awesome 6 Free"']; + } + + /** + * @param string|list $family + * + * @return array + */ + private static function typography(string|array $family, int $weight = 400, int|float $size = 1, int|float $lineHeight = 1.5): array + { + return [ + 'fontFamily' => $family, + 'fontSize' => ['value' => $size, 'unit' => 'rem'], + 'fontWeight' => $weight, + 'letterSpacing' => ['value' => 0, 'unit' => 'px'], + 'lineHeight' => $lineHeight, + ]; + } +} diff --git a/src/DesignTokens/tests/Unit/TokenPathTest.php b/src/DesignTokens/tests/Unit/TokenPathTest.php new file mode 100644 index 00000000000..f268e6739b0 --- /dev/null +++ b/src/DesignTokens/tests/Unit/TokenPathTest.php @@ -0,0 +1,43 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\UX\DesignTokens\Tests\Unit; + +use PHPUnit\Framework\Attributes\CoversClass; +use PHPUnit\Framework\TestCase; +use Symfony\UX\DesignTokens\TokenPath; + +#[CoversClass(TokenPath::class)] +final class TokenPathTest extends TestCase +{ + public function testConvertsAPathToACssVariable(): void + { + self::assertSame('--color-action-primary', TokenPath::toCssVariable('color.action.primary')); + self::assertSame('--my-color-action-primary', TokenPath::toCssVariable('color.action.primary', 'my')); + self::assertSame('--type-heading-large', TokenPath::toCssVariable('type.heading large')); + self::assertSame('--token', TokenPath::toCssVariable('...')); + } + + public function testBuildsCssReferencesWithAnOptionalFallback(): void + { + self::assertSame('var(--color-action-primary)', TokenPath::toCssReference('color.action.primary')); + self::assertSame('var(--color-action-primary, currentColor)', TokenPath::toCssReference('color.action.primary', 'currentColor')); + self::assertSame('var(--my-color-action-primary)', TokenPath::toCssReference('color.action.primary', prefix: 'my')); + } + + public function testRejectsAnInvalidCssPrefix(): void + { + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('CSS prefix'); + + TokenPath::toCssVariable('color.action.primary', '--my'); + } +} diff --git a/src/DesignTokens/tests/Unit/TokenRegistryTest.php b/src/DesignTokens/tests/Unit/TokenRegistryTest.php new file mode 100644 index 00000000000..38a9b2bc8fd --- /dev/null +++ b/src/DesignTokens/tests/Unit/TokenRegistryTest.php @@ -0,0 +1,267 @@ + + * + * 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; + +use PHPUnit\Framework\Attributes\CoversClass; +use PHPUnit\Framework\Attributes\DataProvider; +use PHPUnit\Framework\TestCase; +use Symfony\UX\DesignTokens\Exception\ResolverException; +use Symfony\UX\DesignTokens\Exception\TokenNotFoundException; +use Symfony\UX\DesignTokens\Resolver\ArrayDocumentLoader; +use Symfony\UX\DesignTokens\Resolver\ConfiguredTokenResolver; +use Symfony\UX\DesignTokens\Resolver\TokenResolution; +use Symfony\UX\DesignTokens\Resolver\TokenResolverInterface; +use Symfony\UX\DesignTokens\Tests\Fixtures\Registries; +use Symfony\UX\DesignTokens\Tests\Fixtures\TokenValues; +use Symfony\UX\DesignTokens\Token\BorderToken; +use Symfony\UX\DesignTokens\Token\ColorToken; +use Symfony\UX\DesignTokens\Token\DimensionToken; +use Symfony\UX\DesignTokens\Token\NumberToken; +use Symfony\UX\DesignTokens\Token\TypographyToken; +use Symfony\UX\DesignTokens\TokenRegistry; + +#[CoversClass(TokenRegistry::class)] +final class TokenRegistryTest extends TestCase +{ + public function testGetReturnsTypedTokens(): void + { + $registry = Registries::fromArray([ + 'color' => ['brand' => ['$type' => 'color', '$value' => TokenValues::color()]], + 'dimension' => ['$type' => 'dimension', '$value' => TokenValues::dimension(16)], + 'typography' => ['$type' => 'typography', '$value' => TokenValues::typography()], + 'border' => ['$type' => 'border', '$value' => TokenValues::border()], + ]); + + self::assertInstanceOf(ColorToken::class, $registry->get('color.brand')); + self::assertInstanceOf(DimensionToken::class, $registry->get('dimension')); + self::assertInstanceOf(TypographyToken::class, $registry->get('typography')); + self::assertInstanceOf(BorderToken::class, $registry->get('border')); + } + + public function testGetThrowsForMissingPaths(): void + { + $registry = self::brandRegistry(); + + try { + $registry->get('does.not.exist'); + self::fail('Missing paths should be rejected.'); + } catch (TokenNotFoundException $exception) { + self::assertSame('does.not.exist', $exception->getPath()); + self::assertStringContainsString('not found', $exception->getMessage()); + } + } + + public function testGetThrowsForGroups(): void + { + $registry = self::brandRegistry(); + + $this->expectException(TokenNotFoundException::class); + $this->expectExceptionMessageMatches('/group/'); + + $registry->get('color'); + } + + public function testFindAndHasProvideNonThrowingLookup(): void + { + $registry = self::brandRegistry(); + + self::assertSame($registry->get('color.brand'), $registry->find('color.brand')); + self::assertTrue($registry->has('color.brand')); + self::assertNull($registry->find('color.missing')); + self::assertFalse($registry->has('color.missing')); + self::assertNull($registry->find('color')); + self::assertFalse($registry->has('color')); + } + + public function testAllReturnsTheNestedTree(): void + { + self::assertSame([], new TokenRegistry(new ConfiguredTokenResolver(new ArrayDocumentLoader([])))->all()); + + $all = self::brandRegistry()->all(); + + self::assertInstanceOf(ColorToken::class, $all['color']['brand']); + } + + public function testFlattensDefaultAndContextualSelections(): void + { + $registry = self::themeRegistry(); + + self::assertSame(['theme'], array_keys($registry->flatten())); + self::assertSame('1', (string) $registry->flatten()['theme']); + self::assertSame('2', (string) $registry->flatten(['scheme' => 'dark'])['theme']); + self::assertSame($registry->flatten(), $registry->flatten([])); + } + + public function testDefaultInputsSelectTheDefaultContext(): void + { + $registry = self::themeRegistry(['scheme' => 'dark']); + + self::assertSame('2', (string) $registry->get('theme')); + self::assertSame($registry->get('theme'), $registry->all(['scheme' => 'dark'])['theme']); + } + + public function testResolvesAnotherInputWithoutChangingTheDefaultSelection(): void + { + $registry = self::themeRegistry(['scheme' => 'light']); + + self::assertSame('1', (string) $registry->get('theme')); + self::assertSame('2', (string) $registry->all(['scheme' => 'dark'])['theme']); + self::assertSame('2', (string) $registry->get('theme', ['scheme' => 'dark'])); + self::assertSame('1', (string) $registry->get('theme')); + self::assertSame($registry->get('theme'), $registry->get('theme', [])); + } + + /** @param list> $calls */ + #[DataProvider('sharedResolutionProvider')] + public function testResolvesEachSelectionOnce(array $calls): void + { + $resolver = self::spyResolver(); + $registry = new TokenRegistry($resolver, ['scheme' => 'light']); + + foreach ($calls as $inputs) { + $registry->all($inputs); + } + + self::assertSame([['scheme' => 'dark']], $resolver->calls); + } + + /** @return iterable>}> */ + public static function sharedResolutionProvider(): iterable + { + yield 'the same inputs twice' => [[['scheme' => 'dark'], ['scheme' => 'dark']]]; + yield 'inputs spelled with another case' => [[['scheme' => 'dark'], ['Scheme' => 'dark'], ['scheme' => 'DARK']]]; + } + + public function testResolutionsAreKeptUntilReset(): void + { + $registry = self::themeRegistry(); + + $first = $registry->all(['scheme' => 'dark']); + $second = $registry->all(['scheme' => 'dark']); + + self::assertSame($first['theme'], $second['theme']); + + $registry->reset(); + $third = $registry->all(['scheme' => 'dark']); + + self::assertNotSame($first['theme'], $third['theme']); + self::assertEquals($first, $third); + } + + public function testPermutationsComeFromTheResolver(): void + { + self::assertSame([['scheme' => 'light'], ['scheme' => 'dark']], self::themeRegistry()->getPermutations()); + } + + public function testModifiersComeFromTheResolver(): void + { + self::assertSame(['scheme' => ['contexts' => ['light', 'dark'], 'default' => 'light']], self::themeRegistry()->getModifiers()); + } + + public function testRejectsAnInputGivenTwiceWithDifferentCasing(): void + { + $registry = new TokenRegistry(self::spyResolver()); + + $this->expectException(ResolverException::class); + $this->expectExceptionMessage('Modifier input "scheme" is provided more than once with different casing.'); + + $registry->all(['Scheme' => 'dark', 'scheme' => 'light']); + } + + public function testAnInputThatIsNotUtf8ReachesTheResolver(): void + { + $this->expectException(ResolverException::class); + $this->expectExceptionMessage('Invalid context'); + + self::themeRegistry()->all(['scheme' => "\xff"]); + } + + public function testRejectsAlternativeInputsWithoutResolverDocument(): void + { + $registry = Registries::fromArray(['a' => ['$type' => 'number', '$value' => 1]]); + + $this->expectException(\LogicException::class); + $this->expectExceptionMessage('no Resolver document is configured'); + + $registry->all(['scheme' => 'dark']); + } + + private static function brandRegistry(): TokenRegistry + { + return Registries::fromArray(['color' => ['brand' => ['$type' => 'color', '$value' => TokenValues::color()]]]); + } + + /** @param array $defaultInputs */ + private static function themeRegistry(array $defaultInputs = []): TokenRegistry + { + $loader = new ArrayDocumentLoader(['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']], + ]]); + + return new TokenRegistry(new ConfiguredTokenResolver($loader, resolverPath: 'theme.resolver.json'), $defaultInputs); + } + + /** @return TokenResolverInterface&object{calls: list>} */ + private static function spyResolver(): TokenResolverInterface + { + return new class implements TokenResolverInterface { + /** @var list> */ + public array $calls = []; + + public function resolve(array $inputs): TokenResolution + { + $this->calls[] = $inputs; + + return new TokenResolution(['theme' => new NumberToken(1)]); + } + + public function getPermutations(): array + { + return []; + } + + public function getModifiers(): array + { + return []; + } + }; + } + + public function testAnExplicitInputReplacesADefaultSpelledWithAnotherCase(): void + { + self::assertSame(2, self::themeRegistry(['scheme' => 'light'])->get('theme', ['Scheme' => 'dark'])->getValue()); + } + + public function testAPropertyIsNotAToken(): void + { + $registry = Registries::fromArray([ + 'color' => ['$description' => 'Colors', 'x' => ['$type' => 'number', '$value' => 1]], + 'space' => ['$root' => ['$type' => 'number', '$value' => 4]], + ]); + + self::assertSame(4, $registry->get('space.$root')->getValue()); + + $this->expectException(TokenNotFoundException::class); + $this->expectExceptionMessage('names the DTCG property "$description"'); + $registry->get('color.$description'); + } +} diff --git a/src/DesignTokens/tests/Unit/TokenTreeTest.php b/src/DesignTokens/tests/Unit/TokenTreeTest.php new file mode 100644 index 00000000000..eebb6501623 --- /dev/null +++ b/src/DesignTokens/tests/Unit/TokenTreeTest.php @@ -0,0 +1,95 @@ + + * + * 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; + +use PHPUnit\Framework\Attributes\CoversClass; +use PHPUnit\Framework\TestCase; +use Symfony\UX\DesignTokens\Tests\Fixtures\Registries; +use Symfony\UX\DesignTokens\Token\DimensionToken; +use Symfony\UX\DesignTokens\Token\NumberToken; +use Symfony\UX\DesignTokens\Token\TokenFactory; +use Symfony\UX\DesignTokens\TokenTree; + +#[CoversClass(TokenTree::class)] +#[CoversClass(TokenFactory::class)] +final class TokenTreeTest extends TestCase +{ + public function testFlattensNestedResolvedTokensInInsertionOrder(): void + { + $first = new NumberToken(1); + $second = new NumberToken(2); + + self::assertSame([ + 'content.label' => $first, + 'space.2' => $second, + ], TokenTree::flatten([ + 'content' => ['label' => $first], + 'space' => [2 => $second], + 'ignored' => 'not a token', + ])); + } + + public function testFlattensAnEmptyTree(): void + { + self::assertSame([], TokenTree::flatten([])); + } + + public function testExportWritesOnlyTheDtcgKeysThatCarryInformation(): void + { + $tree = [ + 'spacing' => [ + 'md' => new DimensionToken(['value' => 1, 'unit' => 'rem'], 'Medium gap', ['com.acme' => ['id' => 4]], 'use lg'), + 'sm' => new DimensionToken(['value' => 0.5, 'unit' => 'rem']), + ], + 'ignored' => 'not a token', + ]; + + self::assertSame([ + 'spacing' => [ + 'md' => [ + '$type' => 'dimension', + '$value' => ['value' => 1, 'unit' => 'rem'], + '$description' => 'Medium gap', + '$extensions' => ['com.acme' => ['id' => 4]], + '$deprecated' => 'use lg', + ], + 'sm' => [ + '$type' => 'dimension', + '$value' => ['value' => 0.5, 'unit' => 'rem'], + ], + ], + ], TokenTree::export($tree)); + } + + public function testHydrateIsTheInverseOfExport(): void + { + $registry = Registries::fromJson((string) file_get_contents(\dirname(__DIR__).'/Fixtures/color-scheme/foundation.tokens.json')); + + $tree = $registry->all(); + + self::assertNotSame([], $tree); + self::assertEquals($tree, TokenTree::hydrate(TokenTree::export($tree))); + } + + public function testHydrateRebuildsEveryTokenTypeAndSkipsNonNodes(): void + { + $data = [ + 'motion' => ['fast' => ['$type' => 'duration', '$value' => ['value' => 100, 'unit' => 'ms']]], + 'noise' => 'ignored', + ]; + + $tree = TokenTree::hydrate($data); + + self::assertSame(['motion'], array_keys($tree)); + self::assertSame('100ms', (string) TokenTree::flatten($tree)['motion.fast']); + } +} diff --git a/src/DesignTokens/tests/Unit/Validation/ColorRangeInspectorTest.php b/src/DesignTokens/tests/Unit/Validation/ColorRangeInspectorTest.php new file mode 100644 index 00000000000..83b0a590992 --- /dev/null +++ b/src/DesignTokens/tests/Unit/Validation/ColorRangeInspectorTest.php @@ -0,0 +1,127 @@ + + * + * 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\Validation; + +use PHPUnit\Framework\Attributes\CoversClass; +use PHPUnit\Framework\Attributes\DataProvider; +use PHPUnit\Framework\TestCase; +use Symfony\UX\DesignTokens\Resolver\TokenTreeBuilder; +use Symfony\UX\DesignTokens\Token\ColorToken; +use Symfony\UX\DesignTokens\Validation\ColorRangeInspector; + +#[CoversClass(ColorRangeInspector::class)] +final class ColorRangeInspectorTest extends TestCase +{ + /** @return iterable, ?string}> */ + public static function components(): iterable + { + yield 'srgb in range' => ['srgb', [0.2, 0.4, 0.8], null]; + yield 'srgb above one' => ['srgb', [1.2, 0.4, 0.8], 'component 0 is 1.2, outside the usual range [0, 1] for "srgb"']; + yield 'srgb below zero' => ['srgb', [0.2, -0.1, 0.8], 'component 1 is -0.1, outside the usual range [0, 1] for "srgb"']; + yield 'display-p3 above one' => ['display-p3', [0.2, 0.4, 1.5], 'component 2 is 1.5, outside the usual range [0, 1] for "display-p3"']; + yield 'xyz-d65 above one' => ['xyz-d65', [0.2, 0.4, 2], 'component 2 is 2, outside the usual range [0, 1] for "xyz-d65"']; + + yield 'hsl hue is never a range finding' => ['hsl', [350, 50, 50], null]; + yield 'hsl saturation above hundred' => ['hsl', [210, 150, 50], 'component 1 is 150, outside the usual range [0, 100] for "hsl"']; + yield 'hwb whiteness below zero' => ['hwb', [210, -5, 50], 'component 1 is -5, outside the usual range [0, 100] for "hwb"']; + + yield 'lab lightness in range' => ['lab', [55, 20, -30], null]; + yield 'lab lightness above hundred' => ['lab', [140, 20, -30], 'component 0 is 140, outside the usual range [0, 100] for "lab"']; + yield 'lch lightness above hundred' => ['lch', [140, 40, 230], 'component 0 is 140, outside the usual range [0, 100] for "lch"']; + + yield 'oklch lightness written as a percentage' => ['oklch', [80, 0.18, 255], 'component 0 is 80, outside the usual range [0, 1] for "oklch"']; + yield 'oklab lightness in range' => ['oklab', [0.62, 0.1, -0.1], null]; + + yield 'lch negative chroma' => ['lch', [55, -1, 230], 'component 1 is -1, outside the usual range [0, ∞) for "lch"']; + yield 'oklch negative chroma' => ['oklch', [0.62, -0.2, 255], 'component 1 is -0.2, outside the usual range [0, ∞) for "oklch"']; + yield 'oklch positive chroma' => ['oklch', [0.62, 0.2, 255], null]; + + yield 'a none component' => ['srgb', [0.2, 0.4, 'none'], null]; + } + + /** + * @param list $components + */ + #[DataProvider('components')] + public function testReportsOnlyComponentsOutsideTheirUsualRange(string $space, array $components, ?string $expected): void + { + $warnings = new ColorRangeInspector()->inspect($this->resolve($space, $components)); + + if (null === $expected) { + self::assertSame([], $warnings); + + return; + } + + self::assertCount(1, $warnings); + self::assertSame('color.probe: '.$expected.'.', $warnings[0]); + } + + public function testReportsEveryOffendingComponentOfAToken(): void + { + $warnings = new ColorRangeInspector()->inspect($this->resolve('srgb', [-1, 0.5, 2])); + + self::assertCount(2, $warnings); + self::assertStringContainsString('component 0 is -1', $warnings[0]); + self::assertStringContainsString('component 2 is 2', $warnings[1]); + } + + public function testIgnoresTokensThatAreNotColors(): void + { + $tokens = new TokenTreeBuilder()->resolve([ + 'dimension' => ['gap' => ['$type' => 'dimension', '$value' => ['value' => 1600, 'unit' => 'px']]], + ]); + + self::assertSame([], new ColorRangeInspector()->inspect($tokens)); + } + + public function testAColorSpaceWithNoStatedRangeIsNotSecondGuessed(): void + { + $tokens = ['color' => ['probe' => new ColorToken([ + 'colorSpace' => 'acescc', + 'components' => [42, 42, 42], + ])]]; + + self::assertSame([], new ColorRangeInspector()->inspect($tokens)); + } + + public function testAMalformedColorValueIsSkippedRatherThanReported(): void + { + $tokens = ['color' => [ + 'no-space' => new ColorToken(['components' => [2, 2, 2]]), + 'no-components' => new ColorToken(['colorSpace' => 'srgb']), + 'not-an-array' => new ColorToken('#ff0000'), + ]]; + + self::assertSame([], new ColorRangeInspector()->inspect($tokens)); + } + + public function testAnEmptyTreeReportsNothing(): void + { + self::assertSame([], new ColorRangeInspector()->inspect([])); + } + + /** + * @param list $components + * + * @return array + */ + private function resolve(string $space, array $components): array + { + return new TokenTreeBuilder()->resolve([ + 'color' => ['probe' => [ + '$type' => 'color', + '$value' => ['colorSpace' => $space, 'components' => $components], + ]], + ]); + } +} diff --git a/src/DesignTokens/tests/Unit/Validation/DtcgValidatorTest.php b/src/DesignTokens/tests/Unit/Validation/DtcgValidatorTest.php new file mode 100644 index 00000000000..9ea87210832 --- /dev/null +++ b/src/DesignTokens/tests/Unit/Validation/DtcgValidatorTest.php @@ -0,0 +1,151 @@ + + * + * 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\Validation; + +use PHPUnit\Framework\Attributes\CoversClass; +use PHPUnit\Framework\Attributes\DataProvider; +use PHPUnit\Framework\TestCase; +use Symfony\Component\Filesystem\Filesystem; +use Symfony\UX\DesignTokens\Resolver\ArrayDocumentLoader; +use Symfony\UX\DesignTokens\Resolver\JsonDocumentLoader; +use Symfony\UX\DesignTokens\Resolver\TokenTreeBuilder; +use Symfony\UX\DesignTokens\Tests\Fixtures\TemporaryDirectory; +use Symfony\UX\DesignTokens\Validation\DtcgValidator; + +#[CoversClass(DtcgValidator::class)] +final class DtcgValidatorTest extends TestCase +{ + private DtcgValidator $validator; + + protected function setUp(): void + { + $this->validator = new DtcgValidator(new TokenTreeBuilder(new JsonDocumentLoader())); + } + + public function testValidatesTokenAndResolverDocuments(): void + { + $this->validator->validateJson(self::tokens()); + + $tokenPath = \dirname(__DIR__, 2).'/Integration/Fixtures/app.tokens.json'; + $resolverPath = \dirname(__DIR__, 2).'/Integration/Fixtures/theme.resolver.json'; + $this->validator->validateFile($tokenPath); + $this->validator->validateFile($resolverPath); + + $resolverJson = (string) file_get_contents($resolverPath); + $this->validator->validateJson($resolverJson, $resolverPath, \dirname($resolverPath)); + self::addToAssertionCount(4); + } + + public function testReadsTheDocumentsAResolverReferencesThroughTheLoader(): void + { + $loader = new ArrayDocumentLoader(['parts.json' => [ + 'sets' => ['base' => ['sources' => [['c' => ['$type' => 'number', '$value' => 1]]]]], + ]]); + $validator = new DtcgValidator(new TokenTreeBuilder($loader), documentLoader: $loader); + + self::assertSame([], $validator->validate([ + 'version' => '2025.10', + 'resolutionOrder' => [['$ref' => 'parts.json#/sets/base']], + ], kind: 'resolver')); + } + + public function testRejectsInvalidJsonTopLevelAndSemantics(): void + { + foreach ([ + ['{', 'Invalid JSON'], + ['[]', 'JSON object'], + ['{"bad":{"$type":"dimension","$value":"12px"}}', 'structured dimension'], + ] as [$json, $message]) { + try { + $this->validator->validateJson($json); + self::fail('Expected validation to fail.'); + } catch (\RuntimeException|\InvalidArgumentException $error) { + self::assertStringContainsString($message, $error->getMessage()); + } + } + } + + /** @param class-string<\Throwable> $exception */ + #[DataProvider('fileErrors')] + public function testReportsFileErrors(string $name, ?string $contents, bool $readable, string $exception, string $message): void + { + $directory = new TemporaryDirectory(); + $path = null === $contents ? $directory->path($name) : $directory->write($name, $contents); + if (!$readable) { + new Filesystem()->chmod($path, 0o000); + } + + try { + $this->expectException($exception); + $this->expectExceptionMessage($message); + + $this->validator->validateFile($path); + } finally { + $directory->remove(); + } + } + + /** @return iterable, string}> */ + public static function fileErrors(): iterable + { + yield 'missing file' => ['missing.tokens.json', null, true, \RuntimeException::class, 'not found']; + yield 'unsupported extension' => ['tokens.json', self::tokens(), true, \InvalidArgumentException::class, 'Expected a .tokens.json']; + yield 'unreadable file' => ['unreadable.tokens', self::tokens(), false, \RuntimeException::class, 'Could not read']; + } + + public function testRejectsAnInvalidResolverDefaultContext(): void + { + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('must match a context key'); + + $this->validator->validate([ + 'version' => '2025.10', + 'modifiers' => [ + 'theme' => [ + 'contexts' => ['light' => [], 'dark' => []], + 'default' => 'unknown', + ], + ], + 'resolutionOrder' => [['$ref' => '#/modifiers/theme']], + ], kind: 'resolver'); + } + + public function testFollowsTheNormativeGradientRule(): void + { + $this->validator->validate([ + 'gradient' => [ + '$type' => 'gradient', + '$value' => [ + ['color' => ['colorSpace' => 'srgb', 'components' => [0, 0, 0]], 'position' => -99], + ['color' => ['colorSpace' => 'srgb', 'components' => [1, 1, 1]], 'position' => 42], + ], + ], + ]); + + self::addToAssertionCount(1); + } + + private static function tokens(): string + { + return '{"color":{"brand":{"$type":"color","$value":{"colorSpace":"srgb","components":[0.2,0.4,0.8]}}}}'; + } + + public function testNumericTopLevelNamesAreValid(): void + { + self::assertSame([], $this->validator->validateJson('{"100":{"$type":"number","$value":1}}')); + } + + public function testAnEmptyGroupIsValid(): void + { + self::assertSame([], $this->validator->validateJson('{"empty":{},"a":{"$type":"number","$value":1}}')); + } +} diff --git a/src/DesignTokens/tests/Unit/Validation/NormalizerTest.php b/src/DesignTokens/tests/Unit/Validation/NormalizerTest.php new file mode 100644 index 00000000000..5bbd44d9928 --- /dev/null +++ b/src/DesignTokens/tests/Unit/Validation/NormalizerTest.php @@ -0,0 +1,84 @@ + + * + * 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\Validation; + +use PHPUnit\Framework\Attributes\CoversClass; +use PHPUnit\Framework\Attributes\DataProvider; +use PHPUnit\Framework\TestCase; +use Symfony\Component\Filesystem\Filesystem; +use Symfony\UX\DesignTokens\Resolver\JsonDocumentLoader; +use Symfony\UX\DesignTokens\Resolver\TokenTreeBuilder; +use Symfony\UX\DesignTokens\Tests\Fixtures\TemporaryDirectory; +use Symfony\UX\DesignTokens\Validation\DtcgValidator; +use Symfony\UX\DesignTokens\Validation\Normalizer; + +#[CoversClass(Normalizer::class)] +final class NormalizerTest extends TestCase +{ + private Normalizer $normalizer; + + protected function setUp(): void + { + $this->normalizer = new Normalizer(new DtcgValidator(new TokenTreeBuilder(new JsonDocumentLoader()))); + } + + public function testNormalizesWithoutDestroyingAuthoredStructure(): void + { + $json = '{"empty":{},"number":{"$type":"number","$value":1.0,"$description":"Échelle"},"alias":{"$type":"number","$value":"{number}"}}'; + + $normalized = $this->normalizer->normalize($json); + + self::assertStringContainsString('"empty": {}', $normalized); + self::assertStringContainsString('"$value": 1.0', $normalized); + self::assertStringContainsString('"$description": "Échelle"', $normalized); + self::assertStringContainsString('"$value": "{number}"', $normalized); + self::assertStringEndsWith("\n", $normalized); + self::assertLessThan(strpos($normalized, '"number"'), strpos($normalized, '"empty"')); + } + + public function testNormalizesTokenAndResolverFiles(): void + { + $tokenPath = \dirname(__DIR__, 2).'/Integration/Fixtures/app.tokens.json'; + $resolverPath = \dirname(__DIR__, 2).'/Integration/Fixtures/theme.resolver.json'; + + self::assertStringContainsString('"color"', $this->normalizer->normalizeFile($tokenPath)); + self::assertStringContainsString('"resolutionOrder"', $this->normalizer->normalizeFile($resolverPath)); + } + + /** @param class-string<\Throwable> $exception */ + #[DataProvider('fileErrors')] + public function testReportsFileErrors(string $name, ?string $contents, bool $readable, string $exception, string $message): void + { + $directory = new TemporaryDirectory(); + $path = null === $contents ? $directory->path($name) : $directory->write($name, $contents); + if (!$readable) { + new Filesystem()->chmod($path, 0o000); + } + + try { + $this->expectException($exception); + $this->expectExceptionMessage($message); + + $this->normalizer->normalizeFile($path); + } finally { + $directory->remove(); + } + } + + /** @return iterable, string}> */ + public static function fileErrors(): iterable + { + yield 'missing file' => ['missing.tokens.json', null, true, \RuntimeException::class, 'not found']; + yield 'unsupported extension' => ['tokens.json', '{"empty":{}}', true, \InvalidArgumentException::class, 'Expected a .tokens.json']; + yield 'unreadable file' => ['unreadable.tokens', '{"empty":{}}', false, \RuntimeException::class, 'Could not read']; + } +} diff --git a/src/DesignTokens/tests/Unit/Validation/TokenValueValidatorTest.php b/src/DesignTokens/tests/Unit/Validation/TokenValueValidatorTest.php new file mode 100644 index 00000000000..7fbd821126e --- /dev/null +++ b/src/DesignTokens/tests/Unit/Validation/TokenValueValidatorTest.php @@ -0,0 +1,139 @@ + + * + * 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\Validation; + +use PHPUnit\Framework\Attributes\CoversClass; +use PHPUnit\Framework\Attributes\DataProvider; +use PHPUnit\Framework\TestCase; +use Symfony\UX\DesignTokens\Exception\InvalidArgumentException; +use Symfony\UX\DesignTokens\Tests\Fixtures\TokenValues; +use Symfony\UX\DesignTokens\Token\ColorToken; +use Symfony\UX\DesignTokens\Validation\ColorRangeInspector; +use Symfony\UX\DesignTokens\Validation\TokenValueValidator; + +#[CoversClass(TokenValueValidator::class)] +final class TokenValueValidatorTest extends TestCase +{ + #[DataProvider('validValueProvider')] + public function testAcceptsDtcgValues(string $type, mixed $value): void + { + new TokenValueValidator()->validate($type, $value); + self::assertTrue(true); + } + + /** @return iterable */ + public static function validValueProvider(): iterable + { + yield 'srgb with fallback' => ['color', ['colorSpace' => 'srgb', 'components' => [0.2, 'none', 0.8], 'alpha' => 0.5, 'hex' => '#3366cc']]; + yield 'lab unbounded axes' => ['color', ['colorSpace' => 'lab', 'components' => [50, -160, 170]]]; + yield 'oklab normalized lightness' => ['color', ['colorSpace' => 'oklab', 'components' => [0.5, -0.4, 0.4]]]; + yield 'oklch normalized lightness' => ['color', ['colorSpace' => 'oklch', 'components' => [0.75, 0.4, 359.9]]]; + yield 'dimension' => ['dimension', ['value' => -16.5, 'unit' => 'px']]; + yield 'duration' => ['duration', ['value' => 250, 'unit' => 'ms']]; + yield 'number' => ['number', -1.5]; + yield 'font family' => ['fontFamily', ['Inter', 'sans-serif']]; + yield 'font weight' => ['fontWeight', 'semi-bold']; + yield 'cubic bezier' => ['cubicBezier', [0, -2, 1, 3]]; + yield 'stroke style keyword' => ['strokeStyle', 'dashed']; + yield 'stroke style object' => ['strokeStyle', ['dashArray' => [TokenValues::dimension(2), TokenValues::dimension(4)], 'lineCap' => 'round']]; + yield 'border' => ['border', TokenValues::border()]; + yield 'transition' => ['transition', TokenValues::transition()]; + yield 'single shadow' => ['shadow', TokenValues::shadow(true)]; + yield 'shadow array' => ['shadow', [TokenValues::shadow(true), TokenValues::shadow(false)]]; + yield 'gradient' => ['gradient', [['color' => TokenValues::color(), 'position' => 0], ['color' => TokenValues::color(), 'position' => 1]]]; + yield 'gradient positions are clamped at consumption' => ['gradient', [['color' => TokenValues::color(), 'position' => -99], ['color' => TokenValues::color(), 'position' => 42]]]; + yield 'typography' => ['typography', ['fontFamily' => 'Inter', 'fontSize' => TokenValues::dimension(16), 'fontWeight' => 400, 'letterSpacing' => TokenValues::dimension(0), 'lineHeight' => 1.5]]; + } + + #[DataProvider('invalidValueProvider')] + public function testRejectsInvalidDtcgValues(string $type, mixed $value): void + { + $this->expectException(\InvalidArgumentException::class); + new TokenValueValidator()->validate($type, $value); + } + + /** @return iterable */ + public static function invalidValueProvider(): iterable + { + yield 'legacy string color' => ['color', '#3366ff']; + yield 'unknown color space' => ['color', ['colorSpace' => 'rgb', 'components' => [0, 0, 0]]]; + yield 'hue 360' => ['color', ['colorSpace' => 'hsl', 'components' => [360, 50, 50]]]; + yield 'lch hue 360' => ['color', ['colorSpace' => 'lch', 'components' => [50, 20, 360]]]; + yield 'hex form' => ['color', ['colorSpace' => 'srgb', 'components' => [0, 0, 0], 'hex' => '#000']]; + yield 'color unknown property' => ['color', ['colorSpace' => 'srgb', 'components' => [0, 0, 0], 'fallback' => '#000000']]; + yield 'legacy dimension' => ['dimension', '16px']; + yield 'dimension value type' => ['dimension', ['value' => '16', 'unit' => 'px']]; + yield 'dimension unknown property' => ['dimension', ['value' => 16, 'unit' => 'px', 'extra' => true]]; + yield 'non-finite number' => ['number', \INF]; + yield 'empty font family' => ['fontFamily', []]; + yield 'curly family item' => ['fontFamily', ['{font.base}']]; + yield 'font weight keyword' => ['fontWeight', 'semibold']; + yield 'bezier x range' => ['cubicBezier', [-0.1, 0, 1, 1]]; + yield 'empty stroke dashes' => ['strokeStyle', ['dashArray' => [], 'lineCap' => 'round']]; + yield 'stroke style shape' => ['strokeStyle', []]; + yield 'stroke line cap' => ['strokeStyle', ['dashArray' => [TokenValues::dimension(2)], 'lineCap' => 'flat']]; + yield 'border missing style' => ['border', ['color' => TokenValues::color(), 'width' => TokenValues::dimension(1)]]; + yield 'border shape' => ['border', []]; + yield 'transition unknown property' => ['transition', ['duration' => ['value' => 1, 'unit' => 's'], 'delay' => ['value' => 0, 'unit' => 's'], 'timingFunction' => [0, 0, 1, 1], 'property' => 'all']]; + yield 'transition shape' => ['transition', []]; + yield 'empty shadows' => ['shadow', []]; + yield 'shadow item shape' => ['shadow', ['not-a-shadow']]; + yield 'shadow inset type' => ['shadow', [...TokenValues::shadow(false), 'inset' => 1]]; + yield 'empty gradient' => ['gradient', []]; + yield 'gradient stop shape' => ['gradient', ['not-a-stop']]; + yield 'gradient position must be finite' => ['gradient', [['color' => TokenValues::color(), 'position' => \INF]]]; + yield 'typography missing letter spacing' => ['typography', ['fontFamily' => 'Inter', 'fontSize' => TokenValues::dimension(16), 'fontWeight' => 400, 'lineHeight' => 1.5]]; + yield 'typography shape' => ['typography', []]; + yield 'typography string line height' => ['typography', ['fontFamily' => 'Inter', 'fontSize' => TokenValues::dimension(16), 'fontWeight' => 400, 'letterSpacing' => TokenValues::dimension(0), 'lineHeight' => '1.5']]; + yield 'string is not DTCG type' => ['string', 'legacy']; + yield 'malformed reference object' => ['dimension', ['$ref' => 'tokens.json']]; + } + + /** @param list $components */ + #[DataProvider('outOfRangeProvider')] + public function testReportsOutOfRangeComponentsWithoutRejectingThem(string $space, array $components): void + { + $value = ['colorSpace' => $space, 'components' => $components]; + + new TokenValueValidator()->validate('color', $value, '/fixture'); + + self::assertCount(1, new ColorRangeInspector()->inspect(['brand' => new ColorToken($value)])); + } + + /** @return iterable}> */ + public static function outOfRangeProvider(): iterable + { + yield 'srgb above' => ['srgb', [1.1, 0, 0]]; + yield 'srgb below' => ['srgb', [-0.1, 0, 0]]; + yield 'oklab lightness' => ['oklab', [50, 0, 0]]; + yield 'D65 white point' => ['xyz-d65', [0.9505, 1, 1.089]]; + } + + /** @return iterable */ + public static function referenceShapedValueProvider(): iterable + { + yield 'color space object' => ['color', ['colorSpace' => ['$ref' => '#/x'], 'components' => 'garbage', 'alpha' => 9]]; + yield 'components object' => ['color', ['colorSpace' => 'srgb', 'components' => ['$ref' => '#/x']]]; + yield 'whole curly value' => ['color', '{palette.blue}']; + yield 'whole pointer value' => ['dimension', ['$ref' => '#/space/base/$value']]; + yield 'cubic bezier coordinate' => ['cubicBezier', [['$ref' => '#/x'], 0, 1, 1]]; + yield 'shadow layer' => ['shadow', ['{shadow.base}']]; + } + + #[DataProvider('referenceShapedValueProvider')] + public function testAReferenceShapedValueIsNotAValue(string $type, mixed $value): void + { + $this->expectException(InvalidArgumentException::class); + + new TokenValueValidator()->validate($type, $value); + } +} diff --git a/src/DesignTokens/tests/bootstrap.php b/src/DesignTokens/tests/bootstrap.php new file mode 100644 index 00000000000..ed0e5e48b8a --- /dev/null +++ b/src/DesignTokens/tests/bootstrap.php @@ -0,0 +1,16 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +use Symfony\Component\ErrorHandler\ErrorHandler; + +require __DIR__.'/../vendor/autoload.php'; + +ErrorHandler::register(null, false); From d03f5b5e37674f8370ca646ceb6cc5cf84ffe6db Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Simon=20Andr=C3=A9?= Date: Sat, 26 Sep 2026 21:24:33 +0200 Subject: [PATCH 2/4] [DesignTokens] Add the bundle: CSS, Twig, cache, commands Render the resolved tokens as CSS custom properties, inline with ux_token_css() or as an AssetMapper stylesheet with ux_token_stylesheet(). The color_scheme option maps a Resolver modifier to prefers-color-scheme and [data-theme]; the dark block holds only the variables that change. Resolutions are cached in the system cache and warmed with the application. Add lint:design-tokens, debug:design-tokens and ux:design-tokens:export (DTCG, CSS, JavaScript). --- src/DesignTokens/config/asset_mapper.php | 29 + src/DesignTokens/config/cache.php | 21 + src/DesignTokens/config/services.php | 159 +++++ src/DesignTokens/config/twig.php | 33 + .../src/CacheWarmer/ConfigurationResource.php | 43 ++ .../CacheWarmer/DesignTokensCacheWarmer.php | 44 ++ .../src/CacheWarmer/StylesheetCache.php | 115 ++++ .../src/Command/DebugTokensCommand.php | 193 ++++++ .../src/Command/ExportCommand.php | 327 ++++++++++ src/DesignTokens/src/Command/Formats.php | 59 ++ .../src/Command/LintDesignTokensCommand.php | 376 +++++++++++ ...WarmDesignTokensOnAssetCompileListener.php | 36 + .../src/Generator/ColorScheme.php | 63 ++ .../src/Generator/CssGenerator.php | 223 +++++++ .../src/Generator/DtcgGenerator.php | 92 +++ .../src/Generator/GeneratorInterface.php | 37 ++ .../src/Generator/JavaScriptGenerator.php | 47 ++ .../src/Twig/DesignTokenExtension.php | 33 + .../src/Twig/DesignTokenRuntime.php | 113 ++++ src/DesignTokens/src/UXDesignTokensBundle.php | 313 +++++++++ .../Fixtures/theme/brand-sky.tokens.json | 27 + .../Fixtures/theme/brand-symfony.tokens.json | 27 + .../Fixtures/theme/foundation.tokens.json | 616 ++++++++++++++++++ .../Fixtures/theme/scheme-dark.tokens.json | 98 +++ .../Fixtures/theme/scheme-light.tokens.json | 97 +++ .../tests/Fixtures/theme/theme.resolver.json | 56 ++ .../Integration/BundleIntegrationTest.php | 350 ++++++++++ .../Integration/IntegrationTestKernel.php | 93 +++ .../tests/Integration/LayeredThemeTest.php | 77 +++ .../DesignTokensCacheWarmerTest.php | 62 ++ .../Unit/CacheWarmer/StylesheetCacheTest.php | 136 ++++ .../tests/Unit/Command/CommandNamingTest.php | 61 ++ .../Unit/Command/DebugTokensCommandTest.php | 134 ++++ .../tests/Unit/Command/ExportCommandTest.php | 329 ++++++++++ .../Command/LintDesignTokensCommandTest.php | 345 ++++++++++ .../DependencyInjection/ConfigurationTest.php | 94 +++ .../DesignTokensExtensionTest.php | 352 ++++++++++ ...DesignTokensOnAssetCompileListenerTest.php | 56 ++ .../tests/Unit/Generator/ColorSchemeTest.php | 54 ++ .../tests/Unit/Generator/CssGeneratorTest.php | 188 ++++++ .../Unit/Generator/DtcgGeneratorTest.php | 107 +++ .../Generator/JavaScriptGeneratorTest.php | 63 ++ .../Token/CompositeMemberProjectionTest.php | 165 +++++ .../Unit/Twig/DesignTokenExtensionTest.php | 50 ++ .../Unit/Twig/DesignTokenRuntimeTest.php | 284 ++++++++ .../tests/Unit/UXDesignTokensBundleTest.php | 41 ++ 46 files changed, 6318 insertions(+) create mode 100644 src/DesignTokens/config/asset_mapper.php create mode 100644 src/DesignTokens/config/cache.php create mode 100644 src/DesignTokens/config/services.php create mode 100644 src/DesignTokens/config/twig.php create mode 100644 src/DesignTokens/src/CacheWarmer/ConfigurationResource.php create mode 100644 src/DesignTokens/src/CacheWarmer/DesignTokensCacheWarmer.php create mode 100644 src/DesignTokens/src/CacheWarmer/StylesheetCache.php create mode 100644 src/DesignTokens/src/Command/DebugTokensCommand.php create mode 100644 src/DesignTokens/src/Command/ExportCommand.php create mode 100644 src/DesignTokens/src/Command/Formats.php create mode 100644 src/DesignTokens/src/Command/LintDesignTokensCommand.php create mode 100644 src/DesignTokens/src/EventListener/WarmDesignTokensOnAssetCompileListener.php create mode 100644 src/DesignTokens/src/Generator/ColorScheme.php create mode 100644 src/DesignTokens/src/Generator/CssGenerator.php create mode 100644 src/DesignTokens/src/Generator/DtcgGenerator.php create mode 100644 src/DesignTokens/src/Generator/GeneratorInterface.php create mode 100644 src/DesignTokens/src/Generator/JavaScriptGenerator.php create mode 100644 src/DesignTokens/src/Twig/DesignTokenExtension.php create mode 100644 src/DesignTokens/src/Twig/DesignTokenRuntime.php create mode 100644 src/DesignTokens/src/UXDesignTokensBundle.php create mode 100644 src/DesignTokens/tests/Fixtures/theme/brand-sky.tokens.json create mode 100644 src/DesignTokens/tests/Fixtures/theme/brand-symfony.tokens.json create mode 100644 src/DesignTokens/tests/Fixtures/theme/foundation.tokens.json create mode 100644 src/DesignTokens/tests/Fixtures/theme/scheme-dark.tokens.json create mode 100644 src/DesignTokens/tests/Fixtures/theme/scheme-light.tokens.json create mode 100644 src/DesignTokens/tests/Fixtures/theme/theme.resolver.json create mode 100644 src/DesignTokens/tests/Integration/BundleIntegrationTest.php create mode 100644 src/DesignTokens/tests/Integration/IntegrationTestKernel.php create mode 100644 src/DesignTokens/tests/Integration/LayeredThemeTest.php create mode 100644 src/DesignTokens/tests/Unit/CacheWarmer/DesignTokensCacheWarmerTest.php create mode 100644 src/DesignTokens/tests/Unit/CacheWarmer/StylesheetCacheTest.php create mode 100644 src/DesignTokens/tests/Unit/Command/CommandNamingTest.php create mode 100644 src/DesignTokens/tests/Unit/Command/DebugTokensCommandTest.php create mode 100644 src/DesignTokens/tests/Unit/Command/ExportCommandTest.php create mode 100644 src/DesignTokens/tests/Unit/Command/LintDesignTokensCommandTest.php create mode 100644 src/DesignTokens/tests/Unit/DependencyInjection/ConfigurationTest.php create mode 100644 src/DesignTokens/tests/Unit/DependencyInjection/DesignTokensExtensionTest.php create mode 100644 src/DesignTokens/tests/Unit/EventListener/WarmDesignTokensOnAssetCompileListenerTest.php create mode 100644 src/DesignTokens/tests/Unit/Generator/ColorSchemeTest.php create mode 100644 src/DesignTokens/tests/Unit/Generator/CssGeneratorTest.php create mode 100644 src/DesignTokens/tests/Unit/Generator/DtcgGeneratorTest.php create mode 100644 src/DesignTokens/tests/Unit/Generator/JavaScriptGeneratorTest.php create mode 100644 src/DesignTokens/tests/Unit/Token/CompositeMemberProjectionTest.php create mode 100644 src/DesignTokens/tests/Unit/Twig/DesignTokenExtensionTest.php create mode 100644 src/DesignTokens/tests/Unit/Twig/DesignTokenRuntimeTest.php create mode 100644 src/DesignTokens/tests/Unit/UXDesignTokensBundleTest.php 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..3c4f93a90de --- /dev/null +++ b/src/DesignTokens/config/services.php @@ -0,0 +1,159 @@ + + * + * 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\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\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; + +/* + * Internal services use dot-prefixed ids so they stay out of reach of the + * application. What an application is meant to consume is exposed through an + * alias on its class name. + */ +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') + + // A resolution is a stateful operation: the resolver records the + // documents it pulled in, the origin of every path and the references + // it is walking. Handing the same instance to two consumers would let + // one wipe what the other is still reading, so every injection point + // gets its own. + ->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') + + // Built-in export formats. Any other service implementing + // GeneratorInterface joins them through the tag the bundle registers + // for autoconfiguration. + ->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.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.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') + ; +}; 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/src/CacheWarmer/ConfigurationResource.php b/src/DesignTokens/src/CacheWarmer/ConfigurationResource.php new file mode 100644 index 00000000000..cea76664579 --- /dev/null +++ b/src/DesignTokens/src/CacheWarmer/ConfigurationResource.php @@ -0,0 +1,43 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\UX\DesignTokens\CacheWarmer; + +use Symfony\Component\Config\Resource\ResourceInterface; +use Symfony\Component\Config\ResourceCheckerInterface; + +/** + * @author Simon André + * + * @internal + */ +final class ConfigurationResource implements ResourceInterface, ResourceCheckerInterface +{ + public function __construct( + private readonly string $fingerprint, + ) { + } + + public function __toString(): string + { + return 'ux_design_tokens.configuration.'.$this->fingerprint; + } + + public function supports(ResourceInterface $metadata): bool + { + return $metadata instanceof self; + } + + public function isFresh(ResourceInterface $resource, int $timestamp): bool + { + return $resource instanceof self && $resource->fingerprint === $this->fingerprint; + } +} diff --git a/src/DesignTokens/src/CacheWarmer/DesignTokensCacheWarmer.php b/src/DesignTokens/src/CacheWarmer/DesignTokensCacheWarmer.php new file mode 100644 index 00000000000..1507b28fa46 --- /dev/null +++ b/src/DesignTokens/src/CacheWarmer/DesignTokensCacheWarmer.php @@ -0,0 +1,44 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\UX\DesignTokens\CacheWarmer; + +use Symfony\Component\HttpKernel\CacheWarmer\CacheWarmerInterface; + +/** + * @author Simon André + * + * @internal + */ +final class DesignTokensCacheWarmer implements CacheWarmerInterface +{ + public function __construct( + private readonly StylesheetCache $stylesheets, + ) { + } + + public function isOptional(): bool + { + return true; + } + + /** + * Returns no file: what a warmer returns is appended to the opcache preload script. + * + * @return list + */ + public function warmUp(string $cacheDir, ?string $buildDir = null): array + { + $this->stylesheets->path(); + + return []; + } +} diff --git a/src/DesignTokens/src/CacheWarmer/StylesheetCache.php b/src/DesignTokens/src/CacheWarmer/StylesheetCache.php new file mode 100644 index 00000000000..fe6891dc80a --- /dev/null +++ b/src/DesignTokens/src/CacheWarmer/StylesheetCache.php @@ -0,0 +1,115 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\UX\DesignTokens\CacheWarmer; + +use Symfony\Component\Config\ConfigCacheInterface; +use Symfony\Component\Config\Resource\FileResource; +use Symfony\Component\Config\Resource\ReflectionClassResource; +use Symfony\Component\Config\Resource\SelfCheckingResourceChecker; +use Symfony\Component\Config\ResourceCheckerConfigCache; +use Symfony\Component\Filesystem\Exception\IOExceptionInterface; +use Symfony\Component\Filesystem\Filesystem; +use Symfony\UX\DesignTokens\Generator\ColorScheme; +use Symfony\UX\DesignTokens\Generator\CssGenerator; +use Symfony\UX\DesignTokens\Generator\GeneratorInterface; +use Symfony\UX\DesignTokens\Resolver\TokenResolverInterface; + +/** + * @author Simon André + * + * @internal + */ +final class StylesheetCache +{ + public const DIRECTORY = 'ux_design_tokens'; + + public const STYLESHEET = 'tokens.css'; + + /** + * @param array $defaultInputs + * @param string $fingerprint identifies the configuration the stylesheet depends on + */ + public function __construct( + private readonly TokenResolverInterface $resolver, + private readonly CssGenerator $css, + private readonly string $buildDir, + private readonly array $defaultInputs = [], + private readonly ColorScheme $colorScheme = new ColorScheme(), + private readonly bool $debug = false, + private readonly string $fingerprint = '', + ) { + } + + public static function directory(string $buildDir): string + { + return $buildDir.'/'.self::DIRECTORY; + } + + /** Path of the up-to-date stylesheet, written when missing or stale. */ + public function path(): string + { + $cache = $this->cache(); + if (!$cache->isFresh()) { + [$css, $documents] = $this->render(); + $resources = [new ConfigurationResource($this->fingerprint), new ReflectionClassResource(new \ReflectionClass($this->css))]; + foreach ($documents as $document) { + if (is_file($document)) { + $resources[] = new FileResource($document); + } + } + $cache->write($css, $resources); + } + + return $cache->getPath(); + } + + /** The stylesheet contents when up to date, without writing anything. */ + public function read(): ?string + { + $cache = $this->cache(); + if (!$cache->isFresh()) { + return null; + } + + try { + return new Filesystem()->readFile($cache->getPath()); + } catch (IOExceptionInterface) { + return null; + } + } + + private function cache(): ConfigCacheInterface + { + $checkers = $this->debug ? [new SelfCheckingResourceChecker(), new ConfigurationResource($this->fingerprint)] : []; + + return new ResourceCheckerConfigCache(self::directory($this->buildDir).'/'.self::STYLESHEET, $checkers); + } + + /** @return array{string, list} */ + private function render(): array + { + $contexts = $this->colorScheme->contexts($this->resolver->getModifiers(), $this->defaultInputs); + if (null === $contexts) { + $resolution = $this->resolver->resolve($this->defaultInputs); + + return [$this->css->generate($resolution->getTokens()), $resolution->getDocuments()]; + } + + $light = $this->resolver->resolve($contexts[0]); + $dark = $this->resolver->resolve($contexts[1]); + + return [ + $this->css->generate($light->getTokens(), [GeneratorInterface::DARK_TOKENS => $dark->getTokens()]), + array_values(array_unique([...$light->getDocuments(), ...$dark->getDocuments()])), + ]; + } +} diff --git a/src/DesignTokens/src/Command/DebugTokensCommand.php b/src/DesignTokens/src/Command/DebugTokensCommand.php new file mode 100644 index 00000000000..67cd43f12f1 --- /dev/null +++ b/src/DesignTokens/src/Command/DebugTokensCommand.php @@ -0,0 +1,193 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\UX\DesignTokens\Command; + +use Symfony\Component\Console\Attribute\AsCommand; +use Symfony\Component\Console\Command\Command; +use Symfony\Component\Console\Input\InputArgument; +use Symfony\Component\Console\Input\InputInterface; +use Symfony\Component\Console\Input\InputOption; +use Symfony\Component\Console\Output\OutputInterface; +use Symfony\Component\Console\Style\SymfonyStyle; +use Symfony\UX\DesignTokens\Exception\LogicException; +use Symfony\UX\DesignTokens\Resolver\ConfiguredTokenResolver; +use Symfony\UX\DesignTokens\Resolver\ResolverSource; +use Symfony\UX\DesignTokens\Token\TokenInterface; +use Symfony\UX\DesignTokens\TokenRegistryInterface; + +/** + * @author Simon André + * + * @internal + */ +#[AsCommand( + name: 'debug:design-tokens', + description: 'Display resolved UX design tokens', +)] +final class DebugTokensCommand extends Command +{ + /** + * @param array $defaultInputs + */ + public function __construct( + private readonly TokenRegistryInterface $tokenRegistry, + private readonly ?ConfiguredTokenResolver $resolver = null, + private readonly array $defaultInputs = [], + ) { + parent::__construct(); + } + + protected function configure(): void + { + $this + ->addArgument('path', InputArgument::OPTIONAL, 'An exact token path or group prefix') + ->addOption('format', null, InputOption::VALUE_REQUIRED, 'Output format (txt or json)', 'txt') + ->addOption('sources', null, InputOption::VALUE_NONE, 'Show which file set each value, and which ones it replaced') + ->setHelp(<<<'HELP' + Display the resolved tokens available through TokenRegistry and Twig: + + php bin/console debug:design-tokens + php bin/console debug:design-tokens color.brand + php bin/console debug:design-tokens --format=json + + Several files may set the same path, and the last one wins. --sources + answers which one that was, and which ones it replaced: + + php bin/console debug:design-tokens color.action --sources + + Resolving that costs a second pass, so it is off by default. + HELP) + ; + } + + protected function execute(InputInterface $input, OutputInterface $output): int + { + $format = $input->getOption('format'); + if (!\is_string($format) || !\in_array($format, ['txt', 'json'], true)) { + new SymfonyStyle($input, $output)->error('The --format option must be one of: txt, json.'); + + return Command::INVALID; + } + $path = $input->getArgument('path'); + if (null !== $path && !\is_string($path)) { + new SymfonyStyle($input, $output)->error('The path argument must be a string.'); + + return Command::INVALID; + } + $withSources = true === $input->getOption('sources'); + + try { + $tokens = $this->flatten($this->tokenRegistry->all()); + $provenance = $withSources ? $this->provenance() : []; + } catch (\Throwable $error) { + new SymfonyStyle($input, $output)->error($error->getMessage()); + + return Command::FAILURE; + } + + if (null !== $path) { + $tokens = array_filter( + $tokens, + static fn (TokenInterface $token, string $tokenPath): bool => $tokenPath === $path || str_starts_with($tokenPath, $path.'.'), + \ARRAY_FILTER_USE_BOTH, + ); + } + if ([] === $tokens) { + new SymfonyStyle($input, $output)->error(null === $path ? 'No design tokens are configured.' : \sprintf('No design tokens found under "%s".', $path)); + + return Command::FAILURE; + } + + if ('json' === $format) { + $data = []; + foreach ($tokens as $tokenPath => $token) { + $data[$tokenPath] = [ + 'type' => $token->getType(), + 'value' => $token->getValue(), + 'description' => $token->getDescription(), + 'deprecated' => $token->getDeprecationMessage() ?? $token->isDeprecated(), + 'extensions' => $token->getExtensions(), + ]; + if ($withSources) { + $data[$tokenPath] += $provenance[$tokenPath] ?? ['source' => null, 'overrides' => []]; + } + } + $output->writeln(json_encode(['tokens' => $data], \JSON_PRETTY_PRINT | \JSON_UNESCAPED_SLASHES | \JSON_UNESCAPED_UNICODE | \JSON_THROW_ON_ERROR)); + + return Command::SUCCESS; + } + + $rows = []; + foreach ($tokens as $tokenPath => $token) { + $row = [$tokenPath, $token->getType(), (string) $token]; + if ($withSources) { + $entry = $provenance[$tokenPath] ?? ['source' => null, 'overrides' => []]; + $row[] = $entry['source'] ?? ''; + $row[] = implode("\n", $entry['overrides']); + } + $row[] = $token->getDescription() ?? ''; + $rows[] = $row; + } + $headers = $withSources + ? ['Path', 'Type', 'CSS value', 'Source', 'Replaced', 'Description'] + : ['Path', 'Type', 'CSS value', 'Description']; + new SymfonyStyle($input, $output)->table($headers, $rows); + + return Command::SUCCESS; + } + + /** @return array}> */ + private function provenance(): array + { + $resolution = ($this->resolver ?? throw new LogicException('Tracing sources needs the configured token resolver.'))->trace($this->defaultInputs); + + $provenance = []; + foreach (array_keys($this->flatten($resolution->getTokens())) as $path) { + $provenance[$path] = [ + 'source' => self::label($resolution->getSource($path)), + 'overrides' => array_values(array_filter(array_map(self::label(...), $resolution->getOverrides($path)))), + ]; + } + + return $provenance; + } + + private static function label(?ResolverSource $source): ?string + { + if (null === $source) { + return null; + } + + return $source->uri ?? ('' !== $source->basePath ? $source->basePath : null); + } + + /** + * @param array $values + * + * @return array + */ + private function flatten(array $values, string $prefix = ''): array + { + $tokens = []; + foreach ($values as $name => $value) { + $name = (string) $name; + $path = '' === $prefix ? $name : $prefix.'.'.$name; + if ($value instanceof TokenInterface) { + $tokens[$path] = $value; + } elseif (\is_array($value)) { + $tokens += $this->flatten($value, $path); + } + } + + return $tokens; + } +} diff --git a/src/DesignTokens/src/Command/ExportCommand.php b/src/DesignTokens/src/Command/ExportCommand.php new file mode 100644 index 00000000000..383fe18005f --- /dev/null +++ b/src/DesignTokens/src/Command/ExportCommand.php @@ -0,0 +1,327 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\UX\DesignTokens\Command; + +use Symfony\Component\Console\Attribute\AsCommand; +use Symfony\Component\Console\Command\Command; +use Symfony\Component\Console\Completion\CompletionInput; +use Symfony\Component\Console\Completion\CompletionSuggestions; +use Symfony\Component\Console\Input\InputArgument; +use Symfony\Component\Console\Input\InputInterface; +use Symfony\Component\Console\Input\InputOption; +use Symfony\Component\Console\Output\OutputInterface; +use Symfony\Component\Console\Style\SymfonyStyle; +use Symfony\Component\Filesystem\Exception\IOExceptionInterface; +use Symfony\Component\Filesystem\Filesystem; +use Symfony\Contracts\Service\ServiceProviderInterface; +use Symfony\UX\DesignTokens\Exception\InvalidArgumentException; +use Symfony\UX\DesignTokens\Exception\LogicException; +use Symfony\UX\DesignTokens\Generator\ColorScheme; +use Symfony\UX\DesignTokens\Generator\GeneratorInterface; +use Symfony\UX\DesignTokens\TokenPath; +use Symfony\UX\DesignTokens\TokenRegistryInterface; + +/** + * @author Simon André + * + * @internal + */ +#[AsCommand( + name: 'ux:design-tokens:export', + description: 'Export design tokens to a file (DTCG, CSS, JavaScript)', +)] +final class ExportCommand extends Command +{ + /** + * Contents are not typed: the locator is fed by a tag, so each service is + * checked against GeneratorInterface when it is pulled out. + */ + private readonly Formats $generators; + + /** + * @param ServiceProviderInterface $generators keyed by the "format" tag attribute + */ + public function __construct( + private readonly TokenRegistryInterface $tokenRegistry, + ServiceProviderInterface $generators, + private readonly string $cssPrefix = 'dt', + private readonly ColorScheme $colorScheme = new ColorScheme(), + private readonly Filesystem $filesystem = new Filesystem(), + ) { + $this->generators = new Formats($generators); + + parent::__construct(); + } + + protected function configure(): void + { + $this + ->addArgument('format', InputArgument::REQUIRED, \sprintf('Output format (%s)', implode(', ', $this->generators->names()))) + ->addArgument('output', InputArgument::OPTIONAL, 'Output file path (stdout if omitted)') + ->addOption('title', null, InputOption::VALUE_REQUIRED, 'Page title, for formats that have one', 'Design System') + ->addOption('input', null, InputOption::VALUE_REQUIRED | InputOption::VALUE_IS_ARRAY, 'Resolver input as name=value, repeatable') + ->addOption('all-permutations', null, InputOption::VALUE_NONE, 'Write one file per Resolver permutation, using the output path as a template') + ->addOption('css-prefix', null, InputOption::VALUE_REQUIRED, 'Application prefix for CSS variables', $this->cssPrefix) + ->setHelp(<<<'HELP' + Export the configured design tokens: + + php bin/console ux:design-tokens:export dtcg build/tokens.tokens.json + php bin/console ux:design-tokens:export css assets/styles/theme.css + + When the Resolver declares the color scheme modifier, the CSS holds + the light context and, for the dark one, only what changes. + + Select another Resolver context with --input: + + php bin/console ux:design-tokens:export dtcg build/ocean.tokens.json --input=brand=ocean + + Or write every context the Resolver can produce at once. The output + path gains one suffix per input, so build/theme.css becomes + build/theme.brand-ocean.scheme-dark.css: + + php bin/console ux:design-tokens:export css build/theme.css --all-permutations + HELP) + ; + } + + protected function execute(InputInterface $input, OutputInterface $output): int + { + $io = new SymfonyStyle($input, $output); + + /** @var string $requested */ + $requested = $input->getArgument('format'); + $format = $this->generators->find($requested); + + if (null === $format) { + $io->error(\sprintf('Unknown format "%s". Available: %s', $requested, implode(', ', $this->generators->names()))); + + return Command::INVALID; + } + + // The locator is fed by a tag, so a wrongly tagged service is reported + // here instead of surfacing as a TypeError further down. + $generator = $this->generators->get($format); + if (!$generator instanceof GeneratorInterface) { + $io->error(\sprintf('The service registered for the "%s" export format must implement %s.', $format, GeneratorInterface::class)); + + return Command::FAILURE; + } + + $cssPrefix = $input->getOption('css-prefix'); + if (!\is_string($cssPrefix)) { + $io->error('The --css-prefix option must be a string.'); + + return Command::INVALID; + } + + /** @var string|null $outputPath */ + $outputPath = $input->getArgument('output'); + $everyPermutation = true === $input->getOption('all-permutations'); + + try { + TokenPath::validateCssPrefix($cssPrefix); + $inputs = $this->parseInputs($input); + + if ($everyPermutation) { + if (null === $outputPath) { + throw new InvalidArgumentException('The --all-permutations option needs an output path to derive file names from.'); + } + if ([] !== $inputs) { + throw new InvalidArgumentException('The --all-permutations and --input options cannot be used together.'); + } + + return $this->exportPermutations($io, $generator, $format, $cssPrefix, $outputPath, $input); + } + + $result = $this->render($generator, $cssPrefix, $inputs, $input); + } catch (\InvalidArgumentException|\LogicException|\RuntimeException|\JsonException $e) { + $io->error($e->getMessage()); + + return Command::FAILURE; + } + + if (null === $outputPath) { + $output->write($result); + + return Command::SUCCESS; + } + + try { + $this->filesystem->dumpFile($outputPath, $result); + } catch (IOExceptionInterface $e) { + $io->error(\sprintf('Could not write export to "%s": %s', $outputPath, $e->getMessage())); + + return Command::FAILURE; + } + + $io->success(\sprintf('Exported %s to %s', $format, $outputPath)); + + return Command::SUCCESS; + } + + /** + * Write one file per Resolver permutation. + */ + private function exportPermutations( + SymfonyStyle $io, + GeneratorInterface $generator, + string $format, + string $cssPrefix, + string $outputPath, + InputInterface $input, + ): int { + $permutations = $this->tokenRegistry->getPermutations(); + if ([] === $permutations) { + throw new LogicException('Exporting every permutation needs a Resolver document. Configure "ux_design_tokens.resolver.path".'); + } + + // Two contexts such as "a b" and "a-b" reduce to one file name; writing + // both would silently keep only the last. + $paths = []; + foreach ($permutations as $index => $inputs) { + $path = $this->permutationPath($outputPath, $inputs); + if (isset($paths[$path])) { + throw new InvalidArgumentException(\sprintf('The permutations %s and %s would both be written to the same file "%s". Rename one of the contexts.', json_encode($permutations[$paths[$path]], \JSON_UNESCAPED_SLASHES | \JSON_UNESCAPED_UNICODE), json_encode($inputs, \JSON_UNESCAPED_SLASHES | \JSON_UNESCAPED_UNICODE), $path)); + } + $paths[$path] = $index; + } + + $written = []; + foreach ($paths as $path => $index) { + $inputs = $permutations[$index]; + + $contents = $this->render($generator, $cssPrefix, $inputs, $input); + + try { + $this->filesystem->dumpFile($path, $contents); + } catch (IOExceptionInterface $e) { + $io->error(\sprintf('Could not write export to "%s": %s', $path, $e->getMessage())); + + return Command::FAILURE; + } + + $written[] = $path; + } + + $io->success(\sprintf('Exported %d %s permutations', \count($written), $format)); + $io->listing($written); + + return Command::SUCCESS; + } + + /** + * @param array $inputs + */ + private function render( + GeneratorInterface $generator, + string $cssPrefix, + array $inputs, + InputInterface $input, + ): string { + $title = $input->getOption('title'); + \assert(\is_string($title)); + + // Any format may read these: a page title, the prefix of CSS custom + // properties, and the dark resolution the CSS writes next to the light + // one. Without a color scheme in the Resolver, there is only one + // resolution. + $contexts = $this->colorScheme->contexts($this->tokenRegistry->getModifiers(), $inputs); + + return $generator->generate($this->tokenRegistry->all($contexts[0] ?? $inputs), [ + GeneratorInterface::TITLE => $title, + GeneratorInterface::CSS_PREFIX => $cssPrefix, + GeneratorInterface::DARK_TOKENS => null === $contexts ? null : $this->tokenRegistry->all($contexts[1]), + ]); + } + + /** + * Insert the selected inputs before the extension, so several permutations + * can be written next to each other without overwriting one another. + * + * @param array $inputs + */ + private function permutationPath(string $outputPath, array $inputs): string + { + ksort($inputs); + $parts = []; + foreach ($inputs as $name => $value) { + // Names and contexts come from a Resolver document, so they are + // reduced to a safe segment before they reach a path. + $parts[] = self::slug($name).'-'.self::slug((string) $value); + } + if ([] === $parts) { + return $outputPath; + } + + $suffix = '.'.implode('.', $parts); + + // `.tokens.json` and `.resolver.json` carry meaning as a whole, so the + // suffix goes in front of the pair rather than between its halves. + foreach (['.tokens.json', '.resolver.json'] as $compound) { + if (str_ends_with($outputPath, $compound)) { + return substr($outputPath, 0, -\strlen($compound)).$suffix.$compound; + } + } + + $extension = pathinfo($outputPath, \PATHINFO_EXTENSION); + + return '' === $extension + ? $outputPath.$suffix + : substr($outputPath, 0, -\strlen($extension) - 1).$suffix.'.'.$extension; + } + + private static function slug(string $value): string + { + $slug = trim((string) preg_replace('/[^A-Za-z0-9]+/', '-', $value), '-'); + + return '' === $slug ? 'x' : $slug; + } + + /** @return array */ + private function parseInputs(InputInterface $input): array + { + $raw = $input->getOption('input'); + if (!\is_array($raw)) { + throw new InvalidArgumentException('The --input option must be a list of name=value pairs.'); + } + + $inputs = []; + foreach ($raw as $pair) { + if (!\is_string($pair) || !str_contains($pair, '=')) { + throw new InvalidArgumentException(\sprintf('The --input option expects "name=value", got "%s".', \is_string($pair) ? $pair : get_debug_type($pair))); + } + [$name, $value] = explode('=', $pair, 2); + if ('' === $name) { + throw new InvalidArgumentException(\sprintf('The --input option expects a non-empty name, got "%s".', $pair)); + } + $inputs[$name] = $value; + } + + return $inputs; + } + + public function complete(CompletionInput $input, CompletionSuggestions $suggestions): void + { + if ($input->mustSuggestArgumentValuesFor('format')) { + $suggestions->suggestValues($this->generators->names()); + } + if ($input->mustSuggestOptionValuesFor('input')) { + $pairs = []; + foreach ($this->tokenRegistry->getPermutations() as $permutation) { + foreach ($permutation as $name => $context) { + $pairs[$name.'='.$context] = true; + } + } + $suggestions->suggestValues(array_keys($pairs)); + } + } +} diff --git a/src/DesignTokens/src/Command/Formats.php b/src/DesignTokens/src/Command/Formats.php new file mode 100644 index 00000000000..7e9e1f7214f --- /dev/null +++ b/src/DesignTokens/src/Command/Formats.php @@ -0,0 +1,59 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\UX\DesignTokens\Command; + +use Symfony\Contracts\Service\ServiceProviderInterface; + +/** + * The formats a tagged locator provides, keyed by the "format" tag attribute + * or, without it, by the service id. + * + * @author Simon André + * + * @internal + */ +final class Formats +{ + /** @param ServiceProviderInterface $services */ + public function __construct( + private readonly ServiceProviderInterface $services, + ) { + } + + /** @return list */ + public function names(): array + { + $names = array_map(strval(...), array_keys($this->services->getProvidedServices())); + sort($names); + + return $names; + } + + /** + * The registered name a requested format matches, compared case-insensitively. + */ + public function find(string $requested): ?string + { + foreach ($this->names() as $name) { + if (0 === strcasecmp($name, $requested)) { + return $name; + } + } + + return null; + } + + public function get(string $name): mixed + { + return $this->services->get($name); + } +} diff --git a/src/DesignTokens/src/Command/LintDesignTokensCommand.php b/src/DesignTokens/src/Command/LintDesignTokensCommand.php new file mode 100644 index 00000000000..5c5ef87199d --- /dev/null +++ b/src/DesignTokens/src/Command/LintDesignTokensCommand.php @@ -0,0 +1,376 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\UX\DesignTokens\Command; + +use Symfony\Component\Console\Attribute\AsCommand; +use Symfony\Component\Console\CI\GithubActionReporter; +use Symfony\Component\Console\Command\Command; +use Symfony\Component\Console\Input\InputArgument; +use Symfony\Component\Console\Input\InputInterface; +use Symfony\Component\Console\Input\InputOption; +use Symfony\Component\Console\Input\StreamableInputInterface; +use Symfony\Component\Console\Output\ConsoleOutputInterface; +use Symfony\Component\Console\Output\OutputInterface; +use Symfony\Component\Console\Style\SymfonyStyle; +use Symfony\Component\Filesystem\Exception\IOExceptionInterface; +use Symfony\Component\Filesystem\Filesystem; +use Symfony\UX\DesignTokens\Exception\RuntimeException; +use Symfony\UX\DesignTokens\Exception\UnresolvedReferenceException; +use Symfony\UX\DesignTokens\Resolver\JsonDocumentLoader; +use Symfony\UX\DesignTokens\Resolver\ResolverDocument; +use Symfony\UX\DesignTokens\Token\TokenInterface; +use Symfony\UX\DesignTokens\TokenRegistryInterface; +use Symfony\UX\DesignTokens\Validation\DtcgValidator; +use Symfony\UX\DesignTokens\Validation\Normalizer; + +/** + * @author Simon André + * + * @internal + */ +#[AsCommand( + name: 'lint:design-tokens', + description: 'Lint and normalize DTCG 2025.10 token and Resolver documents', +)] +final class LintDesignTokensCommand extends Command +{ + /** + * @param list $configuredPaths + */ + public function __construct( + private readonly DtcgValidator $validator, + private readonly Normalizer $normalizer, + private readonly ?TokenRegistryInterface $tokenRegistry = null, + private readonly array $configuredPaths = [], + private readonly ?string $configuredResolverPath = null, + private readonly Filesystem $filesystem = new Filesystem(), + ) { + parent::__construct(); + } + + protected function configure(): void + { + $this + ->addArgument('filename', InputArgument::OPTIONAL | InputArgument::IS_ARRAY, 'A file, directory, or "-" for STDIN') + ->addOption('fix', null, InputOption::VALUE_NONE, 'Rewrite each document in its normalized form') + ->addOption('format', null, InputOption::VALUE_REQUIRED, 'Output format (txt, json, or github; github by default on GitHub Actions)') + ->setHelp(<<<'HELP' + Lint DTCG 2025.10 token and Resolver documents: + + php bin/console lint:design-tokens design/theme.resolver.json + php bin/console lint:design-tokens theme.tokens.json --format=json + + Without filenames, the command lints the files configured under + ux_design_tokens.paths and resolver.path, then checks that + the application's own resolution succeeds: + + php bin/console lint:design-tokens + + A token document that a Resolver of the same run uses may alias tokens + other sources define; the Resolver checks those references: + + php bin/console lint:design-tokens design/ + + --fix rewrites each document in canonical form, in place, preserving + references, group metadata, empty objects and author-defined entry + order. Gate it in CI the way any formatter is gated, by running it and + then checking the working tree is clean: + + php bin/console lint:design-tokens design/base.tokens.json --fix + cat theme.tokens.json | php bin/console lint:design-tokens - --fix + HELP) + ; + } + + protected function execute(InputInterface $input, OutputInterface $output): int + { + $format = $input->getOption('format') ?? (GithubActionReporter::isGithubActionEnvironment() ? 'github' : 'txt'); + if (!\is_string($format) || !\in_array($format, ['txt', 'json', 'github'], true)) { + new SymfonyStyle($input, $output)->error('The --format option must be one of: txt, json, github.'); + + return Command::INVALID; + } + + $argument = $input->getArgument('filename'); + if (!\is_array($argument)) { + new SymfonyStyle($input, $output)->error('The filename argument must be a list.'); + + return Command::INVALID; + } + $fix = true === $input->getOption('fix'); + + $requested = array_values(array_filter($argument, \is_string(...))); + $configured = [] === $requested; + if ($configured) { + $requested = $this->configuredPaths; + if (null !== $this->configuredResolverPath) { + $requested[] = $this->configuredResolverPath; + } + } + if ([] === $requested) { + new SymfonyStyle($input, $output)->error('No design token files were provided or configured.'); + + return Command::INVALID; + } + + try { + $files = $this->expand($requested); + } catch (\Throwable $error) { + return $this->render($format, [['file' => '', 'valid' => false, 'error' => $error->getMessage(), 'warnings' => [], 'fixed' => false]], null, $input, $output); + } + + $completed = $this->completedByResolvers($files); + $results = []; + foreach ($files as $file) { + $results[] = $this->lint($file, $fix, isset($completed[(string) realpath($file)]), $input, $output); + } + + // Report on stderr when the fixed document goes to stdout. + $report = $fix && \in_array('-', $files, true) && $output instanceof ConsoleOutputInterface ? $output->getErrorOutput() : $output; + + return $this->render($format, $results, $configured ? $this->resolution() : null, $input, $report); + } + + /** + * @return array{file: string, valid: bool, error: ?string, warnings: list, fixed: bool} + */ + private function lint(string $file, bool $fix, bool $partial, InputInterface $input, OutputInterface $output): array + { + $result = ['file' => $file, 'valid' => true, 'error' => null, 'warnings' => [], 'fixed' => false]; + + try { + if ('-' === $file) { + $stream = $input instanceof StreamableInputInterface ? $input->getStream() : null; + $source = stream_get_contents($stream ?? \STDIN); + if (false === $source || '' === trim($source)) { + throw new RuntimeException('Could not read a DTCG document from standard input.'); + } + $result['warnings'] = $this->validator->validateJson($source); + $normalized = $fix ? $this->normalizer->normalize($source, 'stdin', (string) getcwd()) : $source; + } else { + try { + $source = new Filesystem()->readFile($file); + } catch (IOExceptionInterface $e) { + throw new RuntimeException(\sprintf('Could not read design token document: "%s".', $file), previous: $e); + } + $result['warnings'] = $this->validator->validateFile($file, $partial); + $normalized = $fix ? $this->normalizer->normalizeFile($file, $partial) : $source; + } + } catch (UnresolvedReferenceException $error) { + return [...$result, 'valid' => false, 'error' => $error->getMessage().' If a Resolver completes this document with other sources, lint it with that Resolver.', 'warnings' => []]; + } catch (\Throwable $error) { + return [...$result, 'valid' => false, 'error' => $error->getMessage(), 'warnings' => []]; + } + + if ($fix && '-' === $file) { + $output->write($normalized); + + return [...$result, 'fixed' => $source !== $normalized]; + } + + if (!$fix || $source === $normalized) { + return $result; + } + + try { + $this->filesystem->dumpFile($file, $normalized); + } catch (IOExceptionInterface $error) { + return [...$result, 'valid' => false, 'error' => \sprintf('Could not write the normalized document: %s', $error->getMessage()), 'warnings' => []]; + } + + return [...$result, 'fixed' => true]; + } + + /** + * @param list $files + * + * @return array keyed by real path + */ + private function completedByResolvers(array $files): array + { + $completed = []; + foreach ($files as $file) { + if (!str_ends_with($file, '.resolver.json')) { + continue; + } + try { + $document = new ResolverDocument(new JsonDocumentLoader()->load($file), \dirname($file)); + foreach ($document->getPermutations() as $inputs) { + $document->sourceDescriptors($inputs); + } + } catch (\Throwable) { + continue; + } + foreach ($document->loadedUris() as $uri) { + if (false !== $path = realpath($uri)) { + $completed[$path] = true; + } + } + } + + return $completed; + } + + /** @return array{valid: bool, tokens: int, error: ?string}|null */ + private function resolution(): ?array + { + if (null === $this->tokenRegistry) { + return null; + } + + try { + return ['valid' => true, 'tokens' => $this->countTokens($this->tokenRegistry->all()), 'error' => null]; + } catch (\Throwable $error) { + return ['valid' => false, 'tokens' => 0, 'error' => $error->getMessage()]; + } + } + + /** @param array $values */ + private function countTokens(array $values): int + { + $count = 0; + foreach ($values as $value) { + if ($value instanceof TokenInterface) { + ++$count; + } elseif (\is_array($value)) { + $count += $this->countTokens($value); + } + } + + return $count; + } + + /** + * @param list $requested + * + * @return list + */ + private function expand(array $requested): array + { + $files = []; + foreach ($requested as $path) { + if ('-' === $path || is_file($path)) { + $files[$path] = true; + continue; + } + if (!is_dir($path)) { + throw new RuntimeException(\sprintf('Design token path not found: "%s".', $path)); + } + foreach ($this->filesInDirectory($path) as $filename) { + $files[$filename] = true; + } + } + $paths = array_keys($files); + sort($paths); + if ([] === $paths) { + throw new RuntimeException('No .tokens.json, .tokens, or .resolver.json files were found.'); + } + + return $paths; + } + + /** @return iterable */ + private function filesInDirectory(string $path): iterable + { + $directory = new \RecursiveDirectoryIterator($path, \RecursiveDirectoryIterator::SKIP_DOTS); + for ($directory->rewind(); $directory->valid(); $directory->next()) { + $filename = $directory->getPathname(); + if ($directory->isDir()) { + yield from $this->filesInDirectory($filename); + } elseif ($directory->isFile() && $this->supports($filename)) { + yield $filename; + } + } + } + + private function supports(string $path): bool + { + return str_ends_with($path, '.tokens.json') + || str_ends_with($path, '.tokens') + || str_ends_with($path, '.resolver.json'); + } + + /** + * @param list, fixed: bool}> $results + * @param array{valid: bool, tokens: int, error: ?string}|null $resolution + */ + private function render(string $format, array $results, ?array $resolution, InputInterface $input, OutputInterface $output): int + { + $valid = !\in_array(false, array_column($results, 'valid'), true) + && (null === $resolution || $resolution['valid']); + $warningCount = array_sum(array_map('\count', array_column($results, 'warnings'))); + $fixed = array_values(array_filter($results, static fn (array $result): bool => $result['fixed'] && '-' !== $result['file'])); + + if ('json' === $format) { + $payload = ['valid' => $valid, 'files' => $results]; + if (null !== $resolution) { + $payload['resolution'] = $resolution; + } + $output->writeln(json_encode($payload, \JSON_UNESCAPED_SLASHES | \JSON_UNESCAPED_UNICODE | \JSON_THROW_ON_ERROR)); + + return $valid ? Command::SUCCESS : Command::FAILURE; + } + + if ('github' === $format) { + $reporter = new GithubActionReporter($output); + foreach ($results as $result) { + if (!$result['valid']) { + $reporter->error($result['error'] ?? 'Invalid DTCG document.', '' === $result['file'] ? null : $result['file'], 1); + } + foreach ($result['warnings'] as $warning) { + $reporter->warning($warning, '' === $result['file'] ? null : $result['file'], 1); + } + } + if (null !== $resolution && !$resolution['valid']) { + $reporter->error($resolution['error'] ?? 'The configured design tokens do not resolve.'); + } + + return $valid ? Command::SUCCESS : Command::FAILURE; + } + + $io = new SymfonyStyle($input, $output); + foreach ($results as $result) { + foreach ($result['warnings'] as $warning) { + $io->warning(\sprintf('%s: %s', '' === $result['file'] ? 'Input' : $result['file'], $warning)); + } + } + foreach ($fixed as $result) { + $io->writeln(\sprintf('Normalized %s', $result['file'])); + } + + if (!$valid) { + foreach ($results as $result) { + if (!$result['valid']) { + $io->error(\sprintf('%s: %s', '' === $result['file'] ? 'Input' : $result['file'], $result['error'])); + } + } + if (null !== $resolution && !$resolution['valid']) { + $io->error(\sprintf('The configured design tokens do not resolve: %s', $resolution['error'])); + } + + return Command::FAILURE; + } + + $summary = 1 === \count($results) + ? 'The design token document is valid.' + : \sprintf('All %d design token documents are valid.', \count($results)); + if (0 !== $warningCount) { + $summary = \sprintf('%s %d warning%s.', $summary, $warningCount, 1 === $warningCount ? '' : 's'); + } + if (null !== $resolution) { + $summary = \sprintf('%s The configured resolution holds %d token%s.', $summary, $resolution['tokens'], 1 === $resolution['tokens'] ? '' : 's'); + } + $io->success($summary); + + return Command::SUCCESS; + } +} diff --git a/src/DesignTokens/src/EventListener/WarmDesignTokensOnAssetCompileListener.php b/src/DesignTokens/src/EventListener/WarmDesignTokensOnAssetCompileListener.php new file mode 100644 index 00000000000..a49166d1d2f --- /dev/null +++ b/src/DesignTokens/src/EventListener/WarmDesignTokensOnAssetCompileListener.php @@ -0,0 +1,36 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\UX\DesignTokens\EventListener; + +use Symfony\Component\AssetMapper\Event\PreAssetsCompileEvent; +use Symfony\UX\DesignTokens\CacheWarmer\DesignTokensCacheWarmer; + +/** + * @author Simon André + * + * @internal + */ +final class WarmDesignTokensOnAssetCompileListener +{ + public function __construct( + private readonly DesignTokensCacheWarmer $warmer, + private readonly string $buildDir, + ) { + } + + public function __invoke(PreAssetsCompileEvent $event): void + { + $event->getOutput()->writeln('Rendering the design token stylesheet...'); + + $this->warmer->warmUp($this->buildDir, $this->buildDir); + } +} diff --git a/src/DesignTokens/src/Generator/ColorScheme.php b/src/DesignTokens/src/Generator/ColorScheme.php new file mode 100644 index 00000000000..5117d88fc63 --- /dev/null +++ b/src/DesignTokens/src/Generator/ColorScheme.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\Generator; + +use Symfony\UX\DesignTokens\Resolver\ResolverInputs; + +/** + * @author Simon André + * + * @internal + */ +final class ColorScheme +{ + public function __construct( + public readonly string $modifier = 'scheme', + public readonly string $light = 'light', + public readonly string $dark = 'dark', + ) { + } + + /** + * Null when there is only one resolution. + * + * @param array, default: string|null}> $modifiers as TokenResolverInterface::getModifiers() returns them + * @param array $inputs + * + * @return array{array, array}|null + */ + public function contexts(array $modifiers, array $inputs = []): ?array + { + foreach (array_keys($inputs) as $name) { + if (0 === strcasecmp((string) $name, $this->modifier)) { + return null; + } + } + + foreach ($modifiers as $name => $modifier) { + if (0 !== strcasecmp((string) $name, $this->modifier)) { + continue; + } + $contexts = array_map(strtolower(...), $modifier['contexts']); + if (!\in_array(strtolower($this->light), $contexts, true) || !\in_array(strtolower($this->dark), $contexts, true)) { + return null; + } + + return [ + ResolverInputs::merge($inputs, [$this->modifier => $this->light]), + ResolverInputs::merge($inputs, [$this->modifier => $this->dark]), + ]; + } + + return null; + } +} diff --git a/src/DesignTokens/src/Generator/CssGenerator.php b/src/DesignTokens/src/Generator/CssGenerator.php new file mode 100644 index 00000000000..e48d5ed5f75 --- /dev/null +++ b/src/DesignTokens/src/Generator/CssGenerator.php @@ -0,0 +1,223 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\UX\DesignTokens\Generator; + +use Symfony\UX\DesignTokens\Exception\InvalidArgumentException; +use Symfony\UX\DesignTokens\Token\ShadowToken; +use Symfony\UX\DesignTokens\Token\TokenFactory; +use Symfony\UX\DesignTokens\Token\TokenInterface; +use Symfony\UX\DesignTokens\TokenPath; +use Symfony\UX\DesignTokens\TokenTree; + +/** + * @author Simon André + */ +final class CssGenerator implements GeneratorInterface +{ + /** @var array> */ + private const MEMBERS = [ + 'typography' => [ + 'fontFamily' => ['font-family', 'fontFamily'], + 'fontSize' => ['font-size', 'dimension'], + 'fontWeight' => ['font-weight', 'fontWeight'], + 'letterSpacing' => ['letter-spacing', 'dimension'], + 'lineHeight' => ['line-height', 'number'], + ], + 'border' => [ + 'color' => ['color', 'color'], + 'width' => ['width', 'dimension'], + 'style' => ['style', 'strokeStyle'], + ], + 'transition' => [ + 'duration' => ['duration', 'duration'], + 'timingFunction' => ['timing-function', 'cubicBezier'], + 'delay' => ['delay', 'duration'], + ], + 'shadow' => [ + 'color' => ['color', 'color'], + 'offsetX' => ['offset-x', 'dimension'], + 'offsetY' => ['offset-y', 'dimension'], + 'blur' => ['blur', 'dimension'], + 'spread' => ['spread', 'dimension'], + ], + ]; + + public function __construct(private readonly ?string $prefix = null) + { + if (null !== $prefix) { + TokenPath::validateCssPrefix($prefix); + } + } + + /** + * @param array $resolvedTokens nested token tree from {@see TokenTreeBuilder}, written to :root + * @param array $context {@see GeneratorInterface::CSS_PREFIX} replaces the configured prefix + */ + public function generate(array $resolvedTokens, array $context = []): string + { + $prefix = $context[GeneratorInterface::CSS_PREFIX] ?? $this->prefix; + if (null !== $prefix) { + if (!\is_string($prefix)) { + throw new InvalidArgumentException('The CSS prefix must be a string.'); + } + TokenPath::validateCssPrefix($prefix); + } + + $atProperties = []; + $rootVars = [':root {']; + $light = $this->variables($resolvedTokens, $prefix); + + foreach ($light as $cssVarName => [$cssValue, $subValue]) { + $syntax = $this->detectSyntax($subValue); + + if (null !== $syntax) { + $atProperties[] = \sprintf( + "@property %s { syntax: '%s'; inherits: true; initial-value: %s; }", + $cssVarName, + $syntax, + $cssValue, + ); + } + + $rootVars[] = \sprintf(' %s: %s;', $cssVarName, $cssValue); + } + + $rootVars[] = '}'; + $css = implode("\n", $atProperties)."\n\n".implode("\n", $rootVars); + + $darkTokens = $context[GeneratorInterface::DARK_TOKENS] ?? null; + if (!\is_array($darkTokens)) { + return $css; + } + + $dark = []; + foreach ($this->variables($darkTokens, $prefix) as $cssVarName => [$cssValue]) { + if (($light[$cssVarName][0] ?? null) !== $cssValue) { + $dark[] = \sprintf('%s: %s;', $cssVarName, $cssValue); + } + } + if ([] === $dark) { + return $css; + } + + return $css."\n\n" + ."@media (prefers-color-scheme: dark) {\n" + .' :root:not([data-theme="light"]):not([data-theme="dark"]) {'."\n ".implode("\n ", $dark)."\n }\n}\n\n" + .':root[data-theme="dark"] {'."\n ".implode("\n ", $dark)."\n}"; + } + + /** + * @param array $tokens + * + * @return array + */ + private function variables(array $tokens, ?string $prefix): array + { + $variables = []; + $generatedNames = []; + + foreach (TokenTree::flatten($tokens) as $name => $token) { + foreach ($this->unrollToken($name, $token) as $subName => $subValue) { + $cssVarName = TokenPath::toCssVariable($subName, $prefix); + if (isset($generatedNames[$cssVarName]) && $generatedNames[$cssVarName] !== $subName) { + throw new InvalidArgumentException(\sprintf('Design token paths "%s" and "%s" generate the same CSS custom property "%s".', $generatedNames[$cssVarName], $subName, $cssVarName)); + } + $generatedNames[$cssVarName] = $subName; + $variables[$cssVarName] = [(string) $subValue, $subValue]; + } + } + + return $variables; + } + + /** @return iterable */ + private function unrollToken(string $name, TokenInterface $token): iterable + { + yield $name => $token; + + if ($token instanceof ShadowToken) { + yield from self::unrollShadow($name, $token); + + return; + } + + $members = self::MEMBERS[$token->getType()] ?? null; + if (null === $members) { + return; + } + + $value = $token->getValue(); + if (!\is_array($value)) { + return; + } + + foreach ($members as $key => [$suffix, $type]) { + if (isset($value[$key])) { + yield "$name-$suffix" => TokenFactory::project($type, $value[$key]); + } + } + } + + /** @return iterable */ + private static function unrollShadow(string $name, ShadowToken $token): iterable + { + $value = $token->getValue(); + $layers = isset($value['color']) ? [$value] : array_values($value); + + foreach ($layers as $index => $layer) { + if (!\is_array($layer)) { + continue; + } + $prefix = 1 === \count($layers) ? $name : $name.'-'.($index + 1); + + foreach (self::MEMBERS['shadow'] as $key => [$suffix, $type]) { + if (isset($layer[$key])) { + yield "$prefix-$suffix" => TokenFactory::project($type, $layer[$key]); + } + } + } + } + + private function detectSyntax(mixed $value): ?string + { + if ($value instanceof TokenInterface) { + return match ($value->getType()) { + 'color' => '', + 'dimension' => \is_array($dimension = $value->getValue()) && 'px' === ($dimension['unit'] ?? null) ? '' : null, + 'duration' => '