@shuvi/router 2.0.0-dev.31 → 2.0.0-dev.33
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/esm/index.d.ts +1 -1
- package/esm/index.js +1 -1
- package/esm/matchPathname.js +2 -2
- package/esm/matchRoutes.d.ts +13 -1
- package/esm/matchRoutes.js +25 -4
- package/esm/pathParserRanker.js +33 -2
- package/esm/router.d.ts +7 -0
- package/esm/router.js +32 -5
- package/esm/types/router.d.ts +12 -0
- package/lib/index.d.ts +1 -1
- package/lib/index.js +2 -1
- package/lib/matchPathname.js +2 -2
- package/lib/matchRoutes.d.ts +13 -1
- package/lib/matchRoutes.js +26 -4
- package/lib/pathParserRanker.js +33 -2
- package/lib/router.d.ts +7 -0
- package/lib/router.js +31 -4
- package/lib/types/router.d.ts +12 -0
- package/package.json +2 -2
package/esm/index.d.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
export { createRoutesFromArray } from './createRoutesFromArray';
|
|
2
2
|
export { matchPathname, matchStringify } from './matchPathname';
|
|
3
|
-
export { matchRoutes, IRouteBaseObject, rankRouteBranches } from './matchRoutes';
|
|
3
|
+
export { matchRoutes, IRouteBaseObject, rankRouteBranches, IMatchRoutesOptions, getTrailingSlashRedirectPath } from './matchRoutes';
|
|
4
4
|
export { pathToString, parseQuery, resolvePath, joinPaths, createLocation } from './utils';
|
|
5
5
|
export * from './types';
|
|
6
6
|
export * from './history';
|
package/esm/index.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
export { createRoutesFromArray } from './createRoutesFromArray';
|
|
2
2
|
export { matchPathname, matchStringify } from './matchPathname';
|
|
3
|
-
export { matchRoutes, rankRouteBranches } from './matchRoutes';
|
|
3
|
+
export { matchRoutes, rankRouteBranches, getTrailingSlashRedirectPath } from './matchRoutes';
|
|
4
4
|
export { pathToString, parseQuery, resolvePath, joinPaths, createLocation } from './utils';
|
|
5
5
|
export * from './types';
|
|
6
6
|
export * from './history';
|
package/esm/matchPathname.js
CHANGED
|
@@ -27,9 +27,9 @@ export function matchPathname(pattern, pathname) {
|
|
|
27
27
|
if (typeof pattern === 'string') {
|
|
28
28
|
pattern = { path: pattern };
|
|
29
29
|
}
|
|
30
|
-
const { path, caseSensitive = false, end = true } = pattern;
|
|
30
|
+
const { path, caseSensitive = false, end = true, strict = false } = pattern;
|
|
31
31
|
const pathParser = tokensToParser(tokenizePath(path), {
|
|
32
|
-
strict
|
|
32
|
+
strict,
|
|
33
33
|
end,
|
|
34
34
|
sensitive: caseSensitive
|
|
35
35
|
});
|
package/esm/matchRoutes.d.ts
CHANGED
|
@@ -3,4 +3,16 @@ export interface IRouteBaseObject<Element = any> extends Omit<IRouteRecord<Eleme
|
|
|
3
3
|
children?: IRouteBaseObject<Element>[];
|
|
4
4
|
}
|
|
5
5
|
export declare function rankRouteBranches<T extends [string, ...any[]]>(branches: T[]): T[];
|
|
6
|
-
export
|
|
6
|
+
export interface IMatchRoutesOptions {
|
|
7
|
+
strict?: boolean;
|
|
8
|
+
}
|
|
9
|
+
export declare function matchRoutes<T extends IRouteBaseObject>(routes: T[], location: string | PartialLocation, basename?: string, options?: IMatchRoutesOptions): IRouteMatch<T>[] | null;
|
|
10
|
+
/**
|
|
11
|
+
* When strict trailing-slash routing is enabled, a pathname that does not
|
|
12
|
+
* match any route may be a non-canonical variant (e.g. `/foo/` for a route
|
|
13
|
+
* declared as `/foo`). This computes the canonical alternative by stripping
|
|
14
|
+
* a single trailing slash (or adding one when the route is slash-suffixed) and
|
|
15
|
+
* returns it so the caller can issue a redirect. Returns `null` when the
|
|
16
|
+
* pathname is already canonical (`/` or no trailing slash to strip).
|
|
17
|
+
*/
|
|
18
|
+
export declare function getTrailingSlashRedirectPath(pathname: string): string | null;
|
package/esm/matchRoutes.js
CHANGED
|
@@ -2,7 +2,7 @@ import { matchPathname } from './matchPathname';
|
|
|
2
2
|
import { joinPaths, normalizeBase, resolvePath, stripBase } from './utils';
|
|
3
3
|
import { tokensToParser, comparePathParserScore } from './pathParserRanker';
|
|
4
4
|
import { tokenizePath } from './pathTokenizer';
|
|
5
|
-
function matchRouteBranch(branch, pathname) {
|
|
5
|
+
function matchRouteBranch(branch, pathname, strict = false) {
|
|
6
6
|
let routes = branch[1];
|
|
7
7
|
let matchedPathname = '/';
|
|
8
8
|
let matchedParams = {};
|
|
@@ -15,7 +15,8 @@ function matchRouteBranch(branch, pathname) {
|
|
|
15
15
|
let routeMatch = matchPathname({
|
|
16
16
|
path: route.path,
|
|
17
17
|
caseSensitive: route.caseSensitive,
|
|
18
|
-
end: i === routes.length - 1
|
|
18
|
+
end: i === routes.length - 1,
|
|
19
|
+
strict
|
|
19
20
|
}, remainingPathname);
|
|
20
21
|
if (!routeMatch)
|
|
21
22
|
return null;
|
|
@@ -70,7 +71,8 @@ function flattenRoutes(routes, branches = [], parentPath = '', parentRoutes = []
|
|
|
70
71
|
});
|
|
71
72
|
return branches;
|
|
72
73
|
}
|
|
73
|
-
export function matchRoutes(routes, location, basename = '') {
|
|
74
|
+
export function matchRoutes(routes, location, basename = '', options = {}) {
|
|
75
|
+
const { strict = false } = options;
|
|
74
76
|
if (typeof location === 'string') {
|
|
75
77
|
location = resolvePath(location);
|
|
76
78
|
}
|
|
@@ -90,7 +92,26 @@ export function matchRoutes(routes, location, basename = '') {
|
|
|
90
92
|
let matches = null;
|
|
91
93
|
for (let i = 0; matches == null && i < branches.length; ++i) {
|
|
92
94
|
// TODO: Match on search, state too?
|
|
93
|
-
matches = matchRouteBranch(branches[i], pathname);
|
|
95
|
+
matches = matchRouteBranch(branches[i], pathname, strict);
|
|
94
96
|
}
|
|
95
97
|
return matches;
|
|
96
98
|
}
|
|
99
|
+
/**
|
|
100
|
+
* When strict trailing-slash routing is enabled, a pathname that does not
|
|
101
|
+
* match any route may be a non-canonical variant (e.g. `/foo/` for a route
|
|
102
|
+
* declared as `/foo`). This computes the canonical alternative by stripping
|
|
103
|
+
* a single trailing slash (or adding one when the route is slash-suffixed) and
|
|
104
|
+
* returns it so the caller can issue a redirect. Returns `null` when the
|
|
105
|
+
* pathname is already canonical (`/` or no trailing slash to strip).
|
|
106
|
+
*/
|
|
107
|
+
export function getTrailingSlashRedirectPath(pathname) {
|
|
108
|
+
if (pathname === '/' || pathname === '') {
|
|
109
|
+
return null;
|
|
110
|
+
}
|
|
111
|
+
// strip a single trailing slash
|
|
112
|
+
if (pathname.length > 1 && pathname.endsWith('/')) {
|
|
113
|
+
return pathname.slice(0, -1);
|
|
114
|
+
}
|
|
115
|
+
// otherwise the non-canonical variant is the slash-suffixed form
|
|
116
|
+
return pathname + '/';
|
|
117
|
+
}
|
package/esm/pathParserRanker.js
CHANGED
|
@@ -14,6 +14,20 @@ const CATCHALL_TOKEN = {
|
|
|
14
14
|
const CATCHALL_TOKEN_STRICT = Object.assign(Object.assign({}, CATCHALL_TOKEN), { regexp: '(?:/((?:[^/]+?)(?:/(?:[^/]+?))*))?$' });
|
|
15
15
|
// Special Regex characters that must be escaped in static tokens
|
|
16
16
|
const REGEX_CHARS_RE = /[.+*?^${}()[\]/\\]/g;
|
|
17
|
+
/**
|
|
18
|
+
* Whether the pattern built from these segments ends at a separator, with
|
|
19
|
+
* nothing of its own after it. See its use in `tokensToParser`.
|
|
20
|
+
*/
|
|
21
|
+
function endsAtSeparator(segments) {
|
|
22
|
+
const last = segments[segments.length - 1];
|
|
23
|
+
if (!last) {
|
|
24
|
+
return false;
|
|
25
|
+
}
|
|
26
|
+
return (last.length === 0 ||
|
|
27
|
+
(last.length === 1 &&
|
|
28
|
+
last[0].type === 0 /* TokenType.Static */ &&
|
|
29
|
+
last[0].value === ''));
|
|
30
|
+
}
|
|
17
31
|
/**
|
|
18
32
|
* Creates a path parser from an array of Segments (a segment is an array of Tokens)
|
|
19
33
|
*
|
|
@@ -158,8 +172,25 @@ export function tokensToParser(segments, extraOptions, branchInfo) {
|
|
|
158
172
|
if (options.end)
|
|
159
173
|
pattern += '$';
|
|
160
174
|
// allow paths like /dynamic to only match dynamic or dynamic/... but not dynamic_something_else
|
|
161
|
-
|
|
162
|
-
|
|
175
|
+
//
|
|
176
|
+
// The assertion is zero-width. A non-end parser belongs to an intermediate
|
|
177
|
+
// route, and `matchRouteBranch` slices that route's match off the pathname
|
|
178
|
+
// before running the rest of the branch. Consuming the separator would
|
|
179
|
+
// leave the child `42` rather than `/42` for `/item/42`, and no child
|
|
180
|
+
// pattern starts without a slash, so every route under a nested layout
|
|
181
|
+
// stopped matching, at its canonical url as much as at the slash-suffixed
|
|
182
|
+
// one. Holding the separator back also keeps the pathname an intermediate
|
|
183
|
+
// route reports equal to the loose matcher's.
|
|
184
|
+
//
|
|
185
|
+
// A param token already appends the same lookahead, for the same reason.
|
|
186
|
+
//
|
|
187
|
+
// Except when the pattern stops at a separator with nothing of its own
|
|
188
|
+
// after it, which is the `/` route (one empty static token) and a path that
|
|
189
|
+
// is empty or ends in a slash (an empty segment, which the strict branch
|
|
190
|
+
// above turns into a bare `/`). There the separator is the match, so there
|
|
191
|
+
// is nothing to hold back.
|
|
192
|
+
else if (options.strict && !endsAtSeparator(segments))
|
|
193
|
+
pattern += '(?=/|$)';
|
|
163
194
|
}
|
|
164
195
|
const re = new RegExp(pattern, options.sensitive ? '' : 'i');
|
|
165
196
|
function parse(path) {
|
package/esm/router.d.ts
CHANGED
|
@@ -4,6 +4,13 @@ interface IRouterOptions<RouteRecord extends IPartialRouteRecord> {
|
|
|
4
4
|
history: History;
|
|
5
5
|
routes: RouteRecord[];
|
|
6
6
|
caseSensitive?: boolean;
|
|
7
|
+
/**
|
|
8
|
+
* When enabled, path matching treats trailing slashes as significant.
|
|
9
|
+
* A request whose pathname does not match any route but differs only by a
|
|
10
|
+
* trailing slash from a route that does match will be redirected (308) to
|
|
11
|
+
* the canonical URL. Defaults to false (legacy loose matching).
|
|
12
|
+
*/
|
|
13
|
+
strictTrailingSlash?: boolean;
|
|
7
14
|
}
|
|
8
15
|
export declare const createRouter: <RouteRecord extends IRouteRecord>(options: IRouterOptions<RouteRecord>) => IRouter<RouteRecord>;
|
|
9
16
|
export {};
|
package/esm/router.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { createDefer } from '@shuvi/utils/defer';
|
|
2
|
-
import { matchRoutes } from './matchRoutes';
|
|
2
|
+
import { matchRoutes, getTrailingSlashRedirectPath } from './matchRoutes';
|
|
3
3
|
import { createRoutesFromArray } from './createRoutesFromArray';
|
|
4
4
|
import { createEvents, resolvePath } from './utils';
|
|
5
5
|
import { isError, isFunction } from './utils/error';
|
|
@@ -17,7 +17,7 @@ const START = {
|
|
|
17
17
|
redirected: false
|
|
18
18
|
};
|
|
19
19
|
class Router {
|
|
20
|
-
constructor({ history, routes }) {
|
|
20
|
+
constructor({ history, routes, strictTrailingSlash = false }) {
|
|
21
21
|
this._pending = null;
|
|
22
22
|
this._cancleHandler = null;
|
|
23
23
|
this._ready = false;
|
|
@@ -73,8 +73,8 @@ class Router {
|
|
|
73
73
|
return this._history.resolve(to, from);
|
|
74
74
|
};
|
|
75
75
|
this.match = (to) => {
|
|
76
|
-
const { _routes: routes } = this;
|
|
77
|
-
const matches = matchRoutes(routes, to);
|
|
76
|
+
const { _routes: routes, _strictTrailingSlash: strict } = this;
|
|
77
|
+
const matches = matchRoutes(routes, to, undefined, { strict });
|
|
78
78
|
return matches || [];
|
|
79
79
|
};
|
|
80
80
|
this.replaceRoutes = (routes) => {
|
|
@@ -101,6 +101,7 @@ class Router {
|
|
|
101
101
|
};
|
|
102
102
|
this._history = history;
|
|
103
103
|
this._routes = createRoutesFromArray(routes);
|
|
104
|
+
this._strictTrailingSlash = strictTrailingSlash;
|
|
104
105
|
this._current = START;
|
|
105
106
|
this._history.doTransition = this._doTransition.bind(this);
|
|
106
107
|
}
|
|
@@ -119,6 +120,9 @@ class Router {
|
|
|
119
120
|
get basename() {
|
|
120
121
|
return this._history.basename;
|
|
121
122
|
}
|
|
123
|
+
get strictTrailingSlash() {
|
|
124
|
+
return this._strictTrailingSlash;
|
|
125
|
+
}
|
|
122
126
|
/*
|
|
123
127
|
The Full Navigation Resolution Flow for shuvi/router
|
|
124
128
|
1. Navigation triggered.
|
|
@@ -140,6 +144,16 @@ class Router {
|
|
|
140
144
|
redirectedFrom: redirectedFrom || nextRoute
|
|
141
145
|
});
|
|
142
146
|
}
|
|
147
|
+
// Strict trailing-slash routing: redirect to the canonical URL when the
|
|
148
|
+
// current pathname only differs by a trailing slash from a real route.
|
|
149
|
+
const trailingSlashRedirect = nextRoute.redirect;
|
|
150
|
+
if (trailingSlashRedirect) {
|
|
151
|
+
const transitionMethod = isReplace || isInitialNavigation ? 'replace' : 'push';
|
|
152
|
+
return this._history[transitionMethod](trailingSlashRedirect.path, {
|
|
153
|
+
redirectedFrom: redirectedFrom || nextRoute,
|
|
154
|
+
state: { status: trailingSlashRedirect.status }
|
|
155
|
+
});
|
|
156
|
+
}
|
|
143
157
|
const routeContext = new Map();
|
|
144
158
|
const queue = skipGuards
|
|
145
159
|
? []
|
|
@@ -270,8 +284,21 @@ class Router {
|
|
|
270
284
|
const matches = this.match(to);
|
|
271
285
|
const params = matches.length ? matches[matches.length - 1].params : {};
|
|
272
286
|
const parsedPath = resolvePath(to);
|
|
273
|
-
|
|
287
|
+
const route = Object.assign(Object.assign({ matches,
|
|
274
288
|
params }, parsedPath), { key: '', state: null });
|
|
289
|
+
// Strict trailing-slash routing: when the pathname does not match but a
|
|
290
|
+
// canonical variant (differing only by a trailing slash) does, surface a
|
|
291
|
+
// redirect so the transition layer can redirect to the canonical URL.
|
|
292
|
+
if (this._strictTrailingSlash && !matches.length) {
|
|
293
|
+
const canonical = getTrailingSlashRedirectPath(parsedPath.pathname || '/');
|
|
294
|
+
if (canonical) {
|
|
295
|
+
const rematched = matchRoutes(this._routes, Object.assign(Object.assign({}, parsedPath), { pathname: canonical }), undefined, { strict: true });
|
|
296
|
+
if (rematched && rematched.length) {
|
|
297
|
+
route.redirect = { path: canonical, status: 308 };
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
return route;
|
|
275
302
|
}
|
|
276
303
|
}
|
|
277
304
|
export const createRouter = (options) => {
|
package/esm/types/router.d.ts
CHANGED
|
@@ -44,6 +44,7 @@ export type IPathPattern = string | {
|
|
|
44
44
|
path: string;
|
|
45
45
|
caseSensitive?: boolean;
|
|
46
46
|
end?: boolean;
|
|
47
|
+
strict?: boolean;
|
|
47
48
|
};
|
|
48
49
|
export interface IPathMatch {
|
|
49
50
|
path: string;
|
|
@@ -57,6 +58,16 @@ export interface IRoute<RouteRecord extends IRouteRecord = IRouteRecord> extends
|
|
|
57
58
|
matches: IRouteMatch<RouteRecord>[];
|
|
58
59
|
redirected?: boolean;
|
|
59
60
|
key: string;
|
|
61
|
+
/**
|
|
62
|
+
* Set when strict trailing-slash routing resolves a canonical URL for the
|
|
63
|
+
* current (non-matching) pathname. Carries the redirect target + status so
|
|
64
|
+
* the transition layer can issue a redirect instead of falling through to
|
|
65
|
+
* the not-found page.
|
|
66
|
+
*/
|
|
67
|
+
redirect?: {
|
|
68
|
+
path: string;
|
|
69
|
+
status: number;
|
|
70
|
+
};
|
|
60
71
|
}
|
|
61
72
|
export interface IRouter<RouteRecord extends {
|
|
62
73
|
path: string;
|
|
@@ -64,6 +75,7 @@ export interface IRouter<RouteRecord extends {
|
|
|
64
75
|
current: IRoute<RouteRecord>;
|
|
65
76
|
action: History['action'];
|
|
66
77
|
basename: string;
|
|
78
|
+
strictTrailingSlash: boolean;
|
|
67
79
|
push(to: PathRecord, state?: any): void;
|
|
68
80
|
replace(to: PathRecord, state?: any): void;
|
|
69
81
|
go: History['go'];
|
package/lib/index.d.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
export { createRoutesFromArray } from './createRoutesFromArray';
|
|
2
2
|
export { matchPathname, matchStringify } from './matchPathname';
|
|
3
|
-
export { matchRoutes, IRouteBaseObject, rankRouteBranches } from './matchRoutes';
|
|
3
|
+
export { matchRoutes, IRouteBaseObject, rankRouteBranches, IMatchRoutesOptions, getTrailingSlashRedirectPath } from './matchRoutes';
|
|
4
4
|
export { pathToString, parseQuery, resolvePath, joinPaths, createLocation } from './utils';
|
|
5
5
|
export * from './types';
|
|
6
6
|
export * from './history';
|
package/lib/index.js
CHANGED
|
@@ -14,7 +14,7 @@ var __exportStar = (this && this.__exportStar) || function(m, exports) {
|
|
|
14
14
|
for (var p in m) if (p !== "default" && !Object.prototype.hasOwnProperty.call(exports, p)) __createBinding(exports, m, p);
|
|
15
15
|
};
|
|
16
16
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
17
|
-
exports.createLocation = exports.joinPaths = exports.resolvePath = exports.parseQuery = exports.pathToString = exports.rankRouteBranches = exports.matchRoutes = exports.matchStringify = exports.matchPathname = exports.createRoutesFromArray = void 0;
|
|
17
|
+
exports.createLocation = exports.joinPaths = exports.resolvePath = exports.parseQuery = exports.pathToString = exports.getTrailingSlashRedirectPath = exports.rankRouteBranches = exports.matchRoutes = exports.matchStringify = exports.matchPathname = exports.createRoutesFromArray = void 0;
|
|
18
18
|
var createRoutesFromArray_1 = require("./createRoutesFromArray");
|
|
19
19
|
Object.defineProperty(exports, "createRoutesFromArray", { enumerable: true, get: function () { return createRoutesFromArray_1.createRoutesFromArray; } });
|
|
20
20
|
var matchPathname_1 = require("./matchPathname");
|
|
@@ -23,6 +23,7 @@ Object.defineProperty(exports, "matchStringify", { enumerable: true, get: functi
|
|
|
23
23
|
var matchRoutes_1 = require("./matchRoutes");
|
|
24
24
|
Object.defineProperty(exports, "matchRoutes", { enumerable: true, get: function () { return matchRoutes_1.matchRoutes; } });
|
|
25
25
|
Object.defineProperty(exports, "rankRouteBranches", { enumerable: true, get: function () { return matchRoutes_1.rankRouteBranches; } });
|
|
26
|
+
Object.defineProperty(exports, "getTrailingSlashRedirectPath", { enumerable: true, get: function () { return matchRoutes_1.getTrailingSlashRedirectPath; } });
|
|
26
27
|
var utils_1 = require("./utils");
|
|
27
28
|
Object.defineProperty(exports, "pathToString", { enumerable: true, get: function () { return utils_1.pathToString; } });
|
|
28
29
|
Object.defineProperty(exports, "parseQuery", { enumerable: true, get: function () { return utils_1.parseQuery; } });
|
package/lib/matchPathname.js
CHANGED
|
@@ -31,9 +31,9 @@ function matchPathname(pattern, pathname) {
|
|
|
31
31
|
if (typeof pattern === 'string') {
|
|
32
32
|
pattern = { path: pattern };
|
|
33
33
|
}
|
|
34
|
-
const { path, caseSensitive = false, end = true } = pattern;
|
|
34
|
+
const { path, caseSensitive = false, end = true, strict = false } = pattern;
|
|
35
35
|
const pathParser = (0, pathParserRanker_1.tokensToParser)((0, pathTokenizer_1.tokenizePath)(path), {
|
|
36
|
-
strict
|
|
36
|
+
strict,
|
|
37
37
|
end,
|
|
38
38
|
sensitive: caseSensitive
|
|
39
39
|
});
|
package/lib/matchRoutes.d.ts
CHANGED
|
@@ -3,4 +3,16 @@ export interface IRouteBaseObject<Element = any> extends Omit<IRouteRecord<Eleme
|
|
|
3
3
|
children?: IRouteBaseObject<Element>[];
|
|
4
4
|
}
|
|
5
5
|
export declare function rankRouteBranches<T extends [string, ...any[]]>(branches: T[]): T[];
|
|
6
|
-
export
|
|
6
|
+
export interface IMatchRoutesOptions {
|
|
7
|
+
strict?: boolean;
|
|
8
|
+
}
|
|
9
|
+
export declare function matchRoutes<T extends IRouteBaseObject>(routes: T[], location: string | PartialLocation, basename?: string, options?: IMatchRoutesOptions): IRouteMatch<T>[] | null;
|
|
10
|
+
/**
|
|
11
|
+
* When strict trailing-slash routing is enabled, a pathname that does not
|
|
12
|
+
* match any route may be a non-canonical variant (e.g. `/foo/` for a route
|
|
13
|
+
* declared as `/foo`). This computes the canonical alternative by stripping
|
|
14
|
+
* a single trailing slash (or adding one when the route is slash-suffixed) and
|
|
15
|
+
* returns it so the caller can issue a redirect. Returns `null` when the
|
|
16
|
+
* pathname is already canonical (`/` or no trailing slash to strip).
|
|
17
|
+
*/
|
|
18
|
+
export declare function getTrailingSlashRedirectPath(pathname: string): string | null;
|
package/lib/matchRoutes.js
CHANGED
|
@@ -2,11 +2,12 @@
|
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
3
|
exports.rankRouteBranches = rankRouteBranches;
|
|
4
4
|
exports.matchRoutes = matchRoutes;
|
|
5
|
+
exports.getTrailingSlashRedirectPath = getTrailingSlashRedirectPath;
|
|
5
6
|
const matchPathname_1 = require("./matchPathname");
|
|
6
7
|
const utils_1 = require("./utils");
|
|
7
8
|
const pathParserRanker_1 = require("./pathParserRanker");
|
|
8
9
|
const pathTokenizer_1 = require("./pathTokenizer");
|
|
9
|
-
function matchRouteBranch(branch, pathname) {
|
|
10
|
+
function matchRouteBranch(branch, pathname, strict = false) {
|
|
10
11
|
let routes = branch[1];
|
|
11
12
|
let matchedPathname = '/';
|
|
12
13
|
let matchedParams = {};
|
|
@@ -19,7 +20,8 @@ function matchRouteBranch(branch, pathname) {
|
|
|
19
20
|
let routeMatch = (0, matchPathname_1.matchPathname)({
|
|
20
21
|
path: route.path,
|
|
21
22
|
caseSensitive: route.caseSensitive,
|
|
22
|
-
end: i === routes.length - 1
|
|
23
|
+
end: i === routes.length - 1,
|
|
24
|
+
strict
|
|
23
25
|
}, remainingPathname);
|
|
24
26
|
if (!routeMatch)
|
|
25
27
|
return null;
|
|
@@ -74,7 +76,8 @@ function flattenRoutes(routes, branches = [], parentPath = '', parentRoutes = []
|
|
|
74
76
|
});
|
|
75
77
|
return branches;
|
|
76
78
|
}
|
|
77
|
-
function matchRoutes(routes, location, basename = '') {
|
|
79
|
+
function matchRoutes(routes, location, basename = '', options = {}) {
|
|
80
|
+
const { strict = false } = options;
|
|
78
81
|
if (typeof location === 'string') {
|
|
79
82
|
location = (0, utils_1.resolvePath)(location);
|
|
80
83
|
}
|
|
@@ -94,7 +97,26 @@ function matchRoutes(routes, location, basename = '') {
|
|
|
94
97
|
let matches = null;
|
|
95
98
|
for (let i = 0; matches == null && i < branches.length; ++i) {
|
|
96
99
|
// TODO: Match on search, state too?
|
|
97
|
-
matches = matchRouteBranch(branches[i], pathname);
|
|
100
|
+
matches = matchRouteBranch(branches[i], pathname, strict);
|
|
98
101
|
}
|
|
99
102
|
return matches;
|
|
100
103
|
}
|
|
104
|
+
/**
|
|
105
|
+
* When strict trailing-slash routing is enabled, a pathname that does not
|
|
106
|
+
* match any route may be a non-canonical variant (e.g. `/foo/` for a route
|
|
107
|
+
* declared as `/foo`). This computes the canonical alternative by stripping
|
|
108
|
+
* a single trailing slash (or adding one when the route is slash-suffixed) and
|
|
109
|
+
* returns it so the caller can issue a redirect. Returns `null` when the
|
|
110
|
+
* pathname is already canonical (`/` or no trailing slash to strip).
|
|
111
|
+
*/
|
|
112
|
+
function getTrailingSlashRedirectPath(pathname) {
|
|
113
|
+
if (pathname === '/' || pathname === '') {
|
|
114
|
+
return null;
|
|
115
|
+
}
|
|
116
|
+
// strip a single trailing slash
|
|
117
|
+
if (pathname.length > 1 && pathname.endsWith('/')) {
|
|
118
|
+
return pathname.slice(0, -1);
|
|
119
|
+
}
|
|
120
|
+
// otherwise the non-canonical variant is the slash-suffixed form
|
|
121
|
+
return pathname + '/';
|
|
122
|
+
}
|
package/lib/pathParserRanker.js
CHANGED
|
@@ -18,6 +18,20 @@ const CATCHALL_TOKEN = {
|
|
|
18
18
|
const CATCHALL_TOKEN_STRICT = Object.assign(Object.assign({}, CATCHALL_TOKEN), { regexp: '(?:/((?:[^/]+?)(?:/(?:[^/]+?))*))?$' });
|
|
19
19
|
// Special Regex characters that must be escaped in static tokens
|
|
20
20
|
const REGEX_CHARS_RE = /[.+*?^${}()[\]/\\]/g;
|
|
21
|
+
/**
|
|
22
|
+
* Whether the pattern built from these segments ends at a separator, with
|
|
23
|
+
* nothing of its own after it. See its use in `tokensToParser`.
|
|
24
|
+
*/
|
|
25
|
+
function endsAtSeparator(segments) {
|
|
26
|
+
const last = segments[segments.length - 1];
|
|
27
|
+
if (!last) {
|
|
28
|
+
return false;
|
|
29
|
+
}
|
|
30
|
+
return (last.length === 0 ||
|
|
31
|
+
(last.length === 1 &&
|
|
32
|
+
last[0].type === 0 /* TokenType.Static */ &&
|
|
33
|
+
last[0].value === ''));
|
|
34
|
+
}
|
|
21
35
|
/**
|
|
22
36
|
* Creates a path parser from an array of Segments (a segment is an array of Tokens)
|
|
23
37
|
*
|
|
@@ -162,8 +176,25 @@ function tokensToParser(segments, extraOptions, branchInfo) {
|
|
|
162
176
|
if (options.end)
|
|
163
177
|
pattern += '$';
|
|
164
178
|
// allow paths like /dynamic to only match dynamic or dynamic/... but not dynamic_something_else
|
|
165
|
-
|
|
166
|
-
|
|
179
|
+
//
|
|
180
|
+
// The assertion is zero-width. A non-end parser belongs to an intermediate
|
|
181
|
+
// route, and `matchRouteBranch` slices that route's match off the pathname
|
|
182
|
+
// before running the rest of the branch. Consuming the separator would
|
|
183
|
+
// leave the child `42` rather than `/42` for `/item/42`, and no child
|
|
184
|
+
// pattern starts without a slash, so every route under a nested layout
|
|
185
|
+
// stopped matching, at its canonical url as much as at the slash-suffixed
|
|
186
|
+
// one. Holding the separator back also keeps the pathname an intermediate
|
|
187
|
+
// route reports equal to the loose matcher's.
|
|
188
|
+
//
|
|
189
|
+
// A param token already appends the same lookahead, for the same reason.
|
|
190
|
+
//
|
|
191
|
+
// Except when the pattern stops at a separator with nothing of its own
|
|
192
|
+
// after it, which is the `/` route (one empty static token) and a path that
|
|
193
|
+
// is empty or ends in a slash (an empty segment, which the strict branch
|
|
194
|
+
// above turns into a bare `/`). There the separator is the match, so there
|
|
195
|
+
// is nothing to hold back.
|
|
196
|
+
else if (options.strict && !endsAtSeparator(segments))
|
|
197
|
+
pattern += '(?=/|$)';
|
|
167
198
|
}
|
|
168
199
|
const re = new RegExp(pattern, options.sensitive ? '' : 'i');
|
|
169
200
|
function parse(path) {
|
package/lib/router.d.ts
CHANGED
|
@@ -4,6 +4,13 @@ interface IRouterOptions<RouteRecord extends IPartialRouteRecord> {
|
|
|
4
4
|
history: History;
|
|
5
5
|
routes: RouteRecord[];
|
|
6
6
|
caseSensitive?: boolean;
|
|
7
|
+
/**
|
|
8
|
+
* When enabled, path matching treats trailing slashes as significant.
|
|
9
|
+
* A request whose pathname does not match any route but differs only by a
|
|
10
|
+
* trailing slash from a route that does match will be redirected (308) to
|
|
11
|
+
* the canonical URL. Defaults to false (legacy loose matching).
|
|
12
|
+
*/
|
|
13
|
+
strictTrailingSlash?: boolean;
|
|
7
14
|
}
|
|
8
15
|
export declare const createRouter: <RouteRecord extends IRouteRecord>(options: IRouterOptions<RouteRecord>) => IRouter<RouteRecord>;
|
|
9
16
|
export {};
|
package/lib/router.js
CHANGED
|
@@ -20,7 +20,7 @@ const START = {
|
|
|
20
20
|
redirected: false
|
|
21
21
|
};
|
|
22
22
|
class Router {
|
|
23
|
-
constructor({ history, routes }) {
|
|
23
|
+
constructor({ history, routes, strictTrailingSlash = false }) {
|
|
24
24
|
this._pending = null;
|
|
25
25
|
this._cancleHandler = null;
|
|
26
26
|
this._ready = false;
|
|
@@ -76,8 +76,8 @@ class Router {
|
|
|
76
76
|
return this._history.resolve(to, from);
|
|
77
77
|
};
|
|
78
78
|
this.match = (to) => {
|
|
79
|
-
const { _routes: routes } = this;
|
|
80
|
-
const matches = (0, matchRoutes_1.matchRoutes)(routes, to);
|
|
79
|
+
const { _routes: routes, _strictTrailingSlash: strict } = this;
|
|
80
|
+
const matches = (0, matchRoutes_1.matchRoutes)(routes, to, undefined, { strict });
|
|
81
81
|
return matches || [];
|
|
82
82
|
};
|
|
83
83
|
this.replaceRoutes = (routes) => {
|
|
@@ -104,6 +104,7 @@ class Router {
|
|
|
104
104
|
};
|
|
105
105
|
this._history = history;
|
|
106
106
|
this._routes = (0, createRoutesFromArray_1.createRoutesFromArray)(routes);
|
|
107
|
+
this._strictTrailingSlash = strictTrailingSlash;
|
|
107
108
|
this._current = START;
|
|
108
109
|
this._history.doTransition = this._doTransition.bind(this);
|
|
109
110
|
}
|
|
@@ -122,6 +123,9 @@ class Router {
|
|
|
122
123
|
get basename() {
|
|
123
124
|
return this._history.basename;
|
|
124
125
|
}
|
|
126
|
+
get strictTrailingSlash() {
|
|
127
|
+
return this._strictTrailingSlash;
|
|
128
|
+
}
|
|
125
129
|
/*
|
|
126
130
|
The Full Navigation Resolution Flow for shuvi/router
|
|
127
131
|
1. Navigation triggered.
|
|
@@ -143,6 +147,16 @@ class Router {
|
|
|
143
147
|
redirectedFrom: redirectedFrom || nextRoute
|
|
144
148
|
});
|
|
145
149
|
}
|
|
150
|
+
// Strict trailing-slash routing: redirect to the canonical URL when the
|
|
151
|
+
// current pathname only differs by a trailing slash from a real route.
|
|
152
|
+
const trailingSlashRedirect = nextRoute.redirect;
|
|
153
|
+
if (trailingSlashRedirect) {
|
|
154
|
+
const transitionMethod = isReplace || isInitialNavigation ? 'replace' : 'push';
|
|
155
|
+
return this._history[transitionMethod](trailingSlashRedirect.path, {
|
|
156
|
+
redirectedFrom: redirectedFrom || nextRoute,
|
|
157
|
+
state: { status: trailingSlashRedirect.status }
|
|
158
|
+
});
|
|
159
|
+
}
|
|
146
160
|
const routeContext = new Map();
|
|
147
161
|
const queue = skipGuards
|
|
148
162
|
? []
|
|
@@ -273,8 +287,21 @@ class Router {
|
|
|
273
287
|
const matches = this.match(to);
|
|
274
288
|
const params = matches.length ? matches[matches.length - 1].params : {};
|
|
275
289
|
const parsedPath = (0, utils_1.resolvePath)(to);
|
|
276
|
-
|
|
290
|
+
const route = Object.assign(Object.assign({ matches,
|
|
277
291
|
params }, parsedPath), { key: '', state: null });
|
|
292
|
+
// Strict trailing-slash routing: when the pathname does not match but a
|
|
293
|
+
// canonical variant (differing only by a trailing slash) does, surface a
|
|
294
|
+
// redirect so the transition layer can redirect to the canonical URL.
|
|
295
|
+
if (this._strictTrailingSlash && !matches.length) {
|
|
296
|
+
const canonical = (0, matchRoutes_1.getTrailingSlashRedirectPath)(parsedPath.pathname || '/');
|
|
297
|
+
if (canonical) {
|
|
298
|
+
const rematched = (0, matchRoutes_1.matchRoutes)(this._routes, Object.assign(Object.assign({}, parsedPath), { pathname: canonical }), undefined, { strict: true });
|
|
299
|
+
if (rematched && rematched.length) {
|
|
300
|
+
route.redirect = { path: canonical, status: 308 };
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
return route;
|
|
278
305
|
}
|
|
279
306
|
}
|
|
280
307
|
const createRouter = (options) => {
|
package/lib/types/router.d.ts
CHANGED
|
@@ -44,6 +44,7 @@ export type IPathPattern = string | {
|
|
|
44
44
|
path: string;
|
|
45
45
|
caseSensitive?: boolean;
|
|
46
46
|
end?: boolean;
|
|
47
|
+
strict?: boolean;
|
|
47
48
|
};
|
|
48
49
|
export interface IPathMatch {
|
|
49
50
|
path: string;
|
|
@@ -57,6 +58,16 @@ export interface IRoute<RouteRecord extends IRouteRecord = IRouteRecord> extends
|
|
|
57
58
|
matches: IRouteMatch<RouteRecord>[];
|
|
58
59
|
redirected?: boolean;
|
|
59
60
|
key: string;
|
|
61
|
+
/**
|
|
62
|
+
* Set when strict trailing-slash routing resolves a canonical URL for the
|
|
63
|
+
* current (non-matching) pathname. Carries the redirect target + status so
|
|
64
|
+
* the transition layer can issue a redirect instead of falling through to
|
|
65
|
+
* the not-found page.
|
|
66
|
+
*/
|
|
67
|
+
redirect?: {
|
|
68
|
+
path: string;
|
|
69
|
+
status: number;
|
|
70
|
+
};
|
|
60
71
|
}
|
|
61
72
|
export interface IRouter<RouteRecord extends {
|
|
62
73
|
path: string;
|
|
@@ -64,6 +75,7 @@ export interface IRouter<RouteRecord extends {
|
|
|
64
75
|
current: IRoute<RouteRecord>;
|
|
65
76
|
action: History['action'];
|
|
66
77
|
basename: string;
|
|
78
|
+
strictTrailingSlash: boolean;
|
|
67
79
|
push(to: PathRecord, state?: any): void;
|
|
68
80
|
replace(to: PathRecord, state?: any): void;
|
|
69
81
|
go: History['go'];
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@shuvi/router",
|
|
3
|
-
"version": "2.0.0-dev.
|
|
3
|
+
"version": "2.0.0-dev.33",
|
|
4
4
|
"repository": {
|
|
5
5
|
"type": "git",
|
|
6
6
|
"url": "git+https://github.com/shuvijs/shuvi.git",
|
|
@@ -28,7 +28,7 @@
|
|
|
28
28
|
"node": ">= 16.0.0"
|
|
29
29
|
},
|
|
30
30
|
"dependencies": {
|
|
31
|
-
"@shuvi/utils": "2.0.0-dev.
|
|
31
|
+
"@shuvi/utils": "2.0.0-dev.32",
|
|
32
32
|
"query-string": "6.13.8"
|
|
33
33
|
}
|
|
34
34
|
}
|