@raystack/chronicle 0.10.1 → 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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@raystack/chronicle",
3
- "version": "0.10.1",
3
+ "version": "0.10.2",
4
4
  "description": "Config-driven documentation framework",
5
5
  "license": "Apache-2.0",
6
6
  "type": "module",
@@ -49,6 +49,7 @@
49
49
  "@radix-ui/react-icons": "^1.3.2",
50
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",
@@ -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,6 +1,6 @@
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
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'
@@ -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({})
@@ -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
+ }
@@ -1,17 +1,20 @@
1
1
  import '@raystack/apsara/normalize.css';
2
2
  import '@raystack/apsara/style.css';
3
- import { ThemeProvider } from '@raystack/apsara';
3
+ import { ThemeProvider, Skeleton, Flex } from '@raystack/apsara';
4
+ import { lazy, Suspense } from 'react';
4
5
  import { Navigate, useLocation } from 'react-router';
5
6
  import { Head } from '@/lib/head';
6
7
  import { usePageContext } from '@/lib/page-context';
7
8
  import { resolveRoute, RouteType } from '@/lib/route-resolver';
8
- import { ApiLayout } from '@/pages/ApiLayout';
9
- import { ApiPage } from '@/pages/ApiPage';
10
- import { DocsLayout } from '@/pages/DocsLayout';
11
- import { DocsPage } from '@/pages/DocsPage';
12
- import { LandingPage } from '@/pages/LandingPage';
13
9
  import type { ChronicleConfig } from '@/types';
14
10
  import { getThemeConfig } from '@/themes/registry';
11
+ import styles from './App.module.css';
12
+
13
+ const ApiLayout = lazy(() => import('@/pages/ApiLayout').then(m => ({ default: m.ApiLayout })));
14
+ const ApiPage = lazy(() => import('@/pages/ApiPage').then(m => ({ default: m.ApiPage })));
15
+ const DocsLayout = lazy(() => import('@/pages/DocsLayout').then(m => ({ default: m.DocsLayout })));
16
+ const DocsPage = lazy(() => import('@/pages/DocsPage').then(m => ({ default: m.DocsPage })));
17
+ const LandingPage = lazy(() => import('@/pages/LandingPage').then(m => ({ default: m.LandingPage })));
15
18
 
16
19
  export function App() {
17
20
  const { pathname } = useLocation();
@@ -35,19 +38,33 @@ export function App() {
35
38
  forcedTheme={themeConfig.forcedTheme}
36
39
  >
37
40
  <RootHead config={config} />
38
- {isApi ? (
39
- <ApiLayout>
40
- <ApiPage slug={apiSlug} />
41
- </ApiLayout>
42
- ) : (
43
- <DocsLayout hideSidebar={isLanding}>
44
- {isLanding ? <LandingPage /> : <DocsPage slug={docsSlug} />}
45
- </DocsLayout>
46
- )}
41
+ <Suspense fallback={<PageFallback />}>
42
+ {isApi ? (
43
+ <ApiLayout>
44
+ <ApiPage slug={apiSlug} />
45
+ </ApiLayout>
46
+ ) : (
47
+ <DocsLayout hideSidebar={isLanding}>
48
+ {isLanding ? <LandingPage /> : <DocsPage slug={docsSlug} />}
49
+ </DocsLayout>
50
+ )}
51
+ </Suspense>
47
52
  </ThemeProvider>
48
53
  );
49
54
  }
50
55
 
56
+ function PageFallback() {
57
+ return (
58
+ <Flex direction="column" gap={4} className={styles.fallback}>
59
+ <Skeleton width="40%" height="var(--rs-line-height-t2)" />
60
+ <Skeleton width="60%" height="var(--rs-line-height-regular)" />
61
+ {[...new Array(12)].map((_, i) => (
62
+ <Skeleton key={i} width="100%" height="var(--rs-line-height-regular)" />
63
+ ))}
64
+ </Flex>
65
+ );
66
+ }
67
+
51
68
  function RootHead({ config }: { config: ChronicleConfig }) {
52
69
  return (
53
70
  <Head
@@ -1,12 +1,12 @@
1
1
  import { defineHandler, HTTPError } from 'nitro';
2
- import { getPage, getPageNav, extractFrontmatter, getRelativePath, getOriginalPath } from '@/lib/source';
2
+ import { getPage, getPageNav, extractFrontmatter, getRelativePath, getOriginalPath, isDraft } from '@/lib/source';
3
3
 
4
4
  export default defineHandler(async event => {
5
5
  const slugParam = event.url.searchParams.get('slug') ?? '';
6
6
  const slug = slugParam ? slugParam.split(',').filter(Boolean) : [];
7
7
  const page = await getPage(slug);
8
8
 
9
- if (!page) {
9
+ if (!page || isDraft(page)) {
10
10
  throw new HTTPError({ status: 404, message: 'Page not found' });
11
11
  }
12
12
 
@@ -14,6 +14,7 @@ interface SearchDocument {
14
14
  headings: string;
15
15
  body: string;
16
16
  type: 'page' | 'api';
17
+ section: string;
17
18
  }
18
19
 
19
20
  import fs from 'node:fs/promises';
@@ -61,7 +62,8 @@ async function buildIndex(ctx: VersionContext, key: string) {
61
62
  headings TEXT NOT NULL,
62
63
  body TEXT NOT NULL,
63
64
  type TEXT NOT NULL,
64
- version TEXT NOT NULL
65
+ version TEXT NOT NULL,
66
+ section TEXT NOT NULL DEFAULT ''
65
67
  )`);
66
68
 
67
69
  await db.exec(`CREATE VIRTUAL TABLE IF NOT EXISTS search_fts USING fts5(
@@ -74,8 +76,8 @@ async function buildIndex(ctx: VersionContext, key: string) {
74
76
 
75
77
  const docs = await buildDocs(ctx);
76
78
  for (const doc of docs) {
77
- await db.sql`INSERT INTO search_docs (id, url, title, headings, body, type, version)
78
- VALUES (${doc.id}, ${doc.url}, ${doc.title}, ${doc.headings}, ${doc.body}, ${doc.type}, ${key})`;
79
+ await db.sql`INSERT INTO search_docs (id, url, title, headings, body, type, version, section)
80
+ VALUES (${doc.id}, ${doc.url}, ${doc.title}, ${doc.headings}, ${doc.body}, ${doc.type}, ${key}, ${doc.section})`;
79
81
  }
80
82
 
81
83
  await db.sql`INSERT INTO search_fts (rowid, title, headings, body)
@@ -86,11 +88,15 @@ async function buildIndex(ctx: VersionContext, key: string) {
86
88
 
87
89
  async function buildDocs(ctx: VersionContext): Promise<SearchDocument[]> {
88
90
  const docs: SearchDocument[] = [];
91
+ const config = loadConfig();
92
+ const contentEntries = config.content ?? [];
89
93
 
90
94
  const pages = await getPagesForVersion(ctx);
91
95
  for (const p of pages) {
92
96
  const fm = extractFrontmatter(p);
93
97
  const { headings, body } = await getPageSearchContent(p);
98
+ const dir = p.url.replace(/^\//, '').split('/')[0];
99
+ const entry = contentEntries.find(c => c.dir === dir);
94
100
  docs.push({
95
101
  id: p.url,
96
102
  url: p.url,
@@ -98,10 +104,10 @@ async function buildDocs(ctx: VersionContext): Promise<SearchDocument[]> {
98
104
  headings,
99
105
  body: [fm.description ?? '', body].join(' '),
100
106
  type: 'page',
107
+ section: entry?.label ?? dir ?? '',
101
108
  });
102
109
  }
103
110
 
104
- const config = loadConfig();
105
111
  const apiConfigs = getApiConfigsForVersion(config, ctx.dir);
106
112
  if (apiConfigs.length) {
107
113
  const specs = await loadApiSpecs(apiConfigs);
@@ -122,6 +128,7 @@ async function buildDocs(ctx: VersionContext): Promise<SearchDocument[]> {
122
128
  headings: op.summary ?? opId,
123
129
  body: [op.description ?? '', pathStr, method.toUpperCase()].join(' '),
124
130
  type: 'api',
131
+ section: spec.name,
125
132
  });
126
133
  }
127
134
  }
@@ -183,7 +190,7 @@ export default defineHandler(async event => {
183
190
  const key = versionKey(ctx);
184
191
 
185
192
  if (!query) {
186
- const result = await db.sql`SELECT id, url, title, type FROM search_docs
193
+ const result = await db.sql`SELECT id, url, title, type, section FROM search_docs
187
194
  WHERE version = ${key} AND type = 'page'
188
195
  LIMIT 8`;
189
196
  return Response.json((result.rows ?? []).map(r => ({
@@ -191,11 +198,12 @@ export default defineHandler(async event => {
191
198
  url: r.url,
192
199
  type: r.type,
193
200
  content: r.title,
201
+ section: r.section || null,
194
202
  })));
195
203
  }
196
204
 
197
205
  const searchTerm = query.split(/\s+/).map(t => `"${t}"*`).join(' ');
198
- const result = await db.sql`SELECT s.id, s.url, s.title, s.headings, s.body, s.type,
206
+ const result = await db.sql`SELECT s.id, s.url, s.title, s.headings, s.body, s.type, s.section,
199
207
  bm25(search_fts, 10.0, 5.0, 1.0) AS score
200
208
  FROM search_fts f
201
209
  JOIN search_docs s ON s.rowid = f.rowid
@@ -214,6 +222,8 @@ export default defineHandler(async event => {
214
222
  content: r.title,
215
223
  match,
216
224
  snippet,
225
+ section: r.section || null,
217
226
  };
218
227
  }));
219
228
  });
229
+
@@ -3,9 +3,11 @@ import React from 'react';
3
3
  import { hydrateRoot } from 'react-dom/client';
4
4
  import { BrowserRouter } from 'react-router';
5
5
  import { ReactRouterProvider } from 'fumadocs-core/framework/react-router';
6
+ import { QueryClientProvider } from '@tanstack/react-query';
6
7
  import { mdxComponents } from '@/components/mdx';
7
8
  import { getApiConfigsForVersion } from '@/lib/config';
8
9
  import { PageProvider } from '@/lib/page-context';
10
+ import { queryClient } from '@/lib/preload';
9
11
  import { resolveRoute, RouteType } from '@/lib/route-resolver';
10
12
  import { resolveVersionFromUrl, type VersionContext } from '@/lib/version-source';
11
13
  import type { ChronicleConfig, Frontmatter, PageNavLink, Root, TableOfContents } from '@/types';
@@ -93,20 +95,22 @@ async function hydrate() {
93
95
 
94
96
  hydrateRoot(
95
97
  document.getElementById('root') as HTMLElement,
96
- <BrowserRouter>
97
- <ReactRouterProvider>
98
- <PageProvider
99
- initialConfig={config}
100
- initialTree={tree}
101
- initialPage={page}
102
- initialApiSpecs={apiSpecs}
103
- initialVersion={version}
104
- loadMdx={loadMdxModule}
105
- >
106
- <App />
107
- </PageProvider>
108
- </ReactRouterProvider>
109
- </BrowserRouter>
98
+ <QueryClientProvider client={queryClient}>
99
+ <BrowserRouter>
100
+ <ReactRouterProvider>
101
+ <PageProvider
102
+ initialConfig={config}
103
+ initialTree={tree}
104
+ initialPage={page}
105
+ initialApiSpecs={apiSpecs}
106
+ initialVersion={version}
107
+ loadMdx={loadMdxModule}
108
+ >
109
+ <App />
110
+ </PageProvider>
111
+ </ReactRouterProvider>
112
+ </BrowserRouter>
113
+ </QueryClientProvider>
110
114
  );
111
115
  } catch (err) {
112
116
  console.error('Hydration failed:', err);
@@ -9,7 +9,7 @@ import { getApiConfigsForVersion, loadConfig } from '@/lib/config';
9
9
  import { loadApiSpecs } from '@/lib/openapi';
10
10
  import { PageProvider } from '@/lib/page-context';
11
11
  import { resolveRoute, RouteType } from '@/lib/route-resolver';
12
- import { getPageTree, getPage, getPageNav, loadPageModule, extractFrontmatter, getRelativePath, getOriginalPath } from '@/lib/source';
12
+ import { getPageTree, getPage, getPageNav, loadPageModule, extractFrontmatter, getRelativePath, getOriginalPath, isDraft } from '@/lib/source';
13
13
  import { getFirstApiUrl } from '@/lib/api-routes';
14
14
  import { StatusCodes } from 'http-status-codes';
15
15
  import { resolveDocsRedirect } from '@/lib/tree-utils';
@@ -44,10 +44,12 @@ export default {
44
44
  : [];
45
45
  const apiSpecs = apiConfigs.length ? await loadApiSpecs(apiConfigs) : [];
46
46
 
47
- const [tree, page] = await Promise.all([
47
+ const [tree, rawPage] = await Promise.all([
48
48
  getPageTree(),
49
49
  route.type === RouteType.DocsPage ? getPage(route.slug) : Promise.resolve(null),
50
50
  ]);
51
+ const page = rawPage && isDraft(rawPage) ? null : rawPage;
52
+
51
53
  // SSR redirects for index pages
52
54
  if (route.type === RouteType.ApiIndex) {
53
55
  const firstUrl = getFirstApiUrl(apiSpecs);
@@ -9,12 +9,13 @@ import {
9
9
  import { Flex, IconButton, Button, Sidebar } from '@raystack/apsara';
10
10
  import { PlayIcon } from '@radix-ui/react-icons';
11
11
  import { cx } from 'class-variance-authority';
12
- import { useState, useEffect, useMemo, useRef } from 'react';
12
+ import { useState, useEffect, useMemo, useRef, lazy, Suspense } from 'react';
13
13
  import { Link as RouterLink, useLocation, useNavigate } from 'react-router';
14
14
  import type { OpenAPIV3 } from 'openapi-types';
15
15
  import { MethodBadge } from '@/components/api/method-badge';
16
16
  import { useApiOperation } from '@/lib/use-api-operation';
17
- import { PlaygroundDialog } from '@/components/api/playground-dialog';
17
+
18
+ const PlaygroundDialog = lazy(() => import('@/components/api/playground-dialog').then(m => ({ default: m.PlaygroundDialog })));
18
19
  import { ClientThemeSwitcher } from '@/components/ui/client-theme-switcher';
19
20
  import { Search } from '@/components/ui/search';
20
21
  import { Breadcrumbs } from '@/components/ui/breadcrumbs';
@@ -144,7 +145,7 @@ export function Layout({
144
145
  ))}
145
146
  {apiEntries.map(api => (
146
147
  <Sidebar.Item
147
- key={api.basePath}
148
+ key={`${api.basePath}-${api.name}`}
148
149
  href={api.basePath}
149
150
  active={isApiBase(api.basePath)}
150
151
  leadingIcon={renderConfigIcon(
@@ -371,18 +372,22 @@ function TestRequestButton() {
371
372
  >
372
373
  Test request
373
374
  </Button>
374
- <PlaygroundDialog
375
- key={`${match.spec.name}-${match.path}-${match.method}`}
376
- open={open}
377
- onOpenChange={setOpen}
378
- method={match.method}
379
- path={match.path}
380
- operation={match.operation}
381
- serverUrl={match.spec.server.url}
382
- specName={match.spec.name}
383
- auth={match.spec.auth}
384
- document={match.spec.document}
385
- />
375
+ {open && (
376
+ <Suspense fallback={null}>
377
+ <PlaygroundDialog
378
+ key={`${match.spec.name}-${match.path}-${match.method}`}
379
+ open={open}
380
+ onOpenChange={setOpen}
381
+ method={match.method}
382
+ path={match.path}
383
+ operation={match.operation}
384
+ serverUrl={match.spec.server.url}
385
+ specName={match.spec.name}
386
+ auth={match.spec.auth}
387
+ document={match.spec.document}
388
+ />
389
+ </Suspense>
390
+ )}
386
391
  </>
387
392
  );
388
393
  }
@@ -1,9 +1,11 @@
1
1
  'use client';
2
2
 
3
3
  import { Flex, Headline } from '@raystack/apsara';
4
+ import { lazy, Suspense } from 'react';
4
5
  import type { ThemePageProps } from '@/types';
5
6
  import styles from './Page.module.css';
6
- import { Toc } from './Toc';
7
+
8
+ const Toc = lazy(() => import('./Toc').then(m => ({ default: m.Toc })));
7
9
 
8
10
  export function Page({ page }: ThemePageProps) {
9
11
  return (
@@ -16,7 +18,9 @@ export function Page({ page }: ThemePageProps) {
16
18
  )}
17
19
  <div className={styles.content}>{page.content}</div>
18
20
  </article>
19
- <Toc items={page.toc} />
21
+ <Suspense fallback={null}>
22
+ <Toc items={page.toc} />
23
+ </Suspense>
20
24
  </Flex>
21
25
  );
22
26
  }
@@ -10,6 +10,7 @@ export interface Frontmatter {
10
10
  order?: number
11
11
  icon?: string
12
12
  lastModified?: string
13
+ draft?: boolean
13
14
  _readingTime?: number
14
15
  }
15
16