/**
* ThemeOverride - Pathname-aware forced theme
*
* Applies a forced `light` or `dark` theme via `next-themes` while the current
* pathname matches any of the supplied rules, and restores the user's prior
* choice when the pathname no longer matches.
*
* Unlike `ForceTheme` (which scopes CSS vars to a wrapper div), this component
* mutates the real `next-themes` value — so the `html.dark` class is applied
* and every surface (navbar, footer, body, page) picks the new theme up
* consistently.
*
* The matcher is **dependency-free** and understands:
* - Exact match (`/` matches `/`)
* - Prefix match (`/docs` matches `/docs/getting-started`)
* - Glob wildcards (`*` for a single segment, `**` for any depth)
*
* Consumers pass the pathname already stripped of the locale prefix — matching
* is not locale-aware here.
*
* @example
* ```tsx
*
* ```
*/
'use client';
import { useEffect, useMemo, useRef } from 'react';
import { useThemeContext } from './ThemeProvider';
import type { Theme } from './ThemeProvider';
export type ForcedTheme = Exclude;
export interface ThemeOverrideRule {
/** Path (or array of paths) to match against. Supports `*` and `**` globs. */
path: string | string[];
/** Theme to apply while the path matches. */
theme: ForcedTheme;
}
export interface ThemeOverrideProps {
/** Pathname stripped of locale prefix. Use `usePathnameWithoutLocale()` if you have it. */
pathname: string;
/** Rules evaluated top-to-bottom — first match wins. */
rules?: ThemeOverrideRule[];
}
/**
* Resolve the first matching rule's forced theme for a pathname, or `null`.
* Pure helper — use this to render a `ForcedThemeProvider` in the same tree
* where you mount `ThemeOverride`.
*/
export function resolveForcedTheme(
pathname: string,
rules?: ThemeOverrideRule[],
): ForcedTheme | null {
if (!rules || rules.length === 0) return null;
for (const rule of rules) {
if (matchesAny(pathname, rule.path)) return rule.theme;
}
return null;
}
// --- inlined matcher (no dep on @djangocfg/layouts) -------------------------
function matchGlob(pathname: string, pattern: string): boolean {
const pathParts = pathname.replace(/\/+$/, '').split('/').filter(Boolean);
const patternParts = pattern.replace(/\/+$/, '').split('/').filter(Boolean);
let pi = 0;
let ti = 0;
while (ti < patternParts.length && pi < pathParts.length) {
const pat = patternParts[ti];
if (pat === '**') {
if (ti === patternParts.length - 1) return true;
const next = patternParts[ti + 1];
while (pi < pathParts.length) {
if (pathParts[pi] === next || next === '*') break;
pi++;
}
ti++;
} else if (pat === '*') {
pi++;
ti++;
} else {
if (pathParts[pi] !== pat) return false;
pi++;
ti++;
}
}
if (ti < patternParts.length) {
for (let i = ti; i < patternParts.length; i++) {
if (patternParts[i] !== '**') return false;
}
}
return pi === pathParts.length;
}
function matchOne(pathname: string, pattern: string): boolean {
const p = pathname.length > 1 ? pathname.replace(/\/+$/, '') : pathname;
const q = pattern.length > 1 ? pattern.replace(/\/+$/, '') : pattern;
if (q.includes('*')) return matchGlob(p, q);
return p === q || p.startsWith(q + '/');
}
function matchesAny(pathname: string, path: string | string[]): boolean {
return Array.isArray(path) ? path.some((p) => matchOne(pathname, p)) : matchOne(pathname, path);
}
// --- component --------------------------------------------------------------
export function ThemeOverride({ pathname, rules }: ThemeOverrideProps) {
const { theme, setTheme } = useThemeContext();
// First rule whose path matches the current pathname. null = no override.
const forced = useMemo(
() => resolveForcedTheme(pathname, rules),
[pathname, rules],
);
// Stash the user's own pick so we can restore it when they leave a forced route.
// We capture only non-override theme values (from renders where `forced === null`).
// Stored as a ref so it doesn't drive the effect.
const userThemeRef = useRef(undefined);
// Keep userThemeRef in sync with what next-themes reports **while not forcing**.
// Every render where the route isn't overridden, the current `theme` IS the user's
// own pick — snapshot it. During a forced render we leave the ref alone so the
// original value survives until we hand control back.
useEffect(() => {
if (forced === null && theme) {
userThemeRef.current = theme;
}
}, [forced, theme]);
// Apply / revert the forced theme. Intentionally driven only by `forced` — the
// user may toggle the theme manually while forcing is off, and that's fine
// (the sync effect above records the new value).
useEffect(() => {
if (forced) {
setTheme(forced);
return;
}
const restore = userThemeRef.current ?? 'system';
setTheme(restore);
// setTheme from next-themes is stable by ref; omit on purpose.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [forced]);
return null;
}