@raystack/chronicle 0.10.0 → 0.10.2

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 (42) hide show
  1. package/dist/cli/index.js +24 -0
  2. package/package.json +3 -2
  3. package/src/cli/commands/dev.ts +12 -0
  4. package/src/cli/commands/start.ts +12 -0
  5. package/src/components/api/api-overview.tsx +2 -2
  6. package/src/components/api/playground-dialog.tsx +42 -20
  7. package/src/components/mdx/link.tsx +5 -31
  8. package/src/components/ui/PrefetchProvider.tsx +70 -0
  9. package/src/components/ui/search.module.css +6 -0
  10. package/src/components/ui/search.tsx +4 -1
  11. package/src/lib/env.ts +9 -0
  12. package/src/lib/openapi.ts +2 -1
  13. package/src/lib/page-context.tsx +11 -6
  14. package/src/lib/preload.ts +37 -0
  15. package/src/lib/source.ts +21 -2
  16. package/src/pages/DocsLayout.tsx +11 -8
  17. package/src/server/App.module.css +4 -0
  18. package/src/server/App.tsx +32 -15
  19. package/src/server/api/page.ts +2 -2
  20. package/src/server/api/search.ts +16 -6
  21. package/src/server/entry-client.tsx +18 -14
  22. package/src/server/entry-server.tsx +6 -2
  23. package/src/server/routes/[...slug].md.ts +5 -1
  24. package/src/server/{routes/apis/[...slug].md.ts → utils/api-markdown.ts} +3 -6
  25. package/src/themes/default/ContentDirButtons.tsx +1 -1
  26. package/src/themes/default/Layout.tsx +38 -21
  27. package/src/themes/default/Page.module.css +9 -0
  28. package/src/themes/default/Page.tsx +6 -2
  29. package/src/themes/default/Skeleton.tsx +5 -15
  30. package/src/themes/default/VersionSwitcher.tsx +2 -2
  31. package/src/themes/paper/VersionSwitcher.tsx +2 -2
  32. package/src/types/content.ts +1 -0
  33. package/src/components/common/breadcrumb.tsx +0 -3
  34. package/src/components/common/button.tsx +0 -3
  35. package/src/components/common/code-block.tsx +0 -3
  36. package/src/components/common/dialog.tsx +0 -3
  37. package/src/components/common/index.ts +0 -10
  38. package/src/components/common/input-field.tsx +0 -3
  39. package/src/components/common/sidebar.tsx +0 -3
  40. package/src/components/common/switch.tsx +0 -3
  41. package/src/components/common/table.tsx +0 -3
  42. package/src/components/common/tabs.tsx +0 -3
package/dist/cli/index.js CHANGED
@@ -822,6 +822,18 @@ var devCommand = new Command2("dev").description("Start development server").opt
822
822
  });
823
823
  await server.listen();
824
824
  server.printUrls();
825
+ let shuttingDown = false;
826
+ const shutdown = async () => {
827
+ if (shuttingDown)
828
+ return;
829
+ shuttingDown = true;
830
+ try {
831
+ await server.close();
832
+ } catch {}
833
+ process.exit(0);
834
+ };
835
+ process.once("SIGINT", shutdown);
836
+ process.once("SIGTERM", shutdown);
825
837
  });
826
838
 
827
839
  // src/cli/commands/init.ts
@@ -953,6 +965,18 @@ var startCommand = new Command5("start").description("Start production server").
953
965
  preview: { port, host: options.host }
954
966
  });
955
967
  server.printUrls();
968
+ let shuttingDown = false;
969
+ const shutdown = async () => {
970
+ if (shuttingDown)
971
+ return;
972
+ shuttingDown = true;
973
+ try {
974
+ await server.close();
975
+ } catch {}
976
+ process.exit(0);
977
+ };
978
+ process.once("SIGINT", shutdown);
979
+ process.once("SIGTERM", shutdown);
956
980
  });
957
981
 
958
982
  // src/cli/index.ts
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@raystack/chronicle",
3
- "version": "0.10.0",
3
+ "version": "0.10.2",
4
4
  "description": "Config-driven documentation framework",
5
5
  "license": "Apache-2.0",
6
6
  "type": "module",
@@ -47,8 +47,9 @@
47
47
  "@opentelemetry/sdk-metrics": "^2.6.1",
48
48
  "@opentelemetry/semantic-conventions": "^1.40.0",
49
49
  "@radix-ui/react-icons": "^1.3.2",
50
- "@raystack/apsara": "1.0.0-rc.4",
50
+ "@raystack/apsara": "1.0.0-rc.7",
51
51
  "@shikijs/rehype": "^4.0.2",
52
+ "@tanstack/react-query": "5.100.10",
52
53
  "@vitejs/plugin-react": "^6.0.1",
53
54
  "chalk": "^5.6.2",
54
55
  "class-variance-authority": "^0.7.1",
@@ -28,4 +28,16 @@ export const devCommand = new Command('dev')
28
28
 
29
29
  await server.listen();
30
30
  server.printUrls();
31
+
32
+ let shuttingDown = false;
33
+ const shutdown = async () => {
34
+ if (shuttingDown) return;
35
+ shuttingDown = true;
36
+ try {
37
+ await server.close();
38
+ } catch { /* ignore close errors */ }
39
+ process.exit(0);
40
+ };
41
+ process.once('SIGINT', shutdown);
42
+ process.once('SIGTERM', shutdown);
31
43
  });
@@ -26,4 +26,16 @@ export const startCommand = new Command('start')
26
26
  });
27
27
 
28
28
  server.printUrls();
29
+
30
+ let shuttingDown = false;
31
+ const shutdown = async () => {
32
+ if (shuttingDown) return;
33
+ shuttingDown = true;
34
+ try {
35
+ await server.close();
36
+ } catch { /* ignore close errors */ }
37
+ process.exit(0);
38
+ };
39
+ process.once('SIGINT', shutdown);
40
+ process.once('SIGTERM', shutdown);
29
41
  });
@@ -21,7 +21,7 @@ interface ApiOverviewProps {
21
21
  auth?: { type: string; header: string; placeholder?: string }
22
22
  }
23
23
 
24
- export function ApiOverview({ method, path, operation, auth }: ApiOverviewProps) {
24
+ export function ApiOverview({ method, path, operation, serverUrl, auth }: ApiOverviewProps) {
25
25
  const params = (operation.parameters ?? []) as OpenAPIV3.ParameterObject[]
26
26
  const body = getRequestBody(operation.requestBody as OpenAPIV3.RequestBodyObject | undefined)
27
27
 
@@ -36,7 +36,7 @@ export function ApiOverview({ method, path, operation, auth }: ApiOverviewProps)
36
36
  ? headerFields
37
37
  : []
38
38
 
39
- const fullUrl = '{domain}' + path
39
+ const fullUrl = serverUrl + path
40
40
  const snippetHeaders: Record<string, string> = {}
41
41
  if (auth) snippetHeaders[auth.header] = auth.placeholder ?? 'YOUR_API_KEY'
42
42
  if (body) snippetHeaders['Content-Type'] = body.contentType ?? 'application/json'
@@ -1,8 +1,8 @@
1
1
  'use client'
2
2
 
3
- import { useState, useCallback, useMemo } from 'react'
3
+ import { useState, useCallback, useMemo, useEffect } from 'react'
4
4
  import type { OpenAPIV3 } from 'openapi-types'
5
- import { Dialog, Button, Badge, IconButton, InputField, CopyButton, Select, Menu } from '@raystack/apsara'
5
+ import { Dialog, Button, Badge, IconButton, Input, CopyButton, Select, Menu } from '@raystack/apsara'
6
6
  import { Cross2Icon, ChevronDownIcon, ChevronUpIcon, PlayIcon, PlusIcon } from '@radix-ui/react-icons'
7
7
  import { CounterClockwiseClockIcon, CodeIcon } from '@radix-ui/react-icons'
8
8
  import { MethodBadge } from '@/components/api/method-badge'
@@ -68,11 +68,21 @@ export function PlaygroundDialog({
68
68
 
69
69
  const authSchemes = useMemo(() => getAuthSchemes(document, auth), [document, auth])
70
70
  const defaultScheme = authSchemes.find((s) => s.type !== 'none') ?? authSchemes[0]
71
-
72
- const [selectedScheme, setSelectedScheme] = useState(defaultScheme.name)
73
- const [authToken, setAuthToken] = useState('')
74
- const [basicUser, setBasicUser] = useState('')
75
- const [basicPass, setBasicPass] = useState('')
71
+ const storageKey = `chronicle:auth:${specName}`
72
+ const savedAuth = useMemo(() => {
73
+ try {
74
+ const raw = sessionStorage.getItem(storageKey)
75
+ return raw ? JSON.parse(raw) : null
76
+ } catch { return null }
77
+ }, [storageKey])
78
+
79
+ const [selectedScheme, setSelectedScheme] = useState(() => {
80
+ if (savedAuth?.scheme && authSchemes.some((s) => s.name === savedAuth.scheme)) return savedAuth.scheme
81
+ return defaultScheme.name
82
+ })
83
+ const [authToken, setAuthToken] = useState(savedAuth?.token ?? '')
84
+ const [basicUser, setBasicUser] = useState(savedAuth?.basicUser ?? '')
85
+ const [basicPass, setBasicPass] = useState(savedAuth?.basicPass ?? '')
76
86
  const [headerValues, setHeaderValues] = useState<Record<string, string>>({})
77
87
  const [pathValues, setPathValues] = useState<Record<string, string>>({})
78
88
  const [queryValues, setQueryValues] = useState<Record<string, string>>({})
@@ -89,6 +99,17 @@ export function PlaygroundDialog({
89
99
  })
90
100
  const [bodyJsonStr, setBodyJsonStr] = useState(() => body ? body.jsonExample : '{}')
91
101
 
102
+ useEffect(() => {
103
+ try {
104
+ sessionStorage.setItem(storageKey, JSON.stringify({
105
+ scheme: selectedScheme,
106
+ token: authToken,
107
+ basicUser,
108
+ basicPass,
109
+ }))
110
+ } catch { /* ignore */ }
111
+ }, [storageKey, selectedScheme, authToken, basicUser, basicPass])
112
+
92
113
  const [responseData, setResponseData] = useState<{
93
114
  status: number; statusText: string; body: unknown; headers?: Record<string, string>; time: number
94
115
  } | null>(null)
@@ -119,6 +140,7 @@ export function PlaygroundDialog({
119
140
  setAuthToken('')
120
141
  setBasicUser('')
121
142
  setBasicPass('')
143
+ try { sessionStorage.removeItem(storageKey) } catch { /* ignore */ }
122
144
  setHeaderValues({})
123
145
  setPathValues({})
124
146
  setQueryValues({})
@@ -263,13 +285,13 @@ export function PlaygroundDialog({
263
285
  <div className={styles.fieldRow}>
264
286
  <span className={styles.fieldLabel}>Username</span>
265
287
  <div className={styles.fieldInput}>
266
- <InputField size="small" placeholder="Enter username" value={basicUser} onChange={(e) => setBasicUser(e.target.value)} />
288
+ <Input size="small" placeholder="Enter username" value={basicUser} onValueChange={setBasicUser} />
267
289
  </div>
268
290
  </div>
269
291
  <div className={styles.fieldRow}>
270
292
  <span className={styles.fieldLabel}>Password</span>
271
293
  <div className={styles.fieldInput}>
272
- <InputField size="small" type="password" placeholder="Enter password" value={basicPass} onChange={(e) => setBasicPass(e.target.value)} />
294
+ <Input size="small" type="password" placeholder="Enter password" value={basicPass} onValueChange={setBasicPass} />
273
295
  </div>
274
296
  </div>
275
297
  </>
@@ -277,7 +299,7 @@ export function PlaygroundDialog({
277
299
  <div className={styles.fieldRow}>
278
300
  <span className={styles.fieldLabel}>{currentScheme.headerName}</span>
279
301
  <div className={styles.fieldInput}>
280
- <InputField size="small" placeholder={currentScheme.placeholder} value={authToken} onChange={(e) => setAuthToken(e.target.value)} />
302
+ <Input size="small" placeholder={currentScheme.placeholder} value={authToken} onValueChange={setAuthToken} />
281
303
  </div>
282
304
  </div>
283
305
  ) : null}
@@ -285,7 +307,7 @@ export function PlaygroundDialog({
285
307
  <div key={f.name} className={styles.fieldRow}>
286
308
  <span className={styles.fieldLabel}>{f.name}</span>
287
309
  <div className={styles.fieldInput}>
288
- <InputField size="small" placeholder="Enter value" value={headerValues[f.name] ?? ''} onChange={(e) => setHeaderValues({ ...headerValues, [f.name]: e.target.value })} />
310
+ <Input size="small" placeholder="Enter value" value={headerValues[f.name] ?? ''} onValueChange={(v) => setHeaderValues({ ...headerValues, [f.name]: v })} />
289
311
  </div>
290
312
  </div>
291
313
  ))}
@@ -307,11 +329,11 @@ export function PlaygroundDialog({
307
329
  <div key={f.name} className={styles.fieldRow}>
308
330
  <span className={styles.fieldLabel}>{f.name}</span>
309
331
  <div className={styles.fieldInput}>
310
- <InputField
332
+ <Input
311
333
  size="small"
312
334
  placeholder="Enter value"
313
335
  value={pathValues[f.name] ?? ''}
314
- onChange={(e) => setPathValues({ ...pathValues, [f.name]: e.target.value })}
336
+ onValueChange={(v) => setPathValues({ ...pathValues, [f.name]: v })}
315
337
  />
316
338
  </div>
317
339
  </div>
@@ -332,11 +354,11 @@ export function PlaygroundDialog({
332
354
  <div key={f.name} className={styles.fieldRow}>
333
355
  <span className={styles.fieldLabel}>{f.name}</span>
334
356
  <div className={styles.fieldInput}>
335
- <InputField
357
+ <Input
336
358
  size="small"
337
359
  placeholder={f.description ?? 'Enter value'}
338
360
  value={queryValues[f.name] ?? ''}
339
- onChange={(e) => setQueryValues({ ...queryValues, [f.name]: e.target.value })}
361
+ onValueChange={(v) => setQueryValues({ ...queryValues, [f.name]: v })}
340
362
  />
341
363
  </div>
342
364
  </div>
@@ -494,13 +516,13 @@ function BodyFieldRow({ field, value, onChange }: {
494
516
  {items.map((item, i) => (
495
517
  <div key={i} className={styles.arrayItemRow}>
496
518
  <div className={styles.fieldInput}>
497
- <InputField
519
+ <Input
498
520
  size="small"
499
521
  placeholder={`${field.name}[${i}]`}
500
522
  value={String(item)}
501
- onChange={(e) => {
523
+ onValueChange={(v) => {
502
524
  const updated = [...items]
503
- updated[i] = e.target.value
525
+ updated[i] = v
504
526
  onChange(updated)
505
527
  }}
506
528
  />
@@ -539,11 +561,11 @@ function BodyFieldRow({ field, value, onChange }: {
539
561
  <div className={styles.fieldRow}>
540
562
  <span className={styles.fieldLabel}>{field.name} {field.required && <Badge variant="danger" size="micro">required</Badge>}</span>
541
563
  <div className={styles.fieldInput}>
542
- <InputField
564
+ <Input
543
565
  size="small"
544
566
  placeholder={field.description ?? 'Enter value'}
545
567
  value={String(value ?? '')}
546
- onChange={(e) => onChange(e.target.value)}
568
+ onValueChange={(v) => onChange(v)}
547
569
  />
548
570
  </div>
549
571
  </div>
@@ -1,12 +1,10 @@
1
1
  import { Link as ApsaraLink } from '@raystack/apsara';
2
- import type { ComponentProps, MouseEvent } from 'react';
3
- import { useNavigate } from 'react-router';
2
+ import type { ComponentProps } from 'react';
3
+ import { Link as RouterLink } from 'react-router';
4
4
 
5
5
  type LinkProps = ComponentProps<'a'>;
6
6
 
7
- export function Link({ href, children, onClick: onClickProp, ...props }: LinkProps) {
8
- const navigate = useNavigate();
9
-
7
+ export function Link({ href, children, ...props }: LinkProps) {
10
8
  if (!href) {
11
9
  return <span {...props}>{children}</span>;
12
10
  }
@@ -16,12 +14,7 @@ export function Link({ href, children, onClick: onClickProp, ...props }: LinkPro
16
14
 
17
15
  if (isExternal) {
18
16
  return (
19
- <ApsaraLink
20
- href={href}
21
- target='_blank'
22
- rel='noopener noreferrer'
23
- {...props}
24
- >
17
+ <ApsaraLink href={href} external {...props}>
25
18
  {children}
26
19
  </ApsaraLink>
27
20
  );
@@ -35,27 +28,8 @@ export function Link({ href, children, onClick: onClickProp, ...props }: LinkPro
35
28
  );
36
29
  }
37
30
 
38
- const onClick = (e: MouseEvent<HTMLAnchorElement>) => {
39
- if (
40
- e.defaultPrevented ||
41
- e.button !== 0 ||
42
- e.metaKey ||
43
- e.ctrlKey ||
44
- e.shiftKey ||
45
- e.altKey
46
- ) {
47
- return;
48
- }
49
-
50
- onClickProp?.(e);
51
- if (e.defaultPrevented) return;
52
-
53
- e.preventDefault();
54
- navigate(href);
55
- };
56
-
57
31
  return (
58
- <ApsaraLink href={href} {...props} onClick={onClick}>
32
+ <ApsaraLink render={<RouterLink to={href} />} {...props}>
59
33
  {children}
60
34
  </ApsaraLink>
61
35
  );
@@ -0,0 +1,70 @@
1
+ import { useEffect } from 'react';
2
+ import { prefetchPageData } from '@/lib/preload';
3
+
4
+ function resolvePathname(href: string | null): string | null {
5
+ if (!href) return null;
6
+ try {
7
+ const url = new URL(href, location.href);
8
+ if (url.origin !== location.origin) return null;
9
+ return url.pathname;
10
+ } catch {
11
+ return null;
12
+ }
13
+ }
14
+
15
+ export function PrefetchProvider({ children }: { children: React.ReactNode }) {
16
+ useEffect(() => {
17
+ const handleMouseOver = (e: MouseEvent) => {
18
+ const anchor = (e.target as HTMLElement).closest?.('a[href]');
19
+ if (!anchor) return;
20
+ const pathname = resolvePathname(anchor.getAttribute('href'));
21
+ if (pathname) prefetchPageData(pathname);
22
+ };
23
+
24
+ const handleFocusIn = (e: FocusEvent) => {
25
+ const anchor = (e.target as HTMLElement).closest?.('a[href]');
26
+ if (!anchor) return;
27
+ const pathname = resolvePathname(anchor.getAttribute('href'));
28
+ if (pathname) prefetchPageData(pathname);
29
+ };
30
+
31
+ document.addEventListener('mouseover', handleMouseOver);
32
+ document.addEventListener('focusin', handleFocusIn);
33
+
34
+ const observer = new IntersectionObserver(
35
+ (entries) => {
36
+ for (const entry of entries) {
37
+ if (entry.isIntersecting) {
38
+ const pathname = resolvePathname((entry.target as HTMLAnchorElement).getAttribute('href'));
39
+ if (pathname) prefetchPageData(pathname);
40
+ observer.unobserve(entry.target);
41
+ }
42
+ }
43
+ },
44
+ { rootMargin: '200px' },
45
+ );
46
+
47
+ const observeLinks = () => {
48
+ document.querySelectorAll('a[href]:not([data-prefetch-observed])').forEach((link) => {
49
+ const pathname = resolvePathname(link.getAttribute('href'));
50
+ if (pathname) {
51
+ link.setAttribute('data-prefetch-observed', '');
52
+ observer.observe(link);
53
+ }
54
+ });
55
+ };
56
+
57
+ const mutationObserver = new MutationObserver(observeLinks);
58
+ mutationObserver.observe(document.body, { childList: true, subtree: true });
59
+ observeLinks();
60
+
61
+ return () => {
62
+ document.removeEventListener('mouseover', handleMouseOver);
63
+ document.removeEventListener('focusin', handleFocusIn);
64
+ observer.disconnect();
65
+ mutationObserver.disconnect();
66
+ };
67
+ }, []);
68
+
69
+ return children;
70
+ }
@@ -41,6 +41,12 @@
41
41
  display: flex;
42
42
  align-items: center;
43
43
  gap: 12px;
44
+ flex: 1;
45
+ }
46
+
47
+ .sectionBadge {
48
+ margin-left: auto;
49
+ flex-shrink: 0;
44
50
  }
45
51
 
46
52
  .resultText {
@@ -3,7 +3,7 @@ import {
3
3
  HashtagIcon,
4
4
  MagnifyingGlassIcon
5
5
  } from '@heroicons/react/24/outline';
6
- import { Command, IconButton, Text } from '@raystack/apsara';
6
+ import { Badge, Command, IconButton, Text } from '@raystack/apsara';
7
7
  import { debounce } from 'lodash-es';
8
8
  import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
9
9
  import { useNavigate } from 'react-router';
@@ -18,6 +18,7 @@ interface SearchResult {
18
18
  content: string;
19
19
  match?: 'title' | 'heading' | 'body';
20
20
  snippet?: string;
21
+ section?: string;
21
22
  }
22
23
 
23
24
  interface SearchProps {
@@ -157,6 +158,7 @@ export function Search({ classNames }: SearchProps) {
157
158
  html={stripMethod(result.content)}
158
159
  />
159
160
  </Text>
161
+ {result.section && <Badge size="small" className={styles.sectionBadge}>{result.section}</Badge>}
160
162
  </div>
161
163
  </Command.Item>
162
164
  ))}
@@ -187,6 +189,7 @@ export function Search({ classNames }: SearchProps) {
187
189
  </Text>
188
190
  )}
189
191
  </div>
192
+ {result.section && <Badge size="small" className={styles.sectionBadge}>{result.section}</Badge>}
190
193
  </div>
191
194
  </Command.Item>
192
195
  ))}
package/src/lib/env.ts ADDED
@@ -0,0 +1,9 @@
1
+ export function substituteEnvVars(value: string): string {
2
+ return value.replace(/\$\{(\w+)\}/g, (_, name) => {
3
+ const val = process.env[name];
4
+ if (val === undefined) {
5
+ throw new Error(`Environment variable '${name}' is not set`);
6
+ }
7
+ return val;
8
+ });
9
+ }
@@ -3,6 +3,7 @@ import path from 'node:path'
3
3
  import { parse as parseYaml } from 'yaml'
4
4
  import type { OpenAPIV2, OpenAPIV3 } from 'openapi-types'
5
5
  import type { ApiConfig, ApiServerConfig, ApiAuthConfig } from '@/types/config'
6
+ import { substituteEnvVars } from '@/lib/env'
6
7
 
7
8
  type JsonObject = Record<string, unknown>
8
9
 
@@ -41,7 +42,7 @@ export async function loadApiSpec(config: ApiConfig, projectRoot: string): Promi
41
42
  return {
42
43
  name: config.name,
43
44
  basePath: config.basePath,
44
- server: config.server,
45
+ server: { ...config.server, url: substituteEnvVars(config.server.url) },
45
46
  auth: config.auth,
46
47
  document: v3Doc,
47
48
  }
@@ -13,6 +13,7 @@ import { resolveRoute, RouteType } from '@/lib/route-resolver';
13
13
  import type { VersionContext } from '@/lib/version-source';
14
14
  import { LATEST_CONTEXT } from '@/lib/version-source';
15
15
  import type { ChronicleConfig, Frontmatter, Page, PageNavLink, Root, TableOfContents } from '@/types';
16
+ import { queryClient } from '@/lib/preload';
16
17
 
17
18
  export type MdxLoader = (relativePath: string) => Promise<{ content: ReactNode; toc: TableOfContents }>;
18
19
 
@@ -114,12 +115,16 @@ export function PageProvider({
114
115
  }
115
116
 
116
117
  const fetchPageData = useCallback(async (slug: string[]): Promise<PageData> => {
117
- const apiPath = slug.length === 0
118
- ? '/api/page'
119
- : `/api/page?slug=${slug.map(s => encodeURIComponent(s)).join(',')}`;
120
- const res = await fetch(apiPath);
121
- if (!res.ok) throw new Error(String(res.status));
122
- return res.json();
118
+ const key = slug.length === 0 ? '' : slug.map(s => encodeURIComponent(s)).join(',');
119
+ const apiPath = key ? `/api/page?slug=${key}` : '/api/page';
120
+ return queryClient.fetchQuery({
121
+ queryKey: ['pageData', key],
122
+ queryFn: async () => {
123
+ const res = await fetch(apiPath);
124
+ if (!res.ok) throw new Error(String(res.status));
125
+ return res.json();
126
+ },
127
+ });
123
128
  }, []);
124
129
 
125
130
  const loadDocsPage = useCallback(async (slug: string[], cancelled: { current: boolean }) => {
@@ -0,0 +1,37 @@
1
+ import { QueryClient } from '@tanstack/react-query';
2
+
3
+ export const queryClient = new QueryClient({
4
+ defaultOptions: {
5
+ queries: {
6
+ staleTime: Infinity,
7
+ refetchOnWindowFocus: false,
8
+ },
9
+ },
10
+ });
11
+
12
+ export function pageDataQueryKey(pathname: string) {
13
+ const slug = pathname.split('/').filter(Boolean);
14
+ const key = slug.length === 0 ? '' : slug.map(s => encodeURIComponent(s)).join(',');
15
+ return ['pageData', key] as const;
16
+ }
17
+
18
+ async function fetchPageDataByPathname(pathname: string) {
19
+ const slug = pathname.split('/').filter(Boolean);
20
+ const key = slug.length === 0 ? '' : slug.map(s => encodeURIComponent(s)).join(',');
21
+ const apiPath = key ? `/api/page?slug=${key}` : '/api/page';
22
+ const res = await fetch(apiPath);
23
+ if (!res.ok) throw new Error(String(res.status));
24
+ return res.json();
25
+ }
26
+
27
+ function isApisRoute(pathname: string): boolean {
28
+ return pathname === '/apis' || pathname.startsWith('/apis/');
29
+ }
30
+
31
+ export function prefetchPageData(pathname: string) {
32
+ if (isApisRoute(pathname)) return;
33
+ queryClient.prefetchQuery({
34
+ queryKey: pageDataQueryKey(pathname),
35
+ queryFn: () => fetchPageDataByPathname(pathname),
36
+ });
37
+ }
package/src/lib/source.ts CHANGED
@@ -174,17 +174,31 @@ function sortTreeByOrder(tree: Root, pages: { url: string; data: unknown }[], me
174
174
  return { ...tree, children: sortNodes(tree.children, pageOrderMap, folderOrderMap) };
175
175
  }
176
176
 
177
+ function filterDraftsFromTree(tree: Root, draftUrls: Set<string>): Root {
178
+ function filterNodes(nodes: Node[]): Node[] {
179
+ return nodes
180
+ .filter(n => n.type !== NodeType.Page || !draftUrls.has(n.url))
181
+ .map(n => n.type === NodeType.Folder
182
+ ? { ...n, children: filterNodes(n.children) } as Folder
183
+ : n
184
+ );
185
+ }
186
+ return { ...tree, children: filterNodes(tree.children) };
187
+ }
188
+
177
189
  export async function getPageTree(): Promise<Root> {
178
190
  if (cachedTree) return cachedTree;
179
191
  const s = await getSource();
180
192
  const metaFiles = buildFiles().filter(f => f.type === 'meta') as { path: string; data: Record<string, unknown> }[];
181
- cachedTree = sortTreeByOrder(s.pageTree as Root, s.getPages(), metaFiles);
193
+ const sorted = sortTreeByOrder(s.pageTree as Root, s.getPages(), metaFiles);
194
+ const draftUrls = new Set(s.getPages().filter(p => isDraft(p)).map(p => p.url));
195
+ cachedTree = draftUrls.size > 0 ? filterDraftsFromTree(sorted, draftUrls) : sorted;
182
196
  return cachedTree;
183
197
  }
184
198
 
185
199
  export async function getPages() {
186
200
  const s = await getSource();
187
- return s.getPages();
201
+ return s.getPages().filter(p => !isDraft(p));
188
202
  }
189
203
 
190
204
  export async function getPage(slugs?: string[]) {
@@ -254,10 +268,15 @@ export function extractFrontmatter(page: { data: unknown }, fallbackTitle?: stri
254
268
  order: d.order as number | undefined,
255
269
  icon: d.icon as string | undefined,
256
270
  lastModified: d.lastModified as string | undefined,
271
+ draft: d.draft as boolean | undefined,
257
272
  _readingTime: d._readingTime as number | undefined,
258
273
  };
259
274
  }
260
275
 
276
+ export function isDraft(page: { data: unknown }): boolean {
277
+ return (page.data as Record<string, unknown>).draft === true;
278
+ }
279
+
261
280
  export function getRelativePath(page: { data: unknown }): string {
262
281
  return ((page.data as Record<string, unknown>)._relativePath as string) ?? '';
263
282
  }
@@ -1,5 +1,6 @@
1
1
  import type { ReactNode } from 'react';
2
2
  import { useLocation } from 'react-router';
3
+ import { PrefetchProvider } from '@/components/ui/PrefetchProvider';
3
4
  import { usePageContext } from '@/lib/page-context';
4
5
  import { getActiveContentDir } from '@/lib/navigation';
5
6
  import {
@@ -27,13 +28,15 @@ export function DocsLayout({ children, hideSidebar }: DocsLayoutProps) {
27
28
  );
28
29
 
29
30
  return (
30
- <Layout
31
- config={config}
32
- tree={scopedTree}
33
- hideSidebar={hideSidebar}
34
- classNames={{ layout: className }}
35
- >
36
- {children}
37
- </Layout>
31
+ <PrefetchProvider>
32
+ <Layout
33
+ config={config}
34
+ tree={scopedTree}
35
+ hideSidebar={hideSidebar}
36
+ classNames={{ layout: className }}
37
+ >
38
+ {children}
39
+ </Layout>
40
+ </PrefetchProvider>
38
41
  );
39
42
  }
@@ -0,0 +1,4 @@
1
+ .fallback {
2
+ padding: var(--rs-space-8);
3
+ width: 80%;
4
+ }