import { defineMiddleware, sequence } from "astro:middleware"; import { cache } from "src/utils/payload"; import acceptLanguage from "accept-language"; import type { AstroCookies } from "astro"; import { z } from "astro:content"; import { trackRequest, trackEvent } from "src/shared/analytics/analytics"; import { defaultLocale } from "src/i18n/i18n"; const ninetyDaysInSeconds = 60 * 60 * 24 * 90; const getAbsoluteLocaleUrl = (locale: string, url: string) => `/${locale}${url}`; const redirect = (redirectURL: string, headers: Record<string, string> = {}): Response => { return new Response(undefined, { headers: { ...headers, Location: redirectURL }, status: 302, statusText: "Found", }); }; const localeAgnosticPaths = ["/api/"]; const localeNegotiator = defineMiddleware(({ cookies, url, request }, next) => { if (localeAgnosticPaths.some((prefix) => url.pathname.startsWith(prefix))) { return next(); } const currentLocale = getCurrentLocale(url.pathname); const acceptedLocale = getBestAcceptedLanguage(request); const cookieLocale = getCookieLocale(cookies); const bestMatchingLocale = cookieLocale ?? acceptedLocale ?? currentLocale ?? defaultLocale; if (!currentLocale) { const redirectURL = getAbsoluteLocaleUrl(bestMatchingLocale, url.pathname); trackEvent("locale-redirect"); return redirect(redirectURL); } if (currentLocale !== bestMatchingLocale) { const pathnameWithoutLocale = url.pathname.substring(currentLocale.length + 1); const redirectURL = getAbsoluteLocaleUrl(bestMatchingLocale, pathnameWithoutLocale); trackEvent("locale-redirect"); return redirect(redirectURL); } return next(); }); const handleActionsSearchParams = defineMiddleware( async ({ url: { pathname, searchParams }, cookies }, next) => { const language = searchParams.get("action-lang"); if (isValidLocale(language)) { const currentLocale = getCurrentLocale(pathname); const pathnameWithoutLocale = currentLocale ? pathname.substring(currentLocale.length + 1) : pathname; const redirectURL = getAbsoluteLocaleUrl(language, pathnameWithoutLocale); trackEvent("action-lang"); cookies.set(CookieKeys.Language, language, { maxAge: ninetyDaysInSeconds, path: "/", sameSite: "strict", }); return redirect(redirectURL); } const currency = searchParams.get("action-currency"); if (isValidCurrency(currency)) { trackEvent("action-currency"); cookies.set(CookieKeys.Currency, currency, { maxAge: ninetyDaysInSeconds, path: "/", sameSite: "strict", }); return redirect(pathname); } const theme = searchParams.get("action-theme"); if (isValidTheme(theme)) { trackEvent("action-theme"); cookies.set(CookieKeys.Theme, theme, { maxAge: theme === "auto" ? 0 : ninetyDaysInSeconds, path: "/", sameSite: "strict", }); return redirect(pathname); } return next(); } ); const refreshCookiesMaxAge = defineMiddleware(async ({ cookies }, next) => { const response = await next(); const theme = cookies.get(CookieKeys.Theme)?.value; if (isValidTheme(theme) && theme !== "auto") { cookies.set(CookieKeys.Theme, theme, { maxAge: ninetyDaysInSeconds, path: "/", sameSite: "strict", }); } else if (theme) { cookies.set(CookieKeys.Theme, theme, { maxAge: 0, path: "/", sameSite: "strict", }); } const currency = cookies.get(CookieKeys.Currency)?.value; if (isValidCurrency(currency)) { cookies.set(CookieKeys.Currency, currency, { maxAge: ninetyDaysInSeconds, path: "/", sameSite: "strict", }); } else if (currency) { cookies.set(CookieKeys.Currency, currency, { maxAge: 0, path: "/", sameSite: "strict", }); } const language = cookies.get(CookieKeys.Language)?.value; if (isValidLocale(language)) { cookies.set(CookieKeys.Language, language, { maxAge: ninetyDaysInSeconds, path: "/", sameSite: "strict", }); } else if (language) { cookies.set(CookieKeys.Language, language, { maxAge: 0, path: "/", sameSite: "strict", }); } return response; }); const addContentLanguageResponseHeader = defineMiddleware(async ({ url }, next) => { const currentLocale = getCurrentLocale(url.pathname); const response = await next(); if (response.status === 200 && currentLocale) { response.headers.set("Content-Language", currentLocale); } return response; }); const provideLocalsToRequest = defineMiddleware(async ({ url, locals, cookies }, next) => { locals.currentLocale = getCurrentLocale(url.pathname) ?? "en"; locals.currentCurrency = getCookieCurrency(cookies) ?? "USD"; locals.currentTheme = getCookieTheme(cookies) ?? "auto"; return next(); }); const analytics = defineMiddleware(async (context, next) => { const { request, params, locals, clientAddress } = context; const response = await next(); trackRequest(request, { params, locals, clientAddress }); return response; }); export const onRequest = sequence( addContentLanguageResponseHeader, handleActionsSearchParams, refreshCookiesMaxAge, localeNegotiator, provideLocalsToRequest, analytics ); /* LOCALE */ const getCurrentLocale = (pathname: string): string | undefined => { for (const locale of cache.locales) { if (pathname.split("/")[1] === locale.id) { return locale.id; } } return undefined; }; const getBestAcceptedLanguage = (request: Request): string | undefined => { const header = request.headers.get("Accept-Language"); if (!header) return; acceptLanguage.languages(cache.locales.map(({ id }) => id)); return acceptLanguage.get(request.headers.get("Accept-Language")) ?? undefined; }; /* COOKIES */ export enum CookieKeys { Currency = "al_pref_currency", Theme = "al_pref_theme", Language = "al_pref_language", } const themeSchema = z.enum(["dark", "light", "auto"]); export const getCookieLocale = (cookies: AstroCookies): string | undefined => { const cookieValue = cookies.get(CookieKeys.Language)?.value; return isValidLocale(cookieValue) ? cookieValue : undefined; }; export const getCookieCurrency = (cookies: AstroCookies): string | undefined => { const cookieValue = cookies.get(CookieKeys.Currency)?.value; return isValidCurrency(cookieValue) ? cookieValue : undefined; }; export const getCookieTheme = (cookies: AstroCookies): z.infer<typeof themeSchema> | undefined => { const cookieValue = cookies.get(CookieKeys.Theme)?.value; return isValidTheme(cookieValue) ? cookieValue : undefined; }; export const isValidCurrency = (currency: string | null | undefined): currency is string => currency !== null && currency != undefined && cache.currencies.includes(currency); export const isValidLocale = (locale: string | null | undefined): locale is string => locale !== null && locale != undefined && cache.locales.map(({ id }) => id).includes(locale); export const isValidTheme = ( theme: string | null | undefined ): theme is z.infer<typeof themeSchema> => { const result = themeSchema.safeParse(theme); return result.success; };