Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions e2e/fixtures/client-redirects/doc/docs/new1.mdx
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
# New page
1 change: 1 addition & 0 deletions e2e/fixtures/client-redirects/doc/docs/old1.mdx
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
# Old page
2 changes: 2 additions & 0 deletions e2e/fixtures/client-redirects/doc/index.mdx
Original file line number Diff line number Diff line change
@@ -1 +1,3 @@
# Hello world

[Navigate to an old route](/docs/old1)
86 changes: 82 additions & 4 deletions e2e/fixtures/client-redirects/index.test.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,20 @@
import { expect, test } from '@playwright/test';
import { getPort, killProcess, runDevCommand } from '../../utils/runCommands';
import {
getPort,
killProcess,
runBuildCommand,
runDevCommand,
runPreviewCommand,
} from '../../utils/runCommands';

test.describe('client redirects test', async () => {
test.describe('client redirects production test', async () => {
let appPort: number;
let app: Awaited<ReturnType<typeof runDevCommand>>;
let app: Awaited<ReturnType<typeof runPreviewCommand>>;
test.beforeAll(async () => {
const appDir = import.meta.dirname;
appPort = await getPort();
app = await runDevCommand(appDir, appPort);
await runBuildCommand(appDir);
app = await runPreviewCommand(appDir, appPort);
});

test.afterAll(async () => {
Expand All @@ -23,6 +30,51 @@ test.describe('client redirects test', async () => {
await expect(page).toHaveURL(`http://localhost:${appPort}/docs/new1`);
});

test('Should redirect before hydration', async ({ page }) => {
await page.route('**/*', async route => {
if (route.request().resourceType() === 'script') {
await route.abort();
} else {
await route.continue();
}
});

await page.goto(`http://localhost:${appPort}/docs/old1`, {
waitUntil: 'domcontentloaded',
});
await expect(page).toHaveURL(`http://localhost:${appPort}/docs/new1`);
});

test('Should redirect SPA route changes', async ({ page }) => {
await page.goto(`http://localhost:${appPort}/`, {
waitUntil: 'networkidle',
});
await page.getByRole('link', { name: 'Navigate to an old route' }).click();
await expect(page).toHaveURL(
new RegExp(`^http://localhost:${appPort}/docs/new1(?:\\.html)?$`),
);
});

test('Should fall back to the runtime redirect under a strict CSP', async ({
page,
}) => {
await page.route('**/*', async route => {
const response = await route.fetch();
await route.fulfill({
response,
headers: {
...response.headers(),
'content-security-policy': "script-src 'self'",
},
});
});

await page.goto(`http://localhost:${appPort}/docs/old1`, {
waitUntil: 'networkidle',
});
await expect(page).toHaveURL(`http://localhost:${appPort}/docs/new1`);
});

test('Should redirect correctly - array', async ({ page }) => {
await page.goto(`http://localhost:${appPort}/docs/2022`, {
waitUntil: 'networkidle',
Expand Down Expand Up @@ -77,3 +129,29 @@ test.describe('client redirects test', async () => {
await expect(page).toHaveURL(externalUrl);
});
});

test.describe('client redirects development test', async () => {
let appPort: number;
let app: Awaited<ReturnType<typeof runDevCommand>>;

test.beforeAll(async () => {
const appDir = import.meta.dirname;
appPort = await getPort();
app = await runDevCommand(appDir, appPort);
});

test.afterAll(async () => {
if (app) {
await killProcess(app);
}
});

test('Should be inactive in development', async ({ page }) => {
const sourceUrl = `http://localhost:${appPort}/docs/old1`;

await page.goto(sourceUrl, {
waitUntil: 'networkidle',
});
await expect(page).toHaveURL(sourceUrl);
});
});
71 changes: 47 additions & 24 deletions packages/core/src/node/constants.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -101,39 +101,47 @@ const runLocaleRedirectScript = (
search?: string;
userAgent?: string;
visited?: boolean;
redirecting?: boolean;
},
) => {
let redirectedTo: string | undefined;
let visited = options.visited ?? false;
const script = getInlineLocaleRedirectScript(config);

const windowMock = {
navigator: {
language: options.language,
userAgent: options.userAgent ?? 'browser',
},
location: {
pathname: options.pathname,
search: options.search ?? '',
replace: (url: string) => {
redirectedTo = url;
},
},
};
if (options.redirecting) {
Reflect.set(windowMock, Symbol.for('rspress.redirecting'), true);
}

Function(
'window',
'localStorage',
script,
)(
{
navigator: {
language: options.language,
userAgent: options.userAgent ?? 'browser',
},
location: {
pathname: options.pathname,
search: options.search ?? '',
replace: (url: string) => {
redirectedTo = url;
},
},
},
{
getItem: () => (visited ? '1' : null),
setItem: () => {
visited = true;
},
)(windowMock, {
getItem: () => (visited ? '1' : null),
setItem: () => {
visited = true;
},
);
});

return { redirectedTo, visited, script };
return {
redirectedTo,
redirecting: Reflect.get(windowMock, Symbol.for('rspress.redirecting')),
visited,
script,
};
};

describe('getInlineLocaleRedirectScript', () => {
Expand All @@ -155,10 +163,25 @@ describe('getInlineLocaleRedirectScript', () => {
}),
).toMatchObject({
redirectedTo: '/en/guide/?from=home',
redirecting: true,
visited: true,
});
});

test('does not race another Rspress redirect', () => {
expect(
runLocaleRedirectScript(config, {
language: 'en-US',
pathname: '/guide/',
redirecting: true,
}),
).toMatchObject({
redirectedTo: undefined,
redirecting: true,
visited: false,
});
});

test('redirects from a locale-prefixed route to the default locale', () => {
expect(
runLocaleRedirectScript(config, {
Expand Down Expand Up @@ -266,13 +289,13 @@ describe('getInlineLocaleRedirectScript', () => {
});

expect(autoScript).toMatchInlineSnapshot(
`"{;var defaultLang = "zh", langs = ["zh","en","fr"];if (!/bot|spider|crawl|lighthouse/i.test(window.navigator.userAgent)) {;var firstVisitKey = 'rspress-visited', visited = localStorage.getItem(firstVisitKey);if (!visited) {;localStorage.setItem(firstVisitKey, '1');var targetLang = window.navigator.language.split('-')[0], { pathname, search } = window.location, pathSegments = pathname.split('/').filter(Boolean), currentLang = langs.includes(pathSegments[0]) ? pathSegments[0] : defaultLang;if (langs.includes(targetLang) && targetLang !== currentLang) {;var newPathSegments = pathSegments.slice();if (targetLang === defaultLang) {;newPathSegments.splice(0, 1);} else if (currentLang === defaultLang) {;newPathSegments.splice(0, 0, targetLang);} else {;newPathSegments[0] = targetLang;};;var newPathname = '/' + newPathSegments.join('/'), trailingSlash = newPathname !== '/' && pathname.endsWith('/') ? '/' : '';window.location.replace(newPathname + trailingSlash + search);};};};}"`,
`"{;var defaultLang = "zh", langs = ["zh","en","fr"];if (!window[Symbol.for('rspress.redirecting')] && !/bot|spider|crawl|lighthouse/i.test(window.navigator.userAgent)) {;var firstVisitKey = 'rspress-visited', visited = localStorage.getItem(firstVisitKey);if (!visited) {;localStorage.setItem(firstVisitKey, '1');var targetLang = window.navigator.language.split('-')[0], { pathname, search } = window.location, pathSegments = pathname.split('/').filter(Boolean), currentLang = langs.includes(pathSegments[0]) ? pathSegments[0] : defaultLang;if (langs.includes(targetLang) && targetLang !== currentLang) {;var newPathSegments = pathSegments.slice();if (targetLang === defaultLang) {;newPathSegments.splice(0, 1);} else if (currentLang === defaultLang) {;newPathSegments.splice(0, 0, targetLang);} else {;newPathSegments[0] = targetLang;};;var newPathname = '/' + newPathSegments.join('/'), trailingSlash = newPathname !== '/' && pathname.endsWith('/') ? '/' : '';window[Symbol.for('rspress.redirecting')] = true;window.location.replace(newPathname + trailingSlash + search);};};};}"`,
);
expect(onlyDefaultLangScript).toMatchInlineSnapshot(
`"{;var defaultLang = "zh", langs = ["zh","en","fr"];if (!/bot|spider|crawl|lighthouse/i.test(window.navigator.userAgent)) {;var firstVisitKey = 'rspress-visited', visited = localStorage.getItem(firstVisitKey);if (!visited) {;localStorage.setItem(firstVisitKey, '1');var targetLang = window.navigator.language.split('-')[0], { pathname, search } = window.location, pathSegments = pathname.split('/').filter(Boolean), currentLang = langs.includes(pathSegments[0]) ? pathSegments[0] : defaultLang;if (currentLang === defaultLang && langs.includes(targetLang) && targetLang !== defaultLang) {;var newPathSegments = pathSegments.slice();newPathSegments.splice(0, 0, targetLang);;var newPathname = '/' + newPathSegments.join('/'), trailingSlash = newPathname !== '/' && pathname.endsWith('/') ? '/' : '';window.location.replace(newPathname + trailingSlash + search);};};};}"`,
`"{;var defaultLang = "zh", langs = ["zh","en","fr"];if (!window[Symbol.for('rspress.redirecting')] && !/bot|spider|crawl|lighthouse/i.test(window.navigator.userAgent)) {;var firstVisitKey = 'rspress-visited', visited = localStorage.getItem(firstVisitKey);if (!visited) {;localStorage.setItem(firstVisitKey, '1');var targetLang = window.navigator.language.split('-')[0], { pathname, search } = window.location, pathSegments = pathname.split('/').filter(Boolean), currentLang = langs.includes(pathSegments[0]) ? pathSegments[0] : defaultLang;if (currentLang === defaultLang && langs.includes(targetLang) && targetLang !== defaultLang) {;var newPathSegments = pathSegments.slice();newPathSegments.splice(0, 0, targetLang);;var newPathname = '/' + newPathSegments.join('/'), trailingSlash = newPathname !== '/' && pathname.endsWith('/') ? '/' : '';window[Symbol.for('rspress.redirecting')] = true;window.location.replace(newPathname + trailingSlash + search);};};};}"`,
);
expect(versionedBaseScript).toMatchInlineSnapshot(
`"{;var defaultLang = "zh", langs = ["zh","en","fr"];if (!/bot|spider|crawl|lighthouse/i.test(window.navigator.userAgent)) {;var firstVisitKey = 'rspress-visited', visited = localStorage.getItem(firstVisitKey);if (!visited) {;localStorage.setItem(firstVisitKey, '1');var targetLang = window.navigator.language.split('-')[0], { pathname, search } = window.location, base = "/docs", cleanPathname = pathname.startsWith(base) ? pathname.slice(base.length) || '/' : pathname, pathSegments = cleanPathname.split('/').filter(Boolean), versions = ["v1","v2"], langIndex = versions.includes(pathSegments[0]) ? 1 : 0, currentLang = langs.includes(pathSegments[langIndex]) ? pathSegments[langIndex] : defaultLang;if (langs.includes(targetLang) && targetLang !== currentLang) {;var newPathSegments = pathSegments.slice();if (targetLang === defaultLang) {;newPathSegments.splice(langIndex, 1);} else if (currentLang === defaultLang) {;newPathSegments.splice(langIndex, 0, targetLang);} else {;newPathSegments[langIndex] = targetLang;};;var newPathname = '/' + newPathSegments.join('/'), trailingSlash = newPathname !== '/' && cleanPathname.endsWith('/') ? '/' : '';window.location.replace(base + newPathname + trailingSlash + search);};};};}"`,
`"{;var defaultLang = "zh", langs = ["zh","en","fr"];if (!window[Symbol.for('rspress.redirecting')] && !/bot|spider|crawl|lighthouse/i.test(window.navigator.userAgent)) {;var firstVisitKey = 'rspress-visited', visited = localStorage.getItem(firstVisitKey);if (!visited) {;localStorage.setItem(firstVisitKey, '1');var targetLang = window.navigator.language.split('-')[0], { pathname, search } = window.location, base = "/docs", cleanPathname = pathname.startsWith(base) ? pathname.slice(base.length) || '/' : pathname, pathSegments = cleanPathname.split('/').filter(Boolean), versions = ["v1","v2"], langIndex = versions.includes(pathSegments[0]) ? 1 : 0, currentLang = langs.includes(pathSegments[langIndex]) ? pathSegments[langIndex] : defaultLang;if (langs.includes(targetLang) && targetLang !== currentLang) {;var newPathSegments = pathSegments.slice();if (targetLang === defaultLang) {;newPathSegments.splice(langIndex, 1);} else if (currentLang === defaultLang) {;newPathSegments.splice(langIndex, 0, targetLang);} else {;newPathSegments[langIndex] = targetLang;};;var newPathname = '/' + newPathSegments.join('/'), trailingSlash = newPathname !== '/' && cleanPathname.endsWith('/') ? '/' : '';window[Symbol.for('rspress.redirecting')] = true;window.location.replace(base + newPathname + trailingSlash + search);};};};}"`,
);
});

Expand Down
3 changes: 2 additions & 1 deletion packages/core/src/node/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,7 @@ export const getInlineLocaleRedirectScript = (config: UserConfig) => {
.join(', ');
const replaceLocationScript = `
var newPathname = '/' + newPathSegments.join('/'), trailingSlash = newPathname !== '/' && ${routePathname}.endsWith('/') ? '/' : ''
window[Symbol.for('rspress.redirecting')] = true
window.location.replace(${base ? 'base + ' : ''}newPathname + trailingSlash + search)`;
const redirectScript =
localeRedirect === 'only-default-lang'
Expand All @@ -101,7 +102,7 @@ export const getInlineLocaleRedirectScript = (config: UserConfig) => {

return `{
var defaultLang = ${serializeInlineScriptData(defaultLang)}, langs = ${serializeInlineScriptData(langs)}
if (!/bot|spider|crawl|lighthouse/i.test(window.navigator.userAgent)) {
if (!window[Symbol.for('rspress.redirecting')] && !/bot|spider|crawl|lighthouse/i.test(window.navigator.userAgent)) {
var firstVisitKey = 'rspress-visited', visited = localStorage.getItem(firstVisitKey)
if (!visited) {
localStorage.setItem(firstVisitKey, '1')
Expand Down
33 changes: 29 additions & 4 deletions packages/plugin-client-redirects/src/index.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import path from 'node:path';
import type { RspressPlugin } from '@rspress/core';
import { getInlineRedirectScript } from './inlineRedirect';
import type { RedirectsOptions } from './types';

/**
Expand All @@ -8,10 +9,34 @@ import type { RedirectsOptions } from './types';
export function pluginClientRedirects(
options: RedirectsOptions = {},
): RspressPlugin {
return {
const plugin: RspressPlugin = {
name: '@rspress/plugin-client-redirects',
globalUIComponents: [
[path.join(__dirname, '../static/Redirect.tsx'), options],
],
config(config, _utils, isProd) {
const inlineRedirectScript = getInlineRedirectScript(
options,
config.base,
);
plugin.globalUIComponents = isProd
? [[path.join(__dirname, '../static/Redirect.tsx'), options]]
: undefined;
plugin.builderConfig =
isProd && inlineRedirectScript
? {
html: {
tags: [
{
tag: 'script',
children: inlineRedirectScript,
Comment thread
SoonIter marked this conversation as resolved.
append: false,
Comment thread
SoonIter marked this conversation as resolved.
},
],
},
}
: undefined;

return config;
},
};

return plugin;
}
42 changes: 42 additions & 0 deletions packages/plugin-client-redirects/src/inlineRedirect.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
import type { RedirectsOptions } from './types';

const serializeInlineScriptData = (value: unknown) =>
JSON.stringify(value).replace(/</g, '\\u003c');

// Resolve redirects before the first render to avoid showing the source page
// while waiting for React to hydrate.
export function getInlineRedirectScript(
options: RedirectsOptions = {},
basePath = '/',
): string {
const { redirects } = options;

if (!redirects?.length) {
return '';
}

const base = basePath === '/' ? '' : basePath.replace(/\/$/, '');

return `(function() {
var redirects = ${serializeInlineScriptData(redirects)}, base = ${serializeInlineScriptData(base)}, redirectingKey = Symbol.for('rspress.redirecting');
if (window[redirectingKey]) return;
var pathname = window.location.pathname, hash = window.location.hash, hasBase = base && (pathname === base || pathname.startsWith(base + '/')), routePathname = hasBase ? pathname.slice(base.length) || '/' : pathname;
redirectLoop: for (var i = 0; i < redirects.length; i++) {
var redirect = redirects[i], patterns = Array.isArray(redirect.from) ? redirect.from : [redirect.from], to = redirect.to;
for (var j = 0; j < patterns.length; j++) {
var pattern = patterns[j];
try {
var regex = new RegExp(pattern);
if (regex.test(routePathname)) {
var isExternal = /^[a-zA-Z][a-zA-Z0-9+.-]*:/.test(to) && !/^(?:javascript|data|file):/i.test(to);
window[redirectingKey] = true;
window.location.replace(isExternal ? to : (hasBase ? base : '') + routePathname.replace(regex, to) + hash);
break redirectLoop;
}
} catch (error) {
console.warn('Invalid redirect pattern: ' + pattern, error);
}
}
}
})()`.replace(/\n\s*/g, '');
}
22 changes: 13 additions & 9 deletions packages/plugin-client-redirects/static/Redirect.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { isExternalUrl, useLocation } from '@rspress/core/runtime';
import { useEffect, useMemo } from 'react';

// these are types copied from src/types.ts
// These types are copied from src/types.ts because this file is shipped as source.
type RedirectRule = {
to: string;
from: string | string[];
Expand All @@ -15,9 +15,10 @@ export default function Redirect(props: RedirectsOptions = {}) {
const { pathname, hash } = useLocation();
const { redirects } = props;

// Use useMemo to preprocess redirect rules to avoid recreating RegExp objects every time you render
const processedRedirects = useMemo(() => {
if (!redirects?.length) return [];
if (!redirects?.length) {
return [];
}

return redirects.map(({ from, to }) => ({
to,
Expand All @@ -26,22 +27,25 @@ export default function Redirect(props: RedirectsOptions = {}) {
}, [redirects]);

useEffect(() => {
// If there is no redirect rule or if it is not in the browser environment, it will be returned
if (!processedRedirects.length || typeof window === 'undefined') {
return;
}

const redirectingKey = Symbol.for('rspress.redirecting');
if (Reflect.get(window, redirectingKey)) {
return;
}

for (const { patterns, to } of processedRedirects) {
for (const pattern of patterns) {
try {
const regex = new RegExp(pattern);

if (regex.test(pathname)) {
if (isExternalUrl(to)) {
window.location.replace(to);
} else {
window.location.replace(pathname.replace(regex, to) + hash);
}
Reflect.set(window, redirectingKey, true);
window.location.replace(
isExternalUrl(to) ? to : pathname.replace(regex, to) + hash,
);
return;
}
} catch (error) {
Expand Down
Loading
Loading