@btst/stack 1.7.0 → 1.8.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/api/index.d.cts +2 -2
- package/dist/api/index.d.mts +2 -2
- package/dist/api/index.d.ts +2 -2
- package/dist/client/index.cjs +6 -2
- package/dist/client/index.d.cts +2 -1
- package/dist/client/index.d.mts +2 -1
- package/dist/client/index.d.ts +2 -1
- package/dist/client/index.mjs +6 -2
- package/dist/index.d.cts +1 -1
- package/dist/index.d.mts +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/packages/better-stack/src/plugins/route-docs/client/components/loading/docs-skeleton.cjs +43 -0
- package/dist/packages/better-stack/src/plugins/route-docs/client/components/loading/docs-skeleton.mjs +41 -0
- package/dist/packages/better-stack/src/plugins/route-docs/client/components/pages/docs-page.cjs +794 -0
- package/dist/packages/better-stack/src/plugins/route-docs/client/components/pages/docs-page.mjs +788 -0
- package/dist/packages/better-stack/src/plugins/route-docs/client/plugin.cjs +111 -0
- package/dist/packages/better-stack/src/plugins/route-docs/client/plugin.mjs +106 -0
- package/dist/packages/better-stack/src/plugins/route-docs/generator.cjs +244 -0
- package/dist/packages/better-stack/src/plugins/route-docs/generator.mjs +227 -0
- package/dist/packages/ui/src/components/sheet.cjs +25 -0
- package/dist/packages/ui/src/components/sheet.mjs +24 -1
- package/dist/plugins/api/index.d.cts +2 -2
- package/dist/plugins/api/index.d.mts +2 -2
- package/dist/plugins/api/index.d.ts +2 -2
- package/dist/plugins/blog/api/index.d.cts +1 -1
- package/dist/plugins/blog/api/index.d.mts +1 -1
- package/dist/plugins/blog/api/index.d.ts +1 -1
- package/dist/plugins/blog/client/hooks/index.d.cts +2 -2
- package/dist/plugins/blog/client/hooks/index.d.mts +2 -2
- package/dist/plugins/blog/client/hooks/index.d.ts +2 -2
- package/dist/plugins/blog/client/index.d.cts +1 -1
- package/dist/plugins/blog/client/index.d.mts +1 -1
- package/dist/plugins/blog/client/index.d.ts +1 -1
- package/dist/plugins/blog/query-keys.d.cts +2 -2
- package/dist/plugins/blog/query-keys.d.mts +2 -2
- package/dist/plugins/blog/query-keys.d.ts +2 -2
- package/dist/plugins/client/index.d.cts +2 -2
- package/dist/plugins/client/index.d.mts +2 -2
- package/dist/plugins/client/index.d.ts +2 -2
- package/dist/plugins/open-api/api/index.d.cts +1 -1
- package/dist/plugins/open-api/api/index.d.mts +1 -1
- package/dist/plugins/open-api/api/index.d.ts +1 -1
- package/dist/plugins/route-docs/client/index.cjs +10 -0
- package/dist/plugins/route-docs/client/index.d.cts +126 -0
- package/dist/plugins/route-docs/client/index.d.mts +126 -0
- package/dist/plugins/route-docs/client/index.d.ts +126 -0
- package/dist/plugins/route-docs/client/index.mjs +1 -0
- package/dist/plugins/route-docs/client.css +3 -0
- package/dist/plugins/route-docs/style.css +19 -0
- package/dist/shared/{stack.CSce37mX.d.cts → stack.u9iYV6vt.d.cts} +14 -2
- package/dist/shared/{stack.CSce37mX.d.mts → stack.u9iYV6vt.d.mts} +14 -2
- package/dist/shared/{stack.CSce37mX.d.ts → stack.u9iYV6vt.d.ts} +14 -2
- package/package.json +15 -1
- package/src/client/index.ts +11 -4
- package/src/plugins/route-docs/client/components/loading/docs-skeleton.tsx +82 -0
- package/src/plugins/route-docs/client/components/loading/index.tsx +1 -0
- package/src/plugins/route-docs/client/components/pages/docs-page.tsx +1240 -0
- package/src/plugins/route-docs/client/index.ts +7 -0
- package/src/plugins/route-docs/client/plugin.tsx +187 -0
- package/src/plugins/route-docs/client.css +3 -0
- package/src/plugins/route-docs/generator.ts +385 -0
- package/src/plugins/route-docs/index.ts +12 -0
- package/src/plugins/route-docs/style.css +19 -0
- package/src/types.ts +19 -1
- package/dist/shared/{stack.CcI4sYJP.d.mts → stack.DLhzx1-D.d.cts} +1 -1
- package/dist/shared/{stack.CcI4sYJP.d.ts → stack.DLhzx1-D.d.mts} +1 -1
- 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
|
+
}
|