@raystack/chronicle 0.3.0 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (83) hide show
  1. package/dist/cli/index.js +289 -9931
  2. package/package.json +20 -12
  3. package/src/cli/commands/build.ts +28 -31
  4. package/src/cli/commands/dev.ts +24 -31
  5. package/src/cli/commands/init.ts +38 -132
  6. package/src/cli/commands/serve.ts +36 -55
  7. package/src/cli/commands/start.ts +20 -31
  8. package/src/cli/index.ts +14 -14
  9. package/src/cli/utils/config.ts +25 -26
  10. package/src/cli/utils/index.ts +3 -3
  11. package/src/cli/utils/resolve.ts +7 -3
  12. package/src/cli/utils/scaffold.ts +11 -130
  13. package/src/components/mdx/code.tsx +10 -1
  14. package/src/components/mdx/details.module.css +1 -26
  15. package/src/components/mdx/details.tsx +2 -3
  16. package/src/components/mdx/image.tsx +5 -34
  17. package/src/components/mdx/index.tsx +15 -1
  18. package/src/components/mdx/link.tsx +18 -15
  19. package/src/components/ui/breadcrumbs.tsx +8 -42
  20. package/src/components/ui/search.tsx +63 -51
  21. package/src/lib/api-routes.ts +6 -8
  22. package/src/lib/config.ts +12 -36
  23. package/src/lib/head.tsx +49 -0
  24. package/src/lib/openapi.ts +8 -8
  25. package/src/lib/page-context.tsx +111 -0
  26. package/src/lib/remark-strip-md-extensions.ts +14 -0
  27. package/src/lib/source.ts +139 -63
  28. package/src/pages/ApiLayout.tsx +33 -0
  29. package/src/pages/ApiPage.tsx +73 -0
  30. package/src/pages/DocsLayout.tsx +18 -0
  31. package/src/pages/DocsPage.tsx +43 -0
  32. package/src/pages/NotFound.tsx +17 -0
  33. package/src/server/App.tsx +72 -0
  34. package/src/server/api/apis-proxy.ts +69 -0
  35. package/src/server/api/health.ts +5 -0
  36. package/src/server/api/page/[...slug].ts +18 -0
  37. package/src/server/api/search.ts +118 -0
  38. package/src/server/api/specs.ts +9 -0
  39. package/src/server/build-search-index.ts +117 -0
  40. package/src/server/entry-client.tsx +88 -0
  41. package/src/server/entry-server.tsx +102 -0
  42. package/src/server/routes/llms.txt.ts +21 -0
  43. package/src/server/routes/og.tsx +75 -0
  44. package/src/server/routes/robots.txt.ts +11 -0
  45. package/src/server/routes/sitemap.xml.ts +40 -0
  46. package/src/server/utils/safe-path.ts +17 -0
  47. package/src/server/vite-config.ts +129 -0
  48. package/src/themes/default/Layout.tsx +78 -48
  49. package/src/themes/default/Page.module.css +44 -0
  50. package/src/themes/default/Page.tsx +9 -11
  51. package/src/themes/default/Toc.tsx +25 -39
  52. package/src/themes/default/index.ts +7 -9
  53. package/src/themes/paper/ChapterNav.tsx +64 -45
  54. package/src/themes/paper/Layout.module.css +1 -1
  55. package/src/themes/paper/Layout.tsx +24 -12
  56. package/src/themes/paper/Page.module.css +16 -4
  57. package/src/themes/paper/Page.tsx +56 -63
  58. package/src/themes/paper/ReadingProgress.tsx +160 -139
  59. package/src/themes/paper/index.ts +5 -5
  60. package/src/themes/registry.ts +14 -7
  61. package/src/types/content.ts +5 -21
  62. package/src/types/globals.d.ts +4 -0
  63. package/src/types/theme.ts +4 -3
  64. package/tsconfig.json +2 -3
  65. package/next.config.mjs +0 -10
  66. package/source.config.ts +0 -51
  67. package/src/app/[[...slug]]/layout.tsx +0 -15
  68. package/src/app/[[...slug]]/page.tsx +0 -106
  69. package/src/app/api/apis-proxy/route.ts +0 -59
  70. package/src/app/api/health/route.ts +0 -3
  71. package/src/app/api/search/route.ts +0 -90
  72. package/src/app/apis/[[...slug]]/layout.tsx +0 -26
  73. package/src/app/apis/[[...slug]]/page.tsx +0 -117
  74. package/src/app/layout.tsx +0 -57
  75. package/src/app/llms-full.txt/route.ts +0 -18
  76. package/src/app/llms.txt/route.ts +0 -15
  77. package/src/app/og/route.tsx +0 -62
  78. package/src/app/providers.tsx +0 -8
  79. package/src/app/robots.ts +0 -10
  80. package/src/app/sitemap.ts +0 -29
  81. package/src/cli/utils/process.ts +0 -7
  82. package/src/themes/default/font.ts +0 -6
  83. /package/src/{app/apis/[[...slug]]/layout.module.css → pages/ApiLayout.module.css} +0 -0
@@ -1,21 +1,19 @@
1
- "use client";
2
-
3
- import { useState, useEffect, useCallback } from "react";
4
- import { useRouter } from "next/navigation";
5
- import { Button, Command, Dialog, Text } from "@raystack/apsara";
6
- import { cx } from "class-variance-authority";
7
- import { useDocsSearch } from "fumadocs-core/search/client";
8
- import type { SortedResult } from "fumadocs-core/search";
9
- import { DocumentIcon, HashtagIcon } from "@heroicons/react/24/outline";
10
- import { isMacOs } from "react-device-detect";
11
- import { MethodBadge } from "@/components/api/method-badge";
12
- import styles from "./search.module.css";
1
+ import { DocumentIcon, HashtagIcon } from '@heroicons/react/24/outline';
2
+ import { Button, Command, Dialog, Text } from '@raystack/apsara';
3
+ import { cx } from 'class-variance-authority';
4
+ import type { SortedResult } from 'fumadocs-core/search';
5
+ import { useDocsSearch } from 'fumadocs-core/search/client';
6
+ import { useCallback, useEffect, useState } from 'react';
7
+ import { useNavigate } from 'react-router';
8
+ import { MethodBadge } from '@/components/api/method-badge';
9
+ import styles from './search.module.css';
13
10
 
14
11
  function SearchShortcutKey({ className }: { className?: string }) {
15
- const [key, setKey] = useState("");
12
+ const [key, setKey] = useState('');
16
13
 
17
14
  useEffect(() => {
18
- setKey(isMacOs ? "⌘" : "Ctrl");
15
+ const isMac = navigator.platform?.toUpperCase().includes('MAC');
16
+ setKey(isMac ? '⌘' : 'Ctrl');
19
17
  }, []);
20
18
 
21
19
  return (
@@ -26,50 +24,50 @@ function SearchShortcutKey({ className }: { className?: string }) {
26
24
  }
27
25
 
28
26
  interface SearchProps {
29
- className?: string
27
+ className?: string;
30
28
  }
31
29
 
32
30
  export function Search({ className }: SearchProps) {
33
31
  const [open, setOpen] = useState(false);
34
- const router = useRouter();
32
+ const navigate = useNavigate();
35
33
 
36
34
  const { search, setSearch, query } = useDocsSearch({
37
- type: "fetch",
38
- api: "/api/search",
35
+ type: 'fetch',
36
+ api: '/api/search',
39
37
  delayMs: 100,
40
- allowEmpty: true,
38
+ allowEmpty: true
41
39
  });
42
40
 
43
41
  const onSelect = useCallback(
44
42
  (url: string) => {
45
43
  setOpen(false);
46
- router.push(url);
44
+ navigate(url);
47
45
  },
48
- [router],
46
+ [navigate]
49
47
  );
50
48
 
51
49
  useEffect(() => {
52
50
  const down = (e: KeyboardEvent) => {
53
- if (e.key === "k" && (e.metaKey || e.ctrlKey)) {
51
+ if (e.key === 'k' && (e.metaKey || e.ctrlKey)) {
54
52
  e.preventDefault();
55
- setOpen((open) => !open);
53
+ setOpen(open => !open);
56
54
  }
57
55
  };
58
56
 
59
- document.addEventListener("keydown", down);
60
- return () => document.removeEventListener("keydown", down);
57
+ document.addEventListener('keydown', down);
58
+ return () => document.removeEventListener('keydown', down);
61
59
  }, []);
62
60
 
63
61
  const results = deduplicateByUrl(
64
- query.data === "empty" ? [] : (query.data ?? []),
62
+ query.data === 'empty' ? [] : (query.data ?? [])
65
63
  );
66
64
 
67
65
  return (
68
66
  <>
69
67
  <Button
70
- variant="outline"
71
- color="neutral"
72
- size="small"
68
+ variant='outline'
69
+ color='neutral'
70
+ size='small'
73
71
  onClick={() => setOpen(true)}
74
72
  className={cx(styles.trigger, className)}
75
73
  trailingIcon={<SearchShortcutKey className={styles.kbd} />}
@@ -84,7 +82,7 @@ export function Search({ className }: SearchProps) {
84
82
  </Dialog.Title>
85
83
  <Command loop>
86
84
  <Command.Input
87
- placeholder="Search"
85
+ placeholder='Search'
88
86
  value={search}
89
87
  onValueChange={setSearch}
90
88
  className={styles.input}
@@ -100,7 +98,7 @@ export function Search({ className }: SearchProps) {
100
98
  {!query.isLoading &&
101
99
  search.length === 0 &&
102
100
  results.length > 0 && (
103
- <Command.Group heading="Suggestions">
101
+ <Command.Group heading='Suggestions'>
104
102
  {results.slice(0, 8).map((result: SortedResult) => (
105
103
  <Command.Item
106
104
  key={result.id}
@@ -111,7 +109,9 @@ export function Search({ className }: SearchProps) {
111
109
  <div className={styles.itemContent}>
112
110
  {getResultIcon(result)}
113
111
  <Text className={styles.pageText}>
114
- <HighlightedText html={stripMethod(result.content)} />
112
+ <HighlightedText
113
+ html={stripMethod(result.content)}
114
+ />
115
115
  </Text>
116
116
  </div>
117
117
  </Command.Item>
@@ -129,10 +129,12 @@ export function Search({ className }: SearchProps) {
129
129
  <div className={styles.itemContent}>
130
130
  {getResultIcon(result)}
131
131
  <div className={styles.resultText}>
132
- {result.type === "heading" ? (
132
+ {result.type === 'heading' ? (
133
133
  <>
134
134
  <Text className={styles.headingText}>
135
- <HighlightedText html={stripMethod(result.content)} />
135
+ <HighlightedText
136
+ html={stripMethod(result.content)}
137
+ />
136
138
  </Text>
137
139
  <Text className={styles.separator}>-</Text>
138
140
  <Text className={styles.pageText}>
@@ -141,7 +143,9 @@ export function Search({ className }: SearchProps) {
141
143
  </>
142
144
  ) : (
143
145
  <Text className={styles.pageText}>
144
- <HighlightedText html={stripMethod(result.content)} />
146
+ <HighlightedText
147
+ html={stripMethod(result.content)}
148
+ />
145
149
  </Text>
146
150
  )}
147
151
  </div>
@@ -158,49 +162,57 @@ export function Search({ className }: SearchProps) {
158
162
 
159
163
  function deduplicateByUrl(results: SortedResult[]): SortedResult[] {
160
164
  const seen = new Set<string>();
161
- return results.filter((r) => {
162
- const base = r.url.split("#")[0];
165
+ return results.filter(r => {
166
+ const base = r.url.split('#')[0];
163
167
  if (seen.has(base)) return false;
164
168
  seen.add(base);
165
169
  return true;
166
170
  });
167
171
  }
168
172
 
169
- const API_METHODS = new Set(["GET", "POST", "PUT", "DELETE", "PATCH"]);
173
+ const API_METHODS = new Set(['GET', 'POST', 'PUT', 'DELETE', 'PATCH']);
170
174
 
171
175
  function extractMethod(content: string): string | null {
172
- const first = content.split(" ")[0];
176
+ const first = content.split(' ')[0];
173
177
  return API_METHODS.has(first) ? first : null;
174
178
  }
175
179
 
176
180
  function stripMethod(content: string): string {
177
- const first = content.split(" ")[0];
181
+ const first = content.split(' ')[0];
178
182
  return API_METHODS.has(first) ? content.slice(first.length + 1) : content;
179
183
  }
180
184
 
181
- function HighlightedText({ html, className }: { html: string; className?: string }) {
182
- return <span className={className} dangerouslySetInnerHTML={{ __html: html }} />;
185
+ function HighlightedText({
186
+ html,
187
+ className
188
+ }: {
189
+ html: string;
190
+ className?: string;
191
+ }) {
192
+ return (
193
+ <span className={className} dangerouslySetInnerHTML={{ __html: html }} />
194
+ );
183
195
  }
184
196
 
185
197
  function getResultIcon(result: SortedResult): React.ReactNode {
186
- if (!result.url.startsWith("/apis/")) {
187
- return result.type === "page" ? (
198
+ if (!result.url.startsWith('/apis/')) {
199
+ return result.type === 'page' ? (
188
200
  <DocumentIcon className={styles.icon} />
189
201
  ) : (
190
202
  <HashtagIcon className={styles.icon} />
191
203
  );
192
204
  }
193
205
  const method = extractMethod(result.content);
194
- return method ? <MethodBadge method={method} size="micro" /> : null;
206
+ return method ? <MethodBadge method={method} size='micro' /> : null;
195
207
  }
196
208
 
197
209
  function getPageTitle(url: string): string {
198
- const path = url.split("#")[0];
199
- const segments = path.split("/").filter(Boolean);
210
+ const path = url.split('#')[0];
211
+ const segments = path.split('/').filter(Boolean);
200
212
  const lastSegment = segments[segments.length - 1];
201
- if (!lastSegment) return "Home";
213
+ if (!lastSegment) return 'Home';
202
214
  return lastSegment
203
- .split("-")
204
- .map((word) => word.charAt(0).toUpperCase() + word.slice(1))
205
- .join(" ");
215
+ .split('-')
216
+ .map(word => word.charAt(0).toUpperCase() + word.slice(1))
217
+ .join(' ');
206
218
  }
@@ -1,6 +1,6 @@
1
1
  import type { OpenAPIV3 } from 'openapi-types'
2
2
  import slugify from 'slugify'
3
- import type { PageTree, PageTreeItem } from '@/types/content'
3
+ import type { Root, Node, Item, Folder } from 'fumadocs-core/page-tree'
4
4
  import type { ApiSpec } from './openapi'
5
5
 
6
6
  export function getSpecSlug(spec: ApiSpec): string {
@@ -56,16 +56,15 @@ export function findApiOperation(specs: ApiSpec[], slug: string[]): ApiRouteMatc
56
56
  return null
57
57
  }
58
58
 
59
- export function buildApiPageTree(specs: ApiSpec[]): PageTree {
60
- const children: PageTreeItem[] = []
59
+ export function buildApiPageTree(specs: ApiSpec[]): Root {
60
+ const children: Node[] = []
61
61
 
62
62
  for (const spec of specs) {
63
63
  const specSlug = getSpecSlug(spec)
64
64
  const paths = spec.document.paths ?? {}
65
65
  const tags = spec.document.tags ?? []
66
66
 
67
- // Group operations by tag (case-insensitive to avoid duplicates)
68
- const opsByTag = new Map<string, PageTreeItem[]>()
67
+ const opsByTag = new Map<string, Item[]>()
69
68
  const tagDisplayName = new Map<string, string>()
70
69
 
71
70
  for (const [, pathItem] of Object.entries(paths)) {
@@ -90,7 +89,6 @@ export function buildApiPageTree(specs: ApiSpec[]): PageTree {
90
89
  }
91
90
  }
92
91
 
93
- // Use doc.tags display names where available
94
92
  for (const t of tags) {
95
93
  const key = t.name.toLowerCase()
96
94
  if (opsByTag.has(key)) {
@@ -98,7 +96,7 @@ export function buildApiPageTree(specs: ApiSpec[]): PageTree {
98
96
  }
99
97
  }
100
98
 
101
- const tagFolders: PageTreeItem[] = Array.from(opsByTag.entries()).map(([key, ops]) => ({
99
+ const tagFolders: Folder[] = Array.from(opsByTag.entries()).map(([key, ops]) => ({
102
100
  type: 'folder' as const,
103
101
  name: tagDisplayName.get(key) ?? key,
104
102
  icon: 'rectangle-stack',
@@ -110,7 +108,7 @@ export function buildApiPageTree(specs: ApiSpec[]): PageTree {
110
108
  type: 'folder',
111
109
  name: spec.name,
112
110
  children: tagFolders,
113
- })
111
+ } as Folder)
114
112
  } else {
115
113
  children.push(...tagFolders)
116
114
  }
package/src/lib/config.ts CHANGED
@@ -1,56 +1,32 @@
1
- import fs from 'fs'
2
- import path from 'path'
3
- import { parse } from 'yaml'
4
- import type { ChronicleConfig } from '@/types'
5
-
6
- const CONFIG_FILE = 'chronicle.yaml'
1
+ import { parse } from 'yaml';
2
+ import type { ChronicleConfig } from '@/types';
7
3
 
8
4
  const defaultConfig: ChronicleConfig = {
9
5
  title: 'Documentation',
10
6
  theme: { name: 'default' },
11
- search: { enabled: true, placeholder: 'Search...' },
12
- }
13
-
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
22
- const cwdPath = path.join(process.cwd(), CONFIG_FILE)
23
- if (fs.existsSync(cwdPath)) return cwdPath
24
- // Check content dir
25
- const contentDir = process.env.CHRONICLE_CONTENT_DIR
26
- if (contentDir) {
27
- const contentPath = path.join(contentDir, CONFIG_FILE)
28
- if (fs.existsSync(contentPath)) return contentPath
29
- }
30
- return null
31
- }
7
+ search: { enabled: true, placeholder: 'Search...' }
8
+ };
32
9
 
33
10
  export function loadConfig(): ChronicleConfig {
34
- const configPath = resolveConfigPath()
11
+ const raw = typeof __CHRONICLE_CONFIG_RAW__ !== 'undefined' ? __CHRONICLE_CONFIG_RAW__ : null;
35
12
 
36
- if (!configPath) {
37
- return defaultConfig
13
+ if (!raw) {
14
+ return defaultConfig;
38
15
  }
39
16
 
40
- const raw = fs.readFileSync(configPath, 'utf-8')
41
- const userConfig = parse(raw) as Partial<ChronicleConfig>
17
+ const userConfig = parse(raw) as Partial<ChronicleConfig>;
42
18
 
43
19
  return {
44
20
  ...defaultConfig,
45
21
  ...userConfig,
46
22
  theme: {
47
23
  name: userConfig.theme?.name ?? defaultConfig.theme!.name,
48
- colors: { ...defaultConfig.theme?.colors, ...userConfig.theme?.colors },
24
+ colors: { ...defaultConfig.theme?.colors, ...userConfig.theme?.colors }
49
25
  },
50
26
  search: { ...defaultConfig.search, ...userConfig.search },
51
27
  footer: userConfig.footer,
52
28
  api: userConfig.api,
53
29
  llms: { enabled: false, ...userConfig.llms },
54
- analytics: { enabled: false, ...userConfig.analytics },
55
- }
56
- }
30
+ analytics: { enabled: false, ...userConfig.analytics }
31
+ };
32
+ }
@@ -0,0 +1,49 @@
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 && (
24
+ <meta property='og:description' content={description} />
25
+ )}
26
+ <meta property='og:site_name' content={config.title} />
27
+ <meta property='og:type' content='website' />
28
+ <meta property='og:image' content={`/og?${ogParams.toString()}`} />
29
+ <meta property='og:image:width' content='1200' />
30
+ <meta property='og:image:height' content='630' />
31
+
32
+ <meta name='twitter:card' content='summary_large_image' />
33
+ <meta name='twitter:title' content={title} />
34
+ {description && (
35
+ <meta name='twitter:description' content={description} />
36
+ )}
37
+ <meta name='twitter:image' content={`/og?${ogParams.toString()}`} />
38
+ </>
39
+ )}
40
+
41
+ {jsonLd && (
42
+ <script
43
+ type='application/ld+json'
44
+ dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd, null, 2) }}
45
+ />
46
+ )}
47
+ </>
48
+ );
49
+ }
@@ -1,5 +1,5 @@
1
- import fs from 'fs'
2
- import path from 'path'
1
+ import fs from 'node:fs/promises'
2
+ 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'
@@ -17,14 +17,14 @@ export interface ApiSpec {
17
17
  export type { SchemaField } from './schema'
18
18
  export { flattenSchema } from './schema'
19
19
 
20
- export function loadApiSpecs(apiConfigs: ApiConfig[]): ApiSpec[] {
21
- const contentDir = process.env.CHRONICLE_CONTENT_DIR ?? process.cwd()
22
- return apiConfigs.map((config) => loadApiSpec(config, contentDir))
20
+ export async function loadApiSpecs(apiConfigs: ApiConfig[]): Promise<ApiSpec[]> {
21
+ const projectRoot = typeof __CHRONICLE_PROJECT_ROOT__ !== 'undefined' ? __CHRONICLE_PROJECT_ROOT__ : process.cwd()
22
+ return Promise.all(apiConfigs.map((config) => loadApiSpec(config, projectRoot)))
23
23
  }
24
24
 
25
- export function loadApiSpec(config: ApiConfig, contentDir: string): ApiSpec {
26
- const specPath = path.resolve(contentDir, config.spec)
27
- const raw = fs.readFileSync(specPath, 'utf-8')
25
+ export async function loadApiSpec(config: ApiConfig, projectRoot: string): Promise<ApiSpec> {
26
+ const specPath = path.resolve(projectRoot, config.spec)
27
+ const raw = await fs.readFile(specPath, 'utf-8')
28
28
  const isYaml = specPath.endsWith('.yaml') || specPath.endsWith('.yml')
29
29
  const doc = (isYaml ? parseYaml(raw) : JSON.parse(raw)) as OpenAPIV2.Document | OpenAPIV3.Document
30
30
 
@@ -0,0 +1,111 @@
1
+ import {
2
+ createContext,
3
+ type ReactNode,
4
+ useContext,
5
+ useEffect,
6
+ useState
7
+ } from 'react';
8
+ import { useLocation } from 'react-router';
9
+ import type { ApiSpec } from '@/lib/openapi';
10
+ import type { ChronicleConfig, Frontmatter, Root, TableOfContents } from '@/types';
11
+
12
+ export type MdxLoader = (relativePath: string) => Promise<{ content: ReactNode; toc: TableOfContents }>;
13
+
14
+ interface PageData {
15
+ slug: string[];
16
+ frontmatter: Frontmatter;
17
+ content: ReactNode;
18
+ toc: TableOfContents;
19
+ }
20
+
21
+ interface PageContextValue {
22
+ config: ChronicleConfig;
23
+ tree: Root;
24
+ page: PageData | null;
25
+ apiSpecs: ApiSpec[];
26
+ }
27
+
28
+ const PageContext = createContext<PageContextValue | null>(null);
29
+
30
+ export function usePageContext(): PageContextValue {
31
+ const ctx = useContext(PageContext);
32
+ if (!ctx) {
33
+ console.error('usePageContext: no context found!');
34
+ return {
35
+ config: { title: 'Documentation' },
36
+ tree: { name: 'root', children: [] } as Root,
37
+ page: null,
38
+ apiSpecs: []
39
+ };
40
+ }
41
+ return ctx;
42
+ }
43
+
44
+ interface PageProviderProps {
45
+ initialConfig: ChronicleConfig;
46
+ initialTree: Root;
47
+ initialPage: PageData | null;
48
+ initialApiSpecs: ApiSpec[];
49
+ loadMdx: MdxLoader;
50
+ children: ReactNode;
51
+ }
52
+
53
+ export function PageProvider({
54
+ initialConfig,
55
+ initialTree,
56
+ initialPage,
57
+ initialApiSpecs,
58
+ loadMdx,
59
+ children
60
+ }: PageProviderProps) {
61
+ const { pathname } = useLocation();
62
+ const [tree] = useState<Root>(initialTree);
63
+ const [page, setPage] = useState<PageData | null>(initialPage);
64
+ const [apiSpecs, setApiSpecs] = useState<ApiSpec[]>(initialApiSpecs);
65
+ const [currentPath, setCurrentPath] = useState(pathname);
66
+
67
+ useEffect(() => {
68
+ if (pathname === currentPath) return;
69
+ setCurrentPath(pathname);
70
+
71
+ const cancelled = { current: false };
72
+
73
+ if (pathname.startsWith('/apis')) {
74
+ if (apiSpecs.length === 0) {
75
+ fetch('/api/specs')
76
+ .then(res => res.json())
77
+ .then(specs => {
78
+ if (!cancelled.current) setApiSpecs(specs);
79
+ })
80
+ .catch(() => {});
81
+ }
82
+ return () => { cancelled.current = true; };
83
+ }
84
+
85
+ const slug = pathname === '/'
86
+ ? []
87
+ : pathname.slice(1).split('/').filter(Boolean);
88
+
89
+ const apiPath = slug.length === 0 ? '/api/page/' : `/api/page/${slug.join('/')}`;
90
+
91
+ fetch(apiPath)
92
+ .then(res => res.json())
93
+ .then(async (data: { frontmatter: Frontmatter; relativePath: string; originalPath?: string }) => {
94
+ if (cancelled.current) return;
95
+ const { content, toc } = await loadMdx(data.originalPath || data.relativePath);
96
+ if (cancelled.current) return;
97
+ setPage({ slug, frontmatter: data.frontmatter, content, toc });
98
+ })
99
+ .catch(() => {});
100
+
101
+ return () => { cancelled.current = true; };
102
+ }, [pathname]);
103
+
104
+ return (
105
+ <PageContext.Provider
106
+ value={{ config: initialConfig, tree, page, apiSpecs }}
107
+ >
108
+ {children}
109
+ </PageContext.Provider>
110
+ );
111
+ }
@@ -0,0 +1,14 @@
1
+ import { visit } from 'unist-util-visit'
2
+ import type { Plugin } from 'unified'
3
+
4
+ const remarkStripMdExtensions: Plugin = () => {
5
+ return (tree) => {
6
+ visit(tree, 'link', (node: any) => {
7
+ if (!node.url) return
8
+ if (node.url.startsWith('http://') || node.url.startsWith('https://')) return
9
+ node.url = node.url.replace(/\.mdx?(#|$)/, '$1')
10
+ })
11
+ }
12
+ }
13
+
14
+ export default remarkStripMdExtensions