diff --git a/src/ngx-translate-loaders/theme-i18n.util.spec.ts b/src/ngx-translate-loaders/theme-i18n.util.spec.ts new file mode 100644 index 00000000000..5d17d0c9adc --- /dev/null +++ b/src/ngx-translate-loaders/theme-i18n.util.spec.ts @@ -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']); + }); +}); diff --git a/src/ngx-translate-loaders/theme-i18n.util.ts b/src/ngx-translate-loaders/theme-i18n.util.ts new file mode 100644 index 00000000000..6f860025660 --- /dev/null +++ b/src/ngx-translate-loaders/theme-i18n.util.ts @@ -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//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(); + configured.forEach((t) => { + if (t?.name) { + byName.set(t.name, t); + } + }); + + const chain: string[] = []; + const visited = new Set(); + 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 +} diff --git a/src/ngx-translate-loaders/translate-browser.loader.spec.ts b/src/ngx-translate-loaders/translate-browser.loader.spec.ts new file mode 100644 index 00000000000..02e5e42d625 --- /dev/null +++ b/src/ngx-translate-loaders/translate-browser.loader.spec.ts @@ -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(); + }); + }); +}); diff --git a/src/ngx-translate-loaders/translate-browser.loader.ts b/src/ngx-translate-loaders/translate-browser.loader.ts index 44e1710b148..efbd109c895 100644 --- a/src/ngx-translate-loaders/translate-browser.loader.ts +++ b/src/ngx-translate-loaders/translate-browser.loader.ts @@ -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//i18n/.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( @@ -35,18 +50,46 @@ export class TranslateBrowserLoader implements TranslateLoader { */ getTranslation(lang: string): Observable { // 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(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> { + 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> { + return this.http.get(`assets/${themeName}/i18n/${lang}.json5`, { responseType: 'text' }).pipe( + map((text: string) => JSON5.parse(text) as Record), + catchError(() => of({})), + ); } } diff --git a/src/ngx-translate-loaders/translate-server.loader.ts b/src/ngx-translate-loaders/translate-server.loader.ts index 06312b3a2b0..82b331bd4c0 100644 --- a/src/ngx-translate-loaders/translate-server.loader.ts +++ b/src/ngx-translate-loaders/translate-server.loader.ts @@ -1,20 +1,39 @@ -import { readFileSync } from 'node:fs'; +import { + existsSync, + readFileSync, +} from 'node:fs'; +import { + dirname, + resolve, +} from 'node:path'; import { TransferState } from '@angular/core'; +import { AppConfig } from '@dspace/config/app-config.interface'; +import { getDefaultThemeConfig } from '@dspace/config/config.util'; import { TranslateLoader } from '@ngx-translate/core'; +import JSON5 from 'json5'; import { Observable, of, } from 'rxjs'; +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 parse json5 files server-side, and store them in the - * TransferState + * TransferState. + * + * Also overlays per-theme translation overrides from `//i18n/.json5` + * for the active theme and its ancestors (in root → active order), 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. + * The resulting merged map is stored in the TransferState so the browser does not need to + * re-fetch the theme overrides after the initial SSR response. */ export class TranslateServerLoader implements TranslateLoader { @@ -32,13 +51,56 @@ export class TranslateServerLoader implements TranslateLoader { */ public getTranslation(lang: string): Observable { const translationHash: string = (process.env.languageHashes as any)[lang + '.json5']; - // Retrieve the file for the given language, and parse it - const messages = JSON.parse(readFileSync(`${this.prefix}${lang}.${translationHash}${this.suffix}`, 'utf8')); - // Store the parsed messages in the transfer state so they'll be available immediately when the - // app loads on the client - this.storeInTransferState(lang, messages); - // Return the parsed messages to translate things server side - return of(messages); + // Retrieve the base file for the given language and parse it + const baseMessages = JSON.parse(readFileSync(`${this.prefix}${lang}.${translationHash}${this.suffix}`, 'utf8')); + + // Overlay theme override files (if any). Theme keys override base keys. + const merged = Object.assign({}, baseMessages, this.readThemeOverrides(lang)); + + // Store the parsed messages in the transfer state so they'll be available immediately when + // the app loads on the client + this.storeInTransferState(lang, merged); + // Return the merged messages to translate things server side + return of(merged); + } + + /** + * Read and merge i18n override files for the active theme's inheritance chain. + * Only the active theme (and its ancestors) are loaded; sibling themes are excluded. + * Returns an empty object if no overrides are present. + * + * @param lang the language code + * @protected + */ + protected readThemeOverrides(lang: string): Record { + const configured = environment.themes ?? []; + if (configured.length === 0) { + return {}; + } + + // Resolve only the active theme's chain (root ancestor → active theme). + const activeName = getDefaultThemeConfig(environment as unknown as AppConfig).name; + const orderedThemes = resolveActiveThemeChain(configured, activeName); + + // `this.prefix` looks like `dist/server/assets/i18n/`. Theme assets live next to `i18n/` + // at `dist/server/assets//i18n/.json5`. + const assetsRoot = dirname(this.prefix.replace(/\/$/, '')); + + const overrides: Record = {}; + for (const theme of orderedThemes) { + const filePath = resolve(`${assetsRoot}/${theme}/i18n/${lang}.json5`); + if (existsSync(filePath)) { + try { + const themeMessages = JSON5.parse(readFileSync(filePath, 'utf8')) as Record; + Object.assign(overrides, themeMessages); + } catch (e) { + // Skip malformed theme i18n files so they don't break SSR + // eslint-disable-next-line no-console + console.warn(`[TranslateServerLoader] Failed to parse theme i18n file ${filePath}:`, e); + } + } + } + return overrides; } /**