diff --git a/README.md b/README.md
index 1928f0d3a..8df53618e 100644
--- a/README.md
+++ b/README.md
@@ -16,6 +16,7 @@
- [Pages](#pages)
- [Development](#development)
- [Using `fetchWithToken` for Custom Components in Sanity](#using-fetchwithtoken-for-custom-components-in-sanity)
+ - [Nested Path Translation](#nested-path-translation)
- [Steganography in Presentation](#steganography-in-presentation)
- [Serving files from `/public`](#serving-files-from-public)
- [OpenGraph image customization](#opengraph-image-customization)
@@ -188,6 +189,10 @@ export default MyCustomComponent;
By using fetchWithToken, you ensure that all data fetching happens securely, with the server-side API route handling the sensitive token.
+### Nested Path Translation
+
+Some extra handling is required for translating paths with multiple slugs/segments, e.g. `/kunder/fram`. In these cases, the `translatePath` function in [`languageMiddleware.tsx`](src/middlewares/languageMiddleware.ts) must be extended to handle the specific path structure. This usually involves checking which document type corresponds to the first path segment (e.g. `kunder` corresponds to the `customerCase` document type), and then finding the document for the second segment (e.g. customer case with slug `fram`).
+
### Steganography in Presentation
To enable preview functionality in the Presentation view, Sanity applies [steganography](https://www.sanity.io/docs/stega) to the string data. This manipulates the data to include invisible HTML entities to store various metadata. If the strings are used in business logic, that logic will likely break in the Presentation view. To fix this, Sanity provides the `stegaClean` utility to remove this extra metadata. An example of this in action can be found in [CompensationsPreview.tsx](src/compensations/CompensationsPreview.tsx), where JSON parsing of salary data fails without stega cleaning.
diff --git a/src/app/(main)/[lang]/[...path]/page.tsx b/src/app/(main)/[lang]/[...path]/page.tsx
index fcf222f5f..628c964e0 100644
--- a/src/app/(main)/[lang]/[...path]/page.tsx
+++ b/src/app/(main)/[lang]/[...path]/page.tsx
@@ -7,6 +7,7 @@ import CustomerCasesPreview from "src/components/customerCases/CustomerCasesPrev
import CustomErrorMessage from "src/components/customErrorMessage/CustomErrorMessage";
import Legal from "src/components/legal/Legal";
import LegalPreview from "src/components/legal/LegalPreview";
+import PageHeader from "src/components/navigation/header/PageHeader";
import { homeLink } from "src/components/utils/linkTypes";
import { getDraftModeInfo } from "src/utils/draftmode";
import { fetchPageDataFromParams } from "src/utils/pageData";
@@ -77,60 +78,68 @@ async function Page({ params }: Props) {
return Page404;
}
- const { queryResponse, docType } = pageData;
+ const { queryResponse, docType, pathTranslations } = pageData;
- switch (docType) {
- case "pageBuilder":
- return (
- <>
- {queryResponse.data?.sections?.map((section, index) => (
-
- ))}
- >
- );
- case "compensations":
- return isDraftMode ? (
-
- ) : (
-
- );
- case "customerCasesPage":
- return isDraftMode ? (
-
- ) : (
-
- );
- case "customerCase":
- return (
- // TODO: implement customer case detail page
-
- {JSON.stringify(pageData, null, 2)}
-
- );
- case "legalDocument":
- return isDraftMode ? (
-
- ) : (
-
- );
- }
-
- return Page404;
+ return (
+ <>
+
+
+ {(() => {
+ switch (docType) {
+ case "pageBuilder":
+ return (
+ <>
+ {queryResponse.data?.sections?.map((section, index) => (
+
+ ))}
+ >
+ );
+ case "compensations":
+ return isDraftMode ? (
+
+ ) : (
+
+ );
+ case "customerCasesPage":
+ return isDraftMode ? (
+
+ ) : (
+
+ );
+ case "customerCase":
+ return (
+ // TODO: implement customer case detail page
+
+ {JSON.stringify(pageData, null, 2)}
+
+ );
+ case "legalDocument":
+ return isDraftMode ? (
+
+ ) : (
+
+ );
+ }
+ return Page404;
+ })()}
+
+ >
+ );
}
export default Page;
diff --git a/src/app/(main)/[lang]/layout.tsx b/src/app/(main)/[lang]/layout.tsx
index fa07aa0cc..f0b4ad3d7 100644
--- a/src/app/(main)/[lang]/layout.tsx
+++ b/src/app/(main)/[lang]/layout.tsx
@@ -3,8 +3,6 @@ import { draftMode } from "next/headers";
import Footer from "src/components/navigation/footer/Footer";
import FooterPreview from "src/components/navigation/footer/FooterPreview";
-import { Header } from "src/components/navigation/header/Header";
-import HeaderPreview from "src/components/navigation/header/HeaderPreview";
import SkipToMain from "src/components/skipToMain/SkipToMain";
import { getDraftModeInfo } from "src/utils/draftmode";
import { BrandAssets } from "studio/lib/interfaces/brandAssets";
@@ -24,8 +22,6 @@ import {
} from "studio/lib/queries/siteSettings";
import { loadStudioQuery } from "studio/lib/store";
-import styles from "./layout.module.css";
-
import "src/styles/global.css";
const fontBrittiSans = localFont({
@@ -73,45 +69,14 @@ export default async function Layout({
]);
const hasNavData = hasValidData(initialNav.data);
- const hasCompanyInfoData = hasValidData(initialCompanyInfo.data);
-
- const hasHeaderData =
- hasNavData && (initialNav.data.main || initialNav.data.sidebar);
const hasFooterData = hasNavData && initialNav.data.footer;
- const hasMenuData = hasCompanyInfoData && (hasHeaderData || hasFooterData);
-
- if (!hasMenuData) {
- return (
-
-
-
- {children}
-
-
-
- );
- }
return (
- {hasHeaderData && isDraftMode ? (
-
- ) : (
-
- )}
-
- {children}
-
+ {children}
{hasFooterData && isDraftMode ? (
{
@@ -53,16 +59,33 @@ const Home = async ({ params }: Props) => {
);
}
- return initialLandingPage.data.sections.map((section, index) => (
-
- ));
+ const languages = await loadStudioQuery(
+ LANGUAGES_QUERY,
+ );
+
+ const pathTranslations: InternationalizedString =
+ languages?.data?.map((language) => ({
+ _key: language.id,
+ value: "",
+ })) ?? [];
+
+ return (
+ <>
+
+
+ {initialLandingPage.data.sections.map((section, index) => (
+
+ ))}
+
+ >
+ );
};
export default Home;
diff --git a/src/components/languageSwitcher/LanguageSwitcher.tsx b/src/components/languageSwitcher/LanguageSwitcher.tsx
index dfdff5485..c0ddd0654 100644
--- a/src/components/languageSwitcher/LanguageSwitcher.tsx
+++ b/src/components/languageSwitcher/LanguageSwitcher.tsx
@@ -2,46 +2,42 @@ import Link from "next/link";
import { Fragment } from "react";
import Text from "src/components/text/Text";
-import useLanguage from "src/utils/hooks/useLanguage";
+import { InternationalizedString } from "studio/lib/interfaces/global";
import styles from "./languageSwitcher.module.css";
-export default function LanguageSwitcher() {
- const { slugTranslations, language, defaultLanguage } = useLanguage();
-
- const currentLanguage = language ?? defaultLanguage;
-
- // make sure the current language is the first item in the languages list
- const sortedTranslations = slugTranslations?.toSorted((a, b) =>
- a?.language?.id === currentLanguage?.id
- ? -1
- : b?.language?.id === currentLanguage?.id
- ? 1
- : 0,
- );
+export interface LanguageSwitcherProps {
+ currentLanguage: string;
+ pathTranslations: InternationalizedString;
+}
+export default function LanguageSwitcher({
+ currentLanguage,
+ pathTranslations,
+}: LanguageSwitcherProps) {
return (
- {sortedTranslations?.map((slugTranslation, index) => {
- if (slugTranslation?.language === undefined) {
+ {pathTranslations?.map((pathTranslation, index) => {
+ if (pathTranslation._key === undefined) {
return null;
}
const linkText = (
-
- {slugTranslation.language.id.toUpperCase()}
-
+ {pathTranslation._key.toUpperCase()}
);
return (
-
+
- {currentLanguage === undefined ||
- slugTranslation.language.id !== currentLanguage.id ? (
- {linkText}
+ {pathTranslation._key !== currentLanguage ? (
+
+ {linkText}
+
) : (
linkText
)}
- {index < sortedTranslations.length - 1 && (
+ {index < pathTranslations.length - 1 && (
)}
diff --git a/src/components/navigation/header/Header.stories.tsx b/src/components/navigation/header/Header.stories.tsx
index b9d2f8307..469f0ba7f 100644
--- a/src/components/navigation/header/Header.stories.tsx
+++ b/src/components/navigation/header/Header.stories.tsx
@@ -1,6 +1,11 @@
import { Meta, StoryObj } from "@storybook/react";
-import { mockLogo, mockNavigation } from "src/components/navigation/mockData";
+import { defaultLanguage } from "i18n/supportedLanguages";
+import {
+ mockLogo,
+ mockNavigation,
+ mockPathTranslations,
+} from "src/components/navigation/mockData";
import { Header } from "./Header";
@@ -23,7 +28,9 @@ type Story = StoryObj;
export const Default: Story = {
args: {
- data: mockNavigation,
+ navigation: mockNavigation,
assets: mockLogo,
+ currentLanguage: defaultLanguage?.id ?? "en",
+ pathTranslations: mockPathTranslations,
},
};
diff --git a/src/components/navigation/header/Header.tsx b/src/components/navigation/header/Header.tsx
index f9522ab57..857c1a6e4 100644
--- a/src/components/navigation/header/Header.tsx
+++ b/src/components/navigation/header/Header.tsx
@@ -5,12 +5,14 @@ import { usePathname } from "next/navigation";
import { useEffect, useState } from "react";
import { FocusOn } from "react-focus-on";
+import { defaultLanguage } from "i18n/supportedLanguages";
import { SanityImage } from "src/components/image/SanityImage";
import LanguageSwitcher from "src/components/languageSwitcher/LanguageSwitcher";
import CustomLink from "src/components/link/CustomLink";
import LinkButton from "src/components/linkButton/LinkButton";
import { getHref } from "src/utils/link";
import { BrandAssets } from "studio/lib/interfaces/brandAssets";
+import { InternationalizedString } from "studio/lib/interfaces/global";
import { ILink, Navigation } from "studio/lib/interfaces/navigation";
import { callToActionFieldID } from "studio/schemas/fields/callToActionFields";
import { linkID } from "studio/schemas/objects/link";
@@ -18,21 +20,28 @@ import { linkID } from "studio/schemas/objects/link";
import styles from "./header.module.css";
export interface IHeader {
- data: Navigation;
+ navigation: Navigation;
assets: BrandAssets;
+ currentLanguage: string;
+ pathTranslations: InternationalizedString;
}
const filterLinks = (data: ILink[], type: string) =>
data?.filter((link) => link._type === type);
-export const Header = ({ data, assets }: IHeader) => {
+export const Header = ({
+ navigation,
+ assets,
+ currentLanguage,
+ pathTranslations,
+}: IHeader) => {
const pathname = usePathname();
const [isOpen, setIsOpen] = useState(false);
const [isScrolled, setIsScrolled] = useState(false);
- const sidebarData = data.sidebar || data.main;
+ const sidebarData = navigation.sidebar || navigation.main;
- const links = filterLinks(data.main, linkID);
- const ctas = filterLinks(data.main, callToActionFieldID);
+ const links = filterLinks(navigation.main, linkID);
+ const ctas = filterLinks(navigation.main, callToActionFieldID);
const sidebarLinks = filterLinks(sidebarData, linkID);
const sidebarCtas = filterLinks(sidebarData, callToActionFieldID);
@@ -86,7 +95,12 @@ export const Header = ({ data, assets }: IHeader) => {
{renderPageLinks(links, false, pathname)}
{renderPageCTAs(ctas, false)}
-
+ {defaultLanguage && (
+
+ )}
{
>
{renderPageLinks(sidebarLinks, true, pathname)}
{renderPageCTAs(sidebarCtas, true)}
-
+ {defaultLanguage && (
+
+ )}
)}
diff --git a/src/components/navigation/header/HeaderPreview.tsx b/src/components/navigation/header/HeaderPreview.tsx
index b4d67e6be..8a702e354 100644
--- a/src/components/navigation/header/HeaderPreview.tsx
+++ b/src/components/navigation/header/HeaderPreview.tsx
@@ -2,6 +2,7 @@
import { QueryResponseInitial, useQuery } from "@sanity/react-loader";
import { BrandAssets } from "studio/lib/interfaces/brandAssets";
+import { InternationalizedString } from "studio/lib/interfaces/global";
import { Navigation } from "studio/lib/interfaces/navigation";
import { BRAND_ASSETS_QUERY, NAV_QUERY } from "studio/lib/queries/siteSettings";
@@ -10,9 +11,13 @@ import { Header } from "./Header";
export default function HeaderPreview({
initialNav,
initialBrandAssets,
+ currentLanguage,
+ pathTranslations,
}: {
initialNav: QueryResponseInitial;
initialBrandAssets: QueryResponseInitial;
+ currentLanguage: string;
+ pathTranslations: InternationalizedString;
}) {
const { data: newNav } = useQuery(
NAV_QUERY,
@@ -26,6 +31,14 @@ export default function HeaderPreview({
);
return (
- newNav && newBrandAssets &&
+ newNav &&
+ newBrandAssets && (
+
+ )
);
}
diff --git a/src/components/navigation/header/PageHeader.tsx b/src/components/navigation/header/PageHeader.tsx
new file mode 100644
index 000000000..be4403d01
--- /dev/null
+++ b/src/components/navigation/header/PageHeader.tsx
@@ -0,0 +1,59 @@
+import { getDraftModeInfo } from "src/utils/draftmode";
+import { BrandAssets } from "studio/lib/interfaces/brandAssets";
+import { InternationalizedString } from "studio/lib/interfaces/global";
+import { Navigation } from "studio/lib/interfaces/navigation";
+import { BRAND_ASSETS_QUERY, NAV_QUERY } from "studio/lib/queries/siteSettings";
+import { loadStudioQuery } from "studio/lib/store";
+
+import { Header } from "./Header";
+import HeaderPreview from "./HeaderPreview";
+
+interface PageHeaderProps {
+ language: string;
+ pathTranslations: InternationalizedString;
+}
+
+export default async function PageHeader({
+ language,
+ pathTranslations,
+}: PageHeaderProps) {
+ const { perspective, isDraftMode } = getDraftModeInfo();
+
+ const initialNav = await loadStudioQuery(
+ NAV_QUERY,
+ { language },
+ { perspective },
+ );
+
+ const initialBrandAssets = await loadStudioQuery(
+ BRAND_ASSETS_QUERY,
+ {},
+ { perspective },
+ );
+
+ return (
+ initialBrandAssets.data !== null &&
+ initialNav.data !== null &&
+ (isDraftMode ? (
+
+ ) : (
+
+ ))
+ );
+}
diff --git a/src/components/navigation/mockData.ts b/src/components/navigation/mockData.ts
index b56b25975..e59b080f0 100644
--- a/src/components/navigation/mockData.ts
+++ b/src/components/navigation/mockData.ts
@@ -1,5 +1,6 @@
import primaryLogoFile from "src/stories/assets/energiai-primary-logo.svg";
import secondaryLogoFile from "src/stories/assets/energiai-secondary-logo.svg";
+import { InternationalizedString } from "studio/lib/interfaces/global";
import {
LinkType,
Navigation,
@@ -155,3 +156,14 @@ export const mockSocialMediaProfiles: SocialMediaProfiles = {
},
],
};
+
+export const mockPathTranslations: InternationalizedString = [
+ {
+ _key: "no",
+ value: "kunder",
+ },
+ {
+ _key: "en",
+ value: "customers",
+ },
+];
diff --git a/src/middlewares/languageMiddleware.ts b/src/middlewares/languageMiddleware.ts
index 3d8703b3c..4577a6ef3 100644
--- a/src/middlewares/languageMiddleware.ts
+++ b/src/middlewares/languageMiddleware.ts
@@ -1,53 +1,172 @@
import Negotiator from "negotiator";
import { headers } from "next/headers";
import { NextRequest, NextResponse } from "next/server";
+import { SanityClient } from "next-sanity";
import { client } from "studio/lib/client";
-import { SlugTranslations } from "studio/lib/interfaces/slugTranslations";
+import { InternationalizedString } from "studio/lib/interfaces/global";
import { LanguageObject } from "studio/lib/interfaces/supportedLanguages";
import {
DEFAULT_LANGUAGE_QUERY,
LANGUAGES_QUERY,
} from "studio/lib/queries/siteSettings";
import {
+ SLUG_FIELD_TRANSLATIONS_FROM_LANGUAGE_BY_TYPE_QUERY,
SLUG_FIELD_TRANSLATIONS_FROM_LANGUAGE_QUERY,
+ SLUG_FIELD_TRANSLATIONS_TO_LANGUAGE_BY_TYPE_QUERY,
SLUG_FIELD_TRANSLATIONS_TO_LANGUAGE_QUERY,
+ SLUG_TRANSLATIONS_FROM_LANGUAGE_BY_TYPE_QUERY,
SLUG_TRANSLATIONS_FROM_LANGUAGE_QUERY,
+ SLUG_TRANSLATIONS_TO_LANGUAGE_BY_TYPE_QUERY,
SLUG_TRANSLATIONS_TO_LANGUAGE_QUERY,
} from "studio/lib/queries/slugTranslations";
+import { sharedClient } from "studioShared/lib/client";
-async function translateSlug(
+export async function translateDocumentSlug(
+ queryClient: SanityClient,
slug: string,
targetLanguageId: string,
sourceLanguageId?: string,
-): Promise {
- if (slug.length === 0) {
- return slug;
- }
- const queryParams = {
- slug,
- language: sourceLanguageId ?? targetLanguageId,
- };
- // query document-based slug translations
- let slugTranslations = await client.fetch(
+ docType?: string,
+) {
+ return queryClient.fetch(
sourceLanguageId !== undefined
- ? SLUG_TRANSLATIONS_FROM_LANGUAGE_QUERY
- : SLUG_TRANSLATIONS_TO_LANGUAGE_QUERY,
- queryParams,
+ ? docType !== undefined
+ ? SLUG_TRANSLATIONS_FROM_LANGUAGE_BY_TYPE_QUERY
+ : SLUG_TRANSLATIONS_FROM_LANGUAGE_QUERY
+ : docType !== undefined
+ ? SLUG_TRANSLATIONS_TO_LANGUAGE_BY_TYPE_QUERY
+ : SLUG_TRANSLATIONS_TO_LANGUAGE_QUERY,
+ {
+ slug,
+ language: sourceLanguageId ?? targetLanguageId,
+ ...(docType !== undefined
+ ? {
+ type: docType,
+ }
+ : {}),
+ },
+ {
+ perspective: "published",
+ },
);
- if (slugTranslations === null) {
- // try field-based slug translations instead
- slugTranslations = await client.fetch(
- sourceLanguageId !== undefined
- ? SLUG_FIELD_TRANSLATIONS_FROM_LANGUAGE_QUERY
+}
+
+export async function translateFieldSlug(
+ queryClient: SanityClient,
+ slug: string,
+ targetLanguageId: string,
+ sourceLanguageId?: string,
+ docType?: string,
+) {
+ return queryClient.fetch(
+ sourceLanguageId !== undefined
+ ? docType !== undefined
+ ? SLUG_FIELD_TRANSLATIONS_FROM_LANGUAGE_BY_TYPE_QUERY
+ : SLUG_FIELD_TRANSLATIONS_FROM_LANGUAGE_QUERY
+ : docType !== undefined
+ ? SLUG_FIELD_TRANSLATIONS_TO_LANGUAGE_BY_TYPE_QUERY
: SLUG_FIELD_TRANSLATIONS_TO_LANGUAGE_QUERY,
- queryParams,
+ {
+ slug,
+ language: sourceLanguageId ?? targetLanguageId,
+ ...(docType !== undefined
+ ? {
+ type: docType,
+ }
+ : {}),
+ },
+ {
+ perspective: "published",
+ },
+ );
+}
+
+async function translateSlug(
+ slug: string,
+ targetLanguageId: string,
+ sourceLanguageId?: string,
+ docType?: string,
+ translationType?: "document" | "field",
+ project: "studio" | "shared" = "studio",
+): Promise {
+ const queryClient = project === "studio" ? client : sharedClient;
+ let slugTranslations = null;
+ if (translationType === "document" || translationType === undefined) {
+ slugTranslations = await translateDocumentSlug(
+ queryClient,
+ slug,
+ targetLanguageId,
+ sourceLanguageId,
+ docType,
);
}
- return slugTranslations?._translations.find(
- (translation) =>
- translation !== null && translation.language === targetLanguageId,
- )?.slug;
+ if (
+ translationType === "field" ||
+ (translationType === undefined &&
+ (slugTranslations === null || slugTranslations.length === 0))
+ ) {
+ slugTranslations = await translateFieldSlug(
+ queryClient,
+ slug,
+ targetLanguageId,
+ sourceLanguageId,
+ docType,
+ );
+ }
+ return slugTranslations?.find(
+ (translation) => translation._key === targetLanguageId,
+ )?.value;
+}
+
+async function translateCustomerCasePath(
+ path: string[],
+ targetLanguageId: string,
+ sourceLanguageId?: string,
+): Promise {
+ const pageSlugTranslation = await translateSlug(
+ path[0],
+ targetLanguageId,
+ sourceLanguageId,
+ "customerCasesPage",
+ );
+ if (pageSlugTranslation === undefined) {
+ return undefined;
+ }
+ return translateSlug(
+ path[1],
+ targetLanguageId,
+ sourceLanguageId,
+ "customerCase",
+ "field",
+ "shared",
+ ).then((slug) =>
+ slug !== undefined ? [pageSlugTranslation, slug] : undefined,
+ );
+}
+
+async function translatePath(
+ path: string[],
+ targetLanguageId: string,
+ sourceLanguageId?: string,
+): Promise {
+ if (path.length === 0) {
+ return path;
+ }
+ if (path.length === 1) {
+ return translateSlug(path[0], targetLanguageId, sourceLanguageId).then(
+ (slug) => (slug !== undefined ? [slug] : undefined),
+ );
+ }
+ const pathTranslation = await translateCustomerCasePath(
+ path,
+ targetLanguageId,
+ sourceLanguageId,
+ );
+ if (pathTranslation !== undefined) {
+ return pathTranslation;
+ }
+ return undefined;
}
function negotiateClientLanguage(
@@ -71,7 +190,6 @@ function negotiateClientLanguage(
* - If a page does not exist in the user's preferred language, the system will show the default language version instead of a 404 error.
*
* Limitations/Notes:
- * - No special handling for nested paths (e.g., `/kunder` and `/kunder/nav` are treated as distinct slugs `kunder` and `kunder/nav`).
* - **False friend handling:**
* - For words spelled the same in different languages, but with different meanings (e.g., "gift" in Norwegian and English),
* the system prioritizes the user's preferred language and reroutes to the correct content based on translations.
@@ -100,40 +218,74 @@ export async function languageMiddleware(
console.error("No languages available, language middleware aborted.");
return;
}
- const pathname = request.nextUrl.pathname;
- const language = availableLanguages.find(
- ({ id }) => pathname.startsWith(`/${id}/`) || pathname === `/${id}`,
- );
+ const path = request.nextUrl.pathname.replace(/^\//, "").split("/");
+ const language = availableLanguages.find(({ id }) => path[0] === id);
+ const defaultLanguageId = (
+ await client.fetch(DEFAULT_LANGUAGE_QUERY)
+ )?.id;
+ if (defaultLanguageId === undefined) {
+ console.error(
+ "No default language available, language middleware aborted.",
+ );
+ return;
+ }
if (language === undefined) {
- return redirectMissingLanguage(pathname, availableLanguages, request.url);
+ return redirectMissingLanguage(
+ path,
+ availableLanguages,
+ defaultLanguageId,
+ request.url,
+ );
}
- return redirectWithLanguage(pathname, language, request.url);
+ return redirectWithLanguage(path, language, defaultLanguageId, request.url);
}
/**
- * Language is provided, check that the slug actually exists for the given language.
+ * Language is provided, check that the path actually exists for the given language.
* - If it exists, no changes are made.
- * - Otherwise, we attempt to translate to the specified language
- * - If translated, user is redirected to `/[language]/[translatedSlug]`
- * - Otherwise, user is redirected to the slug with default language (`/[slug]`)
+ * - Otherwise, we attempt to translate to the specified language.
+ * We first check if a translation exists from the default language, before checking other languages.
+ * - If translated, user is redirected to `/[language]/[translatedPath]`
+ * - Otherwise, user is redirected to the path with default language (`/[path]`)
*
- * @param pathname
- * @param language
- * @param baseUrl
+ * @param {string[]} path - The current URL path segments.
+ * @param {LanguageObject} language - Language object from URL path
+ * @param {string} defaultLanguageId - Language id for the default language
+ * @param {string} baseUrl - The base URL of the site.
*/
async function redirectWithLanguage(
- pathname: string,
+ path: string[],
language: LanguageObject,
+ defaultLanguageId: string,
baseUrl: string,
) {
- const slug = pathname.split("/").slice(2).join("/");
- const translatedSlug = await translateSlug(slug, language.id);
- if (translatedSlug === undefined) {
- return NextResponse.redirect(new URL(`/${slug}`, baseUrl));
+ const pathWithoutLanguage = path.slice(1);
+ let translatedPath = await translatePath(
+ pathWithoutLanguage,
+ language.id,
+ language.id,
+ );
+ if (translatedPath === undefined) {
+ // path does not exist for requested language, try default language
+ translatedPath = await translatePath(
+ pathWithoutLanguage,
+ language.id,
+ defaultLanguageId,
+ );
}
- if (translatedSlug !== slug) {
- const translatedPathname = `/${language.id}/${translatedSlug}`;
- return NextResponse.redirect(new URL(translatedPathname, baseUrl));
+ if (translatedPath === undefined) {
+ // path does not exist for requested or default language, try other languages
+ translatedPath = await translatePath(pathWithoutLanguage, language.id);
+ }
+ if (translatedPath === undefined) {
+ return NextResponse.redirect(
+ new URL(`/${pathWithoutLanguage.join("/")}`, baseUrl),
+ );
+ }
+ if (translatedPath.join("/") !== pathWithoutLanguage.join("/")) {
+ return NextResponse.redirect(
+ new URL(`/${language.id}/${translatedPath.join("/")}`, baseUrl),
+ );
}
// all good, no modifications needed
return;
@@ -146,54 +298,51 @@ async function redirectWithLanguage(
* - If the user's preferred (negotiated) language matches the default language, no redirection occurs.
* - If the user's preferred language is different from the default:
* - Attempt to find a translation for the current page.
- * - If a translated version exists, redirect the user to the corresponding path: `/[negotiatedLanguage]/[translatedSlug]`.
- * - If no translation is found, keep the user on the default language page at `/[slug]`.
+ * - If a translated version exists, redirect the user to the corresponding path: `/[negotiatedLanguage]/[translatedPath]`.
+ * - If no translation is found, keep the user on the default language page at `/[path]`.
*
- * @param {string} pathname - The current URL path.
+ * @param {string[]} path - The current URL path segments.
* @param {string[]} availableLanguages - A list of languages supported by the site.
+ * @param {string} defaultLanguageId - Language id for the default language
* @param {string} baseUrl - The base URL of the site.
*/
async function redirectMissingLanguage(
- pathname: string,
+ path: string[],
availableLanguages: LanguageObject[],
+ defaultLanguageId: string,
baseUrl: string,
) {
// Pathname does NOT include a language code, we negotiate with the user to find
// the most preferred languages from the list of available languages
- const defaultLanguageId = (
- await client.fetch(DEFAULT_LANGUAGE_QUERY)
- )?.id;
- if (defaultLanguageId === undefined) {
- console.error(
- "No default language available, language middleware aborted.",
- );
- return;
- }
const preferredLanguage =
negotiateClientLanguage(
availableLanguages.map((language) => language.id),
) ?? defaultLanguageId;
- if (preferredLanguage === defaultLanguageId) {
- // Same as default, simply rewrite internally to include language code
- return NextResponse.rewrite(
- new URL(`/${preferredLanguage}${pathname}`, baseUrl),
- );
- }
- // Attempt to translate to the preferred language
- const slug = pathname.replace(/^\//, "");
- const translatedSlug = await translateSlug(
- slug,
+ // Attempt to translate from default to the preferred language
+ let translatedPath = await translatePath(
+ path,
preferredLanguage,
defaultLanguageId,
);
- if (translatedSlug === undefined) {
+ if (translatedPath === undefined) {
+ // Path not valid for default language, attempt to translate from
+ // a different language to the preferred language
+ translatedPath = await translatePath(path, preferredLanguage);
+ }
+ if (translatedPath === undefined) {
// Translation not available, rewrite to default language
return NextResponse.rewrite(
- new URL(`/${defaultLanguageId}${pathname}`, baseUrl),
+ new URL(`/${defaultLanguageId}/${path.join("/")}`, baseUrl),
+ );
+ }
+ if (preferredLanguage === defaultLanguageId) {
+ // Rewrite to default language and translated path
+ return NextResponse.rewrite(
+ new URL(`/${defaultLanguageId}/${translatedPath}`, baseUrl),
);
}
- // Redirect with language code and translated slug
+ // Redirect with language code and translated path
return NextResponse.redirect(
- new URL(`/${preferredLanguage}/${translatedSlug}`, baseUrl),
+ new URL(`/${preferredLanguage}/${translatedPath}`, baseUrl),
);
}
diff --git a/src/utils/hooks/useLanguage.ts b/src/utils/hooks/useLanguage.ts
deleted file mode 100644
index 6d743eba0..000000000
--- a/src/utils/hooks/useLanguage.ts
+++ /dev/null
@@ -1,123 +0,0 @@
-import { usePathname } from "next/navigation";
-import { useEffect, useState } from "react";
-
-import { fetchWithToken } from "studio/lib/fetchWithToken";
-import { SlugTranslations } from "studio/lib/interfaces/slugTranslations";
-import { LanguageObject } from "studio/lib/interfaces/supportedLanguages";
-import { LANGUAGES_QUERY } from "studio/lib/queries/siteSettings";
-import {
- SLUG_FIELD_TRANSLATIONS_FROM_LANGUAGE_QUERY,
- SLUG_TRANSLATIONS_FROM_LANGUAGE_QUERY,
-} from "studio/lib/queries/slugTranslations";
-
-/**
- * Client hook providing access to the available Sanity translations for the given slug
- *
- * Includes the trivial translation for the current language
- *
- * @param currentLanguage the language of the given slug
- * @param slug slug to translate
- * @param availableLanguages languages with possible slug translation
- */
-function useSlugTranslations(
- currentLanguage: LanguageObject | undefined,
- slug: string,
- availableLanguages: LanguageObject[] | null,
-) {
- const [slugTranslationsData, setSlugTranslationsData] =
- useState(null);
-
- useEffect(() => {
- if (currentLanguage === undefined) {
- return;
- }
- fetchWithToken(
- SLUG_TRANSLATIONS_FROM_LANGUAGE_QUERY,
- {
- slug,
- language: currentLanguage?.id,
- },
- ).then(async (translations) => {
- if (translations !== null) {
- setSlugTranslationsData(translations);
- return null;
- }
- const fieldTranslations = await fetchWithToken(
- SLUG_FIELD_TRANSLATIONS_FROM_LANGUAGE_QUERY,
- {
- slug,
- language: currentLanguage?.id,
- },
- );
- setSlugTranslationsData(fieldTranslations);
- });
- }, [currentLanguage, slug]);
-
- const slugTranslations =
- slug === ""
- ? availableLanguages?.map((lang) => ({
- slug: "",
- language: lang.id,
- }))
- : slugTranslationsData?._translations;
-
- // include full language object, not just id, in slug translation
- return slugTranslations?.map(
- (translation) =>
- translation && {
- slug: `/${translation.language}/${translation.slug}`,
- language: availableLanguages?.find(
- (lang) => lang.id === translation.language,
- ),
- },
- );
-}
-
-/**
- * Client hook providing access to:
- * - the currently selected language from URL
- * - all enabled languages in Sanity
- * - default language selected in Sanity
- * - available translations of the current page slug
- */
-export default function useLanguage() {
- const pathname = usePathname();
-
- const [availableLanguages, setAvailableLanguages] = useState<
- LanguageObject[] | null
- >(null);
-
- const defaultLanguage = availableLanguages?.find(
- (language) => language.default,
- );
-
- const language = availableLanguages?.find(
- ({ id }) => pathname.startsWith(`/${id}/`) || pathname === `/${id}`,
- );
-
- const currentLanguage = language ?? defaultLanguage;
-
- const slug = pathname
- .split("/")
- .slice(language !== undefined ? 2 : 1)
- .join("/");
-
- const slugTranslations = useSlugTranslations(
- currentLanguage,
- slug,
- availableLanguages,
- );
-
- useEffect(() => {
- fetchWithToken(LANGUAGES_QUERY).then((data) =>
- setAvailableLanguages(data),
- );
- }, []);
-
- return {
- language,
- defaultLanguage,
- availableLanguages,
- slugTranslations,
- };
-}
diff --git a/src/utils/pageData.ts b/src/utils/pageData.ts
index d071fbe7e..66894ba53 100644
--- a/src/utils/pageData.ts
+++ b/src/utils/pageData.ts
@@ -3,6 +3,7 @@ import { QueryResponseInitial } from "@sanity/react-loader";
import { CompanyLocation } from "studio/lib/interfaces/companyDetails";
import { CompensationsPage } from "studio/lib/interfaces/compensations";
+import { InternationalizedString } from "studio/lib/interfaces/global";
import { LegalDocument } from "studio/lib/interfaces/legalDocuments";
import { LocaleDocument } from "studio/lib/interfaces/locale";
import { PageBuilder } from "studio/lib/interfaces/pages";
@@ -13,6 +14,11 @@ import {
} from "studio/lib/queries/admin";
import { LOCALE_QUERY } from "studio/lib/queries/locale";
import { PAGE_BY_SLUG_QUERY } from "studio/lib/queries/pages";
+import {
+ SLUG_FIELD_TRANSLATIONS_FROM_LANGUAGE_BY_TYPE_QUERY,
+ SLUG_FIELD_TRANSLATIONS_FROM_LANGUAGE_QUERY,
+ SLUG_TRANSLATIONS_FROM_LANGUAGE_QUERY,
+} from "studio/lib/queries/slugTranslations";
import {
COMPENSATIONS_PAGE_BY_SLUG_QUERY,
CUSTOMER_CASES_PAGE_QUERY,
@@ -32,6 +38,7 @@ import { isNonNullQueryResponse } from "./queryResponse";
type PageFromParams = {
queryResponse: D;
docType: T;
+ pathTranslations: InternationalizedString;
};
async function fetchDynamicPage({
@@ -59,6 +66,7 @@ async function fetchDynamicPage({
return {
queryResponse,
docType: pageBuilderID,
+ pathTranslations: [],
};
}
@@ -107,6 +115,14 @@ async function fetchCompensationsPage({
if (!isNonNullQueryResponse(localeDocumentResult)) {
return null;
}
+ const pathTranslations =
+ await loadStudioQuery(
+ SLUG_FIELD_TRANSLATIONS_FROM_LANGUAGE_QUERY,
+ {
+ slug: path[0],
+ language,
+ },
+ );
return {
queryResponse: {
compensationsPage: compensationsPageResult,
@@ -114,6 +130,7 @@ async function fetchCompensationsPage({
locale: localeDocumentResult,
},
docType: compensationsId,
+ pathTranslations: pathTranslations.data ?? [],
};
}
@@ -141,10 +158,19 @@ async function fetchCustomerCase({
if (!isNonNullQueryResponse(customerCasesPageResult)) {
return null;
}
+ const pagePathTranslations =
+ await loadStudioQuery(
+ SLUG_FIELD_TRANSLATIONS_FROM_LANGUAGE_QUERY,
+ {
+ slug: path[0],
+ language,
+ },
+ );
if (path.length === 1) {
return {
queryResponse: customerCasesPageResult,
docType: customerCasesPageID,
+ pathTranslations: pagePathTranslations.data ?? [],
};
}
const customerCaseResult = await loadSharedQuery(
@@ -160,9 +186,36 @@ async function fetchCustomerCase({
if (!isNonNullQueryResponse(customerCaseResult)) {
return null;
}
+ const casePathTranslations =
+ await loadSharedQuery(
+ SLUG_FIELD_TRANSLATIONS_FROM_LANGUAGE_BY_TYPE_QUERY,
+ {
+ slug: path[1],
+ language,
+ type: customerCaseID,
+ },
+ );
return {
queryResponse: customerCaseResult,
docType: customerCaseID,
+ pathTranslations:
+ casePathTranslations.data?.reduce(
+ (acc, translation) => {
+ const pageSlug = pagePathTranslations.data?.find(
+ (pageTranslation) => pageTranslation._key === translation._key,
+ )?.value;
+ return pageSlug !== undefined
+ ? [
+ ...acc,
+ {
+ _key: translation._key,
+ value: `${pageSlug}/${translation.value}`,
+ },
+ ]
+ : acc;
+ },
+ [],
+ ) ?? [],
};
}
@@ -190,9 +243,18 @@ async function fetchLegalDocument({
if (!isNonNullQueryResponse(queryResponse)) {
return null;
}
+ const pathTranslations =
+ await loadStudioQuery(
+ SLUG_TRANSLATIONS_FROM_LANGUAGE_QUERY,
+ {
+ slug: path[0],
+ language,
+ },
+ );
return {
queryResponse,
docType: legalDocumentID,
+ pathTranslations: pathTranslations.data ?? [],
};
}
diff --git a/studio/lib/interfaces/slugTranslations.ts b/studio/lib/interfaces/slugTranslations.ts
deleted file mode 100644
index 5dc9f221c..000000000
--- a/studio/lib/interfaces/slugTranslations.ts
+++ /dev/null
@@ -1,8 +0,0 @@
-export interface SlugTranslation {
- language: string;
- slug: string;
-}
-
-export interface SlugTranslations {
- _translations: (SlugTranslation | null)[];
-}
diff --git a/studio/lib/queries/slugTranslations.ts b/studio/lib/queries/slugTranslations.ts
index 2e29fe621..ea501739a 100644
--- a/studio/lib/queries/slugTranslations.ts
+++ b/studio/lib/queries/slugTranslations.ts
@@ -3,45 +3,61 @@ import { groq } from "next-sanity";
import { translatedFieldFragment } from "./utils/i18n";
export const SLUG_FIELD_TRANSLATIONS_FROM_LANGUAGE_QUERY = groq`
- *[${translatedFieldFragment("slug")} == $slug][0] {
- "_translations": slug[] {
- "language": _key,
- "slug": value
- }
+ *[${translatedFieldFragment("slug")} == $slug][0].slug[] {
+ _key,
+ value
+ }
+`;
+
+export const SLUG_FIELD_TRANSLATIONS_FROM_LANGUAGE_BY_TYPE_QUERY = groq`
+ *[${translatedFieldFragment("slug")} == $slug && _type == $type][0].slug[] {
+ _key,
+ value
}
`;
export const SLUG_TRANSLATIONS_FROM_LANGUAGE_QUERY = groq`
- *[slug.current == $slug && language == $language][0]{
- "_translations": *[
- _type == "translation.metadata"
- && references(^._id)
- ].translations[].value->{
- language,
- "slug": slug.current
+ *[_type == "translation.metadata"
+ && references(*[slug.current == $slug && language == $language][0]._id)].translations[].value->{
+ "_key": language,
+ "value": slug.current
+ }
+`;
+
+export const SLUG_TRANSLATIONS_FROM_LANGUAGE_BY_TYPE_QUERY = groq`
+ *[_type == "translation.metadata"
+ && references(*[slug.current == $slug && language == $language && _type == $type][0]._id)].translations[].value->{
+ "_key": language,
+ "value": slug.current
}
- }
`;
export const SLUG_FIELD_TRANSLATIONS_TO_LANGUAGE_QUERY = groq`
- *[defined(slug[value == $slug][0])][0] {
- "_translations": (slug[] {
- "language": _key,
- "slug": value
- })[language == $language]
+ *[defined(slug[value == $slug][0])][0].slug[_key == $language] {
+ _key,
+ value
}
`;
-export const SLUG_TRANSLATIONS_TO_LANGUAGE_QUERY = groq`
- *[slug.current == $slug][0]{
- "_translations": (
- *[
- _type == "translation.metadata"
- && references(^._id)
- ].translations[].value->{
- language,
- "slug": slug.current
- }
- )[language == $language]
+export const SLUG_FIELD_TRANSLATIONS_TO_LANGUAGE_BY_TYPE_QUERY = groq`
+ *[defined(slug[value == $slug][0]) && _type == $type][0].slug[_key == $language] {
+ _key,
+ value
}
`;
+
+export const SLUG_TRANSLATIONS_TO_LANGUAGE_QUERY = groq`
+ *[_type == "translation.metadata"
+ && references(*[slug.current == $slug][0]._id)].translations[_key == $language].value->{
+ "_key": language,
+ "value": slug.current
+ }
+`;
+
+export const SLUG_TRANSLATIONS_TO_LANGUAGE_BY_TYPE_QUERY = groq`
+ *[_type == "translation.metadata"
+ && references(*[slug.current == $slug && _type == $type][0]._id)].translations[_key == $language].value->{
+ "_key": language,
+ "value": slug.current
+ }
+`;