diff --git a/src/app/app.component.spec.ts b/src/app/app.component.spec.ts index 3f2dc45ce7..937b71eb5a 100644 --- a/src/app/app.component.spec.ts +++ b/src/app/app.component.spec.ts @@ -171,7 +171,8 @@ describe('App component', () => { TestBed.configureTestingModule(getDefaultTestBedConf()); TestBed.overrideProvider(ThemeService, {useValue: getMockThemeService('custom')}); document = TestBed.inject(DOCUMENT); - headSpy = jasmine.createSpyObj('head', ['appendChild']); + headSpy = jasmine.createSpyObj('head', ['appendChild', 'getElementsByClassName']); + headSpy.getElementsByClassName.and.returnValue([]); spyOn(document, 'getElementsByTagName').and.returnValue([headSpy]); fixture = TestBed.createComponent(AppComponent); comp = fixture.componentInstance; diff --git a/src/app/app.component.ts b/src/app/app.component.ts index 6f06a84144..03075f4f18 100644 --- a/src/app/app.component.ts +++ b/src/app/app.component.ts @@ -31,12 +31,12 @@ import { AuthService } from './core/auth/auth.service'; import { CSSVariableService } from './shared/sass-helper/sass-helper.service'; import { MenuService } from './shared/menu/menu.service'; import { HostWindowService } from './shared/host-window.service'; -import { ThemeConfig } from '../config/theme.model'; +import { HeadTagConfig, ThemeConfig } from '../config/theme.model'; import { Angulartics2DSpace } from './statistics/angulartics/dspace-provider'; import { environment } from '../environments/environment'; import { models } from './core/core.module'; import { LocaleService } from './core/locale/locale.service'; -import { hasValue, isNotEmpty } from './shared/empty.util'; +import { hasNoValue, hasValue, isNotEmpty } from './shared/empty.util'; import { KlaroService } from './shared/cookies/klaro.service'; import { GoogleAnalyticsService } from './statistics/google-analytics.service'; import { DOCUMENT, isPlatformBrowser } from '@angular/common'; @@ -115,11 +115,11 @@ export class AppComponent implements OnInit, AfterViewInit { this.isThemeCSSLoading$.next(true); } if (hasValue(themeName)) { - this.setThemeCss(themeName); + this.loadGlobalThemeConfig(themeName); } else if (hasValue(DEFAULT_THEME_CONFIG)) { - this.setThemeCss(DEFAULT_THEME_CONFIG.name); + this.loadGlobalThemeConfig(DEFAULT_THEME_CONFIG.name); } else { - this.setThemeCss(BASE_THEME_NAME); + this.loadGlobalThemeConfig(BASE_THEME_NAME); } }); @@ -233,6 +233,11 @@ export class AppComponent implements OnInit, AfterViewInit { } } + private loadGlobalThemeConfig(themeName: string): void { + this.setThemeCss(themeName); + this.setHeadTags(themeName); + } + /** * Update the theme css file in
* @@ -241,9 +246,13 @@ export class AppComponent implements OnInit, AfterViewInit { */ private setThemeCss(themeName: string): void { const head = this.document.getElementsByTagName('head')[0]; + if (hasNoValue(head)) { + return; + } + // Array.from to ensure we end up with an array, not an HTMLCollection, which would be // automatically updated if we add nodes later - const currentThemeLinks = Array.from(this.document.getElementsByClassName('theme-css')); + const currentThemeLinks = Array.from(head.getElementsByClassName('theme-css')); const link = this.document.createElement('link'); link.setAttribute('rel', 'stylesheet'); link.setAttribute('type', 'text/css'); @@ -265,6 +274,78 @@ export class AppComponent implements OnInit, AfterViewInit { head.appendChild(link); } + private setHeadTags(themeName: string): void { + const head = this.document.getElementsByTagName('head')[0]; + if (hasNoValue(head)) { + return; + } + + // clear head tags + const currentHeadTags = Array.from(head.getElementsByClassName('theme-head-tag')); + if (hasValue(currentHeadTags)) { + currentHeadTags.forEach((currentHeadTag: any) => currentHeadTag.remove()); + } + + // create new head tags (not yet added to DOM) + const headTagFragment = this.document.createDocumentFragment(); + this.createHeadTags(themeName) + .forEach(newHeadTag => headTagFragment.appendChild(newHeadTag)); + + // add new head tags to DOM + head.appendChild(headTagFragment); + } + + private createHeadTags(themeName: string): HTMLElement[] { + const themeConfig = this.themeService.getThemeConfigFor(themeName); + const headTagConfigs = themeConfig?.headTags; + + if (hasNoValue(headTagConfigs)) { + const parentThemeName = themeConfig?.extends; + if (hasValue(parentThemeName)) { + // inherit the head tags of the parent theme + return this.createHeadTags(parentThemeName); + } + + const defaultThemeName = DEFAULT_THEME_CONFIG.name; + if ( + hasNoValue(defaultThemeName) || + themeName === defaultThemeName || + themeName === BASE_THEME_NAME + ) { + // last resort, use fallback favicon.ico + return [ + this.createHeadTag({ + 'tagName': 'link', + 'attributes': { + 'rel': 'icon', + 'href': 'assets/images/favicon.ico', + 'sizes': 'any', + } + }) + ]; + } + + // inherit the head tags of the default theme + return this.createHeadTags(DEFAULT_THEME_CONFIG.name); + } + + return headTagConfigs.map(this.createHeadTag.bind(this)); + } + + private createHeadTag(headTagConfig: HeadTagConfig): HTMLElement { + const tag = this.document.createElement(headTagConfig.tagName); + + if (hasValue(headTagConfig.attributes)) { + Object.entries(headTagConfig.attributes) + .forEach(([key, value]) => tag.setAttribute(key, value)); + } + + // 'class' attribute should always be 'theme-head-tag' for removal + tag.setAttribute('class', 'theme-head-tag'); + + return tag; + } + private trackIdleModal() { const isIdle$ = this.authService.isUserIdle(); const isAuthenticated$ = this.authService.isAuthenticated(); diff --git a/src/assets/images/favicon.ico b/src/assets/images/favicon.ico index c73ad96b26..a5dfd5e322 100644 Binary files a/src/assets/images/favicon.ico and b/src/assets/images/favicon.ico differ diff --git a/src/config/theme.model.ts b/src/config/theme.model.ts index 0130b5ffd8..d65118d5d4 100644 --- a/src/config/theme.model.ts +++ b/src/config/theme.model.ts @@ -12,6 +12,28 @@ export interface NamedThemeConfig extends Config { * its ancestor theme(s) will be checked recursively before falling back to the default theme. */ extends?: string; + + /** + * A list of HTML tags that should be added to the HEAD section of the document, whenever this theme is active. + */ + headTags?: HeadTagConfig[]; +} + +/** + * Interface that represents a single theme-specific HTML tag in the HEAD section of the page. + */ +export interface HeadTagConfig extends Config { + /** + * The name of the HTML tag + */ + tagName: string; + + /** + * The attributes on the HTML tag + */ + attributes?: { + [key: string]: string; + }; } export interface RegExThemeConfig extends NamedThemeConfig { diff --git a/src/environments/environment.common.ts b/src/environments/environment.common.ts index b1cbd699a3..2eaed25195 100644 --- a/src/environments/environment.common.ts +++ b/src/environments/environment.common.ts @@ -292,7 +292,45 @@ export const environment: GlobalConfig = { { // The default dspace theme - name: 'dspace' + name: 'dspace', + // Whenever this theme is active, the following tags will be injected into the of the page. + // Example use case: set the favicon based on the active theme. + headTags: [ + { + // Insert into the of the page. + tagName: 'link', + attributes: { + 'rel': 'icon', + 'href': 'assets/dspace/images/favicons/favicon.ico', + 'sizes': 'any', + } + }, + { + // Insert into the of the page. + tagName: 'link', + attributes: { + 'rel': 'icon', + 'href': 'assets/dspace/images/favicons/favicon.svg', + 'type': 'image/svg+xml', + } + }, + { + // Insert into the of the page. + tagName: 'link', + attributes: { + 'rel': 'apple-touch-icon', + 'href': 'assets/dspace/images/favicons/apple-touch-icon.png', + } + }, + { + // Insert into the of the page. + tagName: 'link', + attributes: { + 'rel': 'manifest', + 'href': 'assets/dspace/images/favicons/manifest.webmanifest', + } + }, + ] }, ], // Whether to enable media viewer for image and/or video Bitstreams (i.e. Bitstreams whose MIME type starts with "image" or "video"). diff --git a/src/index.csr.html b/src/index.csr.html index 8c25dfa084..d23cb2cae3 100644 --- a/src/index.csr.html +++ b/src/index.csr.html @@ -6,7 +6,6 @@