Browse Source

add: services, utils and tests for the url based localization configuration

pull/25174/head
sumeyye 6 months ago
parent
commit
337e649945
  1. 1
      npm/ng-packs/packages/core/src/lib/proxy/volo/abp/asp-net-core/mvc/application-configurations/models.ts
  2. 2
      npm/ng-packs/packages/core/src/lib/services/index.ts
  3. 170
      npm/ng-packs/packages/core/src/lib/services/route-based-culture-url.service.ts
  4. 59
      npm/ng-packs/packages/core/src/lib/services/route-based-culture.service.ts
  5. 4
      npm/ng-packs/packages/core/src/lib/services/title-strategy.service.ts
  6. 81
      npm/ng-packs/packages/core/src/lib/tests/route-based-culture-url.service.spec.ts
  7. 81
      npm/ng-packs/packages/core/src/lib/tests/route-based-culture.utils.spec.ts
  8. 44
      npm/ng-packs/packages/core/src/lib/tests/route-culture-url-matcher.spec.ts
  9. 2
      npm/ng-packs/packages/core/src/lib/utils/index.ts
  10. 6
      npm/ng-packs/packages/core/src/lib/utils/initial-utils.ts
  11. 82
      npm/ng-packs/packages/core/src/lib/utils/route-based-culture.utils.ts
  12. 59
      npm/ng-packs/packages/core/src/lib/utils/route-culture-url-matcher.ts

1
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<string, NameValue[]>;
languageFilesMap: Record<string, NameValue[]>;
useRouteBasedCulture: boolean;
}
export interface ApplicationLocalizationDto {

2
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';

170
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<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}`;
}
}

59
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);
}
}

4
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({

81
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<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');
});
});

81
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',
);
});
});
});

44
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();
});
});
});

2
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';

6
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);
}

82
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);
}

59
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] },
];
}
Loading…
Cancel
Save