@btst/stack 1.7.0 → 1.9.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.
Files changed (110) hide show
  1. package/dist/api/index.d.cts +2 -2
  2. package/dist/api/index.d.mts +2 -2
  3. package/dist/api/index.d.ts +2 -2
  4. package/dist/client/index.cjs +6 -2
  5. package/dist/client/index.d.cts +2 -1
  6. package/dist/client/index.d.mts +2 -1
  7. package/dist/client/index.d.ts +2 -1
  8. package/dist/client/index.mjs +6 -2
  9. package/dist/index.d.cts +1 -1
  10. package/dist/index.d.mts +1 -1
  11. package/dist/index.d.ts +1 -1
  12. package/dist/packages/better-stack/src/plugins/cms/api/plugin.cjs +445 -16
  13. package/dist/packages/better-stack/src/plugins/cms/api/plugin.mjs +445 -16
  14. package/dist/packages/better-stack/src/plugins/cms/client/components/forms/content-form.cjs +24 -7
  15. package/dist/packages/better-stack/src/plugins/cms/client/components/forms/content-form.mjs +25 -8
  16. package/dist/packages/better-stack/src/plugins/cms/client/components/forms/relation-field.cjs +224 -0
  17. package/dist/packages/better-stack/src/plugins/cms/client/components/forms/relation-field.mjs +222 -0
  18. package/dist/packages/better-stack/src/plugins/cms/client/components/inverse-relations-panel.cjs +243 -0
  19. package/dist/packages/better-stack/src/plugins/cms/client/components/inverse-relations-panel.mjs +241 -0
  20. package/dist/packages/better-stack/src/plugins/cms/client/components/pages/content-editor-page.internal.cjs +56 -2
  21. package/dist/packages/better-stack/src/plugins/cms/client/components/pages/content-editor-page.internal.mjs +56 -2
  22. package/dist/packages/better-stack/src/plugins/cms/client/hooks/cms-hooks.cjs +190 -0
  23. package/dist/packages/better-stack/src/plugins/cms/client/hooks/cms-hooks.mjs +187 -1
  24. package/dist/packages/better-stack/src/plugins/cms/db.cjs +38 -0
  25. package/dist/packages/better-stack/src/plugins/cms/db.mjs +38 -0
  26. package/dist/packages/better-stack/src/plugins/route-docs/client/components/loading/docs-skeleton.cjs +43 -0
  27. package/dist/packages/better-stack/src/plugins/route-docs/client/components/loading/docs-skeleton.mjs +41 -0
  28. package/dist/packages/better-stack/src/plugins/route-docs/client/components/pages/docs-page.cjs +794 -0
  29. package/dist/packages/better-stack/src/plugins/route-docs/client/components/pages/docs-page.mjs +788 -0
  30. package/dist/packages/better-stack/src/plugins/route-docs/client/plugin.cjs +111 -0
  31. package/dist/packages/better-stack/src/plugins/route-docs/client/plugin.mjs +106 -0
  32. package/dist/packages/better-stack/src/plugins/route-docs/generator.cjs +244 -0
  33. package/dist/packages/better-stack/src/plugins/route-docs/generator.mjs +227 -0
  34. package/dist/packages/ui/src/components/auto-form/fields/object.cjs +81 -1
  35. package/dist/packages/ui/src/components/auto-form/fields/object.mjs +81 -1
  36. package/dist/packages/ui/src/components/dialog.cjs +6 -0
  37. package/dist/packages/ui/src/components/dialog.mjs +6 -1
  38. package/dist/packages/ui/src/components/sheet.cjs +25 -0
  39. package/dist/packages/ui/src/components/sheet.mjs +24 -1
  40. package/dist/plugins/api/index.d.cts +2 -2
  41. package/dist/plugins/api/index.d.mts +2 -2
  42. package/dist/plugins/api/index.d.ts +2 -2
  43. package/dist/plugins/blog/api/index.d.cts +1 -1
  44. package/dist/plugins/blog/api/index.d.mts +1 -1
  45. package/dist/plugins/blog/api/index.d.ts +1 -1
  46. package/dist/plugins/blog/client/hooks/index.d.cts +2 -2
  47. package/dist/plugins/blog/client/hooks/index.d.mts +2 -2
  48. package/dist/plugins/blog/client/hooks/index.d.ts +2 -2
  49. package/dist/plugins/blog/client/index.d.cts +1 -1
  50. package/dist/plugins/blog/client/index.d.mts +1 -1
  51. package/dist/plugins/blog/client/index.d.ts +1 -1
  52. package/dist/plugins/blog/query-keys.d.cts +2 -2
  53. package/dist/plugins/blog/query-keys.d.mts +2 -2
  54. package/dist/plugins/blog/query-keys.d.ts +2 -2
  55. package/dist/plugins/client/index.d.cts +2 -2
  56. package/dist/plugins/client/index.d.mts +2 -2
  57. package/dist/plugins/client/index.d.ts +2 -2
  58. package/dist/plugins/cms/api/index.d.cts +67 -3
  59. package/dist/plugins/cms/api/index.d.mts +67 -3
  60. package/dist/plugins/cms/api/index.d.ts +67 -3
  61. package/dist/plugins/cms/client/hooks/index.cjs +4 -0
  62. package/dist/plugins/cms/client/hooks/index.d.cts +82 -3
  63. package/dist/plugins/cms/client/hooks/index.d.mts +82 -3
  64. package/dist/plugins/cms/client/hooks/index.d.ts +82 -3
  65. package/dist/plugins/cms/client/hooks/index.mjs +1 -1
  66. package/dist/plugins/cms/query-keys.d.cts +1 -1
  67. package/dist/plugins/cms/query-keys.d.mts +1 -1
  68. package/dist/plugins/cms/query-keys.d.ts +1 -1
  69. package/dist/plugins/form-builder/api/index.d.cts +1 -1
  70. package/dist/plugins/form-builder/api/index.d.mts +1 -1
  71. package/dist/plugins/form-builder/api/index.d.ts +1 -1
  72. package/dist/plugins/open-api/api/index.d.cts +1 -1
  73. package/dist/plugins/open-api/api/index.d.mts +1 -1
  74. package/dist/plugins/open-api/api/index.d.ts +1 -1
  75. package/dist/plugins/route-docs/client/index.cjs +10 -0
  76. package/dist/plugins/route-docs/client/index.d.cts +126 -0
  77. package/dist/plugins/route-docs/client/index.d.mts +126 -0
  78. package/dist/plugins/route-docs/client/index.d.ts +126 -0
  79. package/dist/plugins/route-docs/client/index.mjs +1 -0
  80. package/dist/plugins/route-docs/client.css +3 -0
  81. package/dist/plugins/route-docs/style.css +19 -0
  82. package/dist/shared/{stack.L-UFwz2G.d.mts → stack.oGOteE6g.d.cts} +27 -5
  83. package/dist/shared/{stack.L-UFwz2G.d.ts → stack.oGOteE6g.d.mts} +27 -5
  84. package/dist/shared/{stack.L-UFwz2G.d.cts → stack.oGOteE6g.d.ts} +27 -5
  85. package/dist/shared/{stack.CSce37mX.d.cts → stack.u9iYV6vt.d.cts} +14 -2
  86. package/dist/shared/{stack.CSce37mX.d.mts → stack.u9iYV6vt.d.mts} +14 -2
  87. package/dist/shared/{stack.CSce37mX.d.ts → stack.u9iYV6vt.d.ts} +14 -2
  88. package/package.json +15 -1
  89. package/src/client/index.ts +11 -4
  90. package/src/plugins/cms/api/plugin.ts +667 -21
  91. package/src/plugins/cms/client/components/forms/content-form.tsx +60 -18
  92. package/src/plugins/cms/client/components/forms/relation-field.tsx +299 -0
  93. package/src/plugins/cms/client/components/inverse-relations-panel.tsx +329 -0
  94. package/src/plugins/cms/client/components/pages/content-editor-page.internal.tsx +127 -1
  95. package/src/plugins/cms/client/hooks/cms-hooks.tsx +344 -0
  96. package/src/plugins/cms/db.ts +38 -0
  97. package/src/plugins/cms/types.ts +99 -10
  98. package/src/plugins/route-docs/client/components/loading/docs-skeleton.tsx +82 -0
  99. package/src/plugins/route-docs/client/components/loading/index.tsx +1 -0
  100. package/src/plugins/route-docs/client/components/pages/docs-page.tsx +1240 -0
  101. package/src/plugins/route-docs/client/index.ts +7 -0
  102. package/src/plugins/route-docs/client/plugin.tsx +187 -0
  103. package/src/plugins/route-docs/client.css +3 -0
  104. package/src/plugins/route-docs/generator.ts +385 -0
  105. package/src/plugins/route-docs/index.ts +12 -0
  106. package/src/plugins/route-docs/style.css +19 -0
  107. package/src/types.ts +19 -1
  108. package/dist/shared/{stack.CcI4sYJP.d.mts → stack.DLhzx1-D.d.cts} +1 -1
  109. package/dist/shared/{stack.CcI4sYJP.d.ts → stack.DLhzx1-D.d.mts} +1 -1
  110. package/dist/shared/{stack.CcI4sYJP.d.cts → stack.DLhzx1-D.d.ts} +1 -1
@@ -0,0 +1,1240 @@
1
+ "use client";
2
+
3
+ import React, { useState, useMemo } from "react";
4
+ import {
5
+ Card,
6
+ CardContent,
7
+ CardHeader,
8
+ CardTitle,
9
+ } from "@workspace/ui/components/card";
10
+ import { Badge } from "@workspace/ui/components/badge";
11
+ import { ScrollArea } from "@workspace/ui/components/scroll-area";
12
+ import { Separator } from "@workspace/ui/components/separator";
13
+ import {
14
+ Table,
15
+ TableBody,
16
+ TableCell,
17
+ TableHead,
18
+ TableHeader,
19
+ TableRow,
20
+ } from "@workspace/ui/components/table";
21
+ import { Button } from "@workspace/ui/components/button";
22
+ import { Input } from "@workspace/ui/components/input";
23
+ import { Label } from "@workspace/ui/components/label";
24
+ import {
25
+ Sheet,
26
+ SheetContent,
27
+ SheetHeader,
28
+ SheetTitle,
29
+ SheetTrigger,
30
+ } from "@workspace/ui/components/sheet";
31
+ import {
32
+ ChevronRight,
33
+ ExternalLink,
34
+ FileText,
35
+ Folder,
36
+ FolderOpen,
37
+ Globe,
38
+ Link2,
39
+ Menu,
40
+ Navigation,
41
+ } from "lucide-react";
42
+ import { useSuspenseQuery } from "@tanstack/react-query";
43
+ import type {
44
+ RouteDocsSchema,
45
+ DocumentedPlugin,
46
+ DocumentedRoute,
47
+ RouteParameter,
48
+ PluginSitemapEntry,
49
+ } from "../../../generator";
50
+ import { ROUTE_DOCS_QUERY_KEY, generateSchema } from "../../plugin";
51
+
52
+ /**
53
+ * Escapes regex special characters in a string, except for placeholders
54
+ * that will be replaced with actual regex patterns.
55
+ */
56
+ function escapeRegexForRoutePath(path: string): string {
57
+ // Use unique placeholders that won't appear in URLs
58
+ const PARAM_PLACEHOLDER = "\x00PARAM\x00";
59
+ const WILDCARD_PLACEHOLDER = "\x00WILDCARD\x00";
60
+
61
+ // Replace dynamic segments with placeholders before escaping
62
+ let result = path
63
+ .replace(/:[^/]+/g, PARAM_PLACEHOLDER)
64
+ .replace(/\*/g, WILDCARD_PLACEHOLDER);
65
+
66
+ // Escape all regex metacharacters
67
+ result = result.replace(/[.+?^${}()|[\]\\]/g, "\\$&");
68
+
69
+ // Replace placeholders with actual regex patterns
70
+ result = result
71
+ .replace(new RegExp(PARAM_PLACEHOLDER, "g"), "[^/]+")
72
+ .replace(new RegExp(WILDCARD_PLACEHOLDER, "g"), ".*");
73
+
74
+ return result;
75
+ }
76
+
77
+ /**
78
+ * Render a route path with highlighted parameters
79
+ */
80
+ function HighlightedPath({ path }: { path: string }) {
81
+ const parts = path.split("/");
82
+ return (
83
+ <code className="font-mono text-xl break-all">
84
+ {parts.map((part, i) => {
85
+ const isParam = part.startsWith(":") || part.startsWith("*");
86
+ return (
87
+ <React.Fragment key={i}>
88
+ {i > 0 && <span className="text-muted-foreground">/</span>}
89
+ {isParam ? (
90
+ <span className="text-primary font-semibold">{part}</span>
91
+ ) : (
92
+ <span className="text-foreground">{part}</span>
93
+ )}
94
+ </React.Fragment>
95
+ );
96
+ })}
97
+ </code>
98
+ );
99
+ }
100
+
101
+ /**
102
+ * Mobile-friendly parameter card (used on small screens instead of table)
103
+ */
104
+ function ParameterCard({ param }: { param: RouteParameter }) {
105
+ return (
106
+ <div className="rounded-lg border p-4 space-y-2">
107
+ <div className="flex items-center justify-between gap-2 flex-wrap">
108
+ <code className="font-mono text-sm text-primary font-semibold">
109
+ {param.name}
110
+ </code>
111
+ <div className="flex gap-2">
112
+ <Badge variant="secondary" className="font-mono text-xs">
113
+ {param.type}
114
+ </Badge>
115
+ <Badge
116
+ variant={param.required ? "destructive" : "outline"}
117
+ className="text-xs"
118
+ >
119
+ {param.required ? "required" : "optional"}
120
+ </Badge>
121
+ </div>
122
+ </div>
123
+ {param.description && (
124
+ <p className="text-sm text-muted-foreground">{param.description}</p>
125
+ )}
126
+ {param.schema?.enum && (
127
+ <p className="text-xs text-muted-foreground">
128
+ Values: {param.schema.enum.join(" | ")}
129
+ </p>
130
+ )}
131
+ </div>
132
+ );
133
+ }
134
+
135
+ /**
136
+ * Parameters section - responsive table on desktop, cards on mobile
137
+ */
138
+ function ParametersSection({
139
+ params,
140
+ title,
141
+ }: {
142
+ params: RouteParameter[];
143
+ title: string;
144
+ }) {
145
+ if (params.length === 0) return null;
146
+
147
+ return (
148
+ <div className="space-y-3">
149
+ <h3 className="text-sm font-semibold text-muted-foreground uppercase tracking-wide">
150
+ {title}
151
+ </h3>
152
+
153
+ {/* Desktop table */}
154
+ <div className="hidden md:block rounded-lg border overflow-x-auto">
155
+ <Table>
156
+ <TableHeader>
157
+ <TableRow>
158
+ <TableHead className="w-[150px]">Name</TableHead>
159
+ <TableHead className="w-[120px]">Type</TableHead>
160
+ <TableHead className="w-[100px]">Required</TableHead>
161
+ <TableHead>Description</TableHead>
162
+ </TableRow>
163
+ </TableHeader>
164
+ <TableBody>
165
+ {params.map((param) => (
166
+ <TableRow key={param.name}>
167
+ <TableCell>
168
+ <code className="font-mono text-sm text-primary">
169
+ {param.name}
170
+ </code>
171
+ </TableCell>
172
+ <TableCell>
173
+ <Badge variant="secondary" className="font-mono text-xs">
174
+ {param.type}
175
+ </Badge>
176
+ {param.schema?.enum && (
177
+ <span className="ml-2 text-xs text-muted-foreground">
178
+ ({param.schema.enum.join(" | ")})
179
+ </span>
180
+ )}
181
+ </TableCell>
182
+ <TableCell>
183
+ <Badge
184
+ variant={param.required ? "destructive" : "outline"}
185
+ className="text-xs"
186
+ >
187
+ {param.required ? "required" : "optional"}
188
+ </Badge>
189
+ </TableCell>
190
+ <TableCell className="text-muted-foreground">
191
+ {param.description || "—"}
192
+ </TableCell>
193
+ </TableRow>
194
+ ))}
195
+ </TableBody>
196
+ </Table>
197
+ </div>
198
+
199
+ {/* Mobile cards */}
200
+ <div className="md:hidden space-y-3">
201
+ {params.map((param) => (
202
+ <ParameterCard key={param.name} param={param} />
203
+ ))}
204
+ </div>
205
+ </div>
206
+ );
207
+ }
208
+
209
+ /**
210
+ * Navigation form for routes with path parameters
211
+ */
212
+ function NavigationForm({
213
+ route,
214
+ siteBasePath,
215
+ }: {
216
+ route: DocumentedRoute;
217
+ siteBasePath: string;
218
+ }) {
219
+ const [paramValues, setParamValues] = useState<Record<string, string>>({});
220
+
221
+ const handleParamChange = (name: string, value: string) => {
222
+ setParamValues((prev) => ({ ...prev, [name]: value }));
223
+ };
224
+
225
+ const buildUrl = () => {
226
+ let url = route.path;
227
+ for (const param of route.pathParams) {
228
+ const value = paramValues[param.name] || `{${param.name}}`;
229
+ // Handle different parameter patterns:
230
+ // - *:name (named wildcard) - must check before :name
231
+ // - * (anonymous wildcard, extracted as "_")
232
+ // - :name (standard path param)
233
+ if (param.name === "_") {
234
+ url = url.replace("*", value);
235
+ } else if (url.includes(`*:${param.name}`)) {
236
+ url = url.replace(`*:${param.name}`, value);
237
+ } else {
238
+ url = url.replace(`:${param.name}`, value);
239
+ }
240
+ }
241
+ return `${siteBasePath}${url}`;
242
+ };
243
+
244
+ const handleVisit = () => {
245
+ const url = buildUrl();
246
+ const hasUnfilledParams = route.pathParams.some(
247
+ (p) => !paramValues[p.name],
248
+ );
249
+ if (hasUnfilledParams) {
250
+ return;
251
+ }
252
+ window.open(url, "_blank");
253
+ };
254
+
255
+ const allParamsFilled = route.pathParams.every((p) => paramValues[p.name]);
256
+ const previewUrl = buildUrl();
257
+
258
+ return (
259
+ <Card>
260
+ <CardHeader className="pb-3">
261
+ <CardTitle className="text-base flex items-center gap-2">
262
+ <Navigation className="h-4 w-4" />
263
+ Navigate to Route
264
+ </CardTitle>
265
+ </CardHeader>
266
+ <CardContent className="space-y-4">
267
+ {route.pathParams.length > 0 ? (
268
+ <>
269
+ <div className="grid gap-4 grid-cols-1 sm:grid-cols-2">
270
+ {route.pathParams.map((param) => (
271
+ <div key={param.name} className="space-y-2">
272
+ <Label htmlFor={`param-${param.name}`} className="font-mono">
273
+ :{param.name}
274
+ </Label>
275
+ <Input
276
+ id={`param-${param.name}`}
277
+ placeholder={`Enter ${param.name}...`}
278
+ value={paramValues[param.name] || ""}
279
+ onChange={(e) =>
280
+ handleParamChange(param.name, e.target.value)
281
+ }
282
+ />
283
+ </div>
284
+ ))}
285
+ </div>
286
+ <div className="flex flex-col sm:flex-row items-stretch sm:items-center gap-3 pt-2">
287
+ <code className="flex-1 text-xs bg-muted px-3 py-2 rounded-md font-mono text-muted-foreground break-all">
288
+ {previewUrl}
289
+ </code>
290
+ <Button
291
+ onClick={handleVisit}
292
+ disabled={!allParamsFilled}
293
+ className="shrink-0"
294
+ >
295
+ <ExternalLink className="h-4 w-4 mr-2" />
296
+ Visit
297
+ </Button>
298
+ </div>
299
+ </>
300
+ ) : (
301
+ <div className="flex flex-col sm:flex-row items-stretch sm:items-center gap-3">
302
+ <code className="flex-1 text-sm bg-muted px-3 py-2 rounded-md font-mono break-all">
303
+ {siteBasePath}
304
+ {route.path}
305
+ </code>
306
+ <Button
307
+ onClick={() =>
308
+ window.open(`${siteBasePath}${route.path}`, "_blank")
309
+ }
310
+ className="shrink-0"
311
+ >
312
+ <ExternalLink className="h-4 w-4 mr-2" />
313
+ Visit
314
+ </Button>
315
+ </div>
316
+ )}
317
+ </CardContent>
318
+ </Card>
319
+ );
320
+ }
321
+
322
+ /**
323
+ * Get sitemap entries that match a specific route
324
+ */
325
+ function getMatchingSitemapEntries(
326
+ route: DocumentedRoute,
327
+ sitemapEntries: PluginSitemapEntry[],
328
+ ): PluginSitemapEntry[] {
329
+ const hasParams = route.pathParams.length > 0;
330
+
331
+ if (!hasParams) {
332
+ // Static route - exact matches
333
+ return sitemapEntries.filter((e) => {
334
+ try {
335
+ const url = new URL(e.url);
336
+ return url.pathname.endsWith(route.path);
337
+ } catch {
338
+ return false;
339
+ }
340
+ });
341
+ } else {
342
+ // Dynamic route - pattern matches
343
+ const routePattern = escapeRegexForRoutePath(route.path);
344
+ const regex = new RegExp(`${routePattern}$`);
345
+ return sitemapEntries.filter((e) => {
346
+ try {
347
+ const url = new URL(e.url);
348
+ return regex.test(url.pathname);
349
+ } catch {
350
+ return false;
351
+ }
352
+ });
353
+ }
354
+ }
355
+
356
+ /**
357
+ * Route sitemap entries section - displays sitemap entries for a specific route
358
+ */
359
+ function RouteSitemapSection({
360
+ route,
361
+ sitemapEntries,
362
+ }: {
363
+ route: DocumentedRoute;
364
+ sitemapEntries: PluginSitemapEntry[];
365
+ }) {
366
+ const matchingEntries = useMemo(
367
+ () => getMatchingSitemapEntries(route, sitemapEntries),
368
+ [route, sitemapEntries],
369
+ );
370
+
371
+ if (matchingEntries.length === 0) return null;
372
+
373
+ return (
374
+ <Card>
375
+ <CardHeader className="pb-3">
376
+ <CardTitle className="text-base flex items-center gap-2">
377
+ <Globe className="h-4 w-4" />
378
+ Sitemap Entries
379
+ <Badge variant="secondary" className="ml-1">
380
+ {matchingEntries.length}
381
+ </Badge>
382
+ </CardTitle>
383
+ </CardHeader>
384
+ <CardContent>
385
+ {/* Desktop table */}
386
+ <div className="hidden md:block rounded-lg border overflow-x-auto">
387
+ <Table>
388
+ <TableHeader>
389
+ <TableRow>
390
+ <TableHead>URL</TableHead>
391
+ <TableHead className="w-[120px]">Last Modified</TableHead>
392
+ <TableHead className="w-[80px]">Priority</TableHead>
393
+ <TableHead className="w-[80px]">Actions</TableHead>
394
+ </TableRow>
395
+ </TableHeader>
396
+ <TableBody>
397
+ {matchingEntries.map((entry, idx) => (
398
+ <TableRow key={idx}>
399
+ <TableCell>
400
+ <a
401
+ href={entry.url}
402
+ target="_blank"
403
+ rel="noopener noreferrer"
404
+ className="hover:underline"
405
+ >
406
+ <code className="font-mono text-xs text-primary truncate block max-w-[400px]">
407
+ {entry.url}
408
+ </code>
409
+ </a>
410
+ </TableCell>
411
+ <TableCell className="text-xs text-muted-foreground">
412
+ {formatDate(entry.lastModified)}
413
+ </TableCell>
414
+ <TableCell className="text-xs text-muted-foreground">
415
+ {entry.priority !== undefined ? entry.priority : "—"}
416
+ </TableCell>
417
+ <TableCell>
418
+ <Button
419
+ variant="ghost"
420
+ size="sm"
421
+ className="h-7 px-2"
422
+ onClick={() => window.open(entry.url, "_blank")}
423
+ >
424
+ <ExternalLink className="h-3 w-3" />
425
+ </Button>
426
+ </TableCell>
427
+ </TableRow>
428
+ ))}
429
+ </TableBody>
430
+ </Table>
431
+ </div>
432
+
433
+ {/* Mobile cards */}
434
+ <div className="md:hidden space-y-3">
435
+ {matchingEntries.map((entry, idx) => (
436
+ <div key={idx} className="rounded-lg border p-3 space-y-2">
437
+ <div className="flex items-start justify-between gap-2">
438
+ <a
439
+ href={entry.url}
440
+ target="_blank"
441
+ rel="noopener noreferrer"
442
+ className="font-mono text-xs text-primary break-all hover:underline"
443
+ >
444
+ {entry.url}
445
+ </a>
446
+ <Button
447
+ variant="ghost"
448
+ size="sm"
449
+ className="h-7 w-7 p-0 shrink-0"
450
+ onClick={() => window.open(entry.url, "_blank")}
451
+ >
452
+ <ExternalLink className="h-3 w-3" />
453
+ </Button>
454
+ </div>
455
+ <div className="flex flex-wrap gap-2 text-xs text-muted-foreground">
456
+ {entry.lastModified && (
457
+ <span>{formatDate(entry.lastModified)}</span>
458
+ )}
459
+ {entry.priority !== undefined && (
460
+ <span>Priority: {entry.priority}</span>
461
+ )}
462
+ </div>
463
+ </div>
464
+ ))}
465
+ </div>
466
+ </CardContent>
467
+ </Card>
468
+ );
469
+ }
470
+
471
+ /**
472
+ * Route detail view
473
+ */
474
+ function RouteDetail({
475
+ route,
476
+ pluginName,
477
+ sitemapEntries,
478
+ siteBasePath,
479
+ }: {
480
+ route: DocumentedRoute;
481
+ pluginName: string;
482
+ sitemapEntries: PluginSitemapEntry[];
483
+ siteBasePath: string;
484
+ }) {
485
+ return (
486
+ <div className="space-y-4">
487
+ {/* Route metadata if available */}
488
+ {route.meta && (route.meta.title || route.meta.description) && (
489
+ <Card>
490
+ <CardHeader className="pb-3">
491
+ {route.meta.title && (
492
+ <CardTitle className="text-lg sm:text-xl">
493
+ {route.meta.title}
494
+ </CardTitle>
495
+ )}
496
+ </CardHeader>
497
+ {(route.meta.description ||
498
+ (route.meta.tags && route.meta.tags.length > 0)) && (
499
+ <CardContent className="space-y-3">
500
+ {route.meta.description && (
501
+ <p className="text-muted-foreground text-sm sm:text-base">
502
+ {route.meta.description}
503
+ </p>
504
+ )}
505
+ {route.meta.tags && route.meta.tags.length > 0 && (
506
+ <div className="flex flex-wrap gap-2">
507
+ {route.meta.tags.map((tag) => (
508
+ <Badge key={tag} variant="secondary">
509
+ {tag}
510
+ </Badge>
511
+ ))}
512
+ </div>
513
+ )}
514
+ </CardContent>
515
+ )}
516
+ </Card>
517
+ )}
518
+
519
+ {/* Route path */}
520
+ <div className="flex flex-col sm:flex-row sm:items-center gap-3 flex-wrap">
521
+ <div className="font-mono overflow-x-auto">
522
+ <HighlightedPath path={route.path} />
523
+ </div>
524
+ <Badge variant="outline">{pluginName}</Badge>
525
+ </div>
526
+
527
+ {/* Navigation form */}
528
+ <NavigationForm route={route} siteBasePath={siteBasePath} />
529
+
530
+ {/* Path parameters */}
531
+ <ParametersSection params={route.pathParams} title="Path Parameters" />
532
+
533
+ {/* Query parameters */}
534
+ <ParametersSection params={route.queryParams} title="Query Parameters" />
535
+
536
+ {/* Sitemap entries for this route */}
537
+ <RouteSitemapSection route={route} sitemapEntries={sitemapEntries} />
538
+ </div>
539
+ );
540
+ }
541
+
542
+ /**
543
+ * Generate a unique anchor ID for a route
544
+ */
545
+ function getRouteAnchorId(pluginKey: string, routeKey: string): string {
546
+ return `route-${pluginKey}-${routeKey}`;
547
+ }
548
+
549
+ /**
550
+ * Sidebar route item - now an anchor link
551
+ */
552
+ function SidebarRouteItem({
553
+ route,
554
+ pluginKey,
555
+ onNavigate,
556
+ }: {
557
+ route: DocumentedRoute;
558
+ pluginKey: string;
559
+ onNavigate?: () => void;
560
+ }) {
561
+ const anchorId = getRouteAnchorId(pluginKey, route.key);
562
+
563
+ const handleClick = (e: React.MouseEvent) => {
564
+ e.preventDefault();
565
+ const element = document.getElementById(anchorId);
566
+ if (element) {
567
+ element.scrollIntoView({ behavior: "smooth", block: "start" });
568
+ // Update URL hash without scrolling (scrollIntoView handles it)
569
+ window.history.pushState(null, "", `#${anchorId}`);
570
+ }
571
+ onNavigate?.();
572
+ };
573
+
574
+ return (
575
+ <a
576
+ href={`#${anchorId}`}
577
+ onClick={handleClick}
578
+ className="flex items-center w-full justify-start font-mono text-xs h-auto py-2 px-3 rounded-md hover:bg-accent hover:text-accent-foreground transition-colors"
579
+ >
580
+ <FileText className="mr-2 h-3 w-3 shrink-0" />
581
+ <span className="truncate">{route.path}</span>
582
+ </a>
583
+ );
584
+ }
585
+
586
+ /**
587
+ * Sidebar plugin group
588
+ */
589
+ function SidebarPluginGroup({
590
+ plugin,
591
+ onNavigate,
592
+ }: {
593
+ plugin: DocumentedPlugin;
594
+ onNavigate?: () => void;
595
+ }) {
596
+ const [isExpanded, setIsExpanded] = useState(true);
597
+
598
+ return (
599
+ <div className="space-y-1">
600
+ <Button
601
+ variant="ghost"
602
+ size="sm"
603
+ className="w-full justify-between font-medium h-auto py-2"
604
+ onClick={() => setIsExpanded(!isExpanded)}
605
+ >
606
+ <span className="flex items-center">
607
+ {isExpanded ? (
608
+ <FolderOpen className="mr-2 h-4 w-4" />
609
+ ) : (
610
+ <Folder className="mr-2 h-4 w-4" />
611
+ )}
612
+ {plugin.name}
613
+ </span>
614
+ <ChevronRight
615
+ className={`h-4 w-4 transition-transform ${isExpanded ? "rotate-90" : ""}`}
616
+ />
617
+ </Button>
618
+ {isExpanded && (
619
+ <div className="ml-2 space-y-0.5">
620
+ {plugin.routes.map((route) => (
621
+ <SidebarRouteItem
622
+ key={route.key}
623
+ route={route}
624
+ pluginKey={plugin.key}
625
+ onNavigate={onNavigate}
626
+ />
627
+ ))}
628
+ </div>
629
+ )}
630
+ </div>
631
+ );
632
+ }
633
+
634
+ /**
635
+ * Sidebar content (shared between desktop and mobile)
636
+ */
637
+ function SidebarContent({
638
+ schema,
639
+ onNavigate,
640
+ }: {
641
+ schema: RouteDocsSchema;
642
+ onNavigate?: () => void;
643
+ }) {
644
+ return (
645
+ <div className="p-3 space-y-4">
646
+ {schema.plugins.map((plugin) => (
647
+ <SidebarPluginGroup
648
+ key={plugin.key}
649
+ plugin={plugin}
650
+ onNavigate={onNavigate}
651
+ />
652
+ ))}
653
+ </div>
654
+ );
655
+ }
656
+
657
+ /**
658
+ * Mobile-friendly route card for the routes list
659
+ */
660
+ function RouteCard({
661
+ pluginName,
662
+ route,
663
+ hasParams,
664
+ staticUrl,
665
+ sitemapCount = 0,
666
+ onSelect,
667
+ }: {
668
+ pluginName: string;
669
+ route: DocumentedRoute;
670
+ hasParams: boolean;
671
+ staticUrl: string | null;
672
+ sitemapCount?: number;
673
+ onSelect: () => void;
674
+ }) {
675
+ return (
676
+ <div className="rounded-lg border p-4 space-y-3">
677
+ <div className="flex items-start justify-between gap-2">
678
+ <button onClick={onSelect} className="text-left hover:underline">
679
+ <code className="font-mono text-sm text-primary break-all">
680
+ {route.path}
681
+ </code>
682
+ </button>
683
+ {staticUrl ? (
684
+ <Button
685
+ variant="ghost"
686
+ size="sm"
687
+ className="h-8 w-8 p-0 shrink-0"
688
+ onClick={() => window.open(staticUrl, "_blank")}
689
+ >
690
+ <ExternalLink className="h-4 w-4" />
691
+ </Button>
692
+ ) : (
693
+ <Button
694
+ variant="ghost"
695
+ size="sm"
696
+ className="h-8 w-8 p-0 shrink-0"
697
+ onClick={onSelect}
698
+ >
699
+ <Navigation className="h-4 w-4" />
700
+ </Button>
701
+ )}
702
+ </div>
703
+ {route.meta?.title && (
704
+ <p className="text-sm text-muted-foreground">{route.meta.title}</p>
705
+ )}
706
+ <div className="flex flex-wrap gap-2">
707
+ <Badge variant="outline" className="text-xs">
708
+ {pluginName}
709
+ </Badge>
710
+ {hasParams && (
711
+ <Badge variant="secondary" className="text-xs">
712
+ {route.pathParams.length} param
713
+ {route.pathParams.length > 1 ? "s" : ""}
714
+ </Badge>
715
+ )}
716
+ {sitemapCount > 0 && (
717
+ <Badge variant="secondary" className="text-xs">
718
+ <Link2 className="h-3 w-3 mr-1" />
719
+ {sitemapCount} in sitemap
720
+ </Badge>
721
+ )}
722
+ </div>
723
+ </div>
724
+ );
725
+ }
726
+
727
+ /**
728
+ * Format a date for display
729
+ */
730
+ function formatDate(date: string | Date | undefined): string {
731
+ if (!date) return "—";
732
+ const d = typeof date === "string" ? new Date(date) : date;
733
+ return d.toLocaleDateString(undefined, {
734
+ year: "numeric",
735
+ month: "short",
736
+ day: "numeric",
737
+ });
738
+ }
739
+
740
+ /**
741
+ * Sitemap section - displays all sitemap entries
742
+ */
743
+ function SitemapSection({
744
+ entries,
745
+ schema,
746
+ }: {
747
+ entries: PluginSitemapEntry[];
748
+ schema: RouteDocsSchema;
749
+ }) {
750
+ const [isExpanded, setIsExpanded] = useState(false);
751
+
752
+ // Get plugin name from schema
753
+ const getPluginName = (pluginKey: string): string => {
754
+ const plugin = schema.plugins.find((p) => p.key === pluginKey);
755
+ return plugin?.name || pluginKey;
756
+ };
757
+
758
+ if (entries.length === 0) return null;
759
+
760
+ // Show first 10 entries by default, all when expanded
761
+ const displayedEntries = isExpanded ? entries : entries.slice(0, 10);
762
+ const hasMore = entries.length > 10;
763
+
764
+ return (
765
+ <Card>
766
+ <CardHeader className="pb-3 sm:pb-6">
767
+ <CardTitle className="text-lg flex items-center gap-2">
768
+ <Globe className="h-5 w-5" />
769
+ Sitemap Entries
770
+ <Badge variant="secondary" className="ml-2">
771
+ {entries.length}
772
+ </Badge>
773
+ </CardTitle>
774
+ </CardHeader>
775
+ <CardContent>
776
+ {/* Desktop table */}
777
+ <div className="hidden md:block rounded-lg border overflow-x-auto">
778
+ <Table>
779
+ <TableHeader>
780
+ <TableRow>
781
+ <TableHead>URL</TableHead>
782
+ <TableHead className="w-[100px]">Plugin</TableHead>
783
+ <TableHead className="w-[120px]">Last Modified</TableHead>
784
+ <TableHead className="w-[80px]">Actions</TableHead>
785
+ </TableRow>
786
+ </TableHeader>
787
+ <TableBody>
788
+ {displayedEntries.map((entry, idx) => (
789
+ <TableRow key={`${entry.pluginKey}-${idx}`}>
790
+ <TableCell>
791
+ <a
792
+ href={entry.url}
793
+ target="_blank"
794
+ rel="noopener noreferrer"
795
+ className="hover:underline"
796
+ >
797
+ <code className="font-mono text-xs text-primary truncate block max-w-[400px]">
798
+ {entry.url}
799
+ </code>
800
+ </a>
801
+ </TableCell>
802
+ <TableCell>
803
+ <Badge variant="outline" className="text-xs">
804
+ {getPluginName(entry.pluginKey)}
805
+ </Badge>
806
+ </TableCell>
807
+ <TableCell className="text-xs text-muted-foreground">
808
+ {formatDate(entry.lastModified)}
809
+ </TableCell>
810
+ <TableCell>
811
+ <Button
812
+ variant="ghost"
813
+ size="sm"
814
+ className="h-7 px-2"
815
+ onClick={() => window.open(entry.url, "_blank")}
816
+ >
817
+ <ExternalLink className="h-3 w-3" />
818
+ </Button>
819
+ </TableCell>
820
+ </TableRow>
821
+ ))}
822
+ </TableBody>
823
+ </Table>
824
+ </div>
825
+
826
+ {/* Mobile cards */}
827
+ <div className="md:hidden space-y-3">
828
+ {displayedEntries.map((entry, idx) => (
829
+ <div key={idx} className="rounded-lg border p-3 space-y-2">
830
+ <div className="flex items-start justify-between gap-2">
831
+ <a
832
+ href={entry.url}
833
+ target="_blank"
834
+ rel="noopener noreferrer"
835
+ className="font-mono text-xs text-primary break-all hover:underline"
836
+ >
837
+ {entry.url}
838
+ </a>
839
+ <Button
840
+ variant="ghost"
841
+ size="sm"
842
+ className="h-7 w-7 p-0 shrink-0"
843
+ onClick={() => window.open(entry.url, "_blank")}
844
+ >
845
+ <ExternalLink className="h-3 w-3" />
846
+ </Button>
847
+ </div>
848
+ <div className="flex flex-wrap gap-2 text-xs text-muted-foreground">
849
+ <Badge variant="outline" className="text-xs">
850
+ {getPluginName(entry.pluginKey)}
851
+ </Badge>
852
+ {entry.lastModified && (
853
+ <span>{formatDate(entry.lastModified)}</span>
854
+ )}
855
+ </div>
856
+ </div>
857
+ ))}
858
+ </div>
859
+
860
+ {/* Show more button */}
861
+ {hasMore && (
862
+ <div className="mt-4 text-center">
863
+ <Button
864
+ variant="outline"
865
+ size="sm"
866
+ onClick={() => setIsExpanded(!isExpanded)}
867
+ >
868
+ {isExpanded ? "Show less" : `Show all ${entries.length} entries`}
869
+ </Button>
870
+ </div>
871
+ )}
872
+ </CardContent>
873
+ </Card>
874
+ );
875
+ }
876
+
877
+ /**
878
+ * All routes section - table on desktop, cards on mobile
879
+ */
880
+ function AllRoutesSection({
881
+ schema,
882
+ siteBasePath,
883
+ }: {
884
+ schema: RouteDocsSchema;
885
+ siteBasePath: string;
886
+ }) {
887
+ const scrollToRoute = (pluginKey: string, routeKey: string) => {
888
+ const anchorId = getRouteAnchorId(pluginKey, routeKey);
889
+ const element = document.getElementById(anchorId);
890
+ if (element) {
891
+ element.scrollIntoView({ behavior: "smooth", block: "start" });
892
+ window.history.pushState(null, "", `#${anchorId}`);
893
+ }
894
+ };
895
+ const allRoutes = useMemo(() => {
896
+ const routes: Array<{
897
+ pluginKey: string;
898
+ pluginName: string;
899
+ route: DocumentedRoute;
900
+ hasParams: boolean;
901
+ staticUrl: string | null;
902
+ sitemapCount: number;
903
+ }> = [];
904
+
905
+ for (const plugin of schema.plugins) {
906
+ for (const route of plugin.routes) {
907
+ const hasParams = route.pathParams.length > 0;
908
+
909
+ // Count sitemap entries that match this route pattern
910
+ let sitemapCount = 0;
911
+ if (!hasParams) {
912
+ // Static route - count exact matches
913
+ sitemapCount = plugin.sitemapEntries.filter((e) => {
914
+ try {
915
+ const url = new URL(e.url);
916
+ return url.pathname.endsWith(route.path);
917
+ } catch {
918
+ return false;
919
+ }
920
+ }).length;
921
+ } else {
922
+ // Dynamic route - count entries that could match the pattern
923
+ const routePattern = escapeRegexForRoutePath(route.path);
924
+ const regex = new RegExp(`${routePattern}$`);
925
+ sitemapCount = plugin.sitemapEntries.filter((e) => {
926
+ try {
927
+ const url = new URL(e.url);
928
+ return regex.test(url.pathname);
929
+ } catch {
930
+ return false;
931
+ }
932
+ }).length;
933
+ }
934
+
935
+ routes.push({
936
+ pluginKey: plugin.key,
937
+ pluginName: plugin.name,
938
+ route,
939
+ hasParams,
940
+ staticUrl: hasParams ? null : `${siteBasePath}${route.path}`,
941
+ sitemapCount,
942
+ });
943
+ }
944
+ }
945
+
946
+ return routes;
947
+ }, [schema, siteBasePath]);
948
+
949
+ if (allRoutes.length === 0) return null;
950
+
951
+ return (
952
+ <Card>
953
+ <CardHeader className="pb-3 sm:pb-6">
954
+ <CardTitle className="text-lg">All Routes</CardTitle>
955
+ </CardHeader>
956
+ <CardContent>
957
+ {/* Desktop table */}
958
+ <div className="hidden md:block rounded-lg border overflow-x-auto">
959
+ <Table>
960
+ <TableHeader>
961
+ <TableRow>
962
+ <TableHead>Route</TableHead>
963
+ <TableHead className="w-[100px]">Plugin</TableHead>
964
+ <TableHead className="w-[80px]">Params</TableHead>
965
+ <TableHead className="w-[80px]">Sitemap</TableHead>
966
+ <TableHead className="w-[80px]">Actions</TableHead>
967
+ </TableRow>
968
+ </TableHeader>
969
+ <TableBody>
970
+ {allRoutes.map(
971
+ ({
972
+ pluginKey,
973
+ pluginName,
974
+ route,
975
+ hasParams,
976
+ staticUrl,
977
+ sitemapCount,
978
+ }) => (
979
+ <TableRow key={`${pluginKey}-${route.key}`}>
980
+ <TableCell>
981
+ <button
982
+ onClick={() => scrollToRoute(pluginKey, route.key)}
983
+ className="text-left hover:underline"
984
+ >
985
+ <code className="font-mono text-sm text-primary">
986
+ {route.path}
987
+ </code>
988
+ </button>
989
+ {route.meta?.title && (
990
+ <p className="text-xs text-muted-foreground mt-1">
991
+ {route.meta.title}
992
+ </p>
993
+ )}
994
+ </TableCell>
995
+ <TableCell>
996
+ <Badge variant="outline" className="text-xs">
997
+ {pluginName}
998
+ </Badge>
999
+ </TableCell>
1000
+ <TableCell>
1001
+ {hasParams ? (
1002
+ <Badge variant="secondary" className="text-xs">
1003
+ {route.pathParams.length}
1004
+ </Badge>
1005
+ ) : (
1006
+ <span className="text-xs text-muted-foreground">—</span>
1007
+ )}
1008
+ </TableCell>
1009
+ <TableCell>
1010
+ {sitemapCount > 0 ? (
1011
+ <Badge variant="secondary" className="text-xs">
1012
+ <Link2 className="h-3 w-3 mr-1" />
1013
+ {sitemapCount}
1014
+ </Badge>
1015
+ ) : (
1016
+ <span className="text-xs text-muted-foreground">—</span>
1017
+ )}
1018
+ </TableCell>
1019
+ <TableCell>
1020
+ {staticUrl ? (
1021
+ <Button
1022
+ variant="ghost"
1023
+ size="sm"
1024
+ className="h-7 px-2"
1025
+ onClick={() => window.open(staticUrl, "_blank")}
1026
+ >
1027
+ <ExternalLink className="h-3 w-3" />
1028
+ </Button>
1029
+ ) : (
1030
+ <Button
1031
+ variant="ghost"
1032
+ size="sm"
1033
+ className="h-7 px-2"
1034
+ onClick={() => scrollToRoute(pluginKey, route.key)}
1035
+ >
1036
+ <Navigation className="h-3 w-3" />
1037
+ </Button>
1038
+ )}
1039
+ </TableCell>
1040
+ </TableRow>
1041
+ ),
1042
+ )}
1043
+ </TableBody>
1044
+ </Table>
1045
+ </div>
1046
+
1047
+ {/* Mobile cards */}
1048
+ <div className="md:hidden space-y-3">
1049
+ {allRoutes.map(
1050
+ ({
1051
+ pluginKey,
1052
+ pluginName,
1053
+ route,
1054
+ hasParams,
1055
+ staticUrl,
1056
+ sitemapCount,
1057
+ }) => (
1058
+ <RouteCard
1059
+ key={`${pluginKey}-${route.key}`}
1060
+ pluginName={pluginName}
1061
+ route={route}
1062
+ hasParams={hasParams}
1063
+ staticUrl={staticUrl}
1064
+ sitemapCount={sitemapCount}
1065
+ onSelect={() => scrollToRoute(pluginKey, route.key)}
1066
+ />
1067
+ ),
1068
+ )}
1069
+ </div>
1070
+ </CardContent>
1071
+ </Card>
1072
+ );
1073
+ }
1074
+
1075
+ /**
1076
+ * Route documentation page component
1077
+ */
1078
+ export interface DocsPageProps {
1079
+ title?: string;
1080
+ description?: string;
1081
+ siteBasePath?: string;
1082
+ }
1083
+
1084
+ export function DocsPageComponent({
1085
+ title = "Route Documentation",
1086
+ description = "Documentation for all client routes in your application",
1087
+ siteBasePath = "/pages",
1088
+ }: DocsPageProps) {
1089
+ // Read schema from React Query (prefetched by loader on server, or generated on client)
1090
+ const { data: schema } = useSuspenseQuery<RouteDocsSchema>({
1091
+ queryKey: ROUTE_DOCS_QUERY_KEY,
1092
+ queryFn: generateSchema,
1093
+ staleTime: Infinity, // Don't refetch - schema is static for this session
1094
+ });
1095
+ const [mobileMenuOpen, setMobileMenuOpen] = useState(false);
1096
+
1097
+ const totalRoutes = schema.plugins.reduce(
1098
+ (sum, p) => sum + p.routes.length,
1099
+ 0,
1100
+ );
1101
+
1102
+ const handleMobileNavigate = () => {
1103
+ setMobileMenuOpen(false);
1104
+ };
1105
+
1106
+ return (
1107
+ <div className="flex min-h-screen bg-background">
1108
+ {/* Desktop Sidebar - sticky */}
1109
+ <aside className="hidden md:block w-72 border-r bg-card shrink-0 sticky top-0 h-screen">
1110
+ <div className="p-4 border-b">
1111
+ <h2 className="font-semibold text-sm text-muted-foreground uppercase tracking-wide">
1112
+ Routes
1113
+ </h2>
1114
+ </div>
1115
+ <ScrollArea className="h-[calc(100vh-57px)]">
1116
+ <SidebarContent schema={schema} />
1117
+ </ScrollArea>
1118
+ </aside>
1119
+
1120
+ {/* Mobile Header with Menu */}
1121
+ <div className="md:hidden fixed top-0 left-0 right-0 z-40 bg-card border-b">
1122
+ <div className="flex items-center justify-between p-4">
1123
+ <h2 className="font-semibold text-sm text-muted-foreground uppercase tracking-wide">
1124
+ Route Docs
1125
+ </h2>
1126
+ <Sheet open={mobileMenuOpen} onOpenChange={setMobileMenuOpen}>
1127
+ <SheetTrigger asChild>
1128
+ <Button variant="outline" size="sm">
1129
+ <Menu className="h-4 w-4 mr-2" />
1130
+ Routes
1131
+ </Button>
1132
+ </SheetTrigger>
1133
+ <SheetContent side="left" className="w-80 p-0">
1134
+ <SheetHeader className="p-4 border-b">
1135
+ <SheetTitle className="text-left text-sm text-muted-foreground uppercase tracking-wide">
1136
+ Routes
1137
+ </SheetTitle>
1138
+ </SheetHeader>
1139
+ <ScrollArea className="h-[calc(100vh-57px)]">
1140
+ <SidebarContent
1141
+ schema={schema}
1142
+ onNavigate={handleMobileNavigate}
1143
+ />
1144
+ </ScrollArea>
1145
+ </SheetContent>
1146
+ </Sheet>
1147
+ </div>
1148
+ </div>
1149
+
1150
+ {/* Main content - scrollable list of all routes */}
1151
+ <main className="flex-1 overflow-auto pt-16 md:pt-0">
1152
+ <div className="max-w-4xl mx-auto p-4 sm:p-6 lg:p-8">
1153
+ <div className="space-y-6 sm:space-y-8">
1154
+ {/* Header */}
1155
+ <div>
1156
+ <h1 className="text-2xl sm:text-3xl font-bold tracking-tight">
1157
+ {title}
1158
+ </h1>
1159
+ <p className="text-muted-foreground mt-2 text-sm sm:text-base">
1160
+ {description}
1161
+ </p>
1162
+ </div>
1163
+
1164
+ <Separator />
1165
+
1166
+ {totalRoutes > 0 ? (
1167
+ <>
1168
+ {/* Summary badges */}
1169
+ <div className="flex flex-wrap gap-2">
1170
+ <Badge variant="secondary">
1171
+ {schema.plugins.length} plugins
1172
+ </Badge>
1173
+ <Badge variant="secondary">{totalRoutes} routes</Badge>
1174
+ {schema.allSitemapEntries.length > 0 && (
1175
+ <Badge variant="secondary">
1176
+ <Globe className="h-3 w-3 mr-1" />
1177
+ {schema.allSitemapEntries.length} sitemap entries
1178
+ </Badge>
1179
+ )}
1180
+ </div>
1181
+
1182
+ {/* All routes overview table */}
1183
+ <AllRoutesSection schema={schema} siteBasePath={siteBasePath} />
1184
+
1185
+ {/* All route details - one after another */}
1186
+ {schema.plugins.map((plugin) => (
1187
+ <div key={plugin.key} className="space-y-6">
1188
+ {/* Plugin header */}
1189
+ <div className="flex items-center gap-2 pt-4">
1190
+ <Folder className="h-6 w-6 text-muted-foreground" />
1191
+ <h2 className="text-2xl font-semibold">{plugin.name}</h2>
1192
+ <Badge variant="outline">
1193
+ {plugin.routes.length} routes
1194
+ </Badge>
1195
+ </div>
1196
+
1197
+ {/* Routes in this plugin */}
1198
+ {plugin.routes.map((route) => (
1199
+ <div
1200
+ key={route.key}
1201
+ id={getRouteAnchorId(plugin.key, route.key)}
1202
+ className="scroll-mt-20 md:scroll-mt-4"
1203
+ >
1204
+ <RouteDetail
1205
+ route={route}
1206
+ pluginName={plugin.name}
1207
+ sitemapEntries={plugin.sitemapEntries}
1208
+ siteBasePath={siteBasePath}
1209
+ />
1210
+ </div>
1211
+ ))}
1212
+
1213
+ <Separator />
1214
+ </div>
1215
+ ))}
1216
+
1217
+ {/* Global sitemap section */}
1218
+ <SitemapSection
1219
+ entries={schema.allSitemapEntries}
1220
+ schema={schema}
1221
+ />
1222
+ </>
1223
+ ) : (
1224
+ <Card>
1225
+ <CardContent className="py-8 sm:py-12 text-center">
1226
+ <p className="text-muted-foreground">
1227
+ No documented routes found.
1228
+ </p>
1229
+ <p className="text-sm text-muted-foreground mt-2">
1230
+ Add client plugins with routes to see documentation here.
1231
+ </p>
1232
+ </CardContent>
1233
+ </Card>
1234
+ )}
1235
+ </div>
1236
+ </div>
1237
+ </main>
1238
+ </div>
1239
+ );
1240
+ }