From 6612b452ed7c2bb72239e44e1c3c7283ccfe516d Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 9 Aug 2026 20:56:46 +0000 Subject: [PATCH] feat(i18n): preload SSR translations and localize SEO metadata - Add loadI18nServerProps, withI18nServerSideProps, and localeFromContext - Preload i18n in playground and profile getServerSideProps - Add SITE_SEO_* and PLAYGROUND_SEO_* keys; use LL on home and profile - Localize playground landing SEO via createTranslationFunctions on server - Align App pilot home metadata with shared SEO translation keys --- src/app/internal-marketing/[locale]/page.tsx | 20 +++-- src/i18n/__tests__/getI18nProps.test.ts | 81 ++++++++++++++++++++ src/i18n/createTranslationFunctions.ts | 22 ++++++ src/i18n/en/index.ts | 6 ++ src/i18n/getI18nProps.ts | 58 +++++++++++++- src/i18n/i18n-types.ts | 35 ++++++++- src/i18n/i18n-util.async.ts | 2 +- src/i18n/i18n-util.sync.ts | 2 +- src/i18n/i18n-util.ts | 2 +- src/pages/index.tsx | 24 +++--- src/pages/playground/[[...slug]].tsx | 26 +++++-- src/pages/profile/[userId].tsx | 34 ++++---- vibe-docs/Instant-Navigations-TODO.md | 2 + 13 files changed, 262 insertions(+), 52 deletions(-) create mode 100644 src/i18n/__tests__/getI18nProps.test.ts create mode 100644 src/i18n/createTranslationFunctions.ts diff --git a/src/app/internal-marketing/[locale]/page.tsx b/src/app/internal-marketing/[locale]/page.tsx index 38c82b2b..74abadd5 100644 --- a/src/app/internal-marketing/[locale]/page.tsx +++ b/src/app/internal-marketing/[locale]/page.tsx @@ -3,11 +3,8 @@ import type { Metadata } from "next"; import { MarketingHomeView } from "#/features/homePage/ui/MarketingHomeView"; import type { Locales } from "#/i18n/i18n-types"; import { locales } from "#/i18n/i18n-util"; -import { DEFAULT_SITE_DESCRIPTION } from "#/shared/lib/seo"; -import { internalMarketingPilotMetadata } from "#/app/internal-marketing/internalMarketingPilotMetadata"; - -const homeTitle = "dStruct — visualize LeetCode solutions"; +import { pilotPageMetadataFromTranslation } from "#/app/internal-marketing/pilotPageMetadata"; export async function generateMetadata({ params, @@ -16,16 +13,17 @@ export async function generateMetadata({ }): Promise { const { locale: localeParam } = await params; if (!locales.includes(localeParam as Locales)) { - return { title: homeTitle, robots: { index: false, follow: false } }; + return pilotPageMetadataFromTranslation("en", "/", (translation) => ({ + title: translation.SITE_SEO_TITLE, + description: translation.SITE_SEO_DESCRIPTION, + })); } const locale = localeParam as Locales; - return internalMarketingPilotMetadata({ - locale, - pagePath: "/", - title: homeTitle, - description: DEFAULT_SITE_DESCRIPTION, - }); + return pilotPageMetadataFromTranslation(locale, "/", (translation) => ({ + title: translation.SITE_SEO_TITLE, + description: translation.SITE_SEO_DESCRIPTION, + })); } /** diff --git a/src/i18n/__tests__/getI18nProps.test.ts b/src/i18n/__tests__/getI18nProps.test.ts new file mode 100644 index 00000000..c26dc87a --- /dev/null +++ b/src/i18n/__tests__/getI18nProps.test.ts @@ -0,0 +1,81 @@ +import { describe, expect, it, vi } from "vitest"; + +import { + loadI18nServerProps, + localeFromContext, + withI18nServerSideProps, +} from "#/i18n/getI18nProps"; + +vi.mock("#/i18n/i18n-util.async", () => ({ + importLocaleAsync: vi.fn(async (locale: string) => ({ + SITE_SEO_TITLE: `title-${locale}`, + SITE_SEO_DESCRIPTION: `description-${locale}`, + })), +})); + +describe("localeFromContext", () => { + it("prefers locale over defaultLocale", () => { + expect(localeFromContext({ locale: "de", defaultLocale: "en" })).toBe("de"); + }); + + it("falls back to defaultLocale then en", () => { + expect(localeFromContext({ locale: undefined, defaultLocale: "fr" })).toBe( + "fr", + ); + expect(localeFromContext({ locale: undefined, defaultLocale: undefined })).toBe( + "en", + ); + }); +}); + +describe("loadI18nServerProps", () => { + it("loads translations for the active locale", async () => { + const { i18n } = await loadI18nServerProps({ + locale: "de", + defaultLocale: "en", + }); + + expect(i18n.translations.de).toEqual({ + SITE_SEO_TITLE: "title-de", + SITE_SEO_DESCRIPTION: "description-de", + }); + }); +}); + +describe("withI18nServerSideProps", () => { + it("merges i18n into successful props", async () => { + const wrapped = withI18nServerSideProps(async () => ({ + props: { value: 1 }, + })); + + const result = await wrapped({ + locale: "es", + defaultLocale: "en", + } as Parameters[0]); + + expect(result).toEqual({ + props: { + value: 1, + i18n: { + translations: { + es: { + SITE_SEO_TITLE: "title-es", + SITE_SEO_DESCRIPTION: "description-es", + }, + }, + }, + }, + }); + }); + + it("passes through notFound without loading i18n", async () => { + const wrapped = withI18nServerSideProps(async () => ({ notFound: true })); + + const result = await wrapped({ + locale: "en", + defaultLocale: "en", + } as Parameters[0]); + + expect(result).toEqual({ notFound: true }); + }); +}); diff --git a/src/i18n/createTranslationFunctions.ts b/src/i18n/createTranslationFunctions.ts new file mode 100644 index 00000000..ac474c9b --- /dev/null +++ b/src/i18n/createTranslationFunctions.ts @@ -0,0 +1,22 @@ +import { i18nObject as initI18nObject } from "typesafe-i18n"; + +import { initFormatters } from "#/i18n/formatters"; +import type { + Formatters, + Locales, + TranslationFunctions, + Translations, +} from "#/i18n/i18n-types"; + +/** Server-side `LL` helpers from a loaded locale dictionary (no React provider). */ +export function createTranslationFunctions( + locale: Locales, + translation: Translations, +): TranslationFunctions { + return initI18nObject< + Locales, + Translations, + TranslationFunctions, + Formatters + >(locale, translation, initFormatters(locale)); +} diff --git a/src/i18n/en/index.ts b/src/i18n/en/index.ts index 11089671..170f6002 100644 --- a/src/i18n/en/index.ts +++ b/src/i18n/en/index.ts @@ -75,6 +75,9 @@ const en: BaseTranslation = { PENDING_CHANGES: "Pending changes", PLAYBACK_INTERVAL: "Playback interval", PLAYGROUND: "Playground", + PLAYGROUND_PROJECT_SEO_DESCRIPTION: + "Practice {title:string} in dStruct — visualize solutions and run code in the browser.", + PLAYGROUND_SEO_TITLE: "Playground | dStruct", PLEASE_ENTER_YOUR_LEETCODE_ACCOUNT_NAME: "Please enter your LeetCode account name:", PRIVACY_CCPA_BODY: "If you are a California resident, you may have additional rights under the CCPA/CPRA, including the right to know what personal information is collected and to opt out of certain sharing. dStruct does not sell personal information. Analytics run only with your consent. Contact us using the email below to exercise your rights.", @@ -197,6 +200,9 @@ const en: BaseTranslation = { SORT_CATEGORY: "Category", SORT_CATEGORY_ASC: "Category (A-Z)", SORT_CATEGORY_DESC: "Category (Z-A)", + SITE_SEO_DESCRIPTION: + "dStruct is a web app that helps you understand LeetCode problems. It allows you to visualize your solutions that you write in a built-in code editor.", + SITE_SEO_TITLE: "dStruct — visualize LeetCode solutions", REPLAY: "Replay", REPLAY_PREVIOUS_CODE_RESULT_VISUALIZATION: "Replay previous code result visualization", RESET: "Reset", diff --git a/src/i18n/getI18nProps.ts b/src/i18n/getI18nProps.ts index 66d6b13e..1a54ceac 100644 --- a/src/i18n/getI18nProps.ts +++ b/src/i18n/getI18nProps.ts @@ -1,4 +1,9 @@ -import { type GetStaticProps, type GetStaticPropsContext } from "next"; +import type { + GetServerSideProps, + GetServerSidePropsContext, + GetStaticProps, + GetStaticPropsContext, +} from "next"; import type { Locales } from "#/i18n/i18n-types"; import { importLocaleAsync } from "#/i18n/i18n-util.async"; @@ -6,6 +11,11 @@ import { localePathForPage } from "#/i18n/localePathForPage"; import { SITE_ORIGIN } from "#/shared/lib/seo"; import { type TranslationDictionary } from "#/shared/ui/providers/I18nProvider"; +type LocaleContext = Pick< + GetStaticPropsContext | GetServerSidePropsContext, + "locale" | "defaultLocale" +>; + /** * Serializable i18n payload for static pages: locale dictionary loaded at build time * for the active locale. @@ -47,12 +57,19 @@ export function absoluteCanonicalFromStaticContext( } /** - * Loads translation bundle for the request locale (used by static page props). + * Active locale from Next.js static or server props context. + */ +export function localeFromContext(context: LocaleContext): Locales { + return (context.locale ?? context.defaultLocale ?? "en") as Locales; +} + +/** + * Loads translation bundle for the request locale (static and server pages). */ async function loadI18nPageProps( - context: Pick, + context: LocaleContext, ): Promise<{ i18n: I18nProps }> { - const locale = (context.locale as Locales) || "en"; + const locale = localeFromContext(context); const translations = { [locale]: await importLocaleAsync(locale) }; return { i18n: { @@ -61,6 +78,39 @@ async function loadI18nPageProps( }; } +/** + * `getServerSideProps` helper: provides `i18n.translations` for the active locale. + */ +export async function loadI18nServerProps( + context: LocaleContext, +): Promise<{ i18n: I18nProps }> { + return loadI18nPageProps(context); +} + +/** + * Wraps `getServerSideProps` to merge `i18n.translations` into successful page props. + */ +export function withI18nServerSideProps

>( + handler: GetServerSideProps

, +): GetServerSideProps

{ + return async (context) => { + const result = await handler(context); + if ("notFound" in result && result.notFound) { + return result; + } + if ("redirect" in result && result.redirect) { + return result; + } + const i18n = await loadI18nPageProps(context); + return { + props: { + ...(result as { props: P }).props, + ...i18n, + }, + }; + }; +} + /** * `getStaticProps` helper: provides `i18n.translations` for the active locale only. */ diff --git a/src/i18n/i18n-types.ts b/src/i18n/i18n-types.ts index 702d787b..13af00d0 100644 --- a/src/i18n/i18n-types.ts +++ b/src/i18n/i18n-types.ts @@ -1,5 +1,5 @@ // This file was auto-generated by 'typesafe-i18n'. Any manual changes will be overwritten. - +/* eslint-disable */ import type { BaseTranslation as BaseTranslationType, LocalizedString, RequiredParams } from 'typesafe-i18n' export type BaseTranslation = BaseTranslationType @@ -310,6 +310,15 @@ type RootTranslation = { * P​l​a​y​g​r​o​u​n​d */ PLAYGROUND: string + /** + * P​r​a​c​t​i​c​e​ ​{​t​i​t​l​e​}​ ​i​n​ ​d​S​t​r​u​c​t​ ​—​ ​v​i​s​u​a​l​i​z​e​ ​s​o​l​u​t​i​o​n​s​ ​a​n​d​ ​r​u​n​ ​c​o​d​e​ ​i​n​ ​t​h​e​ ​b​r​o​w​s​e​r​. + * @param {string} title + */ + PLAYGROUND_PROJECT_SEO_DESCRIPTION: RequiredParams<'title'> + /** + * P​l​a​y​g​r​o​u​n​d​ ​|​ ​d​S​t​r​u​c​t + */ + PLAYGROUND_SEO_TITLE: string /** * P​l​e​a​s​e​ ​e​n​t​e​r​ ​y​o​u​r​ ​L​e​e​t​C​o​d​e​ ​a​c​c​o​u​n​t​ ​n​a​m​e​: */ @@ -734,6 +743,14 @@ type RootTranslation = { * C​a​t​e​g​o​r​y​ ​(​Z​-​A​) */ SORT_CATEGORY_DESC: string + /** + * d​S​t​r​u​c​t​ ​i​s​ ​a​ ​w​e​b​ ​a​p​p​ ​t​h​a​t​ ​h​e​l​p​s​ ​y​o​u​ ​u​n​d​e​r​s​t​a​n​d​ ​L​e​e​t​C​o​d​e​ ​p​r​o​b​l​e​m​s​.​ ​I​t​ ​a​l​l​o​w​s​ ​y​o​u​ ​t​o​ ​v​i​s​u​a​l​i​z​e​ ​y​o​u​r​ ​s​o​l​u​t​i​o​n​s​ ​t​h​a​t​ ​y​o​u​ ​w​r​i​t​e​ ​i​n​ ​a​ ​b​u​i​l​t​-​i​n​ ​c​o​d​e​ ​e​d​i​t​o​r​. + */ + SITE_SEO_DESCRIPTION: string + /** + * d​S​t​r​u​c​t​ ​—​ ​v​i​s​u​a​l​i​z​e​ ​L​e​e​t​C​o​d​e​ ​s​o​l​u​t​i​o​n​s + */ + SITE_SEO_TITLE: string /** * R​e​p​l​a​y */ @@ -1530,6 +1547,14 @@ export type TranslationFunctions = { * Playground */ PLAYGROUND: () => LocalizedString + /** + * Practice {title} in dStruct — visualize solutions and run code in the browser. + */ + PLAYGROUND_PROJECT_SEO_DESCRIPTION: (arg: { title: string }) => LocalizedString + /** + * Playground | dStruct + */ + PLAYGROUND_SEO_TITLE: () => LocalizedString /** * Please enter your LeetCode account name: */ @@ -1954,6 +1979,14 @@ export type TranslationFunctions = { * Category (Z-A) */ SORT_CATEGORY_DESC: () => LocalizedString + /** + * dStruct is a web app that helps you understand LeetCode problems. It allows you to visualize your solutions that you write in a built-in code editor. + */ + SITE_SEO_DESCRIPTION: () => LocalizedString + /** + * dStruct — visualize LeetCode solutions + */ + SITE_SEO_TITLE: () => LocalizedString /** * Replay */ diff --git a/src/i18n/i18n-util.async.ts b/src/i18n/i18n-util.async.ts index aae1acd7..98c1859c 100644 --- a/src/i18n/i18n-util.async.ts +++ b/src/i18n/i18n-util.async.ts @@ -1,5 +1,5 @@ // This file was auto-generated by 'typesafe-i18n'. Any manual changes will be overwritten. - +/* eslint-disable */ import { initFormatters } from './formatters' import type { Locales, Translations } from './i18n-types' diff --git a/src/i18n/i18n-util.sync.ts b/src/i18n/i18n-util.sync.ts index 54c617df..628da8dd 100644 --- a/src/i18n/i18n-util.sync.ts +++ b/src/i18n/i18n-util.sync.ts @@ -1,5 +1,5 @@ // This file was auto-generated by 'typesafe-i18n'. Any manual changes will be overwritten. - +/* eslint-disable */ import { initFormatters } from './formatters' import type { Locales, Translations } from './i18n-types' diff --git a/src/i18n/i18n-util.ts b/src/i18n/i18n-util.ts index 38de22df..36ac028c 100644 --- a/src/i18n/i18n-util.ts +++ b/src/i18n/i18n-util.ts @@ -1,5 +1,5 @@ // This file was auto-generated by 'typesafe-i18n'. Any manual changes will be overwritten. - +/* eslint-disable */ import { i18n as initI18n, i18nObject as initI18nObject, i18nString as initI18nString } from 'typesafe-i18n' import type { LocaleDetector } from 'typesafe-i18n/detectors' diff --git a/src/pages/index.tsx b/src/pages/index.tsx index bdc14455..a2f274a2 100644 --- a/src/pages/index.tsx +++ b/src/pages/index.tsx @@ -2,6 +2,7 @@ import type { InferGetStaticPropsType, NextPage } from "next"; import { MarketingHomeView } from "#/features/homePage/ui/MarketingHomeView"; import { getI18nPropsWithCanonical } from "#/i18n/getI18nProps"; +import { useI18nContext } from "#/shared/hooks"; import { SiteSeoHead } from "#/shared/ui/seo/SiteSeoHead"; export const getStaticProps = getI18nPropsWithCanonical("/"); @@ -16,14 +17,19 @@ type DashboardProps = InferGetStaticPropsType; * becomes `/{locale}/internal-marketing/...` → 404). Public cutover waits on * migrating locale routing off `next.config` `i18n`. */ -const DashboardPage: NextPage = ({ canonicalUrl }) => ( - <> - - - -); +const DashboardPage: NextPage = ({ canonicalUrl }) => { + const { LL } = useI18nContext(); + + return ( + <> + + + + ); +}; export default DashboardPage; diff --git a/src/pages/playground/[[...slug]].tsx b/src/pages/playground/[[...slug]].tsx index 9efe9f8e..85475ed2 100644 --- a/src/pages/playground/[[...slug]].tsx +++ b/src/pages/playground/[[...slug]].tsx @@ -10,12 +10,13 @@ import { PlaygroundViewProvider } from "#/features/playground/context/Playground import { MobilePlayground } from "#/features/playground/ui/MobilePlayground"; import { ProjectPanel } from "#/features/project/ui/ProjectPanel"; import { TreeViewPanel } from "#/features/treeViewer/ui/TreeViewPanel"; +import { createTranslationFunctions } from "#/i18n/createTranslationFunctions"; +import { loadI18nServerProps, localeFromContext } from "#/i18n/getI18nProps"; import { db } from "#/server/db/client"; import { useAppConfig, useHasMounted } from "#/shared/hooks"; import { useMobileLayout } from "#/shared/hooks/useMobileLayout"; import { absoluteUrlFromPathname, - DEFAULT_SITE_DESCRIPTION, pathnameFromResolvedUrl, } from "#/shared/lib/seo"; import { @@ -113,7 +114,8 @@ const PlaygroundPage: NextPage = ({ export const getServerSideProps: GetServerSideProps< PlaygroundPageProps -> = async ({ req, res, params, resolvedUrl }) => { +> = async (context) => { + const { req, res, params, resolvedUrl } = context; const ssrDeviceType = resolveSsrDeviceType(req.headers); setDeviceHintResponseHeaders(res); @@ -122,9 +124,17 @@ export const getServerSideProps: GetServerSideProps< const pathOnly = pathnameFromResolvedUrl(resolvedUrl) || "/playground"; const canonicalUrl = absoluteUrlFromPathname(pathOnly); - let pageTitle = "Playground | dStruct"; + const { i18n } = await loadI18nServerProps(context); + const locale = localeFromContext(context); + const translation = i18n.translations[locale]; + const LL = translation + ? createTranslationFunctions(locale, translation) + : undefined; + + let pageTitle = LL?.PLAYGROUND_SEO_TITLE() ?? "Playground | dStruct"; let pageDescription = - "Write code in the dStruct playground and visualize data structures for LeetCode-style problems."; + LL?.SITE_SEO_DESCRIPTION() ?? + "dStruct is a web app that helps you understand LeetCode problems. It allows you to visualize your solutions that you write in a built-in code editor."; if (slugStr) { const project = await db.playgroundProject.findUnique({ @@ -135,10 +145,11 @@ export const getServerSideProps: GetServerSideProps< pageTitle = `${project.title} | dStruct`; pageDescription = project.description?.trim() ? `${project.title}: ${project.description.trim()}` - : `Practice ${project.title} in dStruct — visualize solutions and run code in the browser.`; + : (LL?.PLAYGROUND_PROJECT_SEO_DESCRIPTION({ + title: project.title, + }) ?? + `Practice ${project.title} in dStruct — visualize solutions and run code in the browser.`); } - } else { - pageDescription = DEFAULT_SITE_DESCRIPTION; } return { @@ -147,6 +158,7 @@ export const getServerSideProps: GetServerSideProps< canonicalUrl, pageTitle, pageDescription, + i18n, }, }; }; diff --git a/src/pages/profile/[userId].tsx b/src/pages/profile/[userId].tsx index e022ab4b..0ac7658e 100644 --- a/src/pages/profile/[userId].tsx +++ b/src/pages/profile/[userId].tsx @@ -7,7 +7,7 @@ import { Grid, Typography, } from "@mui/material"; -import type { GetServerSideProps, NextPage } from "next"; +import type { NextPage } from "next"; import { useSession } from "next-auth/react"; import Link from "next/link"; import { useRouter } from "next/router"; @@ -15,10 +15,10 @@ import { useRouter } from "next/router"; import { LeetCodeStats } from "#/features/profile/ui/LeetCodeStats"; import { UserSettings } from "#/features/profile/ui/UserSettings"; import { useGetUserProfileQuery } from "#/graphql/generated"; +import { withI18nServerSideProps } from "#/i18n/getI18nProps"; import { useI18nContext } from "#/shared/hooks"; import { absoluteUrlFromPathname, - DEFAULT_SITE_DESCRIPTION, pathnameFromResolvedUrl, } from "#/shared/lib/seo"; import { SiteSeoHead } from "#/shared/ui/seo/SiteSeoHead"; @@ -133,8 +133,8 @@ const ProfilePage: NextPage = ({ canonicalUrl }) => { return ( @@ -167,18 +167,18 @@ const ProfilePage: NextPage = ({ canonicalUrl }) => { ); }; -export const getServerSideProps: GetServerSideProps = async ( - ctx, -) => { - const raw = ctx.params?.userId; - const profileUserId = typeof raw === "string" ? raw : ""; - if (!profileUserId) { - return { notFound: true }; - } - const pathOnly = - pathnameFromResolvedUrl(ctx.resolvedUrl) || `/profile/${profileUserId}`; - const canonicalUrl = absoluteUrlFromPathname(pathOnly); - return { props: { canonicalUrl } }; -}; +export const getServerSideProps = withI18nServerSideProps( + async (ctx) => { + const raw = ctx.params?.userId; + const profileUserId = typeof raw === "string" ? raw : ""; + if (!profileUserId) { + return { notFound: true }; + } + const pathOnly = + pathnameFromResolvedUrl(ctx.resolvedUrl) || `/profile/${profileUserId}`; + const canonicalUrl = absoluteUrlFromPathname(pathOnly); + return { props: { canonicalUrl } }; + }, +); export default ProfilePage; diff --git a/vibe-docs/Instant-Navigations-TODO.md b/vibe-docs/Instant-Navigations-TODO.md index fd33ea1b..b4a35cdf 100644 --- a/vibe-docs/Instant-Navigations-TODO.md +++ b/vibe-docs/Instant-Navigations-TODO.md @@ -31,6 +31,8 @@ - [x] Remove unused `@trpc/next` dependency - [x] Extract `authOptions` to `src/server/auth/authOptions.ts` - [x] Extract `AppShellProviders` shared by `_app` and `AppRootLayoutClient` +- [x] SSR i18n preload for playground + profile (`loadI18nServerProps` / `withI18nServerSideProps`) +- [x] Localized SEO titles/descriptions for home, playground landing, profile ## Phase 3+ — Playground / full migration