2026-08-23 23:18:14 +02:00
|
|
|
/**
|
|
|
|
|
* i18n — locale resolution and dictionary parity.
|
|
|
|
|
*
|
|
|
|
|
* The parity walk is the point: `de.ts` is typed as `Dictionary`, so a *missing*
|
|
|
|
|
* key is already a compile error. What the type cannot catch is a key that was
|
|
|
|
|
* copied over but never translated, or a plural function that ignores its
|
|
|
|
|
* argument — both of which ship an English string on a German site.
|
|
|
|
|
*/
|
|
|
|
|
|
|
|
|
|
import { describe, it, expect } from 'vitest';
|
|
|
|
|
import { resolveLocale, getDictionary, dictionaryFor, SUPPORTED_LOCALES } from '../i18n';
|
|
|
|
|
import { en } from '../i18n/locales/en';
|
|
|
|
|
import { de } from '../i18n/locales/de';
|
|
|
|
|
|
|
|
|
|
describe('resolveLocale', () => {
|
|
|
|
|
it('accepts the supported locales', () => {
|
|
|
|
|
expect(resolveLocale('en')).toBe('en');
|
|
|
|
|
expect(resolveLocale('de')).toBe('de');
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('drops the region subtag', () => {
|
|
|
|
|
expect(resolveLocale('de-DE')).toBe('de');
|
|
|
|
|
expect(resolveLocale('de-AT')).toBe('de');
|
|
|
|
|
expect(resolveLocale('en_GB')).toBe('en');
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('is case- and whitespace-insensitive', () => {
|
|
|
|
|
expect(resolveLocale(' DE ')).toBe('de');
|
|
|
|
|
expect(resolveLocale('De-Ch')).toBe('de');
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('falls back to English for languages without a dictionary', () => {
|
|
|
|
|
expect(resolveLocale('fr')).toBe('en');
|
|
|
|
|
expect(resolveLocale('ja-JP')).toBe('en');
|
|
|
|
|
expect(resolveLocale('')).toBe('en');
|
|
|
|
|
expect(resolveLocale(undefined)).toBe('en');
|
|
|
|
|
expect(resolveLocale(null)).toBe('en');
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
describe('getDictionary', () => {
|
|
|
|
|
it('returns a dictionary for every supported locale', () => {
|
|
|
|
|
for (const locale of SUPPORTED_LOCALES) {
|
|
|
|
|
expect(getDictionary(locale)).toBeDefined();
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('dictionaryFor resolves and looks up in one step', () => {
|
|
|
|
|
expect(dictionaryFor('de-DE')).toBe(de);
|
|
|
|
|
expect(dictionaryFor('fr')).toBe(en);
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
/** Every leaf of a dictionary, as `['nav.home', 'Home']` pairs. */
|
|
|
|
|
function leaves(obj: object, prefix = ''): [string, unknown][] {
|
|
|
|
|
return Object.entries(obj).flatMap(([key, value]) => {
|
|
|
|
|
const path = prefix ? `${prefix}.${key}` : key;
|
|
|
|
|
return value !== null && typeof value === 'object'
|
|
|
|
|
? leaves(value, path)
|
|
|
|
|
: ([[path, value]] as [string, unknown][]);
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/** Call a dictionary function with plausible arguments for its arity. */
|
|
|
|
|
function invoke(fn: (...args: never[]) => string): string {
|
|
|
|
|
const args = Array.from({ length: fn.length }, (_, i) => (i === 0 ? 2 : `arg${i}`));
|
|
|
|
|
return (fn as (...a: unknown[]) => string)(...args);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
describe('dictionary parity', () => {
|
|
|
|
|
const enLeaves = leaves(en);
|
|
|
|
|
const deLeaves = new Map(leaves(de));
|
|
|
|
|
|
|
|
|
|
it('German covers every English key', () => {
|
|
|
|
|
expect([...deLeaves.keys()].sort()).toEqual(enLeaves.map(([k]) => k).sort());
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('matches value kinds — a string never stands in for an interpolator', () => {
|
|
|
|
|
for (const [path, value] of enLeaves) {
|
|
|
|
|
expect(typeof deLeaves.get(path), path).toBe(typeof value);
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('leaves no English string untranslated', () => {
|
release: v0.13.0 (#535)
Twenty-nine commits since v0.12.0. See CHANGELOG.md for the full list
and the upgrade notes.
Ten additions — the SEO package (site URL, robots.txt, sitemap.xml,
JSON-LD), slideshow mode, opt-in original downloads, copy-link,
previous/next album navigation, alt text from the Immich description,
per-album location precision, the update notice, a Help tab, and a
subpage cover grid separate from its photo grid. Two fixes: CJK album
slugs, and the lightbox control bar.
Photo of the Day (#476) was built on `dev` and reverted as not planned;
it never appears in a release.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
https://claude.ai/code/session_01DmsHVRL9yQvckUB1ZpVWre
---------
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: ralksta <ralksta@users.noreply.github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-08-25 10:08:12 +02:00
|
|
|
// Proper nouns, loanwords and units read the same in both languages;
|
|
|
|
|
// `coverAria` is pure interpolation with no words of its own.
|
|
|
|
|
const shared = new Set([
|
|
|
|
|
'nav.journal',
|
|
|
|
|
'journal.title',
|
|
|
|
|
'lightbox.iso',
|
|
|
|
|
'lightbox.copyLinkShort',
|
|
|
|
|
'lightbox.downloadShort',
|
|
|
|
|
'subpage.coverAria',
|
|
|
|
|
]);
|
2026-08-23 23:18:14 +02:00
|
|
|
|
|
|
|
|
const identical = enLeaves
|
|
|
|
|
.filter(([path]) => !shared.has(path))
|
|
|
|
|
.filter(([path, value]) => {
|
|
|
|
|
const other = deLeaves.get(path);
|
|
|
|
|
if (typeof value === 'function') {
|
|
|
|
|
return invoke(other as (...a: never[]) => string) === invoke(value as never);
|
|
|
|
|
}
|
|
|
|
|
return other === value;
|
|
|
|
|
})
|
|
|
|
|
.map(([path]) => path);
|
|
|
|
|
|
|
|
|
|
expect(identical).toEqual([]);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('plural helpers actually vary on their count', () => {
|
|
|
|
|
for (const dict of [en, de]) {
|
|
|
|
|
expect(dict.common.photos(1)).not.toBe(dict.common.photos(2));
|
|
|
|
|
expect(dict.common.albums(1)).not.toBe(dict.common.albums(2));
|
|
|
|
|
expect(dict.common.collections(1)).not.toBe(dict.common.collections(2));
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
});
|