Skip to content

Commit 34c00d5

Browse files
authored
[Seven] PoC for new i18n implementation (#6866)
1 parent baac7d4 commit 34c00d5

24 files changed

+508
-20
lines changed

apps/quanta/.gitignore

+1
Original file line numberDiff line numberDiff line change
@@ -6,3 +6,4 @@ node_modules
66
.react-router
77
registry.loader.js
88
addons.styles.css
9+
/locales/

apps/quanta/app/config.server.ts

+3
Original file line numberDiff line numberDiff line change
@@ -24,5 +24,8 @@ export default function install() {
2424

2525
console.log('API_PATH is:', config.settings.apiPath);
2626

27+
config.settings.defaultLanguage = 'en';
28+
config.settings.supportedLanguages = ['en'];
29+
2730
return config;
2831
}

apps/quanta/app/config.ts

+4
Original file line numberDiff line numberDiff line change
@@ -7,5 +7,9 @@ import applyAddonConfiguration from '../registry.loader';
77

88
export default function install() {
99
applyAddonConfiguration(config);
10+
11+
config.settings.defaultLanguage = 'en';
12+
config.settings.supportedLanguages = ['en'];
13+
1014
return config;
1115
}

apps/quanta/app/entry.client.tsx

+53
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
import { startTransition, StrictMode } from 'react';
2+
import { hydrateRoot } from 'react-dom/client';
3+
import { HydratedRouter } from 'react-router/dom';
4+
import i18n from './i18n';
5+
import i18next from 'i18next';
6+
import { I18nextProvider, initReactI18next } from 'react-i18next';
7+
import LanguageDetector from 'i18next-browser-languagedetector';
8+
import Backend from 'i18next-http-backend';
9+
import { getInitialNamespaces } from 'remix-i18next/client';
10+
import install from './config';
11+
12+
install();
13+
14+
async function hydrate() {
15+
await i18next
16+
.use(initReactI18next) // Tell i18next to use the react-i18next plugin
17+
.use(LanguageDetector) // Setup a client-side language detector
18+
.use(Backend) // Setup your backend
19+
.init({
20+
...i18n, // spread the configuration
21+
// This function detects the namespaces your routes rendered while SSR use
22+
ns: getInitialNamespaces(),
23+
backend: { loadPath: '/locales/{{lng}}/{{ns}}.json' },
24+
detection: {
25+
// Here only enable htmlTag detection, we'll detect the language only
26+
// server-side with remix-i18next, by using the `<html lang>` attribute
27+
// we can communicate to the client the language detected server-side
28+
order: ['htmlTag'],
29+
// Because we only use htmlTag, there's no reason to cache the language
30+
// on the browser, so we disable it
31+
caches: [],
32+
},
33+
});
34+
35+
startTransition(() => {
36+
hydrateRoot(
37+
document,
38+
<I18nextProvider i18n={i18next}>
39+
<StrictMode>
40+
<HydratedRouter />
41+
</StrictMode>
42+
</I18nextProvider>,
43+
);
44+
});
45+
}
46+
47+
if (window.requestIdleCallback) {
48+
window.requestIdleCallback(hydrate);
49+
} else {
50+
// Safari doesn't support requestIdleCallback
51+
// https://caniuse.com/requestidlecallback
52+
window.setTimeout(hydrate, 1);
53+
}

apps/quanta/app/entry.server.tsx

+93
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,93 @@
1+
import { PassThrough } from 'node:stream';
2+
3+
import type { AppLoadContext, EntryContext } from 'react-router';
4+
import { createReadableStreamFromReadable } from '@react-router/node';
5+
import { ServerRouter } from 'react-router';
6+
import { isbot } from 'isbot';
7+
import type { RenderToPipeableStreamOptions } from 'react-dom/server';
8+
import { renderToPipeableStream } from 'react-dom/server';
9+
import { createInstance } from 'i18next';
10+
import i18next from './i18next.server';
11+
import { I18nextProvider, initReactI18next } from 'react-i18next';
12+
import Backend from 'i18next-fs-backend/cjs';
13+
import i18n from './i18n'; // your i18n configuration file
14+
import { resolve } from 'node:path';
15+
16+
export const streamTimeout = 5_000;
17+
18+
export default async function handleRequest(
19+
request: Request,
20+
responseStatusCode: number,
21+
responseHeaders: Headers,
22+
routerContext: EntryContext,
23+
loadContext: AppLoadContext,
24+
// If you have middleware enabled:
25+
// loadContext: unstable_RouterContextProvider
26+
) {
27+
// Ensure requests from bots and SPA Mode renders wait for all content to load before responding
28+
// https://react.dev/reference/react-dom/server/renderToPipeableStream#waiting-for-all-content-to-load-for-crawlers-and-static-generation
29+
const userAgent = request.headers.get('user-agent');
30+
const readyOption: keyof RenderToPipeableStreamOptions =
31+
(userAgent && isbot(userAgent)) || routerContext.isSpaMode
32+
? 'onAllReady'
33+
: 'onShellReady';
34+
35+
const instance = createInstance();
36+
const lng = await i18next.getLocale(request);
37+
const ns = i18next.getRouteNamespaces(routerContext);
38+
39+
await instance
40+
.use(initReactI18next) // Tell our instance to use react-i18next
41+
.use(Backend) // Setup our backend
42+
.init({
43+
...i18n, // spread the configuration
44+
lng, // The locale we detected above
45+
ns, // The namespaces the routes about to render wants to use
46+
backend: { loadPath: resolve('./locales/{{lng}}/{{ns}}.json') },
47+
});
48+
49+
return new Promise((resolve, reject) => {
50+
let shellRendered = false;
51+
52+
const { pipe, abort } = renderToPipeableStream(
53+
<I18nextProvider i18n={instance}>
54+
<ServerRouter context={routerContext} url={request.url} />
55+
</I18nextProvider>,
56+
{
57+
[readyOption]() {
58+
shellRendered = true;
59+
const body = new PassThrough();
60+
const stream = createReadableStreamFromReadable(body);
61+
62+
responseHeaders.set('Content-Type', 'text/html');
63+
64+
resolve(
65+
new Response(stream, {
66+
headers: responseHeaders,
67+
status: responseStatusCode,
68+
}),
69+
);
70+
71+
pipe(body);
72+
},
73+
onShellError(error: unknown) {
74+
reject(error);
75+
},
76+
onError(error: unknown) {
77+
responseStatusCode = 500;
78+
// Log streaming rendering errors from inside the shell. Don't log
79+
// errors encountered during initial shell rendering since they'll
80+
// reject and get logged in handleDocumentRequest.
81+
if (shellRendered) {
82+
// eslint-disable-next-line no-console
83+
console.error(error);
84+
}
85+
},
86+
},
87+
);
88+
89+
// Abort the rendering stream after the `streamTimeout` so it has time to
90+
// flush down the rejected boundaries
91+
setTimeout(abort, streamTimeout + 1000);
92+
});
93+
}

apps/quanta/app/i18n.ts

+11
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
import config from '@plone/registry';
2+
3+
export default {
4+
// This is the list of languages your application supports
5+
supportedLngs: config.settings.supportedLanguages ?? ['en'],
6+
// This is the language you want to use in case
7+
// if the user language is not in the supportedLngs
8+
fallbackLng: config.settings.defaultLanguage ?? 'en',
9+
// The default namespace of i18next is "translation", but you can customize it here
10+
defaultNS: 'common',
11+
};

apps/quanta/app/i18next.server.ts

+25
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
import Backend from 'i18next-fs-backend/cjs';
2+
import { resolve } from 'node:path';
3+
import { RemixI18Next } from 'remix-i18next/server';
4+
import i18n from './i18n'; // your i18n configuration file
5+
6+
const i18next = new RemixI18Next({
7+
detection: {
8+
supportedLanguages: i18n.supportedLngs,
9+
fallbackLanguage: i18n.fallbackLng,
10+
},
11+
// This is the configuration for i18next used
12+
// when translating messages server-side only
13+
i18next: {
14+
...i18n,
15+
backend: {
16+
loadPath: resolve('../locales/{{lng}}/{{ns}}.json'),
17+
},
18+
},
19+
// The i18next plugins you want RemixI18next to use for `i18n.getFixedT` inside loaders and actions.
20+
// E.g. The Backend plugin for loading translations from the file system
21+
// Tip: You could pass `resources` to the `i18next` configuration and avoid a backend here
22+
plugins: [Backend],
23+
});
24+
25+
export default i18next;

apps/quanta/app/root.tsx

+29-9
Original file line numberDiff line numberDiff line change
@@ -11,16 +11,16 @@ import {
1111
useParams,
1212
useLoaderData,
1313
} from 'react-router';
14+
import { useTranslation } from 'react-i18next';
15+
import { useChangeLanguage } from 'remix-i18next/react';
16+
import i18next from './i18next.server';
1417
import type { Route } from './+types/root';
1518
import contentLoader from './loaders/content';
1619

1720
import { AppRouterProvider } from '@plone/providers';
1821
import { flattenToAppURL } from './utils';
19-
import install from './config';
2022
import installServer from './config.server';
2123

22-
install();
23-
2424
// eslint-disable-next-line import/no-unresolved
2525
import stylesheet from './app.css?url';
2626

@@ -30,8 +30,8 @@ function useNavigate() {
3030
}
3131

3232
export const meta: Route.MetaFunction = ({ data }) => [
33-
{ title: data?.title },
34-
{ name: 'description', content: data?.description },
33+
{ title: data.content?.title },
34+
{ name: 'description', content: data.content?.description },
3535
{ name: 'generator', content: 'Plone 7 - https://plone.org' },
3636
];
3737

@@ -60,17 +60,37 @@ export const links: Route.LinksFunction = () => [
6060
},
6161
];
6262

63-
export async function loader({ params, request }: Route.LoaderArgs) {
63+
export async function loader({ params, request, context }: Route.LoaderArgs) {
6464
installServer();
65+
const locale = await i18next.getLocale(request);
6566

66-
return await contentLoader({ params, request });
67+
return {
68+
content: await contentLoader({ params, request, context }),
69+
locale,
70+
};
6771
}
6872

73+
export const handle = {
74+
// In the handle export, we can add a i18n key with namespaces our route
75+
// will need to load. This key can be a single string or an array of strings.
76+
// TIP: In most cases, you should set this to your defaultNS from your i18n config
77+
// or if you did not set one, set it to the i18next default namespace "translation"
78+
i18n: 'common',
79+
};
80+
6981
export function Layout({ children }: { children: React.ReactNode }) {
70-
const data = useLoaderData<typeof loader>();
82+
const { content, locale } = useLoaderData<typeof loader>();
83+
84+
const { i18n } = useTranslation();
85+
86+
// This hook will change the i18n instance language to the current locale
87+
// detected by the loader, this way, when we do something to change the
88+
// language, this locale will change and i18next will load the correct
89+
// translation files
90+
useChangeLanguage(locale);
7191

7292
return (
73-
<html lang={data?.language?.token || 'en'}>
93+
<html lang={content?.language?.token || locale || 'en'} dir={i18n.dir()}>
7494
<head>
7595
<meta charSet="utf-8" />
7696
<meta name="viewport" content="width=device-width, initial-scale=1" />

apps/quanta/news/6866.feature

+1
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
Added i18n support for projects and add-ons @pnicolli

apps/quanta/package.json

+6
Original file line numberDiff line numberDiff line change
@@ -24,10 +24,16 @@
2424
"@plone/theming": "workspace:*",
2525
"@react-router/node": "catalog:",
2626
"@react-router/serve": "catalog:",
27+
"i18next": "^24.2.3",
28+
"i18next-browser-languagedetector": "^8.0.4",
29+
"i18next-fs-backend": "^2.6.0",
30+
"i18next-http-backend": "^3.0.2",
2731
"isbot": "^5.1.17",
2832
"react": "catalog:",
2933
"react-dom": "catalog:",
34+
"react-i18next": "catalog:",
3035
"react-router": "catalog:",
36+
"remix-i18next": "^7.1.0",
3137
"tailwind-merge": "^3.0.2",
3238
"tailwind-variants": "^0.3.1",
3339
"tailwindcss": "^4.0.9",

apps/seven/app/root.tsx

+2-2
Original file line numberDiff line numberDiff line change
@@ -60,10 +60,10 @@ export const links: Route.LinksFunction = () => [
6060
},
6161
];
6262

63-
export async function loader({ params, request }: Route.LoaderArgs) {
63+
export async function loader({ params, request, context }: Route.LoaderArgs) {
6464
installServer();
6565

66-
return await contentLoader({ params, request });
66+
return await contentLoader({ params, request, context });
6767
}
6868

6969
export function Layout({ children }: { children: React.ReactNode }) {

apps/seven/news/6866.feature

+1
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
Added i18n support for projects and add-ons @pnicolli

packages/cmsui/locales/en/common.json

+5
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
{
2+
"cmsui": {
3+
"edit": "Edit"
4+
}
5+
}

packages/cmsui/locales/it/common.json

+5
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
{
2+
"cmsui": {
3+
"edit": "Modifica"
4+
}
5+
}

packages/cmsui/news/6866.feature

+1
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
Added i18n support for projects and add-ons @pnicolli

packages/cmsui/package.json

+1
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,7 @@
5353
"@plone/client": "workspace:*",
5454
"@plone/components": "workspace:*",
5555
"@plone/registry": "workspace:*",
56+
"react-i18next": "catalog:",
5657
"react-router": "catalog:",
5758
"tailwindcss": "^4.0.9",
5859
"tailwindcss-animate": "^1.0.7"

packages/cmsui/routes/edit.tsx

+8-2
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import type { Route } from './+types/edit';
2+
import { useTranslation } from 'react-i18next';
23
import { useRouteLoaderData } from 'react-router';
34

45
import type { Content } from '@plone/types';
@@ -11,8 +12,13 @@ export const meta: Route.MetaFunction = () => {
1112
export async function loader({ params, request }: Route.LoaderArgs) {}
1213

1314
export default function Edit() {
14-
const data = useRouteLoaderData('root') as Content;
15+
const { content } = useRouteLoaderData('root') as { content: Content };
16+
const { t } = useTranslation();
1517
// const pathname = useLocation().pathname;
16-
return <h1>{data.title}</h1>;
18+
return (
19+
<h1>
20+
{content.title} - {t('cmsui.edit')}
21+
</h1>
22+
);
1723
// return <App content={data} location={{ pathname }} />;
1824
}

packages/registry/news/6866.feature

+1
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
Added i18n support for projects and add-ons @pnicolli

packages/registry/package.json

+5
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,10 @@
6060
"import": "./dist/addon-registry/create-addons-styles-loader.js",
6161
"default": "./dist/addon-registry/create-addons-styles-loader.cjs"
6262
},
63+
"./create-addons-locales-loader": {
64+
"import": "./dist/addon-registry/create-addons-locales-loader.js",
65+
"default": "./dist/addon-registry/create-addons-locales-loader.cjs"
66+
},
6367
"./vite-plugin": {
6468
"import": "./vite-plugin.js",
6569
"types": "./vite-plugin.d.ts"
@@ -97,6 +101,7 @@
97101
"auto-config-loader": "^1.7.7",
98102
"crypto-random-string": "3.2.0",
99103
"debug": "4.3.2",
104+
"deepmerge": "^4.3.1",
100105
"dependency-graph": "0.10.0",
101106
"glob": "^10.4.5",
102107
"tmp": "0.2.1"

0 commit comments

Comments
 (0)