router.ts 5.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196
  1. import type { NextAppSpec, MatchedRoute } from "./types";
  2. interface CompiledRoute {
  3. pattern: string;
  4. regex: RegExp;
  5. paramNames: string[];
  6. /** Whether the last segment is catch-all or optional catch-all */
  7. catchAll: boolean;
  8. optionalCatchAll: boolean;
  9. /** Number of static segments (higher = more specific) */
  10. specificity: number;
  11. }
  12. /**
  13. * Compile a Next.js route pattern into a regex matcher.
  14. *
  15. * Supports:
  16. * - Static segments: `/about`, `/blog`
  17. * - Dynamic segments: `/blog/[slug]`
  18. * - Catch-all segments: `/docs/[...path]`
  19. * - Optional catch-all segments: `/settings/[[...path]]`
  20. */
  21. function compileRoute(pattern: string): CompiledRoute {
  22. const paramNames: string[] = [];
  23. let catchAll = false;
  24. let optionalCatchAll = false;
  25. let specificity = 0;
  26. const segments = pattern === "/" ? [""] : pattern.split("/").slice(1);
  27. const regexParts: string[] = [];
  28. for (const segment of segments) {
  29. if (segment.startsWith("[[...") && segment.endsWith("]]")) {
  30. const paramName = segment.slice(5, -2);
  31. paramNames.push(paramName);
  32. optionalCatchAll = true;
  33. regexParts.push("(?:/(.+))?");
  34. } else if (segment.startsWith("[...") && segment.endsWith("]")) {
  35. const paramName = segment.slice(4, -1);
  36. paramNames.push(paramName);
  37. catchAll = true;
  38. regexParts.push("/(.+)");
  39. } else if (segment.startsWith("[") && segment.endsWith("]")) {
  40. const paramName = segment.slice(1, -1);
  41. paramNames.push(paramName);
  42. regexParts.push("/([^/]+)");
  43. } else {
  44. specificity++;
  45. regexParts.push(`/${escapeRegExp(segment)}`);
  46. }
  47. }
  48. const regexStr = pattern === "/" ? "^/$" : `^${regexParts.join("")}$`;
  49. return {
  50. pattern,
  51. regex: new RegExp(regexStr),
  52. paramNames,
  53. catchAll,
  54. optionalCatchAll,
  55. specificity,
  56. };
  57. }
  58. function escapeRegExp(str: string): string {
  59. return str.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
  60. }
  61. /**
  62. * Match a pathname against a spec's routes.
  63. *
  64. * Routes are matched in order of specificity:
  65. * 1. Exact/static matches first (most specific)
  66. * 2. Dynamic segment matches
  67. * 3. Catch-all matches (least specific)
  68. * 4. Optional catch-all matches (fallback)
  69. *
  70. * @returns The matched route with extracted params, or null if no match.
  71. */
  72. export function matchRoute(
  73. spec: NextAppSpec,
  74. pathname: string,
  75. ): MatchedRoute | null {
  76. const normalizedPath = pathname === "" ? "/" : pathname;
  77. const compiled = Object.keys(spec.routes).map(compileRoute);
  78. compiled.sort((a, b) => {
  79. if (a.optionalCatchAll !== b.optionalCatchAll) {
  80. return a.optionalCatchAll ? 1 : -1;
  81. }
  82. if (a.catchAll !== b.catchAll) {
  83. return a.catchAll ? 1 : -1;
  84. }
  85. if (a.specificity !== b.specificity) {
  86. return b.specificity - a.specificity;
  87. }
  88. return a.paramNames.length - b.paramNames.length;
  89. });
  90. for (const route of compiled) {
  91. const match = route.regex.exec(normalizedPath);
  92. if (!match) continue;
  93. const params: Record<string, string | string[]> = {};
  94. for (let i = 0; i < route.paramNames.length; i++) {
  95. const value = match[i + 1];
  96. const name = route.paramNames[i]!;
  97. if (route.catchAll || route.optionalCatchAll) {
  98. params[name] = value ? value.split("/") : [];
  99. } else {
  100. params[name] = value ?? "";
  101. }
  102. }
  103. return {
  104. route: spec.routes[route.pattern]!,
  105. pattern: route.pattern,
  106. params,
  107. };
  108. }
  109. return null;
  110. }
  111. /**
  112. * Convert a Next.js catch-all slug array to a pathname.
  113. *
  114. * @example
  115. * slugToPath(undefined) // "/"
  116. * slugToPath([]) // "/"
  117. * slugToPath(["blog"]) // "/blog"
  118. * slugToPath(["blog","hi"]) // "/blog/hi"
  119. */
  120. export function slugToPath(slug: string[] | undefined): string {
  121. if (!slug || slug.length === 0) return "/";
  122. return "/" + slug.join("/");
  123. }
  124. /**
  125. * Collect all static params from the spec for generateStaticParams.
  126. * Returns params suitable for Next.js [[...slug]] catch-all routes.
  127. */
  128. export function collectStaticParams(spec: NextAppSpec): { slug: string[] }[] {
  129. const results: { slug: string[] }[] = [];
  130. for (const [pattern, route] of Object.entries(spec.routes)) {
  131. if (route.staticParams) {
  132. for (const paramSet of route.staticParams) {
  133. const slug = buildSlugFromPattern(pattern, paramSet);
  134. if (slug) results.push({ slug });
  135. }
  136. } else if (!pattern.includes("[")) {
  137. const slug = pattern === "/" ? [] : pattern.slice(1).split("/");
  138. results.push({ slug });
  139. }
  140. }
  141. return results;
  142. }
  143. /**
  144. * Build a slug array from a route pattern and a set of params.
  145. */
  146. function buildSlugFromPattern(
  147. pattern: string,
  148. params: Record<string, string>,
  149. ): string[] | null {
  150. if (pattern === "/") return [];
  151. const segments = pattern.split("/").slice(1);
  152. const result: string[] = [];
  153. for (const segment of segments) {
  154. if (segment.startsWith("[[...") && segment.endsWith("]]")) {
  155. const paramName = segment.slice(5, -2);
  156. const value = params[paramName];
  157. if (value) result.push(...value.split("/"));
  158. } else if (segment.startsWith("[...") && segment.endsWith("]")) {
  159. const paramName = segment.slice(4, -1);
  160. const value = params[paramName];
  161. if (!value) return null;
  162. result.push(...value.split("/"));
  163. } else if (segment.startsWith("[") && segment.endsWith("]")) {
  164. const paramName = segment.slice(1, -1);
  165. const value = params[paramName];
  166. if (!value) return null;
  167. result.push(value);
  168. } else {
  169. result.push(segment);
  170. }
  171. }
  172. return result;
  173. }