diff --git a/npm/ng-packs/packages/core/src/lib/proxy/volo/abp/asp-net-core/mvc/application-configurations/models.ts b/npm/ng-packs/packages/core/src/lib/proxy/volo/abp/asp-net-core/mvc/application-configurations/models.ts index 524c9a8c78..b2d7b67bea 100644 --- a/npm/ng-packs/packages/core/src/lib/proxy/volo/abp/asp-net-core/mvc/application-configurations/models.ts +++ b/npm/ng-packs/packages/core/src/lib/proxy/volo/abp/asp-net-core/mvc/application-configurations/models.ts @@ -42,6 +42,7 @@ export interface ApplicationLocalizationConfigurationDto { defaultResourceName?: string; languagesMap: Record; languageFilesMap: Record; + useRouteBasedCulture: boolean; } export interface ApplicationLocalizationDto { diff --git a/npm/ng-packs/packages/core/src/lib/services/index.ts b/npm/ng-packs/packages/core/src/lib/services/index.ts index 205fd2f0dc..cccac2ffb6 100644 --- a/npm/ng-packs/packages/core/src/lib/services/index.ts +++ b/npm/ng-packs/packages/core/src/lib/services/index.ts @@ -16,6 +16,8 @@ export * from './resource-wait.service'; export * from './rest.service'; export * from './router-events.service'; export * from './router-wait.service'; +export * from './route-based-culture.service'; +export * from './route-based-culture-url.service'; export * from './routes.service'; export * from './session-state.service'; export * from './subscription.service'; diff --git a/npm/ng-packs/packages/core/src/lib/services/route-based-culture-url.service.ts b/npm/ng-packs/packages/core/src/lib/services/route-based-culture-url.service.ts new file mode 100644 index 0000000000..cbd94f3803 --- /dev/null +++ b/npm/ng-packs/packages/core/src/lib/services/route-based-culture-url.service.ts @@ -0,0 +1,170 @@ +import { Location } from '@angular/common'; +import { isPlatformBrowser } from '@angular/common'; +import { Injectable, PLATFORM_ID, inject } from '@angular/core'; +import { Router } from '@angular/router'; +import { LanguageInfo } from '../proxy/volo/abp/localization/models'; +import { + findMatchingCultureName, + getFirstPathSegment, + stripCultureSegmentFromPath, +} from '../utils/route-based-culture.utils'; +import { getRoutePath } from '../utils/route-utils'; +import { ConfigStateService } from './config-state.service'; +import { SessionStateService } from './session-state.service'; + +/** + * URL helpers for route-based culture: prefix menu links, strip culture for route matching, + * and navigate when the user picks a language so the URL stays in sync with the session. + */ +@Injectable({ + providedIn: 'root', +}) +export class RouteBasedCultureUrlService { + private readonly router = inject(Router); + private readonly location = inject(Location); + private readonly configState = inject(ConfigStateService); + private readonly sessionState = inject(SessionStateService); + private readonly platformId = inject(PLATFORM_ID); + + /** Cached from localization config; refreshed when application configuration updates. */ + private useRouteBasedCulture = false; + private languages: LanguageInfo[] | undefined; + + constructor() { + this.refreshRouteCultureCache(); + this.configState.getAll$().subscribe(() => this.refreshRouteCultureCache()); + } + + private refreshRouteCultureCache(): void { + const loc = this.configState.getOne('localization'); + if (!loc) { + this.useRouteBasedCulture = false; + this.languages = undefined; + return; + } + this.useRouteBasedCulture = !!loc.useRouteBasedCulture; + this.languages = loc.languages as LanguageInfo[] | undefined; + } + + private isRouteBasedCultureEnabled(): boolean { + return this.useRouteBasedCulture; + } + + /** + * Same as {@link getRoutePath} but removes the leading culture segment when route-based culture is enabled, + * so paths match `RoutesService` entries (e.g. `/identity/users`). + */ + getRoutePathForMatching(router: Router, url = router.url): string { + const raw = getRoutePath(router, url); + return this.stripCulturePrefixIfEnabled(raw); + } + + /** + * Strips the leading culture segment when it matches a configured language and route-based culture is enabled. + * Use for menu active state, breadcrumbs, and any comparison between `router.url` and `RoutesService` paths. + */ + stripCulturePrefixIfEnabled(path: string): string { + if (!this.isRouteBasedCultureEnabled() || !path) { + return path; + } + return this.stripCulturePrefix(path); + } + + /** + * Alias for {@link stripCulturePrefixIfEnabled}: normalizes the current URL for **menu highlighting** + * when menu `link` values omit the culture segment (e.g. Lepton `NavbarRoutesComponent`, `NavbarService.getRouteItem`). + */ + normalizeForMenuMatch(path: string): string { + return this.stripCulturePrefixIfEnabled(path); + } + + /** + * Removes the first segment when it is a known UI culture (for matching and normalization). + */ + stripCulturePrefix(path: string): string { + return stripCultureSegmentFromPath(path, this.languages); + } + + /** + * Prefixes an app path with the current session culture when route-based culture is enabled + * (e.g. `/identity/users` → `/en/identity/users`). Use for `routerLink`, `navigateByUrl`, etc. + */ + prefixPathWithCulture(path: string | undefined | null): string | undefined | null { + if (path == null || path === '') { + return path; + } + + if (!this.isRouteBasedCultureEnabled()) { + return path; + } + + if (/^https?:\/\//i.test(path)) { + return path; + } + + const lang = this.sessionState.getLanguage(); + if (!lang) { + return path; + } + + const stripped = this.stripCulturePrefix(path); + const normalized = stripped.startsWith('/') ? stripped : '/' + stripped; + const suffix = normalized === '/' ? '' : normalized; + return `/${lang}${suffix}`; + } + + /** + * Rewrites the current URL so the first segment is {@link cultureName} (or prepends it). + * Call this when the user selects a language in the UI instead of only {@link SessionStateService.setLanguage} + * so the address bar stays aligned with the session. + */ + navigateToUrlWithCulture(cultureName: string): Promise | void { + if (!cultureName || !isPlatformBrowser(this.platformId)) { + return Promise.resolve(false); + } + + if (!this.isRouteBasedCultureEnabled()) { + this.sessionState.setLanguage(cultureName); + return Promise.resolve(true); + } + + const path = this.location.path(); + const newPath = this.rewritePathToCulture(path, cultureName); + return this.router.navigateByUrl(newPath); + } + + /** + * Preferred entry point for language pickers (e.g. Lepton toolbar): same as {@link navigateToUrlWithCulture}. + */ + applyLanguageSelection(cultureName: string): Promise | void { + return this.navigateToUrlWithCulture(cultureName); + } + + /** + * Builds a new path (including query/hash) with the given culture as the first segment, + * replacing the culture segment when one is already present. + */ + rewritePathToCulture(urlPath: string, newCulture: string): string { + const languages = this.languages; + const pathEnd = urlPath.search(/[?#]/); + const pathOnly = pathEnd >= 0 ? urlPath.slice(0, pathEnd) : urlPath; + const suffix = pathEnd >= 0 ? urlPath.slice(pathEnd) : ''; + + const first = getFirstPathSegment(pathOnly); + const firstIsCulture = !!findMatchingCultureName(first, languages); + + if (firstIsCulture) { + const segments = pathOnly.split('/').filter(s => s.length > 0); + if (segments.length === 0) { + return `/${newCulture}${suffix}`; + } + segments[0] = newCulture; + const joined = '/' + segments.join('/'); + return joined + suffix; + } + + const normalized = pathOnly.startsWith('/') ? pathOnly : '/' + pathOnly; + const rest = normalized === '/' ? '' : normalized; + return `/${newCulture}${rest}${suffix}`; + } +} diff --git a/npm/ng-packs/packages/core/src/lib/services/route-based-culture.service.ts b/npm/ng-packs/packages/core/src/lib/services/route-based-culture.service.ts new file mode 100644 index 0000000000..f78b6130a9 --- /dev/null +++ b/npm/ng-packs/packages/core/src/lib/services/route-based-culture.service.ts @@ -0,0 +1,59 @@ +import { isPlatformBrowser } from '@angular/common'; +import { Injectable, PLATFORM_ID, inject } from '@angular/core'; +import { NavigationEnd, Router } from '@angular/router'; +import { filter } from 'rxjs/operators'; +import { LanguageInfo } from '../proxy/volo/abp/localization/models'; +import { findMatchingCultureName, getFirstPathSegment } from '../utils/route-based-culture.utils'; +import { ConfigStateService } from './config-state.service'; +import { SessionStateService } from './session-state.service'; + +/** + * When the backend enables URL-based localization (`localization.useRouteBasedCulture` from application configuration), + * keeps session language in sync with the first URL path segment (e.g. /en/..., /tr-TR/...). + * Works with nested routes because only the leading segment is interpreted as culture. + */ +@Injectable({ + providedIn: 'root', +}) +export class RouteBasedCultureService { + private readonly router = inject(Router); + private readonly configState = inject(ConfigStateService); + private readonly sessionState = inject(SessionStateService); + private readonly platformId = inject(PLATFORM_ID); + protected readonly localization = this.configState.getOne('localization'); + + constructor() { + if (!isPlatformBrowser(this.platformId)) { + return; + } + + if (!this.localization?.useRouteBasedCulture) { + return; + } + + this.router.events + .pipe(filter((e): e is NavigationEnd => e instanceof NavigationEnd)) + .subscribe(() => this.syncLanguageFromUrl()); + } + + /** + * Reads the culture from the current URL and updates session language when it matches a configured language. + * @param pathOverride Optional path (e.g. from `Location.path()` during app bootstrap before navigation settles). + */ + syncLanguageFromUrl(pathOverride?: string): void { + if (!isPlatformBrowser(this.platformId)) { + return; + } + + const languages = this.localization?.languages as LanguageInfo[] | undefined; + const path = pathOverride ?? this.router.url; + const firstSegment = getFirstPathSegment(path); + const cultureName = findMatchingCultureName(firstSegment, languages); + + if (!cultureName) { + return; + } + + this.sessionState.setLanguage(cultureName); + } +} diff --git a/npm/ng-packs/packages/core/src/lib/services/title-strategy.service.ts b/npm/ng-packs/packages/core/src/lib/services/title-strategy.service.ts index af961794f8..18d83290b1 100644 --- a/npm/ng-packs/packages/core/src/lib/services/title-strategy.service.ts +++ b/npm/ng-packs/packages/core/src/lib/services/title-strategy.service.ts @@ -27,6 +27,10 @@ export class AbpTitleStrategy extends TitleStrategy { override updateTitle(routerState: RouterStateSnapshot) { this.routerState = routerState; + if (!routerState?.root) { + return; + } + const title = this.buildTitle(routerState); const projectName = this.localizationService.instant({ diff --git a/npm/ng-packs/packages/core/src/lib/tests/route-based-culture-url.service.spec.ts b/npm/ng-packs/packages/core/src/lib/tests/route-based-culture-url.service.spec.ts new file mode 100644 index 0000000000..eaaf94df1b --- /dev/null +++ b/npm/ng-packs/packages/core/src/lib/tests/route-based-culture-url.service.spec.ts @@ -0,0 +1,81 @@ +import { Location } from '@angular/common'; +import { TestBed } from '@angular/core/testing'; +import { Router } from '@angular/router'; +import { of } from 'rxjs'; +import { ConfigStateService } from '../services/config-state.service'; +import { RouteBasedCultureUrlService } from '../services/route-based-culture-url.service'; +import { SessionStateService } from '../services/session-state.service'; + +const languages = [ + { cultureName: 'en', displayName: 'English' }, + { cultureName: 'tr', displayName: 'Turkish' }, +]; + +describe('RouteBasedCultureUrlService', () => { + let service: RouteBasedCultureUrlService; + let configState: { getOne: ReturnType }; + let sessionState: { + getLanguage: ReturnType; + setLanguage: ReturnType; + }; + + function setup(useRouteBasedCulture: boolean) { + configState = { + getOne: vi.fn((key: string) => { + if (key === 'localization') { + return { useRouteBasedCulture, languages }; + } + return undefined; + }), + getAll$: () => of({}), + }; + sessionState = { + getLanguage: vi.fn(() => 'tr'), + setLanguage: vi.fn(), + }; + + TestBed.configureTestingModule({ + providers: [ + RouteBasedCultureUrlService, + { provide: ConfigStateService, useValue: configState }, + { provide: SessionStateService, useValue: sessionState }, + { + provide: Router, + useValue: { navigateByUrl: vi.fn(() => Promise.resolve(true)), url: '/tr' }, + }, + { provide: Location, useValue: { path: () => '/tr/home' } }, + ], + }); + + service = TestBed.inject(RouteBasedCultureUrlService); + } + + afterEach(() => { + TestBed.resetTestingModule(); + }); + + test('prefixPathWithCulture leaves path unchanged when useRouteBasedCulture is false', () => { + setup(false); + expect(service.prefixPathWithCulture('/identity/users')).toBe('/identity/users'); + }); + + test('prefixPathWithCulture prepends session culture when enabled', () => { + setup(true); + expect(service.prefixPathWithCulture('/identity/users')).toBe('/tr/identity/users'); + }); + + test('stripCulturePrefixIfEnabled removes leading culture segment', () => { + setup(true); + expect(service.stripCulturePrefixIfEnabled('/en/identity/users')).toBe('/identity/users'); + }); + + test('rewritePathToCulture replaces existing culture segment', () => { + setup(true); + expect(service.rewritePathToCulture('/en/home?x=1', 'tr')).toBe('/tr/home?x=1'); + }); + + test('rewritePathToCulture prepends culture when missing', () => { + setup(true); + expect(service.rewritePathToCulture('/identity/users', 'tr')).toBe('/tr/identity/users'); + }); +}); diff --git a/npm/ng-packs/packages/core/src/lib/tests/route-based-culture.utils.spec.ts b/npm/ng-packs/packages/core/src/lib/tests/route-based-culture.utils.spec.ts new file mode 100644 index 0000000000..e9e2ea9b96 --- /dev/null +++ b/npm/ng-packs/packages/core/src/lib/tests/route-based-culture.utils.spec.ts @@ -0,0 +1,81 @@ +import { + findMatchingCultureName, + getFirstPathSegment, + normalizeUrlForRouteCultureMatch, + stripCultureSegmentFromPath, +} from '../utils/route-based-culture.utils'; +import type { LanguageInfo } from '../proxy/volo/abp/localization/models'; + +describe('route-based-culture.utils', () => { + describe('#getFirstPathSegment', () => { + test.each` + urlPath | expected + ${''} | ${''} + ${'/'} | ${''} + ${'/en'} | ${'en'} + ${'/en/account'} | ${'en'} + ${'en/account'} | ${'en'} + ${'/tr-TR/foo/bar'} | ${'tr-TR'} + ${'/zh-Hans/account?x=1'} | ${'zh-Hans'} + ${'tr/home#frag'} | ${'tr'} + `('should return $expected when urlPath is $urlPath', ({ urlPath, expected }) => { + expect(getFirstPathSegment(urlPath)).toBe(expected); + }); + }); + + describe('#findMatchingCultureName', () => { + const languages: LanguageInfo[] = [ + { cultureName: 'en' }, + { cultureName: 'tr-TR' }, + { cultureName: 'zh-Hans' }, + ]; + + test.each` + segment | expected + ${'en'} | ${'en'} + ${'EN'} | ${'en'} + ${'tr-tr'} | ${'tr-TR'} + ${'zh-hans'} | ${'zh-Hans'} + ${'account'} | ${undefined} + ${''} | ${undefined} + `('should return $expected when segment is $segment', ({ segment, expected }) => { + expect(findMatchingCultureName(segment, languages)).toBe(expected); + }); + + test('should return undefined when languages is empty', () => { + expect(findMatchingCultureName('en', [])).toBeUndefined(); + }); + }); + + describe('#stripCultureSegmentFromPath', () => { + const languages: LanguageInfo[] = [{ cultureName: 'en' }, { cultureName: 'tr' }]; + + test('should strip leading culture segment for menu matching', () => { + expect(stripCultureSegmentFromPath('/en/identity/users', languages)).toBe('/identity/users'); + }); + + test('should leave path unchanged when first segment is not a culture', () => { + expect(stripCultureSegmentFromPath('/identity/users', languages)).toBe('/identity/users'); + }); + + test('should preserve query string', () => { + expect(stripCultureSegmentFromPath('/en/home?x=1', languages)).toBe('/home?x=1'); + }); + }); + + describe('#normalizeUrlForRouteCultureMatch', () => { + const languages: LanguageInfo[] = [{ cultureName: 'en' }]; + + test('should no-op when useRouteBasedCulture is false', () => { + expect(normalizeUrlForRouteCultureMatch('/en/identity/users', false, languages)).toBe( + '/en/identity/users', + ); + }); + + test('should strip when enabled', () => { + expect(normalizeUrlForRouteCultureMatch('/en/identity/users', true, languages)).toBe( + '/identity/users', + ); + }); + }); +}); diff --git a/npm/ng-packs/packages/core/src/lib/tests/route-culture-url-matcher.spec.ts b/npm/ng-packs/packages/core/src/lib/tests/route-culture-url-matcher.spec.ts new file mode 100644 index 0000000000..bad77e0aac --- /dev/null +++ b/npm/ng-packs/packages/core/src/lib/tests/route-culture-url-matcher.spec.ts @@ -0,0 +1,44 @@ +import { UrlSegment } from '@angular/router'; +import { + createRouteCultureUrlMatcher, + isLikelyCultureSegment, +} from '../utils/route-culture-url-matcher'; + +describe('route-culture-url-matcher', () => { + describe('#isLikelyCultureSegment', () => { + test.each` + segment | expected + ${''} | ${false} + ${'en'} | ${true} + ${'tr'} | ${true} + ${'zh-Hans'} | ${true} + ${'pt-BR'} | ${true} + ${'home'} | ${false} + ${'identity'} | ${false} + ${'cms-kit'} | ${false} + ${'account'} | ${false} + `('should return $expected when segment is $segment', ({ segment, expected }) => { + expect(isLikelyCultureSegment(segment)).toBe(expected); + }); + }); + + describe('#createRouteCultureUrlMatcher', () => { + const matcher = createRouteCultureUrlMatcher(); + + test('should consume first segment when it is a culture code', () => { + const segments = [new UrlSegment('en', {}), new UrlSegment('home', {})]; + const result = matcher(segments, null as any, {} as any); + expect(result?.consumed).toEqual([segments[0]]); + expect(result?.posParams?.culture).toBe(segments[0]); + }); + + test('should return null when first segment is not a culture code', () => { + const segments = [new UrlSegment('identity', {})]; + expect(matcher(segments, null as any, {} as any)).toBeNull(); + }); + + test('should return null when there are no segments', () => { + expect(matcher([], null as any, {} as any)).toBeNull(); + }); + }); +}); diff --git a/npm/ng-packs/packages/core/src/lib/utils/index.ts b/npm/ng-packs/packages/core/src/lib/utils/index.ts index 9b542ec5b4..2a03a36c4e 100644 --- a/npm/ng-packs/packages/core/src/lib/utils/index.ts +++ b/npm/ng-packs/packages/core/src/lib/utils/index.ts @@ -15,6 +15,8 @@ export * from './multi-tenancy-utils'; export * from './number-utils'; export * from './object-utils'; export * from './queue'; +export * from './route-based-culture.utils'; +export * from './route-culture-url-matcher'; export * from './route-utils'; export * from './string-utils'; export * from './tree-utils'; diff --git a/npm/ng-packs/packages/core/src/lib/utils/initial-utils.ts b/npm/ng-packs/packages/core/src/lib/utils/initial-utils.ts index 414f4d0a31..1534648a72 100644 --- a/npm/ng-packs/packages/core/src/lib/utils/initial-utils.ts +++ b/npm/ng-packs/packages/core/src/lib/utils/initial-utils.ts @@ -1,4 +1,4 @@ -import { registerLocaleData } from '@angular/common'; +import { Location, registerLocaleData } from '@angular/common'; import { inject, Injector } from '@angular/core'; import { tap, catchError } from 'rxjs/operators'; import { firstValueFrom, lastValueFrom, of, throwError, timeout } from 'rxjs'; @@ -7,6 +7,7 @@ import { Environment } from '../models/environment'; import { CurrentTenantDto } from '../proxy/volo/abp/asp-net-core/mvc/multi-tenancy/models'; import { ConfigStateService } from '../services/config-state.service'; import { EnvironmentService } from '../services/environment.service'; +import { RouteBasedCultureService } from '../services/route-based-culture.service'; import { SessionStateService } from '../services/session-state.service'; import { CORE_OPTIONS } from '../tokens/options.token'; import { APP_INIT_ERROR_HANDLERS } from '../tokens/app-config.token'; @@ -63,6 +64,9 @@ export async function getInitialData() { await lastValueFrom(result$); } + const routeBasedCulture = injector.get(RouteBasedCultureService); + routeBasedCulture.syncLanguageFromUrl(injector.get(Location).path()); + await localeInitializer(injector); } diff --git a/npm/ng-packs/packages/core/src/lib/utils/route-based-culture.utils.ts b/npm/ng-packs/packages/core/src/lib/utils/route-based-culture.utils.ts new file mode 100644 index 0000000000..3c0f92b357 --- /dev/null +++ b/npm/ng-packs/packages/core/src/lib/utils/route-based-culture.utils.ts @@ -0,0 +1,82 @@ +import type { LanguageInfo } from '../proxy/volo/abp/localization/models'; + +/** + * Returns the first path segment of a URL path (leading slashes are ignored). + * Strips query string and fragment first (e.g. "/zh-Hans/account?x=1" → "zh-Hans"). + */ +export function getFirstPathSegment(urlPath: string): string { + if (!urlPath) { + return ''; + } + + const suffixIndex = urlPath.search(/[?#]/); + const pathPart = suffixIndex >= 0 ? urlPath.slice(0, suffixIndex) : urlPath; + const segments = pathPart.split('/').filter(s => s.length > 0); + + return segments[0] ?? ''; +} + +/** + * If the first segment matches a configured language culture name (case-insensitive), + * returns the canonical culture name from configuration; otherwise undefined. + */ +export function findMatchingCultureName( + firstSegment: string, + languages: LanguageInfo[] | undefined, +): string | undefined { + if (!firstSegment || !languages?.length) { + return undefined; + } + + const normalized = firstSegment.toLowerCase(); + const match = languages.find( + lang => lang.cultureName && lang.cultureName.toLowerCase() === normalized, + ); + + return match?.cultureName; +} + +/** + * Removes the first path segment when it matches a configured UI culture (e.g. `/en/identity/users` → `/identity/users`). + * Use when comparing the **browser URL** to **menu links** that omit the culture segment. + */ +export function stripCultureSegmentFromPath( + path: string, + languages: LanguageInfo[] | undefined, +): string { + if (!path || !languages?.length) { + return path; + } + + const pathEnd = path.search(/[?#]/); + const pathOnly = pathEnd >= 0 ? path.slice(0, pathEnd) : path; + const suffix = pathEnd >= 0 ? path.slice(pathEnd) : ''; + + const first = getFirstPathSegment(pathOnly); + if (!first || !findMatchingCultureName(first, languages)) { + return path; + } + + const segments = pathOnly.split('/').filter(s => s.length > 0); + if (segments.length === 0) { + return path; + } + + const rest = segments.slice(1); + const normalized = rest.length ? '/' + rest.join('/') : '/'; + return normalized + suffix; +} + +/** + * When route-based culture is enabled, normalizes URLs for comparison with menu routes that do not include a culture prefix. + */ +export function normalizeUrlForRouteCultureMatch( + path: string, + useRouteBasedCulture: boolean, + languages: LanguageInfo[] | undefined, +): string { + if (!useRouteBasedCulture || !path) { + return path; + } + return stripCultureSegmentFromPath(path, languages); +} diff --git a/npm/ng-packs/packages/core/src/lib/utils/route-culture-url-matcher.ts b/npm/ng-packs/packages/core/src/lib/utils/route-culture-url-matcher.ts new file mode 100644 index 0000000000..0093c5cb67 --- /dev/null +++ b/npm/ng-packs/packages/core/src/lib/utils/route-culture-url-matcher.ts @@ -0,0 +1,59 @@ +import { Route, Routes, UrlMatchResult, UrlSegment, UrlSegmentGroup } from '@angular/router'; + +/** + * Heuristic: first path segment looks like a BCP 47-style culture code (e.g. en, tr, zh-Hans, pt-BR). + * Used by the optional culture URL matcher so real routes like `identity` or `cms-kit` are not mistaken for cultures. + */ +export function isLikelyCultureSegment(segment: string): boolean { + if (!segment) { + return false; + } + + return /^[a-z]{2}(-[a-zA-Z0-9]{2,8})?$/.test(segment); +} + +/** + * Matcher that consumes the first segment when it looks like a culture code. + * Exposes it as the route param `culture` on matched routes. + */ +export function createRouteCultureUrlMatcher(): ( + segments: UrlSegment[], + group: UrlSegmentGroup, + route: Route, +) => UrlMatchResult | null { + return (segments: UrlSegment[]) => { + if (segments.length < 1) { + return null; + } + + const first = segments[0].path; + if (!isLikelyCultureSegment(first)) { + return null; + } + + return { + consumed: [segments[0]], + posParams: { culture: segments[0] }, + }; + }; +} + +/** + * Wraps your app routes so the same URLs work with or without a leading culture segment. + * + * Examples (same components for both shapes): + * - `/` and `/en` + * - `/home` and `/en/home` + * - `/identity/users` and `/en/identity/users` + * + * The culture segment is matched only when it passes {@link isLikelyCultureSegment} (e.g. `en`, `tr-TR`, `zh-Hans`). + * Session language from the URL is still applied only when `localization.useRouteBasedCulture` is true (`RouteBasedCultureService`). + */ +export function withOptionalRouteCulturePrefix(routes: Routes): Routes { + const matcher = createRouteCultureUrlMatcher(); + + return [ + { matcher, children: [...routes] }, + { path: '', children: [...routes] }, + ]; +}