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

Add issue195(add i18n config) 2 #231

Open
wants to merge 6 commits into
base: main
Choose a base branch
from
Open
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
4 changes: 2 additions & 2 deletions pkgs/frontend/app/components/Header.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { useState, useEffect, useMemo } from "react";
import { useState, useEffect, useMemo, FC } from "react";
import { Box, Flex, Text } from "@chakra-ui/react";
import { WorkspaceIcon } from "./icon/WorkspaceIcon";
import { UserIcon } from "./icon/UserIcon";
Expand Down Expand Up @@ -28,7 +28,7 @@ enum HeaderType {
WorkspaceAndUserIcons = "WorkspaceAndUserIcons",
}

export const Header = () => {
export const Header: FC = () => {
const [headerType, setHeaderType] = useState<HeaderType>(
HeaderType.NonHeader
);
Expand Down
24 changes: 24 additions & 0 deletions pkgs/frontend/app/config/i18n.server.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
import { createCookie } from "@remix-run/node";
import { RemixI18Next } from "remix-i18next/server";

import * as i18n from "~/config/i18n";

export const localeCookie = createCookie("lng", {
path: "/",
sameSite: "lax",
secure: process.env.NODE_ENV === "production",
httpOnly: true,
});

export default new RemixI18Next({
detection: {
supportedLanguages: i18n.supportedLngs,
fallbackLanguage: i18n.fallbackLng,
cookie: localeCookie,
},
// This is the configuration for i18next used
// when translating messages server-side only
i18next: {
...i18n,
},
});
20 changes: 20 additions & 0 deletions pkgs/frontend/app/config/i18n.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
import { serverOnly$ } from "vite-env-only/macros";

import enTranslation from "~/locales/en";
import jaTranslation from "~/locales/ja";

// This is the list of languages your application supports, the last one is your
// fallback language
export const supportedLngs = ["en", "ja"];

// This is the language you want to use in case
// the user language is not in the supportedLngs
export const fallbackLng = "en";

// The default namespace of i18next is "translation", but you can customize it
export const defaultNS = "translation";

export const resources = serverOnly$({
en: { translation: enTranslation },
ja: { translation: jaTranslation },
});
42 changes: 40 additions & 2 deletions pkgs/frontend/app/entry.client.tsx
Original file line number Diff line number Diff line change
@@ -1,28 +1,66 @@
import { RemixBrowser } from "@remix-run/react";
import I18nextBrowserLanguageDetector from "i18next-browser-languagedetector";
import Fetch from "i18next-fetch-backend";
import { StrictMode, startTransition } from "react";
import { hydrateRoot } from "react-dom/client";
import { I18nextProvider, initReactI18next } from "react-i18next";
import { getInitialNamespaces } from "remix-i18next/client";
import { defaultNS, fallbackLng, supportedLngs } from "~/config/i18n";
import { ChakraProvider } from "./components/chakra-provider";
import { ClientCacheProvider } from "./emotion/emotion-client";

const hydrate = () => {
import i18next from "i18next";

const hydrate = async () => {
await i18next
.use(initReactI18next) // Tell i18next to use the react-i18next plugin
.use(Fetch) // Tell i18next to use the Fetch backend
.use(I18nextBrowserLanguageDetector) // Setup a client-side language detector
.init({
defaultNS,
fallbackLng,
supportedLngs,
ns: getInitialNamespaces(),
detection: {
// Here only enable htmlTag detection, we'll detect the language only
// server-side with remix-i18next, by using the `<html lang>` attribute
// we can communicate to the client the language detected server-side
order: ["htmlTag"],
// Because we only use htmlTag, there's no reason to cache the language
// on the browser, so we disable it
caches: [],
},
backend: {
// We will configure the backend to fetch the translations from the
// resource route /api/locales and pass the lng and ns as search params
loadPath: "/api/locales?lng={{lng}}&ns={{ns}}",
},
});

startTransition(() => {
hydrateRoot(
document,
<StrictMode>
<ClientCacheProvider>
<ChakraProvider>
<RemixBrowser />
<I18nextProvider i18n={i18next}>
<RemixBrowser />
</I18nextProvider>
</ChakraProvider>
</ClientCacheProvider>
</StrictMode>
);
});
};

/*
if (typeof requestIdleCallback === "function") {
requestIdleCallback(hydrate);
} else {
// Safari doesn't support requestIdleCallback
// https://caniuse.com/requestidlecallback
setTimeout(hydrate, 1);
}
*/

hydrate().catch((error) => console.error(error));
162 changes: 145 additions & 17 deletions pkgs/frontend/app/entry.server.tsx
Original file line number Diff line number Diff line change
@@ -1,28 +1,156 @@
import type { EntryContext } from "@remix-run/node";
import { PassThrough } from "node:stream";

import type { AppLoadContext, EntryContext } from "@remix-run/node";
import { createReadableStreamFromReadable } from "@remix-run/node";
import { RemixServer } from "@remix-run/react";
import { createEmotion } from "./emotion/emotion-server";
import { createInstance, i18n as i18next } from "i18next";
import { isbot } from "isbot";
import { renderToPipeableStream } from "react-dom/server";
import { I18nextProvider, initReactI18next } from "react-i18next";
import * as i18n from "./config/i18n";
import i18nServer from "./config/i18n.server";

const ABORT_DELAY = 5_000;

const handleRequest = (
export default async function handleRequest(
request: Request,
responseStatusCode: number,
responseHeaders: Headers,
remixContext: EntryContext
) =>
new Promise((resolve) => {
const { renderToString, injectStyles } = createEmotion();
remixContext: EntryContext,
// This is ignored so we can keep it in the template for visibility. Feel
// free to delete this parameter in your app if you're not using it!
// eslint-disable-next-line @typescript-eslint/no-unused-vars
loadContext: AppLoadContext
) {
const instance = createInstance();
const lng = await i18nServer.getLocale(request);
const ns = i18nServer.getRouteNamespaces(remixContext);

const html = renderToString(
<RemixServer context={remixContext} url={request.url} />
);
await instance.use(initReactI18next).init({ ...i18n, lng, ns });

return isbot(request.headers.get("user-agent") || "")
? handleBotRequest(
request,
responseStatusCode,
responseHeaders,
remixContext,
loadContext,
instance
)
: handleBrowserRequest(
request,
responseStatusCode,
responseHeaders,
remixContext,
loadContext,
instance
);
}

async function handleBotRequest(
request: Request,
responseStatusCode: number,
responseHeaders: Headers,
remixContext: EntryContext,
_loadContext: AppLoadContext,
i18next: i18next
) {
return new Promise((resolve, reject) => {
let shellRendered = false;
const { pipe, abort } = renderToPipeableStream(
<I18nextProvider i18n={i18next}>
<RemixServer
context={remixContext}
url={request.url}
abortDelay={ABORT_DELAY}
/>
</I18nextProvider>,
{
onAllReady() {
shellRendered = true;
const body = new PassThrough();
const stream = createReadableStreamFromReadable(body);

responseHeaders.set("Content-Type", "text/html");
responseHeaders.set("Content-Type", "text/html");

const response = new Response(`<!DOCTYPE html>${injectStyles(html)}`, {
status: responseStatusCode,
headers: responseHeaders,
});
resolve(
new Response(stream, {
headers: responseHeaders,
status: responseStatusCode,
})
);

pipe(body);
},
onShellError(error: unknown) {
reject(error);
},
onError(error: unknown) {
responseStatusCode = 500;
// Log streaming rendering errors from inside the shell. Don't log
// errors encountered during initial shell rendering since they'll
// reject and get logged in handleDocumentRequest.
if (shellRendered) {
console.error(error);
}
},
}
);

resolve(response);
setTimeout(abort, ABORT_DELAY);
});
}

export default handleRequest;
async function handleBrowserRequest(
request: Request,
responseStatusCode: number,
responseHeaders: Headers,
remixContext: EntryContext,
_loadContext: AppLoadContext,
i18next: i18next
) {
return new Promise((resolve, reject) => {
let shellRendered = false;
const { pipe, abort } = renderToPipeableStream(
<I18nextProvider i18n={i18next}>
<RemixServer
context={remixContext}
url={request.url}
abortDelay={ABORT_DELAY}
/>
</I18nextProvider>,
{
onShellReady() {
shellRendered = true;
const body = new PassThrough();
const stream = createReadableStreamFromReadable(body);

responseHeaders.set("Content-Type", "text/html");

resolve(
new Response(stream, {
headers: responseHeaders,
status: responseStatusCode,
})
);

pipe(body);
},
onShellError(error: unknown) {
reject(error);
},
onError(error: unknown) {
responseStatusCode = 500;
// Log streaming rendering errors from inside the shell. Don't log
// errors encountered during initial shell rendering since they'll
// reject and get logged in handleDocumentRequest.
if (shellRendered) {
console.error(error);
}
},
}
);

setTimeout(abort, ABORT_DELAY);
});
}
3 changes: 3 additions & 0 deletions pkgs/frontend/app/locales/en.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
export default {
title: "remix-i18n is awesome",
};
3 changes: 3 additions & 0 deletions pkgs/frontend/app/locales/ja.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
export default {
title: "remix-i18n は凄い!",
};
15 changes: 14 additions & 1 deletion pkgs/frontend/app/root.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { withEmotionCache } from "@emotion/react";
import {
data,
Links,
Meta,
Outlet,
Expand All @@ -9,13 +10,25 @@ import {
import { ChakraProvider } from "./components/chakra-provider";
import { useInjectStyles } from "./emotion/emotion-client";
import { PrivyProvider } from "@privy-io/react-auth";
import { Container } from "@chakra-ui/react";
import { Header } from "./components/Header";
import { ApolloProvider } from "@apollo/client/react";
import { Container } from "@chakra-ui/react";
import type { LoaderFunctionArgs } from "@remix-run/node";
import { goldskyClient } from "utils/apollo";
import i18nServer, { localeCookie } from "./config/i18n.server";

interface LayoutProps extends React.PropsWithChildren {}

export const handle = { i18n: ["translation"] };

export async function loader({ request }: LoaderFunctionArgs) {
const locale = await i18nServer.getLocale(request);
return data(
{ locale },
{ headers: { "Set-Cookie": await localeCookie.serialize(locale) } }
);
}

export const Layout = withEmotionCache((props: LayoutProps, cache) => {
const { children } = props;

Expand Down
4 changes: 2 additions & 2 deletions pkgs/frontend/app/routes/_index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,8 @@ import CommonButton from "~/components/common/CommonButton";

export const meta: MetaFunction = () => {
return [
{ title: "New Remix App" },
{ name: "description", content: "Welcome to Remix!" },
{ title: "Toban" },
{ name: "description", content: "Welcome to Toban!" },
];
};

Expand Down
Loading
Loading