@raystack/chronicle 0.12.1 → 0.12.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/cli/index.js +4 -0
- 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/fonts/Inter-Regular.ttf +0 -0
- package/src/lib/api-routes.ts +1 -1
- 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/pages/DocsPage.tsx +2 -1
- package/src/pages/LandingPage.tsx +8 -0
- package/src/server/App.tsx +19 -17
- package/src/server/api/page.ts +11 -5
- package/src/server/entry-server.tsx +65 -25
- package/src/server/plugins/telemetry.test.ts +47 -0
- package/src/server/plugins/telemetry.ts +65 -3
- package/src/server/routes/og-utils.ts +32 -0
- package/src/server/routes/og.test.ts +63 -0
- package/src/server/routes/og.tsx +21 -19
- package/src/server/vite-config.ts +1 -0
- package/src/types/globals.d.ts +1 -0
package/dist/cli/index.js
CHANGED
|
@@ -496,6 +496,7 @@ async function createViteConfig(options) {
|
|
|
496
496
|
define: {
|
|
497
497
|
__CHRONICLE_CONTENT_DIR__: JSON.stringify(contentMirror),
|
|
498
498
|
__CHRONICLE_PROJECT_ROOT__: JSON.stringify(projectRoot),
|
|
499
|
+
__CHRONICLE_PACKAGE_ROOT__: JSON.stringify(packageRoot),
|
|
499
500
|
__CHRONICLE_CONFIG_RAW__: JSON.stringify(rawConfig)
|
|
500
501
|
},
|
|
501
502
|
css: {
|
|
@@ -907,6 +908,9 @@ var devCommand = new Command2("dev").description("Start development server").opt
|
|
|
907
908
|
const { config: config2, projectRoot, configPath } = await loadCLIConfig(options.config);
|
|
908
909
|
const port = parseInt(options.port, 10);
|
|
909
910
|
await linkContent(projectRoot, config2);
|
|
911
|
+
if (process.platform === "win32" && !process.env.NITRO_DEV_RUNNER) {
|
|
912
|
+
process.env.NITRO_DEV_RUNNER = "node-process";
|
|
913
|
+
}
|
|
910
914
|
console.log(chalk3.cyan("Starting dev server..."));
|
|
911
915
|
const { createServer } = await import("vite");
|
|
912
916
|
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.3",
|
|
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
|
+
}
|
|
Binary file
|
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) {
|
|
@@ -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);
|
package/src/pages/DocsPage.tsx
CHANGED
|
@@ -38,7 +38,8 @@ export function DocsPage({ slug }: DocsPageProps) {
|
|
|
38
38
|
'@type': 'Article',
|
|
39
39
|
headline: page.frontmatter.title,
|
|
40
40
|
description: page.frontmatter.description,
|
|
41
|
-
...(pageUrl && { url: pageUrl })
|
|
41
|
+
...(pageUrl && { url: pageUrl }),
|
|
42
|
+
...(page.frontmatter.lastModified && { dateModified: new Date(page.frontmatter.lastModified).toISOString() }),
|
|
42
43
|
}}
|
|
43
44
|
/>
|
|
44
45
|
<Page
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { FolderIcon } from '@heroicons/react/24/outline';
|
|
2
2
|
import { Link as RouterLink } from 'react-router';
|
|
3
3
|
import { getLandingEntries } from '@/lib/config';
|
|
4
|
+
import { Head } from '@/lib/head';
|
|
4
5
|
import { usePageContext } from '@/lib/page-context';
|
|
5
6
|
import styles from './LandingPage.module.css';
|
|
6
7
|
|
|
@@ -13,6 +14,12 @@ export function LandingPage() {
|
|
|
13
14
|
: `${config.site.title} — ${versionLabel(config, version.dir)}`;
|
|
14
15
|
|
|
15
16
|
return (
|
|
17
|
+
<>
|
|
18
|
+
<Head
|
|
19
|
+
title={version.dir ? `${config.site.title} — ${versionLabel(config, version.dir)}` : 'Documentation'}
|
|
20
|
+
description={config.site.description}
|
|
21
|
+
config={config}
|
|
22
|
+
/>
|
|
16
23
|
<div className={styles.root}>
|
|
17
24
|
<div className={styles.header}>
|
|
18
25
|
<h1 className={styles.title}>{heading}</h1>
|
|
@@ -42,6 +49,7 @@ export function LandingPage() {
|
|
|
42
49
|
))}
|
|
43
50
|
</div>
|
|
44
51
|
</div>
|
|
52
|
+
</>
|
|
45
53
|
);
|
|
46
54
|
}
|
|
47
55
|
|
package/src/server/App.tsx
CHANGED
|
@@ -4,7 +4,6 @@ import { ThemeProvider, Skeleton, Flex } from '@raystack/apsara';
|
|
|
4
4
|
import { lazy, Suspense } from 'react';
|
|
5
5
|
import { Navigate, useLocation } from 'react-router';
|
|
6
6
|
import { AnalyticsProvider } from '@/components/analytics/AnalyticsProvider';
|
|
7
|
-
import { Head } from '@/lib/head';
|
|
8
7
|
import { usePageContext } from '@/lib/page-context';
|
|
9
8
|
import { resolveRoute, RouteType } from '@/lib/route-resolver';
|
|
10
9
|
import type { ChronicleConfig } from '@/types';
|
|
@@ -69,22 +68,25 @@ function PageFallback() {
|
|
|
69
68
|
}
|
|
70
69
|
|
|
71
70
|
function RootHead({ config }: { config: ChronicleConfig }) {
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
config.url
|
|
79
|
-
? {
|
|
80
|
-
'@context': 'https://schema.org',
|
|
81
|
-
'@type': 'WebSite',
|
|
82
|
-
name: config.site.title,
|
|
83
|
-
description: config.site.description,
|
|
84
|
-
url: config.url
|
|
85
|
-
}
|
|
86
|
-
: undefined
|
|
71
|
+
const siteJsonLd = config.url
|
|
72
|
+
? {
|
|
73
|
+
'@context': 'https://schema.org',
|
|
74
|
+
'@type': 'WebSite',
|
|
75
|
+
name: config.site.title,
|
|
76
|
+
description: config.site.description,
|
|
77
|
+
url: config.url,
|
|
87
78
|
}
|
|
88
|
-
|
|
79
|
+
: null;
|
|
80
|
+
|
|
81
|
+
return (
|
|
82
|
+
<>
|
|
83
|
+
<title>{config.site.title}</title>
|
|
84
|
+
{siteJsonLd && (
|
|
85
|
+
<script
|
|
86
|
+
type='application/ld+json'
|
|
87
|
+
dangerouslySetInnerHTML={{ __html: JSON.stringify(siteJsonLd, null, 2) }}
|
|
88
|
+
/>
|
|
89
|
+
)}
|
|
90
|
+
</>
|
|
89
91
|
);
|
|
90
92
|
}
|
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),
|
|
@@ -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,
|
|
@@ -132,6 +158,16 @@ export default {
|
|
|
132
158
|
const href = isLocalImage(src) && !isSvg(src) ? buildOptimizedUrl(src, DEFAULT_WIDTH) : src;
|
|
133
159
|
return <link key={src} rel="preload" as="image" href={href} />;
|
|
134
160
|
})}
|
|
161
|
+
{isApiRoute && (
|
|
162
|
+
<link
|
|
163
|
+
rel="preload"
|
|
164
|
+
as="fetch"
|
|
165
|
+
crossOrigin="anonymous"
|
|
166
|
+
href={route.version.dir
|
|
167
|
+
? `/api/specs?version=${encodeURIComponent(route.version.dir)}`
|
|
168
|
+
: '/api/specs'}
|
|
169
|
+
/>
|
|
170
|
+
)}
|
|
135
171
|
<script type="module" src={assets.entry} />
|
|
136
172
|
<script dangerouslySetInnerHTML={{ __html: `window.__PAGE_DATA__ = ${safeJson}` }} />
|
|
137
173
|
</head>
|
|
@@ -169,5 +205,9 @@ export default {
|
|
|
169
205
|
status,
|
|
170
206
|
headers: { 'Content-Type': 'text/html;charset=utf-8' },
|
|
171
207
|
});
|
|
208
|
+
} catch (err) {
|
|
209
|
+
console.error(`[chronicle] SSR error for ${pathname}:`, err);
|
|
210
|
+
return errorResponse(500, 'Internal Server Error', err instanceof Error ? err.message : String(err));
|
|
211
|
+
}
|
|
172
212
|
},
|
|
173
213
|
};
|
|
@@ -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
|
})
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import fs from 'node:fs/promises';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
|
|
4
|
+
const MIME_MAP: Record<string, string> = {
|
|
5
|
+
'.svg': 'image/svg+xml',
|
|
6
|
+
'.png': 'image/png',
|
|
7
|
+
'.jpg': 'image/jpeg',
|
|
8
|
+
'.jpeg': 'image/jpeg',
|
|
9
|
+
};
|
|
10
|
+
|
|
11
|
+
export function getLogoDataUri(data: Buffer, filePath: string): string | null {
|
|
12
|
+
const ext = path.extname(filePath).toLowerCase();
|
|
13
|
+
const mime = MIME_MAP[ext];
|
|
14
|
+
if (!mime) return null;
|
|
15
|
+
return `data:${mime};base64,${data.toString('base64')}`;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export async function loadLogo(projectRoot: string, logoPath: string): Promise<string | null> {
|
|
19
|
+
try {
|
|
20
|
+
const filePath = path.resolve(projectRoot, 'public', logoPath.replace(/^\//, ''));
|
|
21
|
+
const data = await fs.readFile(filePath);
|
|
22
|
+
return getLogoDataUri(data, filePath);
|
|
23
|
+
} catch {
|
|
24
|
+
return null;
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export async function loadFont(packageRoot: string): Promise<ArrayBuffer> {
|
|
29
|
+
const fontPath = path.resolve(packageRoot, 'src/fonts/Inter-Regular.ttf');
|
|
30
|
+
const buffer = await fs.readFile(fontPath);
|
|
31
|
+
return buffer.buffer.slice(buffer.byteOffset, buffer.byteOffset + buffer.byteLength);
|
|
32
|
+
}
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import { describe, expect, test } from 'bun:test';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { getLogoDataUri, loadLogo, loadFont } from './og-utils';
|
|
4
|
+
|
|
5
|
+
const PACKAGE_ROOT = path.resolve(__dirname, '../../..');
|
|
6
|
+
const FIXTURES = path.resolve(__dirname, '__fixtures__');
|
|
7
|
+
|
|
8
|
+
describe('getLogoDataUri', () => {
|
|
9
|
+
test('svg file returns svg mime type', () => {
|
|
10
|
+
const data = Buffer.from('<svg></svg>');
|
|
11
|
+
const result = getLogoDataUri(data, '/logo.svg');
|
|
12
|
+
expect(result).toStartWith('data:image/svg+xml;base64,');
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
test('png file returns png mime type', () => {
|
|
16
|
+
const data = Buffer.from('fake-png');
|
|
17
|
+
const result = getLogoDataUri(data, '/logo.png');
|
|
18
|
+
expect(result).toStartWith('data:image/png;base64,');
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
test('jpg file returns jpeg mime type', () => {
|
|
22
|
+
const data = Buffer.from('fake-jpg');
|
|
23
|
+
const result = getLogoDataUri(data, '/photo.jpg');
|
|
24
|
+
expect(result).toStartWith('data:image/jpeg;base64,');
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
test('returns null for unsupported format', () => {
|
|
28
|
+
const data = Buffer.from('fake-webp');
|
|
29
|
+
const result = getLogoDataUri(data, '/logo.webp');
|
|
30
|
+
expect(result).toBeNull();
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
test('encodes data as base64', () => {
|
|
34
|
+
const content = '<svg xmlns="http://www.w3.org/2000/svg"></svg>';
|
|
35
|
+
const data = Buffer.from(content);
|
|
36
|
+
const result = getLogoDataUri(data, '/icon.svg');
|
|
37
|
+
const base64 = result!.split(',')[1];
|
|
38
|
+
expect(Buffer.from(base64, 'base64').toString()).toBe(content);
|
|
39
|
+
});
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
describe('loadLogo', () => {
|
|
43
|
+
test('returns null for nonexistent file', async () => {
|
|
44
|
+
const result = await loadLogo('/nonexistent', '/logo.svg');
|
|
45
|
+
expect(result).toBeNull();
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
test('strips leading slash from logo path', async () => {
|
|
49
|
+
const result = await loadLogo('/nonexistent', '/nested/logo.svg');
|
|
50
|
+
expect(result).toBeNull();
|
|
51
|
+
});
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
describe('loadFont', () => {
|
|
55
|
+
test('loads Inter font from package', async () => {
|
|
56
|
+
const font = await loadFont(PACKAGE_ROOT);
|
|
57
|
+
expect(font.byteLength).toBeGreaterThan(0);
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
test('throws for invalid path', async () => {
|
|
61
|
+
expect(loadFont('/nonexistent')).rejects.toThrow();
|
|
62
|
+
});
|
|
63
|
+
});
|
package/src/server/routes/og.tsx
CHANGED
|
@@ -2,23 +2,10 @@ import { defineHandler } from 'nitro';
|
|
|
2
2
|
import React from 'react';
|
|
3
3
|
import satori from 'satori';
|
|
4
4
|
import { loadConfig } from '@/lib/config';
|
|
5
|
+
import { loadFont, loadLogo } from './og-utils';
|
|
5
6
|
|
|
6
7
|
let fontData: ArrayBuffer | null = null;
|
|
7
|
-
|
|
8
|
-
async function loadFont(): Promise<ArrayBuffer> {
|
|
9
|
-
if (fontData) return fontData;
|
|
10
|
-
|
|
11
|
-
try {
|
|
12
|
-
const response = await fetch(
|
|
13
|
-
'https://fonts.gstatic.com/s/inter/v18/UcCO3FwrK3iLTeHuS_nVMrMxCp50SjIw2boKoduKmMEVuLyfAZ9hiA.woff2'
|
|
14
|
-
);
|
|
15
|
-
fontData = await response.arrayBuffer();
|
|
16
|
-
} catch {
|
|
17
|
-
fontData = new ArrayBuffer(0);
|
|
18
|
-
}
|
|
19
|
-
|
|
20
|
-
return fontData;
|
|
21
|
-
}
|
|
8
|
+
let cachedLogo: string | null | undefined;
|
|
22
9
|
|
|
23
10
|
export default defineHandler(async event => {
|
|
24
11
|
const config = loadConfig();
|
|
@@ -26,7 +13,12 @@ export default defineHandler(async event => {
|
|
|
26
13
|
const description = event.url.searchParams.get('description') ?? '';
|
|
27
14
|
const siteName = config.site.title;
|
|
28
15
|
|
|
29
|
-
|
|
16
|
+
if (!fontData) fontData = await loadFont(__CHRONICLE_PACKAGE_ROOT__);
|
|
17
|
+
if (cachedLogo === undefined) {
|
|
18
|
+
cachedLogo = config.logo?.dark
|
|
19
|
+
? await loadLogo(__CHRONICLE_PROJECT_ROOT__, config.logo.dark)
|
|
20
|
+
: null;
|
|
21
|
+
}
|
|
30
22
|
|
|
31
23
|
const svg = await satori(
|
|
32
24
|
<div
|
|
@@ -41,8 +33,18 @@ export default defineHandler(async event => {
|
|
|
41
33
|
color: '#fafafa',
|
|
42
34
|
}}
|
|
43
35
|
>
|
|
44
|
-
<div style={{
|
|
45
|
-
{
|
|
36
|
+
<div style={{ display: 'flex', alignItems: 'center', marginBottom: 16 }}>
|
|
37
|
+
{cachedLogo && (
|
|
38
|
+
<img
|
|
39
|
+
src={cachedLogo}
|
|
40
|
+
width={48}
|
|
41
|
+
height={48}
|
|
42
|
+
style={{ marginRight: 16 }}
|
|
43
|
+
/>
|
|
44
|
+
)}
|
|
45
|
+
<div style={{ fontSize: 32, color: '#888' }}>
|
|
46
|
+
{siteName}
|
|
47
|
+
</div>
|
|
46
48
|
</div>
|
|
47
49
|
<div
|
|
48
50
|
style={{
|
|
@@ -64,7 +66,7 @@ export default defineHandler(async event => {
|
|
|
64
66
|
width: 1200,
|
|
65
67
|
height: 630,
|
|
66
68
|
fonts: [
|
|
67
|
-
{ name: 'Inter', data:
|
|
69
|
+
{ name: 'Inter', data: fontData, weight: 400, style: 'normal' as const },
|
|
68
70
|
],
|
|
69
71
|
},
|
|
70
72
|
);
|
|
@@ -131,6 +131,7 @@ export async function createViteConfig(
|
|
|
131
131
|
define: {
|
|
132
132
|
__CHRONICLE_CONTENT_DIR__: JSON.stringify(contentMirror),
|
|
133
133
|
__CHRONICLE_PROJECT_ROOT__: JSON.stringify(projectRoot),
|
|
134
|
+
__CHRONICLE_PACKAGE_ROOT__: JSON.stringify(packageRoot),
|
|
134
135
|
__CHRONICLE_CONFIG_RAW__: JSON.stringify(rawConfig),
|
|
135
136
|
},
|
|
136
137
|
css: {
|
package/src/types/globals.d.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
// Vite build-time constants (injected via define in vite-config.ts)
|
|
2
2
|
declare const __CHRONICLE_CONTENT_DIR__: string
|
|
3
3
|
declare const __CHRONICLE_PROJECT_ROOT__: string
|
|
4
|
+
declare const __CHRONICLE_PACKAGE_ROOT__: string
|
|
4
5
|
declare const __CHRONICLE_CONFIG_RAW__: string | null
|