diff --git a/docker-compose.yml b/docker-compose.yml index 8123e00aac..ab4d145991 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -18,6 +18,7 @@ services: # - PROXY_ICM=true - TRUST_ICM=true # - PROMETHEUS=on + # - MULTI_SITE_LOCALE_MAP={"en_US":"/en","de_DE":"/de","fr_FR":"/fr"} nginx: build: nginx depends_on: @@ -43,7 +44,6 @@ services: - baseHref: /de channel: default lang: de_DE - protected: false - baseHref: /fr channel: default lang: fr_FR diff --git a/docs/guides/multi-site-configurations.md b/docs/guides/multi-site-configurations.md index dcd6dfdb2e..0fa0318b44 100644 --- a/docs/guides/multi-site-configurations.md +++ b/docs/guides/multi-site-configurations.md @@ -7,6 +7,17 @@ kb_sync_latest_only # Multi Site Configurations +- [Multi Site Configurations](#multi-site-configurations) + - [Syntax](#syntax) + - [Examples](#examples) + - [One domain, One Channel, Multiple Locales](#one-domain-one-channel-multiple-locales) + - [Multiple Domains, Multiple Channels, Multiple Locales](#multiple-domains-multiple-channels-multiple-locales) + - [Multiple Subdomains, Multiple channels, Multiple Locales](#multiple-subdomains-multiple-channels-multiple-locales) + - [Extended Example with Many Different Configurations](#extended-example-with-many-different-configurations) + - [Extended Example with two domains, one with basic auth (except /fr), the other without](#extended-example-with-two-domains-one-with-basic-auth-except-fr-the-other-without) + - [Integrate your multi-site configuration with the language switch](#integrate-your-multi-site-configuration-with-the-language-switch) +- [Further References](#further-references) + As explained in [Multi-Site Handling](../concepts/multi-site-handling.md), the PWA supports dynamic configurations of a single PWA deployment. This guide explains the YAML syntax used to define a configuration and provides some common configuration examples, mainly focusing on different setups for handling locales and channels. For more information about how the YAML configuration is processed, refer to [Multi-Site Handling](../concepts/multi-site-handling.md) and [Building and Running NGINX Docker Image | Multi-Site](../guides/nginx-startup.md#Multi-Site). @@ -35,7 +46,7 @@ All other properties are optional: - **features**: Comma-separated list of activated features - **lang**: The default language as defined in the Angular CLI environment - **theme**: The theme used for the channel (format: `(|)?`) -- **protected**: Selectively unprotect a given domain and/or baseHref. Only applies in combination with globally activated nginx basic authentication. +- **protected**: Selectively disable protection of a given domain and/or baseHref. Only applies in combination with globally activated nginx basic authentication. Dynamically directing the PWA to different ICM installations can be done by using: @@ -205,6 +216,22 @@ ca.+\.com: lang: en_US ``` +## Integrate your multi-site configuration with the language switch + +To construct new multi-site URLs when switching between languages, the PWA uses the `multi-site.service.ts`. +The `getLangUpdatedUrl` is called with the desired locale string, current url and current baseHref. +From this it constructs a new URL, conforming to our multi-site setup (see [One domain, one channel, multiple locales](#one-domain-one-channel-multiple-locales)). + +To control the transformation of urls, the `multiSiteLocaleMap` environment variable is used. +Depending on your needs, `multiSiteLocaleMap` can be set in either the `environment.ts` or as an environment variable (`MULTI_SITE_LOCALE_MAP`). +See [`docker-compose.yml`](../../docker-compose.yml) for a commented out example or [`environment.model.ts`](../../src/environments/environment.model.ts) for the default value. + +In case you want to disable this functionality, simply override the default environment variable `multiSiteLocaleMap` with `undefined` or `MULTI_SITE_LOCALE_MAP` with `false`. + +In case you want to extend this functionality to work with more locales, extend the default environment variable `multiSiteLocaleMap` with your additional locales. + +In case you want to transfer this functionality to work with your specific multi-site setup, override the `multi-site.service.ts` and provide an implementation that conforms to your setup (as well as configuring the environment variable for your specific use case). + # Further References - [Guide - Building and Running nginx Docker Image](../guides/nginx-startup.md) diff --git a/docs/guides/ssr-startup.md b/docs/guides/ssr-startup.md index d8ac0ba03f..5bc96a4a30 100644 --- a/docs/guides/ssr-startup.md +++ b/docs/guides/ssr-startup.md @@ -34,24 +34,25 @@ If the format is _switch_, the property is switched on by supplying `on`, `1`, ` All parameters are **case sensitive**. Make sure to use them as written in the table below. -| | parameter | format | comment | -| ------------------- | --------------------- | -------------------- | ------------------------------------------------------------ | -| **SSR Specific** | PORT | number | Port for running the application | -| | SSL | any | Enables SSL/TLS | -| **General** | ICM_BASE_URL | string | Sets the base URL for the ICM | -| | ICM_CHANNEL | string | Overrides the default channel | -| | ICM_APPLICATION | string | Overrides the default application | -| | FEATURES | comma-separated list | Overrides active features | -| | THEME | string | Overrides the default theme | -| **Debug** :warning: | TRUST_ICM | any | Use this if ICM is deployed with an insecure certificate | -| | LOGGING | switch | Enables extra log output | -| **Hybrid Approach** | SSR_HYBRID | any | Enables running PWA and ICM in [Hybrid Mode][concept-hybrid] | -| | PROXY_ICM | any \| URL | Proxy ICM via `/INTERSHOP` (enabled if SSR_HYBRID is active) | -| **Third party** | GTM_TOKEN | string | Token for Google Tag Manager | -| | SENTRY_DSN | string | Sentry DSN URL for using Sentry Error Monitor | -| | PROMETHEUS | switch | Exposes Prometheus metrics | -| | ICM_IDENTITY_PROVIDER | string | ID of Identity Provider for [SSO][concept-sso] | -| | IDENTITY_PROVIDERS | JSON | Configuration of Identity Providers for [SSO][concept-sso] | +| | parameter | format | comment | +| ------------------- | --------------------- | -------------------- | -------------------------------------------------------------------------------------------- | +| **SSR Specific** | PORT | number | Port for running the application | +| | SSL | any | Enables SSL/TLS | +| **General** | ICM_BASE_URL | string | Sets the base URL for the ICM | +| | ICM_CHANNEL | string | Overrides the default channel | +| | ICM_APPLICATION | string | Overrides the default application | +| | FEATURES | comma-separated list | Overrides active features | +| | THEME | string | Overrides the default theme | +| | MULTI_SITE_LOCALE_MAP | JSON \| false | Used to map locales to [url modification parameters](../guides/multi-site-configurations.md) | +| **Debug** :warning: | TRUST_ICM | any | Use this if ICM is deployed with an insecure certificate | +| | LOGGING | switch | Enables extra log output | +| **Hybrid Approach** | SSR_HYBRID | any | Enables running PWA and ICM in [Hybrid Mode][concept-hybrid] | +| | PROXY_ICM | any \| URL | Proxy ICM via `/INTERSHOP` (enabled if SSR_HYBRID is active) | +| **Third party** | GTM_TOKEN | string | Token for Google Tag Manager | +| | SENTRY_DSN | string | Sentry DSN URL for using Sentry Error Monitor | +| | PROMETHEUS | switch | Exposes Prometheus metrics | +| | ICM_IDENTITY_PROVIDER | string | ID of Identity Provider for [SSO][concept-sso] | +| | IDENTITY_PROVIDERS | JSON | Configuration of Identity Providers for [SSO][concept-sso] | ## Running with https diff --git a/src/app/core/pipes/make-href.pipe.spec.ts b/src/app/core/pipes/make-href.pipe.spec.ts index 45c9eb8ed2..cd0547c152 100644 --- a/src/app/core/pipes/make-href.pipe.spec.ts +++ b/src/app/core/pipes/make-href.pipe.spec.ts @@ -1,23 +1,33 @@ import { LocationStrategy } from '@angular/common'; import { TestBed } from '@angular/core/testing'; +import { of } from 'rxjs'; +import { anything, instance, mock, verify, when } from 'ts-mockito'; + +import { MultiSiteService } from 'ish-core/utils/multi-site/multi-site.service'; import { MakeHrefPipe } from './make-href.pipe'; describe('Make Href Pipe', () => { let makeHrefPipe: MakeHrefPipe; + let multiSiteService: MultiSiteService; beforeEach(() => { + multiSiteService = mock(MultiSiteService); TestBed.configureTestingModule({ - providers: [MakeHrefPipe], + providers: [MakeHrefPipe, { provide: MultiSiteService, useFactory: () => instance(multiSiteService) }], }); makeHrefPipe = TestBed.inject(MakeHrefPipe); + when(multiSiteService.getLangUpdatedUrl(anything(), anything(), anything())).thenCall( + (url: string, _: LocationStrategy) => of(url) + ); }); it('should be created', () => { expect(makeHrefPipe).toBeTruthy(); }); - - it.each([ + // workaround for https://github.com/DefinitelyTyped/DefinitelyTyped/issues/34617 + // tslint:disable-next-line: no-any + it.each([ [undefined, undefined, 'undefined'], ['/test', undefined, '/test'], ['/test', {}, '/test'], @@ -27,7 +37,19 @@ describe('Make Href Pipe', () => { ['/test?query=q', {}, '/test?query=q'], ['/test?query=q', { foo: 'bar' }, '/test;foo=bar?query=q'], ['/test?query=q', { foo: 'bar', marco: 'polo' }, '/test;foo=bar;marco=polo?query=q'], - ])(`should transform "%s" with %j to "%s"`, (url, params, expected) => { - expect(makeHrefPipe.transform({ path: () => url } as LocationStrategy, params)).toEqual(expected); + ])(`should transform "%s" with %j to "%s"`, (url, params, expected, done: jest.DoneCallback) => { + makeHrefPipe.transform({ path: () => url, getBaseHref: () => '/' } as LocationStrategy, params).subscribe(res => { + expect(res).toEqual(expected); + done(); + }); + }); + + it('should call the multiSiteService if lang parameter exists', done => { + makeHrefPipe + .transform({ path: () => '/de/test', getBaseHref: () => '/de' } as LocationStrategy, { lang: 'en_US' }) + .subscribe(() => { + verify(multiSiteService.getLangUpdatedUrl(anything(), anything(), anything())).once(); + done(); + }); }); }); diff --git a/src/app/core/pipes/make-href.pipe.ts b/src/app/core/pipes/make-href.pipe.ts index 5ea64e6ffb..8a6c795751 100644 --- a/src/app/core/pipes/make-href.pipe.ts +++ b/src/app/core/pipes/make-href.pipe.ts @@ -1,30 +1,49 @@ import { LocationStrategy } from '@angular/common'; import { Pipe, PipeTransform } from '@angular/core'; +import { Observable, of } from 'rxjs'; +import { map, switchMap } from 'rxjs/operators'; + +import { omit } from 'ish-core/utils/functions'; +import { MultiSiteService } from 'ish-core/utils/multi-site/multi-site.service'; @Pipe({ name: 'makeHref', pure: false }) export class MakeHrefPipe implements PipeTransform { - transform(location: LocationStrategy, urlParams: { [key: string]: string }): string { + constructor(private multiSiteService: MultiSiteService) {} + transform(location: LocationStrategy, urlParams: Record): Observable { if (!location || !location.path()) { - return 'undefined'; - } - - const split = location.path().split('?'); - - // url without query params - let newUrl = split[0]; - - // add supplied url params - if (urlParams) { - newUrl += Object.keys(urlParams) - .map(k => `;${k}=${urlParams[k]}`) - .join(''); + return of('undefined'); } - // add query params at the end - if (split.length > 1) { - newUrl += `?${split[1]}`; - } + return of(location.path().split('?')).pipe( + switchMap(split => { + // url without query params + const newUrl = split[0]; - return newUrl; + if (urlParams) { + if (urlParams.lang) { + return this.multiSiteService.getLangUpdatedUrl(urlParams.lang, newUrl, location.getBaseHref()).pipe( + map(modifiedUrl => { + const modifiedUrlParams = modifiedUrl === newUrl ? urlParams : omit(urlParams, 'lang'); + return appendUrlParams(modifiedUrl, modifiedUrlParams, split?.[1]); + }) + ); + } else { + return of(newUrl).pipe(map(url => appendUrlParams(url, urlParams, split?.[1]))); + } + } else { + return of(appendUrlParams(newUrl, undefined, split?.[1])); + } + }) + ); } } + +function appendUrlParams(url: string, urlParams: Record, queryParams: string | undefined): string { + return `${url}${ + urlParams + ? Object.keys(urlParams) + .map(k => `;${k}=${urlParams[k]}`) + .join('') + : '' + }${queryParams ? `?${queryParams}` : ''}`; +} diff --git a/src/app/core/store/core/configuration/configuration.effects.spec.ts b/src/app/core/store/core/configuration/configuration.effects.spec.ts index 03864c88f7..2e8bc5387c 100644 --- a/src/app/core/store/core/configuration/configuration.effects.spec.ts +++ b/src/app/core/store/core/configuration/configuration.effects.spec.ts @@ -45,7 +45,7 @@ describe('Configuration Effects', () => { testComplete$.pipe(take(2)).subscribe({ complete: done }); - effects.setInitialRestEndpoint$.subscribe( + effects.transferEnvironmentProperties$.subscribe( data => { expect(data.type).toEqual(applyConfiguration.type); testComplete$.next(); diff --git a/src/app/core/store/core/configuration/configuration.effects.ts b/src/app/core/store/core/configuration/configuration.effects.ts index 085fba519b..e0d9d190f1 100644 --- a/src/app/core/store/core/configuration/configuration.effects.ts +++ b/src/app/core/store/core/configuration/configuration.effects.ts @@ -79,7 +79,7 @@ export class ConfigurationEffects { }); } - setInitialRestEndpoint$ = createEffect(() => + transferEnvironmentProperties$ = createEffect(() => iif( () => !this.transferState.hasKey(NGRX_STATE_SK), this.actions$.pipe( @@ -100,7 +100,18 @@ export class ConfigurationEffects { .pipe(map(x => x || 'ICM')), this.stateProperties .getStateOrEnvOrDefault('IDENTITY_PROVIDERS', 'identityProviders') - .pipe(map(config => (typeof config === 'string' ? JSON.parse(config) : config))) + .pipe(map(config => (typeof config === 'string' ? JSON.parse(config) : config))), + this.stateProperties + .getStateOrEnvOrDefault | string | false>( + 'MULTI_SITE_LOCALE_MAP', + 'multiSiteLocaleMap' + ) + .pipe( + map(multiSiteLocaleMap => (multiSiteLocaleMap === false ? undefined : multiSiteLocaleMap)), + map(multiSiteLocaleMap => + typeof multiSiteLocaleMap === 'string' ? JSON.parse(multiSiteLocaleMap) : multiSiteLocaleMap + ) + ) ), map( ([ @@ -114,6 +125,7 @@ export class ConfigurationEffects { theme, identityProvider, identityProviders, + multiSiteLocaleMap, ]) => applyConfiguration({ baseURL, @@ -125,6 +137,7 @@ export class ConfigurationEffects { theme, identityProvider, identityProviders, + multiSiteLocaleMap, }) ) ) diff --git a/src/app/core/store/core/configuration/configuration.reducer.ts b/src/app/core/store/core/configuration/configuration.reducer.ts index 67e4469038..8342cef8e5 100644 --- a/src/app/core/store/core/configuration/configuration.reducer.ts +++ b/src/app/core/store/core/configuration/configuration.reducer.ts @@ -27,6 +27,7 @@ export interface ConfigurationState { locales?: Locale[]; lang?: string; serverTranslations: { [lang: string]: Translations }; + multiSiteLocaleMap: Record; // not synced via state transfer _deviceType?: DeviceType; } @@ -43,6 +44,7 @@ const initialState: ConfigurationState = { locales: environment.locales, lang: undefined, serverTranslations: {}, + multiSiteLocaleMap: {}, _deviceType: environment.defaultDeviceType, }; diff --git a/src/app/core/store/core/configuration/configuration.selectors.ts b/src/app/core/store/core/configuration/configuration.selectors.ts index 23932ecf7f..83e505c098 100644 --- a/src/app/core/store/core/configuration/configuration.selectors.ts +++ b/src/app/core/store/core/configuration/configuration.selectors.ts @@ -94,3 +94,8 @@ export const getServerTranslations = (lang: string) => export const getSpecificServerTranslation = (lang: string, key: string) => createSelector(getServerTranslations(lang), translations => translations?.[key]); + +export const getMultiSiteLocaleMap = createSelector( + getConfigurationState, + (state: ConfigurationState) => state.multiSiteLocaleMap +); diff --git a/src/app/core/utils/multi-site/multi-site.service.spec.ts b/src/app/core/utils/multi-site/multi-site.service.spec.ts new file mode 100644 index 0000000000..09e2ee48a6 --- /dev/null +++ b/src/app/core/utils/multi-site/multi-site.service.spec.ts @@ -0,0 +1,45 @@ +import { TestBed } from '@angular/core/testing'; +import { provideMockStore } from '@ngrx/store/testing'; + +import { getMultiSiteLocaleMap } from 'ish-core/store/core/configuration'; + +import { MultiSiteService } from './multi-site.service'; + +const multiSiteLocaleMap = { + en_US: '/en', + de_DE: '/de', + fr_FR: '/fr', +}; + +describe('Multi Site Service', () => { + let multiSiteService: MultiSiteService; + + beforeEach(() => { + TestBed.configureTestingModule({ + providers: [provideMockStore({ selectors: [{ selector: getMultiSiteLocaleMap, value: multiSiteLocaleMap }] })], + }); + multiSiteService = TestBed.inject(MultiSiteService); + }); + + it('should be created', () => { + expect(multiSiteService).toBeTruthy(); + }); + it('should return url unchanged if no language baseHref exists', done => { + multiSiteService.getLangUpdatedUrl('de_DE', '/testpath', '/').subscribe(url => { + expect(url).toMatchInlineSnapshot(`"/testpath"`); + done(); + }); + }); + it('should return with new url if language baseHref exists and language is valid', done => { + multiSiteService.getLangUpdatedUrl('en_US', '/de/testpath', '/de').subscribe(url => { + expect(url).toMatchInlineSnapshot(`"/en/testpath"`); + done(); + }); + }); + it('should return url unchanged if language baseHref exists but language is invalid', done => { + multiSiteService.getLangUpdatedUrl('xy_XY', '/de/testpath', '/de').subscribe(url => { + expect(url).toMatchInlineSnapshot(`"/de/testpath"`); + done(); + }); + }); +}); diff --git a/src/app/core/utils/multi-site/multi-site.service.ts b/src/app/core/utils/multi-site/multi-site.service.ts new file mode 100644 index 0000000000..0b3603d178 --- /dev/null +++ b/src/app/core/utils/multi-site/multi-site.service.ts @@ -0,0 +1,51 @@ +import { Injectable } from '@angular/core'; +import { Store, select } from '@ngrx/store'; +import { Observable } from 'rxjs'; +import { map, take } from 'rxjs/operators'; + +import { getMultiSiteLocaleMap } from 'ish-core/store/core/configuration'; + +export type MultiSiteLocaleMap = Record | undefined; + +@Injectable({ providedIn: 'root' }) +export class MultiSiteService { + constructor(private store: Store) {} + + /** + * returns the current url, modified to fit the locale parameter if the environment parameter "multiSiteLocaleMap" is set + * @param locale the locale which the new url should fit + * @param url the current url + * @param baseHref the current baseHref which needs to be replaced + * @returns the modified url + */ + getLangUpdatedUrl(locale: string, url: string, baseHref: string): Observable { + return this.store.pipe( + select(getMultiSiteLocaleMap), + take(1), + map(multiSiteLocaleMap => { + let newUrl = url; + /** + * only replace lang parameter if: + * - multiSiteLocaleMap environment var is declared (set to undefined to skip this behaviour) + * - current baseHref is part of the map (so the empty "/" baseHref would not be replaced) + * - multiSiteLocaleMap contains target locale + */ + if ( + multiSiteLocaleMap && + Object.values(multiSiteLocaleMap).includes(baseHref) && + localeMapHasLangString(locale, multiSiteLocaleMap) + ) { + newUrl = newUrl.replace(baseHref, multiSiteLocaleMap[locale]); + } + return newUrl; + }) + ); + } +} + +function localeMapHasLangString( + lang: string, + multiSiteLocaleMap: MultiSiteLocaleMap +): multiSiteLocaleMap is Record { + return lang && multiSiteLocaleMap[lang] && typeof multiSiteLocaleMap[lang] === 'string'; +} diff --git a/src/app/shell/header/language-switch/language-switch.component.html b/src/app/shell/header/language-switch/language-switch.component.html index e970e37f51..27b62d184d 100644 --- a/src/app/shell/header/language-switch/language-switch.component.html +++ b/src/app/shell/header/language-switch/language-switch.component.html @@ -17,7 +17,7 @@ diff --git a/src/app/shell/header/language-switch/language-switch.component.spec.ts b/src/app/shell/header/language-switch/language-switch.component.spec.ts index 6f8b85fca8..8b0492605f 100644 --- a/src/app/shell/header/language-switch/language-switch.component.spec.ts +++ b/src/app/shell/header/language-switch/language-switch.component.spec.ts @@ -3,7 +3,7 @@ import { Router } from '@angular/router'; import { RouterTestingModule } from '@angular/router/testing'; import { FaIconComponent } from '@fortawesome/angular-fontawesome'; import { NgbDropdownModule } from '@ng-bootstrap/ng-bootstrap'; -import { MockComponent } from 'ng-mocks'; +import { MockComponent, MockPipe } from 'ng-mocks'; import { of } from 'rxjs'; import { instance, mock, when } from 'ts-mockito'; @@ -18,6 +18,7 @@ describe('Language Switch Component', () => { let fixture: ComponentFixture; let element: HTMLElement; let appFacade: AppFacade; + const locales = [ { lang: 'en_US', value: 'en', displayName: 'English' }, { lang: 'de_DE', value: 'de', displayName: 'Deutsch' }, @@ -28,7 +29,11 @@ describe('Language Switch Component', () => { appFacade = mock(AppFacade); await TestBed.configureTestingModule({ - declarations: [LanguageSwitchComponent, MakeHrefPipe, MockComponent(FaIconComponent)], + declarations: [ + LanguageSwitchComponent, + MockComponent(FaIconComponent), + MockPipe(MakeHrefPipe, (_, urlParams) => of(urlParams.lang)), + ], imports: [NgbDropdownModule, RouterTestingModule], providers: [{ provide: AppFacade, useFactory: () => instance(appFacade) }], }).compileComponents(); @@ -57,8 +62,8 @@ describe('Language Switch Component', () => { expect(element.querySelectorAll('li')).toHaveLength(2); expect(element.querySelectorAll('[href]')).toMatchInlineSnapshot(` NodeList [ - English , - FranĀ¢aise , + English , + FranĀ¢aise , ] `); expect(element.querySelector('.language-switch-current-selection').textContent).toMatchInlineSnapshot(`"de"`); diff --git a/src/environments/environment.model.ts b/src/environments/environment.model.ts index d6601ee0fe..801ad2e1d7 100644 --- a/src/environments/environment.model.ts +++ b/src/environments/environment.model.ts @@ -3,6 +3,7 @@ import { CookieConsentOptions } from 'ish-core/models/cookies/cookies.model'; import { Locale } from 'ish-core/models/locale/locale.model'; import { DeviceType, ViewType } from 'ish-core/models/viewtype/viewtype.types'; import { DataRetentionPolicy } from 'ish-core/utils/meta-reducers'; +import { MultiSiteLocaleMap } from 'ish-core/utils/multi-site/multi-site.service'; import { TactonConfig } from '../app/extensions/tacton/models/tacton-config/tacton-config.model'; @@ -83,9 +84,12 @@ export interface Environment { defaultLocale?: string; - // configuration of the available locales - hard coded for now + // configuration of the possible locales (filtered by the locales activated by the server) locales: Locale[]; + // multi-site URLs to locales mapping ('undefined' if mapping should not be used) + multiSiteLocaleMap: MultiSiteLocaleMap; + // configuration of the styling theme ('default' if not configured) // format: 'themeName|themeColor' e.g. theme: 'blue|688dc3', theme?: string; @@ -138,6 +142,11 @@ export const ENVIRONMENT_DEFAULTS: Environment = { { lang: 'de_DE', currency: 'EUR', value: 'de', displayName: 'German', displayLong: 'German (Germany)' }, { lang: 'fr_FR', currency: 'EUR', value: 'fr', displayName: 'French', displayLong: 'French (France)' }, ], + multiSiteLocaleMap: { + en_US: '/en', + de_DE: '/de', + fr_FR: '/fr', + }, cookieConsentOptions: { options: { required: { diff --git a/tslint-rules/src/useCamelCaseEnvironmentPropertiesRule.ts b/tslint-rules/src/useCamelCaseEnvironmentPropertiesRule.ts index 277b6b5589..931626134f 100644 --- a/tslint-rules/src/useCamelCaseEnvironmentPropertiesRule.ts +++ b/tslint-rules/src/useCamelCaseEnvironmentPropertiesRule.ts @@ -10,7 +10,7 @@ export class Rule extends Lint.Rules.AbstractRule { return this.applyWithFunction(sourceFile, ctx => { tsquery(sourceFile, 'PropertyAssignment').forEach((token: PropertyAssignment) => { const identifier = token.name.getText(); - if (!identifier.match(/^[a-z][A-Za-z0-9]*$/)) { + if (!identifier.match(/^([a-z][A-Za-z0-9]*|[a-z][a-z]_[A-Z][A-Z])$/)) { ctx.addFailureAtNode(token, `Property ${token.getText()} is not camelCase formatted.`); } });