@raystack/chronicle 0.12.1 → 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 CHANGED
@@ -907,6 +907,9 @@ var devCommand = new Command2("dev").description("Start development server").opt
907
907
  const { config: config2, projectRoot, configPath } = await loadCLIConfig(options.config);
908
908
  const port = parseInt(options.port, 10);
909
909
  await linkContent(projectRoot, config2);
910
+ if (process.platform === "win32" && !process.env.NITRO_DEV_RUNNER) {
911
+ process.env.NITRO_DEV_RUNNER = "node-process";
912
+ }
910
913
  console.log(chalk3.cyan("Starting dev server..."));
911
914
  const { createServer } = await import("vite");
912
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.1",
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",
@@ -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
+ }
@@ -5,3 +5,4 @@ export { ApiResponsePanel } from './api-response-panel'
5
5
  export { PlaygroundDialog } from './playground-dialog'
6
6
  export { MethodBadge } from './method-badge'
7
7
  export { JsonEditor } from './json-editor'
8
+ export { ApiPageSkeleton, ApiFullSkeleton } from './ApiSkeleton'
@@ -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 { getFirstPageUrl, findFolderFirstPage, resolveDocsRedirect } from './tree-utils'
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
+ })
@@ -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
+ }
@@ -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
 
@@ -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);
@@ -1,19 +1,25 @@
1
1
  import { defineHandler, HTTPError } from 'nitro';
2
- import { getPage, getPageNav, extractFrontmatter, getRelativePath, getOriginalPath, getPageImages, isDraft } from '@/lib/source';
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 page = await getPage(slug);
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 (!page || isDraft(page)) {
14
+ if (!resolved) {
10
15
  throw new HTTPError({ status: 404, message: 'Page not found' });
11
16
  }
12
17
 
13
- const nav = await getPageNav(slug);
18
+ const { page, slug: resolvedSlug } = resolved;
19
+ const nav = await getPageNav(resolvedSlug);
14
20
 
15
21
  return Response.json({
16
- frontmatter: extractFrontmatter(page, slug[slug.length - 1]),
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, getPage, getPageNav, loadPageModule, extractFrontmatter, getRelativePath, getOriginalPath, getPageImages, isDraft } from '@/lib/source';
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 [tree, rawPage] = await Promise.all([
50
- getPageTree(),
51
- route.type === RouteType.DocsPage ? getPage(route.slug) : Promise.resolve(null),
52
- ]);
53
- const page = rawPage && isDraft(rawPage) ? null : rawPage;
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
- if (route.type === RouteType.DocsPage && !page) {
64
- const versionPrefix = route.version.urlPrefix;
65
- const slugWithoutVersion = versionPrefix && route.slug[0] === route.version.dir
66
- ? route.slug.slice(1)
67
- : route.slug;
68
- const contentEntries = route.version.dir
69
- ? config.versions?.find(v => v.dir === route.version.dir)?.content ?? config.content
70
- : config.content;
71
- const contentConfig = contentEntries?.find((c: { dir: string }) => c.dir === slugWithoutVersion[0]);
72
- const redirectUrl = resolveDocsRedirect(slugWithoutVersion, tree, contentConfig);
73
- if (redirectUrl) {
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(pageSlug) : { prev: null, next: null };
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: pageSlug,
114
+ slug: resolvedSlug,
89
115
  frontmatter: {
90
- ...extractFrontmatter(page, pageSlug[pageSlug.length - 1]),
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: pageSlug,
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
- requestCounter.add(1, { method, route, status: res.status })
59
- requestDuration.record(duration, { method, route, status: res.status })
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
  })