All files / app middleware.ts

19.73% Statements 15/76
5.4% Branches 2/37
16.66% Functions 1/6
20.27% Lines 15/74

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                                                                                                                   9x         12x 4x     8x 4x     4x                                                                                                                                                                                                                             1x                                  
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 { allLanguages } from "./i18n/allLanguages";
import { getBrowserLocaleFromHeader } from "./utils/getBrowserLocaleFromHeader";
 
// 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
  Iif (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;
}
 
/**
 * Check if a language is production-ready (>= 80% translated)
 * Uses server-side Weblate stats with caching
 */
async function isLanguageProductionReady(locale: string): Promise<boolean> {
  // English is always production-ready
  Iif (locale === "en") {
    return true;
  }
 
  const languages = await getCachedWeblateStats();
  Iif (!languages.length) {
    // If stats fail to load, only allow English to be safe
    return false;
  }
 
  // Convert locale format (e.g., "es-419" to "es_419" for Weblate)
  const weblateCode = locale.replace("-", "_");
  const languageStats = languages.find((lang) => lang.code === weblateCode);
 
  return (
    !!languageStats && languageStats.translated_percent >= ALMOST_DONE_CUTOFF
  );
}
 
/**
 * 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 (currentLocale === "en") {
    return false;
  }
 
  if (cookieLocale) {
    return false; // User explicitly selected this language
  }
 
  return !isProductionReady; // Browser detection: only allow >= 80%
}
 
async function getBestLocale(request: NextRequest): Promise<string> {
  // Priority 1: NEXT_LOCALE cookie (set by backend or language picker)
  const cookieLocale = request.cookies.get("NEXT_LOCALE")?.value;
  Iif (cookieLocale && allLanguages.includes(cookieLocale)) {
    return cookieLocale;
  }
 
  // Priority 2: Accept-Language header (browser language)
  // Only use if language is production-ready (>= 80% translated)
  const acceptLanguage = request.headers.get("accept-language");
  const browserLocale = getBrowserLocaleFromHeader(
    acceptLanguage || undefined,
    allLanguages,
  );
  Iif (browserLocale && (await isLanguageProductionReady(browserLocale))) {
    return browserLocale;
  }
 
  // Priority 3: Default to English
  return "en";
}
 
export async function middleware(request: NextRequest) {
  const { pathname, locale: currentLocale } = request.nextUrl;
  const cookieLocale = request.cookies.get("NEXT_LOCALE")?.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 isLanguageProductionReady(currentLocale);
  const shouldBlock = shouldBlockIncompleteLanguage(
    currentLocale,
    cookieLocale,
    isProductionReady,
  );
 
  Iif (shouldBlock) {
    const url = request.nextUrl.clone();
    url.locale = "en";
 
    Iif (isAuthenticated && pathname === "/") {
      url.pathname = "/dashboard";
    }
 
    const response = NextResponse.redirect(url);
    response.cookies.set("NEXT_LOCALE", "en", {
      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 !== "en") {
    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
  Iif (currentLocale !== targetLocale && !isClientNavigation) {
    const url = request.nextUrl.clone();
    url.locale = targetLocale;
 
    // Redirect authenticated users from root to dashboard
    Iif (isAuthenticated && pathname === "/") {
      url.pathname = "/dashboard";
    }
 
    const response = NextResponse.redirect(url);
    response.cookies.set("NEXT_LOCALE", targetLocale, {
      path: "/",
      maxAge: 31536000,
      sameSite: "lax",
    });
    return response;
  }
 
  // Rewrite root path to dashboard for authenticated users
  Iif (isAuthenticated && pathname === "/") {
    const url = request.nextUrl.clone();
    url.pathname = "/dashboard";
    return NextResponse.rewrite(url);
  }
 
  // Set cookie if it doesn't exist yet
  Iif (!cookieLocale) {
    const response = NextResponse.next();
    response.cookies.set("NEXT_LOCALE", 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)$).*)",
  ],
};