blob: 67f8ce568274b1588e7dfcf19590125a69b2ae6b [file]
/** Pure URL policy. Native manifest routes, including fallback routes, are authoritative. */
export type RouteGroup = {contentId: string; navigationRoutes: Record<string, string>};
export type RouteOptions = {
url: string;
baseUrl: string; // Unlocalized deployment root, e.g. / or /preview/.
defaultLocale: string;
locales: readonly string[];
trailingSlash?: boolean;
};
export type RouteRequest = {
href: string;
locale: string;
currentUrl: string;
options: RouteOptions;
routes?: readonly RouteGroup[]; // Current page or an explicitly loaded family only.
contentId?: string;
switchLocale?: boolean;
preserveLocale?: boolean;
queryString?: string;
};
const pathKey = (path: string) => path.replace(/\/+$/, '') || '/';
export function rootBaseUrl(baseUrl: string, currentLocale: string, defaultLocale: string): string {
const base = '/' + baseUrl.split('/').filter(Boolean).join('/') + '/';
return (currentLocale !== defaultLocale && base.endsWith('/' + currentLocale + '/')
? base.slice(0, -(currentLocale.length + 1)) : base).replace(/^\/\/$/, '/');
}
export function localePath(pathname: string, options: RouteOptions) {
const base = options.baseUrl.replace(/\/$/, '');
if (base && pathname !== base && !pathname.startsWith(base + '/')) return undefined;
const rest = pathname.slice(base.length) || '/';
const [first] = rest.slice(1).split('/');
const explicit = options.locales.includes(first) && first !== options.defaultLocale;
return {locale: explicit ? first : options.defaultLocale, explicit,
path: explicit ? rest.slice(first.length + 1) || '/' : rest};
}
export function isSystemNoindexPath(pathname: string, options: RouteOptions): boolean {
return /^\/(?:404\.html|search\/?)$/.test(localePath(pathname, options)?.path ?? '');
}
export function routeForLocale(path: string, locale: string, options: RouteOptions): string {
const base = options.baseUrl.replace(/\/$/, '');
const result = base + (locale === options.defaultLocale ? '' : '/' + locale) + '/' + path.replace(/^\/+/, '');
if (options.trailingSlash === undefined) return result;
const normalized = pathKey(result);
return (options.trailingSlash || path === '/') && normalized !== '/' ? normalized + '/' : normalized;
}
const resourcePath = (path: string) => /^\/(?:assets|images|img|js|css|fonts|files|downloads|api)(?:\/|$)/i.test(path)
|| /\.[a-z\d]{1,8}$/i.test(path);
export function appendRouteSuffix(href: string, search = '', hash = '', extra = ''): string {
const url = new URL(href, 'https://route.invalid');
if (search) url.search = search;
if (hash) url.hash = hash;
if (extra) {
const parameters = new URLSearchParams(extra.replace(/^\?/, ''));
parameters.forEach((value, key) => url.searchParams.set(key, value));
}
return url.pathname + url.search + url.hash;
}
export function resolveLocalizedRoute(request: RouteRequest): {href: string; known: boolean; reason: string} {
const {href, locale, options, currentUrl, switchLocale = false} = request;
const unchanged = (reason: string) => ({href, known: false, reason});
if (request.preserveLocale || href.startsWith('pathname://')) return unchanged('explicit-target');
if (!options.locales.includes(locale)) return unchanged('disabled-locale');
if (!href || (!switchLocale && /^[?#]/.test(href))) return unchanged('same-page');
let target: URL;
try {target = new URL(href, new URL(currentUrl, options.url));} catch {return unchanged('invalid');}
if (!/^https?:$/.test(target.protocol) || target.origin !== new URL(options.url).origin) return unchanged('external');
let info = localePath(target.pathname, options);
let rebased = false;
if (!info && href.startsWith('/') && !href.startsWith('//')) {
const projectPath = localePath(target.pathname, {...options, baseUrl: '/'});
if (projectPath && !resourcePath(projectPath.path)) {
target.pathname = options.baseUrl.replace(/\/$/, '') + target.pathname;
info = localePath(target.pathname, options); rebased = true;
}
}
if (!info || resourcePath(info.path)) return unchanged('resource');
if (info.explicit && !switchLocale && /^(?:\/|https?:)/i.test(href)) return {
href: rebased ? target.pathname + target.search + target.hash : href, known: false, reason: 'explicit-locale'};
// A second locale segment is malformed, rather than a license to create /ja/ko/... .
if (options.locales.includes(info.path.split('/')[1])) return unchanged('duplicate-locale');
const group = request.routes?.find(page => request.contentId ? page.contentId === request.contentId
: Object.values(page.navigationRoutes).some(route => pathKey(route) === pathKey(target.pathname)));
const counterpart = group?.navigationRoutes[locale];
if (group && !counterpart) return unchanged('missing-counterpart');
const result = counterpart ?? routeForLocale(info.path, locale, options);
// Unknown paths keep their identity and reach a genuine localized 404; never use a home URL.
return {href: appendRouteSuffix(result, target.search, target.hash, request.queryString),
known: !!counterpart, reason: counterpart ? 'native-route' : 'unresolved-path'};
}