@raystack/chronicle 0.12.0 → 0.12.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/cli/index.js +47 -14
- package/package.json +3 -1
- package/src/cli/commands/dev.ts +5 -0
- package/src/components/api/ApiSkeleton.module.css +74 -0
- package/src/components/api/ApiSkeleton.tsx +105 -0
- package/src/components/api/index.ts +1 -0
- package/src/lib/api-routes.ts +1 -1
- package/src/lib/remark-resolve-images.ts +44 -13
- package/src/lib/tree-utils.test.ts +192 -2
- package/src/lib/tree-utils.ts +63 -1
- package/src/pages/ApiLayout.tsx +5 -1
- package/src/pages/ApiPage.tsx +4 -1
- package/src/server/api/image.test.ts +6 -0
- package/src/server/api/image.ts +63 -20
- package/src/server/api/page.ts +11 -5
- package/src/server/api/ready.ts +2 -0
- package/src/server/entry-server.tsx +55 -25
- package/src/server/plugins/telemetry.test.ts +47 -0
- package/src/server/plugins/telemetry.ts +65 -3
- package/src/themes/default/Layout.module.css +9 -4
- package/src/themes/default/Layout.tsx +14 -17
package/dist/cli/index.js
CHANGED
|
@@ -71,6 +71,29 @@ var init_image_utils = () => {};
|
|
|
71
71
|
// src/lib/remark-resolve-images.ts
|
|
72
72
|
import path4 from "node:path";
|
|
73
73
|
import { visit } from "unist-util-visit";
|
|
74
|
+
function parseImageParams(src) {
|
|
75
|
+
const qIdx = src.indexOf("?");
|
|
76
|
+
if (qIdx === -1)
|
|
77
|
+
return { base: src, params: {} };
|
|
78
|
+
const base = src.slice(0, qIdx);
|
|
79
|
+
const search = new URLSearchParams(src.slice(qIdx + 1));
|
|
80
|
+
const params = {};
|
|
81
|
+
if (search.has("w"))
|
|
82
|
+
params.w = Number.parseInt(search.get("w"), 10);
|
|
83
|
+
if (search.has("q"))
|
|
84
|
+
params.q = Number.parseInt(search.get("q"), 10);
|
|
85
|
+
return { base, params };
|
|
86
|
+
}
|
|
87
|
+
function appendParams(url, params) {
|
|
88
|
+
if (!params.w && !params.q)
|
|
89
|
+
return url;
|
|
90
|
+
const qs = new URLSearchParams;
|
|
91
|
+
if (params.w)
|
|
92
|
+
qs.set("w", String(params.w));
|
|
93
|
+
if (params.q)
|
|
94
|
+
qs.set("q", String(params.q));
|
|
95
|
+
return `${url}?${qs}`;
|
|
96
|
+
}
|
|
74
97
|
function resolveUrl(src, dir) {
|
|
75
98
|
const normalized = src.replace(/\\/g, "/");
|
|
76
99
|
if (/^[a-z][a-z0-9+\-.]*:/i.test(normalized))
|
|
@@ -86,9 +109,12 @@ function resolveUrl(src, dir) {
|
|
|
86
109
|
return `/_content/${path4.posix.normalize(path4.posix.join(dir, normalized))}`;
|
|
87
110
|
}
|
|
88
111
|
function optimizeUrl(url, optimize) {
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
112
|
+
const { base, params } = parseImageParams(url);
|
|
113
|
+
const width = params.w || DEFAULT_WIDTH;
|
|
114
|
+
const quality = params.q;
|
|
115
|
+
if (optimize && isLocalImage(base) && !isSvg(base))
|
|
116
|
+
return buildOptimizedUrl(base, width, quality);
|
|
117
|
+
return base;
|
|
92
118
|
}
|
|
93
119
|
var remarkResolveImages = (options) => {
|
|
94
120
|
const optimize = options?.optimize ?? true;
|
|
@@ -112,15 +138,17 @@ var remarkResolveImages = (options) => {
|
|
|
112
138
|
visit(tree, "image", (node) => {
|
|
113
139
|
if (!node.url)
|
|
114
140
|
return;
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
141
|
+
const { base, params } = parseImageParams(node.url);
|
|
142
|
+
const resolved = resolveUrl(base, dir);
|
|
143
|
+
collect(resolved);
|
|
144
|
+
node.url = optimizeUrl(appendParams(resolved, params), optimize);
|
|
118
145
|
});
|
|
119
146
|
visit(tree, "html", (node) => {
|
|
120
147
|
node.value = node.value.replace(/(<img\b[^>]*\bsrc=["'])([^"']+)(["'])/gi, (_, before, src, after) => {
|
|
121
|
-
const
|
|
148
|
+
const { base, params } = parseImageParams(src);
|
|
149
|
+
const resolved = resolveUrl(base, dir);
|
|
122
150
|
collect(resolved);
|
|
123
|
-
return `${before}${optimizeUrl(resolved, optimize)}${after}`;
|
|
151
|
+
return `${before}${optimizeUrl(appendParams(resolved, params), optimize)}${after}`;
|
|
124
152
|
});
|
|
125
153
|
});
|
|
126
154
|
visit(tree, (node) => {
|
|
@@ -132,9 +160,10 @@ var remarkResolveImages = (options) => {
|
|
|
132
160
|
const srcAttr = jsx.attributes.find((a) => a.type === "mdxJsxAttribute" && a.name === "src");
|
|
133
161
|
if (!srcAttr?.value || typeof srcAttr.value !== "string")
|
|
134
162
|
return;
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
163
|
+
const { base: jsxBase, params: jsxParams } = parseImageParams(srcAttr.value);
|
|
164
|
+
const jsxResolved = resolveUrl(jsxBase, dir);
|
|
165
|
+
collect(jsxResolved);
|
|
166
|
+
srcAttr.value = optimizeUrl(appendParams(jsxResolved, jsxParams), optimize);
|
|
138
167
|
});
|
|
139
168
|
visit(tree, "element", (node) => {
|
|
140
169
|
if (node.tagName !== "img")
|
|
@@ -142,9 +171,10 @@ var remarkResolveImages = (options) => {
|
|
|
142
171
|
const src = node.properties?.src;
|
|
143
172
|
if (typeof src !== "string")
|
|
144
173
|
return;
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
174
|
+
const { base: elBase, params: elParams } = parseImageParams(src);
|
|
175
|
+
const elResolved = resolveUrl(elBase, dir);
|
|
176
|
+
collect(elResolved);
|
|
177
|
+
node.properties.src = optimizeUrl(appendParams(elResolved, elParams), optimize);
|
|
148
178
|
});
|
|
149
179
|
file.data.images = images;
|
|
150
180
|
};
|
|
@@ -877,6 +907,9 @@ var devCommand = new Command2("dev").description("Start development server").opt
|
|
|
877
907
|
const { config: config2, projectRoot, configPath } = await loadCLIConfig(options.config);
|
|
878
908
|
const port = parseInt(options.port, 10);
|
|
879
909
|
await linkContent(projectRoot, config2);
|
|
910
|
+
if (process.platform === "win32" && !process.env.NITRO_DEV_RUNNER) {
|
|
911
|
+
process.env.NITRO_DEV_RUNNER = "node-process";
|
|
912
|
+
}
|
|
880
913
|
console.log(chalk3.cyan("Starting dev server..."));
|
|
881
914
|
const { createServer } = await import("vite");
|
|
882
915
|
const { createViteConfig: createViteConfig2 } = await Promise.resolve().then(() => (init_vite_config(), exports_vite_config));
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@raystack/chronicle",
|
|
3
|
-
"version": "0.12.
|
|
3
|
+
"version": "0.12.2",
|
|
4
4
|
"description": "Config-driven documentation framework",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"type": "module",
|
|
@@ -43,8 +43,10 @@
|
|
|
43
43
|
"@codemirror/view": "^6.39.14",
|
|
44
44
|
"@heroicons/react": "^2.2.0",
|
|
45
45
|
"@opentelemetry/api": "^1.9.1",
|
|
46
|
+
"@opentelemetry/api-logs": "^0.218.0",
|
|
46
47
|
"@opentelemetry/exporter-prometheus": "^0.214.0",
|
|
47
48
|
"@opentelemetry/resources": "^2.6.1",
|
|
49
|
+
"@opentelemetry/sdk-logs": "^0.218.0",
|
|
48
50
|
"@opentelemetry/sdk-metrics": "^2.6.1",
|
|
49
51
|
"@opentelemetry/semantic-conventions": "^1.40.0",
|
|
50
52
|
"@radix-ui/react-icons": "^1.3.2",
|
package/src/cli/commands/dev.ts
CHANGED
|
@@ -16,6 +16,11 @@ export const devCommand = new Command('dev')
|
|
|
16
16
|
|
|
17
17
|
await linkContent(projectRoot, config);
|
|
18
18
|
|
|
19
|
+
// Nitro 3's default node-worker runner fails on Windows due to Vite 8 environment API incompatibility
|
|
20
|
+
if (process.platform === 'win32' && !process.env.NITRO_DEV_RUNNER) {
|
|
21
|
+
process.env.NITRO_DEV_RUNNER = 'node-process';
|
|
22
|
+
}
|
|
23
|
+
|
|
19
24
|
console.log(chalk.cyan('Starting dev server...'));
|
|
20
25
|
|
|
21
26
|
const { createServer } = await import('vite');
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
.layout {
|
|
2
|
+
padding-left: var(--rs-space-9);
|
|
3
|
+
padding-right: var(--rs-space-9);
|
|
4
|
+
width: 100%;
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
.left {
|
|
8
|
+
min-width: 0;
|
|
9
|
+
flex: 0 1 545px;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
.right {
|
|
13
|
+
min-width: 376px;
|
|
14
|
+
max-width: 500px;
|
|
15
|
+
width: 100%;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
.methodBar {
|
|
19
|
+
padding: var(--rs-space-3) 0;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
.fieldRow {
|
|
23
|
+
padding: var(--rs-space-3) 0;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
.codeBlock {
|
|
27
|
+
background: var(--rs-color-background-neutral-secondary);
|
|
28
|
+
border-radius: var(--rs-radius-4);
|
|
29
|
+
padding: var(--rs-space-5);
|
|
30
|
+
min-height: 180px;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
.responseBlock {
|
|
34
|
+
background: var(--rs-color-background-neutral-secondary);
|
|
35
|
+
border-radius: var(--rs-radius-4);
|
|
36
|
+
padding: var(--rs-space-5);
|
|
37
|
+
min-height: 120px;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
.sidebarGroup {
|
|
41
|
+
margin-top: var(--rs-space-8);
|
|
42
|
+
width: 100%;
|
|
43
|
+
padding: 0 var(--rs-space-3);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
.sidebarGroup:first-child {
|
|
47
|
+
margin-top: 0;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
.sidebarItem {
|
|
51
|
+
padding: var(--rs-space-3) 0;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
@media (max-width: 1100px) {
|
|
55
|
+
.layout {
|
|
56
|
+
flex-direction: column;
|
|
57
|
+
gap: var(--rs-space-9);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
.left,
|
|
61
|
+
.right {
|
|
62
|
+
min-width: 0;
|
|
63
|
+
max-width: 100%;
|
|
64
|
+
width: 100%;
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
@media (max-width: 768px) {
|
|
69
|
+
.layout {
|
|
70
|
+
gap: var(--rs-space-5);
|
|
71
|
+
padding-left: var(--rs-space-5);
|
|
72
|
+
padding-right: var(--rs-space-5);
|
|
73
|
+
}
|
|
74
|
+
}
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
import { Skeleton, Flex, Sidebar } from '@raystack/apsara';
|
|
2
|
+
import { cx } from 'class-variance-authority';
|
|
3
|
+
import styles from './ApiSkeleton.module.css';
|
|
4
|
+
import layoutStyles from '@/themes/default/Layout.module.css';
|
|
5
|
+
import apiLayoutStyles from '@/pages/ApiLayout.module.css';
|
|
6
|
+
|
|
7
|
+
export function ApiPageSkeleton() {
|
|
8
|
+
return (
|
|
9
|
+
<Flex align="start" justify="between" className={styles.layout}>
|
|
10
|
+
<Flex direction="column" gap={9} className={styles.left}>
|
|
11
|
+
<Flex direction="column" gap={7}>
|
|
12
|
+
<Flex direction="column" gap={4}>
|
|
13
|
+
<Skeleton width="40%" height="var(--rs-line-height-t3)" />
|
|
14
|
+
<Skeleton width="60%" height="var(--rs-line-height-regular)" />
|
|
15
|
+
</Flex>
|
|
16
|
+
<Flex align="center" gap={3} className={styles.methodBar}>
|
|
17
|
+
<Skeleton width="48px" height="24px" />
|
|
18
|
+
<Skeleton width="200px" height="var(--rs-line-height-regular)" />
|
|
19
|
+
</Flex>
|
|
20
|
+
</Flex>
|
|
21
|
+
|
|
22
|
+
{[0, 1, 2].map(section => (
|
|
23
|
+
<Flex direction="column" gap={4} key={section}>
|
|
24
|
+
<Skeleton width="120px" height="var(--rs-line-height-small)" />
|
|
25
|
+
{[0, 1, 2, 3].map(row => (
|
|
26
|
+
<Flex align="center" gap={4} className={styles.fieldRow} key={row}>
|
|
27
|
+
<Skeleton width="80px" height="var(--rs-line-height-small)" />
|
|
28
|
+
<Skeleton width="60px" height="var(--rs-line-height-small)" />
|
|
29
|
+
</Flex>
|
|
30
|
+
))}
|
|
31
|
+
</Flex>
|
|
32
|
+
))}
|
|
33
|
+
</Flex>
|
|
34
|
+
|
|
35
|
+
<Flex direction="column" gap={8} className={styles.right}>
|
|
36
|
+
<Flex direction="column" gap={3} className={styles.codeBlock}>
|
|
37
|
+
<Skeleton width="50%" height="var(--rs-line-height-small)" />
|
|
38
|
+
{[0, 1, 2, 3, 4].map(i => (
|
|
39
|
+
<Skeleton key={i} width={`${70 + (i % 3) * 10}%`} height="var(--rs-line-height-small)" />
|
|
40
|
+
))}
|
|
41
|
+
</Flex>
|
|
42
|
+
<Flex direction="column" gap={3} className={styles.responseBlock}>
|
|
43
|
+
<Skeleton width="40%" height="var(--rs-line-height-small)" />
|
|
44
|
+
{[0, 1, 2].map(i => (
|
|
45
|
+
<Skeleton key={i} width={`${60 + (i % 2) * 20}%`} height="var(--rs-line-height-small)" />
|
|
46
|
+
))}
|
|
47
|
+
</Flex>
|
|
48
|
+
</Flex>
|
|
49
|
+
</Flex>
|
|
50
|
+
);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function SidebarSkeleton() {
|
|
54
|
+
return (
|
|
55
|
+
<>
|
|
56
|
+
{[0, 1, 2].map(group => (
|
|
57
|
+
<Flex direction="column" gap={3} className={styles.sidebarGroup} key={group}>
|
|
58
|
+
<Skeleton width="80px" height="var(--rs-line-height-small)" />
|
|
59
|
+
{[0, 1, 2, 3].map(item => (
|
|
60
|
+
<Flex align="center" gap={3} className={styles.sidebarItem} key={item}>
|
|
61
|
+
<Skeleton width="100%" height="var(--rs-line-height-small)" />
|
|
62
|
+
</Flex>
|
|
63
|
+
))}
|
|
64
|
+
</Flex>
|
|
65
|
+
))}
|
|
66
|
+
</>
|
|
67
|
+
);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export function ApiFullSkeleton() {
|
|
71
|
+
return (
|
|
72
|
+
<Flex direction="column" className={cx(layoutStyles.layout, apiLayoutStyles.layout)}>
|
|
73
|
+
<Flex className={layoutStyles.body}>
|
|
74
|
+
<Sidebar
|
|
75
|
+
defaultOpen
|
|
76
|
+
collapsible={false}
|
|
77
|
+
className={cx(layoutStyles.sidebar, apiLayoutStyles.sidebar)}
|
|
78
|
+
>
|
|
79
|
+
<Sidebar.Header className={layoutStyles.sidebarHeader}>
|
|
80
|
+
<Skeleton width="100px" height="28px" />
|
|
81
|
+
</Sidebar.Header>
|
|
82
|
+
<Sidebar.Main className={layoutStyles.sidebarMain}>
|
|
83
|
+
<SidebarSkeleton />
|
|
84
|
+
</Sidebar.Main>
|
|
85
|
+
</Sidebar>
|
|
86
|
+
<Flex direction="column" className={layoutStyles.mainArea}>
|
|
87
|
+
<div className={layoutStyles.cardWrapper}>
|
|
88
|
+
<div className={layoutStyles.card}>
|
|
89
|
+
<nav className={layoutStyles.subNav}>
|
|
90
|
+
<Flex align="center" gap={3}>
|
|
91
|
+
<Skeleton width="24px" height="24px" />
|
|
92
|
+
<Skeleton width="24px" height="24px" />
|
|
93
|
+
<Skeleton width="150px" height="var(--rs-line-height-small)" />
|
|
94
|
+
</Flex>
|
|
95
|
+
</nav>
|
|
96
|
+
<main className={cx(layoutStyles.content, apiLayoutStyles.content)}>
|
|
97
|
+
<ApiPageSkeleton />
|
|
98
|
+
</main>
|
|
99
|
+
</div>
|
|
100
|
+
</div>
|
|
101
|
+
</Flex>
|
|
102
|
+
</Flex>
|
|
103
|
+
</Flex>
|
|
104
|
+
);
|
|
105
|
+
}
|
package/src/lib/api-routes.ts
CHANGED
|
@@ -84,7 +84,7 @@ export function findApiOperation(specs: ApiSpec[], slug: string[]): ApiRouteMatc
|
|
|
84
84
|
return null
|
|
85
85
|
}
|
|
86
86
|
|
|
87
|
-
export function buildApiPageTree(specs: ApiSpec[]): Root {
|
|
87
|
+
export function buildApiPageTree(specs: ApiSpec[] = []): Root {
|
|
88
88
|
const children: Node[] = []
|
|
89
89
|
|
|
90
90
|
for (const spec of specs) {
|
|
@@ -7,6 +7,30 @@ import type { MdxJsxFlowElement, MdxJsxTextElement, MdxJsxAttribute } from 'mdas
|
|
|
7
7
|
import { MdxNodeType } from './mdx-utils'
|
|
8
8
|
import { isLocalImage, isSvg, buildOptimizedUrl, DEFAULT_WIDTH } from './image-utils'
|
|
9
9
|
|
|
10
|
+
interface ImageParams {
|
|
11
|
+
w?: number
|
|
12
|
+
q?: number
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function parseImageParams(src: string): { base: string; params: ImageParams } {
|
|
16
|
+
const qIdx = src.indexOf('?')
|
|
17
|
+
if (qIdx === -1) return { base: src, params: {} }
|
|
18
|
+
const base = src.slice(0, qIdx)
|
|
19
|
+
const search = new URLSearchParams(src.slice(qIdx + 1))
|
|
20
|
+
const params: ImageParams = {}
|
|
21
|
+
if (search.has('w')) params.w = Number.parseInt(search.get('w')!, 10)
|
|
22
|
+
if (search.has('q')) params.q = Number.parseInt(search.get('q')!, 10)
|
|
23
|
+
return { base, params }
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function appendParams(url: string, params: ImageParams): string {
|
|
27
|
+
if (!params.w && !params.q) return url
|
|
28
|
+
const qs = new URLSearchParams()
|
|
29
|
+
if (params.w) qs.set('w', String(params.w))
|
|
30
|
+
if (params.q) qs.set('q', String(params.q))
|
|
31
|
+
return `${url}?${qs}`
|
|
32
|
+
}
|
|
33
|
+
|
|
10
34
|
function resolveUrl(src: string, dir: string): string {
|
|
11
35
|
const normalized = src.replace(/\\/g, '/')
|
|
12
36
|
if (/^[a-z][a-z0-9+\-.]*:/i.test(normalized)) return normalized
|
|
@@ -23,8 +47,11 @@ interface RemarkResolveImagesOptions {
|
|
|
23
47
|
}
|
|
24
48
|
|
|
25
49
|
function optimizeUrl(url: string, optimize: boolean): string {
|
|
26
|
-
|
|
27
|
-
|
|
50
|
+
const { base, params } = parseImageParams(url)
|
|
51
|
+
const width = params.w || DEFAULT_WIDTH
|
|
52
|
+
const quality = params.q
|
|
53
|
+
if (optimize && isLocalImage(base) && !isSvg(base)) return buildOptimizedUrl(base, width, quality)
|
|
54
|
+
return base
|
|
28
55
|
}
|
|
29
56
|
|
|
30
57
|
const remarkResolveImages: Plugin<[RemarkResolveImagesOptions?]> = (options) => {
|
|
@@ -50,18 +77,20 @@ const remarkResolveImages: Plugin<[RemarkResolveImagesOptions?]> = (options) =>
|
|
|
50
77
|
|
|
51
78
|
visit(tree, 'image', (node: Image) => {
|
|
52
79
|
if (!node.url) return
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
80
|
+
const { base, params } = parseImageParams(node.url)
|
|
81
|
+
const resolved = resolveUrl(base, dir)
|
|
82
|
+
collect(resolved)
|
|
83
|
+
node.url = optimizeUrl(appendParams(resolved, params), optimize)
|
|
56
84
|
})
|
|
57
85
|
|
|
58
86
|
visit(tree, 'html', (node: Html) => {
|
|
59
87
|
node.value = node.value.replace(
|
|
60
88
|
/(<img\b[^>]*\bsrc=["'])([^"']+)(["'])/gi,
|
|
61
89
|
(_, before, src, after) => {
|
|
62
|
-
const
|
|
90
|
+
const { base, params } = parseImageParams(src)
|
|
91
|
+
const resolved = resolveUrl(base, dir)
|
|
63
92
|
collect(resolved)
|
|
64
|
-
return `${before}${optimizeUrl(resolved, optimize)}${after}`
|
|
93
|
+
return `${before}${optimizeUrl(appendParams(resolved, params), optimize)}${after}`
|
|
65
94
|
}
|
|
66
95
|
)
|
|
67
96
|
})
|
|
@@ -72,18 +101,20 @@ const remarkResolveImages: Plugin<[RemarkResolveImagesOptions?]> = (options) =>
|
|
|
72
101
|
if (jsx.name !== 'img') return
|
|
73
102
|
const srcAttr = jsx.attributes.find((a): a is MdxJsxAttribute => a.type === 'mdxJsxAttribute' && a.name === 'src')
|
|
74
103
|
if (!srcAttr?.value || typeof srcAttr.value !== 'string') return
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
104
|
+
const { base: jsxBase, params: jsxParams } = parseImageParams(srcAttr.value)
|
|
105
|
+
const jsxResolved = resolveUrl(jsxBase, dir)
|
|
106
|
+
collect(jsxResolved)
|
|
107
|
+
srcAttr.value = optimizeUrl(appendParams(jsxResolved, jsxParams), optimize)
|
|
78
108
|
})
|
|
79
109
|
|
|
80
110
|
visit(tree, 'element', (node: Element) => {
|
|
81
111
|
if (node.tagName !== 'img') return
|
|
82
112
|
const src = node.properties?.src
|
|
83
113
|
if (typeof src !== 'string') return
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
114
|
+
const { base: elBase, params: elParams } = parseImageParams(src)
|
|
115
|
+
const elResolved = resolveUrl(elBase, dir)
|
|
116
|
+
collect(elResolved)
|
|
117
|
+
node.properties.src = optimizeUrl(appendParams(elResolved, elParams), optimize)
|
|
87
118
|
})
|
|
88
119
|
|
|
89
120
|
file.data.images = images
|
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import { describe, expect, test } from 'bun:test'
|
|
2
|
-
import type { Node } from 'fumadocs-core/page-tree'
|
|
3
|
-
import {
|
|
2
|
+
import type { Node, Root } from 'fumadocs-core/page-tree'
|
|
3
|
+
import type { ChronicleConfig } from '@/types'
|
|
4
|
+
import type { VersionContext } from './version-source'
|
|
5
|
+
import { getFirstPageUrl, findFolderFirstPage, resolveDocsRedirect, resolvePageAndSlug, compactTree } from './tree-utils'
|
|
4
6
|
|
|
5
7
|
function page(url: string, name = 'Page'): Node {
|
|
6
8
|
return { type: 'page', name, url } as Node
|
|
@@ -111,3 +113,191 @@ describe('resolveDocsRedirect', () => {
|
|
|
111
113
|
.toBe('/docs/custom')
|
|
112
114
|
})
|
|
113
115
|
})
|
|
116
|
+
|
|
117
|
+
describe('resolvePageAndSlug', () => {
|
|
118
|
+
const treeDef = {
|
|
119
|
+
children: [
|
|
120
|
+
page('/docs/intro'),
|
|
121
|
+
folder('Guides', [page('/docs/guides/install'), page('/docs/guides/config')]),
|
|
122
|
+
] as Node[],
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
const config: ChronicleConfig = {
|
|
126
|
+
site: { title: 'Test' },
|
|
127
|
+
content: [{ dir: 'docs', label: 'Docs' }],
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
const version: VersionContext = { dir: null, urlPrefix: '' }
|
|
131
|
+
|
|
132
|
+
function makeDeps(pages: Record<string, unknown> = {}) {
|
|
133
|
+
return {
|
|
134
|
+
getPage: async (slug: string[]) => pages[slug.join('/')] ?? null,
|
|
135
|
+
getPageTree: async () => treeDef,
|
|
136
|
+
isDraft: () => false,
|
|
137
|
+
config,
|
|
138
|
+
version,
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
test('returns page when found directly', async () => {
|
|
143
|
+
const pageObj = { title: 'Intro' }
|
|
144
|
+
const result = await resolvePageAndSlug(['docs', 'intro'], makeDeps({ 'docs/intro': pageObj }))
|
|
145
|
+
expect(result).toEqual({ page: pageObj, slug: ['docs', 'intro'] })
|
|
146
|
+
})
|
|
147
|
+
|
|
148
|
+
test('resolves folder slug to first child page', async () => {
|
|
149
|
+
const installPage = { title: 'Install' }
|
|
150
|
+
const deps = makeDeps({ 'docs/guides/install': installPage })
|
|
151
|
+
const result = await resolvePageAndSlug(['docs', 'guides'], deps)
|
|
152
|
+
expect(result).toEqual({ page: installPage, slug: ['docs', 'guides', 'install'] })
|
|
153
|
+
})
|
|
154
|
+
|
|
155
|
+
test('returns null for non-matching slug', async () => {
|
|
156
|
+
const result = await resolvePageAndSlug(['docs', 'nonexistent'], makeDeps())
|
|
157
|
+
expect(result).toBeNull()
|
|
158
|
+
})
|
|
159
|
+
|
|
160
|
+
test('returns null when resolved page is draft', async () => {
|
|
161
|
+
const draftPage = { title: 'Draft' }
|
|
162
|
+
const deps = {
|
|
163
|
+
...makeDeps({ 'docs/guides/install': draftPage }),
|
|
164
|
+
isDraft: () => true,
|
|
165
|
+
}
|
|
166
|
+
const result = await resolvePageAndSlug(['docs', 'guides'], deps)
|
|
167
|
+
expect(result).toBeNull()
|
|
168
|
+
})
|
|
169
|
+
})
|
|
170
|
+
|
|
171
|
+
describe('compactTree', () => {
|
|
172
|
+
test('strips $ref and $id from page nodes', () => {
|
|
173
|
+
const tree: Root = {
|
|
174
|
+
name: 'root',
|
|
175
|
+
children: [{
|
|
176
|
+
type: 'page', name: 'Intro', url: '/docs/intro',
|
|
177
|
+
$ref: 'docs/intro.mdx', $id: 'docs/intro.mdx',
|
|
178
|
+
} as Node],
|
|
179
|
+
}
|
|
180
|
+
const result = compactTree(tree)
|
|
181
|
+
expect(result.children[0]).toEqual({ type: 'page', name: 'Intro', url: '/docs/intro' })
|
|
182
|
+
})
|
|
183
|
+
|
|
184
|
+
test('strips description and root from folder nodes', () => {
|
|
185
|
+
const tree: Root = {
|
|
186
|
+
name: 'root',
|
|
187
|
+
children: [{
|
|
188
|
+
type: 'folder', name: 'Guides',
|
|
189
|
+
description: 'Guide section', root: true,
|
|
190
|
+
children: [{ type: 'page', name: 'Install', url: '/guides/install', $ref: 'install.mdx' } as Node],
|
|
191
|
+
} as Node],
|
|
192
|
+
}
|
|
193
|
+
const result = compactTree(tree)
|
|
194
|
+
const folder = result.children[0] as any
|
|
195
|
+
expect(folder.description).toBeUndefined()
|
|
196
|
+
expect(folder.root).toBeUndefined()
|
|
197
|
+
expect(folder.name).toBe('Guides')
|
|
198
|
+
expect(folder.children[0]).toEqual({ type: 'page', name: 'Install', url: '/guides/install' })
|
|
199
|
+
})
|
|
200
|
+
|
|
201
|
+
test('preserves separator nodes', () => {
|
|
202
|
+
const tree: Root = {
|
|
203
|
+
name: 'root',
|
|
204
|
+
children: [{ type: 'separator' } as Node],
|
|
205
|
+
}
|
|
206
|
+
const result = compactTree(tree)
|
|
207
|
+
expect(result.children[0]).toEqual({ type: 'separator' })
|
|
208
|
+
})
|
|
209
|
+
|
|
210
|
+
test('preserves separator name and icon', () => {
|
|
211
|
+
const tree: Root = {
|
|
212
|
+
name: 'root',
|
|
213
|
+
children: [{ type: 'separator', name: 'Section', icon: 'star' } as Node],
|
|
214
|
+
}
|
|
215
|
+
const result = compactTree(tree)
|
|
216
|
+
expect(result.children[0]).toEqual({ type: 'separator', name: 'Section', icon: 'star' })
|
|
217
|
+
})
|
|
218
|
+
|
|
219
|
+
test('preserves folder index and strips its extra fields', () => {
|
|
220
|
+
const tree: Root = {
|
|
221
|
+
name: 'root',
|
|
222
|
+
children: [{
|
|
223
|
+
type: 'folder', name: 'Docs',
|
|
224
|
+
index: { type: 'page', name: 'Overview', url: '/docs', $ref: 'docs/index.mdx' },
|
|
225
|
+
children: [{ type: 'page', name: 'Intro', url: '/docs/intro' } as Node],
|
|
226
|
+
} as Node],
|
|
227
|
+
}
|
|
228
|
+
const result = compactTree(tree)
|
|
229
|
+
const folder = result.children[0] as any
|
|
230
|
+
expect(folder.index).toEqual({ type: 'page', name: 'Overview', url: '/docs' })
|
|
231
|
+
})
|
|
232
|
+
|
|
233
|
+
test('preserves icon field', () => {
|
|
234
|
+
const tree: Root = {
|
|
235
|
+
name: 'root',
|
|
236
|
+
children: [{ type: 'page', name: 'Home', url: '/', icon: 'home', $id: 'x' } as Node],
|
|
237
|
+
}
|
|
238
|
+
const result = compactTree(tree)
|
|
239
|
+
expect(result.children[0]).toEqual({ type: 'page', name: 'Home', url: '/', icon: 'home' })
|
|
240
|
+
})
|
|
241
|
+
|
|
242
|
+
test('page leaf only keeps type, name, url, icon', () => {
|
|
243
|
+
const tree: Root = {
|
|
244
|
+
name: 'root',
|
|
245
|
+
children: [{
|
|
246
|
+
type: 'page', name: 'Test', url: '/test', icon: 'doc',
|
|
247
|
+
$ref: 'test.mdx', $id: 'test', description: 'A test page', external: true,
|
|
248
|
+
} as Node],
|
|
249
|
+
}
|
|
250
|
+
const result = compactTree(tree)
|
|
251
|
+
const node = result.children[0] as any
|
|
252
|
+
expect(Object.keys(node).sort()).toEqual(['icon', 'name', 'type', 'url'])
|
|
253
|
+
})
|
|
254
|
+
|
|
255
|
+
test('separator leaf strips unknown fields', () => {
|
|
256
|
+
const tree: Root = {
|
|
257
|
+
name: 'root',
|
|
258
|
+
children: [{ type: 'separator', name: 'Divider', $id: 'sep1', root: true } as Node],
|
|
259
|
+
}
|
|
260
|
+
const result = compactTree(tree)
|
|
261
|
+
const node = result.children[0] as any
|
|
262
|
+
expect(Object.keys(node).sort()).toEqual(['name', 'type'])
|
|
263
|
+
})
|
|
264
|
+
|
|
265
|
+
test('folder index is compacted as leaf', () => {
|
|
266
|
+
const tree: Root = {
|
|
267
|
+
name: 'root',
|
|
268
|
+
children: [{
|
|
269
|
+
type: 'folder', name: 'Docs',
|
|
270
|
+
index: { type: 'page', name: 'Index', url: '/docs', $ref: 'index.mdx', $id: 'idx', description: 'main' },
|
|
271
|
+
children: [],
|
|
272
|
+
} as Node],
|
|
273
|
+
}
|
|
274
|
+
const result = compactTree(tree)
|
|
275
|
+
const idx = (result.children[0] as any).index
|
|
276
|
+
expect(Object.keys(idx).sort()).toEqual(['name', 'type', 'url'])
|
|
277
|
+
})
|
|
278
|
+
|
|
279
|
+
test('recursively compacts nested folders', () => {
|
|
280
|
+
const tree: Root = {
|
|
281
|
+
name: 'root',
|
|
282
|
+
children: [{
|
|
283
|
+
type: 'folder', name: 'L1', $ref: 'l1',
|
|
284
|
+
children: [{
|
|
285
|
+
type: 'folder', name: 'L2', $ref: 'l2',
|
|
286
|
+
children: [{ type: 'page', name: 'Deep', url: '/l1/l2/deep', $ref: 'deep.mdx', $id: 'deep' } as Node],
|
|
287
|
+
} as Node],
|
|
288
|
+
} as Node],
|
|
289
|
+
}
|
|
290
|
+
const result = compactTree(tree)
|
|
291
|
+
const l1 = result.children[0] as any
|
|
292
|
+
const l2 = l1.children[0] as any
|
|
293
|
+
const deep = l2.children[0]
|
|
294
|
+
expect(l1.$ref).toBeUndefined()
|
|
295
|
+
expect(l2.$ref).toBeUndefined()
|
|
296
|
+
expect(deep).toEqual({ type: 'page', name: 'Deep', url: '/l1/l2/deep' })
|
|
297
|
+
})
|
|
298
|
+
|
|
299
|
+
test('preserves tree name', () => {
|
|
300
|
+
const tree: Root = { name: 'custom', children: [] }
|
|
301
|
+
expect(compactTree(tree).name).toBe('custom')
|
|
302
|
+
})
|
|
303
|
+
})
|
package/src/lib/tree-utils.ts
CHANGED
|
@@ -1,4 +1,32 @@
|
|
|
1
|
-
import type { Node } from 'fumadocs-core/page-tree';
|
|
1
|
+
import type { Folder, Node, Root } from 'fumadocs-core/page-tree';
|
|
2
|
+
import type { ChronicleConfig } from '@/types';
|
|
3
|
+
import type { VersionContext } from './version-source';
|
|
4
|
+
|
|
5
|
+
const KEEP_FIELDS = new Set(['type', 'name', 'url', 'icon', 'children', 'index']);
|
|
6
|
+
|
|
7
|
+
function compactLeaf(node: Node): Node {
|
|
8
|
+
const out: Record<string, unknown> = {};
|
|
9
|
+
for (const [k, v] of Object.entries(node)) {
|
|
10
|
+
if (KEEP_FIELDS.has(k)) out[k] = v;
|
|
11
|
+
}
|
|
12
|
+
return out as Node;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function compactNode(node: Node): Node {
|
|
16
|
+
if (node.type !== 'folder') return compactLeaf(node);
|
|
17
|
+
const out: Record<string, unknown> = {};
|
|
18
|
+
for (const [k, v] of Object.entries(node)) {
|
|
19
|
+
if (!KEEP_FIELDS.has(k)) continue;
|
|
20
|
+
if (k === 'children') out.children = (v as Node[]).map(compactNode);
|
|
21
|
+
else if (k === 'index') out.index = compactLeaf(v as Node);
|
|
22
|
+
else out[k] = v;
|
|
23
|
+
}
|
|
24
|
+
return out as Node;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function compactTree(tree: Root): Root {
|
|
28
|
+
return { ...tree, children: tree.children.map(compactNode) };
|
|
29
|
+
}
|
|
2
30
|
|
|
3
31
|
export const NodeType = {
|
|
4
32
|
Page: 'page',
|
|
@@ -55,3 +83,37 @@ export function resolveDocsRedirect(
|
|
|
55
83
|
|
|
56
84
|
return findFolderFirstPage(tree.children, `/${slug.join('/')}`);
|
|
57
85
|
}
|
|
86
|
+
|
|
87
|
+
interface ResolvePageDeps {
|
|
88
|
+
getPage: (slug: string[]) => Promise<unknown>;
|
|
89
|
+
getPageTree: () => Promise<{ children: Node[] }>;
|
|
90
|
+
isDraft: (page: unknown) => boolean;
|
|
91
|
+
config: ChronicleConfig;
|
|
92
|
+
version: VersionContext;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
export async function resolvePageAndSlug(slug: string[], deps: ResolvePageDeps) {
|
|
96
|
+
const { getPage, getPageTree, isDraft, config, version } = deps;
|
|
97
|
+
|
|
98
|
+
const page = await getPage(slug);
|
|
99
|
+
if (page && !isDraft(page)) return { page, slug };
|
|
100
|
+
|
|
101
|
+
const slugWithoutVersion = version.dir && slug[0] === version.dir
|
|
102
|
+
? slug.slice(1)
|
|
103
|
+
: slug;
|
|
104
|
+
|
|
105
|
+
const tree = await getPageTree();
|
|
106
|
+
const contentEntries = version.dir
|
|
107
|
+
? config.versions?.find(v => v.dir === version.dir)?.content ?? config.content
|
|
108
|
+
: config.content;
|
|
109
|
+
const contentConfig = contentEntries?.find(c => c.dir === slugWithoutVersion[0]);
|
|
110
|
+
const redirectUrl = resolveDocsRedirect(slugWithoutVersion, tree, contentConfig);
|
|
111
|
+
if (!redirectUrl) return null;
|
|
112
|
+
|
|
113
|
+
const fullUrl = version.urlPrefix ? `${version.urlPrefix}${redirectUrl}` : redirectUrl;
|
|
114
|
+
const resolvedSlug = fullUrl.split('/').filter(Boolean);
|
|
115
|
+
const resolvedPage = await getPage(resolvedSlug);
|
|
116
|
+
if (!resolvedPage || isDraft(resolvedPage)) return null;
|
|
117
|
+
|
|
118
|
+
return { page: resolvedPage, slug: resolvedSlug };
|
|
119
|
+
}
|
package/src/pages/ApiLayout.tsx
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { cx } from 'class-variance-authority';
|
|
2
2
|
import type { ReactNode } from 'react';
|
|
3
|
+
import { ApiFullSkeleton } from '@/components/api/ApiSkeleton';
|
|
3
4
|
import { buildApiPageTree } from '@/lib/api-routes';
|
|
4
5
|
import { usePageContext } from '@/lib/page-context';
|
|
5
6
|
import { getTheme } from '@/themes/registry';
|
|
@@ -10,7 +11,10 @@ interface ApiLayoutProps {
|
|
|
10
11
|
}
|
|
11
12
|
|
|
12
13
|
export function ApiLayout({ children }: ApiLayoutProps) {
|
|
13
|
-
const { config, apiSpecs } = usePageContext();
|
|
14
|
+
const { config, apiSpecs, isLoading } = usePageContext();
|
|
15
|
+
|
|
16
|
+
if (isLoading) return <ApiFullSkeleton />;
|
|
17
|
+
|
|
14
18
|
const { Layout, className } = getTheme(config.theme?.name);
|
|
15
19
|
const tree = buildApiPageTree(apiSpecs);
|
|
16
20
|
|
package/src/pages/ApiPage.tsx
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import type { OpenAPIV3 } from 'openapi-types';
|
|
2
2
|
import { Navigate } from 'react-router';
|
|
3
3
|
import { ApiOverview } from '@/components/api';
|
|
4
|
+
import { ApiPageSkeleton } from '@/components/api/ApiSkeleton';
|
|
4
5
|
import { findApiOperation, getFirstApiUrl } from '@/lib/api-routes';
|
|
5
6
|
import { Head } from '@/lib/head';
|
|
6
7
|
import { usePageContext } from '@/lib/page-context';
|
|
@@ -10,7 +11,9 @@ interface ApiPageProps {
|
|
|
10
11
|
}
|
|
11
12
|
|
|
12
13
|
export function ApiPage({ slug }: ApiPageProps) {
|
|
13
|
-
const { config, apiSpecs } = usePageContext();
|
|
14
|
+
const { config, apiSpecs, isLoading } = usePageContext();
|
|
15
|
+
|
|
16
|
+
if (isLoading) return <ApiPageSkeleton />;
|
|
14
17
|
|
|
15
18
|
if (slug.length === 0) {
|
|
16
19
|
const firstUrl = getFirstApiUrl(apiSpecs);
|
|
@@ -48,6 +48,12 @@ describe('cacheKey', () => {
|
|
|
48
48
|
expect(a).not.toBe(b);
|
|
49
49
|
});
|
|
50
50
|
|
|
51
|
+
test('returns different keys for different mtime', () => {
|
|
52
|
+
const a = cacheKey('/_content/img.png', 640, 75, 'webp', 1000);
|
|
53
|
+
const b = cacheKey('/_content/img.png', 640, 75, 'webp', 2000);
|
|
54
|
+
expect(a).not.toBe(b);
|
|
55
|
+
});
|
|
56
|
+
|
|
51
57
|
test('key ends with format extension', () => {
|
|
52
58
|
expect(cacheKey('/_content/img.png', 640, 75, 'webp')).toMatch(/\.webp$/);
|
|
53
59
|
expect(cacheKey('/_content/img.png', 640, 75, 'avif')).toMatch(/\.avif$/);
|
package/src/server/api/image.ts
CHANGED
|
@@ -6,10 +6,9 @@ import { useStorage } from 'nitro/storage'
|
|
|
6
6
|
import sharp from 'sharp'
|
|
7
7
|
import { StatusCodes } from 'http-status-codes'
|
|
8
8
|
import { safePath } from '@/server/utils/safe-path'
|
|
9
|
-
import { ALLOWED_WIDTHS, ALLOWED_QUALITIES, DEFAULT_QUALITY } from '@/lib/image-utils'
|
|
9
|
+
import { ALLOWED_WIDTHS, ALLOWED_QUALITIES, DEFAULT_WIDTH, DEFAULT_QUALITY, isLocalImage, isSvg } from '@/lib/image-utils'
|
|
10
10
|
|
|
11
|
-
const STORAGE_KEY = 'image-cache'
|
|
12
|
-
const MAX_CACHE_ENTRIES = 500
|
|
11
|
+
export const STORAGE_KEY = 'image-cache'
|
|
13
12
|
|
|
14
13
|
const inflight = new Map<string, Promise<Buffer>>()
|
|
15
14
|
|
|
@@ -29,8 +28,8 @@ export const MIME: Record<string, string> = {
|
|
|
29
28
|
'.webp': 'image/webp',
|
|
30
29
|
}
|
|
31
30
|
|
|
32
|
-
export function cacheKey(url: string, w: number, q: number, format: OutputFormat): string {
|
|
33
|
-
const hash = crypto.createHash('sha256').update(`${url}:${w}:${q}:${format}`).digest('hex').slice(0, 16)
|
|
31
|
+
export function cacheKey(url: string, w: number, q: number, format: OutputFormat, mtime?: number): string {
|
|
32
|
+
const hash = crypto.createHash('sha256').update(`${url}:${w}:${q}:${format}:${mtime ?? 0}`).digest('hex').slice(0, 16)
|
|
34
33
|
return `${hash}.${format}`
|
|
35
34
|
}
|
|
36
35
|
|
|
@@ -42,11 +41,17 @@ function snapQuality(q: number): number {
|
|
|
42
41
|
return closest;
|
|
43
42
|
}
|
|
44
43
|
|
|
45
|
-
async function
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
44
|
+
export async function optimizeImage(
|
|
45
|
+
filePath: string,
|
|
46
|
+
w: number,
|
|
47
|
+
q: number,
|
|
48
|
+
format: OutputFormat,
|
|
49
|
+
): Promise<Buffer> {
|
|
50
|
+
const source = await fs.readFile(filePath);
|
|
51
|
+
const pipeline = sharp(source).resize({ width: w, withoutEnlargement: true });
|
|
52
|
+
if (format === 'avif') return pipeline.avif({ quality: q }).toBuffer();
|
|
53
|
+
if (format === 'webp') return pipeline.webp({ quality: q }).toBuffer();
|
|
54
|
+
return pipeline.toBuffer();
|
|
50
55
|
}
|
|
51
56
|
|
|
52
57
|
export default defineHandler(async event => {
|
|
@@ -90,7 +95,11 @@ export default defineHandler(async event => {
|
|
|
90
95
|
const originalMime = MIME[ext] ?? 'application/octet-stream'
|
|
91
96
|
const contentType = format === 'original' ? originalMime : `image/${format}`
|
|
92
97
|
|
|
93
|
-
const
|
|
98
|
+
const stat = await fs.stat(filePath).catch(() => null)
|
|
99
|
+
if (!stat) {
|
|
100
|
+
throw new HTTPError({ status: StatusCodes.NOT_FOUND, message: 'Not Found' })
|
|
101
|
+
}
|
|
102
|
+
const key = cacheKey(url, w, q, format, stat.mtimeMs)
|
|
94
103
|
|
|
95
104
|
const cached = await storage.getItemRaw<Buffer>(key)
|
|
96
105
|
if (cached) {
|
|
@@ -116,16 +125,8 @@ export default defineHandler(async event => {
|
|
|
116
125
|
}
|
|
117
126
|
|
|
118
127
|
const work = (async () => {
|
|
119
|
-
const
|
|
120
|
-
const pipeline = sharp(source).resize({ width: w, withoutEnlargement: true })
|
|
121
|
-
const optimized = format === 'avif'
|
|
122
|
-
? await pipeline.avif({ quality: q }).toBuffer()
|
|
123
|
-
: format === 'webp'
|
|
124
|
-
? await pipeline.webp({ quality: q }).toBuffer()
|
|
125
|
-
: await pipeline.toBuffer()
|
|
126
|
-
|
|
128
|
+
const optimized = await optimizeImage(filePath, w, q, format)
|
|
127
129
|
await storage.setItemRaw(key, optimized)
|
|
128
|
-
await evictIfNeeded(storage)
|
|
129
130
|
return optimized
|
|
130
131
|
})()
|
|
131
132
|
|
|
@@ -154,3 +155,45 @@ export default defineHandler(async event => {
|
|
|
154
155
|
inflight.delete(key)
|
|
155
156
|
}
|
|
156
157
|
})
|
|
158
|
+
|
|
159
|
+
export async function warmupImageCache() {
|
|
160
|
+
const { getPages, getPageImages } = await import('@/lib/source');
|
|
161
|
+
// biome-ignore lint/correctness/useHookAtTopLevel: useStorage is a Nitro DI accessor, not a React hook
|
|
162
|
+
const storage = useStorage(STORAGE_KEY);
|
|
163
|
+
const contentDir = __CHRONICLE_CONTENT_DIR__;
|
|
164
|
+
const format = 'webp' as const;
|
|
165
|
+
const w = DEFAULT_WIDTH;
|
|
166
|
+
const q = DEFAULT_QUALITY;
|
|
167
|
+
|
|
168
|
+
const pages = await getPages();
|
|
169
|
+
const seen = new Set<string>();
|
|
170
|
+
let warmed = 0;
|
|
171
|
+
|
|
172
|
+
for (const page of pages) {
|
|
173
|
+
for (const url of getPageImages(page)) {
|
|
174
|
+
if (!isLocalImage(url) || isSvg(url) || seen.has(url)) continue;
|
|
175
|
+
seen.add(url);
|
|
176
|
+
|
|
177
|
+
const relativePath = url.replace(/^\/_content\//, '');
|
|
178
|
+
const filePath = safePath(contentDir, `/${relativePath}`);
|
|
179
|
+
if (!filePath) continue;
|
|
180
|
+
|
|
181
|
+
const stat = await fs.stat(filePath).catch(() => null);
|
|
182
|
+
if (!stat) continue;
|
|
183
|
+
|
|
184
|
+
const key = cacheKey(url, w, q, format, stat.mtimeMs);
|
|
185
|
+
const cached = await storage.getItemRaw(key);
|
|
186
|
+
if (cached) continue;
|
|
187
|
+
|
|
188
|
+
try {
|
|
189
|
+
const optimized = await optimizeImage(filePath, w, q, format);
|
|
190
|
+
await storage.setItemRaw(key, optimized);
|
|
191
|
+
warmed++;
|
|
192
|
+
} catch { /* skip unprocessable */ }
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
if (warmed > 0) {
|
|
197
|
+
console.log(`[image-warmup] cached ${warmed} images as webp@${w}w`);
|
|
198
|
+
}
|
|
199
|
+
}
|
package/src/server/api/page.ts
CHANGED
|
@@ -1,19 +1,25 @@
|
|
|
1
1
|
import { defineHandler, HTTPError } from 'nitro';
|
|
2
|
-
import {
|
|
2
|
+
import { loadConfig } from '@/lib/config';
|
|
3
|
+
import { getPage, getPageTree, isDraft, getPageNav, extractFrontmatter, getRelativePath, getOriginalPath, getPageImages } from '@/lib/source';
|
|
4
|
+
import { resolvePageAndSlug } from '@/lib/tree-utils';
|
|
5
|
+
import { resolveVersionFromUrl } from '@/lib/version-source';
|
|
3
6
|
|
|
4
7
|
export default defineHandler(async event => {
|
|
5
8
|
const slugParam = event.url.searchParams.get('slug') ?? '';
|
|
6
9
|
const slug = slugParam ? slugParam.split(',').filter(Boolean) : [];
|
|
7
|
-
const
|
|
10
|
+
const config = loadConfig();
|
|
11
|
+
const version = resolveVersionFromUrl(`/${slug.join('/')}`, config);
|
|
12
|
+
const resolved = await resolvePageAndSlug(slug, { getPage, getPageTree, isDraft, config, version });
|
|
8
13
|
|
|
9
|
-
if (!
|
|
14
|
+
if (!resolved) {
|
|
10
15
|
throw new HTTPError({ status: 404, message: 'Page not found' });
|
|
11
16
|
}
|
|
12
17
|
|
|
13
|
-
const
|
|
18
|
+
const { page, slug: resolvedSlug } = resolved;
|
|
19
|
+
const nav = await getPageNav(resolvedSlug);
|
|
14
20
|
|
|
15
21
|
return Response.json({
|
|
16
|
-
frontmatter: extractFrontmatter(page,
|
|
22
|
+
frontmatter: extractFrontmatter(page, resolvedSlug[resolvedSlug.length - 1]),
|
|
17
23
|
relativePath: getRelativePath(page),
|
|
18
24
|
originalPath: getOriginalPath(page),
|
|
19
25
|
images: getPageImages(page),
|
package/src/server/api/ready.ts
CHANGED
|
@@ -1,9 +1,11 @@
|
|
|
1
1
|
import { defineHandler } from 'nitro';
|
|
2
2
|
import { ensureIndex, isSearchReady } from './search';
|
|
3
|
+
import { warmupImageCache } from './image';
|
|
3
4
|
import { LATEST_CONTEXT } from '@/lib/version-source';
|
|
4
5
|
|
|
5
6
|
export default defineHandler(async () => {
|
|
6
7
|
ensureIndex(LATEST_CONTEXT).catch(e => console.error('[search:index]', e));
|
|
8
|
+
warmupImageCache().catch(e => console.error('[image-warmup]', e));
|
|
7
9
|
|
|
8
10
|
if (!isSearchReady()) {
|
|
9
11
|
return new Response(JSON.stringify({ status: 'not_ready', search: false }), {
|
|
@@ -9,10 +9,13 @@ 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,
|
|
12
|
+
import { getPage, getPageTree, isDraft, getPageNav, loadPageModule, extractFrontmatter, getRelativePath, getOriginalPath, getPageImages } from '@/lib/source';
|
|
13
13
|
import { getFirstApiUrl } from '@/lib/api-routes';
|
|
14
14
|
import { StatusCodes } from 'http-status-codes';
|
|
15
|
-
import { resolveDocsRedirect } from '@/lib/tree-utils';
|
|
15
|
+
import { resolvePageAndSlug, resolveDocsRedirect, compactTree } from '@/lib/tree-utils';
|
|
16
|
+
import { filterPageTreeByVersion, filterPageTreeByContentDir } from '@/lib/version-source';
|
|
17
|
+
import { getActiveContentDir } from '@/lib/navigation';
|
|
18
|
+
import { getLatestContentRoots, getVersionContentRoots } from '@/lib/config';
|
|
16
19
|
import { isLocalImage, isSvg, buildOptimizedUrl, DEFAULT_WIDTH } from '@/lib/image-utils';
|
|
17
20
|
import { QueryClientProvider, QueryClient } from '@tanstack/react-query';
|
|
18
21
|
import { useNitroApp } from 'nitro/app';
|
|
@@ -21,10 +24,31 @@ import { App } from './App';
|
|
|
21
24
|
import clientAssets from './entry-client?assets=client';
|
|
22
25
|
import serverAssets from './entry-server?assets=ssr';
|
|
23
26
|
|
|
27
|
+
function errorResponse(status: number, title: string, message: string): Response {
|
|
28
|
+
const safe = message.replace(/[<>&"]/g, '');
|
|
29
|
+
const html = `<!DOCTYPE html>
|
|
30
|
+
<html lang="en">
|
|
31
|
+
<head>
|
|
32
|
+
<meta charset="UTF-8">
|
|
33
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
34
|
+
<title>${status} — ${title}</title>
|
|
35
|
+
</head>
|
|
36
|
+
<body>
|
|
37
|
+
<h1>${status} — ${title}</h1>
|
|
38
|
+
<p>${safe}</p>
|
|
39
|
+
</body>
|
|
40
|
+
</html>`;
|
|
41
|
+
return new Response(html, {
|
|
42
|
+
status,
|
|
43
|
+
headers: { 'Content-Type': 'text/html;charset=utf-8' },
|
|
44
|
+
});
|
|
45
|
+
}
|
|
46
|
+
|
|
24
47
|
export default {
|
|
25
48
|
async fetch(req: Request) {
|
|
26
49
|
const url = new URL(req.url);
|
|
27
50
|
const pathname = decodeURIComponent(url.pathname);
|
|
51
|
+
try {
|
|
28
52
|
|
|
29
53
|
const config = loadConfig();
|
|
30
54
|
const route = resolveRoute(pathname, config);
|
|
@@ -46,11 +70,16 @@ export default {
|
|
|
46
70
|
: [];
|
|
47
71
|
const apiSpecs = apiConfigs.length ? await loadApiSpecs(apiConfigs) : [];
|
|
48
72
|
|
|
49
|
-
const
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
73
|
+
const fullTree = await getPageTree();
|
|
74
|
+
const versionTree = filterPageTreeByVersion(fullTree, route.version, config);
|
|
75
|
+
const contentDirs = route.version.dir
|
|
76
|
+
? getVersionContentRoots(config, route.version.dir)
|
|
77
|
+
: getLatestContentRoots(config);
|
|
78
|
+
const activeDir = getActiveContentDir(pathname, config);
|
|
79
|
+
const scopedTree = contentDirs.length === 1 && activeDir
|
|
80
|
+
? filterPageTreeByContentDir(versionTree, route.version, activeDir)
|
|
81
|
+
: versionTree;
|
|
82
|
+
const tree = compactTree(scopedTree);
|
|
54
83
|
|
|
55
84
|
// SSR redirects for index pages
|
|
56
85
|
if (route.type === RouteType.ApiIndex) {
|
|
@@ -60,23 +89,20 @@ export default {
|
|
|
60
89
|
}
|
|
61
90
|
}
|
|
62
91
|
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
const fullUrl = versionPrefix ? `${versionPrefix}${redirectUrl}` : redirectUrl;
|
|
75
|
-
return new Response(null, { status: StatusCodes.TEMPORARY_REDIRECT, headers: { Location: fullUrl } });
|
|
76
|
-
}
|
|
92
|
+
const resolved = route.type === RouteType.DocsPage
|
|
93
|
+
? await resolvePageAndSlug(route.slug, { getPage, getPageTree, isDraft, config, version: route.version })
|
|
94
|
+
: null;
|
|
95
|
+
const page = resolved?.page ?? null;
|
|
96
|
+
const resolvedSlug = resolved?.slug ?? pageSlug;
|
|
97
|
+
|
|
98
|
+
if (route.type === RouteType.DocsPage && resolved && resolved.slug.join('/') !== route.slug.join('/')) {
|
|
99
|
+
return new Response(null, {
|
|
100
|
+
status: StatusCodes.TEMPORARY_REDIRECT,
|
|
101
|
+
headers: { Location: `/${resolved.slug.join('/')}` },
|
|
102
|
+
});
|
|
77
103
|
}
|
|
78
104
|
|
|
79
|
-
const nav = page ? await getPageNav(
|
|
105
|
+
const nav = page ? await getPageNav(resolvedSlug) : { prev: null, next: null };
|
|
80
106
|
|
|
81
107
|
const relativePath = page ? getRelativePath(page) : null;
|
|
82
108
|
const originalPath = page ? getOriginalPath(page) : null;
|
|
@@ -85,9 +111,9 @@ export default {
|
|
|
85
111
|
|
|
86
112
|
const pageData = page
|
|
87
113
|
? {
|
|
88
|
-
slug:
|
|
114
|
+
slug: resolvedSlug,
|
|
89
115
|
frontmatter: {
|
|
90
|
-
...extractFrontmatter(page,
|
|
116
|
+
...extractFrontmatter(page, resolvedSlug[resolvedSlug.length - 1]),
|
|
91
117
|
_readingTime: mdxModule?._readingTime,
|
|
92
118
|
},
|
|
93
119
|
content: mdxModule?.default
|
|
@@ -102,7 +128,7 @@ export default {
|
|
|
102
128
|
const embeddedData = {
|
|
103
129
|
config,
|
|
104
130
|
tree,
|
|
105
|
-
slug:
|
|
131
|
+
slug: resolvedSlug,
|
|
106
132
|
version: route.version,
|
|
107
133
|
frontmatter: pageData?.frontmatter ?? null,
|
|
108
134
|
relativePath,
|
|
@@ -169,5 +195,9 @@ export default {
|
|
|
169
195
|
status,
|
|
170
196
|
headers: { 'Content-Type': 'text/html;charset=utf-8' },
|
|
171
197
|
});
|
|
198
|
+
} catch (err) {
|
|
199
|
+
console.error(`[chronicle] SSR error for ${pathname}:`, err);
|
|
200
|
+
return errorResponse(500, 'Internal Server Error', err instanceof Error ? err.message : String(err));
|
|
201
|
+
}
|
|
172
202
|
},
|
|
173
203
|
};
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import { describe, expect, test } from 'bun:test'
|
|
2
|
+
import { toEndpoint } from './telemetry'
|
|
3
|
+
|
|
4
|
+
describe('toEndpoint', () => {
|
|
5
|
+
test('root path', () => {
|
|
6
|
+
expect(toEndpoint('/')).toBe('/')
|
|
7
|
+
})
|
|
8
|
+
|
|
9
|
+
test('docs pages map to /docs/:slug', () => {
|
|
10
|
+
expect(toEndpoint('/docs/intro')).toBe('/docs/:slug')
|
|
11
|
+
expect(toEndpoint('/docs/guides/installation')).toBe('/docs/:slug')
|
|
12
|
+
expect(toEndpoint('/developer/gettingstarted/auth')).toBe('/docs/:slug')
|
|
13
|
+
})
|
|
14
|
+
|
|
15
|
+
test('api internal routes keep exact path', () => {
|
|
16
|
+
expect(toEndpoint('/api/page')).toBe('/api/page')
|
|
17
|
+
expect(toEndpoint('/api/search')).toBe('/api/search')
|
|
18
|
+
expect(toEndpoint('/api/specs')).toBe('/api/specs')
|
|
19
|
+
expect(toEndpoint('/api/health')).toBe('/api/health')
|
|
20
|
+
})
|
|
21
|
+
|
|
22
|
+
test('api reference pages map to /apis/:slug', () => {
|
|
23
|
+
expect(toEndpoint('/apis/petstore/listPets')).toBe('/apis/:slug')
|
|
24
|
+
expect(toEndpoint('/apis/frontier/getUser')).toBe('/apis/:slug')
|
|
25
|
+
})
|
|
26
|
+
|
|
27
|
+
test('assets map to /assets/:file', () => {
|
|
28
|
+
expect(toEndpoint('/assets/chunk-abc123.js')).toBe('/assets/:file')
|
|
29
|
+
expect(toEndpoint('/assets/style-xyz.css')).toBe('/assets/:file')
|
|
30
|
+
})
|
|
31
|
+
|
|
32
|
+
test('content paths map to /_content/:path', () => {
|
|
33
|
+
expect(toEndpoint('/_content/docs/intro.mdx')).toBe('/_content/:path')
|
|
34
|
+
})
|
|
35
|
+
|
|
36
|
+
test('static routes keep exact path', () => {
|
|
37
|
+
expect(toEndpoint('/llms.txt')).toBe('/llms.txt')
|
|
38
|
+
expect(toEndpoint('/robots.txt')).toBe('/robots.txt')
|
|
39
|
+
expect(toEndpoint('/sitemap.xml')).toBe('/sitemap.xml')
|
|
40
|
+
expect(toEndpoint('/og')).toBe('/og')
|
|
41
|
+
})
|
|
42
|
+
|
|
43
|
+
test('versioned docs map to /docs/:slug', () => {
|
|
44
|
+
expect(toEndpoint('/v1/docs/intro')).toBe('/docs/:slug')
|
|
45
|
+
expect(toEndpoint('/v2/guides/setup')).toBe('/docs/:slug')
|
|
46
|
+
})
|
|
47
|
+
})
|
|
@@ -1,6 +1,12 @@
|
|
|
1
1
|
import type { Counter, Histogram } from '@opentelemetry/api'
|
|
2
2
|
import { MeterProvider } from '@opentelemetry/sdk-metrics'
|
|
3
3
|
import { PrometheusExporter } from '@opentelemetry/exporter-prometheus'
|
|
4
|
+
import {
|
|
5
|
+
LoggerProvider,
|
|
6
|
+
SimpleLogRecordProcessor,
|
|
7
|
+
ConsoleLogRecordExporter,
|
|
8
|
+
} from '@opentelemetry/sdk-logs'
|
|
9
|
+
import { SeverityNumber } from '@opentelemetry/api-logs'
|
|
4
10
|
import { resourceFromAttributes } from '@opentelemetry/resources'
|
|
5
11
|
import { ATTR_SERVICE_NAME } from '@opentelemetry/semantic-conventions'
|
|
6
12
|
import type { H3Event } from 'h3'
|
|
@@ -13,6 +19,33 @@ declare module 'nitro/types' {
|
|
|
13
19
|
}
|
|
14
20
|
}
|
|
15
21
|
|
|
22
|
+
const ROUTES = {
|
|
23
|
+
ROOT: '/',
|
|
24
|
+
DOCS: '/docs/:slug',
|
|
25
|
+
API_INTERNAL: '/api/:action',
|
|
26
|
+
API_REFERENCE: '/apis/:slug',
|
|
27
|
+
ASSETS: '/assets/:file',
|
|
28
|
+
CONTENT: '/_content/:path',
|
|
29
|
+
} as const
|
|
30
|
+
|
|
31
|
+
const ENDPOINT_MAP: [string, string | null][] = [
|
|
32
|
+
['/api/', null],
|
|
33
|
+
['/_content/', ROUTES.CONTENT],
|
|
34
|
+
['/apis/', ROUTES.API_REFERENCE],
|
|
35
|
+
['/assets/', ROUTES.ASSETS],
|
|
36
|
+
]
|
|
37
|
+
|
|
38
|
+
const STATIC_ROUTES = new Set(['/llms.txt', '/robots.txt', '/sitemap.xml', '/og'])
|
|
39
|
+
|
|
40
|
+
export function toEndpoint(pathname: string): string {
|
|
41
|
+
if (pathname === '/') return ROUTES.ROOT;
|
|
42
|
+
for (const [prefix, template] of ENDPOINT_MAP) {
|
|
43
|
+
if (pathname.startsWith(prefix)) return template ?? pathname;
|
|
44
|
+
}
|
|
45
|
+
if (STATIC_ROUTES.has(pathname)) return pathname;
|
|
46
|
+
return ROUTES.DOCS;
|
|
47
|
+
}
|
|
48
|
+
|
|
16
49
|
export default definePlugin((nitroApp) => {
|
|
17
50
|
const config = loadConfig()
|
|
18
51
|
if (!config.telemetry?.enabled) return
|
|
@@ -26,6 +59,12 @@ export default definePlugin((nitroApp) => {
|
|
|
26
59
|
const provider = new MeterProvider({ resource, readers: [exporter] })
|
|
27
60
|
const meter = provider.getMeter('chronicle')
|
|
28
61
|
|
|
62
|
+
const loggerProvider = new LoggerProvider({
|
|
63
|
+
resource,
|
|
64
|
+
processors: [new SimpleLogRecordProcessor(new ConsoleLogRecordExporter())],
|
|
65
|
+
})
|
|
66
|
+
const logger = loggerProvider.getLogger('chronicle')
|
|
67
|
+
|
|
29
68
|
const requestCounter: Counter = meter.createCounter('http_server_request_total', {
|
|
30
69
|
description: 'Total HTTP requests',
|
|
31
70
|
})
|
|
@@ -37,12 +76,13 @@ export default definePlugin((nitroApp) => {
|
|
|
37
76
|
})
|
|
38
77
|
|
|
39
78
|
nitroApp.hooks.hook('close', async () => {
|
|
79
|
+
await loggerProvider.shutdown()
|
|
40
80
|
await provider.shutdown()
|
|
41
81
|
await exporter.shutdown()
|
|
42
82
|
})
|
|
43
83
|
|
|
44
84
|
nitroApp.hooks.hook('chronicle:ssr-rendered', (route, status, durationMs) => {
|
|
45
|
-
ssrRenderDuration.record(durationMs, { route, status })
|
|
85
|
+
ssrRenderDuration.record(durationMs, { route: toEndpoint(route), status })
|
|
46
86
|
})
|
|
47
87
|
|
|
48
88
|
nitroApp.hooks.hook('request', (event) => {
|
|
@@ -55,7 +95,29 @@ export default definePlugin((nitroApp) => {
|
|
|
55
95
|
const duration = performance.now() - start
|
|
56
96
|
const method = event.req.method
|
|
57
97
|
const route = new URL(event.req.url).pathname
|
|
58
|
-
|
|
59
|
-
|
|
98
|
+
const endpoint = toEndpoint(route)
|
|
99
|
+
|
|
100
|
+
const clientIp =
|
|
101
|
+
event.req.headers['x-forwarded-for']?.toString().split(',')[0].trim() ??
|
|
102
|
+
event.req.headers['x-real-ip']?.toString() ??
|
|
103
|
+
event.req.socket?.remoteAddress ??
|
|
104
|
+
'unknown'
|
|
105
|
+
|
|
106
|
+
requestCounter.add(1, { method, endpoint, status: res.status })
|
|
107
|
+
requestDuration.record(duration, { method, endpoint, status: res.status })
|
|
108
|
+
|
|
109
|
+
logger.emit({
|
|
110
|
+
severityNumber: SeverityNumber.INFO,
|
|
111
|
+
severityText: 'INFO',
|
|
112
|
+
body: `${method} ${route} ${res.status} ${duration.toFixed(1)}ms`,
|
|
113
|
+
attributes: {
|
|
114
|
+
'client.address': clientIp,
|
|
115
|
+
'http.request.method': method,
|
|
116
|
+
'url.path': route,
|
|
117
|
+
'http.response.status_code': res.status,
|
|
118
|
+
'http.request.duration_ms': Math.round(duration),
|
|
119
|
+
},
|
|
120
|
+
})
|
|
121
|
+
|
|
60
122
|
})
|
|
61
123
|
})
|
|
@@ -76,7 +76,9 @@
|
|
|
76
76
|
|
|
77
77
|
.topLinks {
|
|
78
78
|
width: 100%;
|
|
79
|
-
|
|
79
|
+
display: flex;
|
|
80
|
+
flex-direction: column;
|
|
81
|
+
gap: 0;
|
|
80
82
|
}
|
|
81
83
|
|
|
82
84
|
.topLinkItem {
|
|
@@ -135,7 +137,7 @@
|
|
|
135
137
|
.cardWrapper {
|
|
136
138
|
flex: 1;
|
|
137
139
|
display: flex;
|
|
138
|
-
padding: 0
|
|
140
|
+
padding: 0;
|
|
139
141
|
min-height: 0;
|
|
140
142
|
background: var(--rs-color-background-base-secondary);
|
|
141
143
|
}
|
|
@@ -154,7 +156,7 @@
|
|
|
154
156
|
align-items: center;
|
|
155
157
|
justify-content: space-between;
|
|
156
158
|
height: var(--navbar-height);
|
|
157
|
-
padding: var(--rs-space-4) var(--rs-space-
|
|
159
|
+
padding: var(--rs-space-4) var(--rs-space-8);
|
|
158
160
|
background: var(--rs-color-background-base-primary);
|
|
159
161
|
border-bottom: 0.5px solid var(--rs-color-border-base-primary);
|
|
160
162
|
backdrop-filter: blur(1px);
|
|
@@ -172,7 +174,7 @@
|
|
|
172
174
|
|
|
173
175
|
.groupItems {
|
|
174
176
|
padding-left: 0;
|
|
175
|
-
padding-bottom:
|
|
177
|
+
padding-bottom: 0;
|
|
176
178
|
gap: 0;
|
|
177
179
|
}
|
|
178
180
|
|
|
@@ -190,6 +192,9 @@
|
|
|
190
192
|
|
|
191
193
|
.navGroup .navGroupHeader {
|
|
192
194
|
margin: 0;
|
|
195
|
+
margin-bottom: 0;
|
|
196
|
+
padding-top: 0;
|
|
197
|
+
padding-bottom: 0;
|
|
193
198
|
}
|
|
194
199
|
|
|
195
200
|
.navGroup[data-depth='1'] .navGroupHeader {
|
|
@@ -62,8 +62,6 @@ function renderConfigIcon(
|
|
|
62
62
|
return <img src={icon} alt={alt} className={styles.configIcon} />;
|
|
63
63
|
}
|
|
64
64
|
|
|
65
|
-
let savedScrollTop = 0;
|
|
66
|
-
|
|
67
65
|
export function Layout({
|
|
68
66
|
children,
|
|
69
67
|
config,
|
|
@@ -97,22 +95,20 @@ export function Layout({
|
|
|
97
95
|
);
|
|
98
96
|
|
|
99
97
|
useEffect(() => {
|
|
100
|
-
const
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
requestAnimationFrame(() => {
|
|
113
|
-
el.scrollTop = savedScrollTop;
|
|
114
|
-
});
|
|
98
|
+
const timer = setTimeout(() => {
|
|
99
|
+
const container = document.querySelector<HTMLElement>(`.${styles.sidebarMain}`);
|
|
100
|
+
if (!container) return;
|
|
101
|
+
const allActive = container.querySelectorAll<HTMLElement>('[data-active="true"]');
|
|
102
|
+
const activeItem = allActive[allActive.length - 1];
|
|
103
|
+
if (!activeItem) return;
|
|
104
|
+
const containerRect = container.getBoundingClientRect();
|
|
105
|
+
const itemRect = activeItem.getBoundingClientRect();
|
|
106
|
+
if (itemRect.top < containerRect.top || itemRect.bottom > containerRect.bottom) {
|
|
107
|
+
container.scrollTop += itemRect.top - containerRect.top - containerRect.height / 2 + itemRect.height / 2;
|
|
108
|
+
}
|
|
109
|
+
}, 100);
|
|
115
110
|
setMobileSidebarOpen(false);
|
|
111
|
+
return () => clearTimeout(timer);
|
|
116
112
|
}, [pathname]);
|
|
117
113
|
|
|
118
114
|
return (
|
|
@@ -436,6 +432,7 @@ function ApiSidebarNode({ item, pathname }: { item: Node; pathname: string }) {
|
|
|
436
432
|
align='center'
|
|
437
433
|
gap={3}
|
|
438
434
|
className={`${styles.apiItem} ${isActive ? styles.apiItemActive : ''}`}
|
|
435
|
+
data-active={isActive}
|
|
439
436
|
render={<RouterLink to={href} />}
|
|
440
437
|
>
|
|
441
438
|
<span className={styles.apiItemName}>{item.name}</span>
|