@raystack/chronicle 0.10.1 → 0.10.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/cli/index.js CHANGED
@@ -46,6 +46,15 @@ var __export = (target, all) => {
46
46
  var __esm = (fn, res) => () => (fn && (res = fn(fn = 0)), res);
47
47
  var __require = /* @__PURE__ */ createRequire(import.meta.url);
48
48
 
49
+ // src/lib/mdx-utils.ts
50
+ var MdxNodeType;
51
+ var init_mdx_utils = __esm(() => {
52
+ MdxNodeType = {
53
+ JsxFlow: "mdxJsxFlowElement",
54
+ JsxText: "mdxJsxTextElement"
55
+ };
56
+ });
57
+
49
58
  // src/lib/remark-resolve-images.ts
50
59
  import path4 from "node:path";
51
60
  import { visit } from "unist-util-visit";
@@ -72,16 +81,29 @@ var remarkResolveImages = () => {
72
81
  return;
73
82
  const relative = filePath.slice(contentIdx + "/content/".length);
74
83
  const dir = path4.posix.dirname(relative);
84
+ const seen = new Set;
85
+ const images = [];
86
+ function collect(src) {
87
+ if (!src || seen.has(src) || /^data:/i.test(src))
88
+ return;
89
+ seen.add(src);
90
+ images.push(src);
91
+ }
75
92
  visit(tree, "image", (node) => {
76
93
  if (!node.url)
77
94
  return;
78
95
  node.url = resolveUrl(node.url, dir);
96
+ collect(node.url);
79
97
  });
80
98
  visit(tree, "html", (node) => {
81
- node.value = node.value.replace(/(<img\b[^>]*\bsrc=["'])([^"']+)(["'])/gi, (_, before, src, after) => `${before}${resolveUrl(src, dir)}${after}`);
99
+ node.value = node.value.replace(/(<img\b[^>]*\bsrc=["'])([^"']+)(["'])/gi, (_, before, src, after) => {
100
+ const resolved = resolveUrl(src, dir);
101
+ collect(resolved);
102
+ return `${before}${resolved}${after}`;
103
+ });
82
104
  });
83
105
  visit(tree, (node) => {
84
- if (node.type !== "mdxJsxFlowElement" && node.type !== "mdxJsxTextElement")
106
+ if (node.type !== MdxNodeType.JsxFlow && node.type !== MdxNodeType.JsxText)
85
107
  return;
86
108
  const jsx = node;
87
109
  if (jsx.name !== "img")
@@ -90,6 +112,7 @@ var remarkResolveImages = () => {
90
112
  if (!srcAttr?.value || typeof srcAttr.value !== "string")
91
113
  return;
92
114
  srcAttr.value = resolveUrl(srcAttr.value, dir);
115
+ collect(srcAttr.value);
93
116
  });
94
117
  visit(tree, "element", (node) => {
95
118
  if (node.tagName !== "img")
@@ -98,10 +121,13 @@ var remarkResolveImages = () => {
98
121
  if (typeof src !== "string")
99
122
  return;
100
123
  node.properties.src = resolveUrl(src, dir);
124
+ collect(node.properties.src);
101
125
  });
126
+ file.data.images = images;
102
127
  };
103
128
  }, remark_resolve_images_default;
104
129
  var init_remark_resolve_images = __esm(() => {
130
+ init_mdx_utils();
105
131
  remark_resolve_images_default = remarkResolveImages;
106
132
  });
107
133
 
@@ -360,7 +386,7 @@ async function createViteConfig(options) {
360
386
  default: defineFumadocsConfig({
361
387
  mdxOptions: {
362
388
  remarkImageOptions: false,
363
- valueToExport: ["readingTime"],
389
+ valueToExport: ["readingTime", "images"],
364
390
  remarkPlugins: [
365
391
  remarkDirective,
366
392
  [remarkDirectiveAdmonition, {
@@ -433,6 +459,7 @@ async function createViteConfig(options) {
433
459
  },
434
460
  nitro: {
435
461
  logLevel: 2,
462
+ errorHandler: path6.resolve(packageRoot, "src/server/error.ts"),
436
463
  publicAssets: [{ dir: path6.resolve(projectRoot, "public") }],
437
464
  output: {
438
465
  dir: resolveOutputDir(projectRoot, preset)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@raystack/chronicle",
3
- "version": "0.10.1",
3
+ "version": "0.10.3",
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({})
@@ -1,7 +1,7 @@
1
1
  import { Children, isValidElement, type ComponentProps } from 'react'
2
2
  import styles from './paragraph.module.css'
3
3
 
4
- const BLOCK_ELEMENTS = new Set(['summary', 'details', 'div', 'table', 'ul', 'ol'])
4
+ const BLOCK_ELEMENTS = new Set(['summary', 'details', 'div', 'table', 'ul', 'ol', 'p'])
5
5
 
6
6
  function hasBlockChild(children: React.ReactNode): boolean {
7
7
  return Children.toArray(children).some(
@@ -0,0 +1,72 @@
1
+ import { useEffect } from 'react';
2
+ import { prefetchPageData } from '@/lib/preload';
3
+
4
+ const NO_PREFETCH_ATTR = 'data-no-prefetch';
5
+
6
+ function resolvePathname(href: string | null): string | null {
7
+ if (!href) return null;
8
+ try {
9
+ const url = new URL(href, location.href);
10
+ if (url.origin !== location.origin) return null;
11
+ return url.pathname;
12
+ } catch {
13
+ return null;
14
+ }
15
+ }
16
+
17
+ export function PrefetchProvider({ children }: { children: React.ReactNode }) {
18
+ useEffect(() => {
19
+ const handleMouseOver = (e: MouseEvent) => {
20
+ const anchor = (e.target as HTMLElement).closest?.('a[href]');
21
+ if (!anchor || anchor.hasAttribute(NO_PREFETCH_ATTR)) return;
22
+ const pathname = resolvePathname(anchor.getAttribute('href'));
23
+ if (pathname) prefetchPageData(pathname);
24
+ };
25
+
26
+ const handleFocusIn = (e: FocusEvent) => {
27
+ const anchor = (e.target as HTMLElement).closest?.('a[href]');
28
+ if (!anchor || anchor.hasAttribute(NO_PREFETCH_ATTR)) return;
29
+ const pathname = resolvePathname(anchor.getAttribute('href'));
30
+ if (pathname) prefetchPageData(pathname);
31
+ };
32
+
33
+ document.addEventListener('mouseover', handleMouseOver);
34
+ document.addEventListener('focusin', handleFocusIn);
35
+
36
+ const observer = new IntersectionObserver(
37
+ (entries) => {
38
+ for (const entry of entries) {
39
+ if (entry.isIntersecting) {
40
+ const pathname = resolvePathname((entry.target as HTMLAnchorElement).getAttribute('href'));
41
+ if (pathname) prefetchPageData(pathname);
42
+ observer.unobserve(entry.target);
43
+ }
44
+ }
45
+ },
46
+ { rootMargin: '200px' },
47
+ );
48
+
49
+ const observeLinks = () => {
50
+ document.querySelectorAll(`a[href]:not([data-prefetch-observed]):not([${NO_PREFETCH_ATTR}])`).forEach((link) => {
51
+ const pathname = resolvePathname(link.getAttribute('href'));
52
+ if (pathname) {
53
+ link.setAttribute('data-prefetch-observed', '');
54
+ observer.observe(link);
55
+ }
56
+ });
57
+ };
58
+
59
+ const mutationObserver = new MutationObserver(observeLinks);
60
+ mutationObserver.observe(document.body, { childList: true, subtree: true });
61
+ observeLinks();
62
+
63
+ return () => {
64
+ document.removeEventListener('mouseover', handleMouseOver);
65
+ document.removeEventListener('focusin', handleFocusIn);
66
+ observer.disconnect();
67
+ mutationObserver.disconnect();
68
+ };
69
+ }, []);
70
+
71
+ return children;
72
+ }
@@ -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
+ }
@@ -0,0 +1,4 @@
1
+ export const MdxNodeType = {
2
+ JsxFlow: 'mdxJsxFlowElement',
3
+ JsxText: 'mdxJsxTextElement',
4
+ } as const
@@ -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
 
@@ -109,17 +110,22 @@ export function PageProvider({
109
110
  frontmatter: Frontmatter;
110
111
  relativePath: string;
111
112
  originalPath?: string;
113
+ images?: string[];
112
114
  prev?: PageNavLink | null;
113
115
  next?: PageNavLink | null;
114
116
  }
115
117
 
116
118
  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();
119
+ const key = slug.length === 0 ? '' : slug.map(s => encodeURIComponent(s)).join(',');
120
+ const apiPath = key ? `/api/page?slug=${key}` : '/api/page';
121
+ return queryClient.fetchQuery({
122
+ queryKey: ['pageData', key],
123
+ queryFn: async () => {
124
+ const res = await fetch(apiPath);
125
+ if (!res.ok) throw new Error(String(res.status));
126
+ return res.json();
127
+ },
128
+ });
123
129
  }, []);
124
130
 
125
131
  const loadDocsPage = useCallback(async (slug: string[], cancelled: { current: boolean }) => {
@@ -127,6 +133,12 @@ export function PageProvider({
127
133
  try {
128
134
  const data = await fetchPageData(slug);
129
135
  if (cancelled.current) return;
136
+ if (data.images?.length) {
137
+ for (const src of data.images) {
138
+ const img = new Image();
139
+ img.src = src;
140
+ }
141
+ }
130
142
  const { content, toc } = await loadMdx(data.originalPath || data.relativePath);
131
143
  if (cancelled.current) return;
132
144
  setErrorStatus(null);
@@ -0,0 +1,42 @@
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
+ function hasFileExtension(pathname: string): boolean {
32
+ const lastSegment = pathname.split('/').pop() ?? '';
33
+ return lastSegment.includes('.');
34
+ }
35
+
36
+ export function prefetchPageData(pathname: string) {
37
+ if (isApisRoute(pathname) || hasFileExtension(pathname)) return;
38
+ queryClient.prefetchQuery({
39
+ queryKey: pageDataQueryKey(pathname),
40
+ queryFn: () => fetchPageDataByPathname(pathname),
41
+ });
42
+ }
@@ -4,6 +4,7 @@ import type { Plugin } from 'unified'
4
4
  import type { Image, Html } from 'mdast'
5
5
  import type { Element } from 'hast'
6
6
  import type { MdxJsxFlowElement, MdxJsxTextElement, MdxJsxAttribute } from 'mdast-util-mdx-jsx'
7
+ import { MdxNodeType } from './mdx-utils'
7
8
 
8
9
  function resolveUrl(src: string, dir: string): string {
9
10
  if (/^[a-z][a-z0-9+\-.]*:/i.test(src)) return src
@@ -26,25 +27,40 @@ const remarkResolveImages: Plugin = () => {
26
27
  const relative = filePath.slice(contentIdx + '/content/'.length)
27
28
  const dir = path.posix.dirname(relative)
28
29
 
30
+ const seen = new Set<string>()
31
+ const images: string[] = []
32
+
33
+ function collect(src: string) {
34
+ if (!src || seen.has(src) || /^data:/i.test(src)) return
35
+ seen.add(src)
36
+ images.push(src)
37
+ }
38
+
29
39
  visit(tree, 'image', (node: Image) => {
30
40
  if (!node.url) return
31
41
  node.url = resolveUrl(node.url, dir)
42
+ collect(node.url)
32
43
  })
33
44
 
34
45
  visit(tree, 'html', (node: Html) => {
35
46
  node.value = node.value.replace(
36
47
  /(<img\b[^>]*\bsrc=["'])([^"']+)(["'])/gi,
37
- (_, before, src, after) => `${before}${resolveUrl(src, dir)}${after}`
48
+ (_, before, src, after) => {
49
+ const resolved = resolveUrl(src, dir)
50
+ collect(resolved)
51
+ return `${before}${resolved}${after}`
52
+ }
38
53
  )
39
54
  })
40
55
 
41
56
  visit(tree, (node) => {
42
- if (node.type !== 'mdxJsxFlowElement' && node.type !== 'mdxJsxTextElement') return
57
+ if (node.type !== MdxNodeType.JsxFlow && node.type !== MdxNodeType.JsxText) return
43
58
  const jsx = node as MdxJsxFlowElement | MdxJsxTextElement
44
59
  if (jsx.name !== 'img') return
45
60
  const srcAttr = jsx.attributes.find((a): a is MdxJsxAttribute => a.type === 'mdxJsxAttribute' && a.name === 'src')
46
61
  if (!srcAttr?.value || typeof srcAttr.value !== 'string') return
47
62
  srcAttr.value = resolveUrl(srcAttr.value, dir)
63
+ collect(srcAttr.value)
48
64
  })
49
65
 
50
66
  visit(tree, 'element', (node: Element) => {
@@ -52,7 +68,10 @@ const remarkResolveImages: Plugin = () => {
52
68
  const src = node.properties?.src
53
69
  if (typeof src !== 'string') return
54
70
  node.properties.src = resolveUrl(src, dir)
71
+ collect(node.properties.src as string)
55
72
  })
73
+
74
+ file.data.images = images
56
75
  }
57
76
  }
58
77
 
package/src/lib/source.ts CHANGED
@@ -37,6 +37,11 @@ const readingTimeGlob: Record<string, { text: string; minutes: number; words: nu
37
37
  { eager: true, import: 'readingTime' }
38
38
  );
39
39
 
40
+ const imagesGlob: Record<string, string[] | undefined> = import.meta.glob(
41
+ '../../.content/**/*.{mdx,md}',
42
+ { eager: true, import: 'images' }
43
+ );
44
+
40
45
  const metaGlob: Record<string, Record<string, unknown>> = import.meta.glob(
41
46
  '../../.content/**/meta.json',
42
47
  { eager: true }
@@ -54,10 +59,11 @@ function buildFiles() {
54
59
  const relativePath = originalPath.replace(/readme\.(mdx?)$/i, 'index.$1');
55
60
  const rt = readingTimeGlob[key];
56
61
  const _readingTime = rt?.minutes != null ? Math.max(1, Math.round(rt.minutes)) : undefined;
62
+ const _images = imagesGlob[key] ?? [];
57
63
  files.push({
58
64
  type: 'page',
59
65
  path: relativePath,
60
- data: { ...data, _readingTime, _relativePath: relativePath, _originalPath: originalPath }
66
+ data: { ...data, _readingTime, _images, _relativePath: relativePath, _originalPath: originalPath }
61
67
  });
62
68
  }
63
69
 
@@ -174,17 +180,31 @@ function sortTreeByOrder(tree: Root, pages: { url: string; data: unknown }[], me
174
180
  return { ...tree, children: sortNodes(tree.children, pageOrderMap, folderOrderMap) };
175
181
  }
176
182
 
183
+ function filterDraftsFromTree(tree: Root, draftUrls: Set<string>): Root {
184
+ function filterNodes(nodes: Node[]): Node[] {
185
+ return nodes
186
+ .filter(n => n.type !== NodeType.Page || !draftUrls.has(n.url))
187
+ .map(n => n.type === NodeType.Folder
188
+ ? { ...n, children: filterNodes(n.children) } as Folder
189
+ : n
190
+ );
191
+ }
192
+ return { ...tree, children: filterNodes(tree.children) };
193
+ }
194
+
177
195
  export async function getPageTree(): Promise<Root> {
178
196
  if (cachedTree) return cachedTree;
179
197
  const s = await getSource();
180
198
  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);
199
+ const sorted = sortTreeByOrder(s.pageTree as Root, s.getPages(), metaFiles);
200
+ const draftUrls = new Set(s.getPages().filter(p => isDraft(p)).map(p => p.url));
201
+ cachedTree = draftUrls.size > 0 ? filterDraftsFromTree(sorted, draftUrls) : sorted;
182
202
  return cachedTree;
183
203
  }
184
204
 
185
205
  export async function getPages() {
186
206
  const s = await getSource();
187
- return s.getPages();
207
+ return s.getPages().filter(p => !isDraft(p));
188
208
  }
189
209
 
190
210
  export async function getPage(slugs?: string[]) {
@@ -254,10 +274,15 @@ export function extractFrontmatter(page: { data: unknown }, fallbackTitle?: stri
254
274
  order: d.order as number | undefined,
255
275
  icon: d.icon as string | undefined,
256
276
  lastModified: d.lastModified as string | undefined,
277
+ draft: d.draft as boolean | undefined,
257
278
  _readingTime: d._readingTime as number | undefined,
258
279
  };
259
280
  }
260
281
 
282
+ export function isDraft(page: { data: unknown }): boolean {
283
+ return (page.data as Record<string, unknown>).draft === true;
284
+ }
285
+
261
286
  export function getRelativePath(page: { data: unknown }): string {
262
287
  return ((page.data as Record<string, unknown>)._relativePath as string) ?? '';
263
288
  }
@@ -266,6 +291,10 @@ export function getOriginalPath(page: { data: unknown }): string {
266
291
  return ((page.data as Record<string, unknown>)._originalPath as string) ?? '';
267
292
  }
268
293
 
294
+ export function getPageImages(page: { data: unknown }): string[] {
295
+ return ((page.data as Record<string, unknown>)._images as string[]) ?? [];
296
+ }
297
+
269
298
  export async function getPageSearchContent(page: { data: unknown }): Promise<{ headings: string; body: string }> {
270
299
  const originalPath = getOriginalPath(page);
271
300
  if (!originalPath) return { headings: '', body: '' };
@@ -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, getPageImages, 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
 
@@ -16,6 +16,7 @@ export default defineHandler(async event => {
16
16
  frontmatter: extractFrontmatter(page, slug[slug.length - 1]),
17
17
  relativePath: getRelativePath(page),
18
18
  originalPath: getOriginalPath(page),
19
+ images: getPageImages(page),
19
20
  prev: nav.prev,
20
21
  next: nav.next,
21
22
  });
@@ -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, getPageImages, 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);
@@ -77,6 +79,7 @@ export default {
77
79
  const relativePath = page ? getRelativePath(page) : null;
78
80
  const originalPath = page ? getOriginalPath(page) : null;
79
81
  const mdxModule = (originalPath || relativePath) ? await loadPageModule(originalPath || relativePath!) : null;
82
+ const pageImages = page ? getPageImages(page) : [];
80
83
 
81
84
  const pageData = page
82
85
  ? {
@@ -123,6 +126,9 @@ export default {
123
126
  {assets.js.map((attr: { href: string }) => (
124
127
  <link key={attr.href} rel="modulepreload" {...attr} />
125
128
  ))}
129
+ {pageImages.map((src: string) => (
130
+ <link key={src} rel="preload" as="image" href={src} />
131
+ ))}
126
132
  <script type="module" src={assets.entry} />
127
133
  <script dangerouslySetInnerHTML={{ __html: `window.__PAGE_DATA__ = ${safeJson}` }} />
128
134
  </head>
@@ -0,0 +1,11 @@
1
+ import { defineErrorHandler, HTTPError } from 'nitro';
2
+
3
+ export default defineErrorHandler((error, _event) => {
4
+ const status = HTTPError.isError(error) ? error.status : 500;
5
+ const message = error.message || 'Internal Server Error';
6
+
7
+ return new Response(JSON.stringify({ error: true, status, message }), {
8
+ status,
9
+ headers: { 'Content-Type': 'application/json' },
10
+ });
11
+ });
@@ -72,7 +72,7 @@ export async function createViteConfig(
72
72
  default: defineFumadocsConfig({
73
73
  mdxOptions: {
74
74
  remarkImageOptions: false,
75
- valueToExport: ['readingTime'],
75
+ valueToExport: ['readingTime', 'images'],
76
76
  remarkPlugins: [
77
77
  remarkDirective,
78
78
  [remarkDirectiveAdmonition, {
@@ -145,6 +145,7 @@ export async function createViteConfig(
145
145
  },
146
146
  nitro: {
147
147
  logLevel: 2,
148
+ errorHandler: path.resolve(packageRoot, 'src/server/error.ts'),
148
149
  publicAssets: [{ dir: path.resolve(projectRoot, 'public') }],
149
150
  output: {
150
151
  dir: resolveOutputDir(projectRoot, preset),
@@ -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';
@@ -137,14 +138,14 @@ export function Layout({
137
138
  <DocumentTextIcon width={16} height={16} />
138
139
  )}
139
140
  classNames={{ root: styles.topLinkItem, text: styles.topLinkText }}
140
- render={<RouterLink to={entry.href} />}
141
+ render={<RouterLink to={entry.href} data-no-prefetch />}
141
142
  >
142
143
  {entry.label}
143
144
  </Sidebar.Item>
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(
@@ -153,7 +154,7 @@ export function Layout({
153
154
  <CodeBracketSquareIcon width={16} height={16} />
154
155
  )}
155
156
  classNames={{ root: styles.topLinkItem, text: styles.topLinkText }}
156
- render={<RouterLink to={api.basePath} />}
157
+ render={<RouterLink to={api.basePath} data-no-prefetch />}
157
158
  >
158
159
  {api.name} API
159
160
  </Sidebar.Item>
@@ -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