All files / app middleware.ts

25.64% Statements 20/78
13.04% Branches 6/46
20% Functions 2/10
25% Lines 19/76

Press n or j to go to the next uncovered block, b, p or k for the previous block.

1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 2171x 1x 1x   1x 1x 1x 1x           1x   1x                                                                                                                       9x         12x 4x     8x 4x     4x       11x       11x   10x                                                                                                                                                                                                         1x                                  
import { lookupAcceptLanguage } from "i18n/acceptLanguage";
import { allLanguages } from "i18n/allLanguages";
import { NextRequest, NextResponse } from "next/server";
 
import { sessionCookieName } from "./appConstants";
import { ALMOST_DONE_CUTOFF } from "./features/translate/constants";
import { fetchWeblateStats, WeblateLanguage } from "./features/weblate/useWeblateStats";
import { ALWAYS_AVAILABLE_LOCALES, DEFAULT_LOCALE, LOCALE_COOKIE_NAME } from "./i18n/locales";
 
// In-memory cache for Weblate stats
let statsCache: {
  data: WeblateLanguage[];
  timestamp: number;
} | null = null;
 
const CACHE_TTL = 10 * 60 * 1000; // 10 minutes in milliseconds
 
/**
 * Get cached Weblate stats or fetch fresh data if cache is stale
 */
async function getCachedWeblateStats(): Promise<WeblateLanguage[]> {
  const now = Date.now();
 
  // Return cached data if it exists and is still fresh
  if (statsCache && now - statsCache.timestamp < CACHE_TTL) {
    return statsCache.data;
  }
 
  // Fetch fresh data
  const languages = await fetchWeblateStats();
 
  // Update cache
  statsCache = {
    data: languages,
    timestamp: now,
  };
 
  return languages;
}
 
/**
 * Get the list of locale codes that are production-ready (>= 80% translated)
 * Uses server-side Weblate stats with caching
 */
async function getProductionReadyLocales(): Promise<string[]> {
  const languages = await getCachedWeblateStats();
 
  // Convert locale format (e.g., "es_419" to "es-419" to match allLanguages)
  const productionReadyLocales = languages
    .filter((lang) => lang.translated_percent >= ALMOST_DONE_CUTOFF)
    .map((lang) => lang.code.replace("_", "-"));
 
  // English is always production-ready
  return allLanguages.filter(
    (locale) => ALWAYS_AVAILABLE_LOCALES.includes(locale) || productionReadyLocales.includes(locale),
  );
}
 
/**
 * Check if a locale is production-ready (>= 80% translated)
 * Uses server-side Weblate stats with caching
 */
async function isLocaleProductionReady(locale: string): Promise<boolean> {
  const productionReadyLocales = await getProductionReadyLocales();
  return productionReadyLocales.includes(locale);
}
 
/**
 * Determine if a locale should be blocked based on translation completeness.
 *
 * @param currentLocale - The locale from the URL
 * @param cookieLocale - The NEXT_LOCALE cookie value (undefined if not set)
 * @param isProductionReady - Whether the current locale is >= 80% translated
 * @returns true if the locale should be blocked (redirect to English)
 */
export function shouldBlockIncompleteLanguage(
  currentLocale: string,
  cookieLocale: string | undefined,
  isProductionReady: boolean,
): boolean {
  if (ALWAYS_AVAILABLE_LOCALES.includes(currentLocale)) {
    return false;
  }
 
  if (cookieLocale) {
    return false; // User explicitly selected this language
  }
 
  return !isProductionReady; // Browser detection: only allow >= 80%
}
 
/** Detects the user's preferred locale from the Accept-Language header */
export function getBrowserLocaleFromHeader(
  acceptLanguage: string | undefined,
  supportedLocales: string[],
): string | undefined {
  if (!acceptLanguage) return undefined;
 
  return lookupAcceptLanguage(acceptLanguage, supportedLocales);
}
 
async function getBestLocale(request: NextRequest): Promise<string> {
  // Priority 1: NEXT_LOCALE cookie (set by backend or language picker)
  const cookieLocale = request.cookies.get(LOCALE_COOKIE_NAME)?.value;
  if (cookieLocale && allLanguages.includes(cookieLocale)) {
    return cookieLocale;
  }
 
  // Priority 2: Accept-Language header (browser language)
  // Only consider languages that are production-ready (>= 80% translated),
  // falling through to the next preferred language in the header otherwise
  const acceptLanguage = request.headers.get("accept-language");
  const productionReadyLocales = await getProductionReadyLocales();
  return getBrowserLocaleFromHeader(acceptLanguage || undefined, productionReadyLocales) || DEFAULT_LOCALE;
}
 
export async function middleware(request: NextRequest) {
  const { pathname, locale: currentLocale } = request.nextUrl;
  const cookieLocale = request.cookies.get(LOCALE_COOKIE_NAME)?.value;
  const isAuthenticated = !!request.cookies.get(sessionCookieName);
 
  // Skip locale redirect if this is a client-side navigation
  // The Next.js router handles locale changes client-side
  const isClientNavigation = request.headers.get("x-nextjs-data");
 
  // Check if current locale should be blocked
  const isProductionReady = await isLocaleProductionReady(currentLocale);
  const shouldBlock = shouldBlockIncompleteLanguage(currentLocale, cookieLocale, isProductionReady);
 
  if (shouldBlock) {
    const url = request.nextUrl.clone();
    url.locale = DEFAULT_LOCALE;
 
    if (isAuthenticated && pathname === "/") {
      url.pathname = "/dashboard";
    }
 
    const response = NextResponse.redirect(url);
    response.cookies.set(LOCALE_COOKIE_NAME, DEFAULT_LOCALE, {
      path: "/",
      maxAge: 31536000, // 1 year
      sameSite: "lax",
    });
    return response;
  }
 
  // Determine target locale: cookie > URL locale > browser detection
  let targetLocale: string;
 
  if (cookieLocale && allLanguages.includes(cookieLocale)) {
    targetLocale = cookieLocale;
  } else if (currentLocale !== DEFAULT_LOCALE) {
    // The locale is explicit in the URL
    targetLocale = currentLocale;
  } else {
    targetLocale = await getBestLocale(request);
  }
 
  // Redirect to target locale if it differs from current
  // BUT only on initial page loads, not during client-side navigations
  if (currentLocale !== targetLocale && !isClientNavigation) {
    const url = request.nextUrl.clone();
    url.locale = targetLocale;
 
    // Redirect authenticated users from root to dashboard
    if (isAuthenticated && pathname === "/") {
      url.pathname = "/dashboard";
    }
 
    const response = NextResponse.redirect(url);
    response.cookies.set(LOCALE_COOKIE_NAME, targetLocale, {
      path: "/",
      maxAge: 31536000,
      sameSite: "lax",
    });
    return response;
  }
 
  // Rewrite root path to dashboard for authenticated users
  if (isAuthenticated && pathname === "/") {
    const url = request.nextUrl.clone();
    url.pathname = "/dashboard";
    return NextResponse.rewrite(url);
  }
 
  // Set cookie if it doesn't exist yet
  if (!cookieLocale) {
    const response = NextResponse.next();
    response.cookies.set(LOCALE_COOKIE_NAME, currentLocale, {
      path: "/",
      maxAge: 31536000, // 1 year
      sameSite: "lax",
    });
    return response;
  }
 
  return NextResponse.next();
}
 
export const config = {
  matcher: [
    /*
     * Match all request paths except for the ones starting with:
     * - api (API routes)
     * - _next/static (static files)
     * - _next/image (image optimization files)
     * - favicon.ico (favicon file)
     * - manifest.json (PWA manifest)
     * - img (static images)
     * - logo files (PWA icons)
     * - robots.txt, sitemap.xml (SEO files)
     * - Files with common static extensions
     */
    "/((?!api|_next/static|_next/image|favicon.ico|manifest.json|img/|logo.*\\.png|robots.txt|sitemap.xml|.*\\.(?:svg|png|jpg|jpeg|gif|webp|ico|woff|woff2|ttf|eot|json)$).*)",
  ],
};