zudoku 0.91.0 → 0.93.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/dist/cli/cli.js CHANGED
@@ -3402,6 +3402,7 @@ var ApiOptionsSchema = z7.object({
3402
3402
  supportedLanguages: z7.array(LanguageOption),
3403
3403
  disablePlayground: z7.boolean(),
3404
3404
  disableSidecar: z7.boolean(),
3405
+ disableRequestBox: z7.boolean(),
3405
3406
  showVersionSelect: z7.enum(["always", "if-available", "hide"]),
3406
3407
  expandAllTags: z7.boolean(),
3407
3408
  showInfoPage: z7.boolean(),
@@ -6816,6 +6817,7 @@ var generateDefaultApiOptionsCode = () => [
6816
6817
  ` supportedLanguages: config.defaults?.apis?.supportedLanguages,`,
6817
6818
  ` disablePlayground: config.defaults?.apis?.disablePlayground,`,
6818
6819
  ` disableSidecar: config.defaults?.apis?.disableSidecar,`,
6820
+ ` disableRequestBox: config.defaults?.apis?.disableRequestBox,`,
6819
6821
  ` disableSecurity: config.defaults?.apis?.disableSecurity ?? true,`,
6820
6822
  ` disableMcpAuthInstructions: config.defaults?.apis?.disableMcpAuthInstructions,`,
6821
6823
  ` showVersionSelect: config.defaults?.apis?.showVersionSelect ?? "if-available",`,
@@ -9105,6 +9107,98 @@ import { cp, mkdir as mkdir3, readdir, rm, writeFile as writeFile4 } from "node:
9105
9107
  import path20 from "node:path";
9106
9108
  init_joinUrl();
9107
9109
 
9110
+ // src/lib/util/redirectPattern.ts
9111
+ init_joinUrl();
9112
+ var SPLAT = "*";
9113
+ var ABSOLUTE_URL_REGEX = /^(?:[a-z][a-z0-9+.-]*:|[\\/]{2})/i;
9114
+ var TOKEN_REGEX = /\/(?::([\w-]+)|\*)(?=[/?#]|$)/g;
9115
+ var parseSegments = (path32) => {
9116
+ const parts = path32.split("/").filter(Boolean);
9117
+ return parts.map((part, index) => {
9118
+ if (part === SPLAT && index === parts.length - 1) return { type: "splat" };
9119
+ const match = part.match(/^:([\w-]+)(\?)?$/);
9120
+ if (!match?.[1]) return { type: "static", value: part };
9121
+ return { type: "param", name: match[1], optional: match[2] === "?" };
9122
+ });
9123
+ };
9124
+ var getParamNames = (from) => parseSegments(from).flatMap((segment) => {
9125
+ if (segment.type === "param") return [segment.name];
9126
+ if (segment.type === "splat") return [SPLAT];
9127
+ return [];
9128
+ });
9129
+ var isDynamicRedirect = (redirect) => getParamNames(redirect.from).length > 0;
9130
+ var replaceTokens = (to, names, replace) => {
9131
+ const target = to.replace(TOKEN_REGEX, (token, name) => {
9132
+ const key = name ?? SPLAT;
9133
+ return names.includes(key) ? replace(key) : token;
9134
+ });
9135
+ const lostPath = target === "" || /^[?#]/.test(target);
9136
+ return target !== to && lostPath ? `/${target}` : target;
9137
+ };
9138
+ var rankPath = (path32) => {
9139
+ const segments = joinUrl(path32).split("/");
9140
+ return segments.reduce(
9141
+ (score, segment) => {
9142
+ if (segment === SPLAT) return score;
9143
+ if (segment.startsWith(":")) return score + 3;
9144
+ return score + (segment === "" ? 1 : 10);
9145
+ },
9146
+ segments.length - (segments.includes(SPLAT) ? 2 : 0)
9147
+ );
9148
+ };
9149
+ var segmentToPath = (segment) => {
9150
+ if (segment.type === "static") return segment.value;
9151
+ if (segment.type === "splat") return SPLAT;
9152
+ return `:${segment.name}`;
9153
+ };
9154
+ var explodeOptional = (segments) => {
9155
+ const [first, ...rest] = segments;
9156
+ if (!first) return [[]];
9157
+ const variants = explodeOptional(rest);
9158
+ const withFirst = variants.map((variant) => [first, ...variant]);
9159
+ return first.type === "param" && first.optional ? [...withFirst, ...variants] : withFirst;
9160
+ };
9161
+ var escapeRegex = (value) => value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
9162
+ var toBuildOutputRedirects = (redirects, basePath) => {
9163
+ const base = joinUrl(basePath);
9164
+ const prefix = base === "/" ? "" : escapeRegex(base);
9165
+ return redirects.flatMap((redirect) => {
9166
+ const names = getParamNames(redirect.from);
9167
+ const to = ABSOLUTE_URL_REGEX.test(redirect.to) ? redirect.to : joinUrl(basePath, redirect.to);
9168
+ const toRoute = (segments) => {
9169
+ const captured = segments.flatMap((segment) => {
9170
+ if (segment.type === "param") return [segment.name];
9171
+ if (segment.type === "splat") return [SPLAT];
9172
+ return [];
9173
+ });
9174
+ const pattern = segments.map((segment) => {
9175
+ if (segment.type === "static") {
9176
+ return `/${escapeRegex(segment.value)}`;
9177
+ }
9178
+ return segment.type === "splat" ? "(/.+)" : "(/[^/]+)";
9179
+ }).join("");
9180
+ const endsWithSplat = segments.at(-1)?.type === "splat";
9181
+ return {
9182
+ // React Router accepts any number of trailing slashes
9183
+ src: `^${prefix}${pattern}${endsWithSplat ? "" : "/*"}$`,
9184
+ // Like React Router, a repeated param name takes the last value
9185
+ location: replaceTokens(
9186
+ to,
9187
+ names,
9188
+ (name) => captured.includes(name) ? `$${captured.lastIndexOf(name) + 1}` : ""
9189
+ )
9190
+ };
9191
+ };
9192
+ return explodeOptional(parseSegments(redirect.from)).flatMap(
9193
+ (segments) => {
9194
+ const rank = rankPath(`/${segments.map(segmentToPath).join("/")}`);
9195
+ const variants = segments.at(-1)?.type === "splat" ? [segments, segments.slice(0, -1)] : [segments];
9196
+ return variants.map((variant) => ({ rank, ...toRoute(variant) }));
9197
+ }
9198
+ );
9199
+ }).toSorted((a, b) => b.rank - a.rank).map(({ src, location }) => ({ src, location }));
9200
+ };
9201
+
9108
9202
  // src/vite/vercel-markdown-middleware.ts
9109
9203
  var normalizePath2 = (value) => {
9110
9204
  const pathname = value.split("/").filter(Boolean).map((segment) => {
@@ -9573,8 +9667,20 @@ function generateOutput({
9573
9667
  continue: true
9574
9668
  });
9575
9669
  }
9576
- if (rewrites.length > 0) {
9670
+ const dynamicRedirects = toBuildOutputRedirects(
9671
+ (config.redirects ?? []).filter(isDynamicRedirect),
9672
+ config.basePath
9673
+ );
9674
+ if (rewrites.length > 0 || dynamicRedirects.length > 0) {
9577
9675
  routes.push({ handle: "filesystem" });
9676
+ for (const { src, location } of dynamicRedirects) {
9677
+ routes.push({
9678
+ src,
9679
+ dest: location,
9680
+ status: 301,
9681
+ headers: { Location: location }
9682
+ });
9683
+ }
9578
9684
  for (const rewrite of rewrites) {
9579
9685
  routes.push({
9580
9686
  src: joinUrl(config.basePath, rewrite.source),
@@ -9776,10 +9882,16 @@ var collectRewrites = (resolved) => resolved.flatMap((r) => [
9776
9882
  ...collectRewrites(r.children)
9777
9883
  ]);
9778
9884
  var routesToPaths = (routes) => collectPaths(resolveRoutes(routes), "");
9885
+ var withoutDynamicRedirectRoutes = (routes, redirects = []) => {
9886
+ const paths = new Set(
9887
+ redirects.filter(isDynamicRedirect).map(({ from }) => joinUrl(from))
9888
+ );
9889
+ return routes.filter((route) => !route.path || !paths.has(route.path));
9890
+ };
9779
9891
  var routesToPrerenderPaths = (routes, redirects = []) => Array.from(
9780
9892
  /* @__PURE__ */ new Set([
9781
9893
  ...routesToPaths(routes),
9782
- ...redirects.map(({ from }) => joinUrl(from))
9894
+ ...redirects.filter((redirect) => !isDynamicRedirect(redirect)).map(({ from }) => joinUrl(from))
9783
9895
  ])
9784
9896
  );
9785
9897
  var routesToRewrites = (routes) => collectRewrites(resolveRoutes(routes));
@@ -9824,7 +9936,10 @@ var prerender = async ({
9824
9936
  const buildConfig = await getBuildConfig();
9825
9937
  const module = await import(entryServerPath);
9826
9938
  const getRoutes = module.getRoutesByConfig;
9827
- const routes = getRoutes(config);
9939
+ const routes = withoutDynamicRedirectRoutes(
9940
+ getRoutes(config),
9941
+ config.redirects
9942
+ );
9828
9943
  const paths = routesToPrerenderPaths(routes, config.redirects);
9829
9944
  const rewrites = routesToRewrites(routes);
9830
9945
  const { maxThreads, maxOldGenerationSizeMb } = getWorkerScaling(
@@ -2514,6 +2514,7 @@ var ApiOptionsSchema = z4.object({
2514
2514
  supportedLanguages: z4.array(LanguageOption),
2515
2515
  disablePlayground: z4.boolean(),
2516
2516
  disableSidecar: z4.boolean(),
2517
+ disableRequestBox: z4.boolean(),
2517
2518
  showVersionSelect: z4.enum(["always", "if-available", "hide"]),
2518
2519
  expandAllTags: z4.boolean(),
2519
2520
  showInfoPage: z4.boolean(),
@@ -49,6 +49,7 @@ declare const ApiOptionsSchema: z.ZodObject<{
49
49
  }, z.core.$strip>>>;
50
50
  disablePlayground: z.ZodOptional<z.ZodBoolean>;
51
51
  disableSidecar: z.ZodOptional<z.ZodBoolean>;
52
+ disableRequestBox: z.ZodOptional<z.ZodBoolean>;
52
53
  showVersionSelect: z.ZodOptional<z.ZodEnum<{
53
54
  always: "always";
54
55
  hide: "hide";
@@ -7741,6 +7742,7 @@ export declare const ZudokuConfig: z.ZodObject<{
7741
7742
  }, z.core.$strip>>>;
7742
7743
  disablePlayground: z.ZodOptional<z.ZodBoolean>;
7743
7744
  disableSidecar: z.ZodOptional<z.ZodBoolean>;
7745
+ disableRequestBox: z.ZodOptional<z.ZodBoolean>;
7744
7746
  showVersionSelect: z.ZodOptional<z.ZodEnum<{
7745
7747
  always: "always";
7746
7748
  hide: "hide";
@@ -7778,6 +7780,7 @@ export declare const ZudokuConfig: z.ZodObject<{
7778
7780
  }, z.core.$strip>>>;
7779
7781
  disablePlayground: z.ZodOptional<z.ZodBoolean>;
7780
7782
  disableSidecar: z.ZodOptional<z.ZodBoolean>;
7783
+ disableRequestBox: z.ZodOptional<z.ZodBoolean>;
7781
7784
  showVersionSelect: z.ZodOptional<z.ZodEnum<{
7782
7785
  always: "always";
7783
7786
  hide: "hide";
@@ -7819,6 +7822,7 @@ export declare const ZudokuConfig: z.ZodObject<{
7819
7822
  }, z.core.$strip>>>;
7820
7823
  disablePlayground: z.ZodOptional<z.ZodBoolean>;
7821
7824
  disableSidecar: z.ZodOptional<z.ZodBoolean>;
7825
+ disableRequestBox: z.ZodOptional<z.ZodBoolean>;
7822
7826
  showVersionSelect: z.ZodOptional<z.ZodEnum<{
7823
7827
  always: "always";
7824
7828
  hide: "hide";
@@ -7852,6 +7856,7 @@ export declare const ZudokuConfig: z.ZodObject<{
7852
7856
  }, z.core.$strip>>>;
7853
7857
  disablePlayground: z.ZodOptional<z.ZodBoolean>;
7854
7858
  disableSidecar: z.ZodOptional<z.ZodBoolean>;
7859
+ disableRequestBox: z.ZodOptional<z.ZodBoolean>;
7855
7860
  showVersionSelect: z.ZodOptional<z.ZodEnum<{
7856
7861
  always: "always";
7857
7862
  hide: "hide";
@@ -7889,6 +7894,7 @@ export declare const ZudokuConfig: z.ZodObject<{
7889
7894
  }, z.core.$strip>>>;
7890
7895
  disablePlayground: z.ZodOptional<z.ZodBoolean>;
7891
7896
  disableSidecar: z.ZodOptional<z.ZodBoolean>;
7897
+ disableRequestBox: z.ZodOptional<z.ZodBoolean>;
7892
7898
  showVersionSelect: z.ZodOptional<z.ZodEnum<{
7893
7899
  always: "always";
7894
7900
  hide: "hide";
@@ -7930,6 +7936,7 @@ export declare const ZudokuConfig: z.ZodObject<{
7930
7936
  }, z.core.$strip>>>;
7931
7937
  disablePlayground: z.ZodOptional<z.ZodBoolean>;
7932
7938
  disableSidecar: z.ZodOptional<z.ZodBoolean>;
7939
+ disableRequestBox: z.ZodOptional<z.ZodBoolean>;
7933
7940
  showVersionSelect: z.ZodOptional<z.ZodEnum<{
7934
7941
  always: "always";
7935
7942
  hide: "hide";
@@ -8032,6 +8039,7 @@ export declare const ZudokuConfig: z.ZodObject<{
8032
8039
  }, z.core.$strip>>>;
8033
8040
  disablePlayground: z.ZodOptional<z.ZodBoolean>;
8034
8041
  disableSidecar: z.ZodOptional<z.ZodBoolean>;
8042
+ disableRequestBox: z.ZodOptional<z.ZodBoolean>;
8035
8043
  showVersionSelect: z.ZodOptional<z.ZodEnum<{
8036
8044
  always: "always";
8037
8045
  hide: "hide";
@@ -0,0 +1,2 @@
1
+ import type { ZudokuContext } from "../../core/ZudokuContext.js";
2
+ export declare const fetchSigned: (context: ZudokuContext, request: Request) => Promise<Response>;
@@ -76,6 +76,7 @@ type BaseOasConfig = {
76
76
  }[];
77
77
  disablePlayground?: boolean;
78
78
  disableSidecar?: boolean;
79
+ disableRequestBox?: boolean;
79
80
  disableSecurity?: boolean;
80
81
  disableMcpAuthInstructions?: boolean;
81
82
  showVersionSelect?: "always" | "if-available" | "hide";
@@ -0,0 +1 @@
1
+ export declare const shouldShowRequestBox: (extensions: Record<string, unknown> | null | undefined, disableRequestBox: boolean | undefined) => boolean;
@@ -0,0 +1,11 @@
1
+ type Redirect = {
2
+ from: string;
3
+ to: string;
4
+ };
5
+ export declare const isDynamicRedirect: (redirect: Pick<Redirect, "from">) => boolean;
6
+ export declare const resolveRedirectTarget: ({ from, to }: Redirect, params: Record<string, string | undefined>) => string;
7
+ export declare const toBuildOutputRedirects: (redirects: readonly Redirect[], basePath?: string) => {
8
+ src: string;
9
+ location: string;
10
+ }[];
11
+ export {};
@@ -538,6 +538,7 @@ export interface _Schema20 {
538
538
  }[]
539
539
  disablePlayground?: boolean
540
540
  disableSidecar?: boolean
541
+ disableRequestBox?: boolean
541
542
  showVersionSelect?: ("always" | "if-available" | "hide")
542
543
  expandAllTags?: boolean
543
544
  showInfoPage?: boolean
@@ -251,6 +251,7 @@ const config = {
251
251
  ],
252
252
  disablePlayground: false, // Disable the interactive API playground
253
253
  disableSidecar: false, // Disable the sidecar completely
254
+ disableRequestBox: false, // Hide the request box at the top of the sidecar
254
255
  disableSecurity: true, // Disable security scheme display and playground auth (default)
255
256
  disableMcpAuthInstructions: false, // Hide auth steps in the MCP server card
256
257
  showVersionSelect: "if-available", // Control version selector visibility
@@ -272,6 +273,11 @@ Available options:
272
273
  identifier) and `label` (display name)
273
274
  - `disablePlayground`: Disable the interactive API playground globally
274
275
  - `disableSidecar`: Disable the sidecar panel completely
276
+ - `disableRequestBox`: Hide the request box at the top of the sidecar. The box holds the method and
277
+ path, the generated code snippet with its language selector, the auth selector and the button that
278
+ opens the playground, so hiding it also removes the way to open the playground. The request body
279
+ and response examples below it are still shown. Operations can override this with
280
+ [`x-zudoku-request-box-enabled`](../openapi-extensions/x-zudoku-request-box-enabled)
275
281
  - `disableSecurity`: Disable OpenAPI security scheme display (auth badges on operations, security
276
282
  schemes section on the info page, and the Authorize dialog in the playground). Disabled by default
277
283
  (`true`). Set to `false` to enable security scheme support
@@ -310,6 +316,7 @@ const config = {
310
316
  examplesLanguage: "shell", // Default language for code examples
311
317
  disablePlayground: false, // Disable the interactive API playground
312
318
  disableSidecar: false, // Disable the sidecar completely
319
+ disableRequestBox: false, // Hide the request box at the top of the sidecar
313
320
  disableSecurity: true, // Disable security scheme display and playground auth (default)
314
321
  disableMcpAuthInstructions: false, // Hide auth steps in the MCP server card
315
322
  showVersionSelect: "if-available", // Control version selector visibility
@@ -399,9 +406,14 @@ different levels of your API documentation.
399
406
  ### Operations
400
407
 
401
408
  - `x-zudoku-playground-enabled`: Control playground visibility for an operation (default: `true`)
409
+ - `x-zudoku-request-box-enabled`: Control request box visibility in the sidecar for an operation
410
+ (default: `true`). See
411
+ [`x-zudoku-request-box-enabled`](../openapi-extensions/x-zudoku-request-box-enabled)
402
412
  - `x-internal`: Hide an operation from the documentation. Also works on path items and parameters.
403
413
  See [`x-internal`](../openapi-extensions/x-internal)
404
- - `x-explorer-enabled`: Alias for `x-zudoku-playground-enabled` for compatibility Example:
414
+ - `x-explorer-enabled`: Alias for `x-zudoku-playground-enabled` for compatibility
415
+
416
+ Example:
405
417
 
406
418
  ```json
407
419
  {
@@ -409,7 +421,8 @@ different levels of your API documentation.
409
421
  "/users": {
410
422
  "get": {
411
423
  "summary": "Get users",
412
- "x-zudoku-playground-enabled": false // Disable playground for this operation
424
+ "x-zudoku-playground-enabled": false, // Disable playground for this operation
425
+ "x-zudoku-request-box-enabled": false // Hide the sidecar request box for this operation
413
426
  }
414
427
  }
415
428
  }
@@ -50,21 +50,7 @@ If you don't have an Auth0 account, you can sign up for a
50
50
 
51
51
  Keep the default **Refresh Token Expiration** settings unless you have specific requirements.
52
52
 
53
- 3. Create an Auth0 API:
54
- - Navigate to the [APIs section](https://manage.auth0.com/#/apis) in the Auth0 dashboard
55
- - Click **Create API**
56
- - Set a name (e.g., "Zudoku API") and an identifier (e.g., `https://your-domain.com/api`)
57
- - Choose **RS256** as the signing algorithm
58
- - Save the API
59
-
60
- :::warning
61
-
62
- This step is important. If you skip creating an API, Zudoku will not be able to validate the
63
- tokens issued by Auth0, leading to authentication failures.
64
-
65
- :::
66
-
67
- 4. **Configure Zudoku**
53
+ 3. **Configure Zudoku**
68
54
 
69
55
  Add the Auth0 configuration to your [Zudoku configuration file](./overview.md):
70
56
 
@@ -76,7 +62,6 @@ If you don't have an Auth0 account, you can sign up for a
76
62
  type: "auth0",
77
63
  domain: "your-domain.us.auth0.com",
78
64
  clientId: "<your-auth0-client-id>",
79
- audience: "https://your-domain.com/api", // Your Auth0 API identifier
80
65
  },
81
66
  // ... other configuration
82
67
  };
@@ -85,7 +70,36 @@ If you don't have an Auth0 account, you can sign up for a
85
70
  Where:
86
71
  - **domain**: Your Auth0 domain (found in your application's Basic Information)
87
72
  - **clientId**: The Client ID from your Auth0 application settings
88
- - **audience**: The identifier of the Auth0 API you created (e.g., `https://your-domain.com/api`)
73
+
74
+ 4. **(Optional) Configure an audience for API access**
75
+
76
+ Only configure an `audience` if you need the access token to call a specific API (for example,
77
+ when using the API playground to send authenticated requests to your backend). Without an
78
+ audience, Auth0 issues an opaque token that can be used to identify the user via `/userinfo` —
79
+ which is all Zudoku needs for sign-in and protected routes.
80
+
81
+ :::tip
82
+
83
+ Avoid setting an audience when you don't need it. Issuing an access token scoped to an API when
84
+ it isn't going to call that API gives the token broader privileges than necessary.
85
+
86
+ :::
87
+
88
+ If you do need API access:
89
+ - Navigate to the [APIs section](https://manage.auth0.com/#/apis) in the Auth0 dashboard
90
+ - Click **Create API**, set a name (e.g., "Zudoku API") and an identifier (e.g.,
91
+ `https://your-domain.com/api`)
92
+ - Choose **RS256** as the signing algorithm and save the API
93
+ - Add the identifier to your Zudoku configuration:
94
+
95
+ ```typescript
96
+ authentication: {
97
+ type: "auth0",
98
+ domain: "your-domain.us.auth0.com",
99
+ clientId: "<your-auth0-client-id>",
100
+ audience: "https://your-domain.com/api", // Your Auth0 API identifier
101
+ }
102
+ ```
89
103
 
90
104
  </Stepper>
91
105
 
@@ -188,8 +202,9 @@ authentication: {
188
202
  3. **Authentication Loop**: Check that your Auth0 domain is a plain hostname only (e.g.,
189
203
  `your-domain.us.auth0.com`) without a protocol prefix (`https://`) or trailing slash.
190
204
 
191
- 4. **Token Validation Errors**: Ensure the audience in your Zudoku configuration matches the
192
- identifier of the Auth0 API you created.
205
+ 4. **Token Validation Errors**: If you've configured an `audience`, make sure it matches the
206
+ identifier of the Auth0 API you created. If you don't need to call an API, remove `audience` from
207
+ your configuration — Auth0 will issue an opaque token suitable for `/userinfo`.
193
208
 
194
209
  ## Next Steps
195
210
 
@@ -284,12 +284,16 @@ URL.
284
284
  // ...
285
285
  "redirects": [
286
286
  { "from": "/", "to": "/documentation/introduction" },
287
- { "from": "/documentation", "to": "/documentation/introduction" }
287
+ { "from": "/documentation", "to": "/documentation/introduction" },
288
+ { "from": "/blog/:slug", "to": "/posts/:slug" }
288
289
  ]
289
290
  // ...
290
291
  }
291
292
  ```
292
293
 
294
+ `from` paths can contain `:param` segments and a trailing `*` wildcard. See
295
+ [Redirects](/docs/guides/redirects#path-parameters-and-wildcards) for details.
296
+
293
297
  ### `port`
294
298
 
295
299
  The port on which the development server will run. Defaults to `3000`. This option can also be
@@ -35,6 +35,14 @@ this is already enabled.)
35
35
 
36
36
  This will make the API Key UI available at `/settings/api-keys` in your Zudoku.
37
37
 
38
+ :::note
39
+
40
+ Zuplo's API key service identifies users by their JWT access token. If you use
41
+ [Auth0](../configuration/authentication-auth0.md), configure an `audience` in your authentication
42
+ settings. Without one, Auth0 issues an opaque access token that can't be used to manage API keys.
43
+
44
+ :::
45
+
38
46
  ### Creating API Keys
39
47
 
40
48
  By default, there is no **Create API Key** Button. To get the button working you have to implement
@@ -52,6 +52,43 @@ redirects: [{ from: "/old-page", to: "/new-page" }];
52
52
  Trailing slashes in the `from` path are normalized automatically. Both `/old-page` and `/old-page/`
53
53
  will match the same redirect rule.
54
54
 
55
+ ## Path parameters and wildcards
56
+
57
+ A `from` path can match more than one URL. Use `:name` for a single path segment and a trailing `*`
58
+ for everything after a prefix. Reference the same tokens in `to` to carry the matched values over:
59
+
60
+ ```ts
61
+ redirects: [
62
+ // /blog/hello-world → /posts/hello-world
63
+ { from: "/blog/:slug", to: "/posts/:slug" },
64
+ // /v1/guides/auth/tokens → /guides/auth/tokens
65
+ { from: "/v1/*", to: "/*" },
66
+ // /dashboard/apps/42 → https://dashboard.example.com/apps/42
67
+ { from: "/dashboard/*", to: "https://dashboard.example.com/*" },
68
+ ];
69
+ ```
70
+
71
+ - **`:name`** matches exactly one segment. Add `?` (`:name?`) to make it optional.
72
+ - **`*`** must be the last segment of `from`. It also matches the prefix itself, so `/dashboard/*`
73
+ redirects `/dashboard` too. In that case the `/*` in `to` is dropped, leaving `/` if nothing else
74
+ remains.
75
+ - In `to`, `/:name` and `/*` must each fill a whole path segment, so `/posts/:slug` works and
76
+ `/posts-:slug` doesn't. Tokens that don't appear in `from` are left as they are.
77
+ - Zudoku doesn't copy the incoming query string to the target.
78
+
79
+ Pages in your portal take precedence over a wildcard redirect, so `{ from: "/docs/*", to: "/" }`
80
+ still lets visitors reach `/docs/introduction` if that page exists.
81
+
82
+ Static hosts can't prerender a file for every URL a pattern matches, so pattern redirects behave
83
+ differently from exact ones:
84
+
85
+ - **Zuplo** and **Vercel**: Zudoku turns the pattern into a regular-expression route in the build
86
+ output, and the platform returns an HTTP **301**.
87
+ - **SSR deployments**: the server returns an HTTP 301, the same as exact redirects.
88
+ - **Other static hosts**: no redirect file is written. If your host serves `404.html` (or your app's
89
+ `index.html`) for unknown paths, the page loads, Zudoku matches the pattern, and the browser is
90
+ redirected. Otherwise, set up the redirect in your host's own configuration.
91
+
55
92
  ## How redirects work
56
93
 
57
94
  Zudoku redirects operate at two levels depending on how the visitor reaches the page:
@@ -0,0 +1,47 @@
1
+ ---
2
+ title: x-zudoku-request-box-enabled
3
+ sidebar_icon: square-code
4
+ ---
5
+
6
+ Use `x-zudoku-request-box-enabled` to show or hide the request box at the top of an operation's
7
+ sidecar for a specific operation. By default, the request box is shown for all operations unless
8
+ globally disabled via the [`disableRequestBox`](/docs/configuration/api-reference) option.
9
+
10
+ The request box holds the method and path, the generated code snippet (or your
11
+ [`x-code-samples`](./x-code-samples)) with its language selector, the auth selector and the button
12
+ that opens the playground. Hiding it also removes the way to open the playground for that operation.
13
+ The request body and response examples below it are still shown.
14
+
15
+ ## Location
16
+
17
+ The extension is added at the **Operation Object** level.
18
+
19
+ | Option | Type | Description |
20
+ | ------------------------------ | --------- | ------------------------------------------------ |
21
+ | `x-zudoku-request-box-enabled` | `boolean` | Show (`true`) or hide (`false`) the request box. |
22
+
23
+ If the extension is not set, the request box visibility falls back to the global `disableRequestBox`
24
+ configuration.
25
+
26
+ ## Example
27
+
28
+ ```yaml
29
+ paths:
30
+ /users:
31
+ get:
32
+ summary: List users
33
+ x-zudoku-request-box-enabled: true
34
+ responses:
35
+ "200":
36
+ description: Successful response
37
+ /webhooks/trigger:
38
+ post:
39
+ summary: Trigger webhook
40
+ x-zudoku-request-box-enabled: false
41
+ responses:
42
+ "200":
43
+ description: Accepted
44
+ ```
45
+
46
+ In this example, `List users` shows the request box while `Trigger webhook` hides it regardless of
47
+ the global setting.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "zudoku",
3
- "version": "0.91.0",
3
+ "version": "0.93.0",
4
4
  "type": "module",
5
5
  "engines": {
6
6
  "node": ">=22.22.0"
@@ -1,11 +1,12 @@
1
1
  import { redirect, type RouteObject } from "react-router";
2
2
  import type { ZudokuRedirect } from "../../config/validators/ZudokuConfig.js";
3
3
  import { joinUrl } from "../../lib/util/joinUrl.js";
4
+ import { resolveRedirectTarget } from "../../lib/util/redirectPattern.js";
4
5
 
5
6
  export const createRedirectRoutes = (
6
7
  redirects?: ZudokuRedirect[],
7
8
  ): RouteObject[] =>
8
9
  (redirects ?? []).map((r) => ({
9
10
  path: joinUrl(r.from),
10
- loader: () => redirect(r.to, 301),
11
+ loader: ({ params }) => redirect(resolveRedirectTarget(r, params), 301),
11
12
  }));
@@ -93,6 +93,7 @@ const ApiOptionsSchema = z
93
93
  supportedLanguages: z.array(LanguageOption),
94
94
  disablePlayground: z.boolean(),
95
95
  disableSidecar: z.boolean(),
96
+ disableRequestBox: z.boolean(),
96
97
  showVersionSelect: z.enum(["always", "if-available", "hide"]),
97
98
  expandAllTags: z.boolean(),
98
99
  showInfoPage: z.boolean(),
@@ -17,7 +17,7 @@ import { LogoutCallbackHandler } from "../components/LogoutCallbackHandler.js";
17
17
  import { OAuthErrorPage } from "../components/OAuthErrorPage.js";
18
18
  import { fetchServerSession } from "../cookie-sync.js";
19
19
  import { DEFAULT_SESSION_MAX_AGE, SESSION_ENDPOINT_PATH } from "../cookies.js";
20
- import { AuthorizationError, OAuthAuthorizationError } from "../errors.js";
20
+ import { AuthorizationError } from "../errors.js";
21
21
  import { type UserProfile, useAuthState } from "../state.js";
22
22
  import { redirectToSignUpUrl } from "./util.js";
23
23
 
@@ -178,19 +178,6 @@ export class OpenIDAuthenticationProvider
178
178
  throw new AuthorizationError("No expires_in in response");
179
179
  }
180
180
 
181
- const accessToken = response.access_token;
182
- if (accessToken.split(".").length !== 3) {
183
- throw new OAuthAuthorizationError(
184
- "The access token received is not a valid JWT.",
185
- {
186
- error: "configuration_error",
187
- error_description:
188
- "The authentication provider is issuing opaque tokens instead of JWTs. " +
189
- "Ensure you have configured the correct `audience` in your authentication settings.",
190
- },
191
- );
192
- }
193
-
194
181
  const claims = response.id_token
195
182
  ? oauth.getValidatedIdTokenClaims(response)
196
183
  : undefined;
@@ -0,0 +1,35 @@
1
+ import type { ZudokuContext } from "../../core/ZudokuContext.js";
2
+ import { ZudokuError } from "../../util/invariant.js";
3
+
4
+ const isJwt = (token: string) => token.split(".").length === 3;
5
+
6
+ const isAuthFailure = (response: Response) =>
7
+ response.status === 401 || response.status === 403;
8
+
9
+ /**
10
+ * Signs and sends a request to Zuplo's API key service. The service identifies
11
+ * the user from a JWT access token, so an opaque token (e.g. Auth0 without an
12
+ * `audience`) rejected with 401/403 is reported as a configuration error
13
+ * instead of a generic request failure.
14
+ */
15
+ export const fetchSigned = async (context: ZudokuContext, request: Request) => {
16
+ const signedRequest = await context.signRequest(request);
17
+ const response = await fetch(signedRequest);
18
+
19
+ const accessToken = signedRequest.headers
20
+ .get("Authorization")
21
+ ?.replace(/^Bearer /i, "");
22
+
23
+ if (isAuthFailure(response) && accessToken && !isJwt(accessToken)) {
24
+ throw new ZudokuError(
25
+ "The access token issued by the authentication provider can't be used to manage API keys.",
26
+ {
27
+ title: "Configuration Error",
28
+ developerHint:
29
+ "Zuplo API key management requires a JWT access token, but the authentication provider issued an opaque token. If you use Auth0, set `audience` in your authentication configuration to the identifier of your Auth0 API.",
30
+ },
31
+ );
32
+ }
33
+
34
+ return response;
35
+ };
@@ -11,6 +11,7 @@ import type { ZudokuContext } from "../../core/ZudokuContext.js";
11
11
  import invariant from "../../util/invariant.js";
12
12
  import { joinUrl } from "../../util/joinUrl.js";
13
13
  import { throwIfProblemJson } from "../../util/problemJson.js";
14
+ import { fetchSigned } from "./fetchSigned.js";
14
15
  import { SettingsApiKeys } from "./SettingsApiKeys.js";
15
16
 
16
17
  const DEFAULT_GATEWAY_URL = "https://api.zuploedge.com";
@@ -93,7 +94,7 @@ const createZuploService = ({
93
94
  method: "DELETE",
94
95
  },
95
96
  );
96
- const response = await fetch(await context.signRequest(request));
97
+ const response = await fetchSigned(context, request);
97
98
  await throwIfProblemJson(response);
98
99
  invariant(response.ok, "Failed to delete API key");
99
100
  },
@@ -103,23 +104,22 @@ const createZuploService = ({
103
104
  "Cannot update API key description.",
104
105
  developerHintOptions,
105
106
  );
106
- const response = await fetch(
107
- await context.signRequest(
108
- new Request(
109
- joinUrl(
110
- getApiKeyEndpoint(context),
111
- `${deploymentName}/consumers/${consumer.id}`,
112
- ),
113
- {
114
- method: "PATCH",
115
- headers: {
116
- "Content-Type": "application/json",
117
- },
118
- body: JSON.stringify({
119
- label: consumer.label,
120
- }),
121
- },
107
+ const response = await fetchSigned(
108
+ context,
109
+ new Request(
110
+ joinUrl(
111
+ getApiKeyEndpoint(context),
112
+ `${deploymentName}/consumers/${consumer.id}`,
122
113
  ),
114
+ {
115
+ method: "PATCH",
116
+ headers: {
117
+ "Content-Type": "application/json",
118
+ },
119
+ body: JSON.stringify({
120
+ label: consumer.label,
121
+ }),
122
+ },
123
123
  ),
124
124
  );
125
125
  await throwIfProblemJson(response);
@@ -127,21 +127,20 @@ const createZuploService = ({
127
127
  },
128
128
  rollKey: async (consumerId, context) => {
129
129
  invariant(deploymentName, "Cannot roll API key.", developerHintOptions);
130
- const response = await fetch(
131
- await context.signRequest(
132
- new Request(
133
- joinUrl(
134
- getApiKeyEndpoint(context),
135
- `${deploymentName}/consumers/${consumerId}/roll-key`,
136
- ),
137
- {
138
- method: "POST",
139
- headers: {
140
- "Content-Type": "application/json",
141
- },
142
- body: JSON.stringify({}),
143
- },
130
+ const response = await fetchSigned(
131
+ context,
132
+ new Request(
133
+ joinUrl(
134
+ getApiKeyEndpoint(context),
135
+ `${deploymentName}/consumers/${consumerId}/roll-key`,
144
136
  ),
137
+ {
138
+ method: "POST",
139
+ headers: {
140
+ "Content-Type": "application/json",
141
+ },
142
+ body: JSON.stringify({}),
143
+ },
145
144
  ),
146
145
  );
147
146
  await throwIfProblemJson(response);
@@ -152,9 +151,7 @@ const createZuploService = ({
152
151
  const request = new Request(
153
152
  joinUrl(getApiKeyEndpoint(context), `${deploymentName}/consumers`),
154
153
  );
155
- await context.signRequest(request);
156
-
157
- const keys = await fetch(request);
154
+ const keys = await fetchSigned(context, request);
158
155
  await throwIfProblemJson(keys);
159
156
  invariant(keys.ok, "Failed to fetch API keys");
160
157
 
@@ -33,6 +33,7 @@ import {
33
33
  import { generateSchemaExample } from "./util/generateSchemaExample.js";
34
34
  import { getGraphQLEndpoint } from "./util/graphqlEndpoint.js";
35
35
  import { methodForColor } from "./util/methodToColor.js";
36
+ import { shouldShowRequestBox } from "./util/shouldShowRequestBox.js";
36
37
  import { useResolvedAuth } from "./util/useResolvedAuth.js";
37
38
 
38
39
  export const GetServerQuery = graphql(/* GraphQL */ `
@@ -221,7 +222,14 @@ export const Sidecar = ({
221
222
  const graphQLEndpoint = getGraphQLEndpoint(operation);
222
223
  const isGraphQLEndpoint = graphQLEndpoint !== undefined;
223
224
 
225
+ const showRequestBox = shouldShowRequestBox(
226
+ operation.extensions,
227
+ options?.disableRequestBox,
228
+ );
229
+
224
230
  const httpSnippetCode = useMemo<string | undefined>(() => {
231
+ if (!showRequestBox) return;
232
+
225
233
  if (codeSamples && !hasResolvedAuth) {
226
234
  const match = codeSamples.find((s) => s.lang === selectedLang);
227
235
  return match?.source;
@@ -258,6 +266,7 @@ export const Sidecar = ({
258
266
 
259
267
  return getConverted(snippet, selectedLang);
260
268
  }, [
269
+ showRequestBox,
261
270
  codeSamples,
262
271
  currentExampleCode,
263
272
  operation,
@@ -331,82 +340,84 @@ export const Sidecar = ({
331
340
  className="flex flex-col sticky top-(--scroll-padding) gap-4"
332
341
  data-pagefind-ignore="all"
333
342
  >
334
- <SidecarBox.Root>
335
- <SidecarBox.Head className="py-1.5">
336
- <div className="flex items-center flex-wrap gap-2 justify-between w-full">
337
- <span className="font-mono wrap-break-word leading-6 space-x-1">
338
- <Badge
339
- variant="outline"
340
- className={cn(
341
- methodTextColor,
342
- "px-1.5 rounded-md border-none bg-current/7 dark:bg-current/15",
343
- )}
344
- >
345
- {operation.method.toUpperCase()}
346
- </Badge>
347
- {path}
348
- </span>
349
- {showPlayground &&
350
- (isGraphQLEndpoint ? (
351
- <GraphiQLDialog
352
- endpoint={graphQLEndpoint?.endpoint ?? operationUrl}
353
- operation={operation}
354
- securitySchemes={securitySchemes}
355
- defaultTabs={
356
- graphQLTabs && graphQLTabs.length > 0
357
- ? graphQLTabs
358
- : undefined
359
- }
360
- />
361
- ) : (
362
- <PlaygroundDialogWrapper
363
- servers={operation.servers}
364
- operation={operation}
365
- examples={requestBodyContent ?? undefined}
366
- />
343
+ {showRequestBox && (
344
+ <SidecarBox.Root>
345
+ <SidecarBox.Head className="py-1.5">
346
+ <div className="flex items-center flex-wrap gap-2 justify-between w-full">
347
+ <span className="font-mono wrap-break-word leading-6 space-x-1">
348
+ <Badge
349
+ variant="outline"
350
+ className={cn(
351
+ methodTextColor,
352
+ "px-1.5 rounded-md border-none bg-current/7 dark:bg-current/15",
353
+ )}
354
+ >
355
+ {operation.method.toUpperCase()}
356
+ </Badge>
357
+ {path}
358
+ </span>
359
+ {showPlayground &&
360
+ (isGraphQLEndpoint ? (
361
+ <GraphiQLDialog
362
+ endpoint={graphQLEndpoint?.endpoint ?? operationUrl}
363
+ operation={operation}
364
+ securitySchemes={securitySchemes}
365
+ defaultTabs={
366
+ graphQLTabs && graphQLTabs.length > 0
367
+ ? graphQLTabs
368
+ : undefined
369
+ }
370
+ />
371
+ ) : (
372
+ <PlaygroundDialogWrapper
373
+ servers={operation.servers}
374
+ operation={operation}
375
+ examples={requestBodyContent ?? undefined}
376
+ />
377
+ ))}
378
+ </div>
379
+ </SidecarBox.Head>
380
+ <SidecarBox.Body>
381
+ {shouldLazyHighlight && !isOnScreen ? (
382
+ <NonHighlightedCode code={httpSnippetCode ?? ""} />
383
+ ) : (
384
+ <SyntaxHighlight
385
+ embedded
386
+ language={selectedLang}
387
+ showLanguageIndicator={false}
388
+ className="[--scrollbar-color:gray] rounded-none text-xs max-h-50"
389
+ // biome-ignore lint/style/noNonNullAssertion: code is guaranteed to be defined
390
+ code={httpSnippetCode!}
391
+ />
392
+ )}
393
+ </SidecarBox.Body>
394
+ <SidecarBox.Footer className="text-xs self-end flex justify-between items-center gap-2">
395
+ <NativeSelect
396
+ className="text-xs h-fit py-1 max-w-32 truncate bg-background"
397
+ value={selectedLang}
398
+ onChange={(e) => {
399
+ startTransition(() => {
400
+ setSearchParams((prev) => {
401
+ prev.set("lang", e.target.value);
402
+ return prev;
403
+ });
404
+ });
405
+ }}
406
+ >
407
+ {supportedLanguages.map((language) => (
408
+ <NativeSelectOption key={language.value} value={language.value}>
409
+ {language.label}
410
+ </NativeSelectOption>
367
411
  ))}
368
- </div>
369
- </SidecarBox.Head>
370
- <SidecarBox.Body>
371
- {shouldLazyHighlight && !isOnScreen ? (
372
- <NonHighlightedCode code={httpSnippetCode ?? ""} />
373
- ) : (
374
- <SyntaxHighlight
375
- embedded
376
- language={selectedLang}
377
- showLanguageIndicator={false}
378
- className="[--scrollbar-color:gray] rounded-none text-xs max-h-50"
379
- // biome-ignore lint/style/noNonNullAssertion: code is guaranteed to be defined
380
- code={httpSnippetCode!}
412
+ </NativeSelect>
413
+ <AuthSelectorPopover
414
+ operation={operation}
415
+ url={operationUrl}
416
+ securitySchemes={securitySchemes}
381
417
  />
382
- )}
383
- </SidecarBox.Body>
384
- <SidecarBox.Footer className="text-xs self-end flex justify-between items-center gap-2">
385
- <NativeSelect
386
- className="text-xs h-fit py-1 max-w-32 truncate bg-background"
387
- value={selectedLang}
388
- onChange={(e) => {
389
- startTransition(() => {
390
- setSearchParams((prev) => {
391
- prev.set("lang", e.target.value);
392
- return prev;
393
- });
394
- });
395
- }}
396
- >
397
- {supportedLanguages.map((language) => (
398
- <NativeSelectOption key={language.value} value={language.value}>
399
- {language.label}
400
- </NativeSelectOption>
401
- ))}
402
- </NativeSelect>
403
- <AuthSelectorPopover
404
- operation={operation}
405
- url={operationUrl}
406
- securitySchemes={securitySchemes}
407
- />
408
- </SidecarBox.Footer>
409
- </SidecarBox.Root>
418
+ </SidecarBox.Footer>
419
+ </SidecarBox.Root>
420
+ )}
410
421
 
411
422
  {transformedRequestBodyContent && currentExample ? (
412
423
  <RequestBodySidecarBox
@@ -88,6 +88,7 @@ type BaseOasConfig = {
88
88
  supportedLanguages?: { value: string; label: string }[];
89
89
  disablePlayground?: boolean;
90
90
  disableSidecar?: boolean;
91
+ disableRequestBox?: boolean;
91
92
  disableSecurity?: boolean;
92
93
  disableMcpAuthInstructions?: boolean;
93
94
  showVersionSelect?: "always" | "if-available" | "hide";
@@ -0,0 +1,18 @@
1
+ /**
2
+ * Decides whether the request box (method and path, code snippet, language
3
+ * selector, playground button and auth selector) is shown at the top of an
4
+ * operation's sidecar.
5
+ *
6
+ * - `x-zudoku-request-box-enabled: true` shows it, even if the API sets
7
+ * `disableRequestBox`.
8
+ * - Any other explicit value hides it, matching `x-zudoku-playground-enabled`.
9
+ * - When unset (`undefined`), it falls back to the API's `disableRequestBox`.
10
+ */
11
+ export const shouldShowRequestBox = (
12
+ extensions: Record<string, unknown> | null | undefined,
13
+ disableRequestBox: boolean | undefined,
14
+ ) => {
15
+ const enabled = extensions?.["x-zudoku-request-box-enabled"];
16
+ if (enabled === undefined) return !disableRequestBox;
17
+ return enabled === true;
18
+ };
@@ -0,0 +1,172 @@
1
+ import { joinUrl } from "./joinUrl.js";
2
+
3
+ type Redirect = { from: string; to: string };
4
+
5
+ type Segment =
6
+ | { type: "static"; value: string }
7
+ | { type: "param"; name: string; optional: boolean }
8
+ | { type: "splat" };
9
+
10
+ const SPLAT = "*";
11
+ // React Router's own check for URLs that leave the app
12
+ const ABSOLUTE_URL_REGEX = /^(?:[a-z][a-z0-9+.-]*:|[\\/]{2})/i;
13
+ // A `/:param` or `/*` token that spans a whole path segment
14
+ const TOKEN_REGEX = /\/(?::([\w-]+)|\*)(?=[/?#]|$)/g;
15
+
16
+ // Parses a `from` path using React Router's syntax: `:param`, `:param?` and a
17
+ // trailing `*` splat.
18
+ const parseSegments = (path: string): Segment[] => {
19
+ const parts = path.split("/").filter(Boolean);
20
+ return parts.map((part, index): Segment => {
21
+ if (part === SPLAT && index === parts.length - 1) return { type: "splat" };
22
+ const match = part.match(/^:([\w-]+)(\?)?$/);
23
+ if (!match?.[1]) return { type: "static", value: part };
24
+ return { type: "param", name: match[1], optional: match[2] === "?" };
25
+ });
26
+ };
27
+
28
+ const getParamNames = (from: string) =>
29
+ parseSegments(from).flatMap((segment) => {
30
+ if (segment.type === "param") return [segment.name];
31
+ if (segment.type === "splat") return [SPLAT];
32
+ return [];
33
+ });
34
+
35
+ export const isDynamicRedirect = (redirect: Pick<Redirect, "from">) =>
36
+ getParamNames(redirect.from).length > 0;
37
+
38
+ // Replaces the `/:param` and `/*` tokens in `to` that `from` defines. Tokens
39
+ // unknown to `from` are left untouched. Removing empty params can leave no path
40
+ // at all (`/*` for `/v1/*` at `/v1`), which would redirect back to the same
41
+ // URL, so that falls back to the root. A `to` without replaced tokens is kept
42
+ // as written, so relative targets like `?tab=2` still work.
43
+ const replaceTokens = (
44
+ to: string,
45
+ names: string[],
46
+ replace: (name: string) => string,
47
+ ) => {
48
+ const target = to.replace(TOKEN_REGEX, (token, name: string | undefined) => {
49
+ const key = name ?? SPLAT;
50
+ return names.includes(key) ? replace(key) : token;
51
+ });
52
+ const lostPath = target === "" || /^[?#]/.test(target);
53
+ return target !== to && lostPath ? `/${target}` : target;
54
+ };
55
+
56
+ const encodeParam = (name: string, value: string) =>
57
+ name === SPLAT
58
+ ? value.split("/").map(encodeURIComponent).join("/")
59
+ : encodeURIComponent(value);
60
+
61
+ /**
62
+ * Builds the redirect target for a matched `from` path by substituting the
63
+ * matched params into `to`, e.g. `{ from: "/blog/:slug", to: "/posts/:slug" }`
64
+ * sends `/blog/hello` to `/posts/hello`. Empty params drop their segment.
65
+ */
66
+ export const resolveRedirectTarget = (
67
+ { from, to }: Redirect,
68
+ params: Record<string, string | undefined>,
69
+ ) =>
70
+ replaceTokens(to, getParamNames(from), (name) => {
71
+ const value = params[name];
72
+ return value ? `/${encodeParam(name, value)}` : "";
73
+ });
74
+
75
+ // Mirrors React Router's `computeScore` for a concrete (non-optional) path.
76
+ const rankPath = (path: string) => {
77
+ const segments = joinUrl(path).split("/");
78
+ return segments.reduce(
79
+ (score, segment) => {
80
+ if (segment === SPLAT) return score;
81
+ if (segment.startsWith(":")) return score + 3;
82
+ return score + (segment === "" ? 1 : 10);
83
+ },
84
+ segments.length - (segments.includes(SPLAT) ? 2 : 0),
85
+ );
86
+ };
87
+
88
+ const segmentToPath = (segment: Segment) => {
89
+ if (segment.type === "static") return segment.value;
90
+ if (segment.type === "splat") return SPLAT;
91
+ return `:${segment.name}`;
92
+ };
93
+
94
+ // React Router ranks each concrete variant of a path with optional segments,
95
+ // in this order: `/:a?/:b?` becomes `/:a/:b`, `/:a`, `/:b` and `/`.
96
+ const explodeOptional = (segments: Segment[]): Segment[][] => {
97
+ const [first, ...rest] = segments;
98
+ if (!first) return [[]];
99
+ const variants = explodeOptional(rest);
100
+ const withFirst = variants.map((variant) => [first, ...variant]);
101
+ return first.type === "param" && first.optional
102
+ ? [...withFirst, ...variants]
103
+ : withFirst;
104
+ };
105
+
106
+ const escapeRegex = (value: string) =>
107
+ value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
108
+
109
+ /**
110
+ * Converts dynamic redirects into Build Output API routes. Each concrete
111
+ * variant of a `from` path becomes a regex with one capture group per param
112
+ * (including its leading slash), and the tokens in `to` become the matching
113
+ * `$n` references. Build Output routes match top to bottom, so they are sorted
114
+ * the way React Router ranks the same paths, keeping configuration order for
115
+ * ties.
116
+ */
117
+ export const toBuildOutputRedirects = (
118
+ redirects: readonly Redirect[],
119
+ basePath?: string,
120
+ ) => {
121
+ const base = joinUrl(basePath);
122
+ const prefix = base === "/" ? "" : escapeRegex(base);
123
+
124
+ return redirects
125
+ .flatMap((redirect) => {
126
+ const names = getParamNames(redirect.from);
127
+ const to = ABSOLUTE_URL_REGEX.test(redirect.to)
128
+ ? redirect.to
129
+ : joinUrl(basePath, redirect.to);
130
+
131
+ const toRoute = (segments: Segment[]) => {
132
+ const captured = segments.flatMap((segment) => {
133
+ if (segment.type === "param") return [segment.name];
134
+ if (segment.type === "splat") return [SPLAT];
135
+ return [];
136
+ });
137
+ const pattern = segments
138
+ .map((segment) => {
139
+ if (segment.type === "static") {
140
+ return `/${escapeRegex(segment.value)}`;
141
+ }
142
+ return segment.type === "splat" ? "(/.+)" : "(/[^/]+)";
143
+ })
144
+ .join("");
145
+ const endsWithSplat = segments.at(-1)?.type === "splat";
146
+
147
+ return {
148
+ // React Router accepts any number of trailing slashes
149
+ src: `^${prefix}${pattern}${endsWithSplat ? "" : "/*"}$`,
150
+ // Like React Router, a repeated param name takes the last value
151
+ location: replaceTokens(to, names, (name) =>
152
+ captured.includes(name) ? `$${captured.lastIndexOf(name) + 1}` : "",
153
+ ),
154
+ };
155
+ };
156
+
157
+ return explodeOptional(parseSegments(redirect.from)).flatMap(
158
+ (segments) => {
159
+ const rank = rankPath(`/${segments.map(segmentToPath).join("/")}`);
160
+ // An empty splat gets its own route so its target is resolved here
161
+ // rather than from an empty `$n` at request time
162
+ const variants =
163
+ segments.at(-1)?.type === "splat"
164
+ ? [segments, segments.slice(0, -1)]
165
+ : [segments];
166
+ return variants.map((variant) => ({ rank, ...toRoute(variant) }));
167
+ },
168
+ );
169
+ })
170
+ .toSorted((a, b) => b.rank - a.rank)
171
+ .map(({ src, location }) => ({ src, location }));
172
+ };
@@ -6,6 +6,10 @@ import { getZudokuPackageJson } from "../cli/common/package-json.js";
6
6
  import type { LoadedConfig } from "../config/config.js";
7
7
  import invariant from "../lib/util/invariant.js";
8
8
  import { joinUrl } from "../lib/util/joinUrl.js";
9
+ import {
10
+ isDynamicRedirect,
11
+ toBuildOutputRedirects,
12
+ } from "../lib/util/redirectPattern.js";
9
13
  import type { RouteRewrite } from "./prerender/utils.js";
10
14
  import {
11
15
  generateVercelMarkdownMiddleware,
@@ -200,8 +204,23 @@ export function generateOutput({
200
204
  });
201
205
  }
202
206
 
203
- if (rewrites.length > 0) {
207
+ // Dynamic redirects can't be prerendered, so match them with a regex. They
208
+ // run after the filesystem so real pages win, like React Router's ranking.
209
+ const dynamicRedirects = toBuildOutputRedirects(
210
+ (config.redirects ?? []).filter(isDynamicRedirect),
211
+ config.basePath,
212
+ );
213
+
214
+ if (rewrites.length > 0 || dynamicRedirects.length > 0) {
204
215
  routes.push({ handle: "filesystem" });
216
+ for (const { src, location } of dynamicRedirects) {
217
+ routes.push({
218
+ src,
219
+ dest: location,
220
+ status: 301,
221
+ headers: { Location: location },
222
+ });
223
+ }
205
224
  for (const rewrite of rewrites) {
206
225
  routes.push({
207
226
  src: joinUrl(config.basePath, rewrite.source),
@@ -52,6 +52,7 @@ export const generateDefaultApiOptionsCode = () => [
52
52
  ` supportedLanguages: config.defaults?.apis?.supportedLanguages,`,
53
53
  ` disablePlayground: config.defaults?.apis?.disablePlayground,`,
54
54
  ` disableSidecar: config.defaults?.apis?.disableSidecar,`,
55
+ ` disableRequestBox: config.defaults?.apis?.disableRequestBox,`,
55
56
  ` disableSecurity: config.defaults?.apis?.disableSecurity ?? true,`,
56
57
  ` disableMcpAuthInstructions: config.defaults?.apis?.disableMcpAuthInstructions,`,
57
58
  ` showVersionSelect: config.defaults?.apis?.showVersionSelect ?? "if-available",`,
@@ -23,6 +23,7 @@ import { generateSitemap } from "../sitemap.js";
23
23
  import {
24
24
  routesToPrerenderPaths,
25
25
  routesToRewrites,
26
+ withoutDynamicRedirectRoutes,
26
27
  selectPagesToIndex,
27
28
  } from "./utils.js";
28
29
  import type { StaticWorkerData, WorkerData } from "./worker.js";
@@ -87,7 +88,10 @@ export const prerender = async ({
87
88
  const module = await import(entryServerPath);
88
89
  const getRoutes = module.getRoutesByConfig as typeof getRoutesByConfig;
89
90
 
90
- const routes = getRoutes(config);
91
+ const routes = withoutDynamicRedirectRoutes(
92
+ getRoutes(config),
93
+ config.redirects,
94
+ );
91
95
  const paths = routesToPrerenderPaths(routes, config.redirects);
92
96
  const rewrites = routesToRewrites(routes);
93
97
  const { maxThreads, maxOldGenerationSizeMb } = getWorkerScaling(
@@ -1,5 +1,6 @@
1
1
  import type { RouteObject } from "react-router";
2
2
  import { joinUrl } from "../../lib/util/joinUrl.js";
3
+ import { isDynamicRedirect } from "../../lib/util/redirectPattern.js";
3
4
 
4
5
  const resolveRoutePath = (path: string): string | undefined => {
5
6
  const segments = path.split("/");
@@ -72,6 +73,20 @@ const collectRewrites = (resolved: ResolvedRoute[]): RouteRewrite[] =>
72
73
  export const routesToPaths = (routes: RouteObject[]): string[] =>
73
74
  collectPaths(resolveRoutes(routes), "");
74
75
 
76
+ // Dynamic redirects (`/:param`, `/*`) can't be prerendered to a file; they are
77
+ // emitted as Build Output API routes instead (see `generateOutput`). Their
78
+ // top-level route objects are dropped so optional params don't get stripped
79
+ // into a prerendered path and rewrite.
80
+ export const withoutDynamicRedirectRoutes = (
81
+ routes: RouteObject[],
82
+ redirects: readonly { from: string }[] = [],
83
+ ): RouteObject[] => {
84
+ const paths = new Set(
85
+ redirects.filter(isDynamicRedirect).map(({ from }) => joinUrl(from)),
86
+ );
87
+ return routes.filter((route) => !route.path || !paths.has(route.path));
88
+ };
89
+
75
90
  export const routesToPrerenderPaths = (
76
91
  routes: RouteObject[],
77
92
  redirects: readonly { from: string }[] = [],
@@ -79,7 +94,9 @@ export const routesToPrerenderPaths = (
79
94
  Array.from(
80
95
  new Set([
81
96
  ...routesToPaths(routes),
82
- ...redirects.map(({ from }) => joinUrl(from)),
97
+ ...redirects
98
+ .filter((redirect) => !isDynamicRedirect(redirect))
99
+ .map(({ from }) => joinUrl(from)),
83
100
  ]),
84
101
  );
85
102