diff --git a/.gitignore b/.gitignore index d6999b60fbb..4cd730d60a1 100644 --- a/.gitignore +++ b/.gitignore @@ -76,6 +76,8 @@ yarn-error.log src/data/contributors.json # These files are generated by `yarn merge-translations` command src/intl/*.json +i18n/locales +i18n/merged # Auto generated code when gatsby build the site src/gatsby-types.d.ts diff --git a/docs/locales-process.md b/docs/locales-process.md new file mode 100644 index 00000000000..53d29051ae1 --- /dev/null +++ b/docs/locales-process.md @@ -0,0 +1,9 @@ +# Locales generation process + +Every time `yarn build` or `yarn start` is executed, the following process is +going to be triggered as well: + + + +With this process, we reduce the amount of text we bundle on each page since we +are querying only the necessary translations that each page needs. diff --git a/docs/locales.png b/docs/locales.png new file mode 100644 index 00000000000..ae34fe1eecd Binary files /dev/null and b/docs/locales.png differ diff --git a/gatsby-browser.tsx b/gatsby-browser.tsx index 709de7493a1..bfb9a18f2c4 100644 --- a/gatsby-browser.tsx +++ b/gatsby-browser.tsx @@ -4,10 +4,6 @@ * See: https://www.gatsbyjs.org/docs/browser-apis/ */ -import React from "react" -import browserLang from "browser-lang" -import { withPrefix, GatsbyBrowser } from "gatsby" - import Prism from "prism-react-renderer/prism" ;(typeof global !== "undefined" ? global : window).Prism = Prism @@ -16,53 +12,6 @@ import "@formatjs/intl-locale/polyfill" import "@formatjs/intl-numberformat/polyfill" import "@formatjs/intl-numberformat/locale-data/en" -import Layout from "./src/components/Layout" -import { - supportedLanguages, - defaultLanguage, - isLang, -} from "./src/utils/languages" -import { IS_DEV } from "./src/utils/env" -import { Context } from "./src/types" - // Default languages included: // https://github.com/FormidableLabs/prism-react-renderer/blob/master/src/vendor/prism/includeLangs.js require("prismjs/components/prism-solidity") - -// Prevents from unmounting on page transitions -// https://www.gatsbyjs.com/docs/layout-components/#how-to-prevent-layout-components-from-unmounting -// @ts-ignore: returning `null` is not accepted by the `GatsbyBrowser` type def. -export const wrapPageElement: GatsbyBrowser< - any, - Context ->["wrapPageElement"] = ({ element, props }) => { - const { location, pageContext } = props - const { pathname, search } = location - const { originalPath } = pageContext - - const [, pathLocale] = pathname.split("/") - - // client side redirect on paths that don't have a locale in them. Most useful - // on dev env where we don't have server redirects - if (IS_DEV && !isLang(pathLocale)) { - let detected = - window.localStorage.getItem("eth-org-language") || - browserLang({ - languages: supportedLanguages, - fallback: defaultLanguage, - }) - - if (!isLang(detected)) { - detected = defaultLanguage - } - - const queryParams = search || "" - const newUrl = withPrefix(`/${detected}${originalPath}${queryParams}`) - window.localStorage.setItem("eth-org-language", detected) - window.location.replace(newUrl) - - return null - } - - return {element} -} diff --git a/gatsby-config.ts b/gatsby-config.ts index 1399706d5b5..7846a425756 100644 --- a/gatsby-config.ts +++ b/gatsby-config.ts @@ -35,24 +35,6 @@ const config: GatsbyConfig = { editContentUrl: `https://github.com/ethereum/ethereum-org-website/tree/dev/`, }, plugins: [ - // i18n support - { - resolve: `gatsby-theme-i18n`, - options: { - defaultLang: defaultLanguage, - prefixDefault: true, - locales: supportedLanguages.length - ? supportedLanguages.join(" ") - : null, - configPath: path.resolve(`./i18n/config.json`), - }, - }, - { - resolve: `gatsby-theme-i18n-react-intl`, - options: { - defaultLocale: `./src/intl/en.json`, - }, - }, // Web app manifest { resolve: `gatsby-plugin-manifest`, @@ -266,6 +248,59 @@ const config: GatsbyConfig = { generateMatchPathRewrites: false, }, }, + // i18n support + { + resolve: `gatsby-source-filesystem`, + options: { + path: path.resolve(`./i18n/locales`), + name: `locale`, + }, + }, + // Wraps the entire page with a custom layout component + // Note: keep this before the i18n plugin declaration in order to have the + // i18n provider wrapping the layout component + { + resolve: `gatsby-plugin-layout`, + options: { + component: path.resolve(`./src/components/Layout`), + }, + }, + { + resolve: `gatsby-plugin-react-i18next`, + options: { + localeJsonSourceName: `locale`, // name given to `gatsby-source-filesystem` plugin. + languages: supportedLanguages, + defaultLanguage, + generateDefaultLanguagePage: true, + redirect: false, + siteUrl, + trailingSlash: "always", + // i18next options + i18nextOptions: { + fallbackLng: defaultLanguage, + interpolation: { + escapeValue: false, + }, + load: "currentOnly", + lowerCaseLng: true, + cleanCode: true, + react: { + transSupportBasicHtmlNodes: true, + transKeepBasicHtmlNodesFor: [ + "br", + "strong", + "i", + "bold", + "b", + "em", + "sup", + ], + }, + keySeparator: false, + nsSeparator: false, + }, + }, + }, ], // https://www.gatsbyjs.com/docs/reference/release-notes/v2.28/#feature-flags-in-gatsby-configjs flags: { diff --git a/gatsby-node.ts b/gatsby-node.ts index ef5f9af66ab..6262c932acf 100644 --- a/gatsby-node.ts +++ b/gatsby-node.ts @@ -10,7 +10,7 @@ import type { Context } from "./src/types" import * as Schema from "./src/schema" -import mergeTranslations from "./src/scripts/mergeTranslations" +import createLocales from "./src/scripts/createLocales" import copyContributors from "./src/scripts/copyContributors" import { @@ -284,17 +284,23 @@ export const createPages: GatsbyNode["createPages"] = async ({ component: path.resolve(`src/templates/${template}.tsx`), context: { language: lang, + languagesToFetch: [lang], slug: langSlug, ignoreTranslationBanner: isLegal, isLegal: isLegal, isOutdated: false, isContentEnglish: true, relativePath, // Use English path for template MDX query - // gatsby i18n theme context - locale: lang, - hrefLang: lang, - originalPath: langSlug.slice(3), - dateFormat: "MM/DD/YYYY", + // gatsby i18n plugin + i18n: { + language: lang, + languages: supportedLanguages, + defaultLanguage: defaultLanguage, + generateDefaultLanguagePage: false, + routed: true, + originalPath: langSlug.slice(3), + path: langSlug, + }, }, }) } @@ -306,15 +312,21 @@ export const createPages: GatsbyNode["createPages"] = async ({ component: path.resolve(`src/templates/${template}.tsx`), context: { language, + languagesToFetch: [language], slug, isOutdated: !!node.fields.isOutdated, isDefaultLang: language === defaultLanguage, relativePath, - // gatsby i18n theme context - locale: language, - hrefLang: language, - originalPath: slug.slice(3), - dateFormat: "MM/DD/YYYY", + // gatsby i18n plugin + i18n: { + language, + languages: supportedLanguages, + defaultLanguage, + generateDefaultLanguagePage: false, + routed: true, + originalPath: slug.slice(3), + path: slug, + }, }, }) }) @@ -344,22 +356,26 @@ export const createPages: GatsbyNode["createPages"] = async ({ page, lang ) - const slug = `/${lang}${originalPath}` - createPage({ path: slug, component: path.resolve(`src/pages-conditional/${page}.tsx`), context: { language: lang, + languagesToFetch: [lang], slug, isContentEnglish, isOutdated, - // gatsby i18n theme context - locale: lang, - hrefLang: lang, - originalPath, - dateFormat: "MM/DD/YYYY", + // gatsby i18n plugin + i18n: { + language: lang, + languages: supportedLanguages, + defaultLanguage, + generateDefaultLanguagePage: false, + routed: true, + originalPath, + path: slug, + }, }, }) } @@ -376,51 +392,68 @@ export const onCreatePage: GatsbyNode["onCreatePage"] = async ({ }) => { const { createPage, deletePage, createRedirect } = actions - const isDefaultLang = page.path.startsWith(`/${defaultLanguage}`) - - if (isDefaultLang) { - const path = page.path.slice(3) - - if (IS_DEV) { - // create routes without the lang prefix e.g. `/{path}` as our i18n plugin - // only creates `/{lang}/{path}` routes. This is useful on dev env to avoid - // getting a 404 since we don't have server side redirects - createPage({ ...page, path }) - } - - if (!IS_DEV && !path.match(/^\/404(\/|.html)$/)) { - // on prod, indicate our servers to redirect the root paths to the - // `/{defaultLang}/{path}` - createRedirect({ - ...commonRedirectProps, - fromPath: path, - toPath: page.path, - }) - } - } - if (!page.context) { return } - const isTranslated = page.context.locale !== defaultLanguage - const hasNoContext = page.context.isOutdated === undefined + // these are the native Gatsby pages (those living under `/pages`) + // which do not pass through the `createPages` hook thus they don't have our + // custom context in them + const isPageWithoutCustomContext = page.context.isOutdated === undefined - if (isTranslated && hasNoContext) { + if (isPageWithoutCustomContext) { + const { language, i18n } = page.context + const isDefaultLang = language === defaultLanguage + + // as we don't have our custom context for this page, we calculate & add it + // later to them const { isOutdated, isContentEnglish } = await checkIsPageOutdated( - page.context.originalPath, - page.context.locale + i18n.originalPath, + language ) - deletePage(page) - createPage({ + + let newPage = { ...page, context: { ...page.context, + languagesToFetch: [language], isOutdated, //display TranslationBanner for translation-component pages that are still in English isContentEnglish, }, - }) + } + + // there seems to be a bug in the i18n plugin where 404 pages get a + // duplicated `/lang` in their `matchPath`s + if (newPage.matchPath?.includes(`/${language}/${language}/*`)) { + newPage = { ...newPage, matchPath: `/${language}/*` } + } + + // on dev, we will have 2 pages for the default lang + // - 1 for the ones with the prefix `/{defaultLang}/learn/` + // - 1 for the ones without the prefix `/learn/` + // we do this to avoid having a 404 on those without the prefix since in + // dev we don't have the redirects from the server + deletePage(page) + + if (IS_DEV) { + createPage(newPage) + } + + // `routed` means that the page have the lang prefix on the url + // e.g. `/en/learn` or `/en` + if (!IS_DEV && i18n.routed) { + createPage(newPage) + + const rootPath = page.path.slice(3) + if (isDefaultLang && !rootPath.match(/^\/404(\/|.html)$/)) { + createRedirect({ + ...commonRedirectProps, + fromPath: rootPath, + toPath: page.path, + }) + } + } } } @@ -446,9 +479,11 @@ export const createSchemaCustomization: GatsbyNode["createSchemaCustomization"] createTypes([...Object.keys(sdls).map((sdlKey) => sdls[sdlKey])]) } -export const onPreBootstrap: GatsbyNode["onPreBootstrap"] = ({ reporter }) => { - mergeTranslations() - reporter.info(`Merged translations saved`) +export const onPreBootstrap: GatsbyNode["onPreBootstrap"] = async ({ + reporter, +}) => { + await createLocales() + reporter.info(`Created locales`) copyContributors() reporter.info(`Contributors copied`) } diff --git a/gatsby-ssr.tsx b/gatsby-ssr.tsx deleted file mode 100644 index e0925d5ceb6..00000000000 --- a/gatsby-ssr.tsx +++ /dev/null @@ -1,36 +0,0 @@ -/** - * Implement Gatsby's SSR (Server Side Rendering) APIs in this file. - * - * See: https://www.gatsbyjs.org/docs/ssr-apis/ - */ - -import React from "react" - -import type { GatsbySSR } from "gatsby" - -import Layout from "./src/components/Layout" - -import { Context } from "./src/types" -import { IS_DEV } from "./src/utils/env" -import { isLang } from "./src/utils/languages" - -// Prevents from unmounting on page transitions -// https://www.gatsbyjs.com/docs/layout-components/#how-to-prevent-layout-components-from-unmounting -// @ts-ignore: returning `null` is not accepted by the `GatsbySSR` type def. -export const wrapPageElement: GatsbySSR["wrapPageElement"] = ({ - element, - props, -}) => { - const { location } = props - const { pathname } = location - - const [, pathLocale] = pathname.split("/") - - // this is to avoid having hydration issues on dev mode. Check the logic - // inside gatsby-browser.tsx - if (IS_DEV && !isLang(pathLocale)) { - return null - } - - return {element} -} diff --git a/package.json b/package.json index 4e2391e9e74..65e8522a8e7 100644 --- a/package.json +++ b/package.json @@ -33,12 +33,14 @@ "gatsby-plugin-emotion": "^8.4.0", "gatsby-plugin-gatsby-cloud": "^5.4.0", "gatsby-plugin-image": "^3.4.0", + "gatsby-plugin-layout": "^3.24.0", "gatsby-plugin-manifest": "^5.4.0", "gatsby-plugin-matomo": "^0.14.0", "gatsby-plugin-mdx": "^3.0.0", "gatsby-plugin-netlify": "^5.1.0", "gatsby-plugin-react-helmet": "^6.4.0", "gatsby-plugin-react-helmet-canonical-urls": "^1.4.0", + "gatsby-plugin-react-i18next": "^3.0.1", "gatsby-plugin-react-svg": "^3.3.0", "gatsby-plugin-robots-txt": "^1.8.0", "gatsby-plugin-sharp": "^5.4.0", @@ -48,13 +50,13 @@ "gatsby-remark-images": "^6.21.0", "gatsby-remark-reading-time": "^1.1.0", "gatsby-source-filesystem": "^5.4.0", - "gatsby-theme-i18n": "^3.0.0", - "gatsby-theme-i18n-react-intl": "^3.0.0", "gatsby-transformer-csv": "^5.4.0", "gatsby-transformer-gitinfo": "^1.1.0", "gatsby-transformer-json": "^5.4.0", "gatsby-transformer-remark": "^6.4.0", "gatsby-transformer-sharp": "^5.4.0", + "htmr": "^1.0.2", + "i18next": "^21.9.2", "is-relative-url": "^3.0.0", "lodash": "^4.17.21", "luxon": "^1.28.1", @@ -67,9 +69,9 @@ "react-dom": "^18.0.0", "react-emoji-render": "^2.0.1", "react-helmet": "^6.1.0", + "react-i18next": "^12.1.4", "react-icons": "^4.3.1", "react-instantsearch-dom": "^6.32.0", - "react-intl": "^3.12.1", "react-select": "^4.3.0", "recharts": "^2.1.9", "styled-system": "^5.1.5", @@ -110,8 +112,10 @@ "prettier": "^2.2.1", "pretty-quick": "^3.1.0", "storybook": "^7.0.0-beta.51", + "rimraf": "^4.1.1", "ts-node": "^10.9.1", - "typescript": "^4.6.3" + "typescript": "^4.6.3", + "walkdir": "^0.4.1" }, "scripts": { "postinstall": "yarn theme", @@ -124,6 +128,8 @@ "format": "prettier --write \"**/*.{js,jsx,json,md}\"", "markdown-checker": "ts-node -O '{ \"module\": \"commonjs\" }' src/scripts/markdownChecker.ts", "generate-heading-ids": "ts-node -O '{ \"module\": \"commonjs\" }' src/scripts/generateHeadingIds.ts", + "unused-translations": "ts-node -O '{ \"module\": \"commonjs\" }' src/scripts/unusedTranslations.ts", + "reorg-intl": "ts-node -O '{ \"module\": \"commonjs\" }' src/scripts/reorgIntlFiles.ts", "start": "gatsby develop", "start:lambda": "netlify-lambda serve src/lambda", "start:static": "gatsby build && gatsby serve", diff --git a/src/components/BeaconChainActions.tsx b/src/components/BeaconChainActions.tsx index d38e797235f..3ca5727ef72 100644 --- a/src/components/BeaconChainActions.tsx +++ b/src/components/BeaconChainActions.tsx @@ -1,11 +1,8 @@ import React from "react" import { Box, Flex, Heading } from "@chakra-ui/react" - import { useStaticQuery, graphql } from "gatsby" +import { useTranslation } from "gatsby-plugin-react-i18next" -import { useIntl } from "react-intl" - -import { translateMessageId } from "../utils/translations" import { getImage, ImageDataLike } from "../utils/image" import CardList from "./CardList" @@ -57,7 +54,7 @@ type BeaconQueryTypes = { } const BeaconChainActions: React.FC = () => { - const intl = useIntl() + const { t } = useTranslation() const data = useStaticQuery(BeaconStaticQuery) const datapoints: Array = [ @@ -66,44 +63,32 @@ const BeaconChainActions: React.FC = () => { image: getImage(data.beaconscan)!, alt: "", link: "https://beaconscan.com", - description: translateMessageId("consensus-beaconscan-desc", intl), + description: t("consensus-beaconscan-desc"), }, { title: "beaconcha.in", image: getImage(data.beaconchain)!, alt: "", link: "https://beaconcha.in", - description: translateMessageId("consensus-beaconcha-in-desc", intl), + description: t("consensus-beaconcha-in-desc"), }, ] //TODO: we should refactor the naming here instead of using authors into the description field const reads: Array = [ { - title: translateMessageId( - "page-upgrade-article-title-two-point-oh", - intl - ), + title: t("page-upgrade-article-title-two-point-oh"), description: "Status", link: "https://our.status.im/two-point-oh-the-beacon-chain/", }, { - title: translateMessageId( - "page-upgrade-article-title-beacon-chain-explainer", - intl - ), + title: t("page-upgrade-article-title-beacon-chain-explainer"), description: "Ethos.dev", link: "https://ethos.dev/beacon-chain/", }, { - title: translateMessageId( - "page-upgrade-article-title-sharding-consensus", - intl - ), - description: translateMessageId( - "page-upgrade-article-author-ethereum-foundation", - intl - ), + title: t("page-upgrade-article-title-sharding-consensus"), + description: t("page-upgrade-article-author-ethereum-foundation"), link: "https://blog.ethereum.org/2020/03/27/sharding-consensus/", }, ] @@ -117,8 +102,8 @@ const BeaconChainActions: React.FC = () => { mr={{ base: 0, md: 4 }} mb={{ base: 8, md: 0 }} emoji=":money_with_wings:" - title={translateMessageId("consensus-become-staker", intl)} - description={translateMessageId("consensus-become-staker-desc", intl)} + title={t("consensus-become-staker")} + description={t("consensus-become-staker-desc")} > @@ -132,11 +117,8 @@ const BeaconChainActions: React.FC = () => { mr={0} ml={{ base: 0, md: 4 }} emoji=":computer:" - title={translateMessageId("consensus-run-beacon-chain", intl)} - description={translateMessageId( - "consensus-run-beacon-chain-desc", - intl - )} + title={t("consensus-run-beacon-chain")} + description={t("consensus-run-beacon-chain-desc")} > diff --git a/src/components/Breadcrumbs.tsx b/src/components/Breadcrumbs.tsx index f646fb7c5c0..6ea6e30f149 100644 --- a/src/components/Breadcrumbs.tsx +++ b/src/components/Breadcrumbs.tsx @@ -1,21 +1,24 @@ import React from "react" +import { useTranslation, useI18next } from "gatsby-plugin-react-i18next" import { Breadcrumb, BreadcrumbItem, BreadcrumbLink, BreadcrumbProps, } from "@chakra-ui/react" -import { useIntl } from "react-intl" import Link from "./Link" -import { isLang } from "../utils/languages" -import { isTranslationKey, translateMessageId } from "../utils/translations" export interface IProps extends BreadcrumbProps { slug: string startDepth?: number } +interface Crumb { + fullPath: string + text: string +} + // Generate crumbs from slug // e.g. "/en/eth2/proof-of-stake/" will generate: // [ @@ -30,28 +33,39 @@ export interface IProps extends BreadcrumbProps { // { fullPath: "/en/eth2/proof-of-stake/", text: "PROOF OF STAKE" }, // ] const Breadcrumbs: React.FC = ({ - slug, + slug: originalSlug, startDepth = 0, ...restProps }) => { - const intl = useIntl() + const { t } = useTranslation() + const { language } = useI18next() + + const hasHome = originalSlug.includes(`/${language}/`) + const slug = originalSlug.replace(`/${language}/`, "/") const slugChunk = slug.split("/") - const sliced = slugChunk.filter((item) => !!item).slice(startDepth) + const sliced = slugChunk.filter((item) => !!item) - const crumbs = sliced.map((path, idx) => { + const crumbs = [ // If homepage (e.g. "en"), set text to "home" translation - const text = isLang(path) - ? translateMessageId("page-index-meta-title", intl) - : isTranslationKey(path) - ? translateMessageId(path, intl) - : "" - - return { - fullPath: slugChunk.slice(0, idx + 2 + startDepth).join("/") + "/", - text: text.toUpperCase(), - } - }) + ...(hasHome + ? [ + { + fullPath: "/", + text: t("page-index-meta-title"), + }, + ] + : []), + , + ...sliced.map((path, idx) => { + return { + fullPath: slugChunk.slice(0, idx + 2).join("/") + "/", + text: t(path), + } + }), + ] + .filter((item): item is Crumb => !!item) + .slice(startDepth) return ( = ({ _hover={{ color: "primary", textDecor: "none" }} _active={{ color: "primary" }} > - {crumb.text} + {crumb.text.toUpperCase()} ) diff --git a/src/components/ButtonDropdown.tsx b/src/components/ButtonDropdown.tsx index 501bcc139cb..04f8a468f3a 100644 --- a/src/components/ButtonDropdown.tsx +++ b/src/components/ButtonDropdown.tsx @@ -1,7 +1,6 @@ // Libraries import React, { useState, createRef } from "react" import styled from "@emotion/styled" -import { useIntl } from "react-intl" import { motion } from "framer-motion" import { MdMenu } from "react-icons/md" @@ -12,7 +11,6 @@ import Translation from "./Translation" // Utils import { useOnClickOutside } from "../hooks/useOnClickOutside" -import { translateMessageId, TranslationKey } from "../utils/translations" import { trackCustomEvent } from "../utils/matomo" const Container = styled.div` @@ -97,7 +95,7 @@ const NakedNavLink = styled.div` ` export interface ListItem { - text: TranslationKey + text: string to?: string matomo?: { eventCategory: string @@ -108,7 +106,7 @@ export interface ListItem { } export interface List { - text: TranslationKey + text: string ariaLabel: string items: Array } @@ -120,7 +118,6 @@ export interface IProps { const ButtonDropdown: React.FC = ({ list, className }) => { const [isOpen, setIsOpen] = useState(false) - const intl = useIntl() const ref = createRef() useOnClickOutside(ref, () => setIsOpen(false)) @@ -136,7 +133,7 @@ const ButtonDropdown: React.FC = ({ list, className }) => { ) : (