fix(admin): restore i18n on /admin (issue #7586) (#7602)

* fix(admin): restore i18n on /admin by copying locales to the right path

The admin SPA fetches `/admin/locales/<lang>.json`. Building with
vite-plugin-static-copy and `src: '../src/locales'` was placing the
115 core locale files at `src/templates/admin/src/locales/` (the
plugin's `dirClean` strips a leading `../` but keeps the remaining
parent path). The express admin handler 404'd those fetches, fell
back to serving `index.html`, JSON.parse silently failed, and every
`<Trans>` rendered its raw key — see #7586.

Replace the plugin with a small inline build/dev plugin: at build
time copy `src/locales/*.json` to `<outDir>/locales/`; in dev serve
the same files via middleware so `vite dev` also works. Drop the
now-unused `vite-plugin-static-copy` dependency.

Add regression coverage that none of the existing admin specs had:
- backend HTTP test for GET /admin/locales/{en,de}.json
- Playwright admin i18n spec asserting translated <h1> renders for
  the default locale and for ?lng=de, plus a request-level check
  that the response is JSON, not the SPA fallback.

Closes #7586

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* refactor(admin): bundle locales via import.meta.glob, drop copy plugin

The first pass at #7586 replaced vite-plugin-static-copy with a
custom build/dev plugin that copied src/locales/*.json into the
admin output and served them in dev. That works, but the
vite-plugin-static-copy README explicitly recommends the public
directory or a JS import for this case, and the import path is
strictly cleaner: no copy step, no /admin/locales/* express route,
no SPA-fallback-shaped failure mode.

Use import.meta.glob in admin/src/localization/i18n.ts so each
language ships as its own hashed JSON chunk and is lazy-loaded on
demand. The vite config goes back to just react + base + outDir.
The plugin namespaces (e.g. ep_admin_pads) keep their existing
admin/public/<ns>/<lang>.json layout.

Tests:
- Drop tests/backend/specs/adminLocales.ts — it asserted on a
  /admin/locales/<lang>.json route that this approach no longer
  uses; the regression mechanism it pinned doesn't exist anymore
  and the test required the admin frontend to be built before the
  backend test runs (which CI doesn't do).
- Keep tests/frontend-new/admin-spec/admini18n.spec.ts (rendered
  <h1> in default and ?lng=de). Verified red→green: reverting just
  the loader to the pre-fix /admin/locales fetch makes both specs
  fail; restoring makes them pass.

Also update pnpm-lock.yaml to drop the now-unused
vite-plugin-static-copy entries — fixes ERR_PNPM_OUTDATED_LOCKFILE
that was failing every CI install upfront.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
John McLear 2026-04-26 03:07:45 +01:00 committed by GitHub
parent ea57f764ad
commit 5fd600d608
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
5 changed files with 86 additions and 64 deletions

View File

@ -37,7 +37,6 @@
"typescript": "^6.0.3",
"vite": "npm:rolldown-vite@7.2.10",
"vite-plugin-babel": "^1.6.0",
"vite-plugin-static-copy": "^4.1.0",
"zustand": "^5.0.12"
},
"overrides": {

View File

@ -1,36 +1,47 @@
import i18n from 'i18next'
import {initReactI18next} from "react-i18next";
import LanguageDetector from 'i18next-browser-languagedetector'
import type {BackendModule} from 'i18next';
// Core translations live in /src/locales (shared with the pad UI). Letting
// Vite resolve them via import.meta.glob means each language ships as its own
// hashed JSON chunk, lazy-loaded on demand — no build-time copy step or
// /admin/locales/* express route. Earlier setups copying files into the build
// output were fragile (see https://github.com/ether/etherpad/issues/7586).
const coreLocales = import.meta.glob<{default: Record<string, unknown>}>(
'../../../src/locales/*.json');
import { BackendModule } from 'i18next';
const coreLocaleByLang = (language: string) =>
coreLocales[`../../../src/locales/${language}.json`];
const LazyImportPlugin: BackendModule = {
type: 'backend',
init: function () {
},
read: async function (language, namespace, callback) {
let baseURL = import.meta.env.BASE_URL
if(namespace === "translation") {
// If default we load the translation file
baseURL+=`/locales/${language}.json`
} else {
// Else we load the former plugin translation file
baseURL+=`/${namespace}/${language}.json`
}
const localeJSON = await fetch(baseURL)
let json;
try {
json = JSON.parse(await localeJSON.text())
} catch(e) {
callback(new Error("Error loading"), null);
if (namespace === 'translation') {
const loader = coreLocaleByLang(language);
if (!loader) {
callback(new Error(`No core locale for "${language}"`), null);
return;
}
const mod = await loader();
callback(null, mod.default);
return;
}
// Plugin namespaces (e.g. ep_admin_pads) are still served as static
// assets from admin/public/<namespace>/<lang>.json.
const baseURL = `${import.meta.env.BASE_URL}/${namespace}/${language}.json`;
const res = await fetch(baseURL);
if (!res.ok) {
callback(new Error(`HTTP ${res.status} loading ${baseURL}`), null);
return;
}
callback(null, await res.json());
} catch (e) {
callback(e instanceof Error ? e : new Error(String(e)), null);
}
callback(null, json);
},
save: function () {

View File

@ -1,36 +1,31 @@
import { defineConfig } from 'vite'
import {viteStaticCopy} from "vite-plugin-static-copy";
import react from '@vitejs/plugin-react';
import react from '@vitejs/plugin-react'
// https://vitejs.dev/config/
export default defineConfig({
plugins: [viteStaticCopy({
targets: [
{
src: '../src/locales',
dest: ''
}
]
}), react({
babel: {
plugins: ['babel-plugin-react-compiler'],
}})],
base: '/admin',
build:{
outDir: '../src/templates/admin',
emptyOutDir: true,
},
server:{
plugins: [
react({
babel: {
plugins: ['babel-plugin-react-compiler'],
},
}),
],
base: '/admin',
build: {
outDir: '../src/templates/admin',
emptyOutDir: true,
},
server: {
proxy: {
'/socket.io/*': {
target: 'http://localhost:9001',
changeOrigin: true,
rewrite: (path) => path.replace(/^\/api/, '')
rewrite: (path) => path.replace(/^\/api/, ''),
},
'/admin-auth/': {
target: 'http://localhost:9001',
changeOrigin: true,
}
}
}
'/admin-auth/': {
target: 'http://localhost:9001',
changeOrigin: true,
},
},
},
})

17
pnpm-lock.yaml generated
View File

@ -115,9 +115,6 @@ importers:
vite-plugin-babel:
specifier: ^1.6.0
version: 1.6.0(@babel/core@7.29.0)(rolldown-vite@7.2.10(@types/node@25.6.0)(tsx@4.21.0))
vite-plugin-static-copy:
specifier: ^4.1.0
version: 4.1.0(rolldown-vite@7.2.10(@types/node@25.6.0)(tsx@4.21.0))
zustand:
specifier: ^5.0.12
version: 5.0.12(@types/react@19.2.14)(react@19.2.5)(use-sync-external-store@1.6.0(react@19.2.5))
@ -5452,12 +5449,6 @@ packages:
'@babel/core': ^7.0.0
vite: '>=7.3.2'
vite-plugin-static-copy@4.1.0:
resolution: {integrity: sha512-9XOarNV7LgP0KBB7AApxdgFikLXx3daZdqjC3AevYsL6MrUH62zphonLUs2a6LZc1HN1GY+vQdheZ8VVJb6dQQ==}
engines: {node: ^22.0.0 || >=24.0.0}
peerDependencies:
vite: '>=7.3.2'
vite@8.0.8:
resolution: {integrity: sha512-dbU7/iLVa8KZALJyLOBOQ88nOXtNG8vxKuOT4I2mD+Ya70KPceF4IAmDsmU0h1Qsn5bPrvsY9HJstCRh3hG6Uw==}
engines: {node: ^20.19.0 || >=22.12.0}
@ -11001,14 +10992,6 @@ snapshots:
'@babel/core': 7.29.0
vite: rolldown-vite@7.2.10(@types/node@25.6.0)(tsx@4.21.0)
vite-plugin-static-copy@4.1.0(rolldown-vite@7.2.10(@types/node@25.6.0)(tsx@4.21.0)):
dependencies:
chokidar: 3.6.0
p-map: 7.0.4
picocolors: 1.1.1
tinyglobby: 0.2.16
vite: rolldown-vite@7.2.10(@types/node@25.6.0)(tsx@4.21.0)
vite@8.0.8(@types/node@25.6.0)(esbuild@0.28.0)(tsx@4.21.0):
dependencies:
lightningcss: 1.32.0

View File

@ -0,0 +1,34 @@
import {expect, test} from "@playwright/test";
import {loginToAdmin} from "../helper/adminhelper";
// Regression coverage for https://github.com/ether/etherpad/issues/7586
//
// 2.7.0 shipped with the admin SPA's locale files copied to a wrong
// build path; fetches for them silently fell back to the SPA's
// index.html, JSON.parse failed, and every <Trans> rendered as its
// raw key. None of the existing admin specs asserted on translated
// strings, so the regression slipped through. We now bundle the
// translations through Vite (import.meta.glob) — these tests pin the
// rendered behaviour rather than the file path so any future
// loading-mechanism change is covered too.
test.beforeEach(async ({ page })=>{
await loginToAdmin(page, 'admin', 'changeme1');
});
test.describe('admin i18n', () => {
test('renders translated text on /admin (default English)', async ({page}) => {
await page.goto('http://localhost:9001/admin/');
// HomePage renders <h1><Trans i18nKey="admin_plugins"/></h1>. If
// translations fail to load, the visible text becomes the raw key
// "admin_plugins". Asserting on the translated form catches that.
await expect(page.locator('h1', { hasText: /^Plugin manager$/ }))
.toBeVisible({ timeout: 30000 });
await expect(page.getByText('admin_plugins', { exact: true })).toHaveCount(0);
});
test('switches language to German via ?lng=de', async ({page}) => {
await page.goto('http://localhost:9001/admin/?lng=de');
await expect(page.locator('h1', { hasText: /^Pluginverwaltung$/ }))
.toBeVisible({ timeout: 30000 });
});
});