diff --git a/.storybook/preview.tsx b/.storybook/preview.tsx
index 860df806d..d513c5f49 100644
--- a/.storybook/preview.tsx
+++ b/.storybook/preview.tsx
@@ -5,7 +5,7 @@ import "../src/styles/global.css";
import localFont from "next/font/local";
const fontRecoleta = localFont({
- src: "../../public/recoleta.otf",
+ src: "../../public/_assets/recoleta.otf",
variable: "--font-recoleta",
});
diff --git a/README.md b/README.md
index cff16731d..a00191d92 100644
--- a/README.md
+++ b/README.md
@@ -186,6 +186,10 @@ By using fetchWithToken, you ensure that all data fetching happens securely, wit
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.
+### Serving files from `/public`
+
+As part of the Next.js middlewares setup, a [matcher config](src/middleware.ts) is used to ignore certain paths. In most cases, static files stored in the `/public` folder should not be passed through the middlewares. However, defining a regex that excludes all files from this folder can [get messy and risks excluding too many paths](https://github.com/vercel/next.js/discussions/36308#discussioncomment-9913288). To make the regex matcher cleaner, it is encouraged that no files are placed directly in `/public`. Instead, subfolders (e.g. `/public/_assets`) should be defined and then excluded explicitly. Just make sure to not collide with other router paths (prefixing with `_` reduces this risk).
+
### OpenGraph image customization
As part of providing the basic metadata for the [OpenGraph Protocol](https://ogp.me), a fallback image is generated if no other is specified. Fonts and background can be customized as shown below.
diff --git a/internationalization/languageSchemaField.ts b/internationalization/languageSchemaField.ts
index 2cd228998..2c6640b74 100644
--- a/internationalization/languageSchemaField.ts
+++ b/internationalization/languageSchemaField.ts
@@ -4,7 +4,7 @@ export const languageID = "language";
const languageSchemaField = defineField({
name: languageID,
- title: "Langauge",
+ title: "Language",
description:
"Select the language for this document from the translation menu. This field reflects the chosen language and is set automatically based on your selection.",
type: "string",
diff --git a/package-lock.json b/package-lock.json
index a3f9fc256..ab451eb8e 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -14,6 +14,8 @@
"@sanity/preview-url-secret": "^1.6.11",
"@sanity/react-loader": "^1.9.15",
"@sanity/vision": "^3.39.1",
+ "@types/negotiator": "^0.6.3",
+ "negotiator": "^0.6.3",
"next": "^14.2.13",
"next-sanity": "^7.1.4",
"next-sanity-image": "^6.1.1",
@@ -7888,6 +7890,12 @@
"integrity": "sha512-hov8bUuiLiyFPGyFPE1lwWhmzYbirOXQNNo40+y3zow8aFVTeyn3VWL0VFFfdNddA8S4Vf0Tc062rzyNr7Paag==",
"license": "MIT"
},
+ "node_modules/@types/negotiator": {
+ "version": "0.6.3",
+ "resolved": "https://registry.npmjs.org/@types/negotiator/-/negotiator-0.6.3.tgz",
+ "integrity": "sha512-JkXTOdKs5MF086b/pt8C3+yVp3iDUwG635L7oCH6HvJvvr6lSUU5oe/gLXnPEfYRROHjJIPgCV6cuAg8gGkntQ==",
+ "license": "MIT"
+ },
"node_modules/@types/node": {
"version": "20.16.5",
"resolved": "https://registry.npmjs.org/@types/node/-/node-20.16.5.tgz",
@@ -20387,7 +20395,6 @@
"version": "0.6.3",
"resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz",
"integrity": "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==",
- "dev": true,
"license": "MIT",
"engines": {
"node": ">= 0.6"
diff --git a/package.json b/package.json
index 0529d71e6..dd291d392 100644
--- a/package.json
+++ b/package.json
@@ -20,6 +20,8 @@
"@sanity/preview-url-secret": "^1.6.11",
"@sanity/react-loader": "^1.9.15",
"@sanity/vision": "^3.39.1",
+ "@types/negotiator": "^0.6.3",
+ "negotiator": "^0.6.3",
"next": "^14.2.13",
"next-sanity": "^7.1.4",
"next-sanity-image": "^6.1.1",
diff --git a/public/alert-circle.svg b/public/_assets/alert-circle.svg
similarity index 100%
rename from public/alert-circle.svg
rename to public/_assets/alert-circle.svg
diff --git a/public/arrow-back.svg b/public/_assets/arrow-back.svg
similarity index 100%
rename from public/arrow-back.svg
rename to public/_assets/arrow-back.svg
diff --git a/public/arrow.svg b/public/_assets/arrow.svg
similarity index 100%
rename from public/arrow.svg
rename to public/_assets/arrow.svg
diff --git a/public/check-circle.svg b/public/_assets/check-circle.svg
similarity index 100%
rename from public/check-circle.svg
rename to public/_assets/check-circle.svg
diff --git a/public/menu-close.svg b/public/_assets/menu-close.svg
similarity index 100%
rename from public/menu-close.svg
rename to public/_assets/menu-close.svg
diff --git a/public/menu.svg b/public/_assets/menu.svg
similarity index 100%
rename from public/menu.svg
rename to public/_assets/menu.svg
diff --git a/public/recoleta.otf b/public/_assets/recoleta.otf
similarity index 100%
rename from public/recoleta.otf
rename to public/_assets/recoleta.otf
diff --git a/public/sharedStudioIcon.png b/public/_assets/sharedStudioIcon.png
similarity index 100%
rename from public/sharedStudioIcon.png
rename to public/_assets/sharedStudioIcon.png
diff --git a/public/spinner.svg b/public/_assets/spinner.svg
similarity index 100%
rename from public/spinner.svg
rename to public/_assets/spinner.svg
diff --git a/public/studioIcon.png b/public/_assets/studioIcon.png
similarity index 100%
rename from public/studioIcon.png
rename to public/_assets/studioIcon.png
diff --git a/public/arrow-right.svg b/public/arrow-right.svg
deleted file mode 100644
index 01d388b6c..000000000
--- a/public/arrow-right.svg
+++ /dev/null
@@ -1,4 +0,0 @@
-
\ No newline at end of file
diff --git a/public/arrow-test.svg b/public/arrow-test.svg
deleted file mode 100644
index 0eff6413d..000000000
--- a/public/arrow-test.svg
+++ /dev/null
@@ -1,4 +0,0 @@
-
diff --git a/public/next.svg b/public/next.svg
deleted file mode 100644
index 5174b28c5..000000000
--- a/public/next.svg
+++ /dev/null
@@ -1 +0,0 @@
-
\ No newline at end of file
diff --git a/public/vercel.svg b/public/vercel.svg
deleted file mode 100644
index d2f842227..000000000
--- a/public/vercel.svg
+++ /dev/null
@@ -1 +0,0 @@
-
\ No newline at end of file
diff --git a/src/app/(main)/[slug]/[id]/page.tsx b/src/app/(main)/[lang]/[slug]/[id]/page.tsx
similarity index 100%
rename from src/app/(main)/[slug]/[id]/page.tsx
rename to src/app/(main)/[lang]/[slug]/[id]/page.tsx
diff --git a/src/app/(main)/[slug]/page.tsx b/src/app/(main)/[lang]/[slug]/page.tsx
similarity index 85%
rename from src/app/(main)/[slug]/page.tsx
rename to src/app/(main)/[lang]/[slug]/page.tsx
index fe3494a52..435c106a6 100644
--- a/src/app/(main)/[slug]/page.tsx
+++ b/src/app/(main)/[lang]/[slug]/page.tsx
@@ -3,6 +3,8 @@ import { Metadata } from "next";
import { Blog } from "src/blog/Blog";
import BlogPreview from "src/blog/BlogPreview";
import CustomErrorMessage from "src/blog/components/customErrorMessage/CustomErrorMessage";
+import Legal from "src/blog/components/legal/Legal";
+import LegalPreview from "src/blog/components/legal/LegalPreview";
import { homeLink } from "src/blog/components/utils/linkTypes";
import Compensations from "src/compensations/Compensations";
import CompensationsPreview from "src/compensations/CompensationsPreview";
@@ -13,9 +15,11 @@ import SectionRenderer from "src/utils/renderSection";
import { fetchSeoData, generateMetadataFromSeo } from "src/utils/seo";
import { CompanyLocation } from "studio/lib/interfaces/companyDetails";
import { CompensationsPage } from "studio/lib/interfaces/compensations";
+import { LegalDocument } from "studio/lib/interfaces/legalDocuments";
import { BlogPage, PageBuilder, Post } from "studio/lib/interfaces/pages";
import { CustomerCasePage } from "studio/lib/interfaces/specialPages";
import { COMPANY_LOCATIONS_QUERY } from "studio/lib/queries/companyDetails";
+import { LEGAL_DOCUMENTS_BY_SLUG_AND_LANG_QUERY } from "studio/lib/queries/legalDocuments";
import {
BLOG_PAGE_QUERY,
POSTS_QUERY,
@@ -31,9 +35,7 @@ import { loadStudioQuery } from "studio/lib/store";
export const dynamic = "force-dynamic";
type Props = {
- params: {
- slug: string;
- };
+ params: { lang: string; slug: string };
};
export async function generateMetadata({ params }: Props): Promise {
@@ -51,7 +53,8 @@ const Page404 = (
);
async function Page({ params }: Props) {
- const { slug } = params;
+ const { lang, slug } = params;
+
const { perspective, isDraftMode } = getDraftModeInfo();
const [
@@ -60,6 +63,7 @@ async function Page({ params }: Props) {
initialCompensationsPage,
initialLocationsData,
initialCustomerCases,
+ initialLegalDocument,
] = await Promise.all([
loadStudioQuery(SLUG_QUERY, { slug }, { perspective }),
loadStudioQuery(BLOG_PAGE_QUERY, { slug }, { perspective }),
@@ -78,6 +82,11 @@ async function Page({ params }: Props) {
{ slug },
{ perspective },
),
+ loadStudioQuery(
+ LEGAL_DOCUMENTS_BY_SLUG_AND_LANG_QUERY,
+ { slug, language: lang },
+ { perspective },
+ ),
]);
if (initialPage.data) {
@@ -145,6 +154,14 @@ async function Page({ params }: Props) {
);
}
+ if (initialLegalDocument.data) {
+ return isDraftMode ? (
+
+ ) : (
+
+ );
+ }
+
return Page404;
}
diff --git a/src/app/(main)/page.tsx b/src/app/(main)/[lang]/page.tsx
similarity index 75%
rename from src/app/(main)/page.tsx
rename to src/app/(main)/[lang]/page.tsx
index f22e0228b..eb1c72acc 100644
--- a/src/app/(main)/page.tsx
+++ b/src/app/(main)/[lang]/page.tsx
@@ -1,11 +1,16 @@
import { Metadata } from "next";
+import CustomErrorMessage from "src/blog/components/customErrorMessage/CustomErrorMessage";
import InformationSection from "src/blog/components/informationSection/InformationSection";
+import { homeLink } from "src/blog/components/utils/linkTypes";
import { getDraftModeInfo } from "src/utils/draftmode";
import SectionRenderer from "src/utils/renderSection";
import { fetchSeoData, generateMetadataFromSeo } from "src/utils/seo";
+import { client } from "studio/lib/client";
import { LinkType } from "studio/lib/interfaces/navigation";
import { PageBuilder } from "studio/lib/interfaces/pages";
+import { LanguageObject } from "studio/lib/interfaces/supportedLanguages";
+import { LANGUAGES_QUERY } from "studio/lib/queries/languages";
import { LANDING_PAGE_REF_QUERY } from "studio/lib/queries/navigation";
import { PAGE_QUERY, SEO_PAGE_QUERY } from "studio/lib/queries/page";
import { loadStudioQuery } from "studio/lib/store";
@@ -34,9 +39,29 @@ const pagesLink = {
internalLink: { _ref: "studio/structure/pages" },
};
-const Home = async () => {
+type Props = {
+ params: { lang: string; slug: string };
+};
+
+const Page404 = (
+
+);
+
+const Home = async ({ params }: Props) => {
const { perspective, isDraftMode } = getDraftModeInfo();
+ const language = (
+ await client.fetch(LANGUAGES_QUERY)
+ )?.find((l) => l.id === params.lang);
+
+ if (language === undefined) {
+ return Page404;
+ }
+
const { data: landingId } = await loadStudioQuery(
LANDING_PAGE_REF_QUERY,
{},
diff --git a/src/app/(main)/legal/[id]/page.tsx b/src/app/(main)/legal/[id]/page.tsx
deleted file mode 100644
index efeda4d0f..000000000
--- a/src/app/(main)/legal/[id]/page.tsx
+++ /dev/null
@@ -1,41 +0,0 @@
-import Legal from "src/blog/components/legal/Legal";
-import LegalPreview from "src/blog/components/legal/LegalPreview";
-import { getDraftModeInfo } from "src/utils/draftmode";
-import { LegalDocument } from "studio/lib/interfaces/legalDocuments";
-import { LEGAL_DOCUMENTS_BY_SLUG_AND_LANG_QUERY } from "studio/lib/queries/legalDocuments";
-import { loadStudioQuery } from "studio/lib/store";
-
-export const dynamic = "force-dynamic";
-
-type Props = {
- params: {
- id: string;
- };
-};
-
-// TODO: hide from SEO???
-
-async function Page({ params }: Props) {
- const { id } = params;
- const { perspective, isDraftMode } = getDraftModeInfo();
-
- const initialDocument = await loadStudioQuery(
- LEGAL_DOCUMENTS_BY_SLUG_AND_LANG_QUERY,
- { slug: id, language: "en" }, //TODO: replace this with selected language for the page
- { perspective },
- );
-
- if (!initialDocument) {
- throw new Error("Page not found");
- }
-
- if (isDraftMode) {
- return ;
- }
-
- if (initialDocument) {
- return ;
- }
-}
-
-export default Page;
diff --git a/src/app/layout.tsx b/src/app/layout.tsx
index d0d512f34..97ba3788f 100644
--- a/src/app/layout.tsx
+++ b/src/app/layout.tsx
@@ -12,7 +12,7 @@ import { loadStudioQuery } from "studio/lib/store";
import "src/styles/global.css";
const fontRecoleta = localFont({
- src: "../../public/recoleta.otf",
+ src: "../../public/_assets/recoleta.otf",
variable: "--font-recoleta",
});
diff --git a/src/blog/components/loadingNews/loadingNews.module.css b/src/blog/components/loadingNews/loadingNews.module.css
index c3ba0d61e..28d2340b8 100644
--- a/src/blog/components/loadingNews/loadingNews.module.css
+++ b/src/blog/components/loadingNews/loadingNews.module.css
@@ -28,8 +28,8 @@
display: block;
width: 1.5rem;
height: 1.5rem;
- -webkit-mask: url("/spinner.svg") no-repeat 50% 50%;
- mask: url("/spinner.svg") no-repeat 50% 50%;
+ -webkit-mask: url("/_assets/spinner.svg") no-repeat 50% 50%;
+ mask: url("/_assets/spinner.svg") no-repeat 50% 50%;
background-color: var(--primary-black);
animation: rotateSpinner 750ms linear infinite;
}
diff --git a/src/components/buttons/button.module.css b/src/components/buttons/button.module.css
index a702559c3..0afd1aab7 100644
--- a/src/components/buttons/button.module.css
+++ b/src/components/buttons/button.module.css
@@ -72,7 +72,7 @@
content: "";
width: 1.5rem;
height: 1.5rem;
- -webkit-mask: url("/arrow-back.svg") no-repeat 50% 50%;
+ -webkit-mask: url("/_assets/arrow-back.svg") no-repeat 50% 50%;
background-color: var(--primary-black);
}
}
@@ -83,8 +83,8 @@
display: block;
width: 1.5rem;
height: 1.5rem;
- -webkit-mask: url("/spinner.svg") no-repeat 50% 50%;
- mask: url("/spinner.svg") no-repeat 50% 50%;
+ -webkit-mask: url("/_assets/spinner.svg") no-repeat 50% 50%;
+ mask: url("/_assets/spinner.svg") no-repeat 50% 50%;
background-color: var(--primary-black);
animation: rotateSpinner 750ms linear infinite;
}
diff --git a/src/components/forms/checkbox/checkbox.module.css b/src/components/forms/checkbox/checkbox.module.css
index 334fffe97..ccba4c15e 100644
--- a/src/components/forms/checkbox/checkbox.module.css
+++ b/src/components/forms/checkbox/checkbox.module.css
@@ -72,7 +72,7 @@
display: inline-block;
-webkit-mask-size: cover;
background-color: var(--primary-red-error);
- -webkit-mask: url("/alert-circle.svg") no-repeat 50% 50%;
+ -webkit-mask: url("/_assets/alert-circle.svg") no-repeat 50% 50%;
}
@media (min-height: 1024px) {
diff --git a/src/components/forms/inputField/inputField.module.css b/src/components/forms/inputField/inputField.module.css
index 442bfc3ec..7bc743bf0 100644
--- a/src/components/forms/inputField/inputField.module.css
+++ b/src/components/forms/inputField/inputField.module.css
@@ -35,7 +35,7 @@
display: inline-block;
-webkit-mask-size: cover;
background-color: var(--primary-red-error);
- -webkit-mask: url("/alert-circle.svg") no-repeat 50% 50%;
+ -webkit-mask: url("/_assets/alert-circle.svg") no-repeat 50% 50%;
}
@media (min-height: 1024px) {
diff --git a/src/components/forms/inputTextArea/inputTextArea.module.css b/src/components/forms/inputTextArea/inputTextArea.module.css
index cd517e834..f24784123 100644
--- a/src/components/forms/inputTextArea/inputTextArea.module.css
+++ b/src/components/forms/inputTextArea/inputTextArea.module.css
@@ -36,7 +36,7 @@
display: inline-block;
-webkit-mask-size: cover;
background-color: var(--primary-red-error);
- -webkit-mask: url("/alert-circle.svg") no-repeat 50% 50%;
+ -webkit-mask: url("/_assets/alert-circle.svg") no-repeat 50% 50%;
}
@media (min-height: 1024px) {
diff --git a/src/components/link/CustomLink.tsx b/src/components/link/CustomLink.tsx
index 7bdb6d3b5..0d7334473 100644
--- a/src/components/link/CustomLink.tsx
+++ b/src/components/link/CustomLink.tsx
@@ -16,6 +16,9 @@ interface ICustomLink {
const CustomLink = ({ type = "link", link, isSelected }: ICustomLink) => {
const linkTitle = link.linkTitle;
+ // TODO: pick title based on selected language
+ const linkTitleValue =
+ (typeof linkTitle === "string" ? linkTitle : linkTitle[0].value) ?? "";
const href = getHref(link);
const newTab = link.newTab;
const target = newTab ? "_blank" : undefined;
@@ -34,7 +37,7 @@ const CustomLink = ({ type = "link", link, isSelected }: ICustomLink) => {
rel={rel}
aria-label={link.ariaLabel}
>
- {linkTitle}
+ {linkTitleValue}
@@ -46,7 +49,7 @@ const CustomLink = ({ type = "link", link, isSelected }: ICustomLink) => {
rel={rel}
aria-label={link.ariaLabel}
>
- {linkTitle}
+ {linkTitleValue}
);
};
diff --git a/src/components/link/link.module.css b/src/components/link/link.module.css
index d8e5636e8..094eefe59 100644
--- a/src/components/link/link.module.css
+++ b/src/components/link/link.module.css
@@ -44,7 +44,7 @@
display: inline-block;
-webkit-mask-size: cover;
background-color: var(--primary-black);
- -webkit-mask: url("/arrow.svg") no-repeat 50% 50%;
+ -webkit-mask: url("/_assets/arrow.svg") no-repeat 50% 50%;
}
}
diff --git a/src/components/linkButton/LinkButton.tsx b/src/components/linkButton/LinkButton.tsx
index 13ad947bf..09f4a67a7 100644
--- a/src/components/linkButton/LinkButton.tsx
+++ b/src/components/linkButton/LinkButton.tsx
@@ -21,10 +21,15 @@ const typeClassMap: { [key in LinkButtonType]: string } = {
const LinkButton = ({ isSmall, type = "primary", link }: IButton) => {
const className = `${styles.button} ${isSmall ? styles.small : ""} ${typeClassMap[type]}`;
const href = getHref(link);
+ const linkTitle = link.linkTitle;
+ // TODO: pick title based on selected language
+ const linkTitleValue =
+ typeof linkTitle === "string" ? linkTitle : linkTitle[0].value;
return (
- href && (
+ href &&
+ linkTitleValue && (
- {link.linkTitle}
+ {linkTitleValue}
)
);
diff --git a/src/components/linkButton/linkButton.module.css b/src/components/linkButton/linkButton.module.css
index ba80bb09c..eb95c3269 100644
--- a/src/components/linkButton/linkButton.module.css
+++ b/src/components/linkButton/linkButton.module.css
@@ -32,9 +32,9 @@
width: 1.5rem;
height: 1.5rem;
content: "";
- -webkit-mask: url("/arrow.svg") no-repeat center;
+ -webkit-mask: url("/_assets/arrow.svg") no-repeat center;
-webkit-mask-size: cover;
- mask: url("/arrow.svg") no-repeat center;
+ mask: url("/_assets/arrow.svg") no-repeat center;
mask-size: cover;
}
}
diff --git a/src/components/navigation/footer/Footer.tsx b/src/components/navigation/footer/Footer.tsx
index 8851dd57e..2d227bd8e 100644
--- a/src/components/navigation/footer/Footer.tsx
+++ b/src/components/navigation/footer/Footer.tsx
@@ -52,14 +52,13 @@ const Footer = ({
{legalData?.map((legal) => {
- const path = `legal/${legal.slug.current}`;
const link = {
_key: legal._id,
_type: legal._type,
linkTitle: legal.basicTitle,
linkType: LinkType.Internal,
internalLink: {
- _ref: path,
+ _ref: legal.slug.current,
},
};
return (
diff --git a/src/components/navigation/header/header.module.css b/src/components/navigation/header/header.module.css
index ae71218d8..181627a8b 100644
--- a/src/components/navigation/header/header.module.css
+++ b/src/components/navigation/header/header.module.css
@@ -138,10 +138,10 @@
.closed {
composes: button;
- background: url("/menu.svg") no-repeat 50% 50%;
+ background: url("/_assets/menu.svg") no-repeat 50% 50%;
}
.open {
composes: button;
- background: url("/menu-close.svg") no-repeat 50% 50%;
+ background: url("/_assets/menu-close.svg") no-repeat 50% 50%;
}
diff --git a/src/components/sections/contactForm/contactForm.module.css b/src/components/sections/contactForm/contactForm.module.css
index 2293d1521..efa5b149f 100644
--- a/src/components/sections/contactForm/contactForm.module.css
+++ b/src/components/sections/contactForm/contactForm.module.css
@@ -69,7 +69,7 @@
color: var(--primary-red-error);
&::before {
background-color: var(--primary-red-error);
- -webkit-mask: url("/alert-circle.svg") no-repeat 50% 50%;
+ -webkit-mask: url("/_assets/alert-circle.svg") no-repeat 50% 50%;
}
}
@@ -78,6 +78,6 @@
color: var(--primary-dark);
&::before {
background-color: var(--primary-dark);
- -webkit-mask: url("/check-circle.svg") no-repeat 50% 50%;
+ -webkit-mask: url("/_assets/check-circle.svg") no-repeat 50% 50%;
}
}
diff --git a/src/middleware.ts b/src/middleware.ts
index f639dc33c..cd98a0682 100644
--- a/src/middleware.ts
+++ b/src/middleware.ts
@@ -1,58 +1,28 @@
-import { NextRequest, NextResponse } from "next/server";
+import { NextRequest } from "next/server";
-import { RedirectDestinationSlugPage } from "studio/lib/interfaces/redirect";
-import { REDIRECT_BY_SOURCE_SLUG_QUERY } from "studio/lib/queries/redirects";
-
-import { readBaseUrl } from "./app/env";
-import { HTTP_STATUSES } from "./utils/http";
+import { languageMiddleware } from "./middlewares/languageMiddleware";
+import { redirectMiddleware } from "./middlewares/redirectMiddleware";
export async function middleware(request: NextRequest) {
- const baseUrlResult = readBaseUrl();
- if (!baseUrlResult.ok) {
- console.error(
- "Failed to run middleware, missing base url:",
- baseUrlResult.error,
- );
- return;
- }
- const baseUrl = baseUrlResult.value;
- const slug = request.nextUrl.pathname;
- const slugQueryParam = slug.replace(/^\/+/, "");
- /*
- fetching redirect data via API route to avoid token leaking to client
- (middleware should run on server, but `experimental_taintUniqueValue` begs to differ...)
- */
- const redirectRes = await fetch(new URL("/api/fetchData", baseUrl), {
- method: "POST",
- headers: {
- "Content-Type": "application/json",
- },
- body: JSON.stringify({
- query: REDIRECT_BY_SOURCE_SLUG_QUERY,
- params: { slug: slugQueryParam },
- }),
- });
- if (redirectRes.ok) {
- const redirect: RedirectDestinationSlugPage | null =
- await redirectRes.json();
- if (redirect?.destination) {
- return NextResponse.redirect(
- new URL(redirect.destination, request.url),
- HTTP_STATUSES.TEMPORARY_REDIRECT,
- );
- }
+ const response = await redirectMiddleware(request);
+ if (response !== undefined) {
+ return response;
}
+ return languageMiddleware(request);
}
export const config = {
matcher: [
/*
- * Match all request paths except for the ones starting with:
+ * Exclude request paths starting with:
* - api (API routes)
+ * - sw.js (Service Worker)
* - _next/static (static files)
* - _next/image (image optimization files)
+ * - _assets (asset files)
* - favicon.ico, sitemap.xml, robots.txt (metadata files)
+ * - studio, shared (Sanity studios)
*/
- "/((?!api|_next/static|_next/image|favicon.ico|sitemap.xml|robots.txt).*)",
+ "/((?!api|sw.js|_next/static|_next/image|_assets|favicon.ico|sitemap.xml|robots.txt|studio|shared).*)",
],
};
diff --git a/src/middlewares/languageMiddleware.ts b/src/middlewares/languageMiddleware.ts
new file mode 100644
index 000000000..3fca40f71
--- /dev/null
+++ b/src/middlewares/languageMiddleware.ts
@@ -0,0 +1,186 @@
+import Negotiator from "negotiator";
+import { headers } from "next/headers";
+import { NextRequest, NextResponse } from "next/server";
+
+import { client } from "studio/lib/client";
+import { SlugTranslations } from "studio/lib/interfaces/slugTranslations";
+import { LanguageObject } from "studio/lib/interfaces/supportedLanguages";
+import {
+ DEFAULT_LANGUAGE_QUERY,
+ LANGUAGES_QUERY,
+} from "studio/lib/queries/languages";
+import {
+ SLUG_TRANSLATIONS_FROM_LANGUAGE_QUERY,
+ SLUG_TRANSLATIONS_TO_LANGUAGE_QUERY,
+} from "studio/lib/queries/slugTranslations";
+
+async function translateSlug(
+ slug: string,
+ targetLanguageId: string,
+ sourceLanguageId?: string,
+): Promise {
+ if (slug.length === 0) {
+ return slug;
+ }
+ const slugTranslations = await client.fetch(
+ sourceLanguageId !== undefined
+ ? SLUG_TRANSLATIONS_FROM_LANGUAGE_QUERY
+ : SLUG_TRANSLATIONS_TO_LANGUAGE_QUERY,
+ {
+ slug,
+ language: sourceLanguageId ?? targetLanguageId,
+ },
+ );
+ return slugTranslations?._translations.find(
+ (translation) =>
+ translation !== null && translation.language === targetLanguageId,
+ )?.slug;
+}
+
+function negotiateClientLanguage(
+ availableLanguages: string[],
+): string | undefined {
+ const acceptLanguage = headers().get("Accept-Language");
+ if (acceptLanguage === null) return undefined;
+ return new Negotiator({
+ // Negotiator expects lower-case header name
+ headers: { "accept-language": acceptLanguage },
+ }).language(availableLanguages);
+}
+
+/**
+ * Handles rerouting of paths based on the language code in the URL or the user's preferred language settings,
+ * with special emphasis on cases where words are spelled the same in both Norwegian and English but have different meanings.
+ *
+ * Key behaviors:
+ * - Reroutes users to the appropriate page based on their language preferences, especially for words that
+ * are identical in spelling across languages but have different meanings (false friends).
+ * - 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., `/blogg` and `/blogg/post` are treated as distinct slugs `blogg` and `blogg/post`).
+ * - **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.
+ * - Example scenario:
+ * - Given the following pages:
+ * - (1) `/gift` (Norwegian page for "poison")
+ * - (2) `/en/poison` (English page for "poison")
+ * - (3) `/en/gift` (English page for "present")
+ * - If a user with English preferences visits (1) `/gift`, they will be rerouted to (2) `/en/poison` because
+ * the system prioritizes translated content over slug matching, despite identical slugs in (1) and (3).
+ * - If the same user visits (3) `/en/gift`, they will be directed to the English page for "present".
+ *
+ * - **Default language fallback:**
+ * - If a user visits a page like `/eple` and no translation exists in their preferred language (e.g., `/en/apple`),
+ * they will see the default language version (`/eple`) instead of receiving a 404 error.
+ *
+ * @param {Object} request - The incoming request object containing URL and user preferences.
+ */
+export async function languageMiddleware(
+ request: NextRequest,
+): Promise {
+ const availableLanguages = await client.fetch(
+ LANGUAGES_QUERY,
+ );
+ if (availableLanguages === null) {
+ console.error("No languages available, language middleware aborted.");
+ return;
+ }
+ const pathname = request.nextUrl.pathname;
+ const language = availableLanguages.find(
+ ({ id }) => pathname.startsWith(`/${id}/`) || pathname === `/${id}`,
+ );
+ if (language === undefined) {
+ return redirectMissingLanguage(pathname, availableLanguages, request.url);
+ }
+ return redirectWithLanguage(pathname, language, request.url);
+}
+
+/**
+ * Language is provided, check that the slug 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]`)
+ *
+ * @param pathname
+ * @param language
+ * @param baseUrl
+ */
+async function redirectWithLanguage(
+ pathname: string,
+ language: LanguageObject,
+ 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));
+ }
+ if (translatedSlug !== slug) {
+ const translatedPathname = `/${language.id}/${translatedSlug}`;
+ return NextResponse.redirect(new URL(translatedPathname, baseUrl));
+ }
+ // all good, no modifications needed
+ return;
+}
+
+/**
+ * Determines the user's most preferred language from the available languages and handles URL redirection accordingly.
+ *
+ * Key behaviors:
+ * - 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]`.
+ *
+ * @param {string} pathname - The current URL path.
+ * @param {string[]} availableLanguages - A list of languages supported by the site.
+ * @param {string} baseUrl - The base URL of the site.
+ */
+async function redirectMissingLanguage(
+ pathname: string,
+ availableLanguages: LanguageObject[],
+ 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,
+ preferredLanguage,
+ defaultLanguageId,
+ );
+ if (translatedSlug === undefined) {
+ // Translation not available, rewrite to default language
+ return NextResponse.rewrite(
+ new URL(`/${defaultLanguageId}${pathname}`, baseUrl),
+ );
+ }
+ // Redirect with language code and translated slug
+ return NextResponse.redirect(
+ new URL(`/${preferredLanguage}/${translatedSlug}`, baseUrl),
+ );
+}
diff --git a/src/middlewares/redirectMiddleware.ts b/src/middlewares/redirectMiddleware.ts
new file mode 100644
index 000000000..5715ca576
--- /dev/null
+++ b/src/middlewares/redirectMiddleware.ts
@@ -0,0 +1,46 @@
+import { NextRequest, NextResponse } from "next/server";
+
+import { readBaseUrl } from "src/app/env";
+import { HTTP_STATUSES } from "src/utils/http";
+import { RedirectDestinationSlugPage } from "studio/lib/interfaces/redirect";
+import { REDIRECT_BY_SOURCE_SLUG_QUERY } from "studio/lib/queries/redirects";
+
+export async function redirectMiddleware(
+ request: NextRequest,
+): Promise {
+ const baseUrlResult = readBaseUrl();
+ if (!baseUrlResult.ok) {
+ console.error(
+ "Failed to run redirect middleware, missing base url:",
+ baseUrlResult.error,
+ );
+ return;
+ }
+ const baseUrl = baseUrlResult.value;
+ const slug = request.nextUrl.pathname;
+ const slugQueryParam = slug.replace(/^\/+/, "");
+ /*
+ fetching redirect data via API route to avoid token leaking to client
+ (middleware should run on server, but `experimental_taintUniqueValue` begs to differ...)
+ */
+ const redirectRes = await fetch(new URL("/api/fetchData", baseUrl), {
+ method: "POST",
+ headers: {
+ "Content-Type": "application/json",
+ },
+ body: JSON.stringify({
+ query: REDIRECT_BY_SOURCE_SLUG_QUERY,
+ params: { slug: slugQueryParam },
+ }),
+ });
+ if (redirectRes.ok) {
+ const redirect: RedirectDestinationSlugPage | null =
+ await redirectRes.json();
+ if (redirect?.destination) {
+ return NextResponse.redirect(
+ new URL(redirect.destination, request.url),
+ HTTP_STATUSES.TEMPORARY_REDIRECT,
+ );
+ }
+ }
+}
diff --git a/studio/components/studioIcon/StudioIcon.tsx b/studio/components/studioIcon/StudioIcon.tsx
index 3b524832e..572847603 100644
--- a/studio/components/studioIcon/StudioIcon.tsx
+++ b/studio/components/studioIcon/StudioIcon.tsx
@@ -9,7 +9,11 @@ const StudioIcon = ({ variant }: { variant: "studio" | "shared" }) => {
width={540}
height={540}
className={styles.icon}
- src={variant === "studio" ? "/studioIcon.png" : "/sharedStudioIcon.png"}
+ src={
+ variant === "studio"
+ ? "/_assets/studioIcon.png"
+ : "/_assets/sharedStudioIcon.png"
+ }
/>
);
};
diff --git a/studio/lib/interfaces/global.ts b/studio/lib/interfaces/global.ts
index 7b51d3dcb..6bc8dc31c 100644
--- a/studio/lib/interfaces/global.ts
+++ b/studio/lib/interfaces/global.ts
@@ -7,3 +7,10 @@ export interface DocumentWithSlug {
slug: Slug;
_updatedAt: string;
}
+
+export type InternationalizedString = InternationalizedStringValue[];
+
+export interface InternationalizedStringValue {
+ _key: string;
+ value?: string;
+}
diff --git a/studio/lib/interfaces/navigation.ts b/studio/lib/interfaces/navigation.ts
index 85d382421..394f3e946 100644
--- a/studio/lib/interfaces/navigation.ts
+++ b/studio/lib/interfaces/navigation.ts
@@ -1,3 +1,5 @@
+import { InternationalizedString } from "./global";
+
export interface Navigation {
_id: string;
main: ILink[];
@@ -12,7 +14,7 @@ interface Reference {
export interface ILink {
_key: string;
_type: string;
- linkTitle: string;
+ linkTitle: string | InternationalizedString;
linkType: LinkType;
internalLink?: Reference;
url?: string;
diff --git a/studio/lib/interfaces/slugTranslations.ts b/studio/lib/interfaces/slugTranslations.ts
new file mode 100644
index 000000000..3c68882aa
--- /dev/null
+++ b/studio/lib/interfaces/slugTranslations.ts
@@ -0,0 +1,6 @@
+export interface SlugTranslations {
+ _translations: ({
+ language: string;
+ slug: string;
+ } | null)[];
+}
diff --git a/studio/lib/queries/languages.ts b/studio/lib/queries/languages.ts
index c5d608f2f..9903a54a1 100644
--- a/studio/lib/queries/languages.ts
+++ b/studio/lib/queries/languages.ts
@@ -1,3 +1,5 @@
import { groq } from "next-sanity";
export const DEFAULT_LANGUAGE_QUERY = groq`*[_type == "languageSettings" && _id == "languageSettings"][0].languages[default][0]`;
+
+export const LANGUAGES_QUERY = groq`*[_type == "languageSettings" && _id == "languageSettings"][0].languages`;
diff --git a/studio/lib/queries/redirects.ts b/studio/lib/queries/redirects.ts
index 86401b246..b54619985 100644
--- a/studio/lib/queries/redirects.ts
+++ b/studio/lib/queries/redirects.ts
@@ -1,7 +1,7 @@
import { groq } from "next-sanity";
export const REDIRECT_BY_SOURCE_SLUG_QUERY = groq`
- *[_type == "redirect" && source.current == $slug][0]{
+ *[_type == "brokenLink" && source.current == $slug][0]{
"destination": select(
destination.type == "reference" => destination.reference->slug.current,
destination.type == "external" => destination.external
diff --git a/studio/lib/queries/slugTranslations.ts b/studio/lib/queries/slugTranslations.ts
new file mode 100644
index 000000000..b2e636523
--- /dev/null
+++ b/studio/lib/queries/slugTranslations.ts
@@ -0,0 +1,27 @@
+import { groq } from "next-sanity";
+
+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,
+ },
+ }
+`;
+
+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],
+ }
+`;
diff --git a/studio/schemas/schemaTypes/slug.ts b/studio/schemas/schemaTypes/slug.ts
index dc3e15f0f..bd7cd4d5f 100644
--- a/studio/schemas/schemaTypes/slug.ts
+++ b/studio/schemas/schemaTypes/slug.ts
@@ -20,14 +20,21 @@ function isSlugUniqueAcrossAllDocuments(
if (document === undefined) {
return true;
}
- return getClient({ apiVersion }).fetch(
- "!defined(*[!(_id in [$draft, $published]) && slug.current == $slug][0]._id)",
- {
- draft: buildDraftId(document._id),
- published: buildPublishedId(document._id),
- slug,
- },
- );
+ const language = "language" in document ? document.language : undefined;
+ const isUniqueQuery =
+ language !== undefined
+ ? `
+ !defined(*[!(_id in [$draft, $published]) && slug.current == $slug && language == $language][0]._id)
+ `
+ : `
+ !defined(*[!(_id in [$draft, $published]) && slug.current == $slug][0]._id)
+ `;
+ return getClient({ apiVersion }).fetch(isUniqueQuery, {
+ draft: buildDraftId(document._id),
+ published: buildPublishedId(document._id),
+ slug,
+ ...(language !== undefined ? { language } : {}),
+ });
}
/**