Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: simplify next-js integration #3391

Merged
merged 24 commits into from
Oct 30, 2024
Merged
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 .github/workflows/publish-examples-check.yml
Original file line number Diff line number Diff line change
Expand Up @@ -15,8 +15,9 @@ jobs:
[
'react',
'next',
'next-app',
'vue',
'next-app',
'next-app-intl',
'svelte',
'ngx',
'react-i18next',
Expand Down
1 change: 1 addition & 0 deletions .github/workflows/publish-examples.yml
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ jobs:
'next',
'vue',
'next-app',
'next-app-intl',
'svelte',
'ngx',
'react-i18next',
Expand Down
40 changes: 40 additions & 0 deletions e2e/cypress/e2e/next-app-intl/dev.cy.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
import { exampleAppTest } from '../../common/exampleAppTest';
import { translationMethodsTest } from '../../common/translationMethodsTest';
import { exampleAppDevTest } from '../../common/exampleAppDevTest';

context(
'Next with app router (with next-intl) in dev mode',
{ retries: 5 },
() => {
const url = 'http://localhost:8125';
const translationMethods = url + '/en/translation-methods';
exampleAppTest(url);
translationMethodsTest(translationMethods, {
en: [
{ text: 'This is a key', count: 2 },
{ text: 'This is key with params value value2', count: 6 },
{
text: 'This is a key with tags bold value',
count: 2,
testId: 'translationWithTags',
},
{ text: 'Translation in translation', count: 2 },
],
de: [
{ text: 'Dies ist ein Schlüssel', count: 2 },
{
text: 'Dies ist ein Schlüssel mit den Parametern value value2',
count: 6,
},
{
text: 'Dies ist ein Schlüssel mit den Tags bold value',
count: 2,
testId: 'translationWithTags',
},
{ text: 'Translation in translation', count: 2 },
],
});

exampleAppDevTest(url, { noLoading: true });
}
);
33 changes: 33 additions & 0 deletions e2e/cypress/e2e/next-app-intl/prod.cy.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
import { exampleAppTest } from '../../common/exampleAppTest';
import { translationMethodsTest } from '../../common/translationMethodsTest';

context('Next with app router (with next-intl) in prod mode', () => {
const url = 'http://localhost:8127';
const translationMethods = url + '/en/translation-methods';
exampleAppTest(url);
translationMethodsTest(translationMethods, {
en: [
{ text: 'This is a key', count: 2 },
{ text: 'This is key with params value value2', count: 6 },
{
text: 'This is a key with tags bold value',
count: 2,
testId: 'translationWithTags',
},
{ text: 'Translation in translation', count: 2 },
],
de: [
{ text: 'Dies ist ein Schlüssel', count: 2 },
{
text: 'Dies ist ein Schlüssel mit den Parametern value value2',
count: 6,
},
{
text: 'Dies ist ein Schlüssel mit den Tags bold value',
count: 2,
testId: 'translationWithTags',
},
{ text: 'Translation in translation', count: 2 },
],
});
});
2 changes: 1 addition & 1 deletion e2e/cypress/e2e/next-app/dev.cy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import { exampleAppDevTest } from '../../common/exampleAppDevTest';

context('Next with app router in dev mode', { retries: 5 }, () => {
const url = 'http://localhost:8122';
const translationMethods = url + '/en/translation-methods';
const translationMethods = url + '/translation-methods';
exampleAppTest(url);
translationMethodsTest(translationMethods, {
en: [
Expand Down
2 changes: 1 addition & 1 deletion e2e/cypress/e2e/next-app/prod.cy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import { translationMethodsTest } from '../../common/translationMethodsTest';

context('Next with app router in prod mode', () => {
const url = 'http://localhost:8121';
const translationMethods = url + '/en/translation-methods';
const translationMethods = url + '/translation-methods';
exampleAppTest(url);
translationMethodsTest(translationMethods, {
en: [
Expand Down
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@
"develop:react-i18next": "npm run develop -- --scope=@tolgee/react-i18next-testapp",
"develop:vue-i18next": "npm run develop -- --scope=@tolgee/vue-i18next-testapp",
"develop:next-app": "npm run develop -- --scope=@tolgee/next-app-testapp",
"develop:next-app-intl": "npm run develop -- --scope=@tolgee/next-app-intl-testapp",
"develop:vue-ssr": "npm run develop -- --scope=@tolgee/vue-ssr-testapp",
"build:e2e": "turbo run build:e2e --cache-dir='.turbo'",
"test:e2e": "pnpm run build:e2e && pnpm --prefix e2e run start",
Expand Down
4 changes: 2 additions & 2 deletions packages/core/src/Controller/State/initState.ts
Original file line number Diff line number Diff line change
Expand Up @@ -81,8 +81,8 @@ export type TolgeeOptionsInternal = {
*
* ```ts
* {
* 'locale': <translations | async function>
* 'locale:namespace': <translations | async function>
* 'language': <translations | async function>
* 'language:namespace': <translations | async function>
* }
* ```
*/
Expand Down
8 changes: 4 additions & 4 deletions packages/format-icu/src/createFormatIcu.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,10 +12,10 @@ describe('format icu', () => {
expect(result).toEqual('result is 42,000');
});

it('fixes invalid locale', () => {
it('fixes invalid language', () => {
const formatter = createFormatIcu() as any;
expect(formatter.getLocale('en_GB')).toEqual('en-GB');
expect(formatter.getLocale('en_GB-nonsenceeeee')).toEqual('en-GB');
expect(formatter.getLocale('cs CZ')).toEqual('cs-CZ');
expect(formatter.getLanguage('en_GB')).toEqual('en-GB');
expect(formatter.getLanguage('en_GB-nonsenceeeee')).toEqual('en-GB');
expect(formatter.getLanguage('cs CZ')).toEqual('cs-CZ');
});
});
6 changes: 3 additions & 3 deletions packages/format-icu/src/createFormatIcu.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ export const createFormatIcu = (): FinalFormatterMiddleware => {
}
}

function getLocale(language: string) {
function getLanguage(language: string) {
if (!locales.get(language)) {
let localeCandidate: string = String(language).replace(/[^a-zA-Z]/g, '-');
while (!isLocaleValid(localeCandidate)) {
Expand All @@ -33,12 +33,12 @@ export const createFormatIcu = (): FinalFormatterMiddleware => {
(p) => typeof p === 'function'
);

const locale = getLocale(language);
const locale = getLanguage(language);

return new IntlMessageFormat(translation, locale, undefined, {
ignoreTag,
}).format(params);
};

return Object.freeze({ getLocale, format });
return Object.freeze({ getLanguage, format });
};
39 changes: 34 additions & 5 deletions packages/react/src/TolgeeProvider.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import React, { Suspense, useEffect, useState } from 'react';
import { TolgeeInstance } from '@tolgee/web';
import { TolgeeInstance, TolgeeStaticData } from '@tolgee/web';
import { ReactOptions, TolgeeReactContext } from './types';
import { useTolgeeSSR } from './useTolgeeSSR';

export const DEFAULT_REACT_OPTIONS: ReactOptions = {
useSuspense: true,
Expand All @@ -20,21 +21,40 @@ export const getProviderInstance = () => {

let LAST_TOLGEE_INSTANCE: TolgeeInstance | undefined = undefined;

export type SSROptions = {
/**
* Hard set language to this value, use together with `staticData`
*/
language?: string;
/**
* If provided, static data will be hard set to Tolgee cache for initial render
*/
staticData?: TolgeeStaticData;
};

export interface TolgeeProviderProps {
children?: React.ReactNode;
tolgee: TolgeeInstance;
options?: ReactOptions;
fallback?: React.ReactNode;
/**
* use this option if you use SSR
*
* You can pass staticData and language
* which will be set to tolgee instance for the initial render
*
* Don't switch between ssr and non-ssr dynamically
*/
ssr?: SSROptions | boolean;
}

export const TolgeeProvider: React.FC<TolgeeProviderProps> = ({
tolgee,
options,
children,
fallback,
ssr,
}) => {
const [loading, setLoading] = useState(!tolgee.isLoaded());

// prevent restarting tolgee unnecesarly
// however if the instance change on hot-reloading
// we want to restart
Expand All @@ -56,14 +76,23 @@ export const TolgeeProvider: React.FC<TolgeeProviderProps> = ({
}
}, [tolgee]);

let tolgeeSSR = tolgee;

const { language, staticData } = (
typeof ssr !== 'object' ? {} : ssr
) as SSROptions;
tolgeeSSR = useTolgeeSSR(tolgee, language, staticData, Boolean(ssr));

const [loading, setLoading] = useState(!tolgeeSSR.isLoaded());

const optionsWithDefault = { ...DEFAULT_REACT_OPTIONS, ...options };

const TolgeeProviderContext = getProviderInstance();

if (optionsWithDefault.useSuspense) {
return (
<TolgeeProviderContext.Provider
value={{ tolgee, options: optionsWithDefault }}
value={{ tolgee: tolgeeSSR, options: optionsWithDefault }}
>
{loading ? (
fallback
Expand All @@ -76,7 +105,7 @@ export const TolgeeProvider: React.FC<TolgeeProviderProps> = ({

return (
<TolgeeProviderContext.Provider
value={{ tolgee, options: optionsWithDefault }}
value={{ tolgee: tolgeeSSR, options: optionsWithDefault }}
>
{loading ? fallback : children}
</TolgeeProviderContext.Provider>
Expand Down
26 changes: 15 additions & 11 deletions packages/react/src/useTolgeeSSR.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,39 +24,43 @@ function getTolgeeWithDeactivatedWrapper(
*
* It also ensures that the first render is done without wrapping and so it avoids
* "client different than server" issues.
*
* *
* @param tolgeeInstance initialized Tolgee instance
* @param language language that is obtained outside of Tolgee on the server and client
* @param staticData static data for the language
* @param enabled if set to false, no action is taken
*/
export function useTolgeeSSR(
tolgeeInstance: TolgeeInstance,
language?: string,
staticData?: TolgeeStaticData | undefined
staticData?: TolgeeStaticData | undefined,
enabled = true
) {
const [noWrappingTolgee] = useState(() =>
getTolgeeWithDeactivatedWrapper(tolgeeInstance)
);

const [initialRender, setInitialRender] = useState(true);
const [initialRender, setInitialRender] = useState(enabled);

useEffect(() => {
setInitialRender(false);
}, []);

useMemo(() => {
// we have to prepare tolgee before rendering children
// so translations are available right away
// events emitting must be off, to not trigger re-render while rendering
tolgeeInstance.setEmitterActive(false);
tolgeeInstance.addStaticData(staticData);
tolgeeInstance.changeLanguage(language!);
tolgeeInstance.setEmitterActive(true);
if (enabled) {
// we have to prepare tolgee before rendering children
// so translations are available right away
// events emitting must be off, to not trigger re-render while rendering
tolgeeInstance.setEmitterActive(false);
tolgeeInstance.addStaticData(staticData);
tolgeeInstance.changeLanguage(language!);
tolgeeInstance.setEmitterActive(true);
}
}, [language, staticData, tolgeeInstance]);

useState(() => {
// running this function only on first render
if (!tolgeeInstance.isLoaded()) {
if (!tolgeeInstance.isLoaded() && enabled) {
// warning user, that static data provided are not sufficient
// for proper SSR render
const missingRecords = tolgeeInstance
Expand Down
36 changes: 20 additions & 16 deletions packages/web/src/package/LanguageDetector.ts
Original file line number Diff line number Diff line change
@@ -1,27 +1,31 @@
import type { LanguageDetectorMiddleware, TolgeePlugin } from '@tolgee/core';
import { throwIfSSR } from './tools/isSSR';

export function detectLanguage(language: string, availableLanguages: string[]) {
const exactMatch = availableLanguages.find((l) => l === language);
if (exactMatch) {
return exactMatch;
}

const getTwoLetters = (fullTag: string) =>
fullTag.replace(/^(.+?)(-.*)?$/, '$1');

const preferredTwoLetter = getTwoLetters(window.navigator.language);
const twoLetterMatch = availableLanguages.find(
(l) => getTwoLetters(l) === preferredTwoLetter
);
if (twoLetterMatch) {
return twoLetterMatch;
}
return undefined;
}

export function createLanguageDetector(): LanguageDetectorMiddleware {
return {
getLanguage({ availableLanguages }) {
throwIfSSR('LanguageDetector');
const preferred = window.navigator.language;
const exactMatch = availableLanguages.find((l) => l === preferred);
if (exactMatch) {
return exactMatch;
}

const getTwoLetters = (fullTag: string) =>
fullTag.replace(/^(.+?)(-.*)?$/, '$1');

const preferredTwoLetter = getTwoLetters(window.navigator.language);
const twoLetterMatch = availableLanguages.find(
(l) => getTwoLetters(l) === preferredTwoLetter
);
if (twoLetterMatch) {
return twoLetterMatch;
}
return undefined;
return detectLanguage(preferred, availableLanguages);
},
};
}
Expand Down
10 changes: 10 additions & 0 deletions packages/web/src/package/tools/detectLanguageFromHeaders.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
import { detectLanguage } from '../LanguageDetector';
import { getHeaderLanguages } from './getHeaderLanguages';

export const detectLanguageFromHeaders = (
headers: Headers,
availableLanguages: string[]
) => {
const languages = getHeaderLanguages(headers);
return languages[0] && detectLanguage(languages[0], availableLanguages);
};
Loading