From f95eb0b1a827510b86c701ecdf0cd2937bb0040c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Kan=C3=A1sz-Nagy=20Zolt=C3=A1n?= Date: Wed, 13 May 2026 09:03:05 +0200 Subject: [PATCH 1/2] QREPO-405 extending translate loader to use theme inheritence --- .../translate-browser.loader.ts | 95 ++++++++++++-- .../translate-server.loader.ts | 116 ++++++++++++++++-- 2 files changed, 192 insertions(+), 19 deletions(-) diff --git a/src/ngx-translate-loaders/translate-browser.loader.ts b/src/ngx-translate-loaders/translate-browser.loader.ts index 44e1710b148..caf8f255a69 100644 --- a/src/ngx-translate-loaders/translate-browser.loader.ts +++ b/src/ngx-translate-loaders/translate-browser.loader.ts @@ -2,11 +2,16 @@ import { HttpClient } from '@angular/common/http'; import { TransferState } from '@angular/core'; import { hasValue } from '@dspace/shared/utils/empty.util'; import { TranslateLoader } from '@ngx-translate/core'; +import * as 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 { @@ -16,7 +21,12 @@ import { /** * 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 every + * configured theme and deep-merges them on top of the base translations (theme keys win). + * 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 +45,83 @@ 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 + every configured theme's override file (in inheritance order) + // and merge them. Theme keys override base keys; child theme keys override parent theme keys. + const base$ = this.fetchBase(lang); + const orderedThemes = this.resolveThemeLoadOrder(environment.themes as any || []); + 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), {})), + ); + } + + /** + * Resolve the load order for the configured themes, expanding `extends` chains so that + * ancestor themes are loaded before their descendants (descendant wins on conflicts). + * See {@link TranslateServerLoader.resolveThemeLoadOrder} for details. + */ + protected resolveThemeLoadOrder(configured: Array<{ name?: string; extends?: string }>): string[] { + const byName = new Map(); + configured.forEach((t) => { + if (t?.name) { + byName.set(t.name, t); + } + }); + + const result: string[] = []; + const seen = new Set(); + + for (const theme of configured) { + if (!theme?.name) { + continue; + } + const chain: string[] = []; + const visited = new Set(); + let cursor: string | undefined = theme.name; + while (cursor && !visited.has(cursor)) { + visited.add(cursor); + chain.push(cursor); + cursor = byName.get(cursor)?.extends; + } + for (const name of chain.reverse()) { + if (!seen.has(name)) { + seen.add(name); + result.push(name); + } + } + } + return result; + } + + /** + * 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..6e64b618e26 100644 --- a/src/ngx-translate-loaders/translate-server.loader.ts +++ b/src/ngx-translate-loaders/translate-server.loader.ts @@ -1,12 +1,18 @@ -import { readFileSync } from 'node:fs'; +import { + existsSync, + readFileSync, +} from 'node:fs'; +import { dirname, resolve } from 'node:path'; import { TransferState } from '@angular/core'; import { TranslateLoader } from '@ngx-translate/core'; +import * as JSON5 from 'json5'; import { Observable, of, } from 'rxjs'; +import { environment } from '../environments/environment'; import { NGX_TRANSLATE_STATE, NgxTranslateState, @@ -14,7 +20,12 @@ import { /** * 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 every configured theme, merging them on top of the base translations (theme keys win). + * 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 +43,100 @@ 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 from every configured theme's assets folder. + * Returns an empty object if no overrides are present. + * + * @param lang the language code + * @protected + */ + protected readThemeOverrides(lang: string): Record { + const configured = (environment.themes || []) as Array<{ name?: string; extends?: string }>; + if (configured.length === 0) { + return {}; + } + + // Build the ordered list of themes to load, expanding each configured theme's inheritance + // chain (ancestor first, descendant last) so that child theme overrides win over parents. + const orderedThemes = this.resolveThemeLoadOrder(configured); + + // `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; + } + + /** + * Resolve the load order for the given configured themes, expanding `extends` chains. + * + * For each configured theme we walk up its `extends` chain, then emit themes from the root + * ancestor down to the descendant. Duplicates are removed (a theme already loaded as an + * ancestor of an earlier configured theme is not loaded again). + * + * Example given `themes: [kjk (extends qulto)]`: + * load order = [qulto, kjk] → qulto keys, then kjk keys override + */ + protected resolveThemeLoadOrder(configured: Array<{ name?: string; extends?: string }>): string[] { + const byName = new Map(); + configured.forEach((t) => { + if (t?.name) { + byName.set(t.name, t); + } + }); + + const result: string[] = []; + const seen = new Set(); + + for (const theme of configured) { + if (!theme?.name) { + continue; + } + // Build chain from theme up to root ancestor (handle cycles). + const chain: string[] = []; + const visited = new Set(); + let cursor: string | undefined = theme.name; + while (cursor && !visited.has(cursor)) { + visited.add(cursor); + chain.push(cursor); + cursor = byName.get(cursor)?.extends; + } + // Reverse so we apply ancestor → descendant order. + for (const name of chain.reverse()) { + if (!seen.has(name)) { + seen.add(name); + result.push(name); + } + } + } + return result; } /** From ae935ab2f545fbc3c225a0797ce1553059dc85da Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Kan=C3=A1sz-Nagy=20Zolt=C3=A1n?= Date: Wed, 13 May 2026 09:36:57 +0200 Subject: [PATCH 2/2] QREPO-405 fixing problems reported by lint --- src/ngx-translate-loaders/translate-browser.loader.ts | 2 +- src/ngx-translate-loaders/translate-server.loader.ts | 7 +++++-- 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/src/ngx-translate-loaders/translate-browser.loader.ts b/src/ngx-translate-loaders/translate-browser.loader.ts index caf8f255a69..bb973576441 100644 --- a/src/ngx-translate-loaders/translate-browser.loader.ts +++ b/src/ngx-translate-loaders/translate-browser.loader.ts @@ -2,7 +2,7 @@ import { HttpClient } from '@angular/common/http'; import { TransferState } from '@angular/core'; import { hasValue } from '@dspace/shared/utils/empty.util'; import { TranslateLoader } from '@ngx-translate/core'; -import * as JSON5 from 'json5'; +import JSON5 from 'json5'; import { forkJoin, Observable, diff --git a/src/ngx-translate-loaders/translate-server.loader.ts b/src/ngx-translate-loaders/translate-server.loader.ts index 6e64b618e26..8b15b0db300 100644 --- a/src/ngx-translate-loaders/translate-server.loader.ts +++ b/src/ngx-translate-loaders/translate-server.loader.ts @@ -2,11 +2,14 @@ import { existsSync, readFileSync, } from 'node:fs'; -import { dirname, resolve } from 'node:path'; +import { + dirname, + resolve, +} from 'node:path'; import { TransferState } from '@angular/core'; import { TranslateLoader } from '@ngx-translate/core'; -import * as JSON5 from 'json5'; +import JSON5 from 'json5'; import { Observable, of,