Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 17 additions & 0 deletions __tests__/localeFromBreadcrumbs.test.ts
Original file line number Diff line number Diff line change
@@ -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");
});
});
55 changes: 55 additions & 0 deletions __tests__/resolveRequestRoute.test.ts
Original file line number Diff line number Diff line change
@@ -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/<product>/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/<product>", () => {
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();
});
});
62 changes: 62 additions & 0 deletions app/[locale]/[product]/[filename]/page.tsx
Original file line number Diff line number Diff line change
@@ -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 <HomePageClient query={query} data={data} variables={{ relativePath }} />;
} catch (error) {
if (error instanceof NotFoundError) {
return (
<ClientFallbackPage
product={product}
relativePath={filename}
query="getPageData"
Component={HomePageClient}
/>
);
}
notFound();
}
}

export const revalidate = 3600;
58 changes: 58 additions & 0 deletions app/[locale]/[product]/blog/[slug]/page.tsx
Original file line number Diff line number Diff line change
@@ -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() {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

generateStaticParams is copy-pasted across three siblings. This block, plus the equivalents in docs/[slug]/page.tsx and [filename]/page.tsx, are structurally identical: query a *Connection, then map edges to { locale: localeFromBreadcrumbs(bc), product: bc[0], <leaf>: filename }. Only the client query and the leaf key name differ. The page.tsx/blog/page.tsx/docs/page.tsx index pages also repeat the same seen/params dedup loop verbatim.

Worth pulling into one helper in utils/, e.g. staticParamsFromConnection(edges, leafKey), so each page becomes a one-liner and there is a single place to fix a future copy-paste bug. Non-blocking.

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 <BlogPageShared {...data} />;
} catch (error) {
if (error instanceof NotFoundError) {
return (
<ClientFallbackPage<BlogPageSharedProps>
product={product}
relativePath={slug}
query={"getBlogPageData"}
Component={BlogPageShared}
/>
);
}
throw error;
}
}
Original file line number Diff line number Diff line change
@@ -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) {
Expand All @@ -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<string>();
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<string[]>((acc, curr) => {
Expand All @@ -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 (
<div className="text-gray-100 flex flex-col">
<div className="flex flex-col min-h-screen">
<HydrationBoundary state={dehydratedState}>
<BlogSearchProvider categories={categories}>
<BlogIndexClient {...tinaData} product={product} locale={locale} />
</BlogSearchProvider>
</HydrationBoundary>
</div>
<div className="flex flex-col min-h-screen">
<HydrationBoundary state={dehydratedState}>
<BlogSearchProvider categories={categories}>
<BlogIndexClient {...tinaData} product={product} locale={locale} />
</BlogSearchProvider>
</HydrationBoundary>
</div>
</div>
);
}
59 changes: 59 additions & 0 deletions app/[locale]/[product]/docs/[slug]/page.tsx
Original file line number Diff line number Diff line change
@@ -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 <DocPostClient {...documentData} />;
} catch (error) {
if (error instanceof NotFoundError) {
return (
<ClientFallbackPage
product={product}
relativePath={slug}
query="getDocPageData"
Component={DocPostClient}
/>
);
}
throw error;
}
}

export const revalidate = 3600;
Original file line number Diff line number Diff line change
@@ -1,21 +1,16 @@
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 ({
children,
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 (
<>
Expand Down
Loading