diff --git a/.pubignore b/.pubignore new file mode 100644 index 00000000..8896c6c5 --- /dev/null +++ b/.pubignore @@ -0,0 +1,11 @@ +assets/*.png +assets/css/ +build/ +.dart_tool/ +.idea/ +.github/ +SINT_GETX_COMPARISON.docx +SINT_GETX_COMPARISON.md +SINT_GETX_EVOLUTION_BRIEF.md +_config.yml +index.md diff --git a/CHANGELOG.md b/CHANGELOG.md index 979c8f46..ca7d7bd7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,44 @@ +## [1.2.1] - 2026-02-28 + +- **README images**: Switched to absolute GitHub raw URLs so images render correctly on pub.dev (`.pubignore` excludes PNGs from the package). + +--- + +## [1.2.0] - 2026-02-28 + +RESTful Navigation & i18n URL Routing. + +133 lines of new code. Zero new dependencies. Pillars N and T upgraded. + +### Pillar N (Navigation) + +- **RESTful Route Parameters** (Spring Boot-inspired API): + - `Sint.routeParam` — Primary path parameter value. For route `/book/:bookId` navigated as `/book/abc123`, returns `'abc123'`. Equivalent to Spring Boot's `@PathVariable`. + - `Sint.pathParam('bookId')` — Named path parameter. Equivalent to `@PathVariable("bookId")`. + - `Sint.queryParam('page')` — Query parameter from URL. Equivalent to `@RequestParam`. + - `Sint.queryParamOrDefault('sort', 'recent')` — Query parameter with fallback. Equivalent to `@RequestParam(defaultValue = "recent")`. + - Full test mode support via `SintTestMode`. +- **`translateEndpoints` flag**: New parameter on `SintMaterialApp` and `ConfigData` that enables automatic i18n URL routing. When `true`, SINT builds a `PathTranslator` from registered translations and routes. +- **`setUrlStrategy()` resilience**: Wrapped in try-catch to handle "URL strategy already set" when the Flutter engine is already initialized — prevents web startup crashes on hot restart. + +### Pillar T (Translation) + +- **`PathTranslator`** — New class for internationalized URL routing: + - `canonicalizePath()` — Converts localized URLs to canonical English before route matching. e.g. `/libro/abc123` → `/book/abc123`. + - `localizePath()` — Converts canonical URLs to the current locale for the browser URL bar. e.g. `/book/abc123` → `/libro/abc123` (ES) or `/livre/abc123` (FR). + - `extractSegments()` — Automatically extracts static route segments from registered `SintPage` names (skips `:param` segments). + - Built-in diacritics normalization (`Publicación` → `publicacion`) for clean URLs. + - Zero-config: built automatically from existing app translations when `translateEndpoints: true`. No external localization file needed. +- **`Sint.pathTranslator`** — Getter/setter on the `SintInterface` to access the URL translator. Stored in `IntlHost` and cleaned up on `SintRoot.onClose()`. +- **`SintInformationParser` integration** — Automatic canonicalization on `parseRouteInformation()` and localization on `restoreRouteInformation()`. Browser URL bar shows localized paths; internal routing uses canonical English. + +### Housekeeping + +- **Example app**: Added `example/main.dart` demonstrating all four SINT pillars (State, Injection, Navigation, Translation) in a counter app. Targets 160/160 pub points. +- **TickerMode.of deprecation**: Suppressed for cross-SDK compatibility in `RxTickerProviderMixin`. + +--- + ## [1.1.0] - 2026-02-26 The Four Pillars Evolve — Workers, Pattern Matching, Async DI & Web-Safe Navigation. diff --git a/README.md b/README.md index 2f64464e..bbb1e5d3 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,9 @@ # SINT +

+ SINT Framework +

+ **State, Injection, Navigation, Translation — The Four Pillars of High-Fidelity Flutter Infrastructure.** [![pub package](https://img.shields.io/pub/v/sint.svg?label=sint&color=blue)](https://pub.dev/packages/sint) @@ -26,6 +30,7 @@ --- - [About SINT](#about-sint) +- [What's New in 1.2.0](#whats-new-in-120) - [What's New in 1.1.0](#whats-new-in-110) - [Installing](#installing) - [The Four Pillars](#the-four-pillars) @@ -33,6 +38,7 @@ - [Injection (I)](#injection-i) - [Navigation (N)](#navigation-n) - [Translation (T)](#translation-t) +- [Flutter Web & Deep Links](#flutter-web--deep-links) - [Counter App with SINT](#counter-app-with-sint) - [Migration from GetX](#migration-from-getx) - [Origin & Philosophy](#origin--philosophy) @@ -47,8 +53,8 @@ SINT is an architectural evolution of GetX (v5.0.0-rc), built as a focused frame |---|---| | **S** — State Management | `SintController`, `SintBuilder`, `Obx`, `.obs`, Rx types, Workers, `SintStatus`, `SintListener` | | **I** — Injection | `Sint.put`, `Sint.find`, `Sint.lazyPut`, `Sint.putAsync`, Bindings, SmartManagement | -| **N** — Navigation | `SintPage`, `Sint.toNamed`, `Sint.toInitial`, middleware, `SintMaterialApp`, web-safe `back()` | -| **T** — Translation | `.tr` extension, `Translations` class, locale management, `loadTranslations` | +| **N** — Navigation | `SintPage`, `Sint.toNamed`, `Sint.toInitial`, `routeParam`, `pathParam`, `queryParam`, middleware, `SintMaterialApp`, `SintSnackBarStyle`, web-safe `back()` | +| **T** — Translation | `.tr` extension, `Translations` class, locale management, `loadTranslations`, `PathTranslator`, `translateEndpoints` | Everything outside these four pillars has been removed: no HTTP client, no animations, no string validators, no generic utilities. The result is **37.7% less code** than GetX — 12,849 LOC vs 20,615 LOC. @@ -60,6 +66,109 @@ Everything outside these four pillars has been removed: no HTTP client, no anima --- +## What's New in 1.2.0 + +**Focus: Flutter Web, Deep Links & i18n URL Routing — without breaking mobile.** + +### RESTful Route Parameters + +Spring Boot-inspired parameter extraction that works identically on mobile and web: + +```dart +// Define routes with path parameters (same as before) +SintPage(name: '/book/:bookId', page: () => BookDetail()), +SintPage(name: '/shop/product/:productId', page: () => ProductPage()), + +// Navigate (works on all platforms) +Sint.toNamed('/book/abc123'); +Sint.toNamed('/shop/product/42?color=red&size=lg'); + +// Extract parameters — clean API, no manual parsing +String? bookId = Sint.routeParam; // 'abc123' +String? productId = Sint.pathParam('productId'); // '42' +String? color = Sint.queryParam('color'); // 'red' +String size = Sint.queryParamOrDefault('size', 'm'); // 'lg' +``` + +| Method | Equivalent (Spring Boot) | Description | +|--------|--------------------------|-------------| +| `Sint.routeParam` | `@PathVariable` | First path parameter value | +| `Sint.pathParam('id')` | `@PathVariable("id")` | Named path parameter | +| `Sint.queryParam('q')` | `@RequestParam` | Query string parameter | +| `Sint.queryParamOrDefault('sort', 'asc')` | `@RequestParam(defaultValue)` | Query with fallback | + +All four methods support `SintTestMode` for unit testing without a running app. + +### i18n URL Routing (translateEndpoints) + +Localized URLs in the browser address bar — zero configuration beyond what you already have: + +```dart +SintMaterialApp( + translateEndpoints: true, // Enable URL localization + translationsKeys: AppTranslations.keys, + locale: Locale('es'), + sintPages: [ + SintPage(name: '/book/:bookId', page: () => BookDetail()), + SintPage(name: '/event/:eventId', page: () => EventDetail()), + ], +) +``` + +Your existing translations automatically power the URL routing: + +```dart +// In your translations file — no extra config needed +'es': { 'book': 'libro', 'event': 'evento', ... } +'fr': { 'book': 'livre', 'event': 'evenement', ... } +'de': { 'book': 'buch', 'event': 'veranstaltung', ... } +``` + +Result: + +| Locale | Browser URL | Internal Route | +|--------|-------------|----------------| +| EN | `/book/abc123` | `/book/abc123` | +| ES | `/libro/abc123` | `/book/abc123` | +| FR | `/livre/abc123` | `/book/abc123` | +| DE | `/buch/abc123` | `/book/abc123` | + +**How it works:** + +1. `PathTranslator` is built automatically from your registered routes + translations +2. Incoming URLs are canonicalized before route matching (`/libro/x` → `/book/x`) +3. Outgoing URLs are localized for the browser bar (`/book/x` → `/libro/x`) +4. Diacritics are normalized automatically (`Publicación` → `publicacion`) +5. On mobile, `translateEndpoints` has zero overhead — path translation only activates for web URL parsing + +### Global Snackbar Theming + +Define snackbar appearance once, apply everywhere: + +```dart +SintMaterialApp( + snackBarStyle: SintSnackBarStyle( + backgroundColor: Colors.grey[900], + colorText: Colors.white, + borderRadius: 12, + margin: EdgeInsets.all(16), + snackPosition: SnackPosition.bottom, + duration: Duration(seconds: 3), + ), +) + +// All snackbar calls inherit the global style +Sint.snackbar('Title', 'Message'); +// Call-site params still override when needed +Sint.snackbar('Error', 'Failed', backgroundColor: Colors.red); +``` + +Three-level cascade: **call-site > global style > hardcoded defaults**. + +See [CHANGELOG.md](CHANGELOG.md) for the full list of changes. + +--- + ## What's New in 1.1.0 ### Reactive Workers @@ -146,7 +255,7 @@ Add SINT to your `pubspec.yaml`: ```yaml dependencies: - sint: ^1.1.0 + sint: ^1.2.0 ``` Import it: @@ -180,6 +289,10 @@ SINT is built for speed. Every pillar is audited against the Open Neom Standard. ## The Four Pillars +

+ SINT — The Four Pillars +

+ ### State Management (S) Two approaches: **Reactive** (`.obs` + `Obx`) and **Simple** (`SintBuilder`). @@ -233,21 +346,33 @@ final controller = Sint.find(); ### Navigation (N) -Route management without context: +Route management without context — optimized for web deep links and mobile alike: ```dart SintMaterialApp( initialRoute: '/', + translateEndpoints: true, // i18n URLs (web) + snackBarStyle: SintSnackBarStyle(...), // Global theming sintPages: [ SintPage(name: '/', page: () => Home()), - SintPage(name: '/details', page: () => Details()), + SintPage(name: '/book/:bookId', page: () => BookDetail()), + SintPage(name: '/search', page: () => Search()), ], ) -Sint.toNamed('/details'); +// Navigation +Sint.toNamed('/book/abc123?ref=home'); Sint.back(); // Web-safe Sint.toInitial(); // Hard reset to home Sint.toInitial(keep: {AuthController}); // Keep specific controllers + +// RESTful parameter extraction +String? id = Sint.routeParam; // 'abc123' +String? id = Sint.pathParam('bookId'); // 'abc123' +String? ref = Sint.queryParam('ref'); // 'home' +String sort = Sint.queryParamOrDefault('sort', 'a'); // 'a' (default) + +// Snackbar with global style Sint.snackbar('Title', 'Message'); ``` @@ -255,7 +380,7 @@ Sint.snackbar('Title', 'Message'); ### Translation (T) -Internationalization with `.tr`: +Internationalization with `.tr` — now powers URL routing too: ```dart Text('hello'.tr); @@ -267,12 +392,76 @@ await Sint.loadTranslations(() async { final json = await rootBundle.loadString('assets/i18n/shop.json'); return {'es': Map.from(jsonDecode(json))}; }); + +// URL path translation (automatic when translateEndpoints: true) +// Your translation keys double as URL segment mappings: +// 'book' → 'libro' (ES), 'livre' (FR), 'buch' (DE) +// +// PathTranslator handles: +// canonicalizePath('/libro/abc') → '/book/abc' (incoming) +// localizePath('/book/abc', 'es') → '/libro/abc' (outgoing) ``` [Full documentation](documentation/en_US/translation_management.md) --- +## Flutter Web & Deep Links + +SINT is designed with a **web-first, mobile-safe** philosophy. Every feature works identically across platforms, but web gets extra optimizations: + +| Feature | Web Behavior | Mobile Behavior | +|---------|-------------|-----------------| +| `Sint.back()` | No-op if no internal history (browser arrows handle it) | Standard `Navigator.pop()` | +| `Sint.routeParam` | Extracted from browser URL path | Extracted from route arguments | +| `Sint.queryParam()` | Extracted from URL query string `?key=value` | Extracted from route arguments | +| `translateEndpoints` | Localizes browser URL bar + canonicalizes incoming URLs | No overhead — flag is ignored | +| `Sint.showBackButton` | `false` (browser has native arrows) | `true` | +| Default transition | `Transition.fade` (GPU-light for web canvas) | Platform default (Cupertino/Material) | +| Scroll behavior | Drag enabled for touch, mouse, and trackpad | Platform default | +| `SintSnackBarStyle` | Same styling across web and mobile | Same styling across web and mobile | + +### Deep Link Example (Web + Mobile) + +```dart +// 1. Define routes with parameters +SintMaterialApp( + initialRoute: '/', + translateEndpoints: true, + translationsKeys: AppTranslations.keys, + locale: Locale('es'), + sintPages: [ + SintPage(name: '/', page: () => HomePage()), + SintPage(name: '/book/:bookId', page: () => BookDetail()), + SintPage(name: '/profile/:userId', page: () => ProfilePage()), + ], +) + +// 2. In your controller — same code works everywhere +class BookDetailController extends SintController { + late final String bookId; + + @override + void onInit() { + super.onInit(); + // Works from: browser URL, deep link, or Sint.toNamed() + bookId = Sint.routeParam ?? ''; + loadBook(bookId); + } +} +``` + +**On web:** User visits `https://myapp.com/libro/abc123` → +SINT canonicalizes to `/book/abc123` → routes to `BookDetail` → +`Sint.routeParam` returns `'abc123'` → browser shows `/libro/abc123`. + +**On mobile:** `Sint.toNamed('/book/abc123')` → +routes to `BookDetail` → `Sint.routeParam` returns `'abc123'`. + +**Same controller. Same routes. Same parameters. Zero platform checks.** + +--- + ## Counter App with SINT ```dart diff --git a/assets/SINT - Framework - 2026.png b/assets/SINT - Framework - 2026.png new file mode 100644 index 00000000..6dca6eb2 Binary files /dev/null and b/assets/SINT - Framework - 2026.png differ diff --git a/assets/SINT - Logo - 2026.png b/assets/SINT - Logo - 2026.png new file mode 100644 index 00000000..f0e4f11e Binary files /dev/null and b/assets/SINT - Logo - 2026.png differ diff --git a/lib/navigation/src/domain/extensions/navigation_extensions.dart b/lib/navigation/src/domain/extensions/navigation_extensions.dart index 8e743a19..d439fc5e 100644 --- a/lib/navigation/src/domain/extensions/navigation_extensions.dart +++ b/lib/navigation/src/domain/extensions/navigation_extensions.dart @@ -906,6 +906,37 @@ extension NavigationExtension on SintInterface { return rootController.rootDelegate.parameters; } + /// Primary route parameter from URL path. Returns null if none. + /// For route '/book/:bookId' navigated as '/book/abc123', returns 'abc123'. + /// Usage: `String? id = Sint.routeParam;` + String? get routeParam { + if (_shouldUseMock) return SintTestMode.routeParam; + return rootController.rootDelegate.routeParam; + } + + /// Named path parameter (like Spring Boot @PathVariable). + /// Usage: `String? id = Sint.pathParam('bookId');` + String? pathParam(String name) { + if (_shouldUseMock) return SintTestMode.pathParam(name); + return rootController.rootDelegate.pathParam(name); + } + + /// Query parameter from URL (like Spring Boot @RequestParam). + /// Usage: `String? page = Sint.queryParam('page');` + String? queryParam(String name) { + if (_shouldUseMock) return SintTestMode.queryParam(name); + return rootController.rootDelegate.queryParam(name); + } + + /// Query parameter with default value. + /// Usage: `String sort = Sint.queryParamOrDefault('sort', 'recent');` + String queryParamOrDefault(String name, String defaultValue) { + if (_shouldUseMock) { + return SintTestMode.queryParamOrDefault(name, defaultValue); + } + return rootController.rootDelegate.queryParamOrDefault(name, defaultValue); + } + /// Casts the stored router delegate to a desired type TDelegate? delegate, TPage>() => _getxController.routerDelegate as TDelegate?; diff --git a/lib/navigation/src/domain/models/config_data.dart b/lib/navigation/src/domain/models/config_data.dart index ad7da160..8336a7d5 100644 --- a/lib/navigation/src/domain/models/config_data.dart +++ b/lib/navigation/src/domain/models/config_data.dart @@ -55,6 +55,7 @@ class ConfigData { final Routing routing; final Map parameters; final SintSnackBarStyle? snackBarStyle; + final bool translateEndpoints; final SnackBarQueue snackBarQueue = SnackBarQueue(); ConfigData({ @@ -98,6 +99,7 @@ class ConfigData { this.parameters = const {}, required this.defaultPopGesture, this.snackBarStyle, + this.translateEndpoints = false, Routing? routing, }) : routing = routing ?? Routing(); @@ -141,6 +143,7 @@ class ConfigData { Curve? defaultDialogTransitionCurve, Duration? defaultDialogTransitionDuration, SintSnackBarStyle? snackBarStyle, + bool? translateEndpoints, Routing? routing, Map? parameters, }) { @@ -191,6 +194,7 @@ class ConfigData { defaultDialogTransitionDuration: defaultDialogTransitionDuration ?? this.defaultDialogTransitionDuration, snackBarStyle: snackBarStyle ?? this.snackBarStyle, + translateEndpoints: translateEndpoints ?? this.translateEndpoints, routing: routing ?? this.routing, parameters: parameters ?? this.parameters, ); @@ -243,6 +247,7 @@ class ConfigData { defaultDialogTransitionDuration && other.routing == routing && other.snackBarStyle == snackBarStyle && + other.translateEndpoints == translateEndpoints && mapEquals(other.parameters, parameters); } @@ -289,6 +294,7 @@ class ConfigData { defaultDialogTransitionDuration.hashCode ^ routing.hashCode ^ snackBarStyle.hashCode ^ + translateEndpoints.hashCode ^ parameters.hashCode; } } \ No newline at end of file diff --git a/lib/navigation/src/router/sint_delegate.dart b/lib/navigation/src/router/sint_delegate.dart index b393c962..aa11b7d5 100644 --- a/lib/navigation/src/router/sint_delegate.dart +++ b/lib/navigation/src/router/sint_delegate.dart @@ -90,7 +90,13 @@ class SintDelegate extends RouterDelegate body: Center(child: Text('Route not found')), ), ) { - if (!showHashOnUrl && kIsWeb) setUrlStrategy(); + if (!showHashOnUrl && kIsWeb) { + try { + setUrlStrategy(); + } catch (_) { + // URL strategy already set or engine already initialized — safe to ignore. + } + } addPages(pages); addPage(notFoundRoute); Sint.log('GetDelegate is created !'); @@ -162,6 +168,37 @@ class SintDelegate extends RouterDelegate return currentConfiguration?.pageSettings; } + /// Primary route path parameter value (first path param). + /// For route '/book/:bookId' navigated as '/book/abc123', returns 'abc123'. + /// Returns null if no path parameter exists. + /// Inspired by Spring Boot's @PathVariable annotation. + String? get routeParam { + final ps = currentConfiguration?.pageSettings; + if (ps == null) return null; + final allParams = ps.params; + if (allParams.isEmpty) return null; + final queryKeys = ps.query.keys.toSet(); + for (final entry in allParams.entries) { + if (!queryKeys.contains(entry.key)) return entry.value; + } + return null; + } + + /// Named path parameter (like Spring Boot @PathVariable("bookId")). + String? pathParam(String name) { + return currentConfiguration?.pageSettings?.params[name]; + } + + /// Query parameter only (like Spring Boot @RequestParam). + String? queryParam(String name) { + return currentConfiguration?.pageSettings?.query[name]; + } + + /// Query parameter with default (like @RequestParam(defaultValue = "10")). + String queryParamOrDefault(String name, String defaultValue) { + return currentConfiguration?.pageSettings?.query[name] ?? defaultValue; + } + Future _pushHistory(RouteDecoder config) async { if (config.route!.preventDuplicates) { final originalEntryIndex = _activePages.indexWhere( diff --git a/lib/navigation/src/router/sint_information_parser.dart b/lib/navigation/src/router/sint_information_parser.dart index 0f287b92..ed4120a4 100644 --- a/lib/navigation/src/router/sint_information_parser.dart +++ b/lib/navigation/src/router/sint_information_parser.dart @@ -34,6 +34,12 @@ class SintInformationParser extends RouteInformationParser { location = initialRoute; } + // URL Canonicalization: normalize localized segments to canonical English + // e.g. '/libro/abc123' → '/book/abc123' before route matching + if (Sint.pathTranslator != null) { + location = Sint.pathTranslator!.canonicalizePath(location); + } + Sint.log('GetInformationParser: route location: $location'); return SynchronousFuture(RouteDecoder.fromRoute(location)); @@ -41,8 +47,16 @@ class SintInformationParser extends RouteInformationParser { @override RouteInformation restoreRouteInformation(RouteDecoder configuration) { + var name = configuration.pageSettings?.name ?? ''; + + // URL Localization: translate canonical segments to current locale + // e.g. '/book/abc123' → '/libro/abc123' for the browser URL bar + if (Sint.pathTranslator != null && Sint.locale != null) { + name = Sint.pathTranslator!.localizePath(name, Sint.locale!.languageCode); + } + return RouteInformation( - uri: Uri.tryParse(configuration.pageSettings?.name ?? ''), + uri: Uri.tryParse(name), state: null, ); } diff --git a/lib/navigation/src/router/sint_test_mode.dart b/lib/navigation/src/router/sint_test_mode.dart index 3296cd60..0688d16e 100644 --- a/lib/navigation/src/router/sint_test_mode.dart +++ b/lib/navigation/src/router/sint_test_mode.dart @@ -15,4 +15,18 @@ class SintTestMode { } static Map get parameters => _parameters; + + /// Test mode support for routeParam — returns first parameter value. + static String? get routeParam => + _parameters.values.where((v) => v != null && v.isNotEmpty).firstOrNull; + + /// Test mode support for named path parameter. + static String? pathParam(String name) => _parameters[name]; + + /// Test mode support for query parameter. + static String? queryParam(String name) => null; + + /// Test mode support for query parameter with default. + static String queryParamOrDefault(String name, String defaultValue) => + defaultValue; } diff --git a/lib/navigation/src/ui/sint_material_app.dart b/lib/navigation/src/ui/sint_material_app.dart index cc9e2193..f3f482bb 100644 --- a/lib/navigation/src/ui/sint_material_app.dart +++ b/lib/navigation/src/ui/sint_material_app.dart @@ -75,6 +75,7 @@ class SintMaterialApp extends StatelessWidget { final BackButtonDispatcher? backButtonDispatcher; final SintSnackBarStyle? snackBarStyle; final bool useInheritedMediaQuery; + final bool translateEndpoints; const SintMaterialApp({ super.key, @@ -134,6 +135,7 @@ class SintMaterialApp extends StatelessWidget { this.highContrastTheme, this.highContrastDarkTheme, this.actions, + this.translateEndpoints = false, }) : routeInformationProvider = null, backButtonDispatcher = null, routeInformationParser = null, @@ -194,6 +196,7 @@ class SintMaterialApp extends StatelessWidget { this.navigatorObservers, this.unknownRoute, this.snackBarStyle, + this.translateEndpoints = false, }) : navigatorKey = null, onGenerateRoute = null, // ignore: deprecated_member_use_from_same_package @@ -240,6 +243,7 @@ class SintMaterialApp extends StatelessWidget { themeMode: themeMode, defaultPopGesture: popGesture, snackBarStyle: snackBarStyle, + translateEndpoints: translateEndpoints, ), child: Builder(builder: (context) { final controller = SintRoot.of(context); diff --git a/lib/navigation/src/ui/sint_root.dart b/lib/navigation/src/ui/sint_root.dart index f15e8847..af229495 100644 --- a/lib/navigation/src/ui/sint_root.dart +++ b/lib/navigation/src/ui/sint_root.dart @@ -61,6 +61,7 @@ class SintRootState extends State with WidgetsBindingObserver { void onClose() { config.onDispose?.call(); Sint.clearTranslations(); + Sint.pathTranslator = null; config.snackBarQueue.disposeControllers(); RouterReportManager.instance.clearRouteKeys(); RouterReportManager.dispose(); @@ -133,6 +134,18 @@ class SintRootState extends State with WidgetsBindingObserver { Sint.addTranslations(config.translationsKeys!); } + // Build path translator for URL segment localization (web). + if (config.translateEndpoints) { + final delegate = config.routerDelegate as SintDelegate; + final segments = PathTranslator.extractSegments( + delegate.registeredRoutes, + ); + Sint.pathTranslator = PathTranslator.build( + translations: Sint.translations, + routeSegments: segments, + ); + } + Sint.smartManagement = config.smartManagement; config.onInit?.call(); diff --git a/lib/translation/sint_translation.dart b/lib/translation/sint_translation.dart index 5fe4fbed..afb4412e 100644 --- a/lib/translation/sint_translation.dart +++ b/lib/translation/sint_translation.dart @@ -4,4 +4,5 @@ export 'src/domain/extensions/locale_extension.dart'; export 'src/domain/extensions/trans_extension.dart'; export 'src/domain/interfaces/translations.dart'; export 'src/domain/models/intl_host.dart'; +export 'src/domain/models/path_translator.dart'; export 'src/utils/translations_constants.dart'; diff --git a/lib/translation/src/domain/extensions/locale_extension.dart b/lib/translation/src/domain/extensions/locale_extension.dart index 2d39d192..27462636 100644 --- a/lib/translation/src/domain/extensions/locale_extension.dart +++ b/lib/translation/src/domain/extensions/locale_extension.dart @@ -2,6 +2,7 @@ import 'dart:ui'; import 'package:sint/core/src/domain/interfaces/sint_interface.dart'; import 'package:sint/translation/src/domain/models/intl_host.dart'; +import 'package:sint/translation/src/domain/models/path_translator.dart'; extension LocalesIntl on SintInterface { static final _intlHost = IntlHost(); @@ -16,6 +17,11 @@ extension LocalesIntl on SintInterface { Map> get translations => _intlHost.translations; + /// URL path translator (built when `translateEndpoints: true`). + PathTranslator? get pathTranslator => _intlHost.pathTranslator; + + set pathTranslator(PathTranslator? value) => _intlHost.pathTranslator = value; + void addTranslations(Map> tr) { translations.addAll(tr); } diff --git a/lib/translation/src/domain/models/intl_host.dart b/lib/translation/src/domain/models/intl_host.dart index 2d6bd7b9..dcb6401d 100644 --- a/lib/translation/src/domain/models/intl_host.dart +++ b/lib/translation/src/domain/models/intl_host.dart @@ -1,9 +1,14 @@ import 'dart:ui'; +import 'path_translator.dart'; + class IntlHost { Locale? locale; Locale? fallbackLocale; Map> translations = {}; + + /// URL path translator built from translations when `translateEndpoints` is enabled. + PathTranslator? pathTranslator; } \ No newline at end of file diff --git a/lib/translation/src/domain/models/path_translator.dart b/lib/translation/src/domain/models/path_translator.dart new file mode 100644 index 00000000..f5ab3047 --- /dev/null +++ b/lib/translation/src/domain/models/path_translator.dart @@ -0,0 +1,161 @@ +/// Translates URL path segments between canonical (English) and localized forms. +/// +/// Built automatically by SINT when `translateEndpoints: true` is set on +/// [SintMaterialApp]. Uses the app's registered translations to derive +/// segment mappings — no external localization file needed. +/// +/// Example: +/// - ES: `/book/abc123` ↔ `/libro/abc123` +/// - FR: `/book/abc123` ↔ `/livre/abc123` +class PathTranslator { + /// Per-locale forward maps: canonical segment → localized segment. + /// e.g. `{'es': {'book': 'libro', 'event': 'evento'}, 'fr': {'book': 'livre'}}` + final Map> _forwardMaps; + + /// Reverse map: any localized segment → canonical segment (all locales). + /// e.g. `{'libro': 'book', 'livre': 'book', 'buch': 'book'}` + final Map _reverseMap; + + PathTranslator._(this._forwardMaps, this._reverseMap); + + /// Builds a [PathTranslator] from SINT's loaded translations and the + /// static route segments extracted from registered [SintPage] names. + /// + /// [translations] — full translation map from `Sint.translations` + /// (`{locale: {key: value}}`). + /// [routeSegments] — static segments extracted via [extractSegments]. + factory PathTranslator.build({ + required Map> translations, + required Set routeSegments, + }) { + final forwardMaps = >{}; + final reverseMap = {}; + + for (final localeEntry in translations.entries) { + // Handle both 'es' and 'es_MX' style keys. + final locale = localeEntry.key.split('_').first; + if (locale == 'en') continue; // English is canonical — no mapping. + + final localeMap = {}; + for (final segment in routeSegments) { + final value = localeEntry.value[segment]; + if (value == null) continue; + + final normalized = _removeDiacritics(value.toLowerCase()); + if (normalized.contains(' ')) continue; // Multi-word: can't be URL segment. + if (normalized == segment) continue; // Same as canonical: no-op. + + localeMap[segment] = normalized; + reverseMap[normalized] = segment; + } + + if (localeMap.isNotEmpty) { + // Merge into existing locale map (handles 'es' + 'es_MX' both present). + forwardMaps.putIfAbsent(locale, () => {}).addAll(localeMap); + } + } + + return PathTranslator._(forwardMaps, reverseMap); + } + + /// Extracts unique static segments from registered route names. + /// + /// `/book/:bookId` → `{'book'}` + /// `/shop/product/:productId` → `{'shop', 'product'}` + static Set extractSegments(List routes) { + final segments = {}; + for (final route in routes) { + final name = (route as dynamic).name as String; + for (final seg in name.split('/')) { + if (seg.isEmpty) continue; + if (seg.startsWith(':')) continue; // Skip parameters. + segments.add(seg); + } + } + return segments; + } + + // ─── Public API ─────────────────────────────────────────────── + + /// Canonicalizes a localized URL path to canonical English. + /// + /// `/libro/abc123` → `/book/abc123` + /// `/livre/abc123` → `/book/abc123` + /// `/book/abc123` → `/book/abc123` (already canonical) + String canonicalizePath(String path) { + if (path.isEmpty || path == '/') return path; + + final qIndex = path.indexOf('?'); + final purePath = qIndex > -1 ? path.substring(0, qIndex) : path; + final query = qIndex > -1 ? path.substring(qIndex) : ''; + + final segments = purePath.split('/'); + var changed = false; + for (var i = 0; i < segments.length; i++) { + final seg = segments[i]; + if (seg.isEmpty) continue; + final canonical = _reverseMap[seg]; + if (canonical != null) { + segments[i] = canonical; + changed = true; + } + } + if (!changed) return path; + return segments.join('/') + query; + } + + /// Localizes a canonical English path to the given language code. + /// + /// `/book/abc123` → `/libro/abc123` (for `'es'`) + /// `/book/abc123` → `/book/abc123` (for `'en'`, no-op) + String localizePath(String path, String languageCode) { + if (path.isEmpty || path == '/') return path; + if (languageCode == 'en') return path; + + final localMap = _forwardMaps[languageCode]; + if (localMap == null) return path; + + final qIndex = path.indexOf('?'); + final purePath = qIndex > -1 ? path.substring(0, qIndex) : path; + final query = qIndex > -1 ? path.substring(qIndex) : ''; + + final segments = purePath.split('/'); + var changed = false; + for (var i = 0; i < segments.length; i++) { + final seg = segments[i]; + if (seg.isEmpty) continue; + final localized = localMap[seg]; + if (localized != null) { + segments[i] = localized; + changed = true; + } + } + if (!changed) return path; + return segments.join('/') + query; + } + + // ─── Diacritics ─────────────────────────────────────────────── + + static const _diacriticsFrom = + 'ÀÁÂÃÄÅàáâãäåÈÉÊËèéêëÌÍÎÏìíîïÒÓÔÕÖØòóôõöøÙÚÛÜùúûüÝýÿÑñÇç'; + static const _diacriticsTo = + 'AAAAAAaaaaaaEEEEeeeeIIIIiiiiOOOOOOooooooUUUUuuuuYyyNnCc'; + + static final Map _charMap = () { + final map = {}; + for (var i = 0; i < _diacriticsFrom.length; i++) { + map[_diacriticsFrom.codeUnitAt(i)] = _diacriticsTo[i]; + } + return map; + }(); + + /// Replaces accented characters with their ASCII equivalents. + /// `Publicación` → `Publicacion` + static String _removeDiacritics(String input) { + final buffer = StringBuffer(); + for (final unit in input.codeUnits) { + buffer.write(_charMap[unit] ?? String.fromCharCode(unit)); + } + return buffer.toString(); + } +} diff --git a/pubspec.yaml b/pubspec.yaml index 221644b0..96b58760 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,6 +1,6 @@ name: sint description: SINT (State, Injection, Navigation, Translation) - The Four Pillars of High-Fidelity Flutter Infrastructure. -version: 1.1.0 +version: 1.2.1 homepage: https://github.com/Open-Neom/sint environment: