@uxf/router 11.126.0 → 11.127.0

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/README.md CHANGED
@@ -108,6 +108,61 @@ Add configuration to `tsconfig.json`
108
108
  }
109
109
  ```
110
110
 
111
+ ## routeToUrl
112
+
113
+ Builds a URL from a route name and its params. Path params are substituted into the bracketed segments; anything left over becomes the query string.
114
+
115
+ ```ts
116
+ routeToUrl("blog/detail", { id: 12 }); /* "/blog/[id]" -> "/blog/12" */
117
+ routeToUrl("admin/index", { param1: 3 }); /* "/admin?param1=3" */
118
+ routeToUrl("localized-route", { term: "x" }, { locale: "cs" }); /* "/cs/domu?term=x" */
119
+ routeToUrl("index", {}, { shouldBeAbsolute: true }); /* "https://www.uxf.cz/" */
120
+ ```
121
+
122
+ **A missing required param throws.** Any bracketed segment still present after substitution means the URL would be broken, so it fails loudly rather than shipping a href containing `[id]`:
123
+
124
+ ```ts
125
+ routeToUrl("blog/detail", {});
126
+ // Error: Missing parameter '[id]' for route 'blog/detail'.
127
+ ```
128
+
129
+ **Optional catch-all segments may be omitted entirely.** For a path like `/catch-all-optional/[[...pathParams]]`, the segment is stripped whether the key is absent, `null`, `undefined`, `""` or `[]`:
130
+
131
+ ```ts
132
+ routeToUrl("optionalCatchAll", {}); /* "/catch-all-optional" */
133
+ routeToUrl("optionalCatchAll", { pathParams: null }); /* "/catch-all-optional" */
134
+ routeToUrl("optionalCatchAll", { pathParams: ["a", "b"] }); /* "/catch-all-optional/a/b" */
135
+ ```
136
+
137
+ A **required** catch-all (`[...pathParams]`) is not optional: an empty array throws `Parameter 'pathParams' can not be empty array for route '…'`, and omitting the key throws the missing-parameter error above.
138
+
139
+ ## getRouteInfo
140
+
141
+ Resolves a pathname back to the route that declared it, or `null` when nothing matches. The result is `RouteInfo` = `{ pathname: string; routeName: string; routeDefinition: RouteDefinition }` — note it carries the pathname, not parsed params.
142
+
143
+ ```ts
144
+ getRouteInfo(
145
+ "/blog/12",
146
+ ); /* { pathname: "/blog/12", routeName: "blog/detail", routeDefinition: { path: "/blog/[id]", … } } */
147
+ getRouteInfo("/not-a-route"); /* null */
148
+ ```
149
+
150
+ **Candidates are ordered by specificity, not by declaration order** — fewest catch-all segments first, then fewest dynamic segments, then most static segments. A localized route is ranked by its most specific variant. So a static path is never shadowed by a dynamic one declared before it:
151
+
152
+ ```ts
153
+ const routes = {
154
+ hotelDetail: { path: "/hotel/[hotel-id]" },
155
+ hotelCreate: { path: "/hotel/create-hotel" },
156
+ hotelCatchAll: { path: "/hotel/[...rest]" },
157
+ } as const;
158
+
159
+ getRouteInfo("/hotel/create-hotel")?.routeName; /* "hotelCreate", not "hotelDetail" */
160
+ getRouteInfo("/hotel/12")?.routeName; /* "hotelDetail" */
161
+ getRouteInfo("/hotel/12/a/b")?.routeName; /* "hotelCatchAll" - only when nothing more specific matches */
162
+ ```
163
+
164
+ `createRouteMatcher` delegates to `getRouteInfo`, so it follows the same ordering — which is what makes active-route detection in navigation and layouts agree with the resolved route.
165
+
111
166
  ## useQueryParams
112
167
 
113
168
  Hooks live in the client entry (`@app-routes/client`):
package/create-router.js CHANGED
@@ -2,9 +2,12 @@
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.createRouter = createRouter;
4
4
  const empty_object_1 = require("@uxf/core/constants/empty-object");
5
+ const is_empty_1 = require("@uxf/core/utils/is-empty");
6
+ const is_not_empty_1 = require("@uxf/core/utils/is-not-empty");
5
7
  const qs_1 = require("@uxf/core/utils/qs");
6
8
  const throw_error_1 = require("@uxf/core/utils/throw-error");
7
9
  const sitemap_generator_1 = require("./sitemap-generator");
10
+ const path_specificity_1 = require("./utils/path-specificity");
8
11
  const path_to_regex_1 = require("./utils/path-to-regex");
9
12
  /**
10
13
  * Arguments can be:
@@ -32,8 +35,15 @@ function createRouter(routes, routerOptions) {
32
35
  : Object.values(routeDefinition.path).map((path) => (0, path_to_regex_1.pathToRegex)(path)),
33
36
  },
34
37
  }), {});
38
+ const routeNamesBySpecificity = Object.entries(routes)
39
+ .map(([routeName, routeDefinition]) => ({
40
+ routeName,
41
+ specificity: (0, path_specificity_1.getRouteSpecificity)(routeDefinition.path),
42
+ }))
43
+ .sort((a, b) => (0, path_specificity_1.comparePathSpecificity)(a.specificity, b.specificity))
44
+ .map((entry) => entry.routeName);
35
45
  const routeToUrl = (...args) => {
36
- var _a;
46
+ var _a, _b;
37
47
  const { route, params, options } = decodeArgs(args);
38
48
  let pathname = typeof routes[route].path === "string"
39
49
  ? routes[route].path
@@ -51,10 +61,7 @@ function createRouter(routes, routerOptions) {
51
61
  pathname = pathname.replace(segment, value);
52
62
  }
53
63
  else if (pathname.includes(catchAllSegmentsOptional)) {
54
- if ((Array.isArray(value) && value.length === 0) ||
55
- value === null ||
56
- value === undefined ||
57
- value === "") {
64
+ if ((Array.isArray(value) && (0, is_empty_1.isEmpty)(value)) || value === null || value === undefined || value === "") {
58
65
  pathname = pathname.replace(`/${catchAllSegmentsOptional}`, "");
59
66
  }
60
67
  else if (Array.isArray(value)) {
@@ -68,7 +75,7 @@ function createRouter(routes, routerOptions) {
68
75
  else if (pathname.includes(catchAllSegments)) {
69
76
  // catch all segments
70
77
  if (Array.isArray(value)) {
71
- if (value.length === 0) {
78
+ if ((0, is_empty_1.isEmpty)(value)) {
72
79
  throw new Error(`Parameter '${key}' can not be empty array for route '${String(route)}'`);
73
80
  }
74
81
  pathname = pathname.replace(catchAllSegments, value.join("/"));
@@ -81,20 +88,31 @@ function createRouter(routes, routerOptions) {
81
88
  restParams[key] = value;
82
89
  }
83
90
  });
84
- if (Object.keys(restParams).length > 0) {
91
+ // Optional catch-all segments must be stripped even when their key is absent from `params`
92
+ // (the loop above only visits provided keys). Otherwise a literal `/[[...xxx]]` leaks into the
93
+ // URL and Next.js — unable to know the brackets were an unsubstituted placeholder — falls back
94
+ // to a full-document navigation.
95
+ pathname = pathname.replace(/\/\[\[\.\.\.[^\]]+\]\]/g, "");
96
+ // Any bracketed segment still present means a required param was never provided; the resulting
97
+ // URL would be broken, so fail loudly instead of shipping it.
98
+ const firstUnresolved = (_b = pathname.match(/\[\[?\.\.\.[^\]]+\]\]?|\[[^\]]+\]/)) === null || _b === void 0 ? void 0 : _b.at(0);
99
+ if (firstUnresolved) {
100
+ throw new Error(`Missing parameter '${firstUnresolved}' for route '${String(route)}'.`);
101
+ }
102
+ if ((0, is_not_empty_1.isNotEmpty)(Object.keys(restParams))) {
85
103
  pathname = `${pathname}?${(0, qs_1.stringify)(restParams, { arrayFormat: "repeat" })}`;
86
104
  }
87
105
  return (options === null || options === void 0 ? void 0 : options.shouldBeAbsolute) ? `${routerOptions.baseUrl}${pathname}` : pathname;
88
106
  };
89
107
  const getRouteInfo = (pathname) => {
90
- const entry = Object.entries(routesWithRegex).find(([, routeDefinition]) => routeDefinition.regex.some((regex) => regex.test(pathname)));
91
- if (!entry) {
108
+ const routeName = routeNamesBySpecificity.find((name) => routesWithRegex[name].regex.some((regex) => regex.test(pathname)));
109
+ if (!routeName) {
92
110
  return null;
93
111
  }
94
112
  return {
95
113
  pathname,
96
- routeName: entry[0],
97
- routeDefinition: entry[1],
114
+ routeName,
115
+ routeDefinition: routesWithRegex[routeName],
98
116
  };
99
117
  };
100
118
  return {
@@ -121,7 +139,7 @@ function createRouter(routes, routerOptions) {
121
139
  if ((routeInfo === null || routeInfo === void 0 ? void 0 : routeInfo.routeName) !== requiredRouteName) {
122
140
  return false;
123
141
  }
124
- if (!requiredParams || Object.keys(requiredParams).length === 0) {
142
+ if (!requiredParams || (0, is_empty_1.isEmpty)(Object.keys(requiredParams))) {
125
143
  return true;
126
144
  }
127
145
  for (const [paramName, paramValue] of Object.entries(requiredParams)) {
@@ -99,6 +99,16 @@ const DATA = [
99
99
  actual: routeToUrl("optionalCatchAllSegments", { pathParams: "", queryParam: "value" }),
100
100
  expected: "/catch-all-optional?queryParam=value",
101
101
  },
102
+ {
103
+ // key omitted entirely — the optional segment must still be stripped
104
+ actual: routeToUrl("optionalCatchAllSegments", {}),
105
+ expected: "/catch-all-optional",
106
+ },
107
+ {
108
+ // key omitted but a query param is present
109
+ actual: routeToUrl("optionalCatchAllSegments", { queryParam: "value" }),
110
+ expected: "/catch-all-optional?queryParam=value",
111
+ },
102
112
  {
103
113
  actual: routeToUrl("routeWithoutParams"),
104
114
  expected: "/route-without-params",
@@ -139,6 +149,14 @@ const DATA = [
139
149
  test("routeToUrl", () => {
140
150
  DATA.map(({ actual, expected }) => expect(actual).toBe(expected));
141
151
  });
152
+ test("routeToUrl throws when a required param is missing", () => {
153
+ expect(() =>
154
+ // @ts-expect-error param2 is required, so this call is invalid on purpose — the point is the runtime guard
155
+ routeToUrl("manyParameters", { param1: "value-1" })).toThrow("Missing parameter '[param2]' for route 'manyParameters'.");
156
+ expect(() =>
157
+ // @ts-expect-error pathParams is required, so this call is invalid on purpose — the point is the runtime guard
158
+ routeToUrl("catchAllSegments", {})).toThrow("Missing parameter '[...pathParams]' for route 'catchAllSegments'.");
159
+ });
142
160
  test("create route matcher", () => {
143
161
  expect(createRouteMatcher("manyParameters", { param1: "value-1" })("/many-parameters/value-1/form/any", {
144
162
  param1: "value-1",
@@ -178,3 +196,37 @@ test("create-router has no client-only imports", () => {
178
196
  expect(source).not.toMatch(/from "react"/);
179
197
  expect(source).not.toMatch(/from "react-dom"/);
180
198
  });
199
+ describe("getRouteInfo", () => {
200
+ const { getRouteInfo, createRouteMatcher: matcher } = (0, create_router_1.createRouter)({
201
+ hotelDetail: { path: "/hotel/[hotel-id]" },
202
+ hotelEdit: { path: "/hotel/[hotel-id]/edit" },
203
+ hotelTab: { path: "/hotel/[hotel-id]/[tab]" },
204
+ hotelCreate: { path: "/hotel/create-hotel" },
205
+ hotelCatchAll: { path: "/hotel/[...rest]" },
206
+ }, {});
207
+ it("resolves a static path declared after a matching dynamic one", () => {
208
+ var _a;
209
+ expect((_a = getRouteInfo("/hotel/create-hotel")) === null || _a === void 0 ? void 0 : _a.routeName).toBe("hotelCreate");
210
+ });
211
+ it("resolves dynamic paths", () => {
212
+ var _a, _b, _c;
213
+ expect((_a = getRouteInfo("/hotel/12")) === null || _a === void 0 ? void 0 : _a.routeName).toBe("hotelDetail");
214
+ expect((_b = getRouteInfo("/hotel/12/edit")) === null || _b === void 0 ? void 0 : _b.routeName).toBe("hotelEdit");
215
+ expect((_c = getRouteInfo("/hotel/12/rooms")) === null || _c === void 0 ? void 0 : _c.routeName).toBe("hotelTab");
216
+ });
217
+ it("falls back to a catch-all only when nothing more specific matches", () => {
218
+ var _a;
219
+ expect((_a = getRouteInfo("/hotel/12/a/b")) === null || _a === void 0 ? void 0 : _a.routeName).toBe("hotelCatchAll");
220
+ });
221
+ it("returns null when nothing matches", () => {
222
+ expect(getRouteInfo("/not-a-route")).toBeNull();
223
+ });
224
+ it("exposes the matched route definition", () => {
225
+ var _a;
226
+ expect((_a = getRouteInfo("/hotel/create-hotel")) === null || _a === void 0 ? void 0 : _a.routeDefinition.path).toBe("/hotel/create-hotel");
227
+ });
228
+ it("makes createRouteMatcher agree with getRouteInfo", () => {
229
+ expect(matcher("hotelCreate")("/hotel/create-hotel")).toBe(true);
230
+ expect(matcher("hotelDetail")("/hotel/create-hotel")).toBe(false);
231
+ });
232
+ });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@uxf/router",
3
- "version": "11.126.0",
3
+ "version": "11.127.0",
4
4
  "description": "UXF Router",
5
5
  "author": "UXFans <dev@uxf.cz>",
6
6
  "homepage": "https://gitlab.com/uxf-npm/router#readme",
@@ -26,12 +26,12 @@
26
26
  "true-case-path": "2.2.1"
27
27
  },
28
28
  "peerDependencies": {
29
- "@uxf/core": "11.126.0",
29
+ "@uxf/core": "11.127.0",
30
30
  "next": ">= 12",
31
31
  "superstruct": "^2.0.2"
32
32
  },
33
33
  "devDependencies": {
34
- "@uxf/core": "11.126.0",
34
+ "@uxf/core": "11.127.0",
35
35
  "next": "16.3.0",
36
36
  "superstruct": "^2.0.2"
37
37
  }
@@ -0,0 +1,16 @@
1
+ export type PathSpecificity = {
2
+ catchAllSegmentCount: number;
3
+ dynamicSegmentCount: number;
4
+ staticSegmentCount: number;
5
+ };
6
+ export declare function getPathSpecificity(path: string): PathSpecificity;
7
+ /**
8
+ * Orders route candidates from the most to the least specific: fewest catch-all segments first, then
9
+ * fewest dynamic segments, then most static segments. Used so that a pathname resolves to the most
10
+ * specific matching route instead of whichever route happens to be declared first.
11
+ */
12
+ export declare function comparePathSpecificity(a: PathSpecificity, b: PathSpecificity): number;
13
+ /**
14
+ * Specificity of a route definition's path. A localized route is ranked by its most specific variant.
15
+ */
16
+ export declare function getRouteSpecificity(path: string | Partial<Record<string, string>>): PathSpecificity;
@@ -0,0 +1,39 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.getPathSpecificity = getPathSpecificity;
4
+ exports.comparePathSpecificity = comparePathSpecificity;
5
+ exports.getRouteSpecificity = getRouteSpecificity;
6
+ const is_not_nil_1 = require("@uxf/core/utils/is-not-nil");
7
+ const LEAST_SPECIFIC = {
8
+ catchAllSegmentCount: Number.MAX_SAFE_INTEGER,
9
+ dynamicSegmentCount: Number.MAX_SAFE_INTEGER,
10
+ staticSegmentCount: 0,
11
+ };
12
+ function getPathSpecificity(path) {
13
+ const segments = path.split("/").filter((segment) => segment !== "");
14
+ const dynamicSegments = segments.filter((segment) => segment.startsWith("["));
15
+ const catchAllSegments = dynamicSegments.filter((segment) => segment.startsWith("[...") || segment.startsWith("[[..."));
16
+ return {
17
+ catchAllSegmentCount: catchAllSegments.length,
18
+ dynamicSegmentCount: dynamicSegments.length,
19
+ staticSegmentCount: segments.length - dynamicSegments.length,
20
+ };
21
+ }
22
+ /**
23
+ * Orders route candidates from the most to the least specific: fewest catch-all segments first, then
24
+ * fewest dynamic segments, then most static segments. Used so that a pathname resolves to the most
25
+ * specific matching route instead of whichever route happens to be declared first.
26
+ */
27
+ function comparePathSpecificity(a, b) {
28
+ return (a.catchAllSegmentCount - b.catchAllSegmentCount ||
29
+ a.dynamicSegmentCount - b.dynamicSegmentCount ||
30
+ b.staticSegmentCount - a.staticSegmentCount);
31
+ }
32
+ /**
33
+ * Specificity of a route definition's path. A localized route is ranked by its most specific variant.
34
+ */
35
+ function getRouteSpecificity(path) {
36
+ var _a;
37
+ const paths = typeof path === "string" ? [path] : Object.values(path).filter(is_not_nil_1.isNotNil);
38
+ return (_a = paths.map(getPathSpecificity).sort(comparePathSpecificity).at(0)) !== null && _a !== void 0 ? _a : LEAST_SPECIFIC;
39
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,72 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ const path_specificity_1 = require("./path-specificity");
4
+ describe("getPathSpecificity", () => {
5
+ it("counts static, dynamic and catch-all segments", () => {
6
+ expect((0, path_specificity_1.getPathSpecificity)("/hotel/create-hotel")).toEqual({
7
+ catchAllSegmentCount: 0,
8
+ dynamicSegmentCount: 0,
9
+ staticSegmentCount: 2,
10
+ });
11
+ expect((0, path_specificity_1.getPathSpecificity)("/hotel/[hotel-id]/edit")).toEqual({
12
+ catchAllSegmentCount: 0,
13
+ dynamicSegmentCount: 1,
14
+ staticSegmentCount: 2,
15
+ });
16
+ expect((0, path_specificity_1.getPathSpecificity)("/hotel/[...rest]")).toEqual({
17
+ catchAllSegmentCount: 1,
18
+ dynamicSegmentCount: 1,
19
+ staticSegmentCount: 1,
20
+ });
21
+ expect((0, path_specificity_1.getPathSpecificity)("/hotel/[[...rest]]")).toEqual({
22
+ catchAllSegmentCount: 1,
23
+ dynamicSegmentCount: 1,
24
+ staticSegmentCount: 1,
25
+ });
26
+ });
27
+ it("handles the root path", () => {
28
+ expect((0, path_specificity_1.getPathSpecificity)("/")).toEqual({
29
+ catchAllSegmentCount: 0,
30
+ dynamicSegmentCount: 0,
31
+ staticSegmentCount: 0,
32
+ });
33
+ });
34
+ });
35
+ describe("comparePathSpecificity", () => {
36
+ const sortPaths = (paths) => [...paths].sort((a, b) => (0, path_specificity_1.comparePathSpecificity)((0, path_specificity_1.getPathSpecificity)(a), (0, path_specificity_1.getPathSpecificity)(b)));
37
+ it("puts a static path before a dynamic one", () => {
38
+ expect(sortPaths(["/hotel/[hotel-id]", "/hotel/create-hotel"])).toEqual([
39
+ "/hotel/create-hotel",
40
+ "/hotel/[hotel-id]",
41
+ ]);
42
+ });
43
+ it("puts catch-all segments last", () => {
44
+ expect(sortPaths(["/hotel/[...rest]", "/hotel/[hotel-id]", "/hotel/detail"])).toEqual([
45
+ "/hotel/detail",
46
+ "/hotel/[hotel-id]",
47
+ "/hotel/[...rest]",
48
+ ]);
49
+ });
50
+ it("prefers more static segments when the dynamic count is equal", () => {
51
+ expect(sortPaths(["/hotel/[hotel-id]/[tab]", "/hotel/[hotel-id]/edit"])).toEqual([
52
+ "/hotel/[hotel-id]/edit",
53
+ "/hotel/[hotel-id]/[tab]",
54
+ ]);
55
+ });
56
+ });
57
+ describe("getRouteSpecificity", () => {
58
+ it("accepts a plain path", () => {
59
+ expect((0, path_specificity_1.getRouteSpecificity)("/hotel/[hotel-id]")).toEqual({
60
+ catchAllSegmentCount: 0,
61
+ dynamicSegmentCount: 1,
62
+ staticSegmentCount: 1,
63
+ });
64
+ });
65
+ it("ranks a localized route by its most specific variant", () => {
66
+ expect((0, path_specificity_1.getRouteSpecificity)({ cs: "/hotel/[hotel-id]", en: "/hotel/detail" })).toEqual({
67
+ catchAllSegmentCount: 0,
68
+ dynamicSegmentCount: 0,
69
+ staticSegmentCount: 2,
70
+ });
71
+ });
72
+ });