Skip to content

Commit

Permalink
feat: simplify next-js integration (#3391)
Browse files Browse the repository at this point in the history
  • Loading branch information
stepan662 authored Oct 30, 2024
1 parent fc6cc84 commit 38f3acb
Show file tree
Hide file tree
Showing 85 changed files with 1,562 additions and 409 deletions.
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

0 comments on commit 38f3acb

Please sign in to comment.