From 4e22e790c96be2587eb50e513e90ecc2e5d65fa8 Mon Sep 17 00:00:00 2001 From: Ryan Miller Date: Thu, 24 Jul 2025 11:42:37 +1000 Subject: [PATCH 01/13] feat: add next-intl and localization support for core pages --- components/CustomHead.tsx | 104 +++++++++++++---- components/RedirectPage.tsx | 81 +++++++++----- components/cards/BenefitsCard.tsx | 37 +++++-- components/cards/PostCard.tsx | 4 +- components/navigation/Footer.tsx | 107 +++++++++--------- components/navigation/Nav.tsx | 23 +++- components/navigation/NavItem.tsx | 17 ++- components/posts/PostList.tsx | 4 +- components/sections/About.tsx | 14 ++- components/sections/Benefits.tsx | 51 +++------ components/sections/EmailSignup.tsx | 16 +-- components/sections/Features.tsx | 60 ++++++---- components/sections/GroupNotice.tsx | 32 ++++-- components/sections/Hero.tsx | 31 ++++-- components/ui/Accordion.tsx | 11 +- components/ui/Button.tsx | 18 ++- components/ui/Layout.tsx | 12 +- constants/localization.ts | 6 + constants/metadata.ts | 32 +++++- constants/navigation.ts | 43 +++---- global.d.ts | 10 ++ locales/en.json | 166 ++++++++++++++++++++++++++++ locales/fr.json | 166 ++++++++++++++++++++++++++++ locales/zh-TW.json | 166 ++++++++++++++++++++++++++++ next.config.js | 12 +- package.json | 3 + pages/404.tsx | 13 ++- pages/[slug].tsx | 5 +- pages/_app.tsx | 12 +- pages/_document.tsx | 6 +- pages/api/sitemap.ts | 137 ++++++++++++++++------- pages/blog/index.tsx | 4 +- pages/community.tsx | 9 +- pages/download.tsx | 35 ++++-- pages/faq.tsx | 3 +- pages/how-to-help.tsx | 7 ++ pages/index.tsx | 4 +- pages/litepaper.tsx | 25 +++-- pages/tag/[tag].tsx | 6 +- pages/whitepaper.tsx | 20 ++++ pnpm-lock.yaml | 125 +++++++++++++++++++++ tsconfig.json | 4 +- 42 files changed, 1292 insertions(+), 349 deletions(-) create mode 100644 constants/localization.ts create mode 100644 global.d.ts create mode 100644 locales/en.json create mode 100644 locales/fr.json create mode 100644 locales/zh-TW.json create mode 100644 pages/whitepaper.tsx diff --git a/components/CustomHead.tsx b/components/CustomHead.tsx index 31b9ba1..e80838f 100644 --- a/components/CustomHead.tsx +++ b/components/CustomHead.tsx @@ -1,21 +1,54 @@ /** biome-ignore-all lint/security/noDangerouslySetInnerHtml: Used as expected */ import Head from 'next/head'; import { useRouter } from 'next/router'; +import { type Messages, useTranslations } from 'next-intl'; import type { ReactElement } from 'react'; +import { NON_LOCALIZED_STRING } from '@/constants/localization'; import METADATA, { type IMetadata } from '@/constants/metadata'; import { isLocal } from '@/utils/links'; +export type MetadataLocaleKey = keyof Messages['metadata']; interface Props { title?: string; + localeKey?: MetadataLocaleKey; metadata?: IMetadata; structuredData?: Array; } export default function CustomHead(props: Props): ReactElement { const router = useRouter(); - const { title, metadata, structuredData } = props; - const pageTitle = - title && title.length > 0 ? `${title} - Session Private Messenger` : METADATA.TITLE; + const t = useTranslations('metadata'); + const tFeature = useTranslations('feature'); + + const { localeKey, metadata, structuredData } = props; + + // TODO: we can probably use the locale defaults as the initialized vars + let title = ''; + let description = ''; + + const localeArgs = { + appName: NON_LOCALIZED_STRING.appName, + appNamePossessive: NON_LOCALIZED_STRING.appNamePossessive, + featureCommunity: tFeature('community'), + }; + + if (localeKey) { + const titleKey = `${localeKey}.title` as const; + // @ts-expect-error -- we use .has to check if the key exists, this should not cause an issue + const pageTitle = t.has(titleKey) ? t(titleKey, localeArgs) : undefined; + + title = + pageTitle && localeKey !== 'default' + ? t('default.titleLayout', { ...localeArgs, title: pageTitle }) + : t('default.title', localeArgs); + + const descriptionKey = `${localeKey}.description` as const; + description = t(t.has(descriptionKey) ? descriptionKey : 'default.description', localeArgs); + } else { + title = props.title || t('default.title', localeArgs); + description = metadata?.DESCRIPTION || t('default.description', localeArgs); + } + const pageUrl = `${METADATA.HOST_URL}${router.asPath}`; const imageALT = metadata?.OG_IMAGE?.ALT ?? METADATA.OG_IMAGE.ALT; let imageWidth = metadata?.OG_IMAGE?.WIDTH ?? METADATA.OG_IMAGE.WIDTH; @@ -37,6 +70,7 @@ export default function CustomHead(props: Props): ReactElement { } const tags = metadata?.TAGS ? metadata?.TAGS : METADATA.TAGS; + const renderTags = (() => { const keywords = ; if (metadata?.TYPE !== 'article') return keywords; @@ -63,6 +97,7 @@ export default function CustomHead(props: Props): ReactElement { ); })(); + const renderLdJSON = (() => { const ldjson = `{ "@context": "https://schema.org", @@ -72,7 +107,7 @@ export default function CustomHead(props: Props): ReactElement { "@id": "${METADATA.HOST_URL}/#website", "url": "${pageUrl}", "name": "${METADATA.SITE_NAME}", - "description": "${METADATA.DESCRIPTION}" + "description": "${t('default.description', localeArgs)}" }, { "@type": "ImageObject", @@ -86,13 +121,13 @@ export default function CustomHead(props: Props): ReactElement { "@id": "${pageUrl}#webpage", "url": "${pageUrl}", "inLanguage": "${METADATA.LOCALE}", - "name": "${pageTitle}", + "name": "${title}", "isPartOf": { "@id": "${METADATA.HOST_URL}/#website" }, "primaryImageOfPage": { "@id": "${pageUrl}#primaryimage" }, "datePublished": "${metadata?.PUBLISHED_TIME ?? ''}", - "description": "${METADATA.DESCRIPTION}" + "description": "${description}" } ] }`; @@ -104,20 +139,47 @@ export default function CustomHead(props: Props): ReactElement { /> ); })(); + + // Generate localized page variants if localeKey is defined + const renderLocalizedVariants = (() => { + if (!localeKey || !router.locales) return null; + + const currentPathWithoutLocale = router.asPath.replace(`/${router.locale}`, '') || '/'; + + return router.locales.map((locale) => { + const localizedPath = + locale === router.defaultLocale + ? currentPathWithoutLocale + : `/${locale}${currentPathWithoutLocale}`; + + const href = `${METADATA.HOST_URL}${localizedPath}`; + + return ; + }); + })(); + + // Add x-default hreflang for the default locale + const renderDefaultHreflang = (() => { + if (!localeKey || !router.defaultLocale) return null; + + const currentPathWithoutLocale = router.asPath.replace(`/${router.locale}`, '') || '/'; + const defaultHref = `${METADATA.HOST_URL}${currentPathWithoutLocale}`; + + return ( + + ); + })(); + return ( - {pageTitle} + {title} - + - + - + @@ -144,12 +202,8 @@ export default function CustomHead(props: Props): ReactElement { - - + + @@ -159,6 +213,8 @@ export default function CustomHead(props: Props): ReactElement { name="msapplication-TileColor" content={METADATA.MSAPPLICATION_TILECOLOR} /> + {renderLocalizedVariants} + {renderDefaultHreflang} {renderTags} diff --git a/components/RedirectPage.tsx b/components/RedirectPage.tsx index 4b1bb41..aa4f88c 100644 --- a/components/RedirectPage.tsx +++ b/components/RedirectPage.tsx @@ -1,37 +1,60 @@ import classNames from 'classnames'; import { useRouter } from 'next/router'; +import { useTranslations } from 'next-intl'; +import { useEffect } from 'react'; import Container from '@/components/Container'; +import type { MetadataLocaleKey } from '@/components/CustomHead'; +import Layout from '@/components/ui/Layout'; +import type { IMetadata } from '@/constants/metadata'; -export default function RedirectPage() { +type RedirectPageProps = { + redirectUrl: string; + localeKey?: MetadataLocaleKey; + metadata?: IMetadata; +}; + +export default function RedirectPage({ redirectUrl, localeKey, metadata }: RedirectPageProps) { const router = useRouter(); + const t = useTranslations('redirect'); + + useEffect(() => { + router.push(redirectUrl); + }, [router, redirectUrl]); + return ( -
- -

Redirecting...

-

- Click{' '} - {' '} - to return to the previous page. -

-
-
+ +
+ +

+ {t('heading')} +

+

+ {t.rich('content', { + button: (chunk) => ( + + ), + })} +

+
+
+
); } diff --git a/components/cards/BenefitsCard.tsx b/components/cards/BenefitsCard.tsx index 39b97eb..b60be32 100644 --- a/components/cards/BenefitsCard.tsx +++ b/components/cards/BenefitsCard.tsx @@ -1,12 +1,13 @@ import classNames from 'classnames'; import Image from 'next/legacy/image'; -import type { ReactElement } from 'react'; +import { useTranslations } from 'next-intl'; +import type { ReactNode } from 'react'; +import { NON_LOCALIZED_STRING } from '@/constants/localization'; import { useScreen } from '@/contexts/screen'; import redact from '@/utils/redact'; interface Props { - title: string; - description?: string[]; + itemNumber: '1' | '2' | '3' | '4' | '5' | '6'; images: string[]; // toggle images on hover [original, redacted] imageAlt: string; imageWidth: number; @@ -14,9 +15,26 @@ interface Props { classes?: string; } -export default function BenefitsCard(props: Props): ReactElement { +export default function BenefitsCard(props: Props): ReactNode { + const t = useTranslations('landing.benefits'); + const tFeature = useTranslations('feature'); const { isSmall } = useScreen(); - const { title, description, images, imageAlt, imageWidth, imageHeight, classes } = props; + + const { images, itemNumber, imageAlt, imageWidth, imageHeight, classes } = props; + + if (!t.has(`${itemNumber}.heading` as const) || !t.has(`${itemNumber}.content` as const)) { + return null; + } + + const title = t(`${itemNumber}.heading` as const); + const rawDescription = t.rich(`${itemNumber}.content` as const, { + br: () => null, + appName: NON_LOCALIZED_STRING.appName, + appNamePossessive: NON_LOCALIZED_STRING.appNamePossessive, + featureAccountIds: tFeature('accountIds'), + featureNodes: tFeature('nodes'), + }); + const redactedClasses = redact({ redactColor: 'gray-dark', textColor: 'gray-dark', @@ -55,11 +73,12 @@ export default function BenefitsCard(props: Props): ReactElement { })(); const renderDescription = (() => { - return description?.map((line) => { + const nodes = Array.isArray(rawDescription) ? rawDescription : [rawDescription]; + return nodes.map((line) => { return (

{line}

@@ -71,7 +90,7 @@ export default function BenefitsCard(props: Props): ReactElement { return (
{renderImages}
-

{title}

+

{title}

{renderDescription}
); diff --git a/components/cards/PostCard.tsx b/components/cards/PostCard.tsx index 3502595..2afed5d 100644 --- a/components/cards/PostCard.tsx +++ b/components/cards/PostCard.tsx @@ -1,6 +1,7 @@ import classNames from 'classnames'; import Image from 'next/legacy/image'; import Link from 'next/link'; +import { useTranslations } from 'next-intl'; import type { ReactElement } from 'react'; import type { IPost } from '@/types/cms'; @@ -13,6 +14,7 @@ interface Props extends IPost { } export default function PostCard(props: Props): ReactElement { + const t = useTranslations('blog'); const { title, description, @@ -84,7 +86,7 @@ export default function PostCard(props: Props): ReactElement { )} {featured && ( - Read More » + {t('readMore')} » )} diff --git a/components/navigation/Footer.tsx b/components/navigation/Footer.tsx index 520246c..67f5292 100644 --- a/components/navigation/Footer.tsx +++ b/components/navigation/Footer.tsx @@ -1,6 +1,7 @@ import classNames from 'classnames'; import Image from 'next/legacy/image'; import Link from 'next/link'; +import { useTranslations } from 'next-intl'; import type { FunctionComponent, ReactElement, SVGProps } from 'react'; import { ReactComponent as GithubSVG } from '@/assets/svgs/github.svg'; import { ReactComponent as InstagramSVG } from '@/assets/svgs/instagram.svg'; @@ -8,12 +9,12 @@ import { ReactComponent as MastodonSVG } from '@/assets/svgs/mastodon.svg'; import { ReactComponent as RssSVG } from '@/assets/svgs/rss.svg'; import { ReactComponent as TwitterSVG } from '@/assets/svgs/twitter.svg'; import { ReactComponent as YouTubeSVG } from '@/assets/svgs/youtube.svg'; +import { NON_LOCALIZED_STRING } from '@/constants/localization'; import redact from '@/utils/redact'; // Type definitions interface SocialLink { href: string; - label: string; icon: FunctionComponent>; external: boolean; platform: string; @@ -34,14 +35,12 @@ const svgClasses = classNames('fill-current w-7 h-7 m-1', 'lg:my-0 lg:ml-0', 'ho const socialLinks: SocialLink[] = [ { href: 'https://twitter.com/session_app', - label: 'Follow Session on Twitter', icon: TwitterSVG, external: true, platform: 'Twitter', }, { href: 'https://mastodon.social/@session', - label: 'Follow Session on Mastodon', icon: MastodonSVG, external: true, platform: 'Mastodon', @@ -50,28 +49,24 @@ const socialLinks: SocialLink[] = [ }, { href: 'https://www.instagram.com/getsession', - label: 'Follow Session on Instagram', icon: InstagramSVG, external: true, platform: 'Instagram', }, { href: 'https://www.youtube.com/@SessionTV', - label: 'Subscribe to Session on YouTube', icon: YouTubeSVG, external: true, platform: 'YouTube', }, { href: 'https://github.com/session-foundation', - label: 'View Session on GitHub', icon: GithubSVG, external: true, platform: 'GitHub', }, { href: '/feed', - label: 'Subscribe to Session RSS feed', icon: RssSVG, external: false, platform: 'RSS', @@ -79,38 +74,45 @@ const socialLinks: SocialLink[] = [ ]; function SocialLinks() { + const t = useTranslations('footer.aria'); return ( -