@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.
- package/dist/cli/index.js +878 -9684
- package/package.json +17 -12
- package/src/cli/__tests__/config.test.ts +25 -0
- package/src/cli/__tests__/scaffold.test.ts +10 -0
- package/src/cli/commands/build.ts +66 -19
- package/src/cli/commands/dev.ts +9 -22
- package/src/cli/commands/init.ts +107 -11
- package/src/cli/commands/serve.ts +36 -35
- package/src/cli/commands/start.ts +11 -21
- package/src/cli/utils/config.ts +2 -2
- package/src/cli/utils/index.ts +1 -1
- package/src/cli/utils/resolve.ts +6 -0
- package/src/cli/utils/scaffold.ts +20 -0
- package/src/components/mdx/code.tsx +10 -1
- package/src/components/mdx/details.module.css +1 -24
- package/src/components/mdx/details.tsx +2 -3
- package/src/components/mdx/image.tsx +5 -19
- package/src/components/mdx/index.tsx +3 -3
- package/src/components/mdx/link.tsx +10 -11
- package/src/components/ui/footer.tsx +3 -2
- package/src/components/ui/search.module.css +7 -0
- package/src/components/ui/search.tsx +62 -87
- package/src/lib/config.ts +9 -0
- package/src/lib/head.tsx +45 -0
- package/src/lib/page-context.tsx +95 -0
- package/src/lib/source.ts +92 -21
- package/src/{app/apis/[[...slug]]/layout.tsx → pages/ApiLayout.tsx} +10 -7
- package/src/pages/ApiPage.tsx +68 -0
- package/src/pages/DocsLayout.tsx +18 -0
- package/src/pages/DocsPage.tsx +43 -0
- package/src/pages/NotFound.tsx +10 -0
- package/src/pages/__tests__/head.test.tsx +57 -0
- package/src/server/App.tsx +59 -0
- package/src/server/__tests__/entry-server.test.tsx +35 -0
- package/src/server/__tests__/handlers.test.ts +77 -0
- package/src/server/__tests__/og.test.ts +23 -0
- package/src/server/__tests__/router.test.ts +72 -0
- package/src/server/__tests__/vite-config.test.ts +25 -0
- package/src/server/adapters/vercel.ts +133 -0
- package/src/server/build-search-index.ts +107 -0
- package/src/server/dev.ts +158 -0
- package/src/server/entry-client.tsx +74 -0
- package/src/server/entry-prod.ts +98 -0
- package/src/server/entry-server.tsx +35 -0
- package/src/server/entry-vercel.ts +28 -0
- package/src/server/handlers/apis-proxy.ts +57 -0
- package/src/{app/api/health/route.ts → server/handlers/health.ts} +1 -1
- package/src/server/handlers/llms.ts +58 -0
- package/src/server/handlers/og.ts +87 -0
- package/src/server/handlers/robots.ts +11 -0
- package/src/server/handlers/search.ts +172 -0
- package/src/server/handlers/sitemap.ts +39 -0
- package/src/server/handlers/specs.ts +9 -0
- package/src/server/index.html +12 -0
- package/src/server/prod.ts +18 -0
- package/src/server/request-handler.ts +64 -0
- package/src/server/router.ts +42 -0
- package/src/server/utils/safe-path.ts +14 -0
- package/src/server/vite-config.ts +71 -0
- package/src/themes/default/Layout.tsx +9 -10
- package/src/themes/default/Page.module.css +60 -0
- package/src/themes/default/font.ts +4 -6
- package/src/themes/paper/ChapterNav.tsx +5 -6
- package/src/themes/paper/Page.tsx +8 -9
- package/src/types/config.ts +11 -0
- package/src/types/content.ts +1 -0
- package/tsconfig.json +29 -0
- package/next.config.mjs +0 -10
- package/source.config.ts +0 -50
- package/src/app/[[...slug]]/layout.tsx +0 -15
- package/src/app/[[...slug]]/page.tsx +0 -57
- package/src/app/api/apis-proxy/route.ts +0 -59
- package/src/app/api/search/route.ts +0 -90
- package/src/app/apis/[[...slug]]/page.tsx +0 -57
- package/src/app/layout.tsx +0 -26
- package/src/app/llms-full.txt/route.ts +0 -18
- package/src/app/llms.txt/route.ts +0 -15
- package/src/app/providers.tsx +0 -8
- package/src/cli/utils/process.ts +0 -7
- package/src/lib/get-llm-text.ts +0 -10
- /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={
|
|
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={
|
|
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
|
-
<
|
|
15
|
+
<img
|
|
32
16
|
src={src}
|
|
33
17
|
alt={alt ?? ''}
|
|
34
|
-
width={
|
|
35
|
-
height={
|
|
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 {
|
|
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:
|
|
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 {
|
|
35
|
+
export { MdxLink } from './link'
|
|
@@ -1,12 +1,11 @@
|
|
|
1
1
|
'use client'
|
|
2
2
|
|
|
3
|
-
import
|
|
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
|
|
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 (
|
|
16
|
+
if (isExternal) {
|
|
18
17
|
return (
|
|
19
|
-
<
|
|
18
|
+
<a href={href} target="_blank" rel="noopener noreferrer" {...props}>
|
|
20
19
|
{children}
|
|
21
|
-
</
|
|
20
|
+
</a>
|
|
22
21
|
)
|
|
23
22
|
}
|
|
24
23
|
|
|
25
|
-
if (
|
|
24
|
+
if (isAnchor) {
|
|
26
25
|
return (
|
|
27
|
-
<
|
|
26
|
+
<a href={href} {...props}>
|
|
28
27
|
{children}
|
|
29
|
-
</
|
|
28
|
+
</a>
|
|
30
29
|
)
|
|
31
30
|
}
|
|
32
31
|
|
|
33
32
|
return (
|
|
34
|
-
<
|
|
33
|
+
<Link to={href} className={props.className}>
|
|
35
34
|
{children}
|
|
36
|
-
</
|
|
35
|
+
</Link>
|
|
37
36
|
)
|
|
38
37
|
}
|
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import {
|
|
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}
|
|
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 {
|
|
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(
|
|
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
|
|
35
|
-
|
|
36
|
-
const {
|
|
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
|
-
|
|
74
|
+
navigate(url);
|
|
47
75
|
},
|
|
48
|
-
[
|
|
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
|
-
{
|
|
95
|
-
{!
|
|
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
|
-
{!
|
|
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
|
|
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
|
-
{
|
|
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
|
|
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
|
-
<
|
|
132
|
-
{result.
|
|
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
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
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
|
-
|
|
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
|
}
|
package/src/lib/head.tsx
ADDED
|
@@ -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 {
|
|
2
|
-
import {
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
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 =
|
|
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:
|
|
101
|
+
name: page.frontmatter.title,
|
|
31
102
|
url: page.url,
|
|
32
|
-
order:
|
|
103
|
+
order: page.frontmatter.order ?? (isIndex ? 0 : undefined),
|
|
33
104
|
}
|
|
34
105
|
|
|
35
106
|
if (page.slugs.length > 1) {
|