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
95 changes: 85 additions & 10 deletions src/ngx-translate-loaders/translate-browser.loader.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 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 {
Expand All @@ -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/<theme>/i18n/<lang>.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(
Expand All @@ -35,18 +45,83 @@ 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 + 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<string, { name?: string; extends?: string }>();
configured.forEach((t) => {
if (t?.name) {
byName.set(t.name, t);
}
});

const result: string[] = [];
const seen = new Set<string>();

for (const theme of configured) {
if (!theme?.name) {
continue;
}
const chain: string[] = [];
const visited = new Set<string>();
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<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({})),
);
}
}
119 changes: 110 additions & 9 deletions src/ngx-translate-loaders/translate-server.loader.ts
Original file line number Diff line number Diff line change
@@ -1,20 +1,34 @@
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 JSON5 from 'json5';
import {
Observable,
of,
} from 'rxjs';

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

/**
* 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 `<themeAssetsRoot>/<theme>/i18n/<lang>.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 {

Expand All @@ -32,13 +46,100 @@ export class TranslateServerLoader implements TranslateLoader {
*/
public getTranslation(lang: string): Observable<any> {
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<string, string> {
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/<theme>/i18n/<lang>.json5`.
const assetsRoot = dirname(this.prefix.replace(/\/$/, ''));

const overrides: Record<string, string> = {};
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<string, string>;
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<string, { name?: string; extends?: string }>();
configured.forEach((t) => {
if (t?.name) {
byName.set(t.name, t);
}
});

const result: string[] = [];
const seen = new Set<string>();

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<string>();
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;
}

/**
Expand Down
Loading