zudoku 0.82.2 → 0.82.3

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.
@@ -2921,6 +2921,7 @@ var renderPage = async ({ urlPath }) => {
2921
2921
  outputPath,
2922
2922
  redirect: { from: pathname, to: redirectTo },
2923
2923
  statusCode: response.status,
2924
+ indexStatusCode: response.status,
2924
2925
  html: ""
2925
2926
  };
2926
2927
  }
@@ -2931,6 +2932,7 @@ var renderPage = async ({ urlPath }) => {
2931
2932
  }
2932
2933
  const fileContent = response.body ? await response.text() : "";
2933
2934
  let html = fileContent;
2935
+ let indexStatusCode = response.status;
2934
2936
  if (isProtectedRoute) {
2935
2937
  const bypassRequest = new Request(url);
2936
2938
  const bypassResponse = await server.handleRequest({
@@ -2946,12 +2948,14 @@ var renderPage = async ({ urlPath }) => {
2946
2948
  );
2947
2949
  }
2948
2950
  html = bypassResponse.body ? await bypassResponse.text() : "";
2951
+ indexStatusCode = bypassResponse.status;
2949
2952
  }
2950
2953
  await fs.mkdir(path.dirname(outputPath), { recursive: true });
2951
2954
  await fs.writeFile(outputPath, fileContent);
2952
2955
  return {
2953
2956
  outputPath,
2954
2957
  statusCode: response.status,
2958
+ indexStatusCode,
2955
2959
  redirect: void 0,
2956
2960
  html
2957
2961
  };
@@ -1,4 +1,9 @@
1
1
  import type { RouteObject } from "react-router";
2
2
  import type { ProtectedRoutesInput } from "../config/validators/ProtectedRoutesSchema.js";
3
3
  export declare const wrapProtectedRoutes: (routes: RouteObject[], protectedRoutes: ProtectedRoutesInput, isAuthenticated: boolean, basePath?: string) => RouteObject[];
4
+ export declare const wrapProtectedRoutesForRender: (routes: RouteObject[], protectedRoutes: ProtectedRoutesInput, { isAuthenticated, bypassProtection, basePath, }: {
5
+ isAuthenticated: boolean;
6
+ bypassProtection?: boolean;
7
+ basePath?: string;
8
+ }) => RouteObject[];
4
9
  export declare const warnInlineProtectedRoutes: (routes: RouteObject[], protectedRoutes: ProtectedRoutesInput, basePath?: string) => void;
@@ -0,0 +1,5 @@
1
+ export type PluginVersion = {
2
+ name: string;
3
+ version: string;
4
+ };
5
+ export declare const getPluginVersions: (pluginDirs: readonly string[]) => Promise<PluginVersion[]>;
@@ -0,0 +1 @@
1
+ export declare const findPackageRoot: (startDir: string) => Promise<string | undefined>;
@@ -10,7 +10,7 @@ A ready-made, customizable landing page for your developer portal. It comes in t
10
10
  covering the most common layouts: a centered hero, a two-column split hero, and a compact
11
11
  documentation hub.
12
12
 
13
- Use it as the `element` of a [custom page](/docs/custom-pages) — typically your home page:
13
+ Use it as the `element` of a [custom page](/docs/guides/custom-pages) — typically your home page:
14
14
 
15
15
  ```tsx title=zudoku.config.tsx
16
16
  import { LandingPage } from "zudoku/components";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "zudoku",
3
- "version": "0.82.2",
3
+ "version": "0.82.3",
4
4
  "type": "module",
5
5
  "engines": {
6
6
  "node": ">=20.19.0 <21.0.0 || >=22.12.0"
@@ -177,8 +177,9 @@
177
177
  "@envelop/core": "5.5.1",
178
178
  "@graphiql/toolkit": "0.12.1",
179
179
  "@graphql-typed-document-node/core": "3.2.0",
180
- "@hono/node-server": "2.0.3",
180
+ "@hono/node-server": "2.0.8",
181
181
  "@lekoarts/rehype-meta-as-attributes": "3.0.3",
182
+ "@mdx-js/mdx": "3.1.0",
182
183
  "@mdx-js/react": "3.1.1",
183
184
  "@mdx-js/rollup": "3.1.1",
184
185
  "@pothos/core": "4.12.0",
@@ -206,14 +207,14 @@
206
207
  "@radix-ui/react-toggle-group": "1.1.11",
207
208
  "@radix-ui/react-tooltip": "1.2.8",
208
209
  "@radix-ui/react-visually-hidden": "1.2.4",
209
- "@scalar/openapi-parser": "0.23.13",
210
+ "@scalar/openapi-parser": "0.28.4",
210
211
  "@scalar/snippetz": "0.9.11",
211
- "@sentry/node": "10.52.0",
212
+ "@sentry/node": "10.62.0",
212
213
  "@shikijs/langs": "4.2.0",
213
214
  "@shikijs/rehype": "4.2.0",
214
215
  "@shikijs/themes": "4.2.0",
215
216
  "@shikijs/transformers": "4.2.0",
216
- "@tailwindcss/typography": "0.5.19",
217
+ "@tailwindcss/typography": "0.5.20",
217
218
  "@tailwindcss/vite": "4.3.0",
218
219
  "@tanem/react-nprogress": "6.0.3",
219
220
  "@tanstack/react-query": "5.101.0",
@@ -242,7 +243,7 @@
242
243
  "hast-util-heading-rank": "3.0.0",
243
244
  "hast-util-to-jsx-runtime": "2.3.6",
244
245
  "hast-util-to-string": "3.0.1",
245
- "hono": "4.12.25",
246
+ "hono": "4.12.27",
246
247
  "http-terminator": "3.2.0",
247
248
  "javascript-stringify": "2.1.0",
248
249
  "jose": "6.2.3",
@@ -277,7 +278,7 @@
277
278
  "remark-frontmatter": "5.0.0",
278
279
  "remark-gfm": "4.0.1",
279
280
  "remark-mdx-frontmatter": "5.2.0",
280
- "semver": "7.8.2",
281
+ "semver": "7.8.5",
281
282
  "shiki": "4.2.0",
282
283
  "sitemap": "9.0.1",
283
284
  "strip-ansi": "7.2.0",
@@ -295,7 +296,7 @@
295
296
  "zustand": "5.0.14"
296
297
  },
297
298
  "devDependencies": {
298
- "@graphql-codegen/cli": "7.1.1",
299
+ "@graphql-codegen/cli": "7.1.3",
299
300
  "@inkeep/cxkit-types": "0.5.119",
300
301
  "@testing-library/dom": "10.4.1",
301
302
  "@testing-library/jest-dom": "6.9.1",
@@ -307,14 +308,14 @@
307
308
  "@types/hast": "3.0.4",
308
309
  "@types/json-schema": "7.0.15",
309
310
  "@types/mdast": "4.0.4",
310
- "@types/mdx": "2.0.13",
311
+ "@types/mdx": "2.0.14",
311
312
  "@types/node": "22.19.1",
312
313
  "@types/react-is": "19.2.0",
313
314
  "@types/semver": "7.7.1",
314
315
  "@types/unist": "3.0.3",
315
316
  "@types/yargs": "17.0.35",
316
- "@typescript/native-preview": "7.0.0-dev.20260610.1",
317
- "@vitest/coverage-v8": "4.1.8",
317
+ "@typescript/native-preview": "7.0.0-dev.20260624.1",
318
+ "@vitest/coverage-v8": "4.1.9",
318
319
  "happy-dom": "20.10.2",
319
320
  "oxc-parser": "^0.135.0",
320
321
  "react": "19.2.7",
@@ -26,7 +26,7 @@ import type { Adapter } from "./adapter.js";
26
26
  import { getRoutesByConfig } from "./main.js";
27
27
  import { protectChunks as rawProtectChunks } from "./protectChunks.js";
28
28
  import { getSsrCacheControl } from "./ssrCacheControl.js";
29
- import { wrapProtectedRoutes } from "./wrapProtectedRoutes.js";
29
+ import { wrapProtectedRoutesForRender } from "./wrapProtectedRoutes.js";
30
30
 
31
31
  export { getRoutesByConfig };
32
32
  export type { Adapter, AdapterContext } from "./adapter.js";
@@ -104,12 +104,16 @@ export const handleRequest = async ({
104
104
  const ssrAuth = await resolveSsrAuth(request);
105
105
 
106
106
  // No-op lazy() on protected subtrees for unauthed requests so loaders
107
- // don't run for a 401 render.
108
- const effectiveRoutes = wrapProtectedRoutes(
107
+ // don't run for a 401 render. A bypass render (search-index pass) keeps the
108
+ // real content so Pagefind can index protected routes.
109
+ const effectiveRoutes = wrapProtectedRoutesForRender(
109
110
  routes,
110
111
  config.protectedRoutes,
111
- !!ssrAuth?.profile,
112
- basePath,
112
+ {
113
+ isAuthenticated: !!ssrAuth?.profile,
114
+ bypassProtection,
115
+ basePath,
116
+ },
113
117
  );
114
118
 
115
119
  const { query, dataRoutes } = createStaticHandler(effectiveRoutes, {
@@ -47,6 +47,31 @@ export const wrapProtectedRoutes = (
47
47
  });
48
48
  };
49
49
 
50
+ // Route-wrapping decision for an SSR/SSG render. The prerender does a second
51
+ // "bypass" pass over protected routes to build the search index; that pass must
52
+ // render the real (unstubbed) protected content, so it is treated as
53
+ // authenticated here. Without this, the bypass pass renders an empty shell and
54
+ // the Pagefind index for protected routes ends up empty (issue #2672).
55
+ export const wrapProtectedRoutesForRender = (
56
+ routes: RouteObject[],
57
+ protectedRoutes: ProtectedRoutesInput,
58
+ {
59
+ isAuthenticated,
60
+ bypassProtection,
61
+ basePath,
62
+ }: {
63
+ isAuthenticated: boolean;
64
+ bypassProtection?: boolean;
65
+ basePath?: string;
66
+ },
67
+ ): RouteObject[] =>
68
+ wrapProtectedRoutes(
69
+ routes,
70
+ protectedRoutes,
71
+ isAuthenticated || bypassProtection === true,
72
+ basePath,
73
+ );
74
+
50
75
  // Inline elements can't be chunk-isolated; RouteGuard still blocks render,
51
76
  // but the JS ships in the main bundle. Only meaningful in dev.
52
77
  export const warnInlineProtectedRoutes = (
@@ -12,6 +12,7 @@ import { getZudokuRootDir } from "../cli/common/package-json.js";
12
12
  import { runPluginTransformConfig } from "../lib/core/transform-config.js";
13
13
  import invariant from "../lib/util/invariant.js";
14
14
  import { fileExists } from "./file-exists.js";
15
+ import { getPluginVersions } from "./plugin-versions.js";
15
16
  import type {
16
17
  ResolvedZudokuConfig,
17
18
  ZudokuConfig,
@@ -240,6 +241,20 @@ export async function loadZudokuConfig(
240
241
  { timestamp: true },
241
242
  );
242
243
 
244
+ // Surface which versions of external plugins (created via `createPlugin`)
245
+ // the build is running, so build logs can pin down e.g. the monetization
246
+ // plugin version without each plugin having to log it itself.
247
+ const pluginVersions = await getPluginVersions(config.__pluginDirs ?? []);
248
+ if (pluginVersions.length > 0) {
249
+ logger.info(
250
+ colors.cyan(`loaded plugins `) +
251
+ colors.dim(
252
+ pluginVersions.map((p) => `${p.name}@${p.version}`).join(", "),
253
+ ),
254
+ { timestamp: true },
255
+ );
256
+ }
257
+
243
258
  return { config, envPrefix, publicEnv };
244
259
  } catch (error) {
245
260
  const lastValid = getConfig();
@@ -0,0 +1,38 @@
1
+ import { readFile } from "node:fs/promises";
2
+ import path from "node:path";
3
+ import { findPackageRoot } from "../vite/package-root.js";
4
+
5
+ export type PluginVersion = { name: string; version: string };
6
+
7
+ // Reads the `name`@`version` from each plugin dir's nearest package.json,
8
+ // deduped by package name.
9
+ export const getPluginVersions = async (
10
+ pluginDirs: readonly string[],
11
+ ): Promise<PluginVersion[]> => {
12
+ const roots = await Promise.all(
13
+ [...new Set(pluginDirs)].map(findPackageRoot),
14
+ );
15
+
16
+ const resolved = await Promise.all(
17
+ roots.map(async (root): Promise<PluginVersion | undefined> => {
18
+ if (!root) return undefined;
19
+ try {
20
+ const pkg = JSON.parse(
21
+ await readFile(path.join(root, "package.json"), "utf-8"),
22
+ ) as { name?: string; version?: string };
23
+ if (!pkg.name) return undefined;
24
+ return { name: pkg.name, version: pkg.version ?? "unknown" };
25
+ } catch {
26
+ // A plugin without a readable package.json simply isn't reported.
27
+ return undefined;
28
+ }
29
+ }),
30
+ );
31
+
32
+ const byName = new Map<string, PluginVersion>();
33
+ for (const version of resolved) {
34
+ if (version && !byName.has(version.name)) byName.set(version.name, version);
35
+ }
36
+
37
+ return [...byName.values()];
38
+ };
@@ -63,11 +63,12 @@ const NavigationCategoryInner = ({
63
63
  setOpen((prev) => !prev);
64
64
  setHasInteracted(true);
65
65
  }}
66
- variant="ghost"
66
+ variant="none"
67
67
  size="icon"
68
68
  aria-label={open ? "Collapse section" : "Expand section"}
69
69
  aria-expanded={open}
70
- className="size-6 hover:bg-[hsl(from_var(--accent)_h_s_calc(l+6*var(--dark)))]"
70
+ // -my-0.5 keeps the chevron from making folder rows taller than plain ones
71
+ className="size-6 -my-0.5"
71
72
  >
72
73
  <ChevronRightIcon
73
74
  size={16}
@@ -24,7 +24,7 @@ export const StackDrillRow = ({
24
24
  className={cn(navigationListItem(), "group justify-between", className)}
25
25
  >
26
26
  <span className="flex items-center gap-2 truncate">{children}</span>
27
- <span className="grid size-6 shrink-0 place-items-center">
27
+ <span className="grid size-6 -my-0.5 shrink-0 place-items-center">
28
28
  <ChevronRightIcon size={16} />
29
29
  </span>
30
30
  </Link>
@@ -1,10 +1,7 @@
1
1
  import fs from "node:fs/promises";
2
2
  import path from "node:path";
3
- import {
4
- $RefParser,
5
- type JSONSchema,
6
- } from "@apidevtools/json-schema-ref-parser";
7
- import { upgrade, validate } from "@scalar/openapi-parser";
3
+ import { $RefParser } from "@apidevtools/json-schema-ref-parser";
4
+ import { upgrade } from "@scalar/openapi-parser";
8
5
  import { deepEqual } from "fast-equals";
9
6
  import type { LoadedConfig } from "../../config/config.js";
10
7
  import type { Processor } from "../../config/validators/BuildSchema.js";
@@ -98,7 +95,14 @@ export class SchemaManager {
98
95
  this.storeDir = storeDir;
99
96
  this.config = config;
100
97
  this.processors = [
101
- ({ schema }) => upgrade(schema).specification as OpenAPIDocument,
98
+ ({ schema }) => {
99
+ const upgraded = upgrade(schema as unknown as Record<string, unknown>)
100
+ .specification as OpenAPIDocument;
101
+ if (upgraded?.info && typeof upgraded.info.version !== "string") {
102
+ upgraded.info.version = "0.0.1";
103
+ }
104
+ return upgraded;
105
+ },
102
106
  flattenAllOfProcessor,
103
107
  ...processors,
104
108
  ];
@@ -147,7 +151,6 @@ export class SchemaManager {
147
151
  this.referencedBy.get(file)?.add(filePath);
148
152
  });
149
153
 
150
- const validatedSchema = await this.validateSchema(schema, filePath);
151
154
  const processedSchema = await this.processors.reduce(
152
155
  async (schema, processor) =>
153
156
  processor({
@@ -162,7 +165,7 @@ export class SchemaManager {
162
165
  },
163
166
  }),
164
167
  }),
165
- Promise.resolve(validatedSchema),
168
+ Promise.resolve(schema as OpenAPIDocument),
166
169
  );
167
170
 
168
171
  const processedTime = Date.now();
@@ -365,21 +368,4 @@ export class SchemaManager {
365
368
  `${fileName}${suffix}${extension}`,
366
369
  );
367
370
  };
368
-
369
- private validateSchema = async (
370
- schema: JSONSchema,
371
- filePath: string,
372
- ): Promise<OpenAPIDocument> => {
373
- const validated = await validate(schema);
374
- if (validated.errors?.length) {
375
- // biome-ignore lint/suspicious/noConsole: Logging allowed here
376
- console.warn(`Schema warnings in ${filePath}:`);
377
- for (const error of validated.errors) {
378
- // biome-ignore lint/suspicious/noConsole: Logging allowed here
379
- console.warn(error);
380
- }
381
- }
382
-
383
- return schema as OpenAPIDocument;
384
- };
385
371
  }
@@ -1,8 +1,10 @@
1
1
  import rehypeMetaAsAttributes from "@lekoarts/rehype-meta-as-attributes";
2
+ import { nodeTypes } from "@mdx-js/mdx";
2
3
  import mdx from "@mdx-js/rollup";
3
4
  import type { Root as HastRoot } from "hast";
4
5
  import { toString as hastToString } from "hast-util-to-string";
5
6
  import rehypeMdxImportMedia from "rehype-mdx-import-media";
7
+ import rehypeRaw from "rehype-raw";
6
8
  import rehypeSlug from "rehype-slug";
7
9
  import remarkComment from "remark-comment";
8
10
  import remarkDirective from "remark-directive";
@@ -128,6 +130,7 @@ const viteMdxPlugin = async (): Promise<Plugin> => {
128
130
  : [...defaultRemarkPlugins, ...(buildConfig?.remarkPlugins ?? [])];
129
131
 
130
132
  const defaultRehypePlugins = [
133
+ [rehypeRaw, { passThrough: nodeTypes }],
131
134
  rehypeSlug,
132
135
  rehypeExtractTocWithJsx,
133
136
  rehypeExtractTocWithJsxExport,
@@ -20,7 +20,11 @@ import {
20
20
  } from "../plugin-markdown-export.js";
21
21
  import { isTTY, throttle, writeLine } from "../reporter.js";
22
22
  import { generateSitemap } from "../sitemap.js";
23
- import { routesToPaths, routesToRewrites } from "./utils.js";
23
+ import {
24
+ routesToPaths,
25
+ routesToRewrites,
26
+ selectPagesToIndex,
27
+ } from "./utils.js";
24
28
  import type { StaticWorkerData, WorkerData } from "./worker.js";
25
29
 
26
30
  const Piscina = PiscinaImport as unknown as typeof PiscinaImport.default;
@@ -28,7 +32,13 @@ const Piscina = PiscinaImport as unknown as typeof PiscinaImport.default;
28
32
  export type WorkerResult = {
29
33
  outputPath: string;
30
34
  html: string;
35
+ // Status of the page as served statically (the file written to disk). For a
36
+ // protected route during SSG this is 401 (the sign-in page).
31
37
  statusCode: number;
38
+ // Status of the render whose HTML is fed to the search index. Equals
39
+ // `statusCode` for normal routes; for protected routes it's the bypass
40
+ // render (200) so the full content gets indexed (issue #2672).
41
+ indexStatusCode: number;
32
42
  redirect?: { from: string; to: string };
33
43
  };
34
44
 
@@ -173,9 +183,24 @@ export const prerender = async ({
173
183
  }
174
184
 
175
185
  if (pagefindIndex) {
176
- const pagesToIndex = workerResults.flatMap(({ statusCode, html }, i) =>
177
- statusCode < 400 ? { url: paths[i], html } : [],
186
+ const { include: pagesToIndex, exclude } = selectPagesToIndex(
187
+ workerResults,
188
+ paths,
178
189
  );
190
+
191
+ // Surface anything dropped from the index (e.g. a protected route whose
192
+ // bypass render returned >= 400). A page silently missing from the index
193
+ // is the exact symptom of #2672, so make it visible rather than quiet.
194
+ if (exclude.length > 0) {
195
+ const details = exclude
196
+ .map(({ url, status }) => `${url} (${status})`)
197
+ .join(", ");
198
+ logger.warn(
199
+ colors.yellow(
200
+ `⚠ ${exclude.length} route(s) excluded from the search index (render status >= 400): ${details}`,
201
+ ),
202
+ );
203
+ }
179
204
  // Batch size caps concurrent IPC writes to the pagefind child process;
180
205
  // higher values can overflow its pipe buffer and trigger ENOBUFS.
181
206
  const BATCH_SIZE = 40;
@@ -74,3 +74,36 @@ export const routesToPaths = (routes: RouteObject[]): string[] =>
74
74
 
75
75
  export const routesToRewrites = (routes: RouteObject[]): RouteRewrite[] =>
76
76
  collectRewrites(resolveRoutes(routes));
77
+
78
+ type IndexablePage = { indexStatusCode: number; html: string };
79
+
80
+ type IndexSelection = {
81
+ include: { url: string; html: string }[];
82
+ exclude: { url: string; status: number }[];
83
+ };
84
+
85
+ // Splits prerendered pages into those added to the search index and those
86
+ // skipped because their indexed render failed. `indexStatusCode` is the status
87
+ // of the render whose HTML is indexed: for protected routes that's the bypass
88
+ // render (200), not the gated main render (401), so protected pages aren't
89
+ // silently dropped (issue #2672). Excluded pages are returned rather than
90
+ // dropped in silence so the caller can warn about them, since a protected page
91
+ // missing from the index is exactly the symptom this fix targets.
92
+ export const selectPagesToIndex = (
93
+ pages: IndexablePage[],
94
+ paths: string[],
95
+ ): IndexSelection => {
96
+ const withUrl = pages.flatMap(({ indexStatusCode, html }, i) => {
97
+ const url = paths[i];
98
+ return url === undefined ? [] : [{ url, html, indexStatusCode }];
99
+ });
100
+
101
+ return {
102
+ include: withUrl
103
+ .filter((p) => p.indexStatusCode < 400)
104
+ .map(({ url, html }) => ({ url, html })),
105
+ exclude: withUrl
106
+ .filter((p) => p.indexStatusCode >= 400)
107
+ .map(({ url, indexStatusCode }) => ({ url, status: indexStatusCode })),
108
+ };
109
+ };
@@ -64,6 +64,7 @@ const renderPage = async ({ urlPath }: WorkerData): Promise<WorkerResult> => {
64
64
  outputPath,
65
65
  redirect: { from: pathname, to: redirectTo },
66
66
  statusCode: response.status,
67
+ indexStatusCode: response.status,
67
68
  html: "",
68
69
  };
69
70
  }
@@ -77,8 +78,12 @@ const renderPage = async ({ urlPath }: WorkerData): Promise<WorkerResult> => {
77
78
  // Get HTML content for file write
78
79
  const fileContent = response.body ? await response.text() : "";
79
80
 
80
- // For protected routes, do a second render with protection bypassed for search index
81
+ // For protected routes, do a second render with protection bypassed so the
82
+ // search index gets the full content instead of the gated sign-in page. The
83
+ // file on disk stays the gated (401) render; only `html`/`indexStatusCode`
84
+ // reflect the bypass render.
81
85
  let html = fileContent;
86
+ let indexStatusCode = response.status;
82
87
  if (isProtectedRoute) {
83
88
  const bypassRequest = new Request(url);
84
89
  const bypassResponse = await server.handleRequest({
@@ -96,6 +101,7 @@ const renderPage = async ({ urlPath }: WorkerData): Promise<WorkerResult> => {
96
101
  }
97
102
 
98
103
  html = bypassResponse.body ? await bypassResponse.text() : "";
104
+ indexStatusCode = bypassResponse.status;
99
105
  }
100
106
 
101
107
  // Write the file
@@ -105,6 +111,7 @@ const renderPage = async ({ urlPath }: WorkerData): Promise<WorkerResult> => {
105
111
  return {
106
112
  outputPath,
107
113
  statusCode: response.status,
114
+ indexStatusCode,
108
115
  redirect: undefined,
109
116
  html,
110
117
  };