@raystack/chronicle 0.1.0-canary.e11f924 → 0.1.0-canary.f0d9bde

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 (81) hide show
  1. package/dist/cli/index.js +878 -9684
  2. package/package.json +17 -12
  3. package/src/cli/__tests__/config.test.ts +25 -0
  4. package/src/cli/__tests__/scaffold.test.ts +10 -0
  5. package/src/cli/commands/build.ts +66 -19
  6. package/src/cli/commands/dev.ts +9 -22
  7. package/src/cli/commands/init.ts +107 -11
  8. package/src/cli/commands/serve.ts +36 -35
  9. package/src/cli/commands/start.ts +11 -21
  10. package/src/cli/utils/config.ts +2 -2
  11. package/src/cli/utils/index.ts +1 -1
  12. package/src/cli/utils/resolve.ts +6 -0
  13. package/src/cli/utils/scaffold.ts +20 -0
  14. package/src/components/mdx/code.tsx +10 -1
  15. package/src/components/mdx/details.module.css +1 -24
  16. package/src/components/mdx/details.tsx +2 -3
  17. package/src/components/mdx/image.tsx +5 -19
  18. package/src/components/mdx/index.tsx +3 -3
  19. package/src/components/mdx/link.tsx +10 -11
  20. package/src/components/ui/footer.tsx +3 -2
  21. package/src/components/ui/search.module.css +7 -0
  22. package/src/components/ui/search.tsx +62 -87
  23. package/src/lib/config.ts +9 -0
  24. package/src/lib/head.tsx +45 -0
  25. package/src/lib/page-context.tsx +95 -0
  26. package/src/lib/source.ts +92 -21
  27. package/src/{app/apis/[[...slug]]/layout.tsx → pages/ApiLayout.tsx} +10 -7
  28. package/src/pages/ApiPage.tsx +68 -0
  29. package/src/pages/DocsLayout.tsx +18 -0
  30. package/src/pages/DocsPage.tsx +43 -0
  31. package/src/pages/NotFound.tsx +10 -0
  32. package/src/pages/__tests__/head.test.tsx +57 -0
  33. package/src/server/App.tsx +59 -0
  34. package/src/server/__tests__/entry-server.test.tsx +35 -0
  35. package/src/server/__tests__/handlers.test.ts +77 -0
  36. package/src/server/__tests__/og.test.ts +23 -0
  37. package/src/server/__tests__/router.test.ts +72 -0
  38. package/src/server/__tests__/vite-config.test.ts +25 -0
  39. package/src/server/adapters/vercel.ts +133 -0
  40. package/src/server/build-search-index.ts +107 -0
  41. package/src/server/dev.ts +158 -0
  42. package/src/server/entry-client.tsx +74 -0
  43. package/src/server/entry-prod.ts +98 -0
  44. package/src/server/entry-server.tsx +35 -0
  45. package/src/server/entry-vercel.ts +28 -0
  46. package/src/server/handlers/apis-proxy.ts +57 -0
  47. package/src/{app/api/health/route.ts → server/handlers/health.ts} +1 -1
  48. package/src/server/handlers/llms.ts +58 -0
  49. package/src/server/handlers/og.ts +87 -0
  50. package/src/server/handlers/robots.ts +11 -0
  51. package/src/server/handlers/search.ts +172 -0
  52. package/src/server/handlers/sitemap.ts +39 -0
  53. package/src/server/handlers/specs.ts +9 -0
  54. package/src/server/index.html +12 -0
  55. package/src/server/prod.ts +18 -0
  56. package/src/server/request-handler.ts +64 -0
  57. package/src/server/router.ts +42 -0
  58. package/src/server/utils/safe-path.ts +14 -0
  59. package/src/server/vite-config.ts +71 -0
  60. package/src/themes/default/Layout.tsx +9 -10
  61. package/src/themes/default/Page.module.css +60 -0
  62. package/src/themes/default/font.ts +4 -6
  63. package/src/themes/paper/ChapterNav.tsx +5 -6
  64. package/src/themes/paper/Page.tsx +8 -9
  65. package/src/types/config.ts +11 -0
  66. package/src/types/content.ts +1 -0
  67. package/tsconfig.json +29 -0
  68. package/next.config.mjs +0 -10
  69. package/source.config.ts +0 -50
  70. package/src/app/[[...slug]]/layout.tsx +0 -15
  71. package/src/app/[[...slug]]/page.tsx +0 -57
  72. package/src/app/api/apis-proxy/route.ts +0 -59
  73. package/src/app/api/search/route.ts +0 -90
  74. package/src/app/apis/[[...slug]]/page.tsx +0 -57
  75. package/src/app/layout.tsx +0 -26
  76. package/src/app/llms-full.txt/route.ts +0 -18
  77. package/src/app/llms.txt/route.ts +0 -15
  78. package/src/app/providers.tsx +0 -8
  79. package/src/cli/utils/process.ts +0 -7
  80. package/src/lib/get-llm-text.ts +0 -10
  81. /package/src/{app/apis/[[...slug]]/layout.module.css → pages/ApiLayout.module.css} +0 -0
@@ -1,9 +1,8 @@
1
1
  import type { ComponentProps } from 'react'
2
- import styles from './details.module.css'
3
2
 
4
3
  export function MdxDetails({ children, className, ...props }: ComponentProps<'details'>) {
5
4
  return (
6
- <details className={`${styles.details} ${className ?? ''}`} {...props}>
5
+ <details className={className} {...props}>
7
6
  {children}
8
7
  </details>
9
8
  )
@@ -11,7 +10,7 @@ export function MdxDetails({ children, className, ...props }: ComponentProps<'de
11
10
 
12
11
  export function MdxSummary({ children, className, ...props }: ComponentProps<'summary'>) {
13
12
  return (
14
- <summary className={`${styles.summary} ${className ?? ''}`} {...props}>
13
+ <summary className={className} {...props}>
15
14
  {children}
16
15
  </summary>
17
16
  )
@@ -1,6 +1,5 @@
1
1
  'use client'
2
2
 
3
- import NextImage from 'next/image'
4
3
  import type { ComponentProps } from 'react'
5
4
 
6
5
  type ImageProps = Omit<ComponentProps<'img'>, 'src'> & {
@@ -12,27 +11,14 @@ type ImageProps = Omit<ComponentProps<'img'>, 'src'> & {
12
11
  export function Image({ src, alt, width, height, ...props }: ImageProps) {
13
12
  if (!src || typeof src !== 'string') return null
14
13
 
15
- const isExternal = src.startsWith('http://') || src.startsWith('https://')
16
-
17
- if (isExternal) {
18
- return (
19
- // eslint-disable-next-line @next/next/no-img-element
20
- <img
21
- src={src}
22
- alt={alt ?? ''}
23
- width={width}
24
- height={height}
25
- {...props}
26
- />
27
- )
28
- }
29
-
30
14
  return (
31
- <NextImage
15
+ <img
32
16
  src={src}
33
17
  alt={alt ?? ''}
34
- width={typeof width === 'string' ? parseInt(width, 10) : (width ?? 800)}
35
- height={typeof height === 'string' ? parseInt(height, 10) : (height ?? 400)}
18
+ width={width}
19
+ height={height}
20
+ loading="lazy"
21
+ {...props}
36
22
  />
37
23
  )
38
24
  }
@@ -1,6 +1,6 @@
1
1
  import type { MDXComponents } from 'mdx/types'
2
2
  import { Image } from './image'
3
- import { Link } from './link'
3
+ import { MdxLink } from './link'
4
4
  import { MdxTable, MdxThead, MdxTbody, MdxTr, MdxTh, MdxTd } from './table'
5
5
  import { MdxPre, MdxCode } from './code'
6
6
  import { MdxDetails, MdxSummary } from './details'
@@ -12,7 +12,7 @@ import { Tabs } from '@raystack/apsara'
12
12
  export const mdxComponents: MDXComponents = {
13
13
  p: MdxParagraph,
14
14
  img: Image,
15
- a: Link,
15
+ a: MdxLink,
16
16
  table: MdxTable,
17
17
  thead: MdxThead,
18
18
  tbody: MdxTbody,
@@ -32,4 +32,4 @@ export const mdxComponents: MDXComponents = {
32
32
  }
33
33
 
34
34
  export { Image } from './image'
35
- export { Link } from './link'
35
+ export { MdxLink } from './link'
@@ -1,12 +1,11 @@
1
1
  'use client'
2
2
 
3
- import NextLink from 'next/link'
4
- import { Link as ApsaraLink } from '@raystack/apsara'
3
+ import { Link } from 'react-router-dom'
5
4
  import type { ComponentProps } from 'react'
6
5
 
7
6
  type LinkProps = ComponentProps<'a'>
8
7
 
9
- export function Link({ href, children, ...props }: LinkProps) {
8
+ export function MdxLink({ href, children, ...props }: LinkProps) {
10
9
  if (!href) {
11
10
  return <span {...props}>{children}</span>
12
11
  }
@@ -14,25 +13,25 @@ export function Link({ href, children, ...props }: LinkProps) {
14
13
  const isExternal = href.startsWith('http://') || href.startsWith('https://')
15
14
  const isAnchor = href.startsWith('#')
16
15
 
17
- if (isAnchor) {
16
+ if (isExternal) {
18
17
  return (
19
- <ApsaraLink href={href} {...props}>
18
+ <a href={href} target="_blank" rel="noopener noreferrer" {...props}>
20
19
  {children}
21
- </ApsaraLink>
20
+ </a>
22
21
  )
23
22
  }
24
23
 
25
- if (isExternal) {
24
+ if (isAnchor) {
26
25
  return (
27
- <ApsaraLink href={href} target="_blank" rel="noopener noreferrer" {...props}>
26
+ <a href={href} {...props}>
28
27
  {children}
29
- </ApsaraLink>
28
+ </a>
30
29
  )
31
30
  }
32
31
 
33
32
  return (
34
- <NextLink href={href} className={props.className}>
33
+ <Link to={href} className={props.className}>
35
34
  {children}
36
- </NextLink>
35
+ </Link>
37
36
  )
38
37
  }
@@ -1,4 +1,5 @@
1
- import { Flex, Link, Text } from "@raystack/apsara";
1
+ import { Link } from "react-router-dom";
2
+ import { Flex, Text } from "@raystack/apsara";
2
3
  import type { FooterConfig } from "@/types";
3
4
  import styles from "./footer.module.css";
4
5
 
@@ -18,7 +19,7 @@ export function Footer({ config }: FooterProps) {
18
19
  {config?.links && config.links.length > 0 && (
19
20
  <Flex gap="medium" className={styles.links}>
20
21
  {config.links.map((link) => (
21
- <Link key={link.href} href={link.href} className={styles.link}>
22
+ <Link key={link.href} to={link.href} className={styles.link}>
22
23
  {link.label}
23
24
  </Link>
24
25
  ))}
@@ -102,3 +102,10 @@
102
102
  .item[data-selected="true"] .icon {
103
103
  color: var(--rs-color-foreground-accent-primary-hover);
104
104
  }
105
+
106
+ .pageText :global(mark),
107
+ .headingText :global(mark) {
108
+ background: transparent;
109
+ color: var(--rs-color-foreground-accent-primary);
110
+ font-weight: 600;
111
+ }
@@ -1,21 +1,54 @@
1
1
  "use client";
2
2
 
3
- import { useState, useEffect, useCallback } from "react";
4
- import { useRouter } from "next/navigation";
3
+ import { useState, useEffect, useCallback, useRef } from "react";
4
+ import { useNavigate } from "react-router-dom";
5
5
  import { Button, Command, Dialog, Text } from "@raystack/apsara";
6
6
  import { cx } from "class-variance-authority";
7
- import { useDocsSearch } from "fumadocs-core/search/client";
8
- import type { SortedResult } from "fumadocs-core/search";
9
7
  import { DocumentIcon, HashtagIcon } from "@heroicons/react/24/outline";
10
- import { isMacOs } from "react-device-detect";
11
8
  import { MethodBadge } from "@/components/api/method-badge";
12
9
  import styles from "./search.module.css";
13
10
 
11
+ interface SearchResult {
12
+ id: string;
13
+ url: string;
14
+ type: "page" | "api";
15
+ content: string;
16
+ }
17
+
18
+ function useSearch(query: string) {
19
+ const [results, setResults] = useState<SearchResult[]>([]);
20
+ const [isLoading, setIsLoading] = useState(false);
21
+ const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
22
+
23
+ useEffect(() => {
24
+ let cancelled = false;
25
+ if (timerRef.current) clearTimeout(timerRef.current);
26
+ timerRef.current = setTimeout(async () => {
27
+ setIsLoading(true);
28
+ try {
29
+ const params = new URLSearchParams();
30
+ if (query) params.set("query", query);
31
+ const res = await fetch(`/api/search?${params}`);
32
+ if (!cancelled) setResults(await res.json());
33
+ } catch {
34
+ if (!cancelled) setResults([]);
35
+ }
36
+ if (!cancelled) setIsLoading(false);
37
+ }, 100);
38
+ return () => {
39
+ cancelled = true;
40
+ if (timerRef.current) clearTimeout(timerRef.current);
41
+ };
42
+ }, [query]);
43
+
44
+ return { results, isLoading };
45
+ }
46
+
14
47
  function SearchShortcutKey({ className }: { className?: string }) {
15
- const [key, setKey] = useState("");
48
+ const [key, setKey] = useState("\u2318");
16
49
 
17
50
  useEffect(() => {
18
- setKey(isMacOs ? "" : "Ctrl");
51
+ setKey(navigator.platform?.startsWith("Mac") ? "\u2318" : "Ctrl");
19
52
  }, []);
20
53
 
21
54
  return (
@@ -26,26 +59,21 @@ function SearchShortcutKey({ className }: { className?: string }) {
26
59
  }
27
60
 
28
61
  interface SearchProps {
29
- className?: string
62
+ className?: string;
30
63
  }
31
64
 
32
65
  export function Search({ className }: SearchProps) {
33
66
  const [open, setOpen] = useState(false);
34
- const router = useRouter();
35
-
36
- const { search, setSearch, query } = useDocsSearch({
37
- type: "fetch",
38
- api: "/api/search",
39
- delayMs: 100,
40
- allowEmpty: true,
41
- });
67
+ const navigate = useNavigate();
68
+ const [search, setSearch] = useState("");
69
+ const { results, isLoading } = useSearch(search);
42
70
 
43
71
  const onSelect = useCallback(
44
72
  (url: string) => {
45
73
  setOpen(false);
46
- router.push(url);
74
+ navigate(url);
47
75
  },
48
- [router],
76
+ [navigate],
49
77
  );
50
78
 
51
79
  useEffect(() => {
@@ -60,10 +88,6 @@ export function Search({ className }: SearchProps) {
60
88
  return () => document.removeEventListener("keydown", down);
61
89
  }, []);
62
90
 
63
- const results = deduplicateByUrl(
64
- query.data === "empty" ? [] : (query.data ?? []),
65
- );
66
-
67
91
  return (
68
92
  <>
69
93
  <Button
@@ -91,17 +115,17 @@ export function Search({ className }: SearchProps) {
91
115
  />
92
116
 
93
117
  <Command.List className={styles.list}>
94
- {query.isLoading && <Command.Empty>Loading...</Command.Empty>}
95
- {!query.isLoading &&
118
+ {isLoading && <Command.Empty>Loading...</Command.Empty>}
119
+ {!isLoading &&
96
120
  search.length > 0 &&
97
121
  results.length === 0 && (
98
122
  <Command.Empty>No results found.</Command.Empty>
99
123
  )}
100
- {!query.isLoading &&
124
+ {!isLoading &&
101
125
  search.length === 0 &&
102
126
  results.length > 0 && (
103
127
  <Command.Group heading="Suggestions">
104
- {results.slice(0, 8).map((result: SortedResult) => (
128
+ {results.slice(0, 8).map((result) => (
105
129
  <Command.Item
106
130
  key={result.id}
107
131
  value={result.id}
@@ -111,7 +135,7 @@ export function Search({ className }: SearchProps) {
111
135
  <div className={styles.itemContent}>
112
136
  {getResultIcon(result)}
113
137
  <Text className={styles.pageText}>
114
- {stripMethod(result.content)}
138
+ {result.content}
115
139
  </Text>
116
140
  </div>
117
141
  </Command.Item>
@@ -119,7 +143,7 @@ export function Search({ className }: SearchProps) {
119
143
  </Command.Group>
120
144
  )}
121
145
  {search.length > 0 &&
122
- results.map((result: SortedResult) => (
146
+ results.map((result) => (
123
147
  <Command.Item
124
148
  key={result.id}
125
149
  value={result.id}
@@ -128,23 +152,9 @@ export function Search({ className }: SearchProps) {
128
152
  >
129
153
  <div className={styles.itemContent}>
130
154
  {getResultIcon(result)}
131
- <div className={styles.resultText}>
132
- {result.type === "heading" ? (
133
- <>
134
- <Text className={styles.headingText}>
135
- {stripMethod(result.content)}
136
- </Text>
137
- <Text className={styles.separator}>-</Text>
138
- <Text className={styles.pageText}>
139
- {getPageTitle(result.url)}
140
- </Text>
141
- </>
142
- ) : (
143
- <Text className={styles.pageText}>
144
- {stripMethod(result.content)}
145
- </Text>
146
- )}
147
- </div>
155
+ <Text className={styles.pageText}>
156
+ {result.content}
157
+ </Text>
148
158
  </div>
149
159
  </Command.Item>
150
160
  ))}
@@ -156,47 +166,12 @@ export function Search({ className }: SearchProps) {
156
166
  );
157
167
  }
158
168
 
159
- function deduplicateByUrl(results: SortedResult[]): SortedResult[] {
160
- const seen = new Set<string>();
161
- return results.filter((r) => {
162
- const base = r.url.split("#")[0];
163
- if (seen.has(base)) return false;
164
- seen.add(base);
165
- return true;
166
- });
167
- }
168
-
169
- const API_METHODS = new Set(["GET", "POST", "PUT", "DELETE", "PATCH"]);
170
-
171
- function extractMethod(content: string): string | null {
172
- const first = content.split(" ")[0];
173
- return API_METHODS.has(first) ? first : null;
174
- }
175
-
176
- function stripMethod(content: string): string {
177
- const first = content.split(" ")[0];
178
- return API_METHODS.has(first) ? content.slice(first.length + 1) : content;
179
- }
180
-
181
- function getResultIcon(result: SortedResult): React.ReactNode {
182
- if (!result.url.startsWith("/apis/")) {
183
- return result.type === "page" ? (
184
- <DocumentIcon className={styles.icon} />
185
- ) : (
186
- <HashtagIcon className={styles.icon} />
187
- );
169
+ function getResultIcon(result: SearchResult): React.ReactNode {
170
+ if (result.type === "api") {
171
+ const method = result.content.split(" ")[0];
172
+ return ["GET", "POST", "PUT", "DELETE", "PATCH"].includes(method)
173
+ ? <MethodBadge method={method} size="micro" />
174
+ : null;
188
175
  }
189
- const method = extractMethod(result.content);
190
- return method ? <MethodBadge method={method} size="micro" /> : null;
191
- }
192
-
193
- function getPageTitle(url: string): string {
194
- const path = url.split("#")[0];
195
- const segments = path.split("/").filter(Boolean);
196
- const lastSegment = segments[segments.length - 1];
197
- if (!lastSegment) return "Home";
198
- return lastSegment
199
- .split("-")
200
- .map((word) => word.charAt(0).toUpperCase() + word.slice(1))
201
- .join(" ");
176
+ return <DocumentIcon className={styles.icon} />;
202
177
  }
package/src/lib/config.ts CHANGED
@@ -12,8 +12,16 @@ const defaultConfig: ChronicleConfig = {
12
12
  }
13
13
 
14
14
  function resolveConfigPath(): string | null {
15
+ // Check project root via env var
16
+ const projectRoot = process.env.CHRONICLE_PROJECT_ROOT
17
+ if (projectRoot) {
18
+ const rootPath = path.join(projectRoot, CONFIG_FILE)
19
+ if (fs.existsSync(rootPath)) return rootPath
20
+ }
21
+ // Check cwd
15
22
  const cwdPath = path.join(process.cwd(), CONFIG_FILE)
16
23
  if (fs.existsSync(cwdPath)) return cwdPath
24
+ // Check content dir
17
25
  const contentDir = process.env.CHRONICLE_CONTENT_DIR
18
26
  if (contentDir) {
19
27
  const contentPath = path.join(contentDir, CONFIG_FILE)
@@ -43,5 +51,6 @@ export function loadConfig(): ChronicleConfig {
43
51
  footer: userConfig.footer,
44
52
  api: userConfig.api,
45
53
  llms: { enabled: false, ...userConfig.llms },
54
+ analytics: { enabled: false, ...userConfig.analytics },
46
55
  }
47
56
  }
@@ -0,0 +1,45 @@
1
+ import type { ChronicleConfig } from '@/types'
2
+
3
+ export interface HeadProps {
4
+ title: string
5
+ description?: string
6
+ config: ChronicleConfig
7
+ jsonLd?: Record<string, unknown>
8
+ }
9
+
10
+ export function Head({ title, description, config, jsonLd }: HeadProps) {
11
+ const fullTitle = `${title} | ${config.title}`
12
+ const ogParams = new URLSearchParams({ title })
13
+ if (description) ogParams.set('description', description)
14
+
15
+ return (
16
+ <>
17
+ <title>{fullTitle}</title>
18
+ {description && <meta name="description" content={description} />}
19
+
20
+ {config.url && (
21
+ <>
22
+ <meta property="og:title" content={title} />
23
+ {description && <meta property="og:description" content={description} />}
24
+ <meta property="og:site_name" content={config.title} />
25
+ <meta property="og:type" content="website" />
26
+ <meta property="og:image" content={`/og?${ogParams.toString()}`} />
27
+ <meta property="og:image:width" content="1200" />
28
+ <meta property="og:image:height" content="630" />
29
+
30
+ <meta name="twitter:card" content="summary_large_image" />
31
+ <meta name="twitter:title" content={title} />
32
+ {description && <meta name="twitter:description" content={description} />}
33
+ <meta name="twitter:image" content={`/og?${ogParams.toString()}`} />
34
+ </>
35
+ )}
36
+
37
+ {jsonLd && (
38
+ <script
39
+ type="application/ld+json"
40
+ dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd, null, 2) }}
41
+ />
42
+ )}
43
+ </>
44
+ )
45
+ }
@@ -0,0 +1,95 @@
1
+ import { createContext, useContext, useState, useEffect, type ReactNode } from 'react'
2
+ import { useLocation } from 'react-router-dom'
3
+ import type { ChronicleConfig, Frontmatter, PageTree } from '@/types'
4
+ import type { ApiSpec } from '@/lib/openapi'
5
+ import { getPage, loadPageComponent, buildPageTree } from '@/lib/source'
6
+ import { mdxComponents } from '@/components/mdx'
7
+ import React from 'react'
8
+
9
+ interface PageData {
10
+ slug: string[]
11
+ frontmatter: Frontmatter
12
+ content: ReactNode
13
+ }
14
+
15
+ interface PageContextValue {
16
+ config: ChronicleConfig
17
+ tree: PageTree
18
+ page: PageData | null
19
+ apiSpecs: ApiSpec[]
20
+ }
21
+
22
+ const PageContext = createContext<PageContextValue | null>(null)
23
+
24
+ export function usePageContext(): PageContextValue {
25
+ const ctx = useContext(PageContext)
26
+ if (!ctx) {
27
+ console.error('usePageContext: no context found!')
28
+ return {
29
+ config: { title: 'Documentation' },
30
+ tree: { name: 'root', children: [] },
31
+ page: null,
32
+ apiSpecs: [],
33
+ }
34
+ }
35
+ return ctx
36
+ }
37
+
38
+ interface PageProviderProps {
39
+ initialConfig: ChronicleConfig
40
+ initialTree: PageTree
41
+ initialPage: PageData | null
42
+ initialApiSpecs: ApiSpec[]
43
+ children: ReactNode
44
+ }
45
+
46
+ export function PageProvider({ initialConfig, initialTree, initialPage, initialApiSpecs, children }: PageProviderProps) {
47
+ const { pathname } = useLocation()
48
+ const [tree, setTree] = useState<PageTree>(initialTree)
49
+ const [page, setPage] = useState<PageData | null>(initialPage)
50
+ const [apiSpecs, setApiSpecs] = useState<ApiSpec[]>(initialApiSpecs)
51
+ const [currentPath, setCurrentPath] = useState(pathname)
52
+
53
+ useEffect(() => {
54
+ if (pathname === currentPath) return
55
+ setCurrentPath(pathname)
56
+
57
+ let cancelled = false
58
+
59
+ if (pathname.startsWith('/apis')) {
60
+ // Fetch API specs if not already loaded
61
+ if (apiSpecs.length === 0) {
62
+ fetch('/api/specs')
63
+ .then((res) => res.json())
64
+ .then((specs) => { if (!cancelled) setApiSpecs(specs) })
65
+ .catch(() => {})
66
+ }
67
+ return () => { cancelled = true }
68
+ }
69
+
70
+ async function load() {
71
+ const slug = pathname === '/' ? [] : pathname.slice(1).split('/').filter(Boolean)
72
+
73
+ const [sourcePage, newTree] = await Promise.all([getPage(slug), buildPageTree()])
74
+ if (cancelled || !sourcePage) return
75
+
76
+ const component = await loadPageComponent(sourcePage)
77
+ if (cancelled) return
78
+
79
+ setTree(newTree)
80
+ setPage({
81
+ slug,
82
+ frontmatter: sourcePage.frontmatter,
83
+ content: component ? React.createElement(component, { components: mdxComponents }) : null,
84
+ })
85
+ }
86
+ load()
87
+ return () => { cancelled = true }
88
+ }, [pathname])
89
+
90
+ return (
91
+ <PageContext.Provider value={{ config: initialConfig, tree, page, apiSpecs }}>
92
+ {children}
93
+ </PageContext.Provider>
94
+ )
95
+ }
package/src/lib/source.ts CHANGED
@@ -1,35 +1,106 @@
1
- import { docs } from '../../.source/server'
2
- import { loader } from 'fumadocs-core/source'
3
- import type { PageTree, PageTreeItem, Frontmatter } from '@/types'
4
-
5
- export const source = loader({
6
- baseUrl: '/',
7
- source: docs.toFumadocsSource(),
8
- })
9
-
10
- export function sortByOrder<T extends { frontmatter?: Frontmatter }>(
11
- items: T[]
12
- ): T[] {
13
- return [...items].sort((a, b) => {
14
- const orderA = a.frontmatter?.order ?? Number.MAX_SAFE_INTEGER
15
- const orderB = b.frontmatter?.order ?? Number.MAX_SAFE_INTEGER
16
- return orderA - orderB
1
+ import type { MDXContent } from 'mdx/types'
2
+ import type { Frontmatter, PageTree, PageTreeItem } from '@/types'
3
+
4
+ const meta: Record<string, Frontmatter> = import.meta.glob(
5
+ '@content/**/*.{mdx,md}',
6
+ { eager: true, import: 'frontmatter' }
7
+ )
8
+
9
+ const loaders: Record<string, () => Promise<{ default: MDXContent }>> = import.meta.glob(
10
+ '@content/**/*.{mdx,md}'
11
+ )
12
+
13
+ export interface SourcePage {
14
+ url: string
15
+ slugs: string[]
16
+ filePath: string
17
+ frontmatter: Frontmatter
18
+ }
19
+
20
+ // Compute common directory prefix of all glob keys once
21
+ function computePrefix(keys: string[]): string {
22
+ if (keys.length === 0) return ''
23
+ const dirs = keys.map((k) => k.split('/').slice(0, -1)) // drop filename
24
+ const first = dirs[0]
25
+ let depth = 0
26
+ for (let i = 0; i < first.length; i++) {
27
+ if (dirs.every((d) => d[i] === first[i])) {
28
+ depth = i + 1
29
+ } else {
30
+ break
31
+ }
32
+ }
33
+ return first.slice(0, depth).join('/') + '/'
34
+ }
35
+
36
+ const prefix = computePrefix(Object.keys(meta))
37
+
38
+ function filePathToSlugs(filePath: string): string[] {
39
+ const relative = filePath.slice(prefix.length)
40
+ const withoutExt = relative.replace(/\.(mdx|md)$/, '')
41
+ const parts = withoutExt.split('/').filter(Boolean)
42
+ if (parts[parts.length - 1] === 'index') parts.pop()
43
+ return parts
44
+ }
45
+
46
+ function slugsToUrl(slugs: string[]): string {
47
+ return slugs.length === 0 ? '/' : '/' + slugs.join('/')
48
+ }
49
+
50
+ let cachedPages: SourcePage[] | null = null
51
+
52
+ export async function getPages(): Promise<SourcePage[]> {
53
+ if (cachedPages) return cachedPages
54
+
55
+ cachedPages = Object.entries(meta).map(([filePath, fm]) => {
56
+ const slugs = filePathToSlugs(filePath)
57
+ const baseName = slugs[slugs.length - 1] ?? 'index'
58
+ return {
59
+ url: slugsToUrl(slugs),
60
+ slugs,
61
+ filePath,
62
+ frontmatter: {
63
+ title: fm?.title ?? baseName,
64
+ description: fm?.description,
65
+ order: fm?.order,
66
+ icon: fm?.icon,
67
+ lastModified: fm?.lastModified,
68
+ },
69
+ }
17
70
  })
71
+
72
+ return cachedPages
73
+ }
74
+
75
+ export async function getPage(slug?: string[]): Promise<SourcePage | null> {
76
+ const pages = await getPages()
77
+ const targetUrl = !slug || slug.length === 0 ? '/' : '/' + slug.join('/')
78
+ return pages.find((p) => p.url === targetUrl) ?? null
79
+ }
80
+
81
+ export async function loadPageComponent(page: SourcePage): Promise<MDXContent | null> {
82
+ const loader = loaders[page.filePath]
83
+ if (!loader) return null
84
+ const mod = await loader()
85
+ return mod.default
86
+ }
87
+
88
+ export function invalidate() {
89
+ cachedPages = null
18
90
  }
19
91
 
20
- export function buildPageTree(): PageTree {
21
- const pages = source.getPages()
92
+ export async function buildPageTree(): Promise<PageTree> {
93
+ const pages = await getPages()
22
94
  const folders = new Map<string, PageTreeItem[]>()
23
95
  const rootPages: PageTreeItem[] = []
24
96
 
25
97
  pages.forEach((page) => {
26
- const data = page.data as { title?: string; order?: number }
27
98
  const isIndex = page.url === '/'
28
99
  const item: PageTreeItem = {
29
100
  type: 'page',
30
- name: data.title ?? page.slugs.join('/') ?? 'Untitled',
101
+ name: page.frontmatter.title,
31
102
  url: page.url,
32
- order: data.order ?? (isIndex ? 0 : undefined),
103
+ order: page.frontmatter.order ?? (isIndex ? 0 : undefined),
33
104
  }
34
105
 
35
106
  if (page.slugs.length > 1) {