diff --git a/README.md b/README.md index 68224c3..7e11b14 100644 --- a/README.md +++ b/README.md @@ -13,7 +13,7 @@ Figmage comes with a comprehensive documentation, which you can find [here](http If this is your first time here, check out our [Getting Started Guide](https://figmage.netlify.app/guides/getting-started/)! ## What's in the box 🎁 -Figmage is a magical CLI tool that helps you generate a flutter package from your Figma Design System. It uses the Figma APIs to fetch your design tokens from published **styles**, as well as **variables**, with full **modes** support. +Figmage is a magical CLI tool that helps you generate a flutter package from your Figma Design System. It uses the Figma APIs to fetch your design tokens from published **styles**, as well as **variables**, with full **modes** support. It can also read **Design Tokens JSON** exported by plugins for offline generation. So a variables section like this: ![Example Screenshot of a Variables Section](doc/example_variables.jpg) @@ -85,8 +85,9 @@ And you can use it like this: - 🎨 Supports many types of tokens: - 🌈 **Color** styles and variables - 🖋️ **Typography** styles (with optional `google_fonts` support!) - - 🔢 **Number** variables, which can be generated as Paddings, and Spacers as well +- 🔢 **Number** variables, which can be generated as Paddings, and Spacers as well - 🌓 **Modes** support for variables: Generate different tokens for different themes (e.g. dark/light) +- 📄 **JSON token sources**: Add `json.paths` in `figmage.yaml` with a resolver manifest (for example `resolver.json`) to load Design Tokens JSON across ordered sets. - 📦 **Package** generation: All your tokens end up in one convenient package. Depend on it from your app, and update it whenever neccessary! - 🤝 **Seamless** integration with `Theme`s from `material.dart`: Generated classes are `ThemeExtension`s, so they can be integrated into your app's theme easily! - 🎯 **Quick access** using `BuildContext` extensions. @@ -101,4 +102,4 @@ And you can use it like this: [mason_link]: https://github.com/felangel/mason [very_good_ventures_link]: https://verygood.ventures [wnmi_badge]: - https://img.shields.io/badge/empowered_by-whynotmake.it-black?logo=data%3Aimage%2Fpng%3Bbase64%2CiVBORw0KGgoAAAANSUhEUgAAAJYAAACWCAYAAAA8AXHiAAAACXBIWXMAAAsTAAALEwEAmpwYAAAAAXNSR0IArs4c6QAAAARnQU1BAACxjwv8YQUAAAe2SURBVHgB7Z2BlaM2EIbHqWBLoITtIE4HlwpMKthNBSYV5DpYXwWXVOB0cNcB6WC3g4l44Lw5kECCEUjwf%2B%2Fx%2FNaGGYG%2BNQLGQAQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAHAgTpQgzHw2L0X35%2FfT6fSdVsDkfTIvL2Y6m%2BnZTM3fH00bzPTFtONGID9Mx76Y6Z2H1GYqKCIm%2FidH7lXbAZQxHXblaS4UAc%2FcDxr5ngmkT2DHqsoVmBty5YKjY5uOezPTV44oF7ulqrgdbzXzPJvpBrkyYkSqZzFPyRHkGpGqdMxfTbUVJICPVGJeVblCpRLLQa6UCZFKLKMi11ypxPKQK0XmSCWWXSTXUqlEHMiVEkukEjFmyaUllYgHuVJAQyoRK0gubalEXJdcTwTioymViOklVyypRHybXBWBuMSQSsR2yfWp%2B%2FwlplSiHZ978b8RiEdMqUSO0pVjDam6NhSW%2FG8xch2esQ4nZUZyRZdqJP%2BDmjGg16PboNGlEvkqHueVIsDtt1U9kRtHixqYjXi2bNyoG9aRU1KQMp5SHVKunygORe%2Fvv1co1vt54vOSFOlEvdNwXW9m%2BtVMX3rvN6cg7keSSx0ejjnuFBH2L33RqopwfVNVvflwElUTth%2BRrV2kd%2BcIcvlKJeaHXJo4OnatIr2y%2BzzoDL1HviCpxHKQSwt2D6ZjF%2BmVvfm0qiJmSSWWh1xaMCdTpLe0KmKRVCIO5NKC0ynSm1sVoSKViAe5tOB0ivRCqyJUpRJxXXIVBMLgdIr0fKsiokgl4tvk%2BkwgHOZkivRc7XjtPj%2FHlEq0oy%2FXO4F5MCdTpOdqh4uKImDLw%2B0tBkAozMkU6fnKVVEEeFhuI6kZA%2FpwRjr1yu2u6E%2BOKJVHOx68UQRM3CczfZvIjaPFOXD47qikCEy040zKeEoFuZbA%2FnKVFJGRdlSkyIhUzXvN%2BOrGkEsHbm8hVPNGUol2qJxvG4k%2FJtWTmA8nUbXglcZUHu2IdRnKSyoxP%2BRaCkc%2B%2BpvRHu3LUEFSieUg11xSk0q0S%2Bsy1CypxPKQK5RUpXqwVK6lUok4kMuX1KV6MFcuLalEPMg1RS5SPeDwy1CqUom4qIpwkZtUD9j%2FMlQUqUR8m1xRf6ySPOy%2BNlZSBvB0VURUqUQ7BlURmvGzw7HRS8qIEblc94hXlUq0o%2B7lufARx1ts%2F0lYSRnC0z%2FnjypV14YxkQs6CmZlX3sb4C%2FKFG53eVNPsYgp1XUi93GOFnl4D6koN%2BlYCx6vTthSqiTkinXvBhv9Df1BK8BtbVdzkbugdWjuUfHL6XRSX79GKvNS9d5u8tzM1N8DHONeETwsBykpMvzjQLvWkovdR7drf1PVcp34iCdRNxLrPtYRM2M%2Bsb3EZ1OpxLzHkmsjsSrfDvGMt8p5ql7Oa%2Bg6HEquLcRy5J3sGEecLKQSyx5Drq3EcuT27qBu%2BaykEjH2L9eWYjnye3VUrlKJWPu%2BcL21WI42jHZY7lKJmPu9cJ2CWI52WDuO21MK2UslYtvkyv%2FCNQ8P%2FUvaCIdc3LXxrXt934tUIsc9lT5QoeusPmfakBG5XGQtVZenf1mtolxxSFVTAgTIlb1UXa5qF2K5pOKEjkg85Kr3IFWX797L9YlyY%2B2NtoQJuc4UgQ2kerHkKygn2H6RNtpG02BErpKU2UCqiyVffqcbeLgLjLbRNGGlyz8TOa6xc%2FTyXdhOQbnBw%2FM%2FJWVCTLkSkirPIkvLimR1Ii6GXAlJVVGu9NeEMkRTLkilBA8L4c6UIRpyQSpFLB2S7QXPJXJBKmXY%2FoCmKDeJXYM5ckGqSLD9sXKHkAtSRYTdjwzZtVyQagUOJtdXtn9LQ6oYHEguhlQrc1C5aki1AgeUK8plE0hl4WByqYs1IhVuF3kguWrWrYq48DQqT9DIFsgVHPvC%2FkAuhlw%2BMS8OgZrSpDvksgC5JmNdHOJUYp5XyGUBcjljXHhCKjFvCbksQK7Bshf2lEosA7lsQK7%2Fl7lwoFRiWZtcOBVxdLlY4eSnQ67PdHSOKpeGVCJWf0D%2FTuBwclXsfrpsRTPh4U1NCgKHr4pYJFWXq4ZYDvYmF%2Fs90WKxVF2uGmKNsEO5pr61KlIAYnmwJ7kmxKpICYjlyV7kcqyDqlSOPAUBO7nLxfZbCTWo%2F%2B4SYgWSq1w8XvryjZSBWDPITS7eoEgPYs0kF7l4vJw4mlwQawGpyzUiVcX2RxmryQWxFpKqXOxXpFfGkgtiKZCaXJxAkR4Pd7X5P4ViC1KQi9tLNldfqcRyLrlm%2FYTM0oaaVuZEO4Lbr%2FvmnFDR%2B%2Bh2Op1%2BowC6%2F%2FCrmULuhV443v%2FD5K9oPF9pXmz%2FBB8U9vzsJxo%2Bfzt4%2FUEPjW%2BukRhzqALylqxPzRhf6cAL5GI9qZoxTvCTH7iVy6cCwoea9%2F4U%2B7XhGXKxjlQ1t6cTZg%2BWu3bceL5g70vbsJRdjbH68MiYy0y%2Fm3HHh8e8X8xUkQcm3r%2BkTCdHiCAfcr1AJNj9LdS899b9Z98d%2F%2Fk3AsAFz9vF3QiAKQLlws%2BlQBjdrs8lWHOjjfye4Zcgux68j8Ht0zKaQ%2FFmYNwMdr%2BbQe8%2FBAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAASfAfQWaAgS7EyI8AAAAASUVORK5CYII%3D \ No newline at end of file + https://img.shields.io/badge/empowered_by-whynotmake.it-black?logo=data%3Aimage%2Fpng%3Bbase64%2CiVBORw0KGgoAAAANSUhEUgAAAJYAAACWCAYAAAA8AXHiAAAACXBIWXMAAAsTAAALEwEAmpwYAAAAAXNSR0IArs4c6QAAAARnQU1BAACxjwv8YQUAAAe2SURBVHgB7Z2BlaM2EIbHqWBLoITtIE4HlwpMKthNBSYV5DpYXwWXVOB0cNcB6WC3g4l44Lw5kECCEUjwf%2B%2Fx%2FNaGGYG%2BNQLGQAQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAHAgTpQgzHw2L0X35%2FfT6fSdVsDkfTIvL2Y6m%2BnZTM3fH00bzPTFtONGID9Mx76Y6Z2H1GYqKCIm%2FidH7lXbAZQxHXblaS4UAc%2FcDxr5ngmkT2DHqsoVmBty5YKjY5uOezPTV44oF7ulqrgdbzXzPJvpBrkyYkSqZzFPyRHkGpGqdMxfTbUVJICPVGJeVblCpRLLQa6UCZFKLKMi11ypxPKQK0XmSCWWXSTXUqlEHMiVEkukEjFmyaUllYgHuVJAQyoRK0gubalEXJdcTwTioymViOklVyypRHybXBWBuMSQSsR2yfWp%2B%2FwlplSiHZ978b8RiEdMqUSO0pVjDam6NhSW%2FG8xch2esQ4nZUZyRZdqJP%2BDmjGg16PboNGlEvkqHueVIsDtt1U9kRtHixqYjXi2bNyoG9aRU1KQMp5SHVKunygORe%2Fvv1co1vt54vOSFOlEvdNwXW9m%2BtVMX3rvN6cg7keSSx0ejjnuFBH2L33RqopwfVNVvflwElUTth%2BRrV2kd%2BcIcvlKJeaHXJo4OnatIr2y%2BzzoDL1HviCpxHKQSwt2D6ZjF%2BmVvfm0qiJmSSWWh1xaMCdTpLe0KmKRVCIO5NKC0ynSm1sVoSKViAe5tOB0ivRCqyJUpRJxXXIVBMLgdIr0fKsiokgl4tvk%2BkwgHOZkivRc7XjtPj%2FHlEq0oy%2FXO4F5MCdTpOdqh4uKImDLw%2B0tBkAozMkU6fnKVVEEeFhuI6kZA%2FpwRjr1yu2u6E%2BOKJVHOx68UQRM3CczfZvIjaPFOXD47qikCEy040zKeEoFuZbA%2FnKVFJGRdlSkyIhUzXvN%2BOrGkEsHbm8hVPNGUol2qJxvG4k%2FJtWTmA8nUbXglcZUHu2IdRnKSyoxP%2BRaCkc%2B%2BpvRHu3LUEFSieUg11xSk0q0S%2Bsy1CypxPKQK5RUpXqwVK6lUok4kMuX1KV6MFcuLalEPMg1RS5SPeDwy1CqUom4qIpwkZtUD9j%2FMlQUqUR8m1xRf6ySPOy%2BNlZSBvB0VURUqUQ7BlURmvGzw7HRS8qIEblc94hXlUq0o%2B7lufARx1ts%2F0lYSRnC0z%2FnjypV14YxkQs6CmZlX3sb4C%2FKFG53eVNPsYgp1XUi93GOFnl4D6koN%2BlYCx6vTthSqiTkinXvBhv9Df1BK8BtbVdzkbugdWjuUfHL6XRSX79GKvNS9d5u8tzM1N8DHONeETwsBykpMvzjQLvWkovdR7drf1PVcp34iCdRNxLrPtYRM2M%2Bsb3EZ1OpxLzHkmsjsSrfDvGMt8p5ql7Oa%2Bg6HEquLcRy5J3sGEecLKQSyx5Drq3EcuT27qBu%2BaykEjH2L9eWYjnye3VUrlKJWPu%2BcL21WI42jHZY7lKJmPu9cJ2CWI52WDuO21MK2UslYtvkyv%2FCNQ8P%2FUvaCIdc3LXxrXt934tUIsc9lT5QoeusPmfakBG5XGQtVZenf1mtolxxSFVTAgTIlb1UXa5qF2K5pOKEjkg85Kr3IFWX797L9YlyY%2B2NtoQJuc4UgQ2kerHkKygn2H6RNtpG02BErpKU2UCqiyVffqcbeLgLjLbRNGGlyz8TOa6xc%2FTyXdhOQbnBw%2FM%2FJWVCTLkSkirPIkvLimR1Ii6GXAlJVVGu9NeEMkRTLkilBA8L4c6UIRpyQSpFLB2S7QXPJXJBKmXY%2FoCmKDeJXYM5ckGqSLD9sXKHkAtSRYTdjwzZtVyQagUOJtdXtn9LQ6oYHEguhlQrc1C5aki1AgeUK8plE0hl4WByqYs1IhVuF3kguWrWrYq48DQqT9DIFsgVHPvC%2FkAuhlw%2BMS8OgZrSpDvksgC5JmNdHOJUYp5XyGUBcjljXHhCKjFvCbksQK7Bshf2lEosA7lsQK7%2Fl7lwoFRiWZtcOBVxdLlY4eSnQ67PdHSOKpeGVCJWf0D%2FTuBwclXsfrpsRTPh4U1NCgKHr4pYJFWXq4ZYDvYmF%2Fs90WKxVF2uGmKNsEO5pr61KlIAYnmwJ7kmxKpICYjlyV7kcqyDqlSOPAUBO7nLxfZbCTWo%2F%2B4SYgWSq1w8XvryjZSBWDPITS7eoEgPYs0kF7l4vJw4mlwQawGpyzUiVcX2RxmryQWxFpKqXOxXpFfGkgtiKZCaXJxAkR4Pd7X5P4ViC1KQi9tLNldfqcRyLrlm%2FYTM0oaaVuZEO4Lbr%2FvmnFDR%2B%2Bh2Op1%2BowC6%2F%2FCrmULuhV443v%2FD5K9oPF9pXmz%2FBB8U9vzsJxo%2Bfzt4%2FUEPjW%2BukRhzqALylqxPzRhf6cAL5GI9qZoxTvCTH7iVy6cCwoea9%2F4U%2B7XhGXKxjlQ1t6cTZg%2BWu3bceL5g70vbsJRdjbH68MiYy0y%2Fm3HHh8e8X8xUkQcm3r%2BkTCdHiCAfcr1AJNj9LdS899b9Z98d%2F%2Fk3AsAFz9vF3QiAKQLlws%2BlQBjdrs8lWHOjjfye4Zcgux68j8Ht0zKaQ%2FFmYNwMdr%2BbQe8%2FBAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAASfAfQWaAgS7EyI8AAAAASUVORK5CYII%3D diff --git a/doc/json_token_source_spec.md b/doc/json_token_source_spec.md new file mode 100644 index 0000000..02f89df --- /dev/null +++ b/doc/json_token_source_spec.md @@ -0,0 +1,95 @@ +JSON Token Source Spec +Status: implemented + +Overview +Figmage supports JSON token ingestion via a resolver-manifest entrypoint. +JSON source data is merged with Figma API tokens (if configured) and uses the +same downstream generation pipeline. + +Resolver Entrypoint +`json.paths` must contain exactly one file path, and that path must point to a +resolver manifest JSON file. + +Resolver format (required fields): +- `version`: non-empty string (currently minimally validated) +- `sets`: object where each key is a set name and each value is an object with + `"$ref": "./some-file.json"` +- `resolutionOrder`: non-empty list of set names + +Example: +```json +{ + "version": "2025.10", + "sets": { + "global": { "$ref": "./global.json" }, + "Color Primitives": { "$ref": "./Color Primitives.json" }, + "Color Aliases": { "$ref": "./Color Aliases.json" } + }, + "resolutionOrder": ["global", "Color Primitives", "Color Aliases"] +} +``` + +Validation +Figmage throws when: +- `json.paths` does not contain exactly one path +- resolver file is missing or invalid JSON +- resolver shape is invalid (`version`/`sets`/`resolutionOrder`) +- `resolutionOrder` references a set not present in `sets` +- resolver set file path is missing/unreadable +- the resolver references the same resolved set file path more than once + +Token Parsing +- Token objects must include `$type` and `$value`. +- Supported `$type` values: + - `color` -> `int` + - `number` -> `double` + - `boolean` -> `bool` + - `text` -> `String` + - `typography` -> `Typography` +- Mode inference remains path-based (same behavior as previous JSON mode): + second segment can become mode when branch signatures match. + +Reference Resolution +Figmage uses a two-pass pipeline: +1. Parse all resolver-ordered set files and collect token definitions. +2. Resolve aliases/refs and emit `AliasOr` structures. + +Supported references: +- Curly alias: `"{Color Primitives.Value.stone.50}"` + - Resolves against the resolver-expanded virtual token graph across all files + in `resolutionOrder`. + - Dot-separated segments map to JSON key path segments. + - If no match exists, value becomes unresolved and a diagnostic is emitted. + - If multiple matches exist, value becomes unresolved and an ambiguity + diagnostic is emitted. +- Cross-file `$ref` inside `$value`: + - `"$value": { "$ref": "./Color Primitives.json#/Value/stone/50" }` + - Relative file paths resolve from the resolver manifest directory. + - Empty file part (for example `#/Color Primitives/Value/stone/50`) resolves + in the current file. + - JSON Pointer syntax is supported and validated. + - Pointer targets are validated by usage context: + - direct token object pointers resolve + - token `$value` pointers resolve to the owning token alias + - non-token targets become unresolved with diagnostics + +Failure Behavior +Reference failures do not abort JSON ingestion. Instead, Figmage: +- emits diagnostics through repository diagnostics callback +- marks the value as unresolved (`AliasOr.unresolved`) + +This includes: +- missing target +- invalid pointer +- target not a token object +- type mismatch +- cyclic references +- malformed alias/ref syntax + +Interaction with `dropUnresolved` +- `dropUnresolved: true` removes tokens that have unresolved values in any mode. +- `dropUnresolved: false` keeps tokens; unresolved values resolve to `null`. + +Non-Goals (current scope) +- Resolver `modifiers` / runtime context selection +- Full Resolver-module execution semantics beyond ordered set expansion diff --git a/integration_test/json_path_integration_test.dart b/integration_test/json_path_integration_test.dart new file mode 100644 index 0000000..ad741c1 --- /dev/null +++ b/integration_test/json_path_integration_test.dart @@ -0,0 +1,182 @@ +import 'dart:io'; + +import 'package:figmage/src/domain/models/config/config.dart'; +import 'package:figmage/src/domain/providers/figmage_package_generator_providers.dart'; +import 'package:figmage/src/domain/providers/generator_providers.dart'; +import 'package:figmage/src/domain/providers/library_provider.dart'; +import 'package:path/path.dart' as p; +import 'package:riverpod/riverpod.dart'; +import 'package:test/test.dart'; + +void main() { + group('json resolver integration', () { + test( + 'generates file-scoped classes and keeps resolver aliases resolved', + () async { + final fakeGeneratedFiles = [ + File('${Directory.systemTemp.path}/colors.dart'), + File('${Directory.systemTemp.path}/typography.dart'), + ]; + + final container = ProviderContainer( + overrides: [ + generatedPackageProvider.overrideWith((ref, settings) async { + return fakeGeneratedFiles; + }), + ], + ); + addTearDown(container.dispose); + + final settings = ( + fileId: null, + token: null, + path: Directory.current.path, + config: const Config( + dropUnresolved: true, + colors: GenerationSettings(from: ['Color Aliases']), + json: JsonTokenSourceConfig( + paths: [r'test/fixtures/json/sample_system/$resolver.json'], + ), + ), + ); + + final generators = await container.read( + generatorsProvider(settings).future, + ); + final codeByFile = container.read(librariesProvider(generators)); + + final colorsCode = codeByFile.entries + .singleWhere((entry) => p.basename(entry.key.path) == 'colors.dart') + .value; + final typographyCode = codeByFile.entries + .singleWhere( + (entry) => p.basename(entry.key.path) == 'typography.dart', + ) + .value; + + expect(colorsCode, contains('class ColorAliases')); + expect(colorsCode, contains('final Color paletteCorePrimary;')); + expect(colorsCode, contains('final Color paletteCoreSystemLink;')); + expect( + colorsCode, + contains('paletteCorePrimary = const Color(0xfff14f28),'), + ); + expect( + colorsCode, + contains('paletteCoreSystemLink = const Color(0xff2f9bff),'), + ); + expect(colorsCode, contains('final Color paletteCoreOverlay5;')); + expect( + colorsCode, + contains('paletteCoreOverlay5 = const Color(0x0d5d8fa9),'), + ); + + expect(typographyCode, contains('class Display')); + expect(typographyCode, contains('class Headline')); + expect(typographyCode, contains('class Body')); + expect(typographyCode, contains('class Label')); + expect(typographyCode, contains('final TextStyle display1;')); + expect(typographyCode, contains('final TextStyle body1;')); + expect( + typographyCode, + contains('fontWeight: const FontWeight(463),'), + ); + }, + ); + + test( + 'does not collapse branches with different schemas ' + 'into one nullable class', + () async { + final tempDir = await Directory.systemTemp.createTemp( + 'figmage_json_branching_', + ); + addTearDown(() async { + if (tempDir.existsSync()) { + await tempDir.delete(recursive: true); + } + }); + + final resolver = File('${tempDir.path}/\$resolver.json'); + final theme = File('${tempDir.path}/theme.json'); + + await theme.writeAsString( + ''' +{ + "colors": { + "light": { + "surface": {"\$type": "color", "\$value": "#ffffff"}, + "text": {"\$type": "color", "\$value": "#111111"} + }, + "dark": { + "surface": {"\$type": "color", "\$value": "#000000"}, + "text": {"\$type": "color", "\$value": "#f0f0f0"} + }, + "compact": { + "badge": {"\$type": "color", "\$value": "#ff0000"} + } + } +} +''', + ); + + await resolver.writeAsString( + ''' +{ + "version": "2025.10", + "sets": { + "theme": {"\$ref": "./theme.json"} + }, + "resolutionOrder": ["theme"] +} +''', + ); + + final fakeGeneratedFiles = [ + File('${Directory.systemTemp.path}/colors.dart'), + ]; + + final container = ProviderContainer( + overrides: [ + generatedPackageProvider.overrideWith((ref, settings) async { + return fakeGeneratedFiles; + }), + ], + ); + addTearDown(container.dispose); + + final settings = ( + fileId: null, + token: null, + path: tempDir.path, + config: Config( + dropUnresolved: true, + colors: const GenerationSettings(from: ['theme']), + json: const JsonTokenSourceConfig(paths: [r'$resolver.json']), + ), + ); + + final generators = await container.read( + generatorsProvider(settings).future, + ); + final codeByFile = container.read(librariesProvider(generators)); + final colorsCode = codeByFile.entries + .singleWhere((entry) => p.basename(entry.key.path) == 'colors.dart') + .value; + + final classCount = RegExp( + r'class\s+\w+\s+extends\s+ThemeExtension<', + ).allMatches(colorsCode).length; + + // Mixed schemas are emitted as one class with branch-prefixed fields. + expect(classCount, 1); + expect(colorsCode, isNot(contains('final Color?'))); + expect(colorsCode, contains('final Color lightSurface;')); + expect(colorsCode, contains('final Color darkSurface;')); + expect(colorsCode, contains('final Color compactBadge;')); + expect(colorsCode, isNot(contains('.light('))); + expect(colorsCode, isNot(contains('.dark('))); + }, + ); + }); +} diff --git a/lib/src/commands/forge/forge_command.dart b/lib/src/commands/forge/forge_command.dart index 45867fa..179beb0 100644 --- a/lib/src/commands/forge/forge_command.dart +++ b/lib/src/commands/forge/forge_command.dart @@ -16,8 +16,7 @@ class ForgeCommand extends Command { ..addOption( "token", abbr: "t", - help: "Your figma API token", - mandatory: true, + help: "Your figma API token (required when using fileId)", ) ..addOption( "path", diff --git a/lib/src/commands/shared/forge_settings_providers.dart b/lib/src/commands/shared/forge_settings_providers.dart index 90c3754..9c0c311 100644 --- a/lib/src/commands/shared/forge_settings_providers.dart +++ b/lib/src/commands/shared/forge_settings_providers.dart @@ -21,15 +21,31 @@ final settingsProvider = FutureProvider.autoDispose final config = await ref.watch(configProvider(configPath).future); + final fileIdFromArgs = args.wasParsed('fileId') ? args['fileId'] : null; + final fileId = switch (fileIdFromArgs ?? config.fileId) { + final String fileId => fileId, + _ => null, + }; + + final tokenFromArgs = args.wasParsed('token') ? args['token'] : null; + final token = switch (tokenFromArgs) { + final String token => token, + _ => null, + }; + + final hasJsonSources = config.json.paths.isNotEmpty; + + if (fileId == null && hasJsonSources == false) { + throw ArgumentError.notNull('fileId'); + } + + if (fileId != null && token == null) { + throw ArgumentError.notNull('token'); + } + return ( - token: switch (args['token']) { - final String token => token, - _ => throw ArgumentError.notNull('token'), - }, - fileId: switch (args['fileId'] ?? config.fileId) { - final String fileId => fileId, - _ => throw ArgumentError.notNull('fileId'), - }, + token: token, + fileId: fileId, path: dir.path, config: config, ); diff --git a/lib/src/commands/shared/generation_notifier.dart b/lib/src/commands/shared/generation_notifier.dart index a62126f..6a81e30 100644 --- a/lib/src/commands/shared/generation_notifier.dart +++ b/lib/src/commands/shared/generation_notifier.dart @@ -40,9 +40,10 @@ class GenerationNotifier filteredTokensProvider(settings).future, ); } catch (e) { - logger.err("Neither styles nor variables could be obtained from file " - "${settings.fileId}"); - if (settings.config.stylesFromLibrary) { + logger.err( + "No tokens could be obtained from the configured sources.", + ); + if (settings.config.stylesFromLibrary && settings.fileId != null) { logger.warn( "Make sure you have published the file's library, " "or set stylesFromLibrary to false.", diff --git a/lib/src/data/generators/file_generators/base_file_generator.dart b/lib/src/data/generators/file_generators/base_file_generator.dart index ee1ba43..341bb63 100644 --- a/lib/src/data/generators/file_generators/base_file_generator.dart +++ b/lib/src/data/generators/file_generators/base_file_generator.dart @@ -19,7 +19,8 @@ abstract class BaseFileGenerator implements DesignTokenFileGenerator { required this.type, required this.tokens, required this.inheritanceSettings, - }); + bool Function(String collectionId)? isMixedTokenCollection, + }) : _isMixedTokenCollection = isMixedTokenCollection ?? _neverMixed; @override final TokenFileType type; @@ -32,12 +33,13 @@ abstract class BaseFileGenerator implements DesignTokenFileGenerator { @override final Iterable inheritanceSettings; + final bool Function(String collectionId) _isMixedTokenCollection; /// Get the class name for a collection name of design tokens for the [type] /// of this generator. @protected String getClassNameForCollection(String collectionName) => - getClassNameForCollectionAndType(collectionName, type); + convertToValidClassName(collectionName); /// Get the class name for a collection name of design tokens and a [type]. /// @@ -46,8 +48,21 @@ abstract class BaseFileGenerator implements DesignTokenFileGenerator { String getClassNameForCollectionAndType( String collectionName, TokenFileType type, - ) => - convertToValidClassName(type.className + collectionName.toTitleCase()); + ) => convertToValidClassName(type.className + collectionName.toTitleCase()); + + /// Returns the deterministic class name for [collectionId] as generated for + /// [type]. + @protected + String getClassNameForCollectionIdAndType( + String collectionId, + TokenFileType type, + ) { + final className = _buildClassNamesForType(type)[collectionId]; + if (className == null) { + throw StateError('No class name found for collectionId "$collectionId".'); + } + return className; + } @override Library generate() { @@ -76,26 +91,71 @@ abstract class BaseFileGenerator implements DesignTokenFileGenerator { tokens, (DesignToken dt) => dt.collectionId, ); - - /// Ensure unique names when multiple collections have the same name. - final usedNames = []; - for (final MapEntry(key: _, :value) in groupedTokens.entries) { - final collectionName = value.first.collectionName; - final name = _getUniqueName(collectionName, usedNames); - usedNames.add(collectionName); + final classNamesByCollectionId = _buildClassNamesForType(type); + for (final entry in groupedTokens.entries) { + final className = classNamesByCollectionId[entry.key]; + if (className == null) { + throw StateError( + 'No class name found for collectionId "${entry.key}".', + ); + } yield buildGeneratorForCollection( - collectionName: name, - collectionTokens: value, - interfaces: _getInterfacesForCollection(name), + collectionName: className, + collectionTokens: entry.value, + interfaces: _getInterfacesForCollection( + entry.value.first.collectionName, + ), ); } } - /// Generates a unique name for a collection. - String _getUniqueName(String name, List usedNames) { - return usedNames.contains(name) - ? '$name${usedNames.where((item) => item == name).length}' - : name; + Map _buildClassNamesForType(TokenFileType classType) { + final groupedTokens = groupBy( + tokens, + (DesignToken dt) => dt.collectionId, + ); + final usedClassNames = {}; + final result = {}; + + for (final entry in groupedTokens.entries) { + final classNamePath = _classNamePathForCollection(entry.value); + final baseClassName = _isMixedTokenCollection(entry.key) + ? getClassNameForCollectionAndType(classNamePath, classType) + : convertToValidClassName(classNamePath); + final className = _getUniqueClassName(baseClassName, usedClassNames); + usedClassNames.add(className); + result[entry.key] = className; + } + + return result; + } + + String _classNamePathForCollection( + Iterable> collectionTokens, + ) { + final collectionName = collectionTokens.first.collectionName; + final modeNames = collectionTokens + .expand((token) => token.valuesByModeName.keys) + .toSet(); + final segments = collectionName + .split('/') + .where((s) => s.isNotEmpty) + .toList(); + if (segments.length < 2 || modeNames.length != 1) { + return collectionName; + } + return segments.sublist(0, segments.length - 1).join('/'); + } + + String _getUniqueClassName(String name, Set usedNames) { + if (usedNames.contains(name) == false) { + return name; + } + var index = 1; + while (usedNames.contains('$name$index')) { + index += 1; + } + return '$name$index'; } Iterable _getInterfacesForCollection( @@ -110,3 +170,5 @@ abstract class BaseFileGenerator implements DesignTokenFileGenerator { .expand((s) => s.interfaces); } } + +bool _neverMixed(String _) => false; diff --git a/lib/src/data/generators/file_generators/color_file_generator.dart b/lib/src/data/generators/file_generators/color_file_generator.dart index 6a587b0..3beee3f 100644 --- a/lib/src/data/generators/file_generators/color_file_generator.dart +++ b/lib/src/data/generators/file_generators/color_file_generator.dart @@ -17,6 +17,7 @@ class ColorFileGenerator extends BaseFileGenerator { ColorFileGenerator({ required super.tokens, required super.inheritanceSettings, + super.isMixedTokenCollection, }) : super(type: TokenFileType.color); @override diff --git a/lib/src/data/generators/file_generators/number_file_generator.dart b/lib/src/data/generators/file_generators/number_file_generator.dart index 6191607..8f57c4e 100644 --- a/lib/src/data/generators/file_generators/number_file_generator.dart +++ b/lib/src/data/generators/file_generators/number_file_generator.dart @@ -17,6 +17,7 @@ class NumberFileGenerator extends BaseFileGenerator { NumberFileGenerator({ required super.tokens, required super.inheritanceSettings, + super.isMixedTokenCollection, }) : super(type: TokenFileType.numbers); @override diff --git a/lib/src/data/generators/file_generators/padding_file_generator.dart b/lib/src/data/generators/file_generators/padding_file_generator.dart index 4590301..abc1fe1 100644 --- a/lib/src/data/generators/file_generators/padding_file_generator.dart +++ b/lib/src/data/generators/file_generators/padding_file_generator.dart @@ -18,6 +18,7 @@ class PaddingFileGenerator extends BaseFileGenerator { PaddingFileGenerator({ required super.tokens, required super.inheritanceSettings, + super.isMixedTokenCollection, }) : super(type: TokenFileType.paddings); @override @@ -27,10 +28,10 @@ class PaddingFileGenerator extends BaseFileGenerator { required Iterable interfaces, }) { return PaddingGenerator( - className: getClassNameForCollection(collectionName), + className: collectionName, fromClass: refer( - getClassNameForCollectionAndType( - collectionName, + getClassNameForCollectionIdAndType( + collectionTokens.first.collectionId, TokenFileType.numbers, ), TokenFileType.numbers.filename, diff --git a/lib/src/data/generators/file_generators/spacer_file_generator.dart b/lib/src/data/generators/file_generators/spacer_file_generator.dart index c9608fd..cb50907 100644 --- a/lib/src/data/generators/file_generators/spacer_file_generator.dart +++ b/lib/src/data/generators/file_generators/spacer_file_generator.dart @@ -18,6 +18,7 @@ class SpacerFileGenerator extends BaseFileGenerator { SpacerFileGenerator({ required super.tokens, required super.inheritanceSettings, + super.isMixedTokenCollection, }) : super(type: TokenFileType.spacers); @override @@ -27,10 +28,10 @@ class SpacerFileGenerator extends BaseFileGenerator { required Iterable interfaces, }) { return SpacerGenerator( - className: getClassNameForCollection(collectionName), + className: collectionName, fromClass: refer( - getClassNameForCollectionAndType( - collectionName, + getClassNameForCollectionIdAndType( + collectionTokens.first.collectionId, TokenFileType.numbers, ), TokenFileType.numbers.filename, diff --git a/lib/src/data/generators/file_generators/typography_file_generator.dart b/lib/src/data/generators/file_generators/typography_file_generator.dart index 1c75b34..7beeea9 100644 --- a/lib/src/data/generators/file_generators/typography_file_generator.dart +++ b/lib/src/data/generators/file_generators/typography_file_generator.dart @@ -19,6 +19,7 @@ class TypographyFileGenerator extends BaseFileGenerator { required super.tokens, required this.useGoogleFonts, required super.inheritanceSettings, + super.isMixedTokenCollection, }) : super(type: TokenFileType.typography); /// Whether to use Google Fonts in the generated theme extensions. diff --git a/lib/src/data/generators/theme_extension_generators/mode_theme_extension_generator.dart b/lib/src/data/generators/theme_extension_generators/mode_theme_extension_generator.dart index af0e8e3..911de23 100644 --- a/lib/src/data/generators/theme_extension_generators/mode_theme_extension_generator.dart +++ b/lib/src/data/generators/theme_extension_generators/mode_theme_extension_generator.dart @@ -40,11 +40,7 @@ abstract class ModeThemeExtensionGenerator required this.interfaces, this.buildContextExtensionNullable = false, this.lerpReference, - }) : className = convertToValidClassName(className), - assert( - ensureSameKeys(valuesByNameByMode.values.toList()), - 'All value maps must have the same keys.', - ); + }) : className = convertToValidClassName(className); @override final String className; @@ -74,18 +70,30 @@ abstract class ModeThemeExtensionGenerator @override Class generateClass() { - final uniqueFieldNames = - _getUniqueVariableNames(valuesByNameByMode.values.first.keys); + final valueNames = []; + for (final valuesByName in valuesByNameByMode.values) { + for (final valueName in valuesByName.keys) { + if (!valueNames.contains(valueName)) { + valueNames.add(valueName); + } + } + } + if (valueNames.isEmpty) { + throw StateError('At least one token value is required per mode.'); + } + + final uniqueFieldNames = _getUniqueVariableNames(valueNames); final validValueMaps = valuesByNameByMode.map( - (key, value) => MapEntry( - switch (key) { + (modeName, valuesByName) => MapEntry( + switch (modeName) { "" => "", - _ => convertToValidVariableName(key), + _ => convertToValidVariableName(modeName), + }, + { + for (final valueName in valueNames) + uniqueFieldNames[valueName]!: valuesByName[valueName], }, - value.map( - (key, value) => MapEntry(uniqueFieldNames[key]!, value), - ), ), ); @@ -110,8 +118,9 @@ abstract class ModeThemeExtensionGenerator ..returns = refer('$className$returnTypeSuffix') ..type = MethodType.getter ..lambda = true - ..body = - Code('Theme.of(this).extension<$className>()$bodySuffix'), + ..body = Code( + 'Theme.of(this).extension<$className>()$bodySuffix', + ), ), ), ); @@ -130,8 +139,10 @@ abstract class ModeThemeExtensionGenerator 'package:flutter/foundation.dart', ), ) - ..extend = - refer('ThemeExtension<$className>', 'package:flutter/material.dart') + ..extend = refer( + 'ThemeExtension<$className>', + 'package:flutter/material.dart', + ) ..implements.addAll([ for (final i in interfaces) refer(i.name, i.import), ]) @@ -202,19 +213,19 @@ abstract class ModeThemeExtensionGenerator for (final name in names) name: switch (valueMaps.allModesResolved(valueName: name)) { true => lerpReference.call( - [ - refer(name), - refer('other.$name'), - refer('t'), - ], - ).nullChecked, + [ + refer(name), + refer('other.$name'), + refer('t'), + ], + ).nullChecked, false => lerpReference.call( - [ - refer(name), - refer('other.$name'), - refer('t'), - ], - ), + [ + refer(name), + refer('other.$name'), + refer('t'), + ], + ), }, }, ); @@ -346,7 +357,7 @@ extension ReferenceX on Reference { switch (symbol) { final symbol? when !symbol.endsWith("?") => "$symbol?", final symbol? => symbol, - null => null + null => null, }, url, ); @@ -366,7 +377,8 @@ extension _ConstructorBuilderX on ConstructorBuilder { in constructorsByParamName.entries) refer(name).assign(expression).code, ]); - constant = initializers.isEmpty || + constant = + initializers.isEmpty || constructorsByParamName.values.every( (element) => element.isConst || element is LiteralExpression, ); diff --git a/lib/src/data/generators/theme_extension_generators/text_style_theme_extension_generator.dart b/lib/src/data/generators/theme_extension_generators/text_style_theme_extension_generator.dart index 3a9cef0..4e281d1 100644 --- a/lib/src/data/generators/theme_extension_generators/text_style_theme_extension_generator.dart +++ b/lib/src/data/generators/theme_extension_generators/text_style_theme_extension_generator.dart @@ -61,7 +61,7 @@ class TextStyleThemeExtensionGenerator return { if (includeFamily) 'fontFamily': literalString(typography.fontFamily), 'fontSize': literal(typography.fontSize), - 'fontWeight': refer('FontWeight').property('w${typography.fontWeight}'), + 'fontWeight': _getFontWeightExpression(typography.fontWeight), 'fontStyle': refer('FontStyle').property( switch (typography.fontStyle) { FontStyle.italic => 'italic', @@ -99,4 +99,11 @@ class TextStyleThemeExtensionGenerator //'overflow': }; } + + Expression _getFontWeightExpression(int fontWeight) { + if (fontWeight >= 100 && fontWeight <= 900 && fontWeight % 100 == 0) { + return refer('FontWeight').property('w$fontWeight'); + } + return refer('FontWeight').constInstance([literal(fontWeight)]); + } } diff --git a/lib/src/data/repositories/figma_styles_repository.dart b/lib/src/data/repositories/figma_styles_repository.dart index 224a541..11d1f0b 100644 --- a/lib/src/data/repositories/figma_styles_repository.dart +++ b/lib/src/data/repositories/figma_styles_repository.dart @@ -120,7 +120,12 @@ class FigmaStylesRepository implements StylesRepository { ); final stylesFromNodes = [ for (final node in styleNodes) - if (_transformNode(node) case final style?) style, + if (_transformNode( + node, + onDiagnostic: onProgress, + ) + case final style?) + style, ]; final droppedNodes = styleNodes.length - stylesFromNodes.length; onProgress?.call( @@ -225,14 +230,24 @@ class FigmaStylesRepository implements StylesRepository { } } - DesignStyle? _transformNode(Node node) { + DesignStyle? _transformNode( + Node node, { + void Function(String message)? onDiagnostic, + }) { return switch (node) { TextNode( :final id, :final name, :final style, ) => - TextDesignStyle(id: id, fullName: name, value: style.toDomain()), + TextDesignStyle( + id: id, + fullName: name, + value: style.toDomain( + onDiagnostic: onDiagnostic, + diagnosticContext: 'Figma style "$name" ($id)', + ), + ), RectangleNode( :final id, :final name, diff --git a/lib/src/data/repositories/json_tokens_repository.dart b/lib/src/data/repositories/json_tokens_repository.dart new file mode 100644 index 0000000..0119c63 --- /dev/null +++ b/lib/src/data/repositories/json_tokens_repository.dart @@ -0,0 +1,1494 @@ +import 'dart:convert'; +import 'dart:io'; + +import 'package:figmage/src/data/util/converters/hex_color_conversion_x.dart'; +import 'package:figmage/src/data/util/converters/type_style_conversion_x.dart'; +import 'package:figmage/src/domain/models/design_token.dart'; +import 'package:figmage/src/domain/models/json_token.dart'; +import 'package:figmage/src/domain/models/typography/typography.dart'; +import 'package:figmage/src/domain/models/variable/alias_or/alias_or.dart'; +import 'package:figmage/src/domain/repositories/json_tokens_repository.dart'; +import 'package:path/path.dart' as p; + +/// {@template file_json_tokens_repository} +/// Reads tokens from local JSON files. +/// {@endtemplate} +class FileJsonTokensRepository implements JsonTokensRepository { + /// {@macro file_json_tokens_repository} + const FileJsonTokensRepository(); + + @override + Future>> getTokens({ + required Iterable paths, + void Function(String message)? onDiagnostic, + }) async { + final pathList = paths.toList(growable: false); + if (pathList.isEmpty) { + return []; + } + if (pathList.length != 1) { + throw ArgumentError.value( + pathList, + 'paths', + 'JSON resolver mode requires exactly one resolver manifest path.', + ); + } + + final resolverPath = p.normalize(pathList.single); + final resolverFile = File(resolverPath); + if (resolverFile.existsSync() == false) { + throw FileSystemException('Resolver manifest not found.', resolverPath); + } + + final resolverDocument = _readJsonObject( + await resolverFile.readAsString(), + resolverPath, + topLevelError: + 'Resolver manifest must contain an object at the top level.', + ); + final manifest = _parseResolverManifest( + resolverDocument, + manifestPath: resolverPath, + ); + + final resolverDir = p.dirname(resolverPath); + final orderedSets = _resolveOrderedSetPaths( + manifest: manifest, + resolverDirectory: resolverDir, + ); + + final rawTokens = <_RawJsonToken>[]; + final documentsByPath = {}; + + for (final setRef in orderedSets) { + final setPath = setRef.setPath; + final setFile = File(setPath); + if (setFile.existsSync() == false) { + throw FileSystemException( + 'Resolver referenced JSON token file not found.', + setPath, + ); + } + final setDocument = _readJsonObject( + await setFile.readAsString(), + setPath, + topLevelError: + 'JSON token file must contain an object at the top level.', + ); + documentsByPath[setPath] = setDocument; + _collectTokens( + node: setDocument, + setName: setRef.setName, + pathSegments: const [], + filePath: setPath, + output: rawTokens, + ); + } + + if (rawTokens.isEmpty) { + return []; + } + + final grouping = _detectModeGroups(rawTokens); + final diagnostics = []; + + final tokensByKey = { + for (final token in rawTokens) token.cacheKey: token, + }; + final tokensByCanonicalPath = >{}; + for (final token in rawTokens) { + tokensByCanonicalPath + .putIfAbsent(token.canonicalPathKey, () => <_RawJsonToken>[]) + .add(token); + } + + final aliasCache = >{}; + final aliasVisitStack = {}; + + final tokens = _buildTokens( + tokens: rawTokens, + modeGroupsByCollectionAndType: grouping.modeGroupsByCollectionAndType, + branchPrefixedBranchesByCollectionAndType: + grouping.branchPrefixedBranchesByCollectionAndType, + resolveColor: (token) => _resolveValue( + token: token, + expectedType: 'color', + parseLiteral: _parseColor, + tokensByKey: tokensByKey, + tokensByCanonicalPath: tokensByCanonicalPath, + documentsByPath: documentsByPath, + resolverDirectory: resolverDir, + cache: aliasCache, + visitStack: aliasVisitStack, + diagnostics: diagnostics, + ), + resolveNumber: (token) => _resolveValue( + token: token, + expectedType: 'number', + parseLiteral: _parseNumber, + tokensByKey: tokensByKey, + tokensByCanonicalPath: tokensByCanonicalPath, + documentsByPath: documentsByPath, + resolverDirectory: resolverDir, + cache: aliasCache, + visitStack: aliasVisitStack, + diagnostics: diagnostics, + ), + resolveBool: (token) => _resolveValue( + token: token, + expectedType: 'boolean', + parseLiteral: _parseBool, + tokensByKey: tokensByKey, + tokensByCanonicalPath: tokensByCanonicalPath, + documentsByPath: documentsByPath, + resolverDirectory: resolverDir, + cache: aliasCache, + visitStack: aliasVisitStack, + diagnostics: diagnostics, + ), + resolveText: (token) => _resolveValue( + token: token, + expectedType: 'text', + parseLiteral: _parseString, + tokensByKey: tokensByKey, + tokensByCanonicalPath: tokensByCanonicalPath, + documentsByPath: documentsByPath, + resolverDirectory: resolverDir, + cache: aliasCache, + visitStack: aliasVisitStack, + diagnostics: diagnostics, + ), + resolveTypography: (token) => _resolveValue( + token: token, + expectedType: 'typography', + parseLiteral: (value, token) => + _parseTypography(value, token, diagnostics), + tokensByKey: tokensByKey, + tokensByCanonicalPath: tokensByCanonicalPath, + documentsByPath: documentsByPath, + resolverDirectory: resolverDir, + cache: aliasCache, + visitStack: aliasVisitStack, + diagnostics: diagnostics, + ), + ); + + for (final message in diagnostics.toSet()) { + onDiagnostic?.call(message); + } + + return tokens; + } +} + +class _ResolverManifest { + const _ResolverManifest({ + required this.version, + required this.setRefsByName, + required this.resolutionOrder, + }); + + final String version; + final Map setRefsByName; + final List resolutionOrder; +} + +class _RawJsonToken { + const _RawJsonToken({ + required this.setName, + required this.pathSegments, + required this.type, + required this.value, + required this.filePath, + }); + + final String setName; + final List pathSegments; + final String type; + final Object? value; + final String filePath; + + String get cacheKey => _tokenKey(filePath, pathSegments); + String get canonicalPathKey => _canonicalTokenPathKey([ + setName, + ...pathSegments, + ]); + String get modeGroupScope => + pathSegments.isEmpty ? setName : '$setName/${pathSegments.first}'; +} + +class _TokenAccumulator { + _TokenAccumulator({ + required this.collectionName, + required this.collectionId, + required this.name, + required this.fullName, + }); + + final String collectionName; + final String collectionId; + final String name; + final String fullName; + final Map> valuesByModeName = {}; +} + +class _AliasTarget { + const _AliasTarget({ + required this.key, + required this.id, + }); + + final String key; + final String id; +} + +Map _readJsonObject( + String raw, + String filePath, { + required String topLevelError, +}) { + final decoded = jsonDecode(raw); + if (decoded is! Map) { + throw FormatException(topLevelError, filePath); + } + return decoded; +} + +_ResolverManifest _parseResolverManifest( + Map json, { + required String manifestPath, +}) { + final version = json['version']; + if (version is! String || version.trim().isEmpty) { + throw FormatException( + 'Resolver manifest must define a non-empty "version".', + manifestPath, + ); + } + + final sets = json['sets']; + if (sets is! Map || sets.isEmpty) { + throw FormatException( + 'Resolver manifest must define a non-empty "sets" object.', + manifestPath, + ); + } + + final setRefsByName = {}; + for (final entry in sets.entries) { + final setName = entry.key; + final setValue = entry.value; + if (setName is! String) { + throw FormatException( + 'Resolver set names must be strings.', + manifestPath, + ); + } + if (setValue is! Map || setValue[r'$ref'] is! String) { + throw FormatException( + 'Resolver set "$setName" must define a string "\$ref".', + manifestPath, + ); + } + final ref = (setValue[r'$ref'] as String).trim(); + if (ref.isEmpty) { + throw FormatException( + 'Resolver set "$setName" has an empty "\$ref".', + manifestPath, + ); + } + if (ref.contains('#')) { + throw FormatException( + r'Resolver set "$ref" must point to a file without a JSON pointer.', + manifestPath, + ); + } + setRefsByName[setName] = ref; + } + + final resolutionOrder = json['resolutionOrder']; + if (resolutionOrder is! List || resolutionOrder.isEmpty) { + throw FormatException( + 'Resolver manifest must define a non-empty "resolutionOrder" list.', + manifestPath, + ); + } + + final parsedOrder = []; + for (final item in resolutionOrder) { + if (item is! String || item.trim().isEmpty) { + throw FormatException( + 'Resolver "resolutionOrder" entries must be non-empty strings.', + manifestPath, + ); + } + if (setRefsByName.containsKey(item) == false) { + throw FormatException( + 'Resolver "resolutionOrder" references unknown set "$item".', + manifestPath, + ); + } + parsedOrder.add(item); + } + + return _ResolverManifest( + version: version, + setRefsByName: setRefsByName, + resolutionOrder: parsedOrder, + ); +} + +List<({String setName, String setPath})> _resolveOrderedSetPaths({ + required _ResolverManifest manifest, + required String resolverDirectory, +}) { + final result = <({String setName, String setPath})>[]; + final seen = {}; + + for (final setName in manifest.resolutionOrder) { + final refPath = manifest.setRefsByName[setName]!; + final resolvedPath = p.normalize( + p.isAbsolute(refPath) ? refPath : p.join(resolverDirectory, refPath), + ); + + if (seen.contains(resolvedPath)) { + throw FormatException( + 'Resolver contains ambiguous duplicate set file reference: ' + '$resolvedPath', + ); + } + seen.add(resolvedPath); + result.add((setName: setName, setPath: resolvedPath)); + } + + return result; +} + +void _collectTokens({ + required Object? node, + required String setName, + required List pathSegments, + required String filePath, + required List<_RawJsonToken> output, +}) { + if (node is! Map) { + throw FormatException( + 'Expected an object at ${_formatPath(pathSegments)}.', + filePath, + ); + } + + if (node.containsKey(r'$value')) { + final typeValue = node[r'$type']; + if (typeValue is! String || typeValue.trim().isEmpty) { + throw FormatException( + 'Missing or invalid \$type at ${_formatPath(pathSegments)}.', + filePath, + ); + } + output.add( + _RawJsonToken( + setName: setName, + pathSegments: pathSegments, + type: typeValue, + value: node[r'$value'], + filePath: filePath, + ), + ); + return; + } + + for (final entry in node.entries) { + final key = entry.key; + if (key is! String) { + throw FormatException('Token keys must be strings.', filePath); + } + _collectTokens( + node: entry.value, + setName: setName, + pathSegments: [...pathSegments, key], + filePath: filePath, + output: output, + ); + } +} + +({ + Map>> + modeGroupsByCollectionAndType, + Map>> + branchPrefixedBranchesByCollectionAndType, +}) +_detectModeGroups( + List<_RawJsonToken> tokens, +) { + final branchesByCollectionAndType = + >>>{}; + for (final token in tokens) { + if (token.pathSegments.length < 3) { + continue; + } + final collection = token.modeGroupScope; + final type = token.type; + final branch = token.pathSegments[1]; + final name = token.pathSegments.sublist(2).join('/'); + final branchMapByType = branchesByCollectionAndType.putIfAbsent( + collection, + () => >>{}, + ); + final branchMap = branchMapByType.putIfAbsent( + type, + () => >{}, + ); + branchMap.putIfAbsent(branch, () => {}).add(name); + } + + final modeGroupsByCollectionAndType = + >>{}; + final branchPrefixedBranchesByCollectionAndType = + >>{}; + for (final collectionEntry in branchesByCollectionAndType.entries) { + final modeGroupsByType = >{}; + final branchPrefixedByType = >{}; + for (final typeEntry in collectionEntry.value.entries) { + final branches = typeEntry.value; + if (branches.length <= 1) { + continue; + } + + final signatures = branches.values.map(_signatureForNameSet).toSet(); + final sortedBranches = branches.keys.toList()..sort(); + if (signatures.length == 1) { + final modeEntries = {}; + for (final branch in sortedBranches) { + modeEntries[branch] = (modeName: branch); + } + modeGroupsByType[typeEntry.key] = modeEntries; + } else { + branchPrefixedByType[typeEntry.key] = sortedBranches.toSet(); + } + } + if (modeGroupsByType.isNotEmpty) { + modeGroupsByCollectionAndType[collectionEntry.key] = modeGroupsByType; + } + if (branchPrefixedByType.isNotEmpty) { + branchPrefixedBranchesByCollectionAndType[collectionEntry.key] = + branchPrefixedByType; + } + } + return ( + modeGroupsByCollectionAndType: modeGroupsByCollectionAndType, + branchPrefixedBranchesByCollectionAndType: + branchPrefixedBranchesByCollectionAndType, + ); +} + +String _signatureForNameSet(Set names) { + final sorted = names.toList()..sort(); + return sorted.join('\t'); +} + +List> _buildTokens({ + required List<_RawJsonToken> tokens, + required Map>> + modeGroupsByCollectionAndType, + required Map>> + branchPrefixedBranchesByCollectionAndType, + required AliasOr Function(_RawJsonToken token) resolveColor, + required AliasOr Function(_RawJsonToken token) resolveNumber, + required AliasOr Function(_RawJsonToken token) resolveBool, + required AliasOr Function(_RawJsonToken token) resolveText, + required AliasOr Function(_RawJsonToken token) resolveTypography, +}) { + final colorTokens = >>{}; + final numberTokens = >>{}; + final boolTokens = >>{}; + final stringTokens = >>{}; + final typographyTokens = >>{}; + + for (final token in tokens) { + final pathSegments = token.pathSegments; + if (pathSegments.isEmpty) { + throw FormatException( + 'Token path must contain at least one segment.', + token.filePath, + ); + } + + final mappedEntries = _mapPathsForToken( + pathSegments, + modeGroupsByCollectionAndType[token.modeGroupScope]?[token.type] ?? + const {}, + branchPrefixedBranchesByCollectionAndType[token.modeGroupScope]?[token + .type] ?? + const {}, + ); + + for (final mapped in mappedEntries) { + final scoped = _mapToSetScopedCollection( + token: token, + mapped: mapped, + ); + switch (token.type) { + case 'color': + _addToken( + colorTokens, + scoped, + resolveColor(token), + ); + case 'number': + _addToken( + numberTokens, + scoped, + resolveNumber(token), + ); + case 'boolean': + _addToken( + boolTokens, + scoped, + resolveBool(token), + ); + case 'text': + _addToken( + stringTokens, + scoped, + resolveText(token), + ); + case 'typography': + _addToken( + typographyTokens, + scoped, + resolveTypography(token), + ); + default: + throw FormatException( + 'Unsupported token type "${token.type}" at ' + '${_formatPath(token.pathSegments)}.', + token.filePath, + ); + } + } + } + + return [ + ...colorTokens.values + .expand((list) => list) + .map( + (acc) => JsonToken( + name: acc.name, + fullName: acc.fullName, + collectionName: acc.collectionName, + collectionId: acc.collectionId, + valuesByModeName: acc.valuesByModeName, + ), + ), + ...numberTokens.values + .expand((list) => list) + .map( + (acc) => JsonToken( + name: acc.name, + fullName: acc.fullName, + collectionName: acc.collectionName, + collectionId: acc.collectionId, + valuesByModeName: acc.valuesByModeName, + ), + ), + ...boolTokens.values + .expand((list) => list) + .map( + (acc) => JsonToken( + name: acc.name, + fullName: acc.fullName, + collectionName: acc.collectionName, + collectionId: acc.collectionId, + valuesByModeName: acc.valuesByModeName, + ), + ), + ...stringTokens.values + .expand((list) => list) + .map( + (acc) => JsonToken( + name: acc.name, + fullName: acc.fullName, + collectionName: acc.collectionName, + collectionId: acc.collectionId, + valuesByModeName: acc.valuesByModeName, + ), + ), + ...typographyTokens.values + .expand((list) => list) + .map( + (acc) => JsonToken( + name: acc.name, + fullName: acc.fullName, + collectionName: acc.collectionName, + collectionId: acc.collectionId, + valuesByModeName: acc.valuesByModeName, + ), + ), + ]; +} + +AliasOr _resolveValue({ + required _RawJsonToken token, + required String expectedType, + required T Function(Object? value, _RawJsonToken token) parseLiteral, + required Map tokensByKey, + required Map> tokensByCanonicalPath, + required Map documentsByPath, + required String resolverDirectory, + required Map> cache, + required Set visitStack, + required List diagnostics, +}) { + final tokenKey = token.cacheKey; + + final cached = cache[tokenKey]; + if (cached != null) { + return cached as AliasOr; + } + + if (visitStack.contains(tokenKey)) { + diagnostics.add( + 'Cycle detected while resolving token ${_formatPath(token.pathSegments)} ' + 'in ${token.filePath}.', + ); + final unresolved = AliasOr.unresolved(id: tokenKey); + cache[tokenKey] = unresolved; + return unresolved; + } + + visitStack.add(tokenKey); + + final aliasTarget = _extractAliasTarget( + token: token, + tokensByKey: tokensByKey, + tokensByCanonicalPath: tokensByCanonicalPath, + resolverDirectory: resolverDirectory, + documentsByPath: documentsByPath, + diagnostics: diagnostics, + ); + + late final AliasOr result; + if (aliasTarget != null) { + final targetToken = tokensByKey[aliasTarget.key]; + + if (targetToken == null) { + diagnostics.add( + 'Unresolved reference "${aliasTarget.id}" at ' + '${_formatPath(token.pathSegments)} in ${token.filePath}.', + ); + result = AliasOr.unresolved(id: aliasTarget.id); + } else if (targetToken.type != expectedType) { + diagnostics.add( + 'Type mismatch for reference "${aliasTarget.id}" at ' + '${_formatPath(token.pathSegments)} in ${token.filePath}: ' + 'expected $expectedType, got ${targetToken.type}.', + ); + result = AliasOr.unresolved(id: aliasTarget.id); + } else { + final targetResolved = _resolveValue( + token: targetToken, + expectedType: expectedType, + parseLiteral: parseLiteral, + tokensByKey: tokensByKey, + tokensByCanonicalPath: tokensByCanonicalPath, + documentsByPath: documentsByPath, + resolverDirectory: resolverDirectory, + cache: cache, + visitStack: visitStack, + diagnostics: diagnostics, + ); + result = AliasOr.alias( + id: aliasTarget.id, + aliasOrValue: targetResolved, + ); + } + } else { + try { + result = AliasOr.data( + data: parseLiteral(token.value, token), + ); + } on FormatException { + visitStack.remove(tokenKey); + rethrow; + } catch (_) { + diagnostics.add( + 'Failed to parse literal value at ${_formatPath(token.pathSegments)} ' + 'in ${token.filePath}.', + ); + result = AliasOr.unresolved(id: tokenKey); + } + } + + visitStack.remove(tokenKey); + cache[tokenKey] = result; + return result; +} + +_AliasTarget? _extractAliasTarget({ + required _RawJsonToken token, + required Map tokensByKey, + required Map> tokensByCanonicalPath, + required String resolverDirectory, + required Map documentsByPath, + required List diagnostics, +}) { + final value = token.value; + + if (value case final String raw) { + final trimmed = raw.trim(); + if (_isCurlyAlias(trimmed) == false) { + return null; + } + + final inner = trimmed.substring(1, trimmed.length - 1).trim(); + final segments = inner + .split('.') + .map((segment) => segment.trim()) + .where((segment) => segment.isNotEmpty) + .toList(growable: false); + + if (segments.isEmpty) { + diagnostics.add( + 'Invalid empty alias reference at ${_formatPath(token.pathSegments)} ' + 'in ${token.filePath}.', + ); + return _AliasTarget( + key: _invalidAliasTargetKey(token, 'empty_curly'), + id: inner, + ); + } + + final globalAliasPath = _canonicalTokenPathKey(segments); + var matches = + tokensByCanonicalPath[globalAliasPath] ?? const <_RawJsonToken>[]; + if (matches.isEmpty) { + final localAliasPath = _canonicalTokenPathKey([ + token.setName, + ...segments, + ]); + matches = + tokensByCanonicalPath[localAliasPath] ?? const <_RawJsonToken>[]; + } + final aliasId = '{${segments.join('.')}}'; + if (matches.isEmpty) { + diagnostics.add( + 'Unresolved reference "$aliasId" at ' + '${_formatPath(token.pathSegments)} in ${token.filePath}.', + ); + return _AliasTarget( + key: _invalidAliasTargetKey(token, 'curly_missing'), + id: aliasId, + ); + } + if (matches.length > 1) { + final matchLocations = + matches + .map( + (match) => + '${_formatPath(match.pathSegments)} in ${match.filePath}', + ) + .toList() + ..sort(); + diagnostics.add( + 'Ambiguous reference "$aliasId" at ' + '${_formatPath(token.pathSegments)} in ${token.filePath}: ' + 'matches ${matches.length} tokens (${matchLocations.join('; ')}).', + ); + return _AliasTarget( + key: _invalidAliasTargetKey(token, 'curly_ambiguous'), + id: aliasId, + ); + } + + return _AliasTarget( + key: matches.single.cacheKey, + id: aliasId, + ); + } + + if (value case final Map map when map.containsKey(r'$ref')) { + final ref = map[r'$ref']; + if (ref is! String || ref.trim().isEmpty) { + diagnostics.add( + 'Invalid \$ref at ${_formatPath(token.pathSegments)} in ' + '${token.filePath}: expected non-empty string.', + ); + return _AliasTarget( + key: _invalidAliasTargetKey(token, 'ref_invalid_type'), + id: r'$ref', + ); + } + + final parts = ref.split('#'); + if (parts.length != 2) { + diagnostics.add( + 'Invalid \$ref "$ref" at ${_formatPath(token.pathSegments)} in ' + '${token.filePath}: expected "path#/json/pointer".', + ); + return _AliasTarget( + key: _invalidAliasTargetKey(token, 'ref_invalid_format'), + id: ref, + ); + } + + final filePart = parts[0].trim(); + final pointerPart = parts[1].trim(); + final targetFilePath = filePart.isEmpty + ? token.filePath + : p.normalize( + p.isAbsolute(filePart) + ? filePart + : p.join(resolverDirectory, filePart), + ); + final pointerSegments = _parseJsonPointer(pointerPart); + if (pointerSegments == null) { + diagnostics.add( + 'Invalid JSON pointer in \$ref "$ref" at ' + '${_formatPath(token.pathSegments)} in ${token.filePath}.', + ); + return _AliasTarget( + key: _invalidAliasTargetKey(token, 'ref_invalid_pointer'), + id: ref, + ); + } + + final targetDocument = documentsByPath[targetFilePath]; + if (targetDocument == null) { + diagnostics.add( + 'Referenced file "$targetFilePath" is not part of the resolver sets ' + 'for \$ref "$ref" at ${_formatPath(token.pathSegments)} in ' + '${token.filePath}.', + ); + return _AliasTarget( + key: _tokenKey(targetFilePath, pointerSegments), + id: ref, + ); + } + + final pointedNode = _readNodeAtPointer(targetDocument, pointerSegments); + if (pointedNode == null) { + diagnostics.add( + 'JSON pointer in \$ref "$ref" did not resolve at ' + '${_formatPath(token.pathSegments)} in ${token.filePath}.', + ); + return _AliasTarget( + key: _tokenKey(targetFilePath, pointerSegments), + id: ref, + ); + } + + final tokenSegments = _tokenSegmentsForRefTarget( + pointerSegments: pointerSegments, + pointedNode: pointedNode, + rootDocument: targetDocument, + ); + if (tokenSegments == null) { + diagnostics.add( + '\$ref "$ref" points to a non-token location ' + 'at ${_formatPath(token.pathSegments)} in ${token.filePath}.', + ); + return _AliasTarget( + key: _tokenKey(targetFilePath, pointerSegments), + id: ref, + ); + } + + final targetKey = _tokenKey(targetFilePath, tokenSegments); + if (tokensByKey.containsKey(targetKey) == false) { + diagnostics.add( + 'Unresolved reference "$ref" at ' + '${_formatPath(token.pathSegments)} in ${token.filePath}.', + ); + return _AliasTarget( + key: targetKey, + id: ref, + ); + } + + return _AliasTarget( + key: targetKey, + id: ref, + ); + } + + return null; +} + +bool _isCurlyAlias(String value) => + value.startsWith('{') && value.endsWith('}') && value.length > 2; + +List? _parseJsonPointer(String pointer) { + if (pointer.isEmpty) { + return const []; + } + if (pointer.startsWith('/')) { + final decodedSegments = []; + for (final segment in pointer.split('/').skip(1)) { + final decodedSegment = _decodePointerSegment(segment); + if (decodedSegment == null) { + return null; + } + decodedSegments.add(decodedSegment); + } + return decodedSegments; + } + return null; +} + +String? _decodePointerSegment(String segment) { + final buffer = StringBuffer(); + var i = 0; + while (i < segment.length) { + final char = segment[i]; + if (char != '~') { + buffer.write(char); + i += 1; + continue; + } + if (i + 1 >= segment.length) { + return null; + } + final escaped = segment[i + 1]; + switch (escaped) { + case '0': + buffer.write('~'); + case '1': + buffer.write('/'); + default: + return null; + } + i += 2; + } + return buffer.toString(); +} + +Object? _readNodeAtPointer(Object? node, List pointerSegments) { + var current = node; + for (final segment in pointerSegments) { + if (current is Map) { + if (current.containsKey(segment) == false) { + return null; + } + current = current[segment]; + continue; + } + if (current is List) { + final index = int.tryParse(segment); + if (index == null || index < 0 || index >= current.length) { + return null; + } + current = current[index]; + continue; + } + return null; + } + return current; +} + +String _tokenKey(String filePath, List pathSegments) { + return '${p.normalize(filePath)}#${pathSegments.join('/')}'; +} + +String _invalidAliasTargetKey(_RawJsonToken token, String reason) { + return '${token.cacheKey}::invalid::$reason'; +} + +String _canonicalTokenPathKey(List pathSegments) { + return pathSegments.join('/'); +} + +List? _tokenSegmentsForRefTarget({ + required List pointerSegments, + required Object? pointedNode, + required Map rootDocument, +}) { + if (_isTokenObject(pointedNode)) { + return pointerSegments; + } + + if (pointerSegments.isNotEmpty && pointerSegments.last == r'$value') { + final parentSegments = pointerSegments.sublist( + 0, + pointerSegments.length - 1, + ); + final parentNode = _readNodeAtPointer(rootDocument, parentSegments); + if (_isTokenObject(parentNode)) { + return parentSegments; + } + } + + return null; +} + +bool _isTokenObject(Object? node) { + return node is Map && node.containsKey(r'$value') && node[r'$type'] is String; +} + +List< + ({ + String collectionName, + String mode, + String name, + String fullName, + bool preserveCollectionScope, + }) +> +_mapPathsForToken( + List pathSegments, + Map modeEntries, + Set branchPrefixedBranches, +) { + if (pathSegments.length == 1) { + final name = pathSegments.first; + return [ + ( + collectionName: '', + mode: '', + name: name, + fullName: name, + preserveCollectionScope: false, + ), + ]; + } + + if (pathSegments.length == 2) { + final collectionName = pathSegments[0]; + final name = pathSegments[1]; + if (modeEntries.isNotEmpty) { + final promotedCollectionName = '$collectionName/$name'; + return [ + ( + collectionName: promotedCollectionName, + mode: '', + name: name, + fullName: '$collectionName/$name', + preserveCollectionScope: false, + ), + ]; + } + return [ + ( + collectionName: collectionName, + mode: '', + name: name, + fullName: '$collectionName/$name', + preserveCollectionScope: false, + ), + ]; + } + + final collection = pathSegments[0]; + final secondSegment = pathSegments[1]; + final name = pathSegments.sublist(2).join('/'); + + final modeEntry = modeEntries[secondSegment]; + if (modeEntry != null) { + return [ + ( + collectionName: collection, + mode: modeEntry.modeName, + name: name, + fullName: '$collection/$name', + preserveCollectionScope: true, + ), + ]; + } + + if (branchPrefixedBranches.contains(secondSegment)) { + final branchPrefixedName = '$secondSegment/$name'; + return [ + ( + collectionName: collection, + mode: '', + name: branchPrefixedName, + fullName: '$collection/$branchPrefixedName', + preserveCollectionScope: true, + ), + ]; + } + + final collectionName = '$collection/$secondSegment'; + return [ + ( + collectionName: collectionName, + mode: '', + name: name, + fullName: '$collectionName/$name', + preserveCollectionScope: true, + ), + ]; +} + +({ + String collectionName, + String collectionId, + String mode, + String name, + String fullName, +}) +_mapToSetScopedCollection({ + required _RawJsonToken token, + required ({ + String collectionName, + String mode, + String name, + String fullName, + bool preserveCollectionScope, + }) + mapped, +}) { + final useScopedCollection = mapped.preserveCollectionScope; + final scopedCollectionName = useScopedCollection + ? '${token.setName}/${mapped.collectionName}' + : token.setName; + final tokenName = useScopedCollection + ? mapped.name + : mapped.collectionName.isEmpty + ? mapped.name + : '${mapped.collectionName}/${mapped.name}'; + return ( + collectionName: scopedCollectionName, + collectionId: scopedCollectionName, + mode: mapped.mode, + name: tokenName, + fullName: '$scopedCollectionName/$tokenName', + ); +} + +void _addToken( + Map>> target, + ({ + String collectionName, + String collectionId, + String mode, + String name, + String fullName, + }) + mapped, + AliasOr value, +) { + final key = '${mapped.collectionId}|${mapped.name}'; + final accumulators = target.putIfAbsent(key, () => []); + _TokenAccumulator? accumulator; + for (final existing in accumulators) { + if (existing.valuesByModeName.containsKey(mapped.mode) == false) { + accumulator = existing; + break; + } + } + + accumulator ??= _TokenAccumulator( + collectionName: mapped.collectionName, + collectionId: mapped.collectionId, + name: mapped.name, + fullName: mapped.fullName, + ); + + if (accumulators.contains(accumulator) == false) { + accumulators.add(accumulator); + } + accumulator.valuesByModeName[mapped.mode] = value; +} + +int _parseColor(Object? value, _RawJsonToken token) { + if (value is! String) { + throw FormatException( + 'Color tokens require a hex string at ' + '${_formatPath(token.pathSegments)}.', + token.filePath, + ); + } + final trimmed = value.trim(); + if (trimmed.startsWith('rgba(') && trimmed.endsWith(')')) { + return _parseRgbaColor(trimmed, token); + } + return trimmed.toHexColorValue(); +} + +int _parseRgbaColor(String rgba, _RawJsonToken token) { + final content = rgba.substring(5, rgba.length - 1); + final parts = content.split(',').map((s) => s.trim()).toList(); + + if (parts.length != 4) { + throw FormatException( + 'rgba() color must have 4 components (r, g, b, a) at ' + '${_formatPath(token.pathSegments)}.', + token.filePath, + ); + } + + final r = int.tryParse(parts[0]); + final g = int.tryParse(parts[1]); + final b = int.tryParse(parts[2]); + final a = double.tryParse(parts[3]); + + if (r == null || g == null || b == null || a == null) { + throw FormatException( + 'Invalid rgba() color values at ' + '${_formatPath(token.pathSegments)}.', + token.filePath, + ); + } + + if (r < 0 || r > 255 || g < 0 || g > 255 || b < 0 || b > 255) { + throw FormatException( + 'rgba() color RGB values must be 0-255 at ' + '${_formatPath(token.pathSegments)}.', + token.filePath, + ); + } + + if (a < 0 || a > 1) { + throw FormatException( + 'rgba() color alpha value must be 0-1 at ' + '${_formatPath(token.pathSegments)}.', + token.filePath, + ); + } + + final alpha = (a * 255).round(); + return (alpha << 24) | (r << 16) | (g << 8) | b; +} + +double _parseNumber(Object? value, _RawJsonToken token) { + final parsed = _parseDouble( + value, + token, + fieldName: 'number', + allowValueWrapper: false, + ); + return parsed; +} + +bool _parseBool(Object? value, _RawJsonToken token) { + if (value is bool) { + return value; + } + if (value is String) { + final normalized = value.toLowerCase().trim(); + if (normalized == 'true') { + return true; + } + if (normalized == 'false') { + return false; + } + } + throw FormatException( + 'Boolean tokens require true/false at ' + '${_formatPath(token.pathSegments)}.', + token.filePath, + ); +} + +String _parseString(Object? value, _RawJsonToken token) { + if (value is String) { + return value; + } + throw FormatException( + 'Text tokens require a string at ${_formatPath(token.pathSegments)}.', + token.filePath, + ); +} + +Typography _parseTypography( + Object? value, + _RawJsonToken token, + List diagnostics, +) { + if (value is! Map) { + throw FormatException( + 'Typography tokens require an object at ' + '${_formatPath(token.pathSegments)}.', + token.filePath, + ); + } + final map = Map.from(value); + final fontFamily = map['fontFamily']; + if (fontFamily is! String || fontFamily.isEmpty) { + throw FormatException( + 'Typography tokens require fontFamily at ' + '${_formatPath(token.pathSegments)}.', + token.filePath, + ); + } + final fontFamilyPostScriptName = map['fontFamilyPostScriptName']; + if (fontFamilyPostScriptName != null && fontFamilyPostScriptName is! String) { + throw FormatException( + 'fontFamilyPostScriptName must be a string at ' + '${_formatPath(token.pathSegments)}.', + token.filePath, + ); + } + + final fontSize = _parseDouble( + map['fontSize'], + token, + fieldName: 'fontSize', + allowValueWrapper: true, + ); + + final fontWeightValue = map['fontWeight']; + final rawFontWeight = fontWeightValue == null + ? 400 + : _parseDouble( + fontWeightValue, + token, + fieldName: 'fontWeight', + allowValueWrapper: false, + ); + final fontWeight = TypeStyleConversionX.convertFontWeight( + rawFontWeight, + onDiagnostic: diagnostics.add, + diagnosticContext: + '${_formatPath(token.pathSegments)} in ' + '${token.filePath}', + ); + + final decoration = _parseDecoration( + map['textDecoration'] ?? map['decoration'], + token, + ); + final fontStyle = _parseFontStyle(map['fontStyle'], token); + + final letterSpacing = map.containsKey('letterSpacing') + ? _parseDouble( + map['letterSpacing'], + token, + fieldName: 'letterSpacing', + allowValueWrapper: true, + ) + : 1.0; + + final wordSpacing = map.containsKey('wordSpacing') + ? _parseDouble( + map['wordSpacing'], + token, + fieldName: 'wordSpacing', + allowValueWrapper: true, + ) + : 1.0; + + final lineHeightValue = map.containsKey('lineHeight') + ? map['lineHeight'] + : map['height']; + final lineHeight = lineHeightValue == null + ? 1.0 + : _parseLineHeight( + lineHeightValue, + token, + fontSize: fontSize, + ); + + return Typography( + fontFamily: fontFamily, + fontFamilyPostScriptName: fontFamilyPostScriptName as String?, + fontSize: fontSize, + fontWeight: fontWeight, + decoration: decoration, + fontStyle: fontStyle, + letterSpacing: letterSpacing, + wordSpacing: wordSpacing, + height: lineHeight, + ); +} + +double _parseDouble( + Object? value, + _RawJsonToken token, { + required String fieldName, + required bool allowValueWrapper, +}) { + final raw = switch (value) { + Map() when allowValueWrapper => value['value'], + _ => value, + }; + if (raw is num) { + return raw.toDouble(); + } + if (raw is String) { + final parsed = double.tryParse(raw); + if (parsed != null) { + return parsed; + } + } + throw FormatException( + 'Expected $fieldName to be numeric at ' + '${_formatPath(token.pathSegments)}.', + token.filePath, + ); +} + +double _parseLineHeight( + Object? value, + _RawJsonToken token, { + required double fontSize, +}) { + if (value is Map) { + final parsed = _parseDouble( + value['value'], + token, + fieldName: 'lineHeight', + allowValueWrapper: false, + ); + return parsed / fontSize; + } + return _parseDouble( + value, + token, + fieldName: 'lineHeight', + allowValueWrapper: true, + ); +} + +TextDecoration _parseDecoration(Object? value, _RawJsonToken token) { + if (value == null) { + return TextDecoration.none; + } + if (value is String) { + return switch (value) { + 'none' => TextDecoration.none, + 'lineThrough' => TextDecoration.lineThrough, + 'underline' => TextDecoration.underline, + 'overline' => TextDecoration.overline, + _ => throw FormatException( + 'Unknown decoration "$value" at ' + '${_formatPath(token.pathSegments)}.', + token.filePath, + ), + }; + } + throw FormatException( + 'Decoration must be a string at ${_formatPath(token.pathSegments)}.', + token.filePath, + ); +} + +FontStyle _parseFontStyle(Object? value, _RawJsonToken token) { + if (value == null) { + return FontStyle.normal; + } + if (value is String) { + return switch (value) { + 'normal' => FontStyle.normal, + 'italic' => FontStyle.italic, + _ => throw FormatException( + 'Unknown fontStyle "$value" at ' + '${_formatPath(token.pathSegments)}.', + token.filePath, + ), + }; + } + throw FormatException( + 'FontStyle must be a string at ${_formatPath(token.pathSegments)}.', + token.filePath, + ); +} + +String _formatPath(List pathSegments) { + if (pathSegments.isEmpty) { + return '(root)'; + } + return pathSegments.join('.'); +} diff --git a/lib/src/data/util/converters/hex_color_conversion_x.dart b/lib/src/data/util/converters/hex_color_conversion_x.dart new file mode 100644 index 0000000..e7e5c11 --- /dev/null +++ b/lib/src/data/util/converters/hex_color_conversion_x.dart @@ -0,0 +1,26 @@ +/// Extension methods for converting hex color strings to integer values. +extension HexColorConversionX on String { + /// Converts a hex color string to a 32-bit ARGB integer. + int toHexColorValue() { + var hex = trim(); + if (hex.startsWith('#')) { + hex = hex.substring(1); + } else if (hex.startsWith('0x') || hex.startsWith('0X')) { + hex = hex.substring(2); + } + + if (hex.length == 3 || hex.length == 4) { + hex = hex.split('').map((c) => '$c$c').join(); + } + + if (hex.length == 6) { + hex = 'ff$hex'; + } else if (hex.length == 8) { + hex = '${hex.substring(6, 8)}${hex.substring(0, 6)}'; + } else { + throw const FormatException('Invalid hex color length.'); + } + + return int.parse(hex, radix: 16); + } +} diff --git a/lib/src/data/util/converters/type_style_conversion_x.dart b/lib/src/data/util/converters/type_style_conversion_x.dart index 04839ed..52b60f3 100644 --- a/lib/src/data/util/converters/type_style_conversion_x.dart +++ b/lib/src/data/util/converters/type_style_conversion_x.dart @@ -5,27 +5,43 @@ import 'package:meta/meta.dart'; /// An extension providing the method to for converting a [TypeStyle] to a /// [Typography]. extension TypeStyleConversionX on TypeStyle { + static const int _minFontWeight = 1; + static const int _maxFontWeight = 1000; + /// Converts a [TypeStyle] to a [Typography]. - Typography toDomain() => Typography( - fontFamily: fontFamily!, - fontFamilyPostScriptName: fontPostScriptName, - fontSize: fontSize!.toDouble(), - fontWeight: convertFontWeight(fontWeight ?? 400), - fontStyle: italic ? FontStyle.italic : FontStyle.normal, - letterSpacing: letterSpacing?.toDouble() ?? 0, - height: convertLineHeight(lineHeightPx, fontSize), - ); + Typography toDomain({ + void Function(String message)? onDiagnostic, + String? diagnosticContext, + }) => Typography( + fontFamily: fontFamily!, + fontFamilyPostScriptName: fontPostScriptName, + fontSize: fontSize!.toDouble(), + fontWeight: convertFontWeight( + fontWeight ?? 400, + onDiagnostic: onDiagnostic, + diagnosticContext: diagnosticContext, + ), + fontStyle: italic ? FontStyle.italic : FontStyle.normal, + letterSpacing: letterSpacing?.toDouble() ?? 0, + height: convertLineHeight(lineHeightPx, fontSize), + ); - /// Makes sure that the font weight is between 100 and 900 in increments of - /// 100. - @visibleForTesting - static int convertFontWeight(num weight) { - final incremented = (weight / 100).round() * 100; - return switch (incremented) { - > 900 => 900, - < 100 => 100, - _ => incremented, - }; + /// Makes sure that the font weight is between 1 and 1000. + static int convertFontWeight( + num weight, { + void Function(String message)? onDiagnostic, + String? diagnosticContext, + }) { + final rounded = weight.round(); + final clamped = rounded.clamp(_minFontWeight, _maxFontWeight); + if (clamped != rounded) { + final msg = 'Clamped fontWeight from $weight to $clamped. ' + 'Valid range is $_minFontWeight..$_maxFontWeight'; + onDiagnostic?.call( + diagnosticContext != null ? '$msg at $diagnosticContext.' : '$msg.', + ); + } + return clamped; } /// Converts the line height to the way Flutter expects it, relative to the diff --git a/lib/src/domain/models/config/config.dart b/lib/src/domain/models/config/config.dart index 484eb7d..944d10a 100644 --- a/lib/src/domain/models/config/config.dart +++ b/lib/src/domain/models/config/config.dart @@ -29,6 +29,7 @@ class Config with EquatableMixin { this.packageName, this.fileId, this.packageDescription = 'A design tokens package, generated by figmage', + this.json = const JsonTokenSourceConfig(), this.dropUnresolved = false, this.includeDeletedButReferenced = false, this.stylesFromLibrary = false, @@ -56,6 +57,9 @@ class Config with EquatableMixin { /// figma file ID. final String? fileId; + /// Optional JSON token sources. + final JsonTokenSourceConfig json; + /// The description of the generated dart package. final String packageDescription; @@ -73,10 +77,10 @@ class Config with EquatableMixin { /// /// When false (default), variables marked as `deletedButReferenced` by Figma /// are filtered out during processing, and a warning is logged. When true, - /// these variables are included in the generated tokens. Variables in this - /// state occur when you bind a property or variable alias to a variable, + /// these variables are included in the generated tokens. Variables in this + /// state occur when you bind a property or variable alias to a variable, /// then delete the variable using the "Local variables" menu. - /// + /// /// Defaults to false. final bool includeDeletedButReferenced; @@ -140,44 +144,46 @@ class Config with EquatableMixin { /// All generation settings. List get allGenerationSettings => [ - colors, - typography, - strings, - bools, - numbers, - spacers, - paddings, - radii, - ]; + colors, + typography, + strings, + bools, + numbers, + spacers, + paddings, + radii, + ]; /// Whether any setting defines at least one `from` but has `generate: false`. /// /// Is used to warn the user that there might be a potential error. - bool get suspiciousFromDefined => allGenerationSettings - .any((element) => element.from.isNotEmpty && element.generate == false); + bool get suspiciousFromDefined => allGenerationSettings.any( + (element) => element.from.isNotEmpty && element.generate == false, + ); /// Converts a [Config] to a map. Map toJson() => _$ConfigToJson(this); @override List get props => [ - fileId, - packageName, - packageDescription, - dropUnresolved, - includeDeletedButReferenced, - stylesFromLibrary, - asPackage, - tokenPath, - colors, - typography, - strings, - bools, - numbers, - spacers, - paddings, - radii, - ]; + fileId, + packageName, + packageDescription, + dropUnresolved, + includeDeletedButReferenced, + stylesFromLibrary, + asPackage, + tokenPath, + json, + colors, + typography, + strings, + bools, + numbers, + spacers, + paddings, + radii, + ]; } /// {@template generation_settings} @@ -217,6 +223,32 @@ class GenerationSettings with EquatableMixin { List get props => [generate, ...from, ...inheritance]; } +/// {@template json_token_source_config} +/// Settings for JSON token sources. +/// {@endtemplate} +@JsonSerializable(anyMap: true, checked: true) +class JsonTokenSourceConfig with EquatableMixin { + /// {@macro json_token_source_config} + const JsonTokenSourceConfig({ + this.paths = const [], + }); + + /// Initializes a [JsonTokenSourceConfig] from a json map. + factory JsonTokenSourceConfig.fromJson(Map json) => + _$JsonTokenSourceConfigFromJson(json); + + /// The JSON resolver manifest paths to read tokens from. + /// + /// JSON resolver mode expects exactly one path. + final Iterable paths; + + /// Converts a [JsonTokenSourceConfig] to a map. + Map toJson() => _$JsonTokenSourceConfigToJson(this); + + @override + List get props => [paths]; +} + /// {@template typography_generation_settings} /// Settings for generating typography tokens. This includes whether or not to /// generate the token, which paths to generate from and whether or not to use diff --git a/lib/src/domain/models/config/config.g.dart b/lib/src/domain/models/config/config.g.dart index d2868c9..c66f1c2 100644 --- a/lib/src/domain/models/config/config.g.dart +++ b/lib/src/domain/models/config/config.g.dart @@ -16,6 +16,12 @@ Config _$ConfigFromJson(Map json) => (v) => v as String? ?? 'A design tokens package, generated by figmage', ), + json: $checkedConvert( + 'json', + (v) => v == null + ? const JsonTokenSourceConfig() + : JsonTokenSourceConfig.fromJson(v as Map), + ), dropUnresolved: $checkedConvert( 'dropUnresolved', (v) => v as bool? ?? false, @@ -93,6 +99,7 @@ Config _$ConfigFromJson(Map json) => Map _$ConfigToJson(Config instance) => { 'packageName': instance.packageName, 'fileId': instance.fileId, + 'json': instance.json.toJson(), 'packageDescription': instance.packageDescription, 'dropUnresolved': instance.dropUnresolved, 'includeDeletedButReferenced': instance.includeDeletedButReferenced, @@ -139,6 +146,21 @@ Map _$GenerationSettingsToJson(GenerationSettings instance) => 'inheritance': instance.inheritance.toList(), }; +JsonTokenSourceConfig _$JsonTokenSourceConfigFromJson(Map json) => + $checkedCreate('JsonTokenSourceConfig', json, ($checkedConvert) { + final val = JsonTokenSourceConfig( + paths: $checkedConvert( + 'paths', + (v) => (v as List?)?.map((e) => e as String) ?? const [], + ), + ); + return val; + }); + +Map _$JsonTokenSourceConfigToJson( + JsonTokenSourceConfig instance, +) => {'paths': instance.paths.toList()}; + TypographyGenerationSettings _$TypographyGenerationSettingsFromJson(Map json) => $checkedCreate('TypographyGenerationSettings', json, ($checkedConvert) { final val = TypographyGenerationSettings( diff --git a/lib/src/domain/models/figmage_settings.dart b/lib/src/domain/models/figmage_settings.dart index 6e17f9c..ea01167 100644 --- a/lib/src/domain/models/figmage_settings.dart +++ b/lib/src/domain/models/figmage_settings.dart @@ -2,8 +2,8 @@ import 'package:figmage/src/domain/models/config/config.dart'; /// All settings that are used for the `forge` command. typedef FigmageSettings = ({ - String token, - String fileId, + String? token, + String? fileId, String path, Config config, }); diff --git a/lib/src/domain/models/json_token.dart b/lib/src/domain/models/json_token.dart new file mode 100644 index 0000000..ba56849 --- /dev/null +++ b/lib/src/domain/models/json_token.dart @@ -0,0 +1,44 @@ +import 'package:equatable/equatable.dart'; +import 'package:figmage/src/domain/models/design_token.dart'; +import 'package:figmage/src/domain/models/variable/alias_or/alias_or.dart'; + +/// {@template json_token} +/// A design token parsed from JSON input. +/// {@endtemplate} +class JsonToken with EquatableMixin implements DesignToken { + /// {@macro json_token} + const JsonToken({ + required this.name, + required this.fullName, + required this.collectionName, + required this.collectionId, + required this.valuesByModeName, + }); + + @override + final String name; + + @override + final String fullName; + + @override + final String collectionName; + + @override + final String collectionId; + + @override + final Map> valuesByModeName; + + @override + Map> get valuesByModeId => valuesByModeName; + + @override + List get props => [ + name, + fullName, + collectionName, + collectionId, + valuesByModeName, + ]; +} diff --git a/lib/src/domain/providers/design_token_providers.dart b/lib/src/domain/providers/design_token_providers.dart index 4602a9a..49821fb 100644 --- a/lib/src/domain/providers/design_token_providers.dart +++ b/lib/src/domain/providers/design_token_providers.dart @@ -10,10 +10,12 @@ import 'package:figmage/src/domain/models/typography/typography.dart'; import 'package:figmage/src/domain/models/variable/variable.dart'; import 'package:figmage/src/domain/providers/logger_providers.dart'; import 'package:figmage/src/domain/repositories/assets_repository.dart'; +import 'package:figmage/src/domain/repositories/json_tokens_repository.dart'; import 'package:figmage/src/domain/repositories/styles_repository.dart'; import 'package:figmage/src/domain/repositories/variables_repository.dart'; import 'package:figmage/src/domain/util/token_filter_x.dart'; import 'package:mason_logger/mason_logger.dart'; +import 'package:path/path.dart'; import 'package:riverpod/riverpod.dart'; /// Provides [TokensByType] based on [FigmageSettings]. @@ -63,26 +65,39 @@ final filterUnresolvedTokensProvider = final filteredTokensProvider = FutureProvider.family((ref, settings) async { late final Iterable variables; - try { - variables = await ref.watch(variablesProvider(settings).future); - } catch (_) { + if (settings.fileId != null && settings.token != null) { + try { + variables = await ref.watch(variablesProvider(settings).future); + } catch (_) { + variables = []; + } + } else { variables = []; } late final Iterable styles; - try { - styles = await ref.watch(stylesProvider(settings).future); - } catch (_) { + if (settings.fileId != null && settings.token != null) { + try { + styles = await ref.watch(stylesProvider(settings).future); + } catch (_) { + styles = []; + } + } else { styles = []; } - final allTokens = [...variables, ...styles]; + final jsonTokens = await ref.watch(jsonTokensProvider(settings).future); + + final allTokens = [ + ...variables, + ...styles, + ...jsonTokens, + ]; if (allTokens.isEmpty) { throw ArgumentError.value( allTokens, "Tokens", - "Neither styles nor variables could be obtained from file " - "${settings.fileId} ", + "No tokens could be obtained from the configured sources.", ); } return TokensByType( @@ -104,6 +119,30 @@ final filteredTokensProvider = ); }); +/// Provides a Iterable of all JSON tokens configured in [FigmageSettings]. +final jsonTokensProvider = + FutureProvider.family, FigmageSettings>(( + ref, + settings, + ) async { + final paths = settings.config.json.paths; + if (paths.isEmpty) { + return []; + } + final repo = ref.watch(jsonTokensRepositoryProvider); + final resolvedPaths = paths.map((path) { + if (isAbsolute(path)) { + return path; + } + return join(settings.path, path); + }); + final logger = ref.watch(loggerProvider); + return repo.getTokens( + paths: resolvedPaths, + onDiagnostic: logger.warn, + ); + }); + /// Provides a Iterable of all variables obtained from the file in /// [FigmageSettings]. /// @@ -113,13 +152,18 @@ final variablesProvider = ref, settings, ) async { + if (settings.fileId == null || settings.token == null) { + return []; + } + final fileId = settings.fileId!; + final token = settings.token!; final logger = ref.watch(loggerProvider); final repo = ref.watch(variablesRepositoryProvider); final varProgress = logger.progress("Fetching all variables..."); try { final variables = await repo.getVariables( - fileId: settings.fileId, - token: settings.token, + fileId: fileId, + token: token, ); switch (variables) { case []: @@ -127,7 +171,7 @@ final variablesProvider = throw ArgumentError.value( variables, "variables", - "No variables found in file ${settings.fileId}", + "No variables found in file $fileId", ); case [...]: // Filter out deleted variables if not included in config @@ -200,6 +244,16 @@ final assetsProvider = ); return {}; } + if (settings.fileId == null || settings.token == null) { + assetsProgress.fail( + "Assets require both fileId and token to be configured.", + ); + throw ArgumentError.notNull( + settings.fileId == null ? 'fileId' : 'token', + ); + } + final fileId = settings.fileId!; + final token = settings.token!; // Scale must be a number between 0.01 and 4 if (settings.config.assets.nodes.entries.any( (e) => e.value.scales.any((s) => s < 0.01 || s > 4), @@ -211,8 +265,8 @@ Figma only supports scale values between 0.01 and 4, values out of this range wi } final assets = await repo.fetchAndSaveAssets( - fileId: settings.fileId, - token: settings.token, + fileId: fileId, + token: token, nodeSettings: settings.config.assets.nodes, outputDir: Directory('${settings.path}/assets'), ); @@ -269,6 +323,11 @@ final stylesProvider = ref, settings, ) async { + if (settings.fileId == null || settings.token == null) { + return []; + } + final fileId = settings.fileId!; + final token = settings.token!; final logger = ref.watch(loggerProvider); final repo = ref.watch(stylesRepositoryProvider); final stylesProgress = logger.progress("Fetching all styles..."); @@ -280,8 +339,8 @@ final stylesProvider = final List> styles; try { styles = await repo.getStyles( - fileId: settings.fileId, - token: settings.token, + fileId: fileId, + token: token, fromLibrary: settings.config.stylesFromLibrary, onProgress: onStylesProgress, ); @@ -299,7 +358,7 @@ final stylesProvider = throw ArgumentError.value( styles, "styles", - "No styles found in file ${settings.fileId}", + "No styles found in file $fileId", ); case [...]: _warnAboutDuplicateStyleNames(logger, styles); @@ -312,10 +371,10 @@ void _warnAboutDuplicateStyleNames( Logger logger, Iterable> styles, ) { - final duplicates = groupBy(styles, (style) => style.fullName) - .entries - .where((entry) => entry.value.length > 1) - .toList(); + final duplicates = groupBy( + styles, + (style) => style.fullName, + ).entries.where((entry) => entry.value.length > 1).toList(); if (duplicates.isEmpty) { return; } diff --git a/lib/src/domain/providers/generator_providers.dart b/lib/src/domain/providers/generator_providers.dart index 2244e16..f201d90 100644 --- a/lib/src/domain/providers/generator_providers.dart +++ b/lib/src/domain/providers/generator_providers.dart @@ -8,7 +8,9 @@ import 'package:figmage/src/data/generators/file_generators/spacer_file_generato import 'package:figmage/src/data/generators/file_generators/typography_file_generator.dart'; import 'package:figmage/src/domain/generated_package_name_provider.dart'; import 'package:figmage/src/domain/generators/file_generator.dart'; +import 'package:figmage/src/domain/models/design_token.dart'; import 'package:figmage/src/domain/models/figmage_settings.dart'; +import 'package:figmage/src/domain/models/tokens_by_file_type/tokens_by_type.dart'; import 'package:figmage/src/domain/providers/design_token_providers.dart'; import 'package:figmage/src/domain/providers/figmage_package_generator_providers.dart'; import 'package:figmage/src/domain/providers/logger_providers.dart'; @@ -19,64 +21,100 @@ import 'package:riverpod/riverpod.dart'; /// Provides a one [FileGenerator] for each file that needs to be generated. final generatorsProvider = FutureProvider.family, FigmageSettings>( - (ref, settings) async { - final logger = ref.watch(loggerProvider); + (ref, settings) async { + final logger = ref.watch(loggerProvider); - final assets = await ref.watch(assetsProvider(settings).future); + final assets = await ref.watch(assetsProvider(settings).future); - final tokensByType = await ref.watch( - filterUnresolvedTokensProvider(settings).future, - ); + final tokensByType = await ref.watch( + filterUnresolvedTokensProvider(settings).future, + ); + final mixedCollectionIds = _mixedCollectionIds(tokensByType); + + final files = await ref.watch( + generatedPackageProvider(settings).future, + ); + + final typesByFile = { + for (final file in files) + if (file.tokenFileType case final type?) file: type, + }; - final files = await ref.watch(generatedPackageProvider(settings).future); + final progress = logger.progress( + "Generating theme classes for ${typesByFile.length} files...", + ); - final typesByFile = { - for (final file in files) - if (file.tokenFileType case final type?) file: type, - }; + final packageName = ref.watch(generatedPackageNameProvider(settings)); - final progress = logger.progress( - "Generating theme classes for ${typesByFile.length} files...", + final generatorsByFile = { + for (final MapEntry(key: file, value: type) in typesByFile.entries) + file: switch (type) { + TokenFileType.color => ColorFileGenerator( + tokens: tokensByType.colorTokens, + inheritanceSettings: settings.config.colors.inheritance, + isMixedTokenCollection: mixedCollectionIds.contains, + ), + TokenFileType.typography => TypographyFileGenerator( + tokens: tokensByType.typographyTokens, + useGoogleFonts: settings.config.typography.useGoogleFonts, + inheritanceSettings: settings.config.typography.inheritance, + isMixedTokenCollection: mixedCollectionIds.contains, + ), + TokenFileType.numbers => NumberFileGenerator( + tokens: tokensByType.numberTokens, + inheritanceSettings: settings.config.numbers.inheritance, + isMixedTokenCollection: mixedCollectionIds.contains, + ), + TokenFileType.spacers => SpacerFileGenerator( + tokens: tokensByType.numberTokens, + inheritanceSettings: settings.config.spacers.inheritance, + isMixedTokenCollection: mixedCollectionIds.contains, + ), + TokenFileType.paddings => PaddingFileGenerator( + tokens: tokensByType.numberTokens, + inheritanceSettings: settings.config.paddings.inheritance, + isMixedTokenCollection: mixedCollectionIds.contains, + ), + TokenFileType.assets => AssetFileGenerator( + assets: assets, + packageName: packageName, + ), + }, + }; + + progress.complete(); + return generatorsByFile; + }, ); - final packageName = ref.watch(generatedPackageNameProvider(settings)); +Set _mixedCollectionIds(TokensByType tokensByType) { + final tokenKindsByCollectionId = >{}; + + void collect( + String kind, + Iterable> tokens, + ) { + for (final token in tokens) { + tokenKindsByCollectionId + .putIfAbsent(token.collectionId, () => {}) + .add(kind); + } + } - final generatorsByFile = { - for (final MapEntry(key: file, value: type) in typesByFile.entries) - file: switch (type) { - TokenFileType.color => ColorFileGenerator( - tokens: tokensByType.colorTokens, - inheritanceSettings: settings.config.colors.inheritance, - ), - TokenFileType.typography => TypographyFileGenerator( - tokens: tokensByType.typographyTokens, - useGoogleFonts: settings.config.typography.useGoogleFonts, - inheritanceSettings: settings.config.typography.inheritance, - ), - TokenFileType.numbers => NumberFileGenerator( - tokens: tokensByType.numberTokens, - inheritanceSettings: settings.config.numbers.inheritance, - ), - TokenFileType.spacers => SpacerFileGenerator( - tokens: tokensByType.numberTokens, - inheritanceSettings: settings.config.spacers.inheritance, - ), - TokenFileType.paddings => PaddingFileGenerator( - tokens: tokensByType.numberTokens, - inheritanceSettings: settings.config.paddings.inheritance, - ), - TokenFileType.assets => - AssetFileGenerator(assets: assets, packageName: packageName), - }, - }; + collect('color', tokensByType.colorTokens); + collect('typography', tokensByType.typographyTokens); + collect('number', tokensByType.numberTokens); + collect('string', tokensByType.stringTokens); + collect('bool', tokensByType.boolTokens); - progress.complete(); - return generatorsByFile; - }, -); + return { + for (final entry in tokenKindsByCollectionId.entries) + if (entry.value.length > 1) entry.key, + }; +} extension on File { TokenFileType? get tokenFileType => TokenFileType.tryFromFilename( - basename(path), - ); + basename(path), + ); } diff --git a/lib/src/domain/repositories/json_tokens_repository.dart b/lib/src/domain/repositories/json_tokens_repository.dart new file mode 100644 index 0000000..3b3adb3 --- /dev/null +++ b/lib/src/domain/repositories/json_tokens_repository.dart @@ -0,0 +1,19 @@ +import 'package:figmage/src/data/repositories/json_tokens_repository.dart'; +import 'package:figmage/src/domain/models/design_token.dart'; +import 'package:riverpod/riverpod.dart'; + +/// A provider for a [JsonTokensRepository] that reads tokens from JSON files. +final jsonTokensRepositoryProvider = Provider( + (ref) => const FileJsonTokensRepository(), +); + +/// {@template json_tokens_repository} +/// Repository for reading design tokens from JSON files. +/// {@endtemplate} +abstract interface class JsonTokensRepository { + /// Returns all design tokens from the provided JSON file paths. + Future>> getTokens({ + required Iterable paths, + void Function(String message)? onDiagnostic, + }); +} diff --git a/test/fixtures/json/sample_system/$resolver.json b/test/fixtures/json/sample_system/$resolver.json new file mode 100644 index 0000000..85fbf74 --- /dev/null +++ b/test/fixtures/json/sample_system/$resolver.json @@ -0,0 +1,31 @@ +{ + "version": "2025.10", + "sets": { + "Display": { + "$ref": "./Display.json" + }, + "Headline": { + "$ref": "./Headline.json" + }, + "Body": { + "$ref": "./Body.json" + }, + "Label": { + "$ref": "./Label.json" + }, + "Color Primitives": { + "$ref": "./Color Primitives.json" + }, + "Color Aliases": { + "$ref": "./Color Aliases.json" + } + }, + "resolutionOrder": [ + "Display", + "Headline", + "Body", + "Label", + "Color Primitives", + "Color Aliases" + ] +} \ No newline at end of file diff --git a/test/fixtures/json/sample_system/Body.json b/test/fixtures/json/sample_system/Body.json new file mode 100644 index 0000000..0402b83 --- /dev/null +++ b/test/fixtures/json/sample_system/Body.json @@ -0,0 +1,88 @@ +{ + "Body 1": { + "$type": "typography", + "$value": { + "fontFamily": "Synthetic Sans", + "fontWeight": 463, + "lineHeight": 1.33, + "fontSize": 40, + "letterSpacing": 0, + "paragraphSpacing": 0, + "paragraphIndent": 0, + "textCase": "none", + "textDecoration": "none" + }, + "$description": "long-form content" + }, + "Body 1 Medium": { + "$type": "typography", + "$value": { + "fontFamily": "Synthetic Sans", + "fontWeight": 500, + "lineHeight": 1.33, + "fontSize": 40, + "letterSpacing": 0, + "paragraphSpacing": 0, + "paragraphIndent": 0, + "textCase": "none", + "textDecoration": "none" + } + }, + "Body 2": { + "$type": "typography", + "$value": { + "fontFamily": "Synthetic Sans", + "fontWeight": 400, + "lineHeight": 1.33, + "fontSize": 34, + "letterSpacing": 0, + "paragraphSpacing": 0, + "paragraphIndent": 0, + "textCase": "none", + "textDecoration": "none" + } + }, + "Body 2 Medium": { + "$type": "typography", + "$value": { + "fontFamily": "Synthetic Sans", + "fontWeight": 500, + "lineHeight": 1.33, + "fontSize": 34, + "letterSpacing": 0, + "paragraphSpacing": 0, + "paragraphIndent": 0, + "textCase": "none", + "textDecoration": "none" + } + }, + "Body 3": { + "$type": "typography", + "$value": { + "fontFamily": "Synthetic Sans", + "fontWeight": 400, + "lineHeight": 1.33, + "fontSize": 32, + "letterSpacing": 0, + "paragraphSpacing": 0, + "paragraphIndent": 0, + "textCase": "none", + "textDecoration": "none" + }, + "$description": "dense content" + }, + "Body 3 Medium": { + "$type": "typography", + "$value": { + "fontFamily": "Synthetic Sans", + "fontWeight": 500, + "lineHeight": 1.33, + "fontSize": 32, + "letterSpacing": 0, + "paragraphSpacing": 0, + "paragraphIndent": 0, + "textCase": "none", + "textDecoration": "none" + } + } +} diff --git a/test/fixtures/json/sample_system/Color Aliases.json b/test/fixtures/json/sample_system/Color Aliases.json new file mode 100644 index 0000000..bf94d76 --- /dev/null +++ b/test/fixtures/json/sample_system/Color Aliases.json @@ -0,0 +1,268 @@ +{ + "Value": { + "Palette Core": { + "Overlay 50%": { + "$type": "color", + "$value": "#5d8fa980" + }, + "Primary": { + "$type": "color", + "$value": "{Color Primitives.Value.coral.50}" + }, + "Secondary": { + "$type": "color", + "$value": "{Color Primitives.Value.coral.70}" + }, + "System Link": { + "$type": "color", + "$value": "{Color Primitives.Value.semantic.Signal Blue}" + }, + "Highlight Cool": { + "$type": "color", + "$value": "{Color Primitives.Value.aqua.30}" + }, + "Highlight Warm": { + "$type": "color", + "$value": "#e9e3de" + }, + "Overlay 5%": { + "$type": "color", + "$value": "#5d8fa90d" + }, + "Warm 5%": { + "$type": "color", + "$value": "#e9e3de0d" + }, + "Warm 50%": { + "$type": "color", + "$value": "#e9e3de80" + }, + "Primary 50%": { + "$type": "color", + "$value": "#f14f2880" + }, + "Primary 5%": { + "$type": "color", + "$value": "#f14f280d" + } + }, + "Control": { + "Disabled": { + "$type": "color", + "$value": "{Color Primitives.Value.slate.55}" + }, + "Primary": { + "$type": "color", + "$value": "{Color Primitives.Value.slate.60}" + }, + "Tertiary": { + "$type": "color", + "$value": "{Color Primitives.Value.slate.95}" + } + }, + "Canvas": { + "Low": { + "$type": "color", + "$value": "{Color Primitives.Value.slate.95}" + }, + "High": { + "$type": "color", + "$value": "{Color Primitives.Value.slate.70}" + }, + "Lowest": { + "$type": "color", + "$value": "#000000" + }, + "Mid": { + "$type": "color", + "$value": "{Color Primitives.Value.slate.80}" + }, + "Low-Mid": { + "$type": "color", + "$value": "{Color Primitives.Value.slate.90}" + }, + "Inverted": { + "$type": "color", + "$value": "{Color Primitives.Value.slate.50}" + } + }, + "Text": { + "Primary": { + "$type": "color", + "$value": "{Color Primitives.Value.mist.50 (core)}" + }, + "Secondary": { + "$type": "color", + "$value": "{Color Primitives.Value.slate.40}" + }, + "Inverted Primary": { + "$type": "color", + "$value": "{Color Primitives.Value.slate.90}" + }, + "Disabled": { + "$type": "color", + "$value": "{Color Primitives.Value.slate.60}" + }, + "Inverted Secondary": { + "$type": "color", + "$value": "{Color Primitives.Value.slate.80}" + } + }, + "Field States (beta)": { + "Primary": { + "Default": { + "$type": "color", + "$value": "{Color Primitives.Value.mist.60}" + }, + "Dragged": { + "$type": "color", + "$value": "{Color Primitives.Value.slate.50}" + }, + "Edit": { + "$type": "color", + "$value": "{Color Primitives.Value.slate.50}" + }, + "Disabled": { + "$type": "color", + "$value": "{Color Primitives.Value.slate.60}" + }, + "Pressed": { + "$type": "color", + "$value": "{Color Primitives.Value.slate.50}" + } + }, + "Secondary": { + "Dragged": { + "$type": "color", + "$value": "{Color Primitives.Value.slate.90}" + }, + "Pressed": { + "$type": "color", + "$value": "{Color Primitives.Value.slate.90}" + }, + "Disabled": { + "$type": "color", + "$value": "{Color Primitives.Value.slate.60}" + }, + "Edit": { + "$type": "color", + "$value": "{Color Primitives.Value.slate.90}" + }, + "Default": { + "$type": "color", + "$value": "{Color Primitives.Value.slate.80}" + } + } + }, + "Border": { + "Primary": { + "$type": "color", + "$value": "{Color Primitives.Value.slate.55}" + }, + "Secondary": { + "$type": "color", + "$value": "{Color Primitives.Value.slate.60}" + } + }, + "Icon": { + "Tip": { + "$type": "color", + "$value": "{Color Primitives.Value.aqua.60}" + }, + "Secondary": { + "$type": "color", + "$value": "{Color Primitives.Value.slate.40}" + }, + "Inverted Primary": { + "$type": "color", + "$value": "{Color Primitives.Value.slate.90}" + }, + "Primary": { + "$type": "color", + "$value": "{Color Primitives.Value.mist.50 (core)}" + }, + "Disabled": { + "$type": "color", + "$value": "{Color Primitives.Value.slate.60}" + }, + "Inverted Secondary": { + "$type": "color", + "$value": "{Color Primitives.Value.slate.80}" + }, + "Signal Red": { + "$type": "color", + "$value": "{Color Primitives.Value.semantic.Signal Red}" + }, + "Signal Yellow": { + "$type": "color", + "$value": "{Color Primitives.Value.semantic.Signal Yellow}" + }, + "Signal Blue": { + "$type": "color", + "$value": "{Color Primitives.Value.semantic.Signal Blue}" + }, + "Signal Green": { + "$type": "color", + "$value": "{Color Primitives.Value.semantic.Signal Green}" + } + }, + "Alert": { + "Primary": { + "$type": "color", + "$value": "#d94a18" + }, + "Secondary": { + "$type": "color", + "$value": "#3b1b16" + }, + "Muted": { + "$type": "color", + "$value": "#77443a" + } + }, + "Route Layer": { + "POI": { + "Cursor": { + "$type": "color", + "$value": "{Color Primitives.Value.aqua.10}" + }, + "Destination": { + "$type": "color", + "$value": "{Color Primitives.Value.aqua.50 (core)}" + } + }, + "Route": { + "Active": { + "$type": "color", + "$value": "{Color Primitives.Value.aqua.20}" + }, + "Active Border": { + "$type": "color", + "$value": "{Color Primitives.Value.aqua.80}" + }, + "Inactive": { + "$type": "color", + "$value": "{Color Primitives.Value.slate.60}" + }, + "Inactive Border": { + "$type": "color", + "$value": "{Color Primitives.Value.slate.80}" + }, + "Passed": { + "$type": "color", + "$value": "{Color Primitives.Value.slate.90}" + } + } + }, + "Thermal": { + "Hot": { + "$type": "color", + "$value": "#f13535" + }, + "Cold": { + "$type": "color", + "$value": "#3d7cf0" + } + } + } +} diff --git a/test/fixtures/json/sample_system/Color Primitives.json b/test/fixtures/json/sample_system/Color Primitives.json new file mode 100644 index 0000000..a16dcac --- /dev/null +++ b/test/fixtures/json/sample_system/Color Primitives.json @@ -0,0 +1,262 @@ +{ + "Value": { + "slate": { + "10": { + "$type": "color", + "$value": "#eceff1" + }, + "20": { + "$type": "color", + "$value": "#d5dbe0" + }, + "30": { + "$type": "color", + "$value": "#aeb8c0" + }, + "40": { + "$type": "color", + "$value": "#8a97a3" + }, + "50": { + "$type": "color", + "$value": "#758390" + }, + "55": { + "$type": "color", + "$value": "#56616c" + }, + "60": { + "$type": "color", + "$value": "#37414a" + }, + "70": { + "$type": "color", + "$value": "#2b333b" + }, + "80": { + "$type": "color", + "$value": "#1b2026" + }, + "90": { + "$type": "color", + "$value": "#12161b" + }, + "95": { + "$type": "color", + "$value": "#0b0e12" + } + }, + "mist": { + "10": { + "$type": "color", + "$value": "#fcfbfa" + }, + "20": { + "$type": "color", + "$value": "#f7f5f3" + }, + "30": { + "$type": "color", + "$value": "#f1efec" + }, + "40": { + "$type": "color", + "$value": "#ece9e5" + }, + "60": { + "$type": "color", + "$value": "#bdb7b2" + }, + "70": { + "$type": "color", + "$value": "#8f8a86" + }, + "80": { + "$type": "color", + "$value": "#615d5a" + }, + "90": { + "$type": "color", + "$value": "#322f2d" + }, + "95": { + "$type": "color", + "$value": "#1a1817" + }, + "50 (core)": { + "$type": "color", + "$value": "#e9e6e3" + } + }, + "crimson-core": { + "10": { + "$type": "color", + "$value": "#f6efef" + }, + "20": { + "$type": "color", + "$value": "#d9b8b8" + }, + "30": { + "$type": "color", + "$value": "#bd8181" + }, + "40": { + "$type": "color", + "$value": "#a04848" + }, + "60": { + "$type": "color", + "$value": "#650f0f" + }, + "70": { + "$type": "color", + "$value": "#460a0a" + }, + "80": { + "$type": "color", + "$value": "#280505" + }, + "90": { + "$type": "color", + "$value": "#110202" + }, + "50 (core)": { + "$type": "color", + "$value": "#821919" + } + }, + "aqua": { + "10": { + "$type": "color", + "$value": "#fafeff" + }, + "20": { + "$type": "color", + "$value": "#edf8fc" + }, + "30": { + "$type": "color", + "$value": "#deeff6" + }, + "40": { + "$type": "color", + "$value": "#cfe6f0" + }, + "60": { + "$type": "color", + "$value": "#94b2be" + }, + "70": { + "$type": "color", + "$value": "#667d87" + }, + "80": { + "$type": "color", + "$value": "#3a474d" + }, + "90": { + "$type": "color", + "$value": "#171b1e" + }, + "50 (core)": { + "$type": "color", + "$value": "#c0dde8" + } + }, + "amber-core": { + "10": { + "$type": "color", + "$value": "#fff5ee" + }, + "20": { + "$type": "color", + "$value": "#ffd6c2" + }, + "30": { + "$type": "color", + "$value": "#ffb694" + }, + "40": { + "$type": "color", + "$value": "#ff9666" + }, + "60": { + "$type": "color", + "$value": "#c15a2e" + }, + "70": { + "$type": "color", + "$value": "#874021" + }, + "80": { + "$type": "color", + "$value": "#4c2412" + }, + "90": { + "$type": "color", + "$value": "#1e0e06" + }, + "50 (core)": { + "$type": "color", + "$value": "#ff7638" + } + }, + "coral": { + "10": { + "$type": "color", + "$value": "#ffe3dd" + }, + "20": { + "$type": "color", + "$value": "#ffbdb0" + }, + "30": { + "$type": "color", + "$value": "#fc9783" + }, + "40": { + "$type": "color", + "$value": "#f87353" + }, + "50": { + "$type": "color", + "$value": "#f14f28" + }, + "60": { + "$type": "color", + "$value": "#be3d1f" + }, + "70": { + "$type": "color", + "$value": "#8c2a15" + }, + "80": { + "$type": "color", + "$value": "#57190c" + }, + "90": { + "$type": "color", + "$value": "#240a04" + } + }, + "semantic": { + "Signal Green": { + "$type": "color", + "$value": "#1ed760" + }, + "Signal Blue": { + "$type": "color", + "$value": "#2f9bff" + }, + "Signal Yellow": { + "$type": "color", + "$value": "#ffd52a" + }, + "Signal Red": { + "$type": "color", + "$value": "#ff3a3a" + } + } + } +} diff --git a/test/fixtures/json/sample_system/Display.json b/test/fixtures/json/sample_system/Display.json new file mode 100644 index 0000000..fb5818f --- /dev/null +++ b/test/fixtures/json/sample_system/Display.json @@ -0,0 +1,62 @@ +{ + "Display 1": { + "$type": "typography", + "$value": { + "fontFamily": "Synthetic Sans Condensed", + "fontWeight": 700, + "lineHeight": 0.9, + "fontSize": 224, + "letterSpacing": -2.24, + "paragraphSpacing": 0, + "paragraphIndent": 0, + "textCase": "none", + "textDecoration": "none" + }, + "$description": "large hero text" + }, + "Numeric Display 1": { + "$type": "typography", + "$value": { + "fontFamily": "Synthetic Sans Condensed", + "fontWeight": 700, + "lineHeight": 0.9, + "fontSize": 224, + "letterSpacing": -2.24, + "paragraphSpacing": 0, + "paragraphIndent": 0, + "textCase": "none", + "textDecoration": "none" + }, + "$description": "data panel value" + }, + "Display 2": { + "$type": "typography", + "$value": { + "fontFamily": "Synthetic Sans Condensed", + "fontWeight": 700, + "lineHeight": 1.16, + "fontSize": 156, + "letterSpacing": -1.56, + "paragraphSpacing": 0, + "paragraphIndent": 0, + "textCase": "none", + "textDecoration": "none" + }, + "$description": "large header text" + }, + "Numeric Display 2": { + "$type": "typography", + "$value": { + "fontFamily": "Synthetic Sans Condensed", + "fontWeight": 700, + "lineHeight": 1.16, + "fontSize": 156, + "letterSpacing": -1.56, + "paragraphSpacing": 0, + "paragraphIndent": 0, + "textCase": "none", + "textDecoration": "none" + }, + "$description": "compact data panel value" + } +} diff --git a/test/fixtures/json/sample_system/Headline.json b/test/fixtures/json/sample_system/Headline.json new file mode 100644 index 0000000..56aa67a --- /dev/null +++ b/test/fixtures/json/sample_system/Headline.json @@ -0,0 +1,72 @@ +{ + "Headline 1": { + "$type": "typography", + "$value": { + "fontFamily": "Synthetic Sans", + "fontWeight": 500, + "lineHeight": 1.33, + "fontSize": 80, + "letterSpacing": -0.8, + "paragraphSpacing": 0, + "paragraphIndent": 0, + "textCase": "none", + "textDecoration": "none" + } + }, + "Headline 2": { + "$type": "typography", + "$value": { + "fontFamily": "Synthetic Sans", + "fontWeight": 500, + "lineHeight": 1.33, + "fontSize": 64, + "letterSpacing": -0.64, + "paragraphSpacing": 0, + "paragraphIndent": 0, + "textCase": "none", + "textDecoration": "none" + } + }, + "Numeric Headline 2": { + "$type": "typography", + "$value": { + "fontFamily": "Synthetic Sans Mono", + "fontWeight": 500, + "lineHeight": 1.33, + "fontSize": 64, + "letterSpacing": -0.64, + "paragraphSpacing": 0, + "paragraphIndent": 0, + "textCase": "none", + "textDecoration": "none" + } + }, + "Headline 3": { + "$type": "typography", + "$value": { + "fontFamily": "Synthetic Sans", + "fontWeight": 500, + "lineHeight": 1.33, + "fontSize": 48, + "letterSpacing": -0.48, + "paragraphSpacing": 0, + "paragraphIndent": 0, + "textCase": "none", + "textDecoration": "none" + } + }, + "Headline 4": { + "$type": "typography", + "$value": { + "fontFamily": "Synthetic Sans", + "fontWeight": 500, + "lineHeight": 1.33, + "fontSize": 44, + "letterSpacing": -0.44, + "paragraphSpacing": 0, + "paragraphIndent": 0, + "textCase": "none", + "textDecoration": "none" + } + } +} diff --git a/test/fixtures/json/sample_system/Label.json b/test/fixtures/json/sample_system/Label.json new file mode 100644 index 0000000..cdfb2f7 --- /dev/null +++ b/test/fixtures/json/sample_system/Label.json @@ -0,0 +1,58 @@ +{ + "Label 1": { + "$type": "typography", + "$value": { + "fontFamily": "Synthetic Sans", + "fontWeight": 400, + "lineHeight": 1.33, + "fontSize": 34, + "letterSpacing": 1, + "paragraphSpacing": 0, + "paragraphIndent": 0, + "textCase": "none", + "textDecoration": "none" + } + }, + "Label 1 Medium": { + "$type": "typography", + "$value": { + "fontFamily": "Synthetic Sans", + "fontWeight": 500, + "lineHeight": 1.33, + "fontSize": 34, + "letterSpacing": 1, + "paragraphSpacing": 0, + "paragraphIndent": 0, + "textCase": "none", + "textDecoration": "none" + } + }, + "Label 2": { + "$type": "typography", + "$value": { + "fontFamily": "Synthetic Sans", + "fontWeight": 400, + "lineHeight": 1.33, + "fontSize": 28, + "letterSpacing": 1, + "paragraphSpacing": 0, + "paragraphIndent": 0, + "textCase": "none", + "textDecoration": "none" + } + }, + "Label 2 Medium": { + "$type": "typography", + "$value": { + "fontFamily": "Synthetic Sans", + "fontWeight": 500, + "lineHeight": 1.33, + "fontSize": 28, + "letterSpacing": 1, + "paragraphSpacing": 0, + "paragraphIndent": 0, + "textCase": "none", + "textDecoration": "none" + } + } +} diff --git a/test/full_coverage_test.dart b/test/full_coverage_test.dart index 7e7353d..590e278 100644 --- a/test/full_coverage_test.dart +++ b/test/full_coverage_test.dart @@ -27,8 +27,10 @@ import 'package:figmage/src/data/repositories/dart_post_generation_repository.da import 'package:figmage/src/data/repositories/figma_assets_repository.dart'; import 'package:figmage/src/data/repositories/figma_styles_repository.dart'; import 'package:figmage/src/data/repositories/figma_variables_repository.dart'; +import 'package:figmage/src/data/repositories/json_tokens_repository.dart'; import 'package:figmage/src/data/repositories/yaml_config_repository.dart'; import 'package:figmage/src/data/util/converters/color_conversion_x.dart'; +import 'package:figmage/src/data/util/converters/hex_color_conversion_x.dart'; import 'package:figmage/src/data/util/converters/string_dart_conversion_x.dart'; import 'package:figmage/src/data/util/converters/type_style_conversion_x.dart'; import 'package:figmage/src/domain/generated_package_name_provider.dart'; @@ -37,6 +39,7 @@ import 'package:figmage/src/domain/generators/theme_class_generator.dart'; import 'package:figmage/src/domain/models/config/config.dart'; import 'package:figmage/src/domain/models/design_token.dart'; import 'package:figmage/src/domain/models/figmage_settings.dart'; +import 'package:figmage/src/domain/models/json_token.dart'; import 'package:figmage/src/domain/models/style/design_style.dart'; import 'package:figmage/src/domain/models/tokens_by_file_type/tokens_by_type.dart'; import 'package:figmage/src/domain/models/typography/typography.dart'; @@ -54,6 +57,7 @@ import 'package:figmage/src/domain/providers/pub_updater_providers.dart'; import 'package:figmage/src/domain/repositories/assets_repository.dart'; import 'package:figmage/src/domain/repositories/config_repository.dart'; import 'package:figmage/src/domain/repositories/file_writer_repository.dart'; +import 'package:figmage/src/domain/repositories/json_tokens_repository.dart'; import 'package:figmage/src/domain/repositories/post_generation_repository.dart'; import 'package:figmage/src/domain/repositories/styles_repository.dart'; import 'package:figmage/src/domain/repositories/variables_repository.dart'; diff --git a/test/src/commands/forge/forge_command_test.dart b/test/src/commands/forge/forge_command_test.dart index 25e8785..2c4ecfe 100644 --- a/test/src/commands/forge/forge_command_test.dart +++ b/test/src/commands/forge/forge_command_test.dart @@ -128,9 +128,9 @@ void main() { const usage = ''' Usage: figmage forge [arguments] --h, --help Print this usage information. --t, --token (mandatory) Your figma API token --p, --path The ouptut path for the generated package, if not provided, the current directory will be used. --f, --fileId Your figma file ID, needs to be either given here, or in the figmage.yaml +-h, --help Print this usage information. +-t, --token Your figma API token (required when using fileId) +-p, --path The ouptut path for the generated package, if not provided, the current directory will be used. +-f, --fileId Your figma file ID, needs to be either given here, or in the figmage.yaml Run "figmage help" to see global options.'''; diff --git a/test/src/commands/shared/forge_settings_providers_test.dart b/test/src/commands/shared/forge_settings_providers_test.dart index 20c472c..266638c 100644 --- a/test/src/commands/shared/forge_settings_providers_test.dart +++ b/test/src/commands/shared/forge_settings_providers_test.dart @@ -27,6 +27,8 @@ void main() { when(() => argResults['token']).thenReturn("arg_token"); when(() => argResults['fileId']).thenReturn("arg_fileId"); when(() => argResults['path']).thenReturn("arg_path"); + when(() => argResults.wasParsed('token')).thenReturn(true); + when(() => argResults.wasParsed('fileId')).thenReturn(true); when(() => argResults.wasParsed('path')).thenReturn(true); }); @@ -45,6 +47,7 @@ void main() { test('takes fileId from config if not in args', () async { when(() => argResults['fileId']).thenReturn(null); + when(() => argResults.wasParsed('fileId')).thenReturn(false); final result = await container.read(settingsProvider(argResults).future); expect(result.fileId, config.fileId); @@ -68,6 +71,7 @@ void main() { test('throws ArgumentError if token is missing', () async { when(() => argResults['token']).thenReturn(null); + when(() => argResults.wasParsed('token')).thenReturn(false); await expectLater( () => container.read(settingsProvider(argResults).future), throwsA( @@ -75,6 +79,48 @@ void main() { ), ); }); + + test('allows json-only config without fileId or token', () async { + const jsonConfig = Config( + packageName: "packageName", + json: JsonTokenSourceConfig(paths: ["tokens.json"]), + ); + container = createContainer( + overrides: [ + configProvider.overrideWith((ref, _) => jsonConfig), + ], + ); + when(() => argResults['token']).thenReturn(null); + when(() => argResults['fileId']).thenReturn(null); + when(() => argResults.wasParsed('token')).thenReturn(false); + when(() => argResults.wasParsed('fileId')).thenReturn(false); + + final result = + await container.read(settingsProvider(argResults).future); + expect(result.fileId, isNull); + expect(result.token, isNull); + }); + + test('throws ArgumentError if fileId is missing and no json paths', + () async { + const emptyConfig = Config(packageName: "packageName"); + container = createContainer( + overrides: [ + configProvider.overrideWith((ref, _) => emptyConfig), + ], + ); + when(() => argResults['token']).thenReturn(null); + when(() => argResults['fileId']).thenReturn(null); + when(() => argResults.wasParsed('token')).thenReturn(false); + when(() => argResults.wasParsed('fileId')).thenReturn(false); + await expectLater( + () => container.read(settingsProvider(argResults).future), + throwsA( + isA().having((p0) => p0.name, 'name', 'fileId'), + ), + ); + }); + }); }); } diff --git a/test/src/data/generators/file_generators/base_file_generator_test.dart b/test/src/data/generators/file_generators/base_file_generator_test.dart index 73ce89d..02c52e6 100644 --- a/test/src/data/generators/file_generators/base_file_generator_test.dart +++ b/test/src/data/generators/file_generators/base_file_generator_test.dart @@ -1,6 +1,10 @@ import 'package:code_builder/code_builder.dart'; import 'package:figmage/src/data/generators/file_generators/base_file_generator.dart'; import 'package:figmage/src/domain/generators/theme_class_generator.dart'; +import 'package:figmage/src/domain/models/config/config.dart'; +import 'package:figmage/src/domain/models/design_token.dart'; +import 'package:figmage/src/domain/models/json_token.dart'; +import 'package:figmage/src/domain/models/variable/alias_or/alias_or.dart'; import 'package:figmage_package_generator/figmage_package_generator.dart'; import 'package:mocktail/mocktail.dart'; import 'package:test/test.dart'; @@ -10,6 +14,20 @@ import '../../../../test_util/mock/mock_variables.dart'; class _MockThemeClassGenerator extends Mock implements ThemeClassGenerator {} +class _FakeThemeClassGenerator implements ThemeClassGenerator { + @override + bool get buildContextExtensionNullable => false; + + @override + String get className => 'Fake'; + + @override + Class generateClass() => Class((c) => c.name = 'Fake'); + + @override + Extension generateExtension() => Extension((e) => e.name = 'FakeX'); +} + class _Generator extends BaseFileGenerator with Mock { _Generator({ required super.tokens, @@ -17,6 +35,28 @@ class _Generator extends BaseFileGenerator with Mock { }) : super(type: TokenFileType.color); } +class _RecordingGenerator extends BaseFileGenerator { + _RecordingGenerator({ + required super.tokens, + super.isMixedTokenCollection, + }) : super(type: TokenFileType.color, inheritanceSettings: []); + + final calls = []; + final classNameByCollectionId = {}; + + @override + ThemeClassGenerator buildGeneratorForCollection({ + required String collectionName, + required Iterable> collectionTokens, + required Iterable interfaces, + }) { + calls.add(collectionName); + classNameByCollectionId[collectionTokens.first.collectionId] = + collectionName; + return _FakeThemeClassGenerator(); + } +} + void main() { late _Generator sut; late List<_MockThemeClassGenerator> generators; @@ -24,8 +64,9 @@ void main() { _MockThemeClassGenerator getGenerator(int i) { final mock = _MockThemeClassGenerator(); when(mock.generateClass).thenReturn(Class((c) => c.name = "Test$i")); - when(mock.generateExtension) - .thenReturn(Extension((c) => c.name = "TestX$i")); + when( + mock.generateExtension, + ).thenReturn(Extension((c) => c.name = "TestX$i")); generators.add(mock); return mock; } @@ -54,10 +95,7 @@ void main() { sut.generate(); verify( () => sut.buildGeneratorForCollection( - collectionName: any( - named: "collectionName", - that: equals(mockColorDesignStyle.collectionName), - ), + collectionName: any(named: "collectionName", that: equals('Colors')), collectionTokens: any( named: "collectionTokens", that: equals([mockColorDesignStyle]), @@ -68,7 +106,7 @@ void main() { verify( () => sut.buildGeneratorForCollection( - collectionName: mockColorVariable.collectionName, + collectionName: 'Collection1', collectionTokens: [mockColorVariable], interfaces: [], ), @@ -104,5 +142,104 @@ void main() { reason: 'generated library body contains 2 classes and 2 extensions', ); }); + + test('drops trailing Value in single-mode class naming', () { + final generator = _RecordingGenerator( + tokens: const [ + JsonToken( + name: 'stone/50', + fullName: 'theme/Color Primitives/Value/stone/50', + collectionName: 'Color Primitives/Value', + collectionId: 'theme/Color Primitives/Value', + valuesByModeName: {'': AliasOr.data(data: 0xff000000)}, + ), + ], + ); + + generator.generate(); + + expect(generator.calls, ['ColorPrimitives']); + }); + + test('adds type prefix only for mixed-token source collections', () { + final generator = _RecordingGenerator( + isMixedTokenCollection: (collectionId) => collectionId == 'theme/light', + tokens: const [ + JsonToken( + name: 'tokenA', + fullName: 'theme/light/tokenA', + collectionName: 'light', + collectionId: 'theme/light', + valuesByModeName: {'': AliasOr.data(data: 0xff000000)}, + ), + JsonToken( + name: 'tokenB', + fullName: 'theme/brand/tokenB', + collectionName: 'brand', + collectionId: 'theme/brand', + valuesByModeName: {'': AliasOr.data(data: 0xffffffff)}, + ), + ], + ); + + generator.generate(); + + expect(generator.calls, contains('ColorsLight')); + expect(generator.calls, contains('Brand')); + expect(generator.calls, hasLength(2)); + }); + + test('uses suffix numbering as deterministic collision fallback', () { + final generator = _RecordingGenerator( + tokens: const [ + JsonToken( + name: 'tokenA', + fullName: 'theme/light/tokenA', + collectionName: 'light', + collectionId: 'theme/light', + valuesByModeName: {'': AliasOr.data(data: 0xff000000)}, + ), + JsonToken( + name: 'tokenB', + fullName: 'theme/light!/tokenB', + collectionName: 'light!', + collectionId: 'theme/light!', + valuesByModeName: {'': AliasOr.data(data: 0xffffffff)}, + ), + ], + ); + + generator.generate(); + + expect(generator.calls, ['Light', 'Light1']); + }); + + test('assigns collision suffixes independent of token input order', () { + const tokenA = JsonToken( + name: 'tokenA', + fullName: 'theme/light/tokenA', + collectionName: 'light', + collectionId: 'theme/light', + valuesByModeName: {'': AliasOr.data(data: 0xff000000)}, + ); + const tokenB = JsonToken( + name: 'tokenB', + fullName: 'theme/light!/tokenB', + collectionName: 'light!', + collectionId: 'theme/light!', + valuesByModeName: {'': AliasOr.data(data: 0xffffffff)}, + ); + + final forward = _RecordingGenerator(tokens: const [tokenA, tokenB]); + final reversed = _RecordingGenerator(tokens: const [tokenB, tokenA]); + + forward.generate(); + reversed.generate(); + + expect( + forward.classNameByCollectionId, + equals(reversed.classNameByCollectionId), + ); + }); }); } diff --git a/test/src/data/generators/file_generators/color_file_generator_test.dart b/test/src/data/generators/file_generators/color_file_generator_test.dart index 66ce385..d985363 100644 --- a/test/src/data/generators/file_generators/color_file_generator_test.dart +++ b/test/src/data/generators/file_generators/color_file_generator_test.dart @@ -67,18 +67,18 @@ import 'my_colors.dart'; import 'tokens.dart'; @immutable -class ColorsCollection1 extends ThemeExtension +class Collection1 extends ThemeExtension implements MyColors, Tokens{ - const ColorsCollection1({ + const Collection1({ required this.colorColorName, required this.colorNameUnresolvable, }); - const ColorsCollection1.dark() + const Collection1.dark() : colorColorName = const Color(0xffffffff), colorNameUnresolvable = null; - const ColorsCollection1.light() + const Collection1.light() : colorColorName = const Color(0xff000000), colorNameUnresolvable = const Color(0x00aaaaaa); @@ -87,23 +87,23 @@ class ColorsCollection1 extends ThemeExtension final Color? colorNameUnresolvable; @override - ColorsCollection1 copyWith([ + Collection1 copyWith([ Color? colorColorName, Color? colorNameUnresolvable, ]) => - ColorsCollection1( + Collection1( colorColorName: colorColorName ?? this.colorColorName, colorNameUnresolvable: colorNameUnresolvable ?? this.colorNameUnresolvable, ); @override - ColorsCollection1 lerp( - ColorsCollection1 other, + Collection1 lerp( + Collection1 other, double t, ) { - if (other is! ColorsCollection1) return this; - return ColorsCollection1( + if (other is! Collection1) return this; + return Collection1( colorColorName: Color.lerp( colorColorName, other.colorColorName, @@ -118,30 +118,30 @@ class ColorsCollection1 extends ThemeExtension } } -extension ColorsCollection1BuildContextX on BuildContext { - ColorsCollection1 get colorsCollection1 => - Theme.of(this).extension()!; +extension Collection1BuildContextX on BuildContext { + Collection1 get collection1 => + Theme.of(this).extension()!; } @immutable -class ColorsColors extends ThemeExtension implements Tokens { - const ColorsColors({required this.colorName}); +class Colors extends ThemeExtension implements Tokens { + const Colors({required this.colorName}); - const ColorsColors.standard() : colorName = const Color(0xffffffff); + const Colors.standard() : colorName = const Color(0xffffffff); final Color colorName; @override - ColorsColors copyWith([Color? colorName]) => - ColorsColors(colorName: colorName ?? this.colorName); + Colors copyWith([Color? colorName]) => + Colors(colorName: colorName ?? this.colorName); @override - ColorsColors lerp( - ColorsColors other, + Colors lerp( + Colors other, double t, ) { - if (other is! ColorsColors) return this; - return ColorsColors( + if (other is! Colors) return this; + return Colors( colorName: Color.lerp( colorName, other.colorName, @@ -150,7 +150,7 @@ class ColorsColors extends ThemeExtension implements Tokens { } } -extension ColorsColorsBuildContextX on BuildContext { - ColorsColors get colorsColors => Theme.of(this).extension()!; +extension ColorsBuildContextX on BuildContext { + Colors get colors => Theme.of(this).extension()!; } """; diff --git a/test/src/data/generators/theme_extension_generators/color_theme_extension_generator_test.dart b/test/src/data/generators/theme_extension_generators/color_theme_extension_generator_test.dart index 09ac9e8..0a7b29a 100644 --- a/test/src/data/generators/theme_extension_generators/color_theme_extension_generator_test.dart +++ b/test/src/data/generators/theme_extension_generators/color_theme_extension_generator_test.dart @@ -81,6 +81,29 @@ void main() { ), ); }); + + test('Can deal with modes containing different token keys', () async { + final generator = ColorThemeExtensionGenerator( + className: 'MyColorTheme', + valuesByNameByMode: const { + 'light': {'primary': 0xFFFFFFFF}, + 'dark': {'secondary': 0xFF000000}, + }, + interfaces: [], + ); + + final classOutput = generator.generateClass().accept(emitter).toString(); + expect(classOutput, contains('const MyColorTheme.light()')); + expect(classOutput, contains('primary = const Color(0xffffffff)')); + expect(classOutput, contains('secondary = null')); + + expect(classOutput, contains('const MyColorTheme.dark()')); + expect(classOutput, contains('primary = null')); + expect(classOutput, contains('secondary = const Color(0xff000000)')); + + expect(classOutput, contains('final Color? primary;')); + expect(classOutput, contains('final Color? secondary;')); + }); } // ************************************************************************** diff --git a/test/src/data/generators/theme_extension_generators/text_style_theme_extension_generator_test.dart b/test/src/data/generators/theme_extension_generators/text_style_theme_extension_generator_test.dart index aff0ea2..f363129 100644 --- a/test/src/data/generators/theme_extension_generators/text_style_theme_extension_generator_test.dart +++ b/test/src/data/generators/theme_extension_generators/text_style_theme_extension_generator_test.dart @@ -39,29 +39,30 @@ void main() { }, }; test( - 'Should create a TextStyleThemeExtension class and BuildContextExtension', - () async { - final generator = TextStyleThemeExtensionGenerator( - className: 'MyTextStyles', - valuesByNameByMode: valuesByNameByMode, - useGoogleFonts: false, - interfaces: [], - ); - expect( - generator.generateClass(), - equalsDart( - _expectedTextStyleThemeExtensionString, - emitter, - ), - ); - expect( - generator.generateExtension(), - equalsDart( - _expectedTextStyleThemeExtensionBuildContextExtensionString, - emitter, - ), - ); - }); + 'Should create a TextStyleThemeExtension class and BuildContextExtension', + () async { + final generator = TextStyleThemeExtensionGenerator( + className: 'MyTextStyles', + valuesByNameByMode: valuesByNameByMode, + useGoogleFonts: false, + interfaces: [], + ); + expect( + generator.generateClass(), + equalsDart( + _expectedTextStyleThemeExtensionString, + emitter, + ), + ); + expect( + generator.generateExtension(), + equalsDart( + _expectedTextStyleThemeExtensionBuildContextExtensionString, + emitter, + ), + ); + }, + ); test('Should create a class and nullable BuildContextExtension', () async { final generator = TextStyleThemeExtensionGenerator( className: 'MyTextStyles', @@ -108,6 +109,36 @@ void main() { ), ); }); + + test('emits const FontWeight(value) for non-100-step weights', () { + final generator = TextStyleThemeExtensionGenerator( + className: 'MyTextStyles', + valuesByNameByMode: const { + 'mode': { + 'textStyle': Typography( + fontSize: 16, + fontFamily: 'Roboto', + fontFamilyPostScriptName: 'Roboto', + fontWeight: 463, + ), + }, + }, + useGoogleFonts: false, + interfaces: [], + ); + + final arguments = generator.getNamedArguments( + const Typography( + fontSize: 16, + fontFamily: 'Roboto', + fontFamilyPostScriptName: 'Roboto', + fontWeight: 463, + ), + ); + final fontWeightCode = arguments['fontWeight']!.accept(emitter).toString(); + + expect(fontWeightCode, 'const FontWeight(463)'); + }); } // ************************************************************************** diff --git a/test/src/data/repositories/json_tokens_repository_test.dart b/test/src/data/repositories/json_tokens_repository_test.dart new file mode 100644 index 0000000..4605824 --- /dev/null +++ b/test/src/data/repositories/json_tokens_repository_test.dart @@ -0,0 +1,885 @@ +import 'dart:convert'; +import 'dart:io'; + +import 'package:figmage/src/data/repositories/json_tokens_repository.dart'; +import 'package:figmage/src/domain/models/design_token.dart'; +import 'package:figmage/src/domain/models/typography/typography.dart'; +import 'package:figmage/src/domain/models/variable/alias_or/alias_or.dart'; +import 'package:test/test.dart'; + +void main() { + group('FileJsonTokensRepository', () { + late Directory tempDir; + late FileJsonTokensRepository sut; + + setUp(() async { + tempDir = await Directory.systemTemp.createTemp( + 'figmage_json_tokens_', + ); + sut = const FileJsonTokensRepository(); + }); + + tearDown(() async { + if (tempDir.existsSync()) { + await tempDir.delete(recursive: true); + } + }); + + test('requires exactly one resolver manifest path', () async { + final resolverA = await _writeResolver( + tempDir, + setRefs: { + 'global': './global.json', + }, + ); + final resolverB = await _writeResolver( + tempDir, + name: 'resolver_b.json', + setRefs: { + 'global': './global.json', + }, + ); + + await expectLater( + () => sut.getTokens(paths: [resolverA.path, resolverB.path]), + throwsA(isA()), + ); + }); + + test( + 'throws when resolver references unknown set in resolution order', + () async { + final resolverFile = File('${tempDir.path}/resolver.json'); + await resolverFile.writeAsString( + jsonEncode({ + 'version': '2025.10', + 'sets': { + 'global': {r'$ref': './global.json'}, + }, + 'resolutionOrder': ['global', 'missing'], + }), + ); + + await expectLater( + () => sut.getTokens(paths: [resolverFile.path]), + throwsA(isA()), + ); + }, + ); + + test('throws when resolver references missing set file', () async { + final resolverFile = await _writeResolver( + tempDir, + setRefs: { + 'global': './missing.json', + }, + ); + + await expectLater( + () => sut.getTokens(paths: [resolverFile.path]), + throwsA(isA()), + ); + }); + + test('parses modes and collections through resolver manifest', () async { + await _writeJsonFile( + tempDir, + 'tokens.json', + { + 'ds': { + 'light': { + 'colors': { + 'primary': { + r'$type': 'color', + r'$value': '#ffffff', + }, + }, + }, + 'dark': { + 'colors': { + 'primary': { + r'$type': 'color', + r'$value': '#000000', + }, + }, + }, + 'gap': { + r'$type': 'number', + r'$value': 12, + }, + }, + }, + ); + final resolverFile = await _writeResolver( + tempDir, + setRefs: { + 'base': './tokens.json', + }, + ); + + final tokens = await sut.getTokens(paths: [resolverFile.path]); + final colorTokens = tokens.whereType>().toList(); + final numberTokens = tokens.whereType>().toList(); + + final primary = colorTokens.singleWhere( + (token) => token.fullName.endsWith('ds/colors/primary'), + ); + expect(primary.valuesByModeName.keys, containsAll(['light', 'dark'])); + + final gap = numberTokens.singleWhere( + (token) => token.fullName.endsWith('ds/gap'), + ); + expect(gap.collectionName, 'base'); + expect(gap.valuesByModeName.keys, contains('')); + }); + + test('resolves same-file curly aliases', () async { + await _writeJsonFile( + tempDir, + 'aliases.json', + { + 'Color Primitives': { + 'Value': { + 'stone': { + '50': { + r'$type': 'color', + r'$value': '#fafafa', + }, + }, + }, + }, + 'Color Aliases': { + 'Pressed': { + r'$type': 'color', + r'$value': '{Color Primitives.Value.stone.50}', + }, + }, + }, + ); + final resolverFile = await _writeResolver( + tempDir, + setRefs: { + 'aliases': './aliases.json', + }, + ); + + final tokens = await sut.getTokens(paths: [resolverFile.path]); + final aliasToken = tokens.whereType>().singleWhere( + (token) => token.fullName.endsWith('Color Aliases/Pressed'), + ); + + expect(aliasToken.valuesByModeName['']!.resolveValue, 0xfffafafa); + expect(aliasToken.valuesByModeName[''], isA>()); + }); + + test('resolves curly aliases across resolver files', () async { + await _writeJsonFile( + tempDir, + 'primitives.json', + { + 'Value': { + 'stone': { + '50': { + r'$type': 'color', + r'$value': '#fafafa', + }, + }, + }, + }, + ); + await _writeJsonFile( + tempDir, + 'aliases.json', + { + 'Color Aliases': { + 'Pressed': { + r'$type': 'color', + r'$value': '{Color Primitives.Value.stone.50}', + }, + }, + }, + ); + final resolverFile = await _writeResolver( + tempDir, + setRefs: { + 'Color Primitives': './primitives.json', + 'Color Aliases': './aliases.json', + }, + ); + + final diagnostics = []; + final tokens = await sut.getTokens( + paths: [resolverFile.path], + onDiagnostic: diagnostics.add, + ); + + final aliasToken = tokens.whereType>().singleWhere( + (token) => token.fullName.endsWith('Color Aliases/Pressed'), + ); + + expect(aliasToken.valuesByModeName['']!.resolveValue, 0xfffafafa); + expect(aliasToken.valuesByModeName[''], isA>()); + expect(diagnostics, isEmpty); + }); + + test( + 'marks missing curly aliases as unresolved and reports diagnostics', + () async { + await _writeJsonFile( + tempDir, + 'aliases.json', + { + 'Color Aliases': { + 'Pressed': { + r'$type': 'color', + r'$value': '{Color Primitives.Value.stone.50}', + }, + }, + }, + ); + final resolverFile = await _writeResolver( + tempDir, + setRefs: { + 'aliases': './aliases.json', + }, + ); + + final diagnostics = []; + final tokens = await sut.getTokens( + paths: [resolverFile.path], + onDiagnostic: diagnostics.add, + ); + + final aliasToken = tokens.whereType>().singleWhere( + (token) => token.fullName.endsWith('Color Aliases/Pressed'), + ); + + expect(aliasToken.valuesByModeName['']!.resolveValue, isNull); + expect(aliasToken.valuesByModeName[''], isA>()); + expect(diagnostics.join('\n'), contains('Unresolved reference')); + }, + ); + + test( + 'marks conflicting cross-set curly aliases as unresolved ' + 'and reports diagnostics', + () async { + await _writeJsonFile( + tempDir, + 'primitives_a.json', + { + 'Color Primitives': { + 'Value': { + 'stone': { + '50': { + r'$type': 'color', + r'$value': '#fafafa', + }, + }, + }, + }, + }, + ); + await _writeJsonFile( + tempDir, + 'primitives_b.json', + { + 'Color Primitives': { + 'Value': { + 'stone': { + '50': { + r'$type': 'color', + r'$value': '#f0f0f0', + }, + }, + }, + }, + }, + ); + await _writeJsonFile( + tempDir, + 'aliases.json', + { + 'Color Aliases': { + 'Pressed': { + r'$type': 'color', + r'$value': '{Color Primitives.Value.stone.50}', + }, + }, + }, + ); + final resolverFile = await _writeResolver( + tempDir, + setRefs: { + 'Color Primitives A': './primitives_a.json', + 'Color Primitives B': './primitives_b.json', + 'aliases': './aliases.json', + }, + ); + + final diagnostics = []; + final tokens = await sut.getTokens( + paths: [resolverFile.path], + onDiagnostic: diagnostics.add, + ); + final aliasToken = tokens.whereType>().singleWhere( + (token) => token.fullName.endsWith('Color Aliases/Pressed'), + ); + + expect(aliasToken.valuesByModeName['']!.resolveValue, isNull); + expect(aliasToken.valuesByModeName[''], isA>()); + expect(diagnostics.join('\n'), contains('Unresolved reference')); + }, + ); + + test( + r'resolves cross-file references using $ref and JSON pointer', + () async { + await _writeJsonFile( + tempDir, + 'primitives.json', + { + 'Color Primitives': { + 'Value': { + 'stone': { + '50': { + r'$type': 'color', + r'$value': '#fafafa', + }, + }, + }, + }, + }, + ); + await _writeJsonFile( + tempDir, + 'aliases.json', + { + 'Color Aliases': { + 'Pressed': { + r'$type': 'color', + r'$value': { + r'$ref': './primitives.json#/Color Primitives/Value/stone/50', + }, + }, + }, + }, + ); + final resolverFile = await _writeResolver( + tempDir, + setRefs: { + 'primitives': './primitives.json', + 'aliases': './aliases.json', + }, + ); + + final tokens = await sut.getTokens(paths: [resolverFile.path]); + final aliasToken = tokens.whereType>().singleWhere( + (token) => token.fullName.endsWith('Color Aliases/Pressed'), + ); + + expect(aliasToken.valuesByModeName['']!.resolveValue, 0xfffafafa); + expect(aliasToken.valuesByModeName[''], isA>()); + }, + ); + + test( + r'maps $ref JSON pointers targeting token $value to the owning token', + () async { + await _writeJsonFile( + tempDir, + 'primitives.json', + { + 'Color Primitives': { + 'Value': { + 'stone': { + '50': { + r'$type': 'color', + r'$value': '#fafafa', + }, + }, + }, + }, + }, + ); + await _writeJsonFile( + tempDir, + 'aliases.json', + { + 'Color Aliases': { + 'Pressed': { + r'$type': 'color', + r'$value': { + r'$ref': + r'./primitives.json#/Color Primitives/Value/stone/50/$value', + }, + }, + }, + }, + ); + final resolverFile = await _writeResolver( + tempDir, + setRefs: { + 'primitives': './primitives.json', + 'aliases': './aliases.json', + }, + ); + + final diagnostics = []; + final tokens = await sut.getTokens( + paths: [resolverFile.path], + onDiagnostic: diagnostics.add, + ); + final aliasToken = tokens.whereType>().singleWhere( + (token) => token.fullName.endsWith('Color Aliases/Pressed'), + ); + + expect(aliasToken.valuesByModeName['']!.resolveValue, 0xfffafafa); + expect(aliasToken.valuesByModeName[''], isA>()); + expect(diagnostics, isEmpty); + }, + ); + + test( + r'marks $ref to non-token pointer as unresolved and reports diagnostics', + () async { + await _writeJsonFile( + tempDir, + 'primitives.json', + { + 'Color Primitives': { + 'Value': { + 'stone': { + '50': { + r'$type': 'color', + r'$value': '#fafafa', + }, + }, + }, + }, + }, + ); + await _writeJsonFile( + tempDir, + 'aliases.json', + { + 'Color Aliases': { + 'Pressed': { + r'$type': 'color', + r'$value': { + r'$ref': + r'./primitives.json#/Color Primitives/Value/stone/50/$type', + }, + }, + }, + }, + ); + final resolverFile = await _writeResolver( + tempDir, + setRefs: { + 'primitives': './primitives.json', + 'aliases': './aliases.json', + }, + ); + + final diagnostics = []; + final tokens = await sut.getTokens( + paths: [resolverFile.path], + onDiagnostic: diagnostics.add, + ); + final token = tokens.whereType>().singleWhere( + (t) => t.fullName.endsWith('Color Aliases/Pressed'), + ); + + expect(token.valuesByModeName['']!.resolveValue, isNull); + expect(token.valuesByModeName[''], isA>()); + expect(diagnostics.join('\n'), contains('non-token location')); + }, + ); + + test(r'marks malformed JSON pointers in $ref as unresolved', () async { + await _writeJsonFile( + tempDir, + 'primitives.json', + { + 'Color Primitives': { + 'Value': { + 'stone': { + '50': { + r'$type': 'color', + r'$value': '#fafafa', + }, + }, + }, + }, + }, + ); + await _writeJsonFile( + tempDir, + 'aliases.json', + { + 'Color Aliases': { + 'Pressed': { + r'$type': 'color', + r'$value': { + r'$ref': './primitives.json#/Color~2Primitives/Value/stone/50', + }, + }, + }, + }, + ); + final resolverFile = await _writeResolver( + tempDir, + setRefs: { + 'primitives': './primitives.json', + 'aliases': './aliases.json', + }, + ); + + final diagnostics = []; + final tokens = await sut.getTokens( + paths: [resolverFile.path], + onDiagnostic: diagnostics.add, + ); + final token = tokens.whereType>().singleWhere( + (t) => t.fullName.endsWith('Color Aliases/Pressed'), + ); + + expect(token.valuesByModeName['']!.resolveValue, isNull); + expect(token.valuesByModeName[''], isA>()); + expect(diagnostics.join('\n'), contains('Invalid JSON pointer')); + }); + + test('keeps unresolved on type mismatch and reports diagnostics', () async { + await _writeJsonFile( + tempDir, + 'numbers.json', + { + 'Spacing': { + 'sm': { + r'$type': 'number', + r'$value': 4, + }, + }, + }, + ); + await _writeJsonFile( + tempDir, + 'colors.json', + { + 'Colors': { + 'primary': { + r'$type': 'color', + r'$value': { + r'$ref': './numbers.json#/Spacing/sm', + }, + }, + }, + }, + ); + final resolverFile = await _writeResolver( + tempDir, + setRefs: { + 'numbers': './numbers.json', + 'colors': './colors.json', + }, + ); + + final diagnostics = []; + final tokens = await sut.getTokens( + paths: [resolverFile.path], + onDiagnostic: diagnostics.add, + ); + final token = tokens.whereType>().singleWhere( + (t) => t.fullName.endsWith('Colors/primary'), + ); + + expect(token.valuesByModeName['']!.resolveValue, isNull); + expect(diagnostics.join('\n'), contains('Type mismatch')); + }); + + test('marks cyclic aliases unresolved and reports diagnostics', () async { + await _writeJsonFile( + tempDir, + 'cycle.json', + { + 'A': { + r'$type': 'color', + r'$value': '{B}', + }, + 'B': { + r'$type': 'color', + r'$value': '{A}', + }, + }, + ); + final resolverFile = await _writeResolver( + tempDir, + setRefs: { + 'cycle': './cycle.json', + }, + ); + + final diagnostics = []; + final tokens = await sut.getTokens( + paths: [resolverFile.path], + onDiagnostic: diagnostics.add, + ); + final tokenA = tokens.whereType>().singleWhere( + (t) => t.fullName.endsWith('A'), + ); + + expect(tokenA.valuesByModeName['']!.resolveValue, isNull); + expect(diagnostics.join('\n'), contains('Cycle detected')); + }); + + test( + 'marks cross-file cyclic aliases unresolved and reports diagnostics', + () async { + await _writeJsonFile( + tempDir, + 'a.json', + { + 'A': { + r'$type': 'color', + r'$value': { + r'$ref': './b.json#/B', + }, + }, + }, + ); + await _writeJsonFile( + tempDir, + 'b.json', + { + 'B': { + r'$type': 'color', + r'$value': { + r'$ref': './a.json#/A', + }, + }, + }, + ); + final resolverFile = await _writeResolver( + tempDir, + setRefs: { + 'a': './a.json', + 'b': './b.json', + }, + ); + + final diagnostics = []; + final tokens = await sut.getTokens( + paths: [resolverFile.path], + onDiagnostic: diagnostics.add, + ); + final tokenA = tokens.whereType>().singleWhere( + (t) => t.fullName.endsWith('A'), + ); + + expect(tokenA.valuesByModeName['']!.resolveValue, isNull); + expect(diagnostics.join('\n'), contains('Cycle detected')); + }, + ); + + test('parses typography values and defaults', () async { + await _writeJsonFile( + tempDir, + 'typography.json', + { + 'ds': { + 'light': { + 'text': { + r'$type': 'typography', + r'$value': { + 'fontFamily': 'Inter', + 'fontSize': 16, + 'fontWeight': 463, + 'textDecoration': 'underline', + }, + }, + }, + 'dark': { + 'text': { + r'$type': 'typography', + r'$value': { + 'fontFamily': 'Inter', + 'fontSize': 12, + 'fontWeight': 700, + 'letterSpacing': {'value': 0.5}, + 'lineHeight': {'value': 24, 'unit': 'px'}, + }, + }, + }, + }, + }, + ); + final resolverFile = await _writeResolver( + tempDir, + setRefs: { + 'typography': './typography.json', + }, + ); + + final tokens = await sut.getTokens(paths: [resolverFile.path]); + final typographyToken = tokens + .whereType>() + .singleWhere((token) => token.fullName.endsWith('ds/text')); + + final light = typographyToken.valuesByModeName['light']!.resolveValue!; + expect(light.fontFamily, 'Inter'); + expect(light.fontSize, 16); + expect(light.fontWeight, 463); + expect(light.letterSpacing, 1.0); + expect(light.height, 1.0); + expect(light.decoration, TextDecoration.underline); + + final dark = typographyToken.valuesByModeName['dark']!.resolveValue!; + expect(dark.fontWeight, 700); + expect(dark.letterSpacing, 0.5); + expect(dark.height, 2); + }); + + test( + 'clamps out-of-range typography fontWeight values ' + 'and reports diagnostics', + () async { + await _writeJsonFile( + tempDir, + 'typography.json', + { + 'ds': { + 'light': { + 'text': { + r'$type': 'typography', + r'$value': { + 'fontFamily': 'Inter', + 'fontSize': 16, + 'fontWeight': 0, + }, + }, + }, + 'dark': { + 'text': { + r'$type': 'typography', + r'$value': { + 'fontFamily': 'Inter', + 'fontSize': 16, + 'fontWeight': 1200, + }, + }, + }, + }, + }, + ); + final resolverFile = await _writeResolver( + tempDir, + setRefs: { + 'typography': './typography.json', + }, + ); + + final diagnostics = []; + final tokens = await sut.getTokens( + paths: [resolverFile.path], + onDiagnostic: diagnostics.add, + ); + final typographyToken = tokens + .whereType>() + .singleWhere((token) => token.fullName.endsWith('ds/text')); + final light = typographyToken.valuesByModeName['light']!.resolveValue!; + final dark = typographyToken.valuesByModeName['dark']!.resolveValue!; + + expect(light.fontWeight, 1); + expect(dark.fontWeight, 1000); + expect( + diagnostics.join('\n'), + contains('Clamped fontWeight from 0.0 to 1'), + ); + expect( + diagnostics.join('\n'), + contains('Clamped fontWeight from 1200.0 to 1000'), + ); + }, + ); + + test( + 'disables modes and keeps mixed-schema branches in one collection', + () async { + await _writeJsonFile( + tempDir, + 'theme.json', + { + 'colors': { + 'light': { + 'surface': {r'$type': 'color', r'$value': '#ffffff'}, + 'text': {r'$type': 'color', r'$value': '#111111'}, + }, + 'dark': { + 'surface': {r'$type': 'color', r'$value': '#000000'}, + 'text': {r'$type': 'color', r'$value': '#f0f0f0'}, + }, + 'compact': { + 'badge': {r'$type': 'color', r'$value': '#ff0000'}, + }, + }, + }, + ); + final resolverFile = await _writeResolver( + tempDir, + setRefs: { + 'theme': './theme.json', + }, + ); + + final tokens = await sut.getTokens(paths: [resolverFile.path]); + final colorTokens = tokens.whereType>().toList(); + final collectionNames = colorTokens + .map((token) => token.collectionName) + .toSet(); + final names = colorTokens.map((token) => token.name).toSet(); + final modeKeys = colorTokens + .expand((token) => token.valuesByModeName.keys) + .toSet(); + + expect(collectionNames, {'theme/colors'}); + expect(names, { + 'light/surface', + 'light/text', + 'dark/surface', + 'dark/text', + 'compact/badge', + }); + expect(modeKeys, {''}); + }, + ); + }); +} + +Future _writeResolver( + Directory dir, { + required Map setRefs, + String name = 'resolver.json', +}) async { + final file = File('${dir.path}/$name'); + await file.writeAsString( + jsonEncode({ + 'version': '2025.10', + 'sets': { + for (final entry in setRefs.entries) entry.key: {r'$ref': entry.value}, + }, + 'resolutionOrder': setRefs.keys.toList(), + }), + ); + return file; +} + +Future _writeJsonFile( + Directory dir, + String name, + Map content, +) async { + final file = File('${dir.path}/$name'); + await file.writeAsString(jsonEncode(content)); + return file; +} diff --git a/test/src/data/repositories/yaml_config_repository_test.dart b/test/src/data/repositories/yaml_config_repository_test.dart index b85e06a..34efd86 100644 --- a/test/src/data/repositories/yaml_config_repository_test.dart +++ b/test/src/data/repositories/yaml_config_repository_test.dart @@ -81,6 +81,7 @@ void main() { config.packageDescription, 'Design Tokens generated from our Figma', ); + expect(config.json.paths, ['tokens.json', 'tokens_extra.json']); expect(config.colors.generate, true); expect(config.colors.from, ['color', 'color2']); @@ -160,6 +161,10 @@ fileId: 'fileId' packageName: Design Token Package packageDescription: Design Tokens generated from our Figma outputPath: ./path +json: + paths: + - 'tokens.json' + - 'tokens_extra.json' colors: generate: true from: diff --git a/test/src/data/util/converters/hex_color_conversion_x_test.dart b/test/src/data/util/converters/hex_color_conversion_x_test.dart new file mode 100644 index 0000000..0053363 --- /dev/null +++ b/test/src/data/util/converters/hex_color_conversion_x_test.dart @@ -0,0 +1,21 @@ +import 'package:figmage/src/data/util/converters/hex_color_conversion_x.dart'; +import 'package:test/test.dart'; + +void main() { + group('HexColorConversionX', () { + test('parses 6 digit hex strings', () { + expect('#d9d9d9'.toHexColorValue(), equals(0xFFD9D9D9)); + }); + + test('parses 8 digit hex strings with alpha last', () { + expect('#11223344'.toHexColorValue(), equals(0x44112233)); + }); + + test('throws on invalid length', () { + expect( + () => '#12345'.toHexColorValue(), + throwsA(isA()), + ); + }); + }); +} diff --git a/test/src/data/util/converters/type_style_conversion_x_test.dart b/test/src/data/util/converters/type_style_conversion_x_test.dart index 605fe2c..81af640 100644 --- a/test/src/data/util/converters/type_style_conversion_x_test.dart +++ b/test/src/data/util/converters/type_style_conversion_x_test.dart @@ -51,98 +51,96 @@ void main() { expect(result.height, 1.25); }); - test('should convert a type style to a typography with no line height', - () { - const typeStyle = TypeStyle( - fontFamily: 'Roboto', - fontPostScriptName: 'Roboto-Regular', - fontSize: 16, - fontWeight: 400, - letterSpacing: 0, - ); - - final result = typeStyle.toDomain(); - - expect(result.fontFamily, 'Roboto'); - expect(result.fontFamilyPostScriptName, 'Roboto-Regular'); - expect(result.fontSize, 16); - expect(result.fontWeight, 400); - expect(result.fontStyle, FontStyle.normal); - expect(result.letterSpacing, 0); - expect(result.height, 1); - }); - - test('should convert a type style to a typography with no font weight', - () { - const typeStyle = TypeStyle( - fontFamily: 'Roboto', - fontPostScriptName: 'Roboto-Regular', - fontSize: 16, - letterSpacing: 0, - lineHeightPx: 20, - ); - - final result = typeStyle.toDomain(); - - expect(result.fontFamily, 'Roboto'); - expect(result.fontFamilyPostScriptName, 'Roboto-Regular'); - expect(result.fontSize, 16); - expect(result.fontWeight, 400); - expect(result.fontStyle, FontStyle.normal); - expect(result.letterSpacing, 0); - expect(result.height, 1.25); - }); + test( + 'should convert a type style to a typography with no line height', + () { + const typeStyle = TypeStyle( + fontFamily: 'Roboto', + fontPostScriptName: 'Roboto-Regular', + fontSize: 16, + fontWeight: 400, + letterSpacing: 0, + ); + + final result = typeStyle.toDomain(); + + expect(result.fontFamily, 'Roboto'); + expect(result.fontFamilyPostScriptName, 'Roboto-Regular'); + expect(result.fontSize, 16); + expect(result.fontWeight, 400); + expect(result.fontStyle, FontStyle.normal); + expect(result.letterSpacing, 0); + expect(result.height, 1); + }, + ); + + test( + 'should convert a type style to a typography with no font weight', + () { + const typeStyle = TypeStyle( + fontFamily: 'Roboto', + fontPostScriptName: 'Roboto-Regular', + fontSize: 16, + letterSpacing: 0, + lineHeightPx: 20, + ); + + final result = typeStyle.toDomain(); + + expect(result.fontFamily, 'Roboto'); + expect(result.fontFamilyPostScriptName, 'Roboto-Regular'); + expect(result.fontSize, 16); + expect(result.fontWeight, 400); + expect(result.fontStyle, FontStyle.normal); + expect(result.letterSpacing, 0); + expect(result.height, 1.25); + }, + ); }); group('convertFontWeight', () { - test('should return 100 for 0', () { - expect(TypeStyleConversionX.convertFontWeight(0), 100); - }); - - test('should return 100 for 100', () { - expect(TypeStyleConversionX.convertFontWeight(100), 100); - }); - - test('should return 200 for 200', () { - expect(TypeStyleConversionX.convertFontWeight(200), 200); + test('should return 1 for 0', () { + expect(TypeStyleConversionX.convertFontWeight(0), 1); }); - test('should return 300 for 300', () { - expect(TypeStyleConversionX.convertFontWeight(300), 300); + test('should return 1 for 1', () { + expect(TypeStyleConversionX.convertFontWeight(1), 1); }); - test('should return 400 for 400', () { - expect(TypeStyleConversionX.convertFontWeight(400), 400); + test('should return 100 for 99.6', () { + expect(TypeStyleConversionX.convertFontWeight(99.6), 100); }); - test('should return 500 for 500', () { - expect(TypeStyleConversionX.convertFontWeight(500), 500); + test('should preserve in-range arbitrary values', () { + expect(TypeStyleConversionX.convertFontWeight(463), 463); }); - test('should return 600 for 600', () { - expect(TypeStyleConversionX.convertFontWeight(600), 600); - }); - - test('should return 700 for 700', () { - expect(TypeStyleConversionX.convertFontWeight(700), 700); + test('should return 900 for 900', () { + expect(TypeStyleConversionX.convertFontWeight(900), 900); }); - test('should return 800 for 800', () { - expect(TypeStyleConversionX.convertFontWeight(800), 800); + test('should return 1000 for 1000', () { + expect(TypeStyleConversionX.convertFontWeight(1000), 1000); }); - test('should return 900 for 900', () { - expect(TypeStyleConversionX.convertFontWeight(900), 900); + test('should return 1000 for 1200', () { + expect(TypeStyleConversionX.convertFontWeight(1200), 1000); }); - test('should return 900 for 1000', () { - expect(TypeStyleConversionX.convertFontWeight(1000), 900); - }); + test('emits a diagnostic when clamping', () { + final diagnostics = []; + final value = TypeStyleConversionX.convertFontWeight( + 1200, + onDiagnostic: diagnostics.add, + diagnosticContext: 'my/path', + ); - test('should round', () { - expect(TypeStyleConversionX.convertFontWeight(520), 500); - expect(TypeStyleConversionX.convertFontWeight(549), 500); - expect(TypeStyleConversionX.convertFontWeight(550), 600); + expect(value, 1000); + expect( + diagnostics.single, + 'Clamped fontWeight from 1200 to 1000. ' + 'Valid range is 1..1000 at my/path.', + ); }); }); diff --git a/test/src/domain/models/config/config_test.dart b/test/src/domain/models/config/config_test.dart index 26b118c..a2a8f06 100644 --- a/test/src/domain/models/config/config_test.dart +++ b/test/src/domain/models/config/config_test.dart @@ -6,22 +6,33 @@ import 'package:yaml/yaml.dart'; void main() { group('Config', () { - late Map minimal; + late Map minimalWithFileId; + late Map minimalWithJson; late Map full; late Config fullExample; setUp(() { - minimal = { + minimalWithFileId = { + "packageName": "packageName", "fileId": "fileId", + }; + + minimalWithJson = { "packageName": "packageName", + "json": { + "paths": ["tokens.json"], + }, }; full = { - ...minimal, + ...minimalWithFileId, "packageDescription": "packageDescription", "asPackage": true, "tokenPath": "src", + "json": { + "paths": ["tokens.json", "tokens_extra.json"], + }, "dropUnresolved": false, "includeDeletedButReferenced": false, "stylesFromLibrary": false, @@ -81,6 +92,9 @@ void main() { fileId: "fileId", packageName: "packageName", packageDescription: "packageDescription", + json: const JsonTokenSourceConfig( + paths: ["tokens.json", "tokens_extra.json"], + ), colors: const GenerationSettings( from: ["path1", "path2"], ), @@ -145,7 +159,7 @@ void main() { group('fromMap', () { test('supports generation from minimal map', () async { - final result = Config.fromMap(minimal); + final result = Config.fromMap(minimalWithFileId); expect( result, const Config( @@ -155,6 +169,18 @@ void main() { ); }); + test('supports json-only minimal map', () async { + final result = Config.fromMap(minimalWithJson); + + expect( + result, + const Config( + packageName: "packageName", + json: JsonTokenSourceConfig(paths: ["tokens.json"]), + ), + ); + }); + test('supports generation from full map', () async { final result = Config.fromMap(full); expect(result, fullExample); @@ -163,7 +189,7 @@ void main() { test('supports includeDeletedButReferenced configuration', () async { final configMap = { - ...minimal, + ...minimalWithFileId, "includeDeletedButReferenced": true, }; @@ -173,9 +199,28 @@ void main() { expect(result.toJson()["includeDeletedButReferenced"], isTrue); }); + test('supports json token sources', () async { + final configMap = { + ...minimalWithFileId, + "json": { + "paths": ["tokens.json", "tokens_extra.json"], + }, + }; + + final result = Config.fromMap(configMap); + + expect(result.json.paths, ["tokens.json", "tokens_extra.json"]); + expect( + result.toJson()["json"], + equals({ + "paths": ["tokens.json", "tokens_extra.json"], + }), + ); + }); + test('supports asset configuration', () async { final map = { - ...minimal, + ...minimalWithFileId, "assets": { "generate": true, "nodes": { diff --git a/test/src/domain/providers/design_token_providers_test.dart b/test/src/domain/providers/design_token_providers_test.dart index e4e4c72..4711b20 100644 --- a/test/src/domain/providers/design_token_providers_test.dart +++ b/test/src/domain/providers/design_token_providers_test.dart @@ -1,12 +1,16 @@ import 'package:figmage/src/domain/models/config/config.dart'; +import 'package:figmage/src/domain/models/json_token.dart'; import 'package:figmage/src/domain/models/style/design_style.dart'; import 'package:figmage/src/domain/models/typography/typography.dart'; +import 'package:figmage/src/domain/models/variable/alias_or/alias_or.dart'; import 'package:figmage/src/domain/providers/design_token_providers.dart'; import 'package:figmage/src/domain/providers/logger_providers.dart'; +import 'package:figmage/src/domain/repositories/json_tokens_repository.dart'; import 'package:figmage/src/domain/repositories/styles_repository.dart'; import 'package:figmage/src/domain/repositories/variables_repository.dart'; import 'package:mason_logger/mason_logger.dart'; import 'package:mocktail/mocktail.dart'; +import 'package:path/path.dart' as path; import 'package:riverpod/riverpod.dart'; import 'package:test/test.dart'; @@ -18,6 +22,8 @@ class _MockStylesRepository extends Mock implements StylesRepository {} class _MockVariablesRepository extends Mock implements VariablesRepository {} +class _MockJsonTokensRepository extends Mock implements JsonTokensRepository {} + class _MockLogger extends Mock implements Logger {} class _MockProgress extends Mock implements Progress {} @@ -98,8 +104,9 @@ void main() { ); }); test('returns correctly filtered tokens', () async { - final tokensByType = - await container.read(filteredTokensProvider(mockSettings).future); + final tokensByType = await container.read( + filteredTokensProvider(mockSettings).future, + ); expect( tokensByType.colorTokens, containsAll([ @@ -130,22 +137,141 @@ void main() { ); expect(tokensByType.stringTokens, hasLength(1)); }); - test('throws ArgumentError if neither variables nor tokens exist', - () async { + test( + 'throws ArgumentError if neither variables nor tokens exist', + () async { + container = createContainer( + overrides: [ + loggerProvider.overrideWith((ref) => logger), + variablesProvider.overrideWith( + (ref, args) => Future.value([]), + ), + stylesProvider.overrideWith( + (ref, args) => Future.value([]), + ), + ], + ); + expect( + () => container.read(filteredTokensProvider(mockSettings).future), + throwsA(isA()), + ); + }, + ); + }); + + group('jsonTokensProvider', () { + late ProviderContainer container; + late _MockJsonTokensRepository jsonTokensRepository; + + setUp(() { + jsonTokensRepository = _MockJsonTokensRepository(); + when( + () => jsonTokensRepository.getTokens( + paths: any(named: 'paths'), + onDiagnostic: any(named: 'onDiagnostic'), + ), + ).thenAnswer((_) async => []); + when(() => logger.warn(any())).thenReturn(null); container = createContainer( overrides: [ loggerProvider.overrideWith((ref) => logger), - variablesProvider.overrideWith( - (ref, args) => Future.value([]), - ), - stylesProvider.overrideWith( - (ref, args) => Future.value([]), + jsonTokensRepositoryProvider.overrideWith( + (ref) => jsonTokensRepository, ), ], ); + }); + + test('resolves relative paths against settings.path', () async { + const settings = ( + config: Config( + json: JsonTokenSourceConfig(paths: ["tokens.json"]), + ), + fileId: null, + path: "root", + token: null, + ); + + await container.read(jsonTokensProvider(settings).future); + + final captured = + verify( + () => jsonTokensRepository.getTokens( + paths: captureAny(named: 'paths'), + onDiagnostic: any(named: 'onDiagnostic'), + ), + ).captured.single + as Iterable; + expect(captured.toList(), [path.join("root", "tokens.json")]); + }); + + test('forwards json token diagnostics to logger warnings', () async { + when( + () => jsonTokensRepository.getTokens( + paths: any(named: 'paths'), + onDiagnostic: any(named: 'onDiagnostic'), + ), + ).thenAnswer((invocation) async { + final callback = + invocation.namedArguments[#onDiagnostic] + as void Function(String message)?; + callback?.call('json diagnostic'); + return []; + }); + + const settings = ( + config: Config( + json: JsonTokenSourceConfig(paths: ["tokens.json"]), + ), + fileId: null, + path: "root", + token: null, + ); + + await container.read(jsonTokensProvider(settings).future); + + verify(() => logger.warn('json diagnostic')).called(1); + }); + }); + + group('filteredTokensProvider with json tokens', () { + late ProviderContainer container; + + const jsonSettings = ( + config: Config( + json: JsonTokenSourceConfig(paths: ["tokens.json"]), + ), + fileId: null, + path: ".", + token: null, + ); + + setUp(() { + const jsonToken = JsonToken( + name: "colors/primary", + fullName: "ds/colors/primary", + collectionName: "ds", + collectionId: "ds", + valuesByModeName: { + "light": AliasOr.data(data: 0xffffffff), + }, + ); + + container = createContainer( + overrides: [ + jsonTokensProvider.overrideWith((ref, _) async => [jsonToken]), + ], + ); + }); + + test('uses json tokens when figma sources are missing', () async { + final tokensByType = await container.read( + filteredTokensProvider(jsonSettings).future, + ); + expect(tokensByType.colorTokens, hasLength(1)); expect( - () => container.read(filteredTokensProvider(mockSettings).future), - throwsA(isA()), + tokensByType.colorTokens.first.fullName, + equals("ds/colors/primary"), ); }); }); @@ -163,8 +289,9 @@ void main() { ).thenAnswer((_) async => mockVariables); container = createContainer( overrides: [ - variablesRepositoryProvider - .overrideWith((ref) => variablesRepository), + variablesRepositoryProvider.overrideWith( + (ref) => variablesRepository, + ), loggerProvider.overrideWith((ref) => logger), ], );