diff --git a/__tests__/localeFromBreadcrumbs.test.ts b/__tests__/localeFromBreadcrumbs.test.ts new file mode 100644 index 000000000..0f7c6b528 --- /dev/null +++ b/__tests__/localeFromBreadcrumbs.test.ts @@ -0,0 +1,17 @@ +import { localeFromBreadcrumbs } from "@utils/localeFromBreadcrumbs"; + +describe("localeFromBreadcrumbs", () => { + it("returns 'en' for a top-level file", () => { + expect(localeFromBreadcrumbs(["YakShaver", "home"])).toBe("en"); + }); + it("returns 'zh' when second segment is zh", () => { + expect(localeFromBreadcrumbs(["YakShaver", "zh", "home"])).toBe("zh"); + }); + it("returns 'en' for other products", () => { + expect(localeFromBreadcrumbs(["SSW", "about"])).toBe("en"); + }); + it("handles empty/short arrays", () => { + expect(localeFromBreadcrumbs([])).toBe("en"); + expect(localeFromBreadcrumbs(["YakShaver"])).toBe("en"); + }); +}); diff --git a/__tests__/resolveRequestRoute.test.ts b/__tests__/resolveRequestRoute.test.ts new file mode 100644 index 000000000..1b684e9a5 --- /dev/null +++ b/__tests__/resolveRequestRoute.test.ts @@ -0,0 +1,55 @@ +import { resolveRequestRoute } from "@utils/resolveRequestRoute"; + +const productList = [ + { product: "YakShaver", domain: "yakshaver.ai" }, + { product: "SSW", domain: "ssw.com.au" }, +]; +const env = { defaultProduct: "YakShaver" }; + +describe("resolveRequestRoute", () => { + it("production en domain → /en//path", () => { + expect( + resolveRequestRoute({ hostname: "ssw.com.au", pathname: "/about", productList, env }) + ).toEqual({ locale: "en", product: "SSW", internalPath: "/en/SSW/about" }); + }); + it("production Chinese domain → /zh/YakShaver/path", () => { + expect( + resolveRequestRoute({ hostname: "yakshaver.cn", pathname: "/features", productList, env }) + ).toEqual({ locale: "zh", product: "YakShaver", internalPath: "/zh/YakShaver/features" }); + }); + it("production www Chinese domain → zh", () => { + const zhResult = resolveRequestRoute({ hostname: "www.yakshaver.com.cn", pathname: "/", productList, env }); + expect(zhResult).not.toBeNull(); + expect(zhResult?.locale).toBe("zh"); + }); + it("root path on en domain → /en/", () => { + expect( + resolveRequestRoute({ hostname: "yakshaver.ai", pathname: "/", productList, env }) + ).toEqual({ locale: "en", product: "YakShaver", internalPath: "/en/YakShaver" }); + }); + it("local default product, en", () => { + expect( + resolveRequestRoute({ hostname: "localhost:3000", pathname: "/blog", productList, env }) + ).toEqual({ locale: "en", product: "YakShaver", internalPath: "/en/YakShaver/blog" }); + }); + it("local /zh prefix → zh, prefix stripped", () => { + expect( + resolveRequestRoute({ hostname: "localhost:3000", pathname: "/zh/blog", productList, env }) + ).toEqual({ locale: "zh", product: "YakShaver", internalPath: "/zh/YakShaver/blog" }); + }); + it("local path starting with a product is not double-prefixed", () => { + expect( + resolveRequestRoute({ hostname: "localhost:3000", pathname: "/SSW/about", productList, env }) + ).toEqual({ locale: "en", product: "SSW", internalPath: "/en/SSW/about" }); + }); + it("staging (vercel.app) behaves like local", () => { + const stagingResult = resolveRequestRoute({ hostname: "ssw-products.vercel.app", pathname: "/", productList, env }); + expect(stagingResult).not.toBeNull(); + expect(stagingResult?.product).toBe("YakShaver"); + }); + it("unknown production host → null", () => { + expect( + resolveRequestRoute({ hostname: "unknown.example.com", pathname: "/", productList, env }) + ).toBeNull(); + }); +}); diff --git a/app/[product]/.well-known/microsoft-identity-association.tsx b/app/[locale]/[product]/.well-known/microsoft-identity-association.tsx similarity index 100% rename from app/[product]/.well-known/microsoft-identity-association.tsx rename to app/[locale]/[product]/.well-known/microsoft-identity-association.tsx diff --git a/app/[locale]/[product]/[filename]/page.tsx b/app/[locale]/[product]/[filename]/page.tsx new file mode 100644 index 000000000..49682bca5 --- /dev/null +++ b/app/[locale]/[product]/[filename]/page.tsx @@ -0,0 +1,62 @@ +import HomePageClient from "@comps/shared/HomePageClient"; +import client from "@tina/__generated__/client"; +import { setPageMetadata } from "@utils/setPageMetaData"; +import { getPageWithFallback } from "@utils/i18n"; +import getPageData from "@utils/pages/getPageData"; +import NotFoundError from "@/errors/not-found"; +import ClientFallbackPage from "@app/client-fallback-page"; +import { notFound } from "next/navigation"; +import { localeFromBreadcrumbs } from "@utils/localeFromBreadcrumbs"; + +export const dynamic = "force-static"; + +interface FilePageProps { + params: Promise<{ locale: string; product: string; filename: string }>; +} + +export async function generateMetadata({ params }: FilePageProps) { + const { locale, product, filename } = await params; + try { + const fileData = await getPageWithFallback({ product, filename, locale, revalidate: 3600, branch: "main" }); + return setPageMetadata(fileData.data?.pages?.seo, product); + } catch (error) { + if (error instanceof NotFoundError) return {}; + throw error; + } +} + +export async function generateStaticParams() { + const sitePosts = await client.queries.pagesConnection({}); + return ( + sitePosts.data.pagesConnection?.edges?.map((post) => { + const breadcrumbs = post?.node?._sys.breadcrumbs ?? []; + return { + locale: localeFromBreadcrumbs(breadcrumbs), + product: breadcrumbs[0], + filename: post?.node?._sys.filename, + }; + }) || [] + ); +} + +export default async function FilePage({ params }: FilePageProps) { + const { locale, product, filename } = await params; + try { + const { data, query, relativePath } = await getPageData(product, filename, locale); + return ; + } catch (error) { + if (error instanceof NotFoundError) { + return ( + + ); + } + notFound(); + } +} + +export const revalidate = 3600; diff --git a/app/[product]/blog/[slug]/blog-shared.tsx b/app/[locale]/[product]/blog/[slug]/blog-shared.tsx similarity index 100% rename from app/[product]/blog/[slug]/blog-shared.tsx rename to app/[locale]/[product]/blog/[slug]/blog-shared.tsx diff --git a/app/[locale]/[product]/blog/[slug]/page.tsx b/app/[locale]/[product]/blog/[slug]/page.tsx new file mode 100644 index 000000000..f553acd9f --- /dev/null +++ b/app/[locale]/[product]/blog/[slug]/page.tsx @@ -0,0 +1,58 @@ +import client from "@tina/__generated__/client"; +import { setPageMetadata } from "@utils/setPageMetaData"; +import { getBlogWithFallback } from "@utils/i18n"; +import getBlogPageData from "@utils/pages/getBlogPageData"; +import ClientFallbackPage from "@app/client-fallback-page"; +import NotFoundError from "@/errors/not-found"; +import { BlogPageShared, BlogPageSharedProps } from "./blog-shared"; +import { localeFromBreadcrumbs } from "@utils/localeFromBreadcrumbs"; + +interface BlogPostProps { + params: Promise<{ locale: string; slug: string; product: string }>; +} + +export async function generateMetadata({ params }: BlogPostProps) { + const { locale, slug, product } = await params; + try { + const res = await getBlogWithFallback({ product, slug, locale }); + if (!res?.data?.blogs) return null; + return setPageMetadata(res?.data?.blogs?.seo, product, "Blog"); + } catch (error) { + if (error instanceof NotFoundError) return {}; + throw error; + } +} + +export async function generateStaticParams() { + const sitePosts = await client.queries.blogsConnection({}); + return ( + sitePosts.data.blogsConnection?.edges?.map((post) => { + const breadcrumbs = post?.node?._sys.breadcrumbs ?? []; + return { + locale: localeFromBreadcrumbs(breadcrumbs), + product: breadcrumbs[0], + slug: post?.node?._sys.filename, + }; + }) || [] + ); +} + +export default async function BlogPost({ params }: BlogPostProps) { + const { locale, slug, product } = await params; + try { + const data = await getBlogPageData(product, slug, locale); + return ; + } catch (error) { + if (error instanceof NotFoundError) { + return ( + + product={product} + relativePath={slug} + query={"getBlogPageData"} + Component={BlogPageShared} + /> + ); + } + throw error; + } +} diff --git a/app/[product]/blog/page.tsx b/app/[locale]/[product]/blog/page.tsx similarity index 54% rename from app/[product]/blog/page.tsx rename to app/[locale]/[product]/blog/page.tsx index d312f0101..21eaf2f63 100644 --- a/app/[product]/blog/page.tsx +++ b/app/[locale]/[product]/blog/page.tsx @@ -1,15 +1,13 @@ -import { - dehydrate, - HydrationBoundary, - QueryClient, -} from "@tanstack/react-query"; -import { BlogSearchProvider } from "../../../components/providers/BlogSearchProvider"; -import BlogIndexClient from "../../../components/shared/BlogIndexClient"; -import client from "../../../tina/__generated__/client"; -import { getBlogsForProduct } from "../../../utils/fetchBlogs"; -import { getLocale, getBlogIndexWithFallback } from "../../../utils/i18n"; +import { dehydrate, HydrationBoundary, QueryClient } from "@tanstack/react-query"; +import { BlogSearchProvider } from "@comps/providers/BlogSearchProvider"; +import BlogIndexClient from "@comps/shared/BlogIndexClient"; +import client from "@tina/__generated__/client"; +import { getBlogsForProduct } from "@utils/fetchBlogs"; +import { getBlogIndexWithFallback } from "@utils/i18n"; +import { localeFromBreadcrumbs } from "@utils/localeFromBreadcrumbs"; + interface BlogIndex { - params: Promise<{ product: string }>; + params: Promise<{ locale: string; product: string }>; } export async function generateMetadata({ params }: BlogIndex) { @@ -27,18 +25,26 @@ export async function generateMetadata({ params }: BlogIndex) { export async function generateStaticParams() { const sitePosts = await client.queries.blogsConnection({}); - return ( - sitePosts.data.blogsConnection?.edges?.map((post) => ({ - product: post?.node?._sys.breadcrumbs[0], - })) || [] - ); + const seen = new Set(); + const params: { locale: string; product: string }[] = []; + for (const edge of sitePosts.data.blogsConnection?.edges ?? []) { + const breadcrumbs = edge?.node?._sys.breadcrumbs ?? []; + const product = breadcrumbs[0]; + if (!product) continue; + const locale = localeFromBreadcrumbs(breadcrumbs); + const key = `${locale}/${product}`; + if (seen.has(key)) continue; + seen.add(key); + params.push({ locale, product }); + } + return params; } const getCategories = async (product: string) => { const posts = await client.queries.blogsConnection(); - const filteredPosts = posts.data.blogsConnection.edges?.filter((blog) => { - return blog?.node?._sys?.path.includes(product); - }); + const filteredPosts = posts.data.blogsConnection.edges?.filter((blog) => + blog?.node?._sys?.path.includes(product), + ); let categories: string[] = []; if (filteredPosts) { categories = filteredPosts.reduce((acc, curr) => { @@ -51,28 +57,25 @@ const getCategories = async (product: string) => { }; export default async function BlogIndex({ params }: BlogIndex) { - const {product} = await params; - const locale = await getLocale(); - + const { locale, product } = await params; const categories = await getCategories(product); const tinaData = await getBlogIndexWithFallback(product, locale); const queryClient = new QueryClient(); await queryClient.prefetchInfiniteQuery({ - queryKey: [`blogs${locale || 'en'}`], + queryKey: [`blogs${locale || "en"}`], queryFn: () => getBlogsForProduct({ product, locale }), initialPageParam: undefined, }); - const dehydratedState = dehydrate(queryClient, {}); return (
-
- - - - - -
+
+ + + + + +
); } diff --git a/app/[product]/docs/[slug]/DocPostClient.tsx b/app/[locale]/[product]/docs/[slug]/DocPostClient.tsx similarity index 100% rename from app/[product]/docs/[slug]/DocPostClient.tsx rename to app/[locale]/[product]/docs/[slug]/DocPostClient.tsx diff --git a/app/[product]/docs/[slug]/PaginationLinksClient.tsx b/app/[locale]/[product]/docs/[slug]/PaginationLinksClient.tsx similarity index 100% rename from app/[product]/docs/[slug]/PaginationLinksClient.tsx rename to app/[locale]/[product]/docs/[slug]/PaginationLinksClient.tsx diff --git a/app/[product]/docs/[slug]/TableOfContentsClient.tsx b/app/[locale]/[product]/docs/[slug]/TableOfContentsClient.tsx similarity index 100% rename from app/[product]/docs/[slug]/TableOfContentsClient.tsx rename to app/[locale]/[product]/docs/[slug]/TableOfContentsClient.tsx diff --git a/app/[locale]/[product]/docs/[slug]/page.tsx b/app/[locale]/[product]/docs/[slug]/page.tsx new file mode 100644 index 000000000..0e09ca902 --- /dev/null +++ b/app/[locale]/[product]/docs/[slug]/page.tsx @@ -0,0 +1,59 @@ +import { getDocPost } from "@utils/fetchDocs"; +import client from "@tina/__generated__/client"; +import { setPageMetadata } from "@utils/setPageMetaData"; +import DocPostClient from "./DocPostClient"; +import getDocPageData from "@utils/pages/getDocPageData"; +import ClientFallbackPage from "@app/client-fallback-page"; +import NotFoundError from "@/errors/not-found"; +import { localeFromBreadcrumbs } from "@utils/localeFromBreadcrumbs"; + +interface DocPostProps { + params: Promise<{ locale: string; slug: string; product: string }>; +} + +export async function generateMetadata({ params }: DocPostProps) { + const { locale, product, slug } = await params; + try { + const docs = await getDocPost({ product, slug, locale }); + return setPageMetadata(docs?.docs?.seo, product, "Docs"); + } catch (error) { + if (error instanceof NotFoundError) return {}; + throw error; + } +} + +export async function generateStaticParams() { + const sitePosts = await client.queries.docsConnection({}); + return ( + sitePosts.data.docsConnection?.edges?.map((post) => { + const breadcrumbs = post?.node?._sys.breadcrumbs ?? []; + return { + locale: localeFromBreadcrumbs(breadcrumbs), + product: breadcrumbs[0], + slug: post?.node?._sys.filename, + }; + }) || [] + ); +} + +export default async function DocPost({ params }: DocPostProps) { + const { locale, slug, product } = await params; + try { + const documentData = await getDocPageData({ product, slug, locale }); + return ; + } catch (error) { + if (error instanceof NotFoundError) { + return ( + + ); + } + throw error; + } +} + +export const revalidate = 3600; diff --git a/app/[product]/docs/layout.tsx b/app/[locale]/[product]/docs/layout.tsx similarity index 88% rename from app/[product]/docs/layout.tsx rename to app/[locale]/[product]/docs/layout.tsx index 028d67072..416662e80 100644 --- a/app/[product]/docs/layout.tsx +++ b/app/[locale]/[product]/docs/layout.tsx @@ -1,7 +1,6 @@ import * as SearchBox from "@comps/search/SearchBox"; import { DocsTableOfContents } from "@tina/__generated__/types"; import { getDocsTableOfContents } from "@utils/fetchDocs"; -import { getLocale } from "../../../utils/i18n"; import TableOfContentsClient from "./[slug]/TableOfContentsClient"; const RootLayout = async ({ @@ -9,13 +8,9 @@ const RootLayout = async ({ params, }: { children: React.ReactNode; - params: Promise<{ - product: string; - }>; + params: Promise<{ locale: string; product: string }>; }) => { - - const { product } = await params; - const locale = await getLocale(); + const { locale, product } = await params; const tableOfContentsData = await getDocsTableOfContents(product, locale); return ( <> diff --git a/app/[product]/docs/page.tsx b/app/[locale]/[product]/docs/page.tsx similarity index 56% rename from app/[product]/docs/page.tsx rename to app/[locale]/[product]/docs/page.tsx index 0215446ed..58afcb3c6 100644 --- a/app/[product]/docs/page.tsx +++ b/app/[locale]/[product]/docs/page.tsx @@ -1,9 +1,10 @@ import { notFound } from "next/navigation"; -import client from "../../../tina/__generated__/client"; -import { getLocale } from "../../../utils/i18n"; +import client from "@tina/__generated__/client"; import DocPost from "./[slug]/page"; +import { localeFromBreadcrumbs } from "@utils/localeFromBreadcrumbs"; + interface DocsIndex { - params: Promise<{ product: string }>; + params: Promise<{ locale: string; product: string }>; } export async function generateMetadata({ params }: DocsIndex) { @@ -21,19 +22,26 @@ export async function generateMetadata({ params }: DocsIndex) { export async function generateStaticParams() { const sitePosts = await client.queries.docsConnection({}); - return ( - sitePosts.data.docsConnection?.edges?.map((post) => ({ - product: post?.node?._sys.breadcrumbs[0], - })) || [] - ); + const seen = new Set(); + const params: { locale: string; product: string }[] = []; + for (const edge of sitePosts.data.docsConnection?.edges ?? []) { + const breadcrumbs = edge?.node?._sys.breadcrumbs ?? []; + const product = breadcrumbs[0]; + if (!product) continue; + const locale = localeFromBreadcrumbs(breadcrumbs); + const key = `${locale}/${product}`; + if (seen.has(key)) continue; + seen.add(key); + params.push({ locale, product }); + } + return params; } export default async function DocsIndex({ params }: DocsIndex) { - const { product } = await params; + const { locale, product } = await params; const defaultSlug = "introduction"; - try { - return ; + return ; } catch (error) { console.error("Error rendering doc post:", error); return notFound(); diff --git a/app/[product]/feedback/page.tsx b/app/[locale]/[product]/feedback/page.tsx similarity index 95% rename from app/[product]/feedback/page.tsx rename to app/[locale]/[product]/feedback/page.tsx index f18d5f3e3..c5c06259f 100644 --- a/app/[product]/feedback/page.tsx +++ b/app/[locale]/[product]/feedback/page.tsx @@ -3,7 +3,7 @@ import { notFound } from "next/navigation"; import Script from "next/script"; interface Feedback { - params: Promise<{ product: string }>; + params: Promise<{ locale: string; product: string }>; } export async function generateMetadata() : Promise { diff --git a/app/[product]/layout.tsx b/app/[locale]/[product]/layout.tsx similarity index 87% rename from app/[product]/layout.tsx rename to app/[locale]/[product]/layout.tsx index c02255235..d660045f9 100644 --- a/app/[product]/layout.tsx +++ b/app/[locale]/[product]/layout.tsx @@ -1,10 +1,9 @@ import FooterServer from "@comps/shared/FooterServer"; import { Inter } from "next/font/google"; import Script from "next/script"; -import NavBarServer from "../../components/shared/NavBarServer"; -import { getGoogleTagId } from "../../utils/getGoogleTagId"; -import { getLocale } from "../../utils/i18n"; -import "../globals.css"; +import NavBarServer from "@comps/shared/NavBarServer"; +import { getGoogleTagId } from "@utils/getGoogleTagId"; +import "@app/globals.css"; import QueryProvider from "@comps/providers/QueryProvider"; const inter = Inter({ @@ -17,12 +16,10 @@ export default async function RootLayout({ params, }: { children: React.ReactNode; - params: Promise<{ product: string }>; + params: Promise<{ locale: string; product: string }>; }) { - - const { product } = await params; + const { locale, product } = await params; const googleTagId = getGoogleTagId(product); - const locale = await getLocale(); const htmlLang = locale === "zh" ? "zh-CN" : "en"; return ( diff --git a/app/[product]/not-found.tsx b/app/[locale]/[product]/not-found.tsx similarity index 100% rename from app/[product]/not-found.tsx rename to app/[locale]/[product]/not-found.tsx diff --git a/app/[product]/not-found/not-found-client.tsx b/app/[locale]/[product]/not-found/not-found-client.tsx similarity index 100% rename from app/[product]/not-found/not-found-client.tsx rename to app/[locale]/[product]/not-found/not-found-client.tsx diff --git a/app/[locale]/[product]/page.tsx b/app/[locale]/[product]/page.tsx new file mode 100644 index 000000000..2c27945cc --- /dev/null +++ b/app/[locale]/[product]/page.tsx @@ -0,0 +1,48 @@ +import HomePageClient from "@comps/shared/HomePageClient"; +import ProductBackground from "@comps/shared/ProductBackground"; +import client from "@tina/__generated__/client"; +import { setPageMetadata } from "@utils/setPageMetaData"; +import { getPageWithFallback, getRelativePath } from "@utils/i18n"; +import { localeFromBreadcrumbs } from "@utils/localeFromBreadcrumbs"; + +interface ProductPageProps { + params: Promise<{ locale: string; product: string }>; +} + +export async function generateMetadata({ params }: ProductPageProps) { + const { locale, product } = await params; + const productData = await getPageWithFallback({ product, filename: "home", locale }); + return setPageMetadata(productData.data?.pages?.seo, product); +} + +export async function generateStaticParams() { + const sitePosts = await client.queries.pagesConnection({}); + const seen = new Set(); + const params: { locale: string; product: string }[] = []; + for (const edge of sitePosts.data.pagesConnection?.edges ?? []) { + const breadcrumbs = edge?.node?._sys.breadcrumbs ?? []; + const product = breadcrumbs[0]; + if (!product) continue; + const locale = localeFromBreadcrumbs(breadcrumbs); + const key = `${locale}/${product}`; + if (seen.has(key)) continue; + seen.add(key); + params.push({ locale, product }); + } + return params; +} + +export default async function ProductPage({ params }: ProductPageProps) { + const { locale, product } = await params; + const productData = await getPageWithFallback({ product, filename: "home", locale }); + const relativePath = getRelativePath(product, "home", locale); + + return ( +
+ + +
+ ); +} + +export const revalidate = 3600; diff --git a/app/[locale]/[product]/privacy/page.tsx b/app/[locale]/[product]/privacy/page.tsx new file mode 100644 index 000000000..3a0116601 --- /dev/null +++ b/app/[locale]/[product]/privacy/page.tsx @@ -0,0 +1,28 @@ +import { Metadata } from "next"; +import InteractiveBackground from "@comps/shared/Background/InteractiveBackground"; +import PrivacyPolicyClient from "@comps/shared/PrivacyPolicyClient"; +import { getPrivacyWithFallback } from "@utils/i18n"; +import { setPageMetadata } from "@utils/setPageMetaData"; + +interface PrivacyPolicyProps { + params: Promise<{ locale: string; product: string }>; +} + +export async function generateMetadata({ params }: PrivacyPolicyProps): Promise { + const { locale, product } = await params; + const res = await getPrivacyWithFallback(product, locale); + return setPageMetadata(res.data.privacy.seo, product); +} + +export default async function PrivacyPolicy({ params }: PrivacyPolicyProps) { + const { locale, product } = await params; + const res = await getPrivacyWithFallback(product, locale); + return ( +
+ +
+ +
+
+ ); +} diff --git a/app/[product]/sitemap.xml/route.ts b/app/[locale]/[product]/sitemap.xml/route.ts similarity index 90% rename from app/[product]/sitemap.xml/route.ts rename to app/[locale]/[product]/sitemap.xml/route.ts index 764701c88..e0ea89603 100644 --- a/app/[product]/sitemap.xml/route.ts +++ b/app/[locale]/[product]/sitemap.xml/route.ts @@ -3,9 +3,8 @@ import { getDomainForTenant } from "@utils/tenancy"; export async function GET( request: Request, - { params }: { params: Promise<{ product: string }> } + { params }: { params: Promise<{ locale: string; product: string }> }, ): Promise { - const { product } = await params; const hostname = getDomainForTenant(product); diff --git a/app/[product]/[filename]/page.tsx b/app/[product]/[filename]/page.tsx deleted file mode 100644 index d5706da06..000000000 --- a/app/[product]/[filename]/page.tsx +++ /dev/null @@ -1,67 +0,0 @@ -import HomePageClient from "../../../components/shared/HomePageClient"; -import client from "../../../tina/__generated__/client"; -import { setPageMetadata } from "../../../utils/setPageMetaData"; -import { getLocale, getPageWithFallback, getRelativePath } from "../../../utils/i18n"; -import getPageData from "@utils/pages/getPageData"; -import NotFoundError from "@/errors/not-found"; -import ClientFallbackPage from "../../client-fallback-page"; -import { notFound } from "next/navigation"; - -export const dynamic = 'force-static'; -interface FilePageProps { - params: Promise<{ product: string; filename: string }>; -} - - -export async function generateMetadata({ params }: FilePageProps) { - - const { product, filename } = await params; - try { - const locale = await getLocale(); - const fileData = await getPageWithFallback({product, filename, locale, revalidate: 10, branch: "main"}); - const metadata = setPageMetadata(fileData.data?.pages?.seo, product); - return metadata; - } - catch(error) { - if(error instanceof NotFoundError){ - return {}; - } - throw error; - } -} - -export async function generateStaticParams() { - const sitePosts = await client.queries.pagesConnection({}); - return ( - sitePosts.data.pagesConnection?.edges?.map((post) => ({ - filename: post?.node?._sys.filename, - product: post?.node?._sys.breadcrumbs[0], - })) || [] - ); -} -export default async function FilePage({ params }: FilePageProps) { - const { product, filename } = await params; - try { - const {data, query, relativePath} = await getPageData(product, filename); - return ( - - ); - } - catch(error) - { - if(error instanceof NotFoundError){ - return ; - } - notFound(); - } -} - -export const revalidate = 60; diff --git a/app/[product]/blog/[slug]/page.tsx b/app/[product]/blog/[slug]/page.tsx deleted file mode 100644 index 4f7ae3051..000000000 --- a/app/[product]/blog/[slug]/page.tsx +++ /dev/null @@ -1,72 +0,0 @@ -import client from "@tina/__generated__/client"; -import { setPageMetadata } from "@utils/setPageMetaData"; -import { getLocale, getBlogWithFallback } from "@utils/i18n"; -import getBlogPageData from "@utils/pages/getBlogPageData"; -import ClientFallbackPage from "../../../client-fallback-page"; -import NotFoundError from "@/errors/not-found"; -import { BlogPageShared, BlogPageSharedProps } from "./blog-shared"; - -interface BlogPostProps { - params: Promise<{ - slug: string; - product: string; - }>; -} - -export async function generateMetadata({ params }: BlogPostProps) { - - const { slug, product } = await params; - const locale = await getLocale(); - - try { - const res = await getBlogWithFallback({product, slug, locale}); - - if (!res?.data?.blogs) { - return null; - } - - const metadata = setPageMetadata(res?.data?.blogs?.seo, product, 'Blog'); - return metadata; - } - catch (error) { - if(error instanceof NotFoundError) { - return {} - } - } -} - -export async function generateStaticParams() { - const sitePosts = await client.queries.blogsConnection({}); - return ( - sitePosts.data.blogsConnection?.edges?.map((post) => ({ - slug: post?.node?._sys.filename, - product: post?.node?._sys.breadcrumbs[0], - })) || [] - ); -} - -export default async function BlogPost({ params }: BlogPostProps) { - const { slug, product } = await params; - try{ - const data = await getBlogPageData(product, slug); - - return ( - - ); - } - catch (error){ - if(error instanceof NotFoundError){ - return - product={product} - relativePath={slug} - query={"getBlogPageData"} - Component={BlogPageShared} - />; - } - throw error; - } -} - - - - diff --git a/app/[product]/docs/[slug]/page.tsx b/app/[product]/docs/[slug]/page.tsx deleted file mode 100644 index 03db04e4f..000000000 --- a/app/[product]/docs/[slug]/page.tsx +++ /dev/null @@ -1,71 +0,0 @@ -import { getDocPost } from "@utils/fetchDocs"; -import client from "../../../../tina/__generated__/client"; -import { getLocale } from "../../../../utils/i18n"; -import { setPageMetadata } from "../../../../utils/setPageMetaData"; -import DocPostClient from "./DocPostClient"; -import getDocPageData from "@utils/pages/getDocPageData"; -import ClientFallbackPage from "../../../client-fallback-page"; -import NotFoundError from "@/errors/not-found"; - -interface DocPostProps { - params: Promise<{ - slug: string; - product: string; - }>; -} - -interface DocPostMetadataProps { - params: Promise<{ - slug: string; - product: string; - }>; -} - -export async function generateMetadata({ params }: DocPostMetadataProps) { - const { product, slug } = await params; - try { - const locale = await getLocale(); - const docs = await getDocPost({product, slug, locale}); - const metadata = setPageMetadata(docs?.docs?.seo, product, "Docs"); - return metadata; - } - catch(error) { - if(error instanceof NotFoundError){ - return {}; - } - throw error; - } -} - -export async function generateStaticParams() { - const sitePosts = await client.queries.docsConnection({}); - return ( - sitePosts.data.docsConnection?.edges?.map((post) => ({ - slug: post?.node?._sys.filename, - product: post?.node?._sys.breadcrumbs[0], - })) || [] - ); -} - -export default async function DocPost({ params }: DocPostProps) { - const { slug, product } = await params; - const locale = await getLocale(); - try { - const documentData = await getDocPageData({product, slug, locale}); - return ; - } - catch (error) { - if(error instanceof NotFoundError){ - return ; - } - throw error; - } -} - -// Add revalidation - page wouldn't update although GraphQL was updated. TODO: remove this once @wicksipedia created the global revalidation route. -export const revalidate = 600; - diff --git a/app/[product]/page.tsx b/app/[product]/page.tsx deleted file mode 100644 index 1bb3c4fba..000000000 --- a/app/[product]/page.tsx +++ /dev/null @@ -1,47 +0,0 @@ -import HomePageClient from "../../components/shared/HomePageClient"; -import ProductBackground from "../../components/shared/ProductBackground"; -import client from "../../tina/__generated__/client"; -import { setPageMetadata } from "../../utils/setPageMetaData"; -import { getLocale, getPageWithFallback, getRelativePath } from "../../utils/i18n"; - -interface ProductPageProps { - params: Promise<{ product: string }>; -} - - -export async function generateMetadata({ params }: ProductPageProps) { - const { product } = await params; - const locale = await getLocale(); - const productData = await getPageWithFallback({product, filename: 'home', locale}); - const metadata = setPageMetadata(productData.data?.pages?.seo, product); - return metadata; -} - -export async function generateStaticParams() { - const sitePosts = await client.queries.pagesConnection({}); - return ( - sitePosts.data.pagesConnection?.edges?.map((post) => ({ - product: post?.node?._sys.breadcrumbs[0], - })) || [] - ); -} - -export default async function ProductPage({ params }: ProductPageProps) { - const {product} = await params; - const locale = await getLocale(); - - const productData = await getPageWithFallback({product, filename: 'home', locale}); - const relativePath = getRelativePath(product, 'home', locale); - - return ( -
- - -
- ); -} - diff --git a/app/[product]/privacy/page.tsx b/app/[product]/privacy/page.tsx deleted file mode 100644 index 1638b9bd1..000000000 --- a/app/[product]/privacy/page.tsx +++ /dev/null @@ -1,39 +0,0 @@ -import { Metadata } from "next"; -import InteractiveBackground from "../../../components/shared/Background/InteractiveBackground"; -import PrivacyPolicyClient from "../../../components/shared/PrivacyPolicyClient"; -import { getLocale, getPrivacyWithFallback } from "../../../utils/i18n"; -import { setPageMetadata } from "../../../utils/setPageMetaData"; - -interface PrivacyPolicyProps { - params: Promise<{ - product: string; - }>; -} - -export async function generateMetadata({ - params, -}: PrivacyPolicyProps): Promise { - const { product } = await params; - const locale = await getLocale(); - const res = await getPrivacyWithFallback(product, locale); - - const privacy = res.data.privacy; - const metadata = setPageMetadata(privacy.seo, product); - - return metadata; -} - -export default async function PrivacyPolicy({ params }: PrivacyPolicyProps) { - const { product } = await params; - const locale = await getLocale(); - const res = await getPrivacyWithFallback(product, locale); - - return ( -
- -
- -
-
- ); -} diff --git a/app/api/page-data/blogs/route.ts b/app/api/page-data/blogs/route.ts index 6d8f064c0..6a5a12769 100644 --- a/app/api/page-data/blogs/route.ts +++ b/app/api/page-data/blogs/route.ts @@ -27,7 +27,8 @@ export async function POST(request: Request) { throw new BadRequestError("Missing relativePath parameter"); } - const data = await getBlogPageData(product, relativePath, branch); + // English-only preview/fallback route; branch is the 4th positional arg. + const data = await getBlogPageData(product, relativePath, "en", branch); return new Response(JSON.stringify(data), {status: 200}); } catch(error) { diff --git a/app/api/page-data/route.ts b/app/api/page-data/route.ts index 4b3cda16c..71bfb6a12 100644 --- a/app/api/page-data/route.ts +++ b/app/api/page-data/route.ts @@ -26,7 +26,8 @@ const POST = async (request: Request) => { throw new BadRequestError("Missing relativePath parameter"); } - const data = await getPageData(product, relativePath, branch); + // English-only preview/fallback route; branch is the 4th positional arg. + const data = await getPageData(product, relativePath, "en", branch); return new Response(JSON.stringify({...data}), {status: 200}); } diff --git a/middleware.ts b/middleware.ts index 022038fa7..56ed24310 100644 --- a/middleware.ts +++ b/middleware.ts @@ -1,116 +1,32 @@ import { NextRequest, NextResponse } from "next/server"; - -function detectLanguage(pathname: string): string { - return pathname.startsWith('/zh') ? 'zh' : 'en'; -} - -function cleanPathFromLanguage(pathname: string): string { - return pathname.startsWith('/zh') ? pathname.substring(3) : pathname; -} - -function parsePathSegments(pathname: string): string[] { - return pathname - .split("/") - .filter((segment) => segment.length > 0); -} - -function createRewriteResponse(targetPath: string, language: string, request: NextRequest): NextResponse { - const rewriteUrl = new URL(targetPath, request.url); - const response = NextResponse.rewrite(rewriteUrl); - response.headers.set('x-language', language); - return response; -} +import { resolveRequestRoute, type ProductEntry } from "@utils/resolveRequestRoute"; export function middleware(request: NextRequest) { const hostname = request.headers.get("x-original-host") || request.headers.get("host"); const { pathname } = request.nextUrl; - const isLocal = - hostname?.includes("localhost") || hostname?.includes("127.0.0.1"); - const isStaging = hostname?.includes("vercel.app"); - const productList = process.env.NEXT_PUBLIC_PRODUCT_LIST - ? JSON.parse(process.env.NEXT_PUBLIC_PRODUCT_LIST) - : []; - - // Allow .well-known paths without rewriting - if (pathname.startsWith("/.well-known")) { - return NextResponse.next(); // Bypass rewriting for these paths - } - - // Allow TinaCMS admin paths - if (pathname.startsWith("/admin")) { + // Bypass rewriting for these paths. + if (pathname.startsWith("/.well-known") || pathname.startsWith("/admin")) { return NextResponse.next(); } - const isChineseDomain = !!(hostname?.endsWith('yakshaver.cn') || hostname?.endsWith('yakshaver.com.cn')); - const language = isChineseDomain ? 'zh' : detectLanguage(pathname); - - if (isLocal || isStaging) { - return handleLocalRequest(pathname, productList, request, language); - } else { - return handleProductionRequest(hostname, productList, pathname, request, language, isChineseDomain); - } -} - -function handleLocalRequest( - pathname: string, - productList: any[], - request: NextRequest, - language: string -) { - const pathSegments = parsePathSegments(pathname); - const isProduct = productList.some( - (product) => product.product === pathSegments[0] - ); + const productList: ProductEntry[] = process.env.NEXT_PUBLIC_PRODUCT_LIST + ? JSON.parse(process.env.NEXT_PUBLIC_PRODUCT_LIST) + : []; - if (isProduct) { - return createRewriteResponse(`/${pathSegments.join("/")}`, language, request); - } else { - const cleanPath = cleanPathFromLanguage(pathname); - return createRewriteResponse( - `/${process.env.DEFAULT_PRODUCT}${cleanPath}`, - language, - request - ); - } -} + const resolved = resolveRequestRoute({ + hostname, + pathname, + productList, + env: { defaultProduct: process.env.DEFAULT_PRODUCT || "YakShaver" }, + }); -function handleProductionRequest( - hostname: string | null, - productList: any[], - pathname: string, - request: NextRequest, - language: string, - isChineseDomain: boolean -) { - let targetProduct: string | null = null; - - if (isChineseDomain) { - targetProduct = 'YakShaver'; - } else { - for (const product of productList) { - if (hostname === product.domain) { - targetProduct = product.product; - break; - } - } + if (!resolved) { + return NextResponse.next(); } - - if (targetProduct) { - const cleanPath = cleanPathFromLanguage(pathname); - const pathSegments = parsePathSegments(cleanPath); - const pathAlreadyHasProduct = productList.some( - (product: any) => product.product === pathSegments[0] - ); - - if (pathAlreadyHasProduct) { - return createRewriteResponse(`/${pathSegments.join("/")}`, language, request); - } - return createRewriteResponse(`/${targetProduct}${cleanPath}`, language, request); - } - - return NextResponse.next(); + const rewriteUrl = new URL(resolved.internalPath, request.url); + return NextResponse.rewrite(rewriteUrl); } export const config = { diff --git a/tsconfig.json b/tsconfig.json index ee7a3f126..cc7bba9f9 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -37,6 +37,9 @@ ], "@images/*": [ "./images/*" + ], + "@app/*": [ + "./app/*" ] }, "target": "ES2017" diff --git a/utils/i18n.ts b/utils/i18n.ts index 4c42db6f2..fcf03f1c7 100644 --- a/utils/i18n.ts +++ b/utils/i18n.ts @@ -1,13 +1,7 @@ -import { headers } from 'next/headers'; import { notFound } from "next/navigation"; import client from "../tina/__generated__/client"; import NotFoundError from '@/errors/not-found'; -export async function getLocale(): Promise { - const headersList = await headers(); - return headersList.get('x-language') || 'en'; -} - export function getRelativePath(product: string, filename: string, locale: string): string { return locale === 'zh' ? `${product}/zh/${filename}.json` : `${product}/${filename}.json`; } diff --git a/utils/localeFromBreadcrumbs.ts b/utils/localeFromBreadcrumbs.ts new file mode 100644 index 000000000..c7992c2d0 --- /dev/null +++ b/utils/localeFromBreadcrumbs.ts @@ -0,0 +1,9 @@ +export type Locale = "en" | "zh"; + +/** + * Tina `_sys.breadcrumbs` look like ["YakShaver","home"] (en) or + * ["YakShaver","zh","home"] (zh). zh content lives only under /zh. + */ +export function localeFromBreadcrumbs(breadcrumbs: string[]): Locale { + return breadcrumbs[1] === "zh" ? "zh" : "en"; +} diff --git a/utils/pages/getBlogPageData.ts b/utils/pages/getBlogPageData.ts index eef14206c..68f82c313 100644 --- a/utils/pages/getBlogPageData.ts +++ b/utils/pages/getBlogPageData.ts @@ -2,17 +2,14 @@ import { Blog } from "@/types/blog"; import { Blogs } from "@tina/__generated__/types"; import { getBlogsForProduct } from "@utils/fetchBlogs"; import { formatDate } from "@utils/formatDate"; -import { getBlogWithFallback, getLocale } from "@utils/i18n"; +import { getBlogWithFallback } from "@utils/i18n"; import NotFoundError from "../../src/errors/not-found"; -async function getBlogPageData(product: string, slug: string, branch?: string) { - const locale = await getLocale(); - const res = await getBlogWithFallback({product, slug, locale, branch}); - if (!res?.data?.blogs) - { +async function getBlogPageData(product: string, slug: string, locale = "en", branch?: string) { + const res = await getBlogWithFallback({ product, slug, locale, branch }); + if (!res?.data?.blogs) { throw new NotFoundError("Blog post not found"); } - const allBlogs = await getBlogsForProduct({ product, locale, branch }); const flattenedBlogs = allBlogs.blogs?.reduce((acc, blog) => { diff --git a/utils/pages/getDocPageData.ts b/utils/pages/getDocPageData.ts index bdb9311f6..f45f72a46 100644 --- a/utils/pages/getDocPageData.ts +++ b/utils/pages/getDocPageData.ts @@ -1,8 +1,6 @@ import { DocsTableOfContents } from "@tina/__generated__/types"; import { getDocPost, getDocsTableOfContents } from "@utils/fetchDocs"; -import { getLocale } from "@utils/i18n"; import NotFoundError from "../../src/errors/not-found"; -import { locale } from "dayjs"; interface PaginationLink { diff --git a/utils/pages/getPageData.tsx b/utils/pages/getPageData.tsx index e0c967f07..5a37bd852 100644 --- a/utils/pages/getPageData.tsx +++ b/utils/pages/getPageData.tsx @@ -1,21 +1,24 @@ -import { getLocale, getPageWithFallback, getRelativePath } from "@utils/i18n"; +import { getPageWithFallback, getRelativePath } from "@utils/i18n"; const defaultBranch = - process.env.GITHUB_BRANCH || - process.env.VERCEL_GIT_COMMIT_REF || - process.env.HEAD || - "main"; + process.env.GITHUB_BRANCH || process.env.VERCEL_GIT_COMMIT_REF || process.env.HEAD || "main"; -const getPageData = async (product: string, filename: string, branch=defaultBranch) => { - const locale = await getLocale(); - const fileData = await getPageWithFallback({product, filename, locale, revalidate: 10, branch}); +const getPageData = async ( + product: string, + filename: string, + locale = "en", + branch = defaultBranch, +) => { + // 1h so the [filename] route's `revalidate = 3600` isn't capped by a shorter + // fetch-level revalidate (Next uses the lowest revalidate across the route). + const fileData = await getPageWithFallback({ product, filename, locale, revalidate: 3600, branch }); const relativePath = getRelativePath(product, filename, locale); - - - const filteredBlocks = fileData.data.pages.pageBlocks?.filter(block => block !== null && block !== undefined); + const filteredBlocks = fileData.data.pages.pageBlocks?.filter( + (block) => block !== null && block !== undefined, + ); fileData.data.pages.pageBlocks = filteredBlocks; - return {...fileData, relativePath}; -} + return { ...fileData, relativePath }; +}; -export default getPageData; \ No newline at end of file +export default getPageData; diff --git a/utils/resolveRequestRoute.ts b/utils/resolveRequestRoute.ts new file mode 100644 index 000000000..9ea64c013 --- /dev/null +++ b/utils/resolveRequestRoute.ts @@ -0,0 +1,67 @@ +import type { Locale } from "@utils/localeFromBreadcrumbs"; + +export interface ProductEntry { product: string; domain: string; } +export interface ResolveInput { + hostname: string | null; + pathname: string; + productList: ProductEntry[]; + env: { defaultProduct: string }; +} +export interface ResolvedRoute { locale: Locale; product: string; internalPath: string; } + +function isChineseDomain(hostname: string): boolean { + return ( + hostname === "yakshaver.cn" || + hostname.endsWith(".yakshaver.cn") || + hostname === "yakshaver.com.cn" || + hostname.endsWith(".yakshaver.com.cn") + ); +} +function splitLocaleFromPath(pathname: string): { locale: Locale; rest: string } { + if (pathname === "/zh" || pathname.startsWith("/zh/")) { + return { locale: "zh", rest: pathname.slice(3) || "" }; + } + return { locale: "en", rest: pathname }; +} +function segments(path: string): string[] { + return path.split("/").filter((s) => s.length > 0); +} + +// `internalPath` targets the app/[locale]/[product]/... route tree introduced +// in the same change set; middleware.ts rewrites to it. Public URLs are +// unchanged (rewrite, not redirect) — locale is invisible to clients. +export function resolveRequestRoute(input: ResolveInput): ResolvedRoute | null { + const { hostname, pathname, productList, env } = input; + if (!hostname) return null; + const isLocal = hostname.includes("localhost") || hostname.includes("127.0.0.1"); + const isStaging = hostname.includes("vercel.app"); + + if (!isLocal && !isStaging && isChineseDomain(hostname)) { + const { rest } = splitLocaleFromPath(pathname); + return { locale: "zh", product: "YakShaver", internalPath: buildPath("zh", "YakShaver", rest, productList) }; + } + const { locale, rest } = splitLocaleFromPath(pathname); + let product: string | null = null; + if (isLocal || isStaging) { + const seg = segments(rest); + product = productList.some((p) => p.product === seg[0]) ? seg[0] : env.defaultProduct; + } else { + product = productList.find((p) => p.domain === hostname)?.product ?? null; + } + if (!product) return null; + return { locale, product, internalPath: buildPath(locale, product, rest, productList) }; +} + +function buildPath(locale: Locale, product: string, rest: string, productList: ProductEntry[]): string { + const seg = segments(rest); + const alreadyHasProduct = productList.some((p) => p.product === seg[0]); + if (alreadyHasProduct) { + // rest already starts with the product name; just prefix locale + const restPath = seg.length > 1 ? `/${seg.join("/")}` : `/${seg[0]}`; + return `/${locale}${restPath}`; + } + // rest is something like "/blog" or "/" or "" + // strip trailing slash from rest to avoid "/en/YakShaver/" + const cleanRest = rest === "/" ? "" : rest.replace(/\/$/, ""); + return `/${locale}/${product}${cleanRest}`; +}