diff --git a/src/app/shared/metadata-representation/metadata-representation.decorator.spec.ts b/src/app/shared/metadata-representation/metadata-representation.decorator.spec.ts index 25c5be0129..a9988aad6f 100644 --- a/src/app/shared/metadata-representation/metadata-representation.decorator.spec.ts +++ b/src/app/shared/metadata-representation/metadata-representation.decorator.spec.ts @@ -7,12 +7,17 @@ import { import { MetadataRepresentationType } from '../../core/shared/metadata-representation/metadata-representation.model'; import { Context } from '../../core/shared/context.model'; import * as uuidv4 from 'uuid/v4'; +import { environment } from '../../../environments/environment'; + +let ogEnvironmentThemes; describe('MetadataRepresentation decorator function', () => { const type1 = 'TestType'; const type2 = 'TestType2'; const type3 = 'TestType3'; const type4 = 'RandomType'; + const typeHier1 = 'TestTypeHier1'; + const typeHier2 = 'TestTypeHier2'; let prefix; /* tslint:disable:max-classes-per-file */ @@ -31,6 +36,12 @@ describe('MetadataRepresentation decorator function', () => { class Test3ItemSubmission { } + class TestHier1Ancestor { + } + + class TestHier2Unthemed { + } + /* tslint:enable:max-classes-per-file */ beforeEach(() => { @@ -46,8 +57,17 @@ describe('MetadataRepresentation decorator function', () => { metadataRepresentationComponent(key + type2, MetadataRepresentationType.Item, Context.Workspace)(Test2ItemSubmission); metadataRepresentationComponent(key + type3, MetadataRepresentationType.Item, Context.Workspace)(Test3ItemSubmission); + + metadataRepresentationComponent(key + typeHier1, MetadataRepresentationType.Item, Context.Any, 'ancestor')(TestHier1Ancestor); + metadataRepresentationComponent(key + typeHier2, MetadataRepresentationType.Item, Context.Any)(TestHier2Unthemed); + + ogEnvironmentThemes = environment.themes; } + afterEach(() => { + environment.themes = ogEnvironmentThemes; + }); + describe('If there\'s an exact match', () => { it('should return the matching class', () => { const component = getMetadataRepresentationComponent(prefix + type3, MetadataRepresentationType.Item, Context.Workspace); @@ -76,4 +96,44 @@ describe('MetadataRepresentation decorator function', () => { }); }); }); + + describe('With theme extensions', () => { + describe('If requested theme has no match', () => { + beforeEach(() => { + environment.themes = [ + { name: 'requested', extends: 'intermediate' }, + { name: 'intermediate', extends: 'ancestor' }, + ]; + }); + + it('should return component from ancestor theme if it has a match', () => { + const component = getMetadataRepresentationComponent(prefix + typeHier1, MetadataRepresentationType.Item, Context.Any, 'requested'); + expect(component).toEqual(TestHier1Ancestor); + }); + + it('should return default component if ancestor theme has no match', () => { + const component = getMetadataRepresentationComponent(prefix + typeHier2, MetadataRepresentationType.Item, Context.Any, 'requested'); + expect(component).toEqual(TestHier2Unthemed); + }); + }); + + describe('If there is a theme extension cycle', () => { + beforeEach(() => { + environment.themes = [ + { name: 'extension-cycle', extends: 'broken1' }, + { name: 'broken1', extends: 'broken2' }, + { name: 'broken2', extends: 'broken3' }, + { name: 'broken3', extends: 'broken1' }, + ]; + }); + + it('should throw an error', () => { + expect(() => { + getMetadataRepresentationComponent(prefix + typeHier1, MetadataRepresentationType.Item, Context.Any, 'extension-cycle'); + }).toThrowError( + 'Theme extension cycle detected: extension-cycle -> broken1 -> broken2 -> broken3 -> broken1' + ); + }); + }); + }); }); diff --git a/src/app/shared/metadata-representation/metadata-representation.decorator.ts b/src/app/shared/metadata-representation/metadata-representation.decorator.ts index 0b5bea33d9..eca08c5c62 100644 --- a/src/app/shared/metadata-representation/metadata-representation.decorator.ts +++ b/src/app/shared/metadata-representation/metadata-representation.decorator.ts @@ -3,6 +3,7 @@ import { hasNoValue, hasValue } from '../empty.util'; import { Context } from '../../core/shared/context.model'; import { InjectionToken } from '@angular/core'; import { GenericConstructor } from '../../core/shared/generic-constructor'; +import { resolveTheme } from '../object-collection/shared/listable-object/listable-object.decorator'; export const METADATA_REPRESENTATION_COMPONENT_FACTORY = new InjectionToken<(entityType: string, mdRepresentationType: MetadataRepresentationType, context: Context, theme: string) => GenericConstructor>('getMetadataRepresentationComponent', { providedIn: 'root', @@ -57,8 +58,9 @@ export function getMetadataRepresentationComponent(entityType: string, mdReprese if (hasValue(entityAndMDRepMap)) { const contextMap = entityAndMDRepMap.get(context); if (hasValue(contextMap)) { - if (hasValue(contextMap.get(theme))) { - return contextMap.get(theme); + const match = resolveTheme(contextMap, theme); + if (hasValue(match)) { + return match; } if (hasValue(contextMap.get(DEFAULT_THEME))) { return contextMap.get(DEFAULT_THEME); diff --git a/src/app/shared/mocks/theme-service.mock.ts b/src/app/shared/mocks/theme-service.mock.ts index 3594270807..058ba993bc 100644 --- a/src/app/shared/mocks/theme-service.mock.ts +++ b/src/app/shared/mocks/theme-service.mock.ts @@ -1,9 +1,18 @@ import { ThemeService } from '../theme-support/theme.service'; import { of as observableOf } from 'rxjs'; +import { ThemeConfig } from '../../../config/theme.model'; +import { isNotEmpty } from '../empty.util'; -export function getMockThemeService(themeName = 'base'): ThemeService { - return jasmine.createSpyObj('themeService', { +export function getMockThemeService(themeName = 'base', themes?: ThemeConfig[]): ThemeService { + const spy = jasmine.createSpyObj('themeService', { getThemeName: themeName, - getThemeName$: observableOf(themeName) + getThemeName$: observableOf(themeName), + getThemeConfigFor: undefined, }); + + if (isNotEmpty(themes)) { + spy.getThemeConfigFor.and.callFake((name: string) => themes.find(theme => theme.name === name)); + } + + return spy; } diff --git a/src/app/shared/object-collection/shared/listable-object/listable-object.decorator.spec.ts b/src/app/shared/object-collection/shared/listable-object/listable-object.decorator.spec.ts index 19765f86b0..d3c7a5a1e1 100644 --- a/src/app/shared/object-collection/shared/listable-object/listable-object.decorator.spec.ts +++ b/src/app/shared/object-collection/shared/listable-object/listable-object.decorator.spec.ts @@ -2,11 +2,16 @@ import { Item } from '../../../../core/shared/item.model'; import { ViewMode } from '../../../../core/shared/view-mode.model'; import { getListableObjectComponent, listableObjectComponent } from './listable-object.decorator'; import { Context } from '../../../../core/shared/context.model'; +import { environment } from '../../../../../environments/environment'; + +let ogEnvironmentThemes; describe('ListableObject decorator function', () => { const type1 = 'TestType'; const type2 = 'TestType2'; const type3 = 'TestType3'; + const typeHier1 = 'TestTypeHier1'; + const typeHier2 = 'TestTypeHier2'; /* tslint:disable:max-classes-per-file */ class Test1List { @@ -27,6 +32,12 @@ describe('ListableObject decorator function', () => { class Test3DetailedSubmission { } + class TestHier1Ancestor { + } + + class TestHier2Unthemed { + } + /* tslint:enable:max-classes-per-file */ beforeEach(() => { @@ -38,6 +49,15 @@ describe('ListableObject decorator function', () => { listableObjectComponent(type3, ViewMode.ListElement)(Test3List); listableObjectComponent(type3, ViewMode.DetailedListElement, Context.Workspace)(Test3DetailedSubmission); + + listableObjectComponent(typeHier1, ViewMode.ListElement, Context.Any, 'ancestor')(TestHier1Ancestor); + listableObjectComponent(typeHier2, ViewMode.ListElement, Context.Any)(TestHier2Unthemed); + + ogEnvironmentThemes = environment.themes; + }); + + afterEach(() => { + environment.themes = ogEnvironmentThemes; }); const gridDecorator = listableObjectComponent('Item', ViewMode.GridElement); @@ -80,4 +100,44 @@ describe('ListableObject decorator function', () => { }); }); }); + + describe('With theme extensions', () => { + describe('If requested theme has no match', () => { + beforeEach(() => { + environment.themes = [ + { name: 'requested', extends: 'intermediate' }, + { name: 'intermediate', extends: 'ancestor' }, + ]; + }); + + it('should return component from ancestor theme if it has a match', () => { + const component = getListableObjectComponent([typeHier1], ViewMode.ListElement, Context.Any, 'requested'); + expect(component).toEqual(TestHier1Ancestor); + }); + + it('should return default component if ancestor theme has no match', () => { + const component = getListableObjectComponent([typeHier2], ViewMode.ListElement, Context.Any, 'requested'); + expect(component).toEqual(TestHier2Unthemed); + }); + }); + + describe('If there is a theme extension cycle', () => { + beforeEach(() => { + environment.themes = [ + { name: 'extension-cycle', extends: 'broken1' }, + { name: 'broken1', extends: 'broken2' }, + { name: 'broken2', extends: 'broken3' }, + { name: 'broken3', extends: 'broken1' }, + ]; + }); + + it('should throw an error', () => { + expect(() => { + getListableObjectComponent([typeHier1], ViewMode.ListElement, Context.Any, 'extension-cycle'); + }).toThrowError( + 'Theme extension cycle detected: extension-cycle -> broken1 -> broken2 -> broken3 -> broken1' + ); + }); + }); + }); }); diff --git a/src/app/shared/object-collection/shared/listable-object/listable-object.decorator.ts b/src/app/shared/object-collection/shared/listable-object/listable-object.decorator.ts index 91140f0ea1..5df2683bb9 100644 --- a/src/app/shared/object-collection/shared/listable-object/listable-object.decorator.ts +++ b/src/app/shared/object-collection/shared/listable-object/listable-object.decorator.ts @@ -1,15 +1,26 @@ import { ViewMode } from '../../../../core/shared/view-mode.model'; import { Context } from '../../../../core/shared/context.model'; -import { hasNoValue, hasValue } from '../../../empty.util'; +import { hasNoValue, hasValue, isNotEmpty } from '../../../empty.util'; import { DEFAULT_CONTEXT, DEFAULT_THEME } from '../../../metadata-representation/metadata-representation.decorator'; import { GenericConstructor } from '../../../../core/shared/generic-constructor'; import { ListableObject } from '../listable-object.model'; +import { environment } from '../../../../../environments/environment'; +import { ThemeConfig } from '../../../../../config/theme.model'; +import { InjectionToken } from '@angular/core'; export const DEFAULT_VIEW_MODE = ViewMode.ListElement; +/** + * Factory to allow us to inject getThemeConfigFor so we can mock it in tests + */ +export const GET_THEME_CONFIG_FOR_FACTORY = new InjectionToken<(str) => ThemeConfig>('getThemeConfigFor', { + providedIn: 'root', + factory: () => getThemeConfigFor +}); + const map = new Map(); /** @@ -54,8 +65,9 @@ export function getListableObjectComponent(types: (string | GenericConstructor { + return environment.themes.find(theme => theme.name === themeName); +}; + +/** + * Find a match in the given map for the given theme name, taking theme extension into account + * + * @param contextMap A map of theme names to components + * @param themeName The name of the theme to check + * @param checkedThemeNames The list of theme names that are already checked + */ +export const resolveTheme = (contextMap: Map, themeName: string, checkedThemeNames: string[] = []): any => { + const match = contextMap.get(themeName); + if (hasValue(match)) { + return match; + } else { + const cfg = getThemeConfigFor(themeName); + if (hasValue(cfg) && isNotEmpty(cfg.extends)) { + const nextTheme = cfg.extends; + const nextCheckedThemeNames = [...checkedThemeNames, themeName]; + if (checkedThemeNames.includes(nextTheme)) { + throw new Error('Theme extension cycle detected: ' + [...nextCheckedThemeNames, nextTheme].join(' -> ')); + } else { + return resolveTheme(contextMap, nextTheme, nextCheckedThemeNames); + } + } + } +}; diff --git a/src/app/shared/theme-support/theme.service.ts b/src/app/shared/theme-support/theme.service.ts index 7b0af93e04..7f000447e4 100644 --- a/src/app/shared/theme-support/theme.service.ts +++ b/src/app/shared/theme-support/theme.service.ts @@ -1,10 +1,16 @@ -import { Injectable } from '@angular/core'; +import { Injectable, Inject } from '@angular/core'; import { Store, createFeatureSelector, createSelector, select } from '@ngrx/store'; import { Observable } from 'rxjs/internal/Observable'; import { ThemeState } from './theme.reducer'; import { SetThemeAction } from './theme.actions'; import { take } from 'rxjs/operators'; import { hasValue } from '../empty.util'; +import { ThemeConfig } from '../../../config/theme.model'; +import { environment } from '../../../environments/environment'; +import { + GET_THEME_CONFIG_FOR_FACTORY, + getThemeConfigFor +} from '../object-collection/shared/listable-object/listable-object.decorator'; export const themeStateSelector = createFeatureSelector('theme'); @@ -19,6 +25,7 @@ export const currentThemeSelector = createSelector( export class ThemeService { constructor( private store: Store, + @Inject(GET_THEME_CONFIG_FOR_FACTORY) private gtcf: (str) => ThemeConfig ) { } @@ -43,4 +50,10 @@ export class ThemeService { ); } + /** + * Searches for a ThemeConfig by its name; + */ + getThemeConfigFor(themeName: string): ThemeConfig { + return this.gtcf(themeName); + } } diff --git a/src/app/shared/theme-support/themed.component.spec.ts b/src/app/shared/theme-support/themed.component.spec.ts index abaee28a29..1db6de072d 100644 --- a/src/app/shared/theme-support/themed.component.spec.ts +++ b/src/app/shared/theme-support/themed.component.spec.ts @@ -5,6 +5,7 @@ import { VarDirective } from '../utils/var.directive'; import { ThemeService } from './theme.service'; import { getMockThemeService } from '../mocks/theme-service.mock'; import { TestComponent } from './test/test.component.spec'; +import { ThemeConfig } from '../../../config/theme.model'; /* tslint:disable:max-classes-per-file */ @Component({ @@ -32,8 +33,8 @@ describe('ThemedComponent', () => { let fixture: ComponentFixture; let themeService: ThemeService; - function setupTestingModuleForTheme(theme: string) { - themeService = getMockThemeService(theme); + function setupTestingModuleForTheme(theme: string, themes?: ThemeConfig[]) { + themeService = getMockThemeService(theme, themes); TestBed.configureTestingModule({ imports: [], declarations: [TestThemedComponent, VarDirective], @@ -44,17 +45,20 @@ describe('ThemedComponent', () => { }).compileComponents(); } + function initComponent() { + fixture = TestBed.createComponent(TestThemedComponent); + component = fixture.componentInstance; + spyOn(component as any, 'importThemedComponent').and.callThrough(); + component.testInput = 'changed'; + fixture.detectChanges(); + } + describe('when the current theme matches a themed component', () => { beforeEach(waitForAsync(() => { setupTestingModuleForTheme('custom'); })); - beforeEach(() => { - fixture = TestBed.createComponent(TestThemedComponent); - component = fixture.componentInstance; - component.testInput = 'changed'; - fixture.detectChanges(); - }); + beforeEach(initComponent); it('should set compRef to the themed component', waitForAsync(() => { fixture.whenStable().then(() => { @@ -70,28 +74,127 @@ describe('ThemedComponent', () => { }); describe('when the current theme doesn\'t match a themed component', () => { - beforeEach(waitForAsync(() => { - setupTestingModuleForTheme('non-existing-theme'); - })); + describe('and it doesn\'t extend another theme', () => { + beforeEach(waitForAsync(() => { + setupTestingModuleForTheme('non-existing-theme'); + })); - beforeEach(() => { - fixture = TestBed.createComponent(TestThemedComponent); - component = fixture.componentInstance; - component.testInput = 'changed'; - fixture.detectChanges(); + beforeEach(initComponent); + + it('should set compRef to the default component', waitForAsync(() => { + fixture.whenStable().then(() => { + expect((component as any).compRef.instance.type).toEqual('default'); + }); + })); + + it('should sync up this component\'s input with the default component', waitForAsync(() => { + fixture.whenStable().then(() => { + expect((component as any).compRef.instance.testInput).toEqual('changed'); + }); + })); }); - it('should set compRef to the default component', waitForAsync(() => { - fixture.whenStable().then(() => { - expect((component as any).compRef.instance.type).toEqual('default'); - }); - })); + describe('and it extends another theme', () => { + describe('that doesn\'t match it either', () => { + beforeEach(waitForAsync(() => { + setupTestingModuleForTheme('current-theme', [ + { name: 'current-theme', extends: 'non-existing-theme' }, + ]); + })); - it('should sync up this component\'s input with the default component', waitForAsync(() => { - fixture.whenStable().then(() => { - expect((component as any).compRef.instance.testInput).toEqual('changed'); + beforeEach(initComponent); + + it('should set compRef to the default component', waitForAsync(() => { + fixture.whenStable().then(() => { + expect((component as any).importThemedComponent).toHaveBeenCalledWith('current-theme'); + expect((component as any).importThemedComponent).toHaveBeenCalledWith('non-existing-theme'); + expect((component as any).compRef.instance.type).toEqual('default'); + }); + })); + + it('should sync up this component\'s input with the default component', waitForAsync(() => { + fixture.whenStable().then(() => { + expect((component as any).compRef.instance.testInput).toEqual('changed'); + }); + })); }); - })); + + describe('that does match it', () => { + beforeEach(waitForAsync(() => { + setupTestingModuleForTheme('current-theme', [ + { name: 'current-theme', extends: 'custom' }, + ]); + })); + + beforeEach(initComponent); + + it('should set compRef to the themed component', waitForAsync(() => { + fixture.whenStable().then(() => { + expect((component as any).importThemedComponent).toHaveBeenCalledWith('current-theme'); + expect((component as any).importThemedComponent).toHaveBeenCalledWith('custom'); + expect((component as any).compRef.instance.type).toEqual('themed'); + }); + })); + + it('should sync up this component\'s input with the themed component', waitForAsync(() => { + fixture.whenStable().then(() => { + expect((component as any).compRef.instance.testInput).toEqual('changed'); + }); + })); + }); + + describe('that extends another theme that doesn\'t match it either', () => { + beforeEach(waitForAsync(() => { + setupTestingModuleForTheme('current-theme', [ + { name: 'current-theme', extends: 'parent-theme' }, + { name: 'parent-theme', extends: 'non-existing-theme' }, + ]); + })); + + beforeEach(initComponent); + + it('should set compRef to the default component', waitForAsync(() => { + fixture.whenStable().then(() => { + expect((component as any).importThemedComponent).toHaveBeenCalledWith('current-theme'); + expect((component as any).importThemedComponent).toHaveBeenCalledWith('parent-theme'); + expect((component as any).importThemedComponent).toHaveBeenCalledWith('non-existing-theme'); + expect((component as any).compRef.instance.type).toEqual('default'); + }); + })); + + it('should sync up this component\'s input with the default component', waitForAsync(() => { + fixture.whenStable().then(() => { + expect((component as any).compRef.instance.testInput).toEqual('changed'); + }); + })); + }); + + describe('that extends another theme that does match it', () => { + beforeEach(waitForAsync(() => { + setupTestingModuleForTheme('current-theme', [ + { name: 'current-theme', extends: 'parent-theme' }, + { name: 'parent-theme', extends: 'custom' }, + ]); + })); + + beforeEach(initComponent); + + it('should set compRef to the themed component', waitForAsync(() => { + fixture.whenStable().then(() => { + expect((component as any).importThemedComponent).toHaveBeenCalledWith('current-theme'); + expect((component as any).importThemedComponent).toHaveBeenCalledWith('parent-theme'); + expect((component as any).importThemedComponent).toHaveBeenCalledWith('custom'); + expect((component as any).compRef.instance.type).toEqual('themed'); + }); + })); + + it('should sync up this component\'s input with the themed component', waitForAsync(() => { + fixture.whenStable().then(() => { + expect((component as any).compRef.instance.testInput).toEqual('changed'); + }); + })); + }); + }); }); }); /* tslint:enable:max-classes-per-file */ diff --git a/src/app/shared/theme-support/themed.component.ts b/src/app/shared/theme-support/themed.component.ts index 1a41327209..6646c0aa30 100644 --- a/src/app/shared/theme-support/themed.component.ts +++ b/src/app/shared/theme-support/themed.component.ts @@ -11,7 +11,7 @@ import { OnChanges } from '@angular/core'; import { hasValue, isNotEmpty } from '../empty.util'; -import { Subscription } from 'rxjs'; +import { Observable, of as observableOf, Subscription } from 'rxjs'; import { ThemeService } from './theme.service'; import { fromPromise } from 'rxjs/internal-compatibility'; import { catchError, switchMap, map } from 'rxjs/operators'; @@ -69,31 +69,27 @@ export abstract class ThemedComponent implements OnInit, OnDestroy, OnChanges this.lazyLoadSub.unsubscribe(); } - this.lazyLoadSub = - fromPromise(this.importThemedComponent(this.themeService.getThemeName())).pipe( - // if there is no themed version of the component an exception is thrown, - // catch it and return null instead - catchError(() => [null]), - switchMap((themedFile: any) => { - if (hasValue(themedFile) && hasValue(themedFile[this.getComponentName()])) { - // if the file is not null, and exports a component with the specified name, - // return that component - return [themedFile[this.getComponentName()]]; - } else { - // otherwise import and return the default component - return fromPromise(this.importUnthemedComponent()).pipe( - map((unthemedFile: any) => { - return unthemedFile[this.getComponentName()]; - }) - ); - } - }), - ).subscribe((constructor: GenericConstructor) => { - const factory = this.resolver.resolveComponentFactory(constructor); - this.compRef = this.vcr.createComponent(factory); - this.connectInputsAndOutputs(); - this.cdr.markForCheck(); - }); + this.lazyLoadSub = this.resolveThemedComponent(this.themeService.getThemeName()).pipe( + switchMap((themedFile: any) => { + if (hasValue(themedFile) && hasValue(themedFile[this.getComponentName()])) { + // if the file is not null, and exports a component with the specified name, + // return that component + return [themedFile[this.getComponentName()]]; + } else { + // otherwise import and return the default component + return fromPromise(this.importUnthemedComponent()).pipe( + map((unthemedFile: any) => { + return unthemedFile[this.getComponentName()]; + }) + ); + } + }), + ).subscribe((constructor: GenericConstructor) => { + const factory = this.resolver.resolveComponentFactory(constructor); + this.compRef = this.vcr.createComponent(factory); + this.connectInputsAndOutputs(); + this.cdr.markForCheck(); + }); } protected destroyComponentInstance(): void { @@ -113,4 +109,32 @@ export abstract class ThemedComponent implements OnInit, OnDestroy, OnChanges }); } } + + /** + * Attempt to import this component from the current theme or a theme it {@link NamedThemeConfig.extends}. + * Recurse until we succeed or when until we run out of themes to fall back to. + * + * @param themeName The name of the theme to check + * @param checkedThemeNames The list of theme names that are already checked + * @private + */ + private resolveThemedComponent(themeName?: string, checkedThemeNames: string[] = []): Observable { + if (isNotEmpty(themeName)) { + return fromPromise(this.importThemedComponent(themeName)).pipe( + catchError(() => { + // Try the next ancestor theme instead + const nextTheme = this.themeService.getThemeConfigFor(themeName)?.extends; + const nextCheckedThemeNames = [...checkedThemeNames, themeName]; + if (checkedThemeNames.includes(nextTheme)) { + throw new Error('Theme extension cycle detected: ' + [...nextCheckedThemeNames, nextTheme].join(' -> ')); + } else { + return this.resolveThemedComponent(nextTheme, nextCheckedThemeNames); + } + }), + ); + } else { + // If we got here, we've failed to import this component from any ancestor theme → fall back to unthemed + return observableOf(null); + } + } } diff --git a/src/config/theme.model.ts b/src/config/theme.model.ts index 908589c71c..0130b5ffd8 100644 --- a/src/config/theme.model.ts +++ b/src/config/theme.model.ts @@ -6,6 +6,12 @@ import { getDSORoute } from '../app/app-routing-paths'; // tslint:disable:max-classes-per-file export interface NamedThemeConfig extends Config { name: string; + + /** + * Specify another theme to build upon: whenever a themed component is not found in the current theme, + * its ancestor theme(s) will be checked recursively before falling back to the default theme. + */ + extends?: string; } export interface RegExThemeConfig extends NamedThemeConfig {