1import { PathConfigMap } from '@react-navigation/core';
2import type { InitialState, NavigationState, PartialState } from '@react-navigation/routers';
3import escape from 'escape-string-regexp';
4import * as queryString from 'query-string';
5import URL from 'url-parse';
6
7import { findFocusedRoute } from './findFocusedRoute';
8import validatePathConfig from './validatePathConfig';
9import { RouteNode } from '../Route';
10import { matchGroupName, stripGroupSegmentsFromPath } from '../matchers';
11
12type Options<ParamList extends object> = {
13  initialRouteName?: string;
14  screens: PathConfigMap<ParamList>;
15};
16
17type ParseConfig = Record<string, (value: string) => any>;
18
19type RouteConfig = {
20  isInitial?: boolean;
21  screen: string;
22  regex?: RegExp;
23  path: string;
24  pattern: string;
25  routeNames: string[];
26  parse?: ParseConfig;
27  hasChildren: boolean;
28  userReadableName: string;
29  _route?: RouteNode;
30};
31
32type InitialRouteConfig = {
33  initialRouteName: string;
34  parentScreens: string[];
35};
36
37export type ResultState = PartialState<NavigationState> & {
38  state?: ResultState;
39};
40
41type ParsedRoute = {
42  name: string;
43  path?: string;
44  params?: Record<string, any> | undefined;
45};
46
47export function getUrlWithReactNavigationConcessions(path: string) {
48  const parsed = new URL(path, 'https://acme.com');
49  const pathname = parsed.pathname;
50
51  // Make sure there is a trailing slash
52  return {
53    // The slashes are at the end, not the beginning
54    nonstandardPathname: pathname.replace(/^\/+/g, '').replace(/\/+$/g, '') + '/',
55    // React Navigation doesn't support hashes, so here
56    inputPathnameWithoutHash: path.replace(/#.*$/, ''),
57  };
58}
59
60/**
61 * Utility to parse a path string to initial state object accepted by the container.
62 * This is useful for deep linking when we need to handle the incoming URL.
63 *
64 * @example
65 * ```js
66 * getStateFromPath(
67 *   '/chat/jane/42',
68 *   {
69 *     screens: {
70 *       Chat: {
71 *         path: 'chat/:author/:id',
72 *         parse: { id: Number }
73 *       }
74 *     }
75 *   }
76 * )
77 * ```
78 * @param path Path string to parse and convert, e.g. /foo/bar?count=42.
79 * @param options Extra options to fine-tune how to parse the path.
80 */
81export default function getStateFromPath<ParamList extends object>(
82  path: string,
83  options?: Options<ParamList>
84): ResultState | undefined {
85  const { initialRoutes, configs } = getMatchableRouteConfigs(options);
86
87  return getStateFromPathWithConfigs(path, configs, initialRoutes);
88}
89
90export function getMatchableRouteConfigs<ParamList extends object>(options?: Options<ParamList>) {
91  if (options) {
92    validatePathConfig(options);
93  }
94
95  const screens = options?.screens;
96  // Expo Router disallows usage without a linking config.
97  if (!screens) {
98    throw Error("You must pass a 'screens' object to 'getStateFromPath' to generate a path.");
99  }
100
101  // This will be mutated...
102  const initialRoutes: InitialRouteConfig[] = [];
103
104  if (options?.initialRouteName) {
105    initialRoutes.push({
106      initialRouteName: options.initialRouteName,
107      parentScreens: [],
108    });
109  }
110
111  // Create a normalized configs array which will be easier to use.
112  const converted = Object.keys(screens)
113    .map((key) => createNormalizedConfigs(key, screens, [], initialRoutes))
114    .flat();
115
116  const resolvedInitialPatterns = initialRoutes.map((route) =>
117    joinPaths(...route.parentScreens, route.initialRouteName)
118  );
119
120  const convertedWithInitial = converted.map((config) => ({
121    ...config,
122    // TODO(EvanBacon): Probably a safer way to do this
123    // Mark initial routes to give them potential priority over other routes that match.
124    isInitial: resolvedInitialPatterns.includes(config.routeNames.join('/')),
125  }));
126
127  // Sort in order of resolution. This is extremely important for the algorithm to work.
128  const configs = convertedWithInitial.sort(sortConfigs);
129
130  // Assert any duplicates before we start parsing.
131  assertConfigDuplicates(configs);
132
133  return { configs, initialRoutes };
134}
135
136function assertConfigDuplicates(configs: RouteConfig[]) {
137  // Check for duplicate patterns in the config
138  configs.reduce<Record<string, RouteConfig>>((acc, config) => {
139    // NOTE(EvanBacon): Uses the regex pattern as key to detect duplicate slugs.
140    const indexedKey = config.regex?.toString() ?? config.pattern;
141    const alpha = acc[indexedKey];
142    // NOTE(EvanBacon): Skips checking nodes that have children.
143    if (alpha && !alpha.hasChildren && !config.hasChildren) {
144      const a = alpha.routeNames;
145      const b = config.routeNames;
146
147      // It's not a problem if the path string omitted from a inner most screen
148      // For example, it's ok if a path resolves to `A > B > C` or `A > B`
149      const intersects =
150        a.length > b.length ? b.every((it, i) => a[i] === it) : a.every((it, i) => b[i] === it);
151
152      if (!intersects) {
153        // NOTE(EvanBacon): Adds more context to the error message since we know about the
154        // file-based routing.
155        const last = config.pattern.split('/').pop();
156        const routeType = last?.startsWith(':')
157          ? 'dynamic route'
158          : last?.startsWith('*')
159          ? 'dynamic-rest route'
160          : 'route';
161        throw new Error(
162          `The ${routeType} pattern '${config.pattern || '/'}' resolves to both '${
163            alpha.userReadableName
164          }' and '${
165            config.userReadableName
166          }'. Patterns must be unique and cannot resolve to more than one route.`
167        );
168      }
169    }
170
171    return Object.assign(acc, {
172      [indexedKey]: config,
173    });
174  }, {});
175}
176
177function sortConfigs(a: RouteConfig, b: RouteConfig): number {
178  // Sort config so that:
179  // - the most exhaustive ones are always at the beginning
180  // - patterns with wildcard are always at the end
181
182  // If 2 patterns are same, move the one with less route names up
183  // This is an error state, so it's only useful for consistent error messages
184  if (a.pattern === b.pattern) {
185    return b.routeNames.join('>').localeCompare(a.routeNames.join('>'));
186  }
187
188  // If one of the patterns starts with the other, it's more exhaustive
189  // So move it up
190  if (
191    a.pattern.startsWith(b.pattern) &&
192    // NOTE(EvanBacon): This is a hack to make sure that `*` is always at the end
193    b.screen !== 'index'
194  ) {
195    return -1;
196  }
197
198  if (b.pattern.startsWith(a.pattern) && a.screen !== 'index') {
199    return 1;
200  }
201
202  // NOTE(EvanBacon): Here we append `index` if the screen was `index` so the length is the same
203  // as a slug or wildcard when nested more than one level deep.
204  // This is so we can compare the length of the pattern, e.g. `foo/*` > `foo` vs `*` < ``.
205  const aParts = a.pattern
206    .split('/')
207    // Strip out group names to ensure they don't affect the priority.
208    .filter((part) => matchGroupName(part) == null);
209  if (a.screen === 'index') {
210    aParts.push('index');
211  }
212
213  const bParts = b.pattern.split('/').filter((part) => matchGroupName(part) == null);
214  if (b.screen === 'index') {
215    bParts.push('index');
216  }
217
218  for (let i = 0; i < Math.max(aParts.length, bParts.length); i++) {
219    // if b is longer, b get higher priority
220    if (aParts[i] == null) {
221      return 1;
222    }
223    // if a is longer, a get higher priority
224    if (bParts[i] == null) {
225      return -1;
226    }
227    const aWildCard = aParts[i].startsWith('*');
228    const bWildCard = bParts[i].startsWith('*');
229    // if both are wildcard we compare next component
230    if (aWildCard && bWildCard) {
231      continue;
232    }
233    // if only a is wild card, b get higher priority
234    if (aWildCard) {
235      return 1;
236    }
237    // if only b is wild card, a get higher priority
238    if (bWildCard) {
239      return -1;
240    }
241
242    const aSlug = aParts[i].startsWith(':');
243    const bSlug = bParts[i].startsWith(':');
244    // if both are wildcard we compare next component
245    if (aSlug && bSlug) {
246      continue;
247    }
248    // if only a is wild card, b get higher priority
249    if (aSlug) {
250      return 1;
251    }
252    // if only b is wild card, a get higher priority
253    if (bSlug) {
254      return -1;
255    }
256  }
257
258  // Sort initial routes with a higher priority than routes which will push more screens
259  // this ensures shared routes go to the shortest path.
260  if (a.isInitial && !b.isInitial) {
261    return -1;
262  }
263  if (!a.isInitial && b.isInitial) {
264    return 1;
265  }
266
267  return bParts.length - aParts.length;
268}
269
270function getStateFromEmptyPathWithConfigs(
271  path: string,
272  configs: RouteConfig[],
273  initialRoutes: InitialRouteConfig[]
274): ResultState | undefined {
275  // We need to add special handling of empty path so navigation to empty path also works
276  // When handling empty path, we should only look at the root level config
277
278  // NOTE(EvanBacon): We only care about matching leaf nodes.
279  const leafNodes = configs
280    .filter((config) => !config.hasChildren)
281    .map((value) => {
282      return {
283        ...value,
284        // Collapse all levels of group segments before testing.
285        // This enables `app/(one)/(two)/index.js` to be matched.
286        path: stripGroupSegmentsFromPath(value.path),
287      };
288    });
289
290  const match =
291    leafNodes.find(
292      (config) =>
293        // NOTE(EvanBacon): Test leaf node index routes that either don't have a regex or match an empty string.
294        config.path === '' && (!config.regex || config.regex.test(''))
295    ) ??
296    leafNodes.find(
297      (config) =>
298        // NOTE(EvanBacon): Test leaf node dynamic routes that match an empty string.
299        config.path.startsWith(':') && config.regex!.test('')
300    ) ??
301    // NOTE(EvanBacon): Test leaf node deep dynamic routes that match a slash.
302    // This should be done last to enable dynamic routes having a higher priority.
303    leafNodes.find((config) => config.path.startsWith('*') && config.regex!.test('/'));
304
305  if (!match) {
306    return undefined;
307  }
308
309  const routes = match.routeNames.map((name) => {
310    if (!match._route) {
311      return { name };
312    }
313    return {
314      name,
315      _route: match._route,
316    };
317  });
318
319  return createNestedStateObject(path, routes, configs, initialRoutes);
320}
321
322function getStateFromPathWithConfigs(
323  path: string,
324  configs: RouteConfig[],
325  initialRoutes: InitialRouteConfig[]
326): ResultState | undefined {
327  const formattedPaths = getUrlWithReactNavigationConcessions(path);
328
329  if (formattedPaths.nonstandardPathname === '/') {
330    return getStateFromEmptyPathWithConfigs(
331      formattedPaths.inputPathnameWithoutHash,
332      configs,
333      initialRoutes
334    );
335  }
336
337  // We match the whole path against the regex instead of segments
338  // This makes sure matches such as wildcard will catch any unmatched routes, even if nested
339  const routes = matchAgainstConfigs(formattedPaths.nonstandardPathname, configs);
340
341  if (routes == null) {
342    return undefined;
343  }
344  // This will always be empty if full path matched
345  return createNestedStateObject(
346    formattedPaths.inputPathnameWithoutHash,
347    routes,
348    configs,
349    initialRoutes
350  );
351}
352
353const joinPaths = (...paths: string[]): string =>
354  ([] as string[])
355    .concat(...paths.map((p) => p.split('/')))
356    .filter(Boolean)
357    .join('/');
358
359function matchAgainstConfigs(remaining: string, configs: RouteConfig[]): ParsedRoute[] | undefined {
360  let routes: ParsedRoute[] | undefined;
361  let remainingPath = remaining;
362
363  // Go through all configs, and see if the next path segment matches our regex
364  for (const config of configs) {
365    if (!config.regex) {
366      continue;
367    }
368
369    const match = remainingPath.match(config.regex);
370
371    // If our regex matches, we need to extract params from the path
372    if (!match) {
373      continue;
374    }
375
376    // TODO: Add support for wildcard routes
377    const matchedParams = config.pattern
378      ?.split('/')
379      .filter((p) => p.match(/^[:*]/))
380      .reduce<Record<string, any>>((acc, p, i) => {
381        if (p.match(/^\*/)) {
382          return {
383            ...acc,
384            [p]: match![(i + 1) * 2], //?.replace(/\//, ""),
385          };
386        }
387        return Object.assign(acc, {
388          // The param segments appear every second item starting from 2 in the regex match result.
389          // This will only work if we ensure groups aren't included in the match.
390          [p]: match![(i + 1) * 2]?.replace(/\//, ''),
391        });
392      }, {});
393
394    const routeFromName = (name: string) => {
395      const config = configs.find((c) => c.screen === name);
396      if (!config?.path) {
397        return { name };
398      }
399
400      const segments = config.path.split('/');
401
402      const params: Record<string, any> = {};
403
404      segments
405        .filter((p) => p.match(/^[:*]/))
406        .forEach((p) => {
407          let value = matchedParams[p];
408          if (value) {
409            if (p.match(/^\*/)) {
410              // Convert to an array before providing as a route.
411              value = value?.split('/').filter(Boolean);
412            }
413
414            const key = p.replace(/^[:*]/, '').replace(/\?$/, '');
415            params[key] = config.parse?.[key] ? config.parse[key](value) : value;
416          }
417        });
418
419      if (params && Object.keys(params).length) {
420        return { name, params };
421      }
422
423      return { name };
424    };
425
426    routes = config.routeNames.map((name) => {
427      if (!config._route) {
428        return { ...routeFromName(name) };
429      }
430      return {
431        ...routeFromName(name),
432        _route: config._route,
433      };
434    });
435
436    // TODO(EvanBacon): Maybe we should warn / assert if multiple slugs use the same param name.
437    const combinedParams = routes.reduce<Record<string, any>>(
438      (acc, r) => Object.assign(acc, r.params),
439      {}
440    );
441
442    const hasCombinedParams = Object.keys(combinedParams).length > 0;
443
444    // Combine all params so a route `[foo]/[bar]/other.js` has access to `{ foo, bar }`
445    routes = routes.map((r) => {
446      if (hasCombinedParams) {
447        r.params = combinedParams;
448      }
449      return r;
450    });
451
452    remainingPath = remainingPath.replace(match[1], '');
453
454    break;
455  }
456
457  return routes;
458}
459
460function equalHeritage(a: string[], b: string[]): boolean {
461  if (a.length !== b.length) {
462    return false;
463  }
464  for (let i = 0; i < a.length; i++) {
465    if (a[i].localeCompare(b[i]) !== 0) {
466      return false;
467    }
468  }
469  return true;
470}
471
472const createNormalizedConfigs = (
473  screen: string,
474  routeConfig: PathConfigMap<object>,
475  routeNames: string[] = [],
476  initials: InitialRouteConfig[] = [],
477  parentScreens: string[] = [],
478  parentPattern?: string
479): RouteConfig[] => {
480  const configs: RouteConfig[] = [];
481
482  routeNames.push(screen);
483
484  parentScreens.push(screen);
485
486  const config = (routeConfig as any)[screen];
487
488  if (typeof config === 'string') {
489    // TODO: This should never happen with the addition of `_route`
490
491    // If a string is specified as the value of the key(e.g. Foo: '/path'), use it as the pattern
492    const pattern = parentPattern ? joinPaths(parentPattern, config) : config;
493
494    configs.push(createConfigItem(screen, routeNames, pattern, config, false));
495  } else if (typeof config === 'object') {
496    let pattern: string | undefined;
497
498    const { _route } = config;
499    // if an object is specified as the value (e.g. Foo: { ... }),
500    // it can have `path` property and
501    // it could have `screens` prop which has nested configs
502    if (typeof config.path === 'string') {
503      if (config.exact && config.path === undefined) {
504        throw new Error(
505          "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: ''`."
506        );
507      }
508
509      pattern =
510        config.exact !== true
511          ? joinPaths(parentPattern || '', config.path || '')
512          : config.path || '';
513
514      configs.push(
515        createConfigItem(
516          screen,
517          routeNames,
518          pattern!,
519          config.path,
520          config.screens ? !!Object.keys(config.screens)?.length : false,
521          config.parse,
522          _route
523        )
524      );
525    }
526
527    if (config.screens) {
528      // property `initialRouteName` without `screens` has no purpose
529      if (config.initialRouteName) {
530        initials.push({
531          initialRouteName: config.initialRouteName,
532          parentScreens,
533        });
534      }
535
536      Object.keys(config.screens).forEach((nestedConfig) => {
537        const result = createNormalizedConfigs(
538          nestedConfig,
539          config.screens as PathConfigMap<object>,
540          routeNames,
541          initials,
542          [...parentScreens],
543          pattern ?? parentPattern
544        );
545
546        configs.push(...result);
547      });
548    }
549  }
550
551  routeNames.pop();
552
553  return configs;
554};
555
556function formatRegexPattern(it: string): string {
557  // Allow spaces in file path names.
558  it = it.replace(' ', '%20');
559
560  if (it.startsWith(':')) {
561    // TODO: Remove unused match group
562    return `(([^/]+\\/)${it.endsWith('?') ? '?' : ''})`;
563  } else if (it.startsWith('*')) {
564    return `((.*\\/)${it.endsWith('?') ? '?' : ''})`;
565  }
566
567  // Strip groups from the matcher
568  if (matchGroupName(it) != null) {
569    // Groups are optional segments
570    // this enables us to match `/bar` and `/(foo)/bar` for the same route
571    // NOTE(EvanBacon): Ignore this match in the regex to avoid capturing the group
572    return `(?:${escape(it)}\\/)?`;
573  }
574
575  return escape(it) + `\\/`;
576}
577
578const createConfigItem = (
579  screen: string,
580  routeNames: string[],
581  pattern: string,
582  path: string,
583  hasChildren?: boolean,
584  parse?: ParseConfig,
585  _route?: any
586): RouteConfig => {
587  // Normalize pattern to remove any leading, trailing slashes, duplicate slashes etc.
588  pattern = pattern.split('/').filter(Boolean).join('/');
589
590  const regex = pattern
591    ? new RegExp(`^(${pattern.split('/').map(formatRegexPattern).join('')})$`)
592    : undefined;
593
594  return {
595    screen,
596    regex,
597    pattern,
598    path,
599    // The routeNames array is mutated, so copy it to keep the current state
600    routeNames: [...routeNames],
601    parse,
602    userReadableName: [...routeNames.slice(0, -1), path || screen].join('/'),
603    hasChildren: !!hasChildren,
604    _route,
605  };
606};
607
608const findParseConfigForRoute = (
609  routeName: string,
610  routeConfigs: RouteConfig[]
611): ParseConfig | undefined => {
612  for (const config of routeConfigs) {
613    if (routeName === config.routeNames[config.routeNames.length - 1]) {
614      return config.parse;
615    }
616  }
617
618  return undefined;
619};
620
621// Try to find an initial route connected with the one passed
622const findInitialRoute = (
623  routeName: string,
624  parentScreens: string[],
625  initialRoutes: InitialRouteConfig[]
626): string | undefined => {
627  for (const config of initialRoutes) {
628    if (equalHeritage(parentScreens, config.parentScreens)) {
629      // If the parents are the same but the route name doesn't match the initial route
630      // then we return the initial route.
631      return routeName !== config.initialRouteName ? config.initialRouteName : undefined;
632    }
633  }
634  return undefined;
635};
636
637// returns state object with values depending on whether
638// it is the end of state and if there is initialRoute for this level
639const createStateObject = (
640  initialRoute: string | undefined,
641  route: ParsedRoute,
642  isEmpty: boolean
643): InitialState => {
644  if (isEmpty) {
645    if (initialRoute) {
646      return {
647        index: 1,
648        routes: [{ name: initialRoute }, route],
649      };
650    }
651    return {
652      routes: [route],
653    };
654  }
655
656  if (initialRoute) {
657    return {
658      index: 1,
659      routes: [{ name: initialRoute }, { ...route, state: { routes: [] } }],
660    };
661  }
662  return {
663    routes: [{ ...route, state: { routes: [] } }],
664  };
665};
666
667const createNestedStateObject = (
668  path: string,
669  routes: ParsedRoute[],
670  routeConfigs: RouteConfig[],
671  initialRoutes: InitialRouteConfig[]
672) => {
673  let route = routes.shift() as ParsedRoute;
674  const parentScreens: string[] = [];
675
676  let initialRoute = findInitialRoute(route.name, parentScreens, initialRoutes);
677
678  parentScreens.push(route.name);
679
680  const state: InitialState = createStateObject(initialRoute, route, routes.length === 0);
681
682  if (routes.length > 0) {
683    let nestedState = state;
684
685    while ((route = routes.shift() as ParsedRoute)) {
686      initialRoute = findInitialRoute(route.name, parentScreens, initialRoutes);
687
688      const nestedStateIndex = nestedState.index || nestedState.routes.length - 1;
689
690      nestedState.routes[nestedStateIndex].state = createStateObject(
691        initialRoute,
692        route,
693        routes.length === 0
694      );
695
696      if (routes.length > 0) {
697        nestedState = nestedState.routes[nestedStateIndex].state as InitialState;
698      }
699
700      parentScreens.push(route.name);
701    }
702  }
703
704  route = findFocusedRoute(state) as ParsedRoute;
705
706  // Remove groups from the path while preserving a trailing slash.
707  route.path = stripGroupSegmentsFromPath(path);
708
709  const params = parseQueryParams(route.path, findParseConfigForRoute(route.name, routeConfigs));
710
711  if (params) {
712    const resolvedParams = { ...route.params, ...params };
713    if (Object.keys(resolvedParams).length > 0) {
714      route.params = resolvedParams;
715    } else {
716      delete route.params;
717    }
718  }
719
720  return state;
721};
722
723const parseQueryParams = (path: string, parseConfig?: Record<string, (value: string) => any>) => {
724  const query = path.split('?')[1];
725  const params = queryString.parse(query);
726
727  if (parseConfig) {
728    Object.keys(params).forEach((name) => {
729      if (Object.hasOwnProperty.call(parseConfig, name) && typeof params[name] === 'string') {
730        params[name] = parseConfig[name](params[name] as string);
731      }
732    });
733  }
734
735  return Object.keys(params).length ? params : undefined;
736};
737