Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
68 changes: 68 additions & 0 deletions src/ngx-translate-loaders/theme-i18n.util.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
import { NamedThemeConfig } from '@dspace/config/theme.config';

import { resolveActiveThemeChain } from './theme-i18n.util';

describe('resolveActiveThemeChain', () => {
const theme = (name: string, ext?: string): NamedThemeConfig =>
ext ? { name, extends: ext } : { name };

it('returns an empty array when activeName is empty', () => {
expect(resolveActiveThemeChain([theme('custom')], '')).toEqual([]);
});

it('returns [active] when the active theme has no extends', () => {
expect(resolveActiveThemeChain([theme('custom')], 'custom')).toEqual(['custom']);
});

it('returns [parent, active] when active extends a configured parent', () => {
expect(resolveActiveThemeChain(
[theme('custom'), theme('child-theme', 'custom')],
'child-theme',
)).toEqual(['custom', 'child-theme']);
});

it('resolves the chain even when the active theme is listed before its parent', () => {
expect(resolveActiveThemeChain(
[theme('child-theme', 'parent-theme'), theme('parent-theme')],
'child-theme',
)).toEqual(['parent-theme', 'child-theme']);
});

it('expands a multi-level chain (root → mid → active)', () => {
expect(resolveActiveThemeChain(
[theme('child-theme', 'parent-theme'), theme('parent-theme', 'base-theme'), theme('base-theme')],
'child-theme',
)).toEqual(['base-theme', 'parent-theme', 'child-theme']);
});

it('includes an ancestor even when it is not separately listed in configured themes', () => {
expect(resolveActiveThemeChain(
[theme('child-theme', 'parent-theme')],
'child-theme',
)).toEqual(['parent-theme', 'child-theme']);
});

it('does NOT include sibling themes — only the active chain', () => {
// sibling-theme and child-theme both extend parent-theme; when child-theme is active,
// sibling-theme must not appear in the chain.
const chain = resolveActiveThemeChain(
[theme('parent-theme'), theme('child-theme', 'parent-theme'), theme('sibling-theme', 'parent-theme')],
'child-theme',
);
expect(chain).toEqual(['parent-theme', 'child-theme']);
expect(chain).not.toContain('sibling-theme');
});

it('terminates and includes each theme once when extends forms a cycle', () => {
const order = resolveActiveThemeChain(
[theme('theme-a', 'theme-b'), theme('theme-b', 'theme-a')],
'theme-a',
);
expect(order.length).toBe(2);
expect([...order].sort()).toEqual(['theme-a', 'theme-b']);
});

it('returns [active] when activeName does not match any configured theme', () => {
expect(resolveActiveThemeChain([theme('custom')], 'unknown-theme')).toEqual(['unknown-theme']);
});
});
44 changes: 44 additions & 0 deletions src/ngx-translate-loaders/theme-i18n.util.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
import { ThemeConfig } from '@dspace/config/theme.config';

/**
* Resolve the i18n load order for a single **active** theme, walking up its
* `extends` chain so ancestors are loaded before descendants (ancestor keys
* are overridden by descendant keys).
*
* Only the active theme's inheritance chain is returned. Sibling themes that
* are configured but not active are intentionally excluded — loading all
* configured themes caused cross-theme key pollution (a sibling theme's
* translations silently overriding the active theme's keys).
*
* This mirrors the official `merge-i18n` CLI approach, which merges exactly
* one theme at a time: `npm run merge-i18n -- -s src/themes/<active>/assets/i18n`.
*
* `extends` cycles are handled gracefully (each theme name appears at most once).
*
* Examples (config `[base-theme, child-theme (extends base-theme), sibling-theme (extends base-theme)]`):
* active = 'base-theme' (no extends) → ['base-theme']
* active = 'child-theme' (extends: 'base-theme') → ['base-theme', 'child-theme']
* active = 'sibling-theme' (extends: 'base-theme') → ['base-theme', 'sibling-theme']
*
* @param configured the configured themes (typically `environment.themes`)
* @param activeName the name of the active/default theme
* @returns ordered list of theme names to load (root ancestor → active theme)
*/
export function resolveActiveThemeChain(configured: ThemeConfig[], activeName: string): string[] {
const byName = new Map<string, ThemeConfig>();
configured.forEach((t) => {
if (t?.name) {
byName.set(t.name, t);
}
});

const chain: string[] = [];
const visited = new Set<string>();
let cursor: string | undefined = activeName;
while (cursor && !visited.has(cursor)) {
visited.add(cursor);
chain.push(cursor);
cursor = byName.get(cursor)?.extends;
}
return chain.reverse(); // ancestor → descendant
}
174 changes: 174 additions & 0 deletions src/ngx-translate-loaders/translate-browser.loader.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,174 @@
import {
of,
throwError,
} from 'rxjs';

import { environment } from '../environments/environment';
import { TranslateBrowserLoader } from './translate-browser.loader';

describe('TranslateBrowserLoader', () => {
const PREFIX = 'assets/i18n/';
const SUFFIX = '.json';

let transferState: { get: jasmine.Spy; set: jasmine.Spy };
let http: { get: jasmine.Spy };
let loader: TranslateBrowserLoader;
let originalThemes: typeof environment.themes;

/** Match the base i18n request regardless of the production content hash. */
const isBaseRequest = (url: string): boolean => url.startsWith(`${PREFIX}en`) && url.endsWith(SUFFIX);

beforeEach(() => {
transferState = {
get: jasmine.createSpy('get'),
set: jasmine.createSpy('set'),
};
http = { get: jasmine.createSpy('get') };
loader = new TranslateBrowserLoader(transferState as any, http as any, PREFIX, SUFFIX);
originalThemes = environment.themes;
});

afterEach(() => {
environment.themes = originalThemes;
});

it('returns the cached translations from the TransferState without any HTTP call', (done) => {
const cached = { 'home.title': 'Cached' };
transferState.get.and.returnValue({ en: cached });

loader.getTranslation('en').subscribe((messages) => {
expect(messages).toEqual(cached);
expect(http.get).not.toHaveBeenCalled();
done();
});
});

it('fetches the base file and merges theme overrides on top (theme keys win)', (done) => {
environment.themes = [{ name: 'custom' }];
transferState.get.and.returnValue({});
http.get.and.callFake((url: string) => {
if (isBaseRequest(url)) {
return of(JSON.stringify({ 'home.title': 'Base', 'home.subtitle': 'Base sub' }));
}
if (url === 'assets/custom/i18n/en.json5') {
return of('{ "home.title": "Custom" }');
}
return throwError(() => new Error('404'));
});

loader.getTranslation('en').subscribe((messages) => {
expect(messages).toEqual({ 'home.title': 'Custom', 'home.subtitle': 'Base sub' });
done();
});
});

it('falls back to the base translations when a theme override file is missing', (done) => {
environment.themes = [{ name: 'custom' }];
transferState.get.and.returnValue({});
http.get.and.callFake((url: string) => {
if (isBaseRequest(url)) {
return of(JSON.stringify({ 'home.title': 'Base' }));
}
return throwError(() => new Error('404'));
});

loader.getTranslation('en').subscribe((messages) => {
expect(messages).toEqual({ 'home.title': 'Base' });
done();
});
});

it('applies overrides in inheritance order so descendant themes win over ancestors', (done) => {
environment.themes = [{ name: 'child-theme', extends: 'parent-theme' }, { name: 'parent-theme' }];
transferState.get.and.returnValue({});
http.get.and.callFake((url: string) => {
if (isBaseRequest(url)) {
return of(JSON.stringify({ key: 'base' }));
}
if (url === 'assets/parent-theme/i18n/en.json5') {
return of('{ key: "parent" }');
}
if (url === 'assets/child-theme/i18n/en.json5') {
return of('{ key: "child" }');
}
return throwError(() => new Error('404'));
});

loader.getTranslation('en').subscribe((messages) => {
expect(messages.key).toBe('child');
done();
});
});

it('returns the base translations unchanged when no themes are configured', (done) => {
environment.themes = [];
transferState.get.and.returnValue({});
http.get.and.callFake((url: string) => {
if (isBaseRequest(url)) {
return of(JSON.stringify({ 'home.title': 'Base' }));
}
return throwError(() => new Error('404'));
});

loader.getTranslation('en').subscribe((messages) => {
expect(messages).toEqual({ 'home.title': 'Base' });
done();
});
});

it('does NOT apply sibling theme overrides when the active theme is first in the list', (done) => {
// Regression: when parent-theme is active, a sibling theme that also extends parent-theme
// must NOT override parent-theme's translations.
environment.themes = [
{ name: 'parent-theme' },
{ name: 'sibling-theme', extends: 'parent-theme' },
];
transferState.get.and.returnValue({});
http.get.and.callFake((url: string) => {
if (isBaseRequest(url)) {
return of(JSON.stringify({ key: 'base' }));
}
if (url === 'assets/parent-theme/i18n/en.json5') {
return of('{ key: "parent" }');
}
if (url === 'assets/sibling-theme/i18n/en.json5') {
// sibling has its own value for the same key — must NOT win when parent-theme is active
return of('{ key: "sibling" }');
}
return throwError(() => new Error('404'));
});

loader.getTranslation('en').subscribe((messages) => {
expect(messages.key).toBe('parent');
done();
});
});

it('applies the full ancestor chain when a child theme is active', (done) => {
// When child-theme (extends parent-theme) is active, parent-theme keys provide the base
// and child-theme keys override only what they define.
environment.themes = [
{ name: 'child-theme', extends: 'parent-theme' },
{ name: 'parent-theme' },
];
transferState.get.and.returnValue({});
http.get.and.callFake((url: string) => {
if (isBaseRequest(url)) {
return of(JSON.stringify({ 'key.a': 'base-a', 'key.b': 'base-b' }));
}
if (url === 'assets/parent-theme/i18n/en.json5') {
return of('{ "key.a": "parent-a" }');
}
if (url === 'assets/child-theme/i18n/en.json5') {
return of('{ "key.b": "child-b" }');
}
return throwError(() => new Error('404'));
});

loader.getTranslation('en').subscribe((messages) => {
expect(messages['key.a']).toBe('parent-a'); // inherited from parent-theme
expect(messages['key.b']).toBe('child-b'); // overridden by child-theme
done();
});
});
});
63 changes: 53 additions & 10 deletions src/ngx-translate-loaders/translate-browser.loader.ts
Original file line number Diff line number Diff line change
@@ -1,22 +1,37 @@
import { HttpClient } from '@angular/common/http';
import { TransferState } from '@angular/core';
import { AppConfig } from '@dspace/config/app-config.interface';
import { getDefaultThemeConfig } from '@dspace/config/config.util';
import { hasValue } from '@dspace/shared/utils/empty.util';
import { TranslateLoader } from '@ngx-translate/core';
import JSON5 from 'json5';
import {
forkJoin,
Observable,
of,
} from 'rxjs';
import { map } from 'rxjs/operators';
import {
catchError,
map,
} from 'rxjs/operators';

import { environment } from '../environments/environment';
import {
NGX_TRANSLATE_STATE,
NgxTranslateState,
} from './ngx-translate-state';
import { resolveActiveThemeChain } from './theme-i18n.util';

/**
* A TranslateLoader for ngx-translate to retrieve i18n messages from the TransferState, or download
* them if they're not available there
* them if they're not available there.
*
* Also fetches per-theme translation overrides from `assets/<theme>/i18n/<lang>.json5` for the
* active theme and its ancestors (root → active), merging them on top of the base translations
* so the active theme's keys win. Only the active theme's inheritance chain is loaded — sibling
* themes are excluded to prevent cross-theme key pollution.
* This removes the need for the build-time `merge-i18n` script, allowing a single Docker image
* to serve multiple customers with their own theme-specific translations.
*/
export class TranslateBrowserLoader implements TranslateLoader {
constructor(
Expand All @@ -35,18 +50,46 @@ export class TranslateBrowserLoader implements TranslateLoader {
*/
getTranslation(lang: string): Observable<any> {
// Get the ngx-translate messages from the transfer state, to speed up the initial page load
// client side
// client side. The server has already merged theme overrides into this state.
const state = this.transferState.get<NgxTranslateState>(NGX_TRANSLATE_STATE, {});
const messages = state[lang];
if (hasValue(messages)) {
return of(messages);
} else {
const translationHash: string = environment.production ? `.${(process.env.languageHashes as any)[lang + '.json5']}` : '';
// If they're not available on the transfer state (e.g. when running in dev mode), retrieve
// them using HttpClient
return this.http.get(`${this.prefix}${lang}${translationHash}${this.suffix}`, { responseType: 'text' }).pipe(
map((json: any) => JSON.parse(json)),
);
}

// Fetch base translations + the active theme's override files (root ancestor → active theme).
// Only the active theme's inheritance chain is loaded; sibling themes are excluded.
const base$ = this.fetchBase(lang);
const activeName = getDefaultThemeConfig(environment as unknown as AppConfig).name;
const orderedThemes = resolveActiveThemeChain(environment.themes ?? [], activeName);
const themeStreams = orderedThemes.map((name: string) => this.fetchThemeOverride(name, lang));

return forkJoin([base$, ...themeStreams]).pipe(
map((parts) => parts.reduce((acc, part) => Object.assign(acc, part), {})),
);
}

/**
* Fetch the base i18n file (the hashed JSON produced by the build pipeline).
*/
protected fetchBase(lang: string): Observable<Record<string, string>> {
const translationHash: string = environment.production
? `.${(process.env.languageHashes as any)[lang + '.json5']}`
: '';
return this.http.get(`${this.prefix}${lang}${translationHash}${this.suffix}`, { responseType: 'text' }).pipe(
map((json: any) => JSON.parse(json)),
catchError(() => of({})),
);
}

/**
* Fetch the override JSON5 file for the given theme/language.
* Returns an empty object if the file is missing (most themes won't override every language).
*/
protected fetchThemeOverride(themeName: string, lang: string): Observable<Record<string, string>> {
return this.http.get(`assets/${themeName}/i18n/${lang}.json5`, { responseType: 'text' }).pipe(
map((text: string) => JSON5.parse(text) as Record<string, string>),
catchError(() => of({})),
);
}
}
Loading
Loading