UNPKG

14.1 kBJavaScriptView Raw
1import escape from 'escape-string-regexp';
2import * as queryString from 'query-string';
3import findFocusedRoute from './findFocusedRoute';
4import validatePathConfig from './validatePathConfig';
5
6/**
7 * Utility to parse a path string to initial state object accepted by the container.
8 * This is useful for deep linking when we need to handle the incoming URL.
9 *
10 * @example
11 * ```js
12 * getStateFromPath(
13 * '/chat/jane/42',
14 * {
15 * screens: {
16 * Chat: {
17 * path: 'chat/:author/:id',
18 * parse: { id: Number }
19 * }
20 * }
21 * }
22 * )
23 * ```
24 * @param path Path string to parse and convert, e.g. /foo/bar?count=42.
25 * @param options Extra options to fine-tune how to parse the path.
26 */
27export default function getStateFromPath(path, options) {
28 if (options) {
29 validatePathConfig(options);
30 }
31
32 let initialRoutes = [];
33
34 if (options !== null && options !== void 0 && options.initialRouteName) {
35 initialRoutes.push({
36 initialRouteName: options.initialRouteName,
37 parentScreens: []
38 });
39 }
40
41 const screens = options === null || options === void 0 ? void 0 : options.screens;
42 let remaining = path.replace(/\/+/g, '/') // Replace multiple slash (//) with single ones
43 .replace(/^\//, '') // Remove extra leading slash
44 .replace(/\?.*$/, ''); // Remove query params which we will handle later
45 // Make sure there is a trailing slash
46
47 remaining = remaining.endsWith('/') ? remaining : `${remaining}/`;
48
49 if (screens === undefined) {
50 // When no config is specified, use the path segments as route names
51 const routes = remaining.split('/').filter(Boolean).map(segment => {
52 const name = decodeURIComponent(segment);
53 return {
54 name
55 };
56 });
57
58 if (routes.length) {
59 return createNestedStateObject(path, routes, initialRoutes);
60 }
61
62 return undefined;
63 } // Create a normalized configs array which will be easier to use
64
65
66 const configs = [].concat(...Object.keys(screens).map(key => createNormalizedConfigs(key, screens, [], initialRoutes, []))).sort((a, b) => {
67 // Sort config so that:
68 // - the most exhaustive ones are always at the beginning
69 // - patterns with wildcard are always at the end
70 // If 2 patterns are same, move the one with less route names up
71 // This is an error state, so it's only useful for consistent error messages
72 if (a.pattern === b.pattern) {
73 return b.routeNames.join('>').localeCompare(a.routeNames.join('>'));
74 } // If one of the patterns starts with the other, it's more exhaustive
75 // So move it up
76
77
78 if (a.pattern.startsWith(b.pattern)) {
79 return -1;
80 }
81
82 if (b.pattern.startsWith(a.pattern)) {
83 return 1;
84 }
85
86 const aParts = a.pattern.split('/');
87 const bParts = b.pattern.split('/');
88
89 for (let i = 0; i < Math.max(aParts.length, bParts.length); i++) {
90 // if b is longer, b get higher priority
91 if (aParts[i] == null) {
92 return 1;
93 } // if a is longer, a get higher priority
94
95
96 if (bParts[i] == null) {
97 return -1;
98 }
99
100 const aWildCard = aParts[i] === '*' || aParts[i].startsWith(':');
101 const bWildCard = bParts[i] === '*' || bParts[i].startsWith(':'); // if both are wildcard we compare next component
102
103 if (aWildCard && bWildCard) {
104 continue;
105 } // if only a is wild card, b get higher priority
106
107
108 if (aWildCard) {
109 return 1;
110 } // if only b is wild card, a get higher priority
111
112
113 if (bWildCard) {
114 return -1;
115 }
116 }
117
118 return bParts.length - aParts.length;
119 }); // Check for duplicate patterns in the config
120
121 configs.reduce((acc, config) => {
122 if (acc[config.pattern]) {
123 const a = acc[config.pattern].routeNames;
124 const b = config.routeNames; // It's not a problem if the path string omitted from a inner most screen
125 // For example, it's ok if a path resolves to `A > B > C` or `A > B`
126
127 const intersects = a.length > b.length ? b.every((it, i) => a[i] === it) : a.every((it, i) => b[i] === it);
128
129 if (!intersects) {
130 throw new Error(`Found conflicting screens with the same pattern. The pattern '${config.pattern}' resolves to both '${a.join(' > ')}' and '${b.join(' > ')}'. Patterns must be unique and cannot resolve to more than one screen.`);
131 }
132 }
133
134 return Object.assign(acc, {
135 [config.pattern]: config
136 });
137 }, {});
138
139 if (remaining === '/') {
140 // We need to add special handling of empty path so navigation to empty path also works
141 // When handling empty path, we should only look at the root level config
142 const match = configs.find(config => config.path === '' && config.routeNames.every( // Make sure that none of the parent configs have a non-empty path defined
143 name => {
144 var _configs$find;
145
146 return !((_configs$find = configs.find(c => c.screen === name)) !== null && _configs$find !== void 0 && _configs$find.path);
147 }));
148
149 if (match) {
150 return createNestedStateObject(path, match.routeNames.map(name => ({
151 name
152 })), initialRoutes, configs);
153 }
154
155 return undefined;
156 }
157
158 let result;
159 let current; // We match the whole path against the regex instead of segments
160 // This makes sure matches such as wildcard will catch any unmatched routes, even if nested
161
162 const {
163 routes,
164 remainingPath
165 } = matchAgainstConfigs(remaining, configs.map(c => ({ ...c,
166 // Add `$` to the regex to make sure it matches till end of the path and not just beginning
167 regex: c.regex ? new RegExp(c.regex.source + '$') : undefined
168 })));
169
170 if (routes !== undefined) {
171 // This will always be empty if full path matched
172 current = createNestedStateObject(path, routes, initialRoutes, configs);
173 remaining = remainingPath;
174 result = current;
175 }
176
177 if (current == null || result == null) {
178 return undefined;
179 }
180
181 return result;
182}
183
184const joinPaths = function () {
185 for (var _len = arguments.length, paths = new Array(_len), _key = 0; _key < _len; _key++) {
186 paths[_key] = arguments[_key];
187 }
188
189 return [].concat(...paths.map(p => p.split('/'))).filter(Boolean).join('/');
190};
191
192const matchAgainstConfigs = (remaining, configs) => {
193 let routes;
194 let remainingPath = remaining; // Go through all configs, and see if the next path segment matches our regex
195
196 for (const config of configs) {
197 if (!config.regex) {
198 continue;
199 }
200
201 const match = remainingPath.match(config.regex); // If our regex matches, we need to extract params from the path
202
203 if (match) {
204 var _config$pattern;
205
206 const matchedParams = (_config$pattern = config.pattern) === null || _config$pattern === void 0 ? void 0 : _config$pattern.split('/').filter(p => p.startsWith(':')).reduce((acc, p, i) => Object.assign(acc, {
207 // The param segments appear every second item starting from 2 in the regex match result
208 [p]: match[(i + 1) * 2].replace(/\//, '')
209 }), {});
210 routes = config.routeNames.map(name => {
211 var _config$path;
212
213 const config = configs.find(c => c.screen === name);
214 const params = config === null || config === void 0 ? void 0 : (_config$path = config.path) === null || _config$path === void 0 ? void 0 : _config$path.split('/').filter(p => p.startsWith(':')).reduce((acc, p) => {
215 const value = matchedParams[p];
216
217 if (value) {
218 var _config$parse;
219
220 const key = p.replace(/^:/, '').replace(/\?$/, '');
221 acc[key] = (_config$parse = config.parse) !== null && _config$parse !== void 0 && _config$parse[key] ? config.parse[key](value) : value;
222 }
223
224 return acc;
225 }, {});
226
227 if (params && Object.keys(params).length) {
228 return {
229 name,
230 params
231 };
232 }
233
234 return {
235 name
236 };
237 });
238 remainingPath = remainingPath.replace(match[1], '');
239 break;
240 }
241 }
242
243 return {
244 routes,
245 remainingPath
246 };
247};
248
249const createNormalizedConfigs = function (screen, routeConfig) {
250 let routeNames = arguments.length > 2 && arguments[2] !== undefined ? arguments[2] : [];
251 let initials = arguments.length > 3 ? arguments[3] : undefined;
252 let parentScreens = arguments.length > 4 ? arguments[4] : undefined;
253 let parentPattern = arguments.length > 5 ? arguments[5] : undefined;
254 const configs = [];
255 routeNames.push(screen);
256 parentScreens.push(screen); // @ts-expect-error: we can't strongly typecheck this for now
257
258 const config = routeConfig[screen];
259
260 if (typeof config === 'string') {
261 // If a string is specified as the value of the key(e.g. Foo: '/path'), use it as the pattern
262 const pattern = parentPattern ? joinPaths(parentPattern, config) : config;
263 configs.push(createConfigItem(screen, routeNames, pattern, config));
264 } else if (typeof config === 'object') {
265 let pattern; // if an object is specified as the value (e.g. Foo: { ... }),
266 // it can have `path` property and
267 // it could have `screens` prop which has nested configs
268
269 if (typeof config.path === 'string') {
270 if (config.exact && config.path === undefined) {
271 throw new Error("A 'path' needs to be specified when specifying 'exact: true'. If you don't want this screen in the URL, specify it as empty string, e.g. `path: ''`.");
272 }
273
274 pattern = config.exact !== true ? joinPaths(parentPattern || '', config.path || '') : config.path || '';
275 configs.push(createConfigItem(screen, routeNames, pattern, config.path, config.parse));
276 }
277
278 if (config.screens) {
279 // property `initialRouteName` without `screens` has no purpose
280 if (config.initialRouteName) {
281 initials.push({
282 initialRouteName: config.initialRouteName,
283 parentScreens
284 });
285 }
286
287 Object.keys(config.screens).forEach(nestedConfig => {
288 var _pattern;
289
290 const result = createNormalizedConfigs(nestedConfig, config.screens, routeNames, initials, [...parentScreens], (_pattern = pattern) !== null && _pattern !== void 0 ? _pattern : parentPattern);
291 configs.push(...result);
292 });
293 }
294 }
295
296 routeNames.pop();
297 return configs;
298};
299
300const createConfigItem = (screen, routeNames, pattern, path, parse) => {
301 // Normalize pattern to remove any leading, trailing slashes, duplicate slashes etc.
302 pattern = pattern.split('/').filter(Boolean).join('/');
303 const regex = pattern ? new RegExp(`^(${pattern.split('/').map(it => {
304 if (it.startsWith(':')) {
305 return `(([^/]+\\/)${it.endsWith('?') ? '?' : ''})`;
306 }
307
308 return `${it === '*' ? '.*' : escape(it)}\\/`;
309 }).join('')})`) : undefined;
310 return {
311 screen,
312 regex,
313 pattern,
314 path,
315 // The routeNames array is mutated, so copy it to keep the current state
316 routeNames: [...routeNames],
317 parse
318 };
319};
320
321const findParseConfigForRoute = (routeName, flatConfig) => {
322 for (const config of flatConfig) {
323 if (routeName === config.routeNames[config.routeNames.length - 1]) {
324 return config.parse;
325 }
326 }
327
328 return undefined;
329}; // Try to find an initial route connected with the one passed
330
331
332const findInitialRoute = (routeName, parentScreens, initialRoutes) => {
333 for (const config of initialRoutes) {
334 if (parentScreens.length === config.parentScreens.length) {
335 let sameParents = true;
336
337 for (let i = 0; i < parentScreens.length; i++) {
338 if (parentScreens[i].localeCompare(config.parentScreens[i]) !== 0) {
339 sameParents = false;
340 break;
341 }
342 }
343
344 if (sameParents) {
345 return routeName !== config.initialRouteName ? config.initialRouteName : undefined;
346 }
347 }
348 }
349
350 return undefined;
351}; // returns state object with values depending on whether
352// it is the end of state and if there is initialRoute for this level
353
354
355const createStateObject = (initialRoute, route, isEmpty) => {
356 if (isEmpty) {
357 if (initialRoute) {
358 return {
359 index: 1,
360 routes: [{
361 name: initialRoute
362 }, route]
363 };
364 } else {
365 return {
366 routes: [route]
367 };
368 }
369 } else {
370 if (initialRoute) {
371 return {
372 index: 1,
373 routes: [{
374 name: initialRoute
375 }, { ...route,
376 state: {
377 routes: []
378 }
379 }]
380 };
381 } else {
382 return {
383 routes: [{ ...route,
384 state: {
385 routes: []
386 }
387 }]
388 };
389 }
390 }
391};
392
393const createNestedStateObject = (path, routes, initialRoutes, flatConfig) => {
394 let state;
395 let route = routes.shift();
396 const parentScreens = [];
397 let initialRoute = findInitialRoute(route.name, parentScreens, initialRoutes);
398 parentScreens.push(route.name);
399 state = createStateObject(initialRoute, route, routes.length === 0);
400
401 if (routes.length > 0) {
402 let nestedState = state;
403
404 while (route = routes.shift()) {
405 initialRoute = findInitialRoute(route.name, parentScreens, initialRoutes);
406 const nestedStateIndex = nestedState.index || nestedState.routes.length - 1;
407 nestedState.routes[nestedStateIndex].state = createStateObject(initialRoute, route, routes.length === 0);
408
409 if (routes.length > 0) {
410 nestedState = nestedState.routes[nestedStateIndex].state;
411 }
412
413 parentScreens.push(route.name);
414 }
415 }
416
417 route = findFocusedRoute(state);
418 route.path = path;
419 const params = parseQueryParams(path, flatConfig ? findParseConfigForRoute(route.name, flatConfig) : undefined);
420
421 if (params) {
422 route.params = { ...route.params,
423 ...params
424 };
425 }
426
427 return state;
428};
429
430const parseQueryParams = (path, parseConfig) => {
431 const query = path.split('?')[1];
432 const params = queryString.parse(query);
433
434 if (parseConfig) {
435 Object.keys(params).forEach(name => {
436 if (Object.hasOwnProperty.call(parseConfig, name) && typeof params[name] === 'string') {
437 params[name] = parseConfig[name](params[name]);
438 }
439 });
440 }
441
442 return Object.keys(params).length ? params : undefined;
443};
444//# sourceMappingURL=getStateFromPath.js.map
\No newline at end of file