diff --git a/.github/dependabot.yml b/.github/dependabot.yml index 2972364a..677ec35c 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -129,3 +129,23 @@ updates: schedule: interval: "weekly" versioning-strategy: widen + + - package-ecosystem: "pub" + directory: "/packages/jaspr_class_scope/" + groups: + all_dependencies: + patterns: + - "*" + schedule: + interval: "weekly" + versioning-strategy: widen + + - package-ecosystem: "pub" + directory: "/packages/jaspr_class_scope_builder/" + groups: + all_dependencies: + patterns: + - "*" + schedule: + interval: "weekly" + versioning-strategy: widen diff --git a/.github/labeler.yml b/.github/labeler.yml index b2a020e9..893788f7 100644 --- a/.github/labeler.yml +++ b/.github/labeler.yml @@ -50,3 +50,11 @@ - changed-files: - any-glob-to-any-file: - packages/leancode_cyberware_contract_base/** +'p: jaspr_class_scope': + - changed-files: + - any-glob-to-any-file: + - packages/jaspr_class_scope/** +'p: jaspr_class_scope_builder': + - changed-files: + - any-glob-to-any-file: + - packages/jaspr_class_scope_builder/** diff --git a/.github/workflows/jaspr_class_scope-publish.yml b/.github/workflows/jaspr_class_scope-publish.yml new file mode 100644 index 00000000..5283cc00 --- /dev/null +++ b/.github/workflows/jaspr_class_scope-publish.yml @@ -0,0 +1,33 @@ +name: jaspr_class_scope publish + +on: + push: + tags: ['jaspr_class_scope-v*'] + +jobs: + publish: + name: Publish to pub.dev + + runs-on: ubuntu-latest + + permissions: + id-token: write + contents: write + + defaults: + run: + working-directory: packages/jaspr_class_scope + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup Dart + uses: dart-lang/setup-dart@v1 + with: + sdk: 3.13 + + - name: Publish and release + uses: leancodepl/mobile-tools/.github/actions/pub-release@pub-release-v1 + with: + path: packages/jaspr_class_scope diff --git a/.github/workflows/jaspr_class_scope-test.yml b/.github/workflows/jaspr_class_scope-test.yml new file mode 100644 index 00000000..1183302a --- /dev/null +++ b/.github/workflows/jaspr_class_scope-test.yml @@ -0,0 +1,82 @@ +name: jaspr_class_scope test + +on: + push: + branches: [master] + tags-ignore: ['jaspr_class_scope-v*'] + paths: + - 'packages/jaspr_class_scope/**' + - '.github/workflows/jaspr_class_scope-test.yml' + pull_request: + branches: [master] + paths: + - 'packages/jaspr_class_scope/**' + - '.github/workflows/jaspr_class_scope-test.yml' + +jobs: + test: + name: Dart ${{ matrix.dart_release }} + + runs-on: ubuntu-latest + + strategy: + fail-fast: false + matrix: + dart_release: ['3.13'] + + defaults: + run: + working-directory: packages/jaspr_class_scope + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup Dart + uses: dart-lang/setup-dart@v1 + with: + sdk: ${{ matrix.dart_release }} + + - name: Dart version + run: dart --version + + - name: Cache pub dependencies + uses: actions/cache@v4 + with: + path: ~/.pub-cache + key: ${{ runner.os }}-pub-${{ hashFiles('**/pubspec.lock') }} + restore-keys: ${{ runner.os }}-pub- + + - name: Download pub dependencies + run: dart pub get + + - name: Run analyzer + run: dart analyze + + - name: Run format + run: dart format . --set-exit-if-changed + + - name: Run tests + run: dart test + + - name: Dry run pub publish + # We don't want it to fail the CI, it's just to see how would `pub publish` behave. + run: dart pub publish --dry-run || true + + pana-score: + name: pana score + runs-on: ubuntu-latest + + permissions: + contents: read + statuses: write + + steps: + - name: Clone repository + uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 + + - name: Score with pana + continue-on-error: true + uses: leancodepl/mobile-tools/.github/actions/pana-score@e9f138c252f8e2254c88273367958fcc6bb39cad # pana-score-v1.3 + with: + path: packages/jaspr_class_scope diff --git a/.github/workflows/jaspr_class_scope_builder-publish.yml b/.github/workflows/jaspr_class_scope_builder-publish.yml new file mode 100644 index 00000000..553886ce --- /dev/null +++ b/.github/workflows/jaspr_class_scope_builder-publish.yml @@ -0,0 +1,33 @@ +name: jaspr_class_scope_builder publish + +on: + push: + tags: ['jaspr_class_scope_builder-v*'] + +jobs: + publish: + name: Publish to pub.dev + + runs-on: ubuntu-latest + + permissions: + id-token: write + contents: write + + defaults: + run: + working-directory: packages/jaspr_class_scope_builder + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup Dart + uses: dart-lang/setup-dart@v1 + with: + sdk: 3.13 + + - name: Publish and release + uses: leancodepl/mobile-tools/.github/actions/pub-release@pub-release-v1 + with: + path: packages/jaspr_class_scope_builder diff --git a/.github/workflows/jaspr_class_scope_builder-test.yml b/.github/workflows/jaspr_class_scope_builder-test.yml new file mode 100644 index 00000000..ba1c366d --- /dev/null +++ b/.github/workflows/jaspr_class_scope_builder-test.yml @@ -0,0 +1,82 @@ +name: jaspr_class_scope_builder test + +on: + push: + branches: [master] + tags-ignore: ['jaspr_class_scope_builder-v*'] + paths: + - 'packages/jaspr_class_scope_builder/**' + - '.github/workflows/jaspr_class_scope_builder-test.yml' + pull_request: + branches: [master] + paths: + - 'packages/jaspr_class_scope_builder/**' + - '.github/workflows/jaspr_class_scope_builder-test.yml' + +jobs: + test: + name: Dart ${{ matrix.dart_release }} + + runs-on: ubuntu-latest + + strategy: + fail-fast: false + matrix: + dart_release: ['3.13'] + + defaults: + run: + working-directory: packages/jaspr_class_scope_builder + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup Dart + uses: dart-lang/setup-dart@v1 + with: + sdk: ${{ matrix.dart_release }} + + - name: Dart version + run: dart --version + + - name: Cache pub dependencies + uses: actions/cache@v4 + with: + path: ~/.pub-cache + key: ${{ runner.os }}-pub-${{ hashFiles('**/pubspec.lock') }} + restore-keys: ${{ runner.os }}-pub- + + - name: Download pub dependencies + run: dart pub get + + - name: Run analyzer + run: dart analyze + + - name: Run format + run: dart format . --set-exit-if-changed + + - name: Run tests + run: dart test + + - name: Dry run pub publish + # We don't want it to fail the CI, it's just to see how would `pub publish` behave. + run: dart pub publish --dry-run || true + + pana-score: + name: pana score + runs-on: ubuntu-latest + + permissions: + contents: read + statuses: write + + steps: + - name: Clone repository + uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 + + - name: Score with pana + continue-on-error: true + uses: leancodepl/mobile-tools/.github/actions/pana-score@e9f138c252f8e2254c88273367958fcc6bb39cad # pana-score-v1.3 + with: + path: packages/jaspr_class_scope_builder diff --git a/README.md b/README.md index 058eccd4..ec197438 100644 --- a/README.md +++ b/README.md @@ -2,6 +2,8 @@ | ----------------------------------------------------------------------------------- | :-----------------------------------------------------------------: | :----------------------------------------------------------------------------------------------------------------------------------------------------------: | :------------------------------------------------------------------------------------------------------------: | | [`cqrs`][cqrs-link] | [Documentation][cqrs-documentation] | [![cqrs pub.dev badge][cqrs-pub-badge]][cqrs-pub-badge-link] | [![][cqrs-build-badge]][cqrs-build-badge-link] | | [`enhanced_gradients`][enhanced_gradients-link] | [Documentation][enhanced_gradients-documentation] | [![enhanced_gradients pub.dev badge][enhanced_gradients-pub-badge]][enhanced_gradients-pub-badge-link] | [![][enhanced_gradients-build-badge]][enhanced_gradients-build-badge-link] | +| [`jaspr_class_scope`][jaspr_class_scope-link] | [Documentation][jaspr_class_scope-documentation] | [![jaspr_class_scope pub.dev badge][jaspr_class_scope-pub-badge]][jaspr_class_scope-pub-badge-link] | [![][jaspr_class_scope-build-badge]][jaspr_class_scope-build-badge-link] | +| [`jaspr_class_scope_builder`][jaspr_class_scope_builder-link] | [Documentation][jaspr_class_scope_builder-documentation] | [![jaspr_class_scope_builder pub.dev badge][jaspr_class_scope_builder-pub-badge]][jaspr_class_scope_builder-pub-badge-link] | [![][jaspr_class_scope_builder-build-badge]][jaspr_class_scope_builder-build-badge-link] | | [~~`leancode_analytics`~~][leancode_analytics-link] | [Documentation][leancode_analytics-documentation] | [![leancode_analytics pub.dev badge][leancode_analytics-pub-badge]][leancode_analytics-pub-badge-link] | [![][leancode_analytics-build-badge]][leancode_analytics-build-badge-link] | | [`leancode_analytics_base`][leancode_analytics_base-link] | [Documentation][leancode_analytics_base-documentation] | [![leancode_analytics_base pub.dev badge][leancode_analytics_base-pub-badge]][leancode_analytics_base-pub-badge-link] | [![][leancode_analytics_base-build-badge]][leancode_analytics_base-build-badge-link] | | [`leancode_analytics_firebase`][leancode_analytics_firebase-link] | [Documentation][leancode_analytics_firebase-documentation] | [![leancode_analytics_firebase pub.dev badge][leancode_analytics_firebase-pub-badge]][leancode_analytics_firebase-pub-badge-link] | [![][leancode_analytics_firebase-build-badge]][leancode_analytics_firebase-build-badge-link] | @@ -41,6 +43,20 @@ [enhanced_gradients-build-badge]: https://img.shields.io/github/actions/workflow/status/leancodepl/flutter_corelibrary/enhanced_gradients-test.yml?branch=master [enhanced_gradients-build-badge-link]: https://github.com/leancodepl/flutter_corelibrary/actions/workflows/enhanced_gradients-test.yml +[jaspr_class_scope-link]: https://github.com/leancodepl/flutter_corelibrary/tree/master/packages/jaspr_class_scope +[jaspr_class_scope-documentation]: https://pub.dev/documentation/jaspr_class_scope/latest/ +[jaspr_class_scope-pub-badge]: https://img.shields.io/pub/v/jaspr_class_scope +[jaspr_class_scope-pub-badge-link]: https://pub.dev/packages/jaspr_class_scope +[jaspr_class_scope-build-badge]: https://img.shields.io/github/actions/workflow/status/leancodepl/flutter_corelibrary/jaspr_class_scope-test.yml?branch=master +[jaspr_class_scope-build-badge-link]: https://github.com/leancodepl/flutter_corelibrary/actions/workflows/jaspr_class_scope-test.yml + +[jaspr_class_scope_builder-link]: https://github.com/leancodepl/flutter_corelibrary/tree/master/packages/jaspr_class_scope_builder +[jaspr_class_scope_builder-documentation]: https://pub.dev/documentation/jaspr_class_scope_builder/latest/ +[jaspr_class_scope_builder-pub-badge]: https://img.shields.io/pub/v/jaspr_class_scope_builder +[jaspr_class_scope_builder-pub-badge-link]: https://pub.dev/packages/jaspr_class_scope_builder +[jaspr_class_scope_builder-build-badge]: https://img.shields.io/github/actions/workflow/status/leancodepl/flutter_corelibrary/jaspr_class_scope_builder-test.yml?branch=master +[jaspr_class_scope_builder-build-badge-link]: https://github.com/leancodepl/flutter_corelibrary/actions/workflows/jaspr_class_scope_builder-test.yml + [leancode_analytics-link]: https://github.com/leancodepl/flutter_corelibrary/tree/master/packages/leancode_analytics [leancode_analytics-documentation]: https://pub.dev/documentation/leancode_analytics/latest/ [leancode_analytics-pub-badge]: https://img.shields.io/pub/v/leancode_analytics diff --git a/packages/jaspr_class_scope/.gitignore b/packages/jaspr_class_scope/.gitignore new file mode 100644 index 00000000..eb8f5613 --- /dev/null +++ b/packages/jaspr_class_scope/.gitignore @@ -0,0 +1,7 @@ +# https://dart.dev/guides/libraries/private-files + +.dart_tool/ +build/ +coverage/ +doc/api/ +pubspec.lock diff --git a/packages/jaspr_class_scope/CHANGELOG.md b/packages/jaspr_class_scope/CHANGELOG.md new file mode 100644 index 00000000..951a5a3f --- /dev/null +++ b/packages/jaspr_class_scope/CHANGELOG.md @@ -0,0 +1,3 @@ +## 0.1.0 + +- Initial release. diff --git a/packages/jaspr_class_scope/LICENSE b/packages/jaspr_class_scope/LICENSE new file mode 100644 index 00000000..a53097bc --- /dev/null +++ b/packages/jaspr_class_scope/LICENSE @@ -0,0 +1,201 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright 2026 LeanCode Sp. z o.o. + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/packages/jaspr_class_scope/README.md b/packages/jaspr_class_scope/README.md new file mode 100644 index 00000000..b5ccef31 --- /dev/null +++ b/packages/jaspr_class_scope/README.md @@ -0,0 +1,101 @@ +# jaspr_class_scope + +[![jaspr_class_scope pub.dev badge][pub-badge]][pub-badge-link] +[![jaspr_class_scope continuous integration badge][build-badge]][build-badge-link] + +Locally scoped CSS class names for [Jaspr] components: a name written in one +component cannot style another component's markup. + +## Usage + +```shell +dart pub add jaspr_class_scope +dart pub add --dev jaspr_class_scope_builder build_runner +``` + +```dart +part 'hero.scopes.dart'; + +@scopedCss +class Hero extends StatelessComponent { + static const _class = _$HeroScope; + + static final _grid = _class('grid'); + static final _title = _class('title'); + + @css + static List get styles => [ + css(_grid.selector).styles(display: Display.grid), + css(_title.selector).styles(fontSize: 3.rem), + ]; + + @override + Component build(BuildContext context) => + div(classes: _grid.name, [h1(classes: _title.name, [text('Hero')])]); +} +``` + +After `dart run build_runner build`, `_grid` and `_title` render as `grid` and +`title` with a suffix belonging to `Hero`, and another component's +`_class('grid')` gets a different one. A raw `'grid'` string elsewhere matches +neither. The scope itself comes from +[`jaspr_class_scope_builder`][builder]. + +## Classes another file knows by name + +A class a script looks up or a hand-written stylesheet styles renders as +written: + +```dart +static const copyButton = ClassName.shared('js-copy'); +``` + +## Two classes on one element + +```dart +final primary = _class('button') + _class('primary'); + +primary.name; // the two classes, space-separated +primary.selector; // the two classes, for an element carrying both +``` + +--- + +## 🛠️ Maintained by LeanCode +
+ + [LeanCode Logo][leancode-landing] + +
+ +This package is built with 💙 by **[LeanCode][leancode-landing]**. +We are **top-tier experts** focused on Flutter Enterprise solutions. + +### Why LeanCode? + +- **Creators of [Patrol][patrol-landing]** – the next-gen testing framework for Flutter. + +- **Production-Ready** – We use this package in apps with millions of users. +- **Full-Cycle Product Development** – We take your product from scratch to long-term maintenance. + +
+
+ + **Need help with your Flutter project?** + + [**👉 Hire our team**][leancode-estimate] +   •   + [Check our other packages][leancode-packages] + +
+ +[pub-badge]: https://img.shields.io/pub/v/jaspr_class_scope +[pub-badge-link]: https://pub.dev/packages/jaspr_class_scope +[build-badge]: https://img.shields.io/github/actions/workflow/status/leancodepl/flutter_corelibrary/jaspr_class_scope-test.yml?branch=master +[build-badge-link]: https://github.com/leancodepl/flutter_corelibrary/actions/workflows/jaspr_class_scope-test.yml +[builder]: https://pub.dev/packages/jaspr_class_scope_builder +[Jaspr]: https://jaspr.site +[leancode-landing]: https://leancode.co/?utm_source=github.com&utm_medium=referral&utm_campaign=jaspr-class-scope +[leancode-estimate]: https://leancode.co/get-estimate?utm_source=github.com&utm_medium=referral&utm_campaign=jaspr-class-scope +[leancode-packages]: https://pub.dev/packages?q=publisher%3Aleancode.co&sort=downloads +[patrol-landing]: https://patrol.leancode.co/?utm_source=github.com&utm_medium=referral&utm_campaign=jaspr-class-scope diff --git a/packages/jaspr_class_scope/analysis_options.yaml b/packages/jaspr_class_scope/analysis_options.yaml new file mode 100644 index 00000000..f3ed8d06 --- /dev/null +++ b/packages/jaspr_class_scope/analysis_options.yaml @@ -0,0 +1,4 @@ +include: package:leancode_lint/analysis_options_package.yaml + +plugins: + leancode_lint: ^27.0.0 diff --git a/packages/jaspr_class_scope/example/hero.dart b/packages/jaspr_class_scope/example/hero.dart new file mode 100644 index 00000000..184f2aae --- /dev/null +++ b/packages/jaspr_class_scope/example/hero.dart @@ -0,0 +1,23 @@ +// The Jaspr parts — `@css`, `css()`, `div()` — are left out so that the +// example stays dependency-free. + +import 'package:jaspr_class_scope/jaspr_class_scope.dart'; + +part 'hero.scopes.dart'; + +@scopedCss +class Hero { + static const _class = _$HeroScope; + + static final _grid = _class('grid'); + static final _title = _class('title'); + + /// What a `@css` getter would style. + static String get styles => '${_grid.selector} > ${_title.selector}'; + + /// What `build` would render. + static String get gridClasses => _grid.name; +} + +/// A class another file knows by name is not scoped. +const copyButton = ClassName.shared('js-copy'); diff --git a/packages/jaspr_class_scope/example/hero.scopes.dart b/packages/jaspr_class_scope/example/hero.scopes.dart new file mode 100644 index 00000000..0306f10d --- /dev/null +++ b/packages/jaspr_class_scope/example/hero.scopes.dart @@ -0,0 +1,8 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND +// Written by jaspr_class_scope_builder. +// dart format off + +part of 'hero.dart'; + +/// The scope of [Hero]'s class names. +const _$HeroScope = ClassScope('Hero', '7en7oj'); diff --git a/packages/jaspr_class_scope/example/jaspr_class_scope_example.dart b/packages/jaspr_class_scope/example/jaspr_class_scope_example.dart new file mode 100644 index 00000000..aa2c9801 --- /dev/null +++ b/packages/jaspr_class_scope/example/jaspr_class_scope_example.dart @@ -0,0 +1,10 @@ +// The example prints what the component would render. +// ignore_for_file: avoid_print + +import 'hero.dart'; + +void main() { + print(Hero.styles); // .grid-7en7oj > .title-7en7oj + print(Hero.gridClasses); // grid-7en7oj + print(copyButton.name); // js-copy +} diff --git a/packages/jaspr_class_scope/lib/jaspr_class_scope.dart b/packages/jaspr_class_scope/lib/jaspr_class_scope.dart new file mode 100644 index 00000000..79a91a2c --- /dev/null +++ b/packages/jaspr_class_scope/lib/jaspr_class_scope.dart @@ -0,0 +1,6 @@ +/// Locally scoped CSS class names for Jaspr components. +library; + +export 'src/class_name.dart'; +export 'src/class_scope.dart'; +export 'src/scoped_css.dart'; diff --git a/packages/jaspr_class_scope/lib/src/class_name.dart b/packages/jaspr_class_scope/lib/src/class_name.dart new file mode 100644 index 00000000..fc870183 --- /dev/null +++ b/packages/jaspr_class_scope/lib/src/class_name.dart @@ -0,0 +1,50 @@ +import 'package:jaspr_class_scope/src/class_scope.dart'; + +/// A class name, spelled once for both the `classes:` attribute ([name]) and +/// the selector ([selector]) so the two cannot drift apart. +final class ClassName { + /// A class rendered as written, because something outside the component + /// knows it by [name]. + const ClassName.shared(this._local) : _scope = null, _and = null; + + /// A class local to the component of a [ClassScope]. + const ClassName.scoped(this._local, this._scope) : _and = null; + + const ClassName._(this._local, this._scope, this._and); + + final String _local; + final ClassScope? _scope; + final ClassName? _and; + + /// The `classes:` value; `a b` for a combination. + String get name => switch (_and) { + null => _rendered, + final and => '$_rendered ${and.name}', + }; + + /// The selector; `.a.b` for a combination, matching an element with both. + String get selector => switch (_and) { + null => '.$_rendered', + final and => '.$_rendered${and.selector}', + }; + + String get _rendered => switch (_scope) { + null => _local, + final scope => '$_local-${scope.suffix}', + }; + + /// This class and [other] on the same element. + ClassName operator +(ClassName other) => switch (_and) { + null => ClassName._(_local, _scope, other), + final and => ClassName._(_local, _scope, and + other), + }; + + @override + bool operator ==(Object other) => other is ClassName && other.name == name; + + @override + int get hashCode => name.hashCode; + + @override + String toString() => name; +} diff --git a/packages/jaspr_class_scope/lib/src/class_scope.dart b/packages/jaspr_class_scope/lib/src/class_scope.dart new file mode 100644 index 00000000..f93f9777 --- /dev/null +++ b/packages/jaspr_class_scope/lib/src/class_scope.dart @@ -0,0 +1,19 @@ +import 'package:jaspr_class_scope/src/class_name.dart'; + +/// The locally scoped class names of one component. +final class ClassScope { + /// The scope of [name]'s class names, which all end with [suffix]. + const ClassScope(this.name, this.suffix); + + /// The component this scope belongs to. + final String name; + + /// What makes this scope's class names local to it. + final String suffix; + + /// The class [local] of this scope's component. + ClassName call(String local) => ClassName.scoped(local, this); + + @override + String toString() => 'ClassScope($name)'; +} diff --git a/packages/jaspr_class_scope/lib/src/scoped_css.dart b/packages/jaspr_class_scope/lib/src/scoped_css.dart new file mode 100644 index 00000000..b7f7e82a --- /dev/null +++ b/packages/jaspr_class_scope/lib/src/scoped_css.dart @@ -0,0 +1,17 @@ +/// Marks a component whose scope `jaspr_class_scope_builder` writes. +/// +/// ```dart +/// part 'hero.scopes.dart'; +/// +/// @scopedCss +/// class Hero { +/// static const _class = _$HeroScope; +/// } +/// ``` +const scopedCss = ScopedCss(); + +/// The annotation behind [scopedCss]. +final class ScopedCss { + /// See [scopedCss]. + const ScopedCss(); +} diff --git a/packages/jaspr_class_scope/pubspec.yaml b/packages/jaspr_class_scope/pubspec.yaml new file mode 100644 index 00000000..446c2157 --- /dev/null +++ b/packages/jaspr_class_scope/pubspec.yaml @@ -0,0 +1,21 @@ +name: jaspr_class_scope +version: 0.1.0 +homepage: https://github.com/leancodepl/flutter_corelibrary/tree/master/packages/jaspr_class_scope +repository: https://github.com/leancodepl/flutter_corelibrary +description: >- + Locally scoped CSS class names for Jaspr components: a name written in one + component cannot style another component's markup. + +topics: + - css + - jaspr + - scoped-styles + - styling + - web + +environment: + sdk: '>=3.7.0 <4.0.0' + +dev_dependencies: + leancode_lint: ^27.0.0 + test: ^1.26.0 diff --git a/packages/jaspr_class_scope/test/class_scope_test.dart b/packages/jaspr_class_scope/test/class_scope_test.dart new file mode 100644 index 00000000..a8130a23 --- /dev/null +++ b/packages/jaspr_class_scope/test/class_scope_test.dart @@ -0,0 +1,54 @@ +import 'package:jaspr_class_scope/jaspr_class_scope.dart'; +import 'package:test/test.dart'; + +// The scope a component gets from `jaspr_class_scope_builder`. +const heroScope = ClassScope('Hero', 'lz7xyh'); + +void main() { + group('ClassScope', () { + test('renders a class as local-suffix', () { + expect(heroScope('grid').name, 'grid-lz7xyh'); + expect(heroScope('grid').selector, '.grid-lz7xyh'); + }); + }); + + group('ClassName', () { + test('renders a shared class as written', () { + expect(const ClassName.shared('af-container').name, 'af-container'); + expect(const ClassName.shared('af-container').selector, '.af-container'); + }); + + test('combines classes onto one element', () { + final combined = heroScope('button') + const ClassName.shared('primary'); + + expect(combined.name, 'button-lz7xyh primary'); + expect(combined.selector, '.button-lz7xyh.primary'); + }); + + test('combines more than two classes, left to right', () { + final combined = + const ClassName.shared('a') + + const ClassName.shared('b') + + const ClassName.shared('c'); + + expect(combined.name, 'a b c'); + expect(combined.selector, '.a.b.c'); + }); + + test('is a value: equal when it renders the same', () { + expect(heroScope('grid'), const ClassScope('Hero', 'lz7xyh')('grid')); + expect(heroScope('grid'), isNot(heroScope('list'))); + expect(heroScope('grid'), isNot(const ClassName.shared('grid'))); + // And hashes alike, or a set would hold it twice. + expect({ + heroScope('grid'), + const ClassScope('Hero', 'lz7xyh')('grid'), + }, hasLength(1)); + }); + + test('stringifies to the name it renders', () { + expect('${heroScope('grid')}', 'grid-lz7xyh'); + expect('${const ClassName.shared('js-copy')}', 'js-copy'); + }); + }); +} diff --git a/packages/jaspr_class_scope_builder/.gitignore b/packages/jaspr_class_scope_builder/.gitignore new file mode 100644 index 00000000..eb8f5613 --- /dev/null +++ b/packages/jaspr_class_scope_builder/.gitignore @@ -0,0 +1,7 @@ +# https://dart.dev/guides/libraries/private-files + +.dart_tool/ +build/ +coverage/ +doc/api/ +pubspec.lock diff --git a/packages/jaspr_class_scope_builder/CHANGELOG.md b/packages/jaspr_class_scope_builder/CHANGELOG.md new file mode 100644 index 00000000..951a5a3f --- /dev/null +++ b/packages/jaspr_class_scope_builder/CHANGELOG.md @@ -0,0 +1,3 @@ +## 0.1.0 + +- Initial release. diff --git a/packages/jaspr_class_scope_builder/LICENSE b/packages/jaspr_class_scope_builder/LICENSE new file mode 100644 index 00000000..a53097bc --- /dev/null +++ b/packages/jaspr_class_scope_builder/LICENSE @@ -0,0 +1,201 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright 2026 LeanCode Sp. z o.o. + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/packages/jaspr_class_scope_builder/README.md b/packages/jaspr_class_scope_builder/README.md new file mode 100644 index 00000000..b1b8a680 --- /dev/null +++ b/packages/jaspr_class_scope_builder/README.md @@ -0,0 +1,104 @@ +# jaspr_class_scope_builder + +[![jaspr_class_scope_builder pub.dev badge][pub-badge]][pub-badge-link] +[![jaspr_class_scope_builder continuous integration badge][build-badge]][build-badge-link] + +Build-time generator for [jaspr_class_scope]: gives every Jaspr component +locally scoped CSS class names, so one component's styles cannot reach another. + +## Usage + +```yaml +dependencies: + jaspr_class_scope: ^0.1.0 + +dev_dependencies: + build_runner: ^2.4.0 + jaspr_class_scope_builder: ^0.1.0 +``` + +```dart +import 'package:jaspr_class_scope/jaspr_class_scope.dart'; + +part 'hero.scopes.dart'; + +@scopedCss +class Hero extends StatelessComponent { + static const _class = _$HeroScope; + + static final _grid = _class('grid'); +} +``` + +Run `dart run build_runner build`. It writes `hero.scopes.dart` next to +`hero.dart`: + +```dart +part of 'hero.dart'; + +const _$HeroScope = ClassScope('Hero', '7en7oj'); +``` + +The suffix is derived from the package name, the file path and the class name, +so `_grid` renders as `grid-7en7oj`, and two components with the same name get +different suffixes. Renaming or moving `Hero` changes its suffix. + +Every `@scopedCss` class in the file gets a constant named after it, so +`CardGrid` gives `_$CardGridScope`. A file without the `part` directive is +skipped. + +## The check phase + +Two components can end up with the same suffix, rarely. A second builder checks +for that, so it fails the build instead of a page: + +``` +[SEVERE] jaspr_class_scope_builder:class_scope_check on $package$: +Hero (site|lib/marketing/hero.dart) and Hero (site|lib/components/hero.dart) +both scope to the same suffix. Rename or move one of them. +``` + +It covers the whole build, not just your own package: every package lists its +scopes for the check to read, so a component of a dependency is checked against +yours. Fixing such a pair means renaming or moving your own component, since +the other one is not yours to move. + +--- + +## 🛠️ Maintained by LeanCode +
+ + [LeanCode Logo][leancode-landing] + +
+ +This package is built with 💙 by **[LeanCode][leancode-landing]**. +We are **top-tier experts** focused on Flutter Enterprise solutions. + +### Why LeanCode? + +- **Creators of [Patrol][patrol-landing]** – the next-gen testing framework for Flutter. + +- **Production-Ready** – We use this package in apps with millions of users. +- **Full-Cycle Product Development** – We take your product from scratch to long-term maintenance. + +
+
+ + **Need help with your Flutter project?** + + [**👉 Hire our team**][leancode-estimate] +   •   + [Check our other packages][leancode-packages] + +
+ +[pub-badge]: https://img.shields.io/pub/v/jaspr_class_scope_builder +[pub-badge-link]: https://pub.dev/packages/jaspr_class_scope_builder +[build-badge]: https://img.shields.io/github/actions/workflow/status/leancodepl/flutter_corelibrary/jaspr_class_scope_builder-test.yml?branch=master +[build-badge-link]: https://github.com/leancodepl/flutter_corelibrary/actions/workflows/jaspr_class_scope_builder-test.yml +[jaspr_class_scope]: https://pub.dev/packages/jaspr_class_scope +[leancode-landing]: https://leancode.co/?utm_source=github.com&utm_medium=referral&utm_campaign=jaspr-class-scope-builder +[leancode-estimate]: https://leancode.co/get-estimate?utm_source=github.com&utm_medium=referral&utm_campaign=jaspr-class-scope-builder +[leancode-packages]: https://pub.dev/packages?q=publisher%3Aleancode.co&sort=downloads +[patrol-landing]: https://patrol.leancode.co/?utm_source=github.com&utm_medium=referral&utm_campaign=jaspr-class-scope-builder diff --git a/packages/jaspr_class_scope_builder/analysis_options.yaml b/packages/jaspr_class_scope_builder/analysis_options.yaml new file mode 100644 index 00000000..f3ed8d06 --- /dev/null +++ b/packages/jaspr_class_scope_builder/analysis_options.yaml @@ -0,0 +1,4 @@ +include: package:leancode_lint/analysis_options_package.yaml + +plugins: + leancode_lint: ^27.0.0 diff --git a/packages/jaspr_class_scope_builder/build.yaml b/packages/jaspr_class_scope_builder/build.yaml new file mode 100644 index 00000000..91c2cc19 --- /dev/null +++ b/packages/jaspr_class_scope_builder/build.yaml @@ -0,0 +1,38 @@ +builders: + class_scope: + import: 'package:jaspr_class_scope_builder/jaspr_class_scope_builder.dart' + builder_factories: + - classScopeBuilder + build_extensions: + .dart: + - .scopes.dart + auto_apply: dependents + build_to: source + defaults: + generate_for: + exclude: + - '**.scopes.dart' + + # Every package, not only the dependents: a package that uses + # jaspr_class_scope keeps this builder to itself, in its dev_dependencies. + scopes_manifest: + import: 'package:jaspr_class_scope_builder/jaspr_class_scope_builder.dart' + builder_factories: + - scopesManifestBuilder + build_extensions: + lib/$lib$: + - lib/jaspr_class_scope.scopes.json + auto_apply: all_packages + build_to: cache + + class_scope_check: + import: 'package:jaspr_class_scope_builder/jaspr_class_scope_builder.dart' + builder_factories: + - classScopeCheckBuilder + build_extensions: + $package$: + - jaspr_class_scope.check + auto_apply: dependents + build_to: cache + required_inputs: + - jaspr_class_scope.scopes.json diff --git a/packages/jaspr_class_scope_builder/example/README.md b/packages/jaspr_class_scope_builder/example/README.md new file mode 100644 index 00000000..5b0023b5 --- /dev/null +++ b/packages/jaspr_class_scope_builder/example/README.md @@ -0,0 +1,26 @@ +# Example + +```dart +// lib/components/hero.dart +import 'package:jaspr_class_scope/jaspr_class_scope.dart'; + +part 'hero.scopes.dart'; + +@scopedCss +class Hero extends StatelessComponent { + static const _class = _$HeroScope; + + static final _grid = _class('grid'); + + @css + static List get styles => [ + css(_grid.selector).styles(display: Display.grid), + ]; + + @override + Component build(BuildContext context) => div(classes: _grid.name, []); +} +``` + +`dart run build_runner build` writes the scope next to it, and `_grid` renders +as `grid-lz7xyh`. See the [package readme](../README.md). diff --git a/packages/jaspr_class_scope_builder/lib/jaspr_class_scope_builder.dart b/packages/jaspr_class_scope_builder/lib/jaspr_class_scope_builder.dart new file mode 100644 index 00000000..cd855f1a --- /dev/null +++ b/packages/jaspr_class_scope_builder/lib/jaspr_class_scope_builder.dart @@ -0,0 +1,22 @@ +/// Build-time class-name scopes for `jaspr_class_scope`. +library; + +import 'package:build/build.dart'; +import 'package:jaspr_class_scope_builder/src/class_scope_builder.dart'; +import 'package:jaspr_class_scope_builder/src/class_scope_check_builder.dart'; +import 'package:jaspr_class_scope_builder/src/scopes_manifest_builder.dart'; + +export 'src/class_scope_builder.dart'; +export 'src/class_scope_check_builder.dart'; +export 'src/scopes_manifest_builder.dart'; + +/// Writes the scope of every component annotated with `@scopedCss`. +Builder classScopeBuilder(BuilderOptions options) => const ClassScopeBuilder(); + +/// Lists a package's scopes for the check to read. +Builder scopesManifestBuilder(BuilderOptions options) => + const ScopesManifestBuilder(); + +/// Fails the build when two of those scopes end up the same. +Builder classScopeCheckBuilder(BuilderOptions options) => + const ClassScopeCheckBuilder(); diff --git a/packages/jaspr_class_scope_builder/lib/src/class_scope_builder.dart b/packages/jaspr_class_scope_builder/lib/src/class_scope_builder.dart new file mode 100644 index 00000000..6327db14 --- /dev/null +++ b/packages/jaspr_class_scope_builder/lib/src/class_scope_builder.dart @@ -0,0 +1,44 @@ +import 'package:build/build.dart'; +import 'package:jaspr_class_scope_builder/src/scoped_components.dart'; + +/// Writes the scope of every component annotated with `@scopedCss` into a +/// `.scopes.dart` part file beside it. +final class ClassScopeBuilder implements Builder { + /// The builder `build.yaml` instantiates. + const ClassScopeBuilder(); + + @override + Map> get buildExtensions => const { + '.dart': [scopesExtension], + }; + + @override + Future build(BuildStep buildStep) async { + final input = buildStep.inputId; + final components = await scopedComponentsIn(buildStep, input); + if (components.isEmpty) { + return; + } + + final scopes = + StringBuffer() + ..writeln('// GENERATED CODE - DO NOT MODIFY BY HAND') + ..writeln('// Written by jaspr_class_scope_builder.') + // A long component name makes a line the formatter would rewrite. + ..writeln('// dart format off') + ..writeln() + ..writeln("part of '${input.pathSegments.last}';"); + + for (final component in components) { + scopes + ..writeln() + ..writeln("/// The scope of [$component]'s class names.") + ..writeln(renderScope(input, component)); + } + + await buildStep.writeAsString( + input.changeExtension(scopesExtension), + scopes.toString(), + ); + } +} diff --git a/packages/jaspr_class_scope_builder/lib/src/class_scope_check_builder.dart b/packages/jaspr_class_scope_builder/lib/src/class_scope_check_builder.dart new file mode 100644 index 00000000..743f85a3 --- /dev/null +++ b/packages/jaspr_class_scope_builder/lib/src/class_scope_check_builder.dart @@ -0,0 +1,61 @@ +import 'dart:convert'; + +import 'package:build/build.dart'; +import 'package:jaspr_class_scope_builder/src/scoped_components.dart'; + +/// Fails the build when two components end up with the same suffix, which +/// would put their class names in one scope. +/// +/// Reads the manifest of every package in the build, so it sees the components +/// of a dependency as well as this package's own. +final class ClassScopeCheckBuilder implements Builder { + /// The builder `build.yaml` instantiates. + const ClassScopeCheckBuilder(); + + @override + Map> get buildExtensions => const { + r'$package$': ['jaspr_class_scope.check'], + }; + + @override + Future build(BuildStep buildStep) async { + // Every package in the build, this one included. Sorted, so that a clash + // is reported the same way on every machine. + final packages = + (await buildStep.packageConfig).packages.map((it) => it.name).toList() + ..sort(); + + final owners = {}; + final scopes = StringBuffer(); + + for (final package in packages) { + final manifest = AssetId(package, manifestAsset); + if (!await buildStep.canRead(manifest)) { + continue; + } + + final listed = + (jsonDecode(await buildStep.readAsString(manifest)) as List) + .cast>(); + + for (final {'suffix': suffix as String, 'owner': owner as String} + in listed) { + final taken = owners[suffix]; + if (taken != null) { + throw StateError( + '$owner and $taken both scope to the same suffix. Rename or move ' + 'one of them.', + ); + } + owners[suffix] = owner; + + scopes.writeln('$suffix $owner'); + } + } + + await buildStep.writeAsString( + buildStep.allowedOutputs.single, + scopes.toString(), + ); + } +} diff --git a/packages/jaspr_class_scope_builder/lib/src/scoped_components.dart b/packages/jaspr_class_scope_builder/lib/src/scoped_components.dart new file mode 100644 index 00000000..505c7437 --- /dev/null +++ b/packages/jaspr_class_scope_builder/lib/src/scoped_components.dart @@ -0,0 +1,70 @@ +import 'package:analyzer/dart/analysis/utilities.dart'; +import 'package:analyzer/dart/ast/ast.dart'; +import 'package:analyzer/dart/element/element.dart'; +import 'package:build/build.dart'; +import 'package:jaspr_class_scope_builder/src/suffix.dart'; + +/// The extension of the part file the scopes are written to. +const scopesExtension = '.scopes.dart'; + +/// Where a package lists its scopes for the check. A build cache asset; the +/// path begins with `lib/` because that is all a build sees of a dependency. +const manifestAsset = 'lib/jaspr_class_scope.scopes.json'; + +/// The components [asset] declares with `@scopedCss`. +Future> scopedComponentsIn( + BuildStep buildStep, + AssetId asset, +) async { + final source = await buildStep.readAsString(asset); + + // Resolving is the expensive part. + if (!source.contains('scopedCss') && !source.contains('ScopedCss')) { + return const []; + } + if (!_declaresScopes( + parseString(content: source, throwIfDiagnostics: false).unit, + )) { + return const []; + } + + // Resolving a part file as a library throws. + if (!await buildStep.resolver.isLibrary(asset)) { + return const []; + } + + final library = await buildStep.resolver.libraryFor(asset); + + return [ + for (final component in library.classes) + if (component.name case final name? when _isScopedCss(component)) name, + ]; +} + +/// What a component's suffix is hashed from: where it is declared. +String scopeSourceOf(AssetId asset, String component) => + '${asset.package}|${asset.path}#$component'; + +/// How a clash names [component] in [asset]. +String scopeOwnerOf(AssetId asset, String component) => + '$component (${asset.package}|${asset.path})'; + +/// The scope of [component] in [asset], as the part file spells it. +String renderScope(AssetId asset, String component) { + final suffix = classScopeSuffix(scopeSourceOf(asset, component)); + + return "const _\$${component}Scope = ClassScope('$component', '$suffix');"; +} + +bool _declaresScopes(CompilationUnit unit) => unit.directives + .whereType() + .any((it) => it.uri.stringValue?.endsWith(scopesExtension) ?? false); + +bool _isScopedCss(ClassElement component) => + component.metadata.annotations.any((annotation) { + final uri = annotation.element?.library?.uri; + + return uri != null && + uri.scheme == 'package' && + uri.pathSegments.first == 'jaspr_class_scope'; + }); diff --git a/packages/jaspr_class_scope_builder/lib/src/scopes_manifest_builder.dart b/packages/jaspr_class_scope_builder/lib/src/scopes_manifest_builder.dart new file mode 100644 index 00000000..272b5db0 --- /dev/null +++ b/packages/jaspr_class_scope_builder/lib/src/scopes_manifest_builder.dart @@ -0,0 +1,45 @@ +import 'dart:convert'; + +import 'package:build/build.dart'; +import 'package:glob/glob.dart'; +import 'package:jaspr_class_scope_builder/src/scoped_components.dart'; +import 'package:jaspr_class_scope_builder/src/suffix.dart'; + +/// Lists this package's scopes for the check, which can glob only the package +/// it runs in. +final class ScopesManifestBuilder implements Builder { + /// The builder `build.yaml` instantiates. + const ScopesManifestBuilder(); + + @override + Map> get buildExtensions => const { + r'lib/$lib$': [manifestAsset], + }; + + @override + Future build(BuildStep buildStep) async { + // Sorted, so that a clash is reported the same way on every machine. + final sources = + await buildStep.findAssets(Glob('**.dart')).toList() + ..sort(); + + final scopes = [ + for (final asset in sources) + if (!asset.path.endsWith(scopesExtension)) + for (final component in await scopedComponentsIn(buildStep, asset)) + { + 'suffix': classScopeSuffix(scopeSourceOf(asset, component)), + 'owner': scopeOwnerOf(asset, component), + }, + ]; + + if (scopes.isEmpty) { + return; + } + + await buildStep.writeAsString( + buildStep.allowedOutputs.single, + jsonEncode(scopes), + ); + } +} diff --git a/packages/jaspr_class_scope_builder/lib/src/suffix.dart b/packages/jaspr_class_scope_builder/lib/src/suffix.dart new file mode 100644 index 00000000..d00e838d --- /dev/null +++ b/packages/jaspr_class_scope_builder/lib/src/suffix.dart @@ -0,0 +1,14 @@ +import 'dart:convert'; + +import 'package:crypto/crypto.dart'; + +/// Six base-36 digits of the md5 of [source], the suffix a scope's classes +/// end with. What `md5sum` says, taken modulo 36^6. +String classScopeSuffix(String source) { + final digest = md5.convert(utf8.encode(source)).bytes; + final value = digest[0] << 24 | digest[1] << 16 | digest[2] << 8 | digest[3]; + + return (value % _space).toRadixString(36).padLeft(6, '0'); +} + +const _space = 36 * 36 * 36 * 36 * 36 * 36; diff --git a/packages/jaspr_class_scope_builder/pubspec.yaml b/packages/jaspr_class_scope_builder/pubspec.yaml new file mode 100644 index 00000000..eec83d2a --- /dev/null +++ b/packages/jaspr_class_scope_builder/pubspec.yaml @@ -0,0 +1,29 @@ +name: jaspr_class_scope_builder +version: 0.1.0 +homepage: https://github.com/leancodepl/flutter_corelibrary/tree/master/packages/jaspr_class_scope_builder +repository: https://github.com/leancodepl/flutter_corelibrary +description: >- + Build-time generator for jaspr_class_scope: gives every Jaspr component + locally scoped CSS class names, so one component's styles cannot reach + another. + +topics: + - build-runner + - codegen + - css + - jaspr + - styling + +environment: + sdk: '>=3.7.0 <4.0.0' + +dependencies: + analyzer: '>=12.1.0 <15.0.0' + build: ^4.0.0 + crypto: ^3.0.0 + glob: ^2.1.0 + +dev_dependencies: + build_test: ^3.0.0 + leancode_lint: ^27.0.0 + test: ^1.26.0 diff --git a/packages/jaspr_class_scope_builder/test/class_scope_builder_test.dart b/packages/jaspr_class_scope_builder/test/class_scope_builder_test.dart new file mode 100644 index 00000000..e5564102 --- /dev/null +++ b/packages/jaspr_class_scope_builder/test/class_scope_builder_test.dart @@ -0,0 +1,172 @@ +import 'package:build/build.dart'; +import 'package:build_test/build_test.dart'; +import 'package:jaspr_class_scope_builder/jaspr_class_scope_builder.dart'; +import 'package:test/test.dart'; + +const _hero = r''' +import 'package:jaspr_class_scope/jaspr_class_scope.dart'; + +part 'hero.scopes.dart'; + +@scopedCss +class Hero { + static const _class = _$HeroScope; +} +'''; + +// The annotation, as the package under test's consumers import it. +const _package = { + 'jaspr_class_scope|lib/jaspr_class_scope.dart': ''' +final class ScopedCss { + const ScopedCss(); +} + +const scopedCss = ScopedCss(); +''', +}; + +Future _build(Map sources) async { + final result = await testBuilder( + const ClassScopeBuilder(), + {..._package, ...sources}, + rootPackage: 'site', + flattenOutput: true, + ); + + expect(result.errors, isEmpty); + + return result.readerWriter; +} + +String _scopesIn(TestReaderWriter written, String path) => + written.testing.readString(AssetId('site', path)); + +void main() { + group('ClassScopeBuilder', () { + test('writes a scope for an annotated component', () async { + final written = await _build({'site|lib/components/hero.dart': _hero}); + + final output = _scopesIn(written, 'lib/components/hero.scopes.dart'); + + expect(output, contains('// dart format off')); + expect(output, contains("part of 'hero.dart';")); + expect( + output, + contains(r"const _$HeroScope = ClassScope('Hero', 'lz7xyh');"), + ); + }); + + test('gives two components of the same name different suffixes', () async { + final written = await _build({ + 'site|lib/components/hero.dart': _hero, + 'site|lib/marketing/hero.dart': _hero, + }); + + expect( + _scopesIn(written, 'lib/components/hero.scopes.dart'), + contains("ClassScope('Hero', 'lz7xyh')"), + ); + expect( + _scopesIn(written, 'lib/marketing/hero.scopes.dart'), + contains("ClassScope('Hero', 'vldqky')"), + ); + }); + + test('writes nothing for a file without annotated components', () async { + final written = await _build({'site|lib/plain.dart': 'class Plain {}'}); + + expect( + written.testing.assetsWritten, + isNot(contains(AssetId('site', 'lib/plain.scopes.dart'))), + ); + }); + + test('writes nothing for a file that declares no part', () async { + final written = await _build({ + 'site|lib/foreign.dart': ''' +import 'package:jaspr_class_scope/jaspr_class_scope.dart'; + +@scopedCss +class Foreign {} +''', + }); + + expect( + written.testing.assetsWritten, + isNot(contains(AssetId('site', 'lib/foreign.scopes.dart'))), + ); + }); + + test('skips a same-named annotation from another package', () async { + final written = await _build({ + 'elsewhere|lib/elsewhere.dart': ''' +final class ScopedCss { + const ScopedCss(); +} + +const scopedCss = ScopedCss(); +''', + 'site|lib/hero.dart': ''' +import 'package:elsewhere/elsewhere.dart'; + +part 'hero.scopes.dart'; + +@scopedCss +class Hero {} +''', + }); + + expect( + written.testing.assetsWritten, + isNot(contains(AssetId('site', 'lib/hero.scopes.dart'))), + ); + }); + + test('finds the annotation through a re-export', () async { + final written = await _build({ + 'site|lib/styles.dart': + "export 'package:jaspr_class_scope/jaspr_class_scope.dart';", + 'site|lib/hero.dart': ''' +import 'styles.dart'; + +part 'hero.scopes.dart'; + +@scopedCss +class Hero {} +''', + }); + + expect( + _scopesIn(written, 'lib/hero.scopes.dart'), + contains(r"const _$HeroScope = ClassScope('Hero', "), + ); + }); + + test('writes one scope per annotated component', () async { + final written = await _build({ + 'site|lib/cards.dart': ''' +import 'package:jaspr_class_scope/jaspr_class_scope.dart'; + +part 'cards.scopes.dart'; + +@scopedCss +class Card {} + +@ScopedCss() +final class CardGrid {} + +class NotAComponent {} +''', + }); + + final output = _scopesIn(written, 'lib/cards.scopes.dart'); + + expect(output, contains(r"const _$CardScope = ClassScope('Card'")); + expect( + output, + contains(r"const _$CardGridScope = ClassScope('CardGrid'"), + ); + expect(output, isNot(contains('NotAComponent'))); + }); + }); +} diff --git a/packages/jaspr_class_scope_builder/test/class_scope_check_builder_test.dart b/packages/jaspr_class_scope_builder/test/class_scope_check_builder_test.dart new file mode 100644 index 00000000..be0f6855 --- /dev/null +++ b/packages/jaspr_class_scope_builder/test/class_scope_check_builder_test.dart @@ -0,0 +1,92 @@ +import 'package:build/build.dart'; +import 'package:build_test/build_test.dart'; +import 'package:jaspr_class_scope_builder/jaspr_class_scope_builder.dart'; +import 'package:test/test.dart'; + +// The annotation, as the package under test's consumers import it. +const _package = { + 'jaspr_class_scope|lib/jaspr_class_scope.dart': ''' +final class ScopedCss { + const ScopedCss(); +} + +const scopedCss = ScopedCss(); +''', +}; + +String _component(String name) => """ +import 'package:jaspr_class_scope/jaspr_class_scope.dart'; + +part '${name.toLowerCase()}.scopes.dart'; + +@scopedCss +class $name {} +"""; + +/// The manifests and the check, as `build.yaml` orders them. +Future _check(Map sources) => testBuilders( + [const ScopesManifestBuilder(), const ClassScopeCheckBuilder()], + { + r'site|$package$': '', + r'site|lib/$lib$': '', + r'other|lib/$lib$': '', + ..._package, + ...sources, + }, + rootPackage: 'site', + flattenOutput: true, +); + +void main() { + group('ClassScopeCheckBuilder', () { + test('passes when every component has its own suffix', () async { + final result = await _check({ + 'site|lib/hero.dart': _component('Hero'), + 'site|lib/card.dart': _component('Card'), + 'other|lib/hero.dart': _component('Hero'), + }); + + expect(result.succeeded, isTrue); + expect(result.errors, isEmpty); + expect( + result.readerWriter.testing.readString( + AssetId('site', 'jaspr_class_scope.check'), + ), + allOf( + contains('Hero (site|lib/hero.dart)'), + contains('Hero (other|lib/hero.dart)'), + ), + ); + }); + + // The one pair of short paths that collides, found by searching the hash. + test('fails the build when two components share a suffix', () async { + final result = await _check({ + 'site|lib/c22156.dart': _component('C22156'), + 'site|lib/c59137.dart': _component('C59137'), + }); + + expect(result.succeeded, isFalse); + expect( + result.errors.join('\n'), + allOf(contains('lib/c22156.dart'), contains('lib/c59137.dart')), + ); + }); + + test('sees a component of another package', () async { + final result = await _check({ + 'site|lib/c37213.dart': _component('C37213'), + 'other|lib/c26278.dart': _component('C26278'), + }); + + expect(result.succeeded, isFalse); + expect( + result.errors.join('\n'), + allOf( + contains('C37213 (site|lib/c37213.dart)'), + contains('C26278 (other|lib/c26278.dart)'), + ), + ); + }); + }); +} diff --git a/packages/jaspr_class_scope_builder/test/scopes_manifest_builder_test.dart b/packages/jaspr_class_scope_builder/test/scopes_manifest_builder_test.dart new file mode 100644 index 00000000..6b8b9dfc --- /dev/null +++ b/packages/jaspr_class_scope_builder/test/scopes_manifest_builder_test.dart @@ -0,0 +1,79 @@ +import 'dart:convert'; + +import 'package:build/build.dart'; +import 'package:build_test/build_test.dart'; +import 'package:jaspr_class_scope_builder/jaspr_class_scope_builder.dart'; +import 'package:test/test.dart'; + +// The annotation, as the package under test's consumers import it. +const _package = { + 'jaspr_class_scope|lib/jaspr_class_scope.dart': ''' +final class ScopedCss { + const ScopedCss(); +} + +const scopedCss = ScopedCss(); +''', +}; + +String _component(String name) => """ +import 'package:jaspr_class_scope/jaspr_class_scope.dart'; + +part '${name.toLowerCase()}.scopes.dart'; + +@scopedCss +class $name {} +"""; + +Future?> _manifestOf(Map sources) async { + final result = await testBuilder( + const ScopesManifestBuilder(), + {r'site|lib/$lib$': '', ..._package, ...sources}, + rootPackage: 'site', + flattenOutput: true, + ); + + expect(result.errors, isEmpty); + + final manifest = AssetId('site', 'lib/jaspr_class_scope.scopes.json'); + if (!result.readerWriter.testing.assetsWritten.contains(manifest)) { + return null; + } + + return jsonDecode(result.readerWriter.testing.readString(manifest)) + as List; +} + +void main() { + group('ScopesManifestBuilder', () { + test('lists every annotated component', () async { + final manifest = await _manifestOf({ + 'site|lib/hero.dart': _component('Hero'), + 'site|lib/plain.dart': 'class Plain {}', + 'site|lib/card.dart': _component('Card'), + }); + + expect(manifest, [ + {'suffix': 'pgv5zb', 'owner': 'Card (site|lib/card.dart)'}, + {'suffix': 'jfw65v', 'owner': 'Hero (site|lib/hero.dart)'}, + ]); + }); + + test('reads the sources, not the generated part files', () async { + final manifest = await _manifestOf({ + 'site|lib/hero.dart': _component('Hero'), + // Whatever stands in a part file is not a component of its own. + 'site|lib/hero.scopes.dart': _component('Ghost'), + }); + + expect(manifest, hasLength(1)); + }); + + test('writes nothing for a package without annotated components', () async { + expect( + await _manifestOf({'site|lib/plain.dart': 'class Plain {}'}), + isNull, + ); + }); + }); +} diff --git a/packages/jaspr_class_scope_builder/test/suffix_test.dart b/packages/jaspr_class_scope_builder/test/suffix_test.dart new file mode 100644 index 00000000..d60ab158 --- /dev/null +++ b/packages/jaspr_class_scope_builder/test/suffix_test.dart @@ -0,0 +1,29 @@ +import 'package:jaspr_class_scope_builder/src/suffix.dart'; +import 'package:test/test.dart'; + +void main() { + group('classScopeSuffix', () { + // A hash below 36^5, which is five digits until it is padded. + test('is six base-36 digits', () { + expect( + classScopeSuffix('site|lib/hero6.dart#Hero'), + matches(RegExp(r'^0[0-9a-z]{5}$')), + ); + }); + + // The first four bytes of the md5, so that `md5sum` says the same thing: + // '' digests to d41d8cd9…, 'abc' to 90015098…, both taken mod 36^6. + test('is the md5', () { + expect(classScopeSuffix(''), 'murffd'); // 0xd41d8cd9 + expect(classScopeSuffix('abc'), '3yfdlk'); // 0x90015098 + }); + + // The suffix ends up in the rendered page and in the stylesheet built from + // it, so it may not move between versions of this package. + test('hashes to a stable suffix', () { + expect(classScopeSuffix('site|lib/components/hero.dart#Hero'), 'lz7xyh'); + // Hashed as UTF-8 bytes. + expect(classScopeSuffix('ą'), 'oaa6rb'); + }); + }); +}