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
3 changes: 2 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -414,7 +414,8 @@ dspace-angular
│ ├── docker-compose.yml *
│ └── README.md *
├── docs * Folder for documentation
│ └── Configuration.md * Configuration documentation
│ ├── Configuration.md * Configuration documentation
│ └── ThemeTranslations.md * Theme-specific translations documentation
├── scripts *
│ ├── merge-i18n-files.ts *
│ ├── serve.ts *
Expand Down
91 changes: 91 additions & 0 deletions docs/ThemeTranslations.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
# Theme-specific translations

Themes can override individual translation keys, so a theme can rename a label or reword a
message without touching the shared language files.

These overrides are applied **at runtime**, while the application is loading its translations.
The alternative is the build-time [`merge-i18n`](#relationship-to-merge-i18n) script, which merges
one theme's translations into the base language files before the build. Applying the overrides at
runtime means a single build - and therefore a single Docker image - can be deployed for different
themes, each with its own translations.

## Adding translations to a theme

Place the override files alongside the theme's other assets:

```
src/themes/<theme>/assets/i18n/<lang>.json5
```

For example:

```
src/assets/i18n/de.json5 * base translations (all keys)
src/themes/my-theme/assets/i18n/de.json5 * overrides for my-theme
```

An override file only has to contain the keys it changes; every other key falls back to the base
file. A theme also does not have to provide a file for every language.

The build copies these files to `assets/<theme>/i18n/<lang>.json5` in the output, where the
translate loaders pick them up.

The build also records which themes provide a file for which language, so the browser only
requests override files that exist; a theme without overrides is not requested at all. That
list is collected at build time, so adding or removing a theme translation file takes effect
on the next build - as was already the case for the files themselves.

## How keys are resolved

Translations are merged key by key. Later sources win:

1. the base file, `src/assets/i18n/<lang>.json5`
2. the active theme's ancestors, starting from the root of the `extends` chain
3. the active theme itself

Given this configuration:

```yaml
themes:
- name: child-theme
extends: parent-theme
- name: parent-theme
```

with `child-theme` active, translations are merged in the order `base → parent-theme →
child-theme`. A key defined only in `parent-theme` is used unless `child-theme` redefines it, and a
key defined in neither falls back to the base file. This is the same `extends` inheritance that
DSpace already uses to resolve themed components.

## Which theme's overrides are applied

The overrides come from the **default theme** - the entry under `themes:` that has no `regex`,
`handle` or `uuid` matching rule - together with its ancestors.

Configured themes outside that chain are deliberately skipped. Loading every configured theme
would let keys from unrelated themes overwrite one another, because the merged result is a single
flat map of keys.

This also means that when one instance serves several themes selected by `regex`, `handle` or
`uuid`, only the default theme's translation overrides are applied.

## Server-side rendering

Under SSR the server loader reads the base file and the theme overrides from disk, merges them,
and stores the result in the Angular `TransferState`. The browser reuses that state, so no extra
request for theme translations is made after the initial page load.

When no `TransferState` is available - client-side rendering, for example `npm run start:dev` -
the browser loader fetches the base file and the theme override files over HTTP and merges them
client-side.

A missing override file is ignored. A malformed override file is skipped and a warning is written
to the server log, so a syntax error in one theme file cannot break rendering.

## Relationship to `merge-i18n`

Runtime overrides need no configuration change, and existing setups do not have to be migrated:
projects that run `npm run merge-i18n` before building keep working exactly as before.

To use runtime overrides instead, keep `src/assets/i18n/<lang>.json5` as the unmodified base file
and do not run the script.
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
}
Loading
Loading