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