diff --git a/docs/__mocks__/react-intl.tsx b/docs/__mocks__/react-intl.tsx new file mode 100644 index 00000000000000..6048da72228f02 --- /dev/null +++ b/docs/__mocks__/react-intl.tsx @@ -0,0 +1,88 @@ +import { type ReactElement, type ReactNode } from 'react'; + +import enMessages from '~/messages/en.json'; + +type FormatMessageDescriptor = { + id?: string; + defaultMessage?: string; +}; + +type FormatValues = Record ReactNode)>; + +type FormattedMessageProps = FormatMessageDescriptor & { + values?: FormatValues; + children?: (chunks: ReactNode) => ReactNode; +}; + +const messages = enMessages as Record; + +function lookup({ id, defaultMessage }: FormatMessageDescriptor) { + if (id && messages[id] !== undefined) { + return messages[id]; + } + return defaultMessage ?? id ?? ''; +} + +function expandTemplate(template: string, values: FormatValues = {}): (string | ReactElement)[] { + const tokenRegex = /<([A-Za-z][\w-]*)>([\S\s]*?)<\/\1>|{([A-Za-z][\w-]*)}/g; + const parts: (string | ReactElement)[] = []; + let lastIndex = 0; + let key = 0; + for (const match of template.matchAll(tokenRegex)) { + const matchIndex = match.index ?? 0; + if (matchIndex > lastIndex) { + parts.push(template.slice(lastIndex, matchIndex)); + } + const [whole, tagName, inner, varName] = match; + if (tagName) { + const fn = values[tagName]; + const inside = expandTemplate(inner, values); + const node = typeof fn === 'function' ? fn(inside) : inside; + parts.push({node}); + } else if (varName) { + const v = values[varName]; + if (typeof v === 'function') { + parts.push({(v as (c: ReactNode) => ReactNode)('')}); + } else if (v !== undefined) { + parts.push({v as ReactNode}); + } + } + lastIndex = matchIndex + whole.length; + } + if (lastIndex < template.length) { + parts.push(template.slice(lastIndex)); + } + return parts; +} + +const stubIntl = { + locale: 'en', + formatMessage: (descriptor: FormatMessageDescriptor = {}) => lookup(descriptor), + formatDate: (value: unknown) => String(value ?? ''), + formatTime: (value: unknown) => String(value ?? ''), + formatRelativeTime: (value: unknown) => String(value ?? ''), + formatNumber: (value: unknown) => String(value ?? ''), + formatPlural: () => 'other', + formatList: (items: unknown[]) => (Array.isArray(items) ? items.join(', ') : String(items)), +}; + +export const useIntl = () => stubIntl; + +export const IntlProvider = ({ children }: { children?: ReactNode }) => <>{children}; + +export const FormattedMessage = ({ + defaultMessage, + id, + values, + children, +}: FormattedMessageProps) => { + const text = lookup({ id, defaultMessage }); + const parts = expandTemplate(text, values); + if (typeof children === 'function') { + return <>{children(parts)}; + } + return <>{parts}; +}; + +export const defineMessages = (messages: T) => messages; +export const createIntl = () => stubIntl; diff --git a/docs/common/i18n.ts b/docs/common/i18n.ts new file mode 100644 index 00000000000000..d64d07b3ff13e8 --- /dev/null +++ b/docs/common/i18n.ts @@ -0,0 +1,185 @@ +import enMessages from '~/messages/en.json'; +import jaMessages from '~/messages/ja.json'; + +export type SupportedLocale = 'en' | 'ja'; + +export const messages: Record> = { + en: enMessages, + ja: jaMessages, +}; + +export function getLocaleFromPath(path: string): SupportedLocale { + if (path === '/ja' || path.startsWith('/ja/')) { + return 'ja'; + } + return 'en'; +} + +export function getCanonicalPath(path: string): string { + if (path === '/ja' || path === '/ja/') { + return '/'; + } + let stripped = path.startsWith('/ja/') ? path.slice(3) : path; + if (stripped !== '/' && stripped.endsWith('/')) { + stripped = stripped.slice(0, -1); + } + return stripped; +} + +export function buildLocalePath(currentPath: string, targetLocale: SupportedLocale): string { + const englishPath = getCanonicalPath(currentPath); + if (targetLocale === 'en') { + return englishPath; + } + if (englishPath === '/') { + return '/ja'; + } + return `/ja${englishPath}`; +} + +const EXPO_TUTORIAL_PATHS: ReadonlySet = new Set([ + '/tutorial/overview', + '/tutorial/introduction', + '/tutorial/create-your-first-app', + '/tutorial/add-navigation', + '/tutorial/build-a-screen', + '/tutorial/image-picker', + '/tutorial/create-a-modal', + '/tutorial/gestures', + '/tutorial/screenshot', + '/tutorial/platform-differences', + '/tutorial/configuration', + '/tutorial/follow-up', +]); + +export function isTranslatableSection(path: string): boolean { + return EXPO_TUTORIAL_PATHS.has(getCanonicalPath(path)); +} + +const PATHS_WITH_JAPANESE: ReadonlySet = new Set([ + '/tutorial/overview', + '/tutorial/introduction', + '/tutorial/create-your-first-app', + '/tutorial/add-navigation', + '/tutorial/build-a-screen', + '/tutorial/image-picker', + '/tutorial/create-a-modal', + '/tutorial/gestures', + '/tutorial/screenshot', + '/tutorial/platform-differences', + '/tutorial/configuration', + '/tutorial/follow-up', +]); + +export function hasJapaneseTranslation(path: string): boolean { + return PATHS_WITH_JAPANESE.has(getCanonicalPath(path)); +} + +const JA_SIDEBAR_TITLES: Record = { + '/tutorial/overview': '概要', + '/tutorial/introduction': 'はじめに', + '/tutorial/create-your-first-app': '最初のアプリを作成する', + '/tutorial/add-navigation': 'ナビゲーションを追加する', + '/tutorial/build-a-screen': '画面を構築する', + '/tutorial/image-picker': '画像ピッカーを使用する', + '/tutorial/create-a-modal': 'モーダルを作成する', + '/tutorial/gestures': 'ジェスチャーを追加する', + '/tutorial/screenshot': 'スクリーンショットを撮影する', + '/tutorial/platform-differences': 'プラットフォームの違いに対応する', + '/tutorial/configuration': 'ステータスバー、スプラッシュスクリーン、アプリアイコンを設定する', + '/tutorial/follow-up': '学習リソース', +}; + +export function getJapaneseSidebarTitle(path: string): string | undefined { + return JA_SIDEBAR_TITLES[getCanonicalPath(path)]; +} + +const JA_SECTION_TITLES: Record = { + 'Expo tutorial': 'Expo チュートリアル', + More: 'その他', +}; + +export function getJapaneseSectionTitle(name: string): string | undefined { + return JA_SECTION_TITLES[name]; +} + +export const OG_LOCALES: Record = { + en: 'en_US', + ja: 'ja_JP', +}; + +export const SITE_NAMES: Record = { + en: 'Expo Documentation', + ja: 'Expo ドキュメント', +}; + +export const BASE_DESCRIPTIONS: Record = { + en: 'Expo is an open-source platform for making universal native apps for Android, iOS, and the web with JavaScript and React.', + ja: 'Expo は、JavaScript と React を使って Android、iOS、web で動作するユニバーサルネイティブアプリを作るためのオープンソースプラットフォームです。', +}; + +type EnglishOgContent = { title: string; description?: string }; + +const EN_OG_OVERRIDES: Record = { + '/tutorial/overview': { + title: 'Overview of Expo and EAS tutorials', + }, + '/tutorial/introduction': { + title: 'Tutorial: Using React Native and Expo', + description: + 'An introduction to a React Native tutorial on how to build a universal app that runs on Android, iOS and the web using Expo.', + }, + '/tutorial/create-your-first-app': { + title: 'Create your first app', + description: 'In this chapter, learn how to create a new Expo project.', + }, + '/tutorial/add-navigation': { + title: 'Add navigation', + description: 'In this chapter, learn how to add navigation to the Expo app.', + }, + '/tutorial/build-a-screen': { + title: 'Build a screen', + description: + "In this tutorial, learn how to use components such as React Native's Pressable and Expo Image to build a screen.", + }, + '/tutorial/image-picker': { + title: 'Use an image picker', + description: 'In this tutorial, learn how to use Expo Image Picker.', + }, + '/tutorial/create-a-modal': { + title: 'Create a modal', + description: 'In this tutorial, learn how to create a React Native modal to select an image.', + }, + '/tutorial/gestures': { + title: 'Add gestures', + description: + 'In this tutorial, learn how to implement gestures from React Native Gesture Handler and Reanimated libraries.', + }, + '/tutorial/screenshot': { + title: 'Take a screenshot', + description: + 'In this tutorial, learn how to capture a screenshot using a third-party library and Expo Media Library.', + }, + '/tutorial/platform-differences': { + title: 'Handle platform differences', + description: + 'In this tutorial, learn how to handle platform differences between native and web when creating a universal app.', + }, + '/tutorial/configuration': { + title: 'Configure status bar, splash screen and app icon', + description: + 'In this tutorial, learn the basics of how to configure a status bar, app icon, and splash screen.', + }, + '/tutorial/follow-up': { + title: 'Learning resources', + description: 'Explore a curated list of resources to learn about Expo and React Native.', + }, +}; + +export function getEnglishOgContent(path: string): { title: string; description: string } { + const override = EN_OG_OVERRIDES[getCanonicalPath(path)]; + return { + title: override?.title ?? SITE_NAMES.en, + description: override?.description ?? BASE_DESCRIPTIONS.en, + }; +} diff --git a/docs/common/routes.ts b/docs/common/routes.ts index 8f4b23569a6d20..115ee78898b048 100644 --- a/docs/common/routes.ts +++ b/docs/common/routes.ts @@ -1,3 +1,11 @@ +import { + buildLocalePath, + getJapaneseSectionTitle, + getCanonicalPath, + getJapaneseSidebarTitle, + hasJapaneseTranslation, + type SupportedLocale, +} from '~/common/i18n'; import * as Utilities from '~/common/utilities'; import { stripVersionFromPath } from '~/common/utilities'; import { PageApiVersionContextType } from '~/providers/page-api-version'; @@ -16,43 +24,50 @@ export const getRoutes = ( }; export const isArchivePath = (path: string) => { - return Utilities.pathStartsWith('archive', path); + return Utilities.pathStartsWith('archive', getCanonicalPath(path)); }; export const isInternalPath = (path: string) => { - return Utilities.pathStartsWith('internal', path); + return Utilities.pathStartsWith('internal', getCanonicalPath(path)); }; export const isVersionedPath = (path: string) => { - return Utilities.pathStartsWith('versions', path); + return Utilities.pathStartsWith('versions', getCanonicalPath(path)); }; export const isReferencePath = (path: string) => { - return navigation.referenceDirectories.some(name => Utilities.pathStartsWith(name, path)); + const canonical = getCanonicalPath(path); + return navigation.referenceDirectories.some(name => Utilities.pathStartsWith(name, canonical)); }; export const isHomePath = (path: string) => { - return navigation.homeDirectories.some(name => Utilities.pathStartsWith(name, path)); + const canonical = getCanonicalPath(path); + return navigation.homeDirectories.some(name => Utilities.pathStartsWith(name, canonical)); }; export const isGeneralPath = (path: string) => { - return navigation.generalDirectories.some(name => Utilities.pathStartsWith(name, path)); + const canonical = getCanonicalPath(path); + return navigation.generalDirectories.some(name => Utilities.pathStartsWith(name, canonical)); }; export const isFeaturePreviewPath = (path: string) => { - return navigation.featurePreview.some(name => Utilities.pathStartsWith(name, path)); + const canonical = getCanonicalPath(path); + return navigation.featurePreview.some(name => Utilities.pathStartsWith(name, canonical)); }; export const isPreviewPath = (path: string) => { - return navigation.previewDirectories.some(name => Utilities.pathStartsWith(name, path)); + const canonical = getCanonicalPath(path); + return navigation.previewDirectories.some(name => Utilities.pathStartsWith(name, canonical)); }; export const isLearnPath = (path: string) => { - return navigation.learnDirectories.some(name => Utilities.pathStartsWith(name, path)); + const canonical = getCanonicalPath(path); + return navigation.learnDirectories.some(name => Utilities.pathStartsWith(name, canonical)); }; export const isEasPath = (path: string) => { - return navigation.easDirectories.some(name => Utilities.pathStartsWith(name, path)); + const canonical = getCanonicalPath(path); + return navigation.easDirectories.some(name => Utilities.pathStartsWith(name, canonical)); }; export const getPageSection = (path: string) => { @@ -183,12 +198,63 @@ export function getBreadcrumbTrail( }); } +function isInternalHref(href?: string) { + if (!href) { + return false; + } + if (href.startsWith('http://') || href.startsWith('https://') || href.startsWith('//')) { + return false; + } + return href.startsWith('/'); +} + +export function localizeRoutes( + routes: T[], + locale: SupportedLocale +): T[] { + if (locale === 'en') { + return routes; + } + return routes.map(route => localizeRoute(route, locale)); +} + +function localizeRoute( + route: T, + locale: SupportedLocale +): T { + const next: T = { ...route }; + if (isInternalHref(next.href) && hasJapaneseTranslation(next.href)) { + next.href = buildLocalePath(next.href, locale); + } + if (isInternalHref(next.as) && hasJapaneseTranslation(next.as as string)) { + next.as = buildLocalePath(next.as as string, locale); + } + if (locale === 'ja' && next.type === 'page' && route.href) { + const translatedTitle = getJapaneseSidebarTitle(route.href); + if (translatedTitle) { + next.name = translatedTitle; + next.sidebarTitle = translatedTitle; + } + } + if (locale === 'ja' && next.type !== 'page' && route.name) { + const translatedSection = getJapaneseSectionTitle(route.name); + if (translatedSection) { + next.sidebarTitle = translatedSection; + } + } + if (next.children) { + next.children = next.children.map(child => localizeRoute(child, locale)); + } + return next; +} + export function appendSectionToRoute(route?: NavigationRouteWithSection) { if (route?.children) { + const sectionName = route.sidebarTitle ?? route.name; return route.children.map((entry: NavigationRouteWithSection) => route.type !== 'page' ? Object.assign(entry, { - section: route.section ? `${route.section} - ${route.name}` : route.name, + section: route.section ? `${route.section} - ${sectionName}` : sectionName, }) : route ); diff --git a/docs/components/DocumentationHead.tsx b/docs/components/DocumentationHead.tsx index 7027c46b61d655..d7391bf77c8e3d 100644 --- a/docs/components/DocumentationHead.tsx +++ b/docs/components/DocumentationHead.tsx @@ -1,30 +1,46 @@ import NextHead from 'next/head'; import type { PropsWithChildren } from 'react'; +import { + BASE_DESCRIPTIONS, + getEnglishOgContent, + OG_LOCALES, + SITE_NAMES, + type SupportedLocale, +} from '~/common/i18n'; + type HeadProps = PropsWithChildren<{ title?: string; description?: string; canonicalUrl?: string; markdownPath?: string; + locale?: SupportedLocale; + pathname?: string; }>; const BASE_OG_URL = 'https://og.expo.dev/?theme=docs'; -const BASE_TITLE = 'Expo Documentation'; -const BASE_DESCRIPTION = `Expo is an open-source platform for making universal native apps for Android, iOS, and the web with JavaScript and React.`; - const DocumentationHead = ({ title, description, canonicalUrl, markdownPath, + locale = 'en', + pathname, children, }: HeadProps) => { - const OGImageURL = `${BASE_OG_URL}&title=${encodeURIComponent(title ?? BASE_TITLE)}&description=${encodeURIComponent(description ?? BASE_DESCRIPTION)}`; + const siteName = SITE_NAMES[locale]; + const baseDescription = BASE_DESCRIPTIONS[locale]; + const resolvedDescription = description === '' ? baseDescription : description; + const ogImageContent = + locale === 'ja' && pathname + ? getEnglishOgContent(pathname) + : { title: title ?? siteName, description: description ?? baseDescription }; + const OGImageURL = `${BASE_OG_URL}&title=${encodeURIComponent(ogImageContent.title)}&description=${encodeURIComponent(ogImageContent.description)}`; return ( - {title ? `${title} - ${BASE_TITLE}` : BASE_TITLE} + {title ? `${title} - ${siteName}` : siteName} @@ -32,26 +48,20 @@ const DocumentationHead = ({ {canonicalUrl && } {markdownPath && } - + - - - + + + - + diff --git a/docs/components/DocumentationPage.tsx b/docs/components/DocumentationPage.tsx index f016b7bc6a3106..7fd864d7e8d654 100644 --- a/docs/components/DocumentationPage.tsx +++ b/docs/components/DocumentationPage.tsx @@ -3,8 +3,14 @@ import { breakpoints } from '@expo/styleguide-base'; import { useRouter } from 'next/compat/router'; import { useEffect, useState, type PropsWithChildren, useRef, useCallback, useMemo } from 'react'; +import { getLocaleFromPath } from '~/common/i18n'; import * as RoutesUtils from '~/common/routes'; -import { appendSectionToRoute, getBreadcrumbTrail, isRouteActive } from '~/common/routes'; +import { + appendSectionToRoute, + getBreadcrumbTrail, + isRouteActive, + localizeRoutes, +} from '~/common/routes'; import { versionToText, throttle } from '~/common/utilities'; import * as WindowUtils from '~/common/window'; import DocumentationHead from '~/components/DocumentationHead'; @@ -61,7 +67,8 @@ export default function DocumentationPage({ const tableOfContentsRef = useRef(null); const pathname = router?.pathname ?? '/'; - const routes = RoutesUtils.getRoutes(pathname, version); + const locale = getLocaleFromPath(pathname); + const routes = localizeRoutes(RoutesUtils.getRoutes(pathname, version), locale); const sidebarActiveGroup = RoutesUtils.getPageSection(pathname); const breadcrumbTrail = getBreadcrumbTrail(routes, pathname); const breadcrumbSchema = buildBreadcrumbListSchema(breadcrumbTrail); @@ -329,7 +336,9 @@ export default function DocumentationPage({ title={title} description={description} canonicalUrl={canonicalUrl} - markdownPath={markdownPath}> + markdownPath={markdownPath} + locale={locale} + pathname={pathname}> {hideFromSearch !== true && ( /$1', + // Stub react-intl in tests so components that call useIntl() don't + // require an ancestor. See __mocks__/react-intl.tsx. + '^react-intl$': '/__mocks__/react-intl.tsx', // note(simek): force Jest to use non ESM bundle '^@radix-ui/react-dropdown-menu$': '/node_modules/@radix-ui/react-dropdown-menu/dist/index.js', diff --git a/docs/messages/en.json b/docs/messages/en.json new file mode 100644 index 00000000000000..78f0a918cb1726 --- /dev/null +++ b/docs/messages/en.json @@ -0,0 +1,43 @@ +{ + "languageSwitcher.label": "Language", + "editPage": "Edit page", + "copyPage": "Copy page", + "onThisPage": "On this page", + "navHome": "Home", + "navGuides": "Guides", + "navEas": "EAS", + "navReference": "Reference", + "navLearn": "Learn", + "headerBlog": "Blog", + "headerChangelog": "Changelog", + "headerStarOnGitHub": "Star Us on GitHub", + "themeAuto": "Auto", + "themeLight": "Light", + "themeDark": "Dark", + "showMore": "Show More", + "showLess": "Show Less", + "codeCopy": "Copy", + "codeCopied": "Copied!", + "codeUseDarkTheme": "Use dark theme", + "codeWrapLines": "Wrap long lines", + "footerPrevious": "Previous", + "footerNext": "Next", + "footerWasHelpful": "Was this doc helpful?", + "footerThankYouForVote": "Thank you for your vote! 💙", + "footerShareFeedback": "Share your feedback", + "footerAskOnForums": "Ask a question on the forums", + "footerEditPage": "Edit this page", + "footerLlmsView": "View llms.txt and {fullName}", + "footerNewsletterTitle": "Sign up for the Expo Newsletter", + "footerSignUp": "Sign Up", + "footerThankYouSignUp": "Thank you for the sign up! 💙", + "footerUnsubscribePrefix": "Unsubscribe at any time. Read our ", + "footerPrivacyPolicy": "privacy policy", + "footerUnsubscribeSuffix": ".", + "progressTrackerMarkAsRead": "Mark this chapter as read", + "progressTrackerMarkAsUnread": "Mark this chapter as unread", + "progressTrackerNext": "Next: {title}", + "prerequisitesHeading": "Prerequisites", + "prerequisitesRequirementSingular": "requirement", + "prerequisitesRequirementPlural": "requirements" +} diff --git a/docs/messages/ja.json b/docs/messages/ja.json new file mode 100644 index 00000000000000..b036f04ba8682e --- /dev/null +++ b/docs/messages/ja.json @@ -0,0 +1,43 @@ +{ + "languageSwitcher.label": "言語", + "editPage": "ページを編集", + "copyPage": "ページをコピー", + "onThisPage": "このページの内容", + "navHome": "ホーム", + "navGuides": "ガイド", + "navEas": "EAS", + "navReference": "リファレンス", + "navLearn": "学習", + "headerBlog": "ブログ", + "headerChangelog": "変更履歴", + "headerStarOnGitHub": "GitHub でスターを付ける", + "themeAuto": "自動", + "themeLight": "ライト", + "themeDark": "ダーク", + "showMore": "もっと見る", + "showLess": "折りたたむ", + "codeCopy": "コピー", + "codeCopied": "コピーしました!", + "codeUseDarkTheme": "ダークテーマを使用", + "codeWrapLines": "長い行を折り返す", + "footerPrevious": "前へ", + "footerNext": "次へ", + "footerWasHelpful": "このドキュメントは役に立ちましたか?", + "footerThankYouForVote": "ご投票ありがとうございます!💙", + "footerShareFeedback": "フィードバックを共有する", + "footerAskOnForums": "フォーラムで質問する", + "footerEditPage": "このページを編集する", + "footerLlmsView": "llms.txt{fullName} を表示", + "footerNewsletterTitle": "Expo ニュースレターに登録する", + "footerSignUp": "登録", + "footerThankYouSignUp": "ご登録ありがとうございます!💙", + "footerUnsubscribePrefix": "いつでも配信停止できます。", + "footerPrivacyPolicy": "プライバシーポリシー", + "footerUnsubscribeSuffix": "をお読みください。", + "progressTrackerMarkAsRead": "この章を読了にする", + "progressTrackerMarkAsUnread": "この章を未読に戻す", + "progressTrackerNext": "次へ:{title}", + "prerequisitesHeading": "前提条件", + "prerequisitesRequirementSingular": "件", + "prerequisitesRequirementPlural": "件" +} diff --git a/docs/package.json b/docs/package.json index ce1a5123c72e9c..1a707ee3a5e6e3 100644 --- a/docs/package.json +++ b/docs/package.json @@ -41,6 +41,7 @@ "@expo/styleguide-cookie-consent": "^2.1.2", "@expo/styleguide-icons": "^4.2.4", "@expo/styleguide-search-ui": "^9.1.6", + "@formatjs/intl-localematcher": "^0.8.5", "@kapaai/react-sdk": "^0.9.6", "@mdx-js/loader": "^3.1.1", "@mdx-js/mdx": "^3.1.1", @@ -70,6 +71,7 @@ "react": "^19.2.6", "react-diff-view": "^3.3.3", "react-dom": "^19.2.6", + "react-intl": "^10.1.4", "react-markdown": "^10.1.0", "react-player": "^3.4.0", "react-qr-code": "^2.0.18", diff --git a/docs/pages/_app.tsx b/docs/pages/_app.tsx index b745b6a6ff58ca..3b7541a13d3d65 100644 --- a/docs/pages/_app.tsx +++ b/docs/pages/_app.tsx @@ -6,7 +6,10 @@ import * as Sentry from '@sentry/react'; import { MotionConfig } from 'framer-motion'; import { AppProps } from 'next/app'; import { Inter, JetBrains_Mono } from 'next/font/google'; +import { useRouter } from 'next/router'; +import { IntlProvider } from 'react-intl'; +import { getLocaleFromPath, messages } from '~/common/i18n'; import { preprocessSentryError } from '~/common/sentry-utilities'; import { useNProgress } from '~/common/useNProgress'; import { DocumentationPageWrapper } from '~/components/DocumentationPageWrapper'; @@ -14,6 +17,7 @@ import { websiteSchema } from '~/constants/structured-data'; import { useAnalyticsPageTracking } from '~/providers/Analytics'; import { CodeBlockSettingsProvider } from '~/providers/CodeBlockSettingsProvider'; import { TutorialChapterCompletionProvider } from '~/providers/TutorialChapterCompletionProvider'; +import { HreflangAlternates } from '~/ui/components/HreflangAlternates'; import { markdownComponents } from '~/ui/components/Markdown'; import { StructuredData } from '~/ui/components/StructuredData'; import * as Tooltip from '~/ui/components/Tooltip'; @@ -62,11 +66,14 @@ const rootMarkdownComponents = { export { reportWebVitals } from '~/providers/Analytics'; export default function App({ Component, pageProps }: AppProps) { + const router = useRouter(); + const locale = getLocaleFromPath(router.asPath); useNProgress(); useAnalyticsPageTracking(); return ( <> + {/* oxlint-disable-next-line react/no-unknown-property */} - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + ); } diff --git a/docs/pages/_document.tsx b/docs/pages/_document.tsx index 6b9bedb5735136..66610710222263 100644 --- a/docs/pages/_document.tsx +++ b/docs/pages/_document.tsx @@ -1,6 +1,8 @@ import { THEME_COOKIE_NAME } from '@expo/styleguide'; import Document, { Html, Head, Main, NextScript, DocumentContext } from 'next/document'; +import { getLocaleFromPath, type SupportedLocale } from '~/common/i18n'; + const BLOCKING_THEME_SCRIPT = ` (function() { function getCookieTheme() { @@ -17,19 +19,25 @@ const BLOCKING_THEME_SCRIPT = ` })(); `; -export default class DocsDocument extends Document { +type DocsDocumentProps = { + locale: SupportedLocale; +}; + +export default class DocsDocument extends Document { // eslint-disable-next-line @typescript-eslint/naming-convention static async getInitialProps(ctx: DocumentContext) { const initialProps = await Document.getInitialProps(ctx); + const locale = getLocaleFromPath(ctx.pathname || ''); return { ...initialProps, + locale, styles: <>{initialProps.styles}, }; } render() { return ( - +