mirror of https://github.com/abpframework/abp.git
12 changed files with 590 additions and 1 deletions
@ -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<boolean> | 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<boolean> | 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}`; |
||||
|
} |
||||
|
} |
||||
@ -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); |
||||
|
} |
||||
|
} |
||||
@ -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<typeof vi.fn> }; |
||||
|
let sessionState: { |
||||
|
getLanguage: ReturnType<typeof vi.fn>; |
||||
|
setLanguage: ReturnType<typeof vi.fn>; |
||||
|
}; |
||||
|
|
||||
|
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'); |
||||
|
}); |
||||
|
}); |
||||
@ -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', |
||||
|
); |
||||
|
}); |
||||
|
}); |
||||
|
}); |
||||
@ -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(); |
||||
|
}); |
||||
|
}); |
||||
|
}); |
||||
@ -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); |
||||
|
} |
||||
@ -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] }, |
||||
|
]; |
||||
|
} |
||||
Loading…
Reference in new issue