@raystack/chronicle 0.5.3 → 0.5.4

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
@@ -256,7 +256,8 @@ var analyticsSchema = z.object({
256
256
  });
257
257
  var telemetrySchema = z.object({
258
258
  enabled: z.boolean().optional(),
259
- serviceName: z.string().optional()
259
+ serviceName: z.string().optional(),
260
+ port: z.number().int().min(1).max(65535).default(9090)
260
261
  });
261
262
  var chronicleConfigSchema = z.object({
262
263
  title: z.string(),
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@raystack/chronicle",
3
- "version": "0.5.3",
3
+ "version": "0.5.4",
4
4
  "description": "Config-driven documentation framework",
5
5
  "license": "Apache-2.0",
6
6
  "type": "module",
@@ -49,7 +49,7 @@
49
49
  "codemirror": "^6.0.2",
50
50
  "commander": "^14.0.2",
51
51
  "fumadocs-core": "16.6.15",
52
- "fumadocs-mdx": "^14.2.6",
52
+ "fumadocs-mdx": "14.2.6",
53
53
  "glob": "^11.0.0",
54
54
  "gray-matter": "^4.0.3",
55
55
  "h3": "^2.0.1-rc.16",
@@ -22,6 +22,7 @@ interface PageContextValue {
22
22
  config: ChronicleConfig;
23
23
  tree: Root;
24
24
  page: PageData | null;
25
+ errorStatus: number | null;
25
26
  apiSpecs: ApiSpec[];
26
27
  }
27
28
 
@@ -35,6 +36,7 @@ export function usePageContext(): PageContextValue {
35
36
  config: { title: 'Documentation' },
36
37
  tree: { name: 'root', children: [] } as Root,
37
38
  page: null,
39
+ errorStatus: null,
38
40
  apiSpecs: []
39
41
  };
40
42
  }
@@ -50,6 +52,16 @@ interface PageProviderProps {
50
52
  children: ReactNode;
51
53
  }
52
54
 
55
+ function isApisRoute(pathname: string): boolean {
56
+ return pathname === '/apis' || pathname.startsWith('/apis/');
57
+ }
58
+
59
+ function getInitialErrorStatus(page: PageData | null, pathname: string): number | null {
60
+ if (page) return null;
61
+ if (pathname === '/' || isApisRoute(pathname)) return null;
62
+ return 404;
63
+ }
64
+
53
65
  export function PageProvider({
54
66
  initialConfig,
55
67
  initialTree,
@@ -61,6 +73,7 @@ export function PageProvider({
61
73
  const { pathname } = useLocation();
62
74
  const [tree] = useState<Root>(initialTree);
63
75
  const [page, setPage] = useState<PageData | null>(initialPage);
76
+ const [errorStatus, setErrorStatus] = useState<number | null>(getInitialErrorStatus(initialPage, pathname));
64
77
  const [apiSpecs, setApiSpecs] = useState<ApiSpec[]>(initialApiSpecs);
65
78
  const [currentPath, setCurrentPath] = useState(pathname);
66
79
 
@@ -70,7 +83,7 @@ export function PageProvider({
70
83
 
71
84
  const cancelled = { current: false };
72
85
 
73
- if (pathname.startsWith('/apis')) {
86
+ if (isApisRoute(pathname)) {
74
87
  if (apiSpecs.length === 0) {
75
88
  fetch('/api/specs')
76
89
  .then(res => res.json())
@@ -86,24 +99,39 @@ export function PageProvider({
86
99
  ? []
87
100
  : pathname.slice(1).split('/').filter(Boolean);
88
101
 
89
- const apiPath = slug.length === 0 ? '/api/page/' : `/api/page/${slug.join('/')}`;
102
+ const apiPath = slug.length === 0 ? '/api/page' : `/api/page?slug=${slug.join(',')}`;
90
103
 
91
104
  fetch(apiPath)
92
- .then(res => res.json())
93
- .then(async (data: { frontmatter: Frontmatter; relativePath: string; originalPath?: string }) => {
94
- if (cancelled.current) return;
105
+ .then(res => {
106
+ if (!res.ok) {
107
+ if (!cancelled.current) {
108
+ setPage(null);
109
+ setErrorStatus(res.status);
110
+ }
111
+ return;
112
+ }
113
+ return res.json();
114
+ })
115
+ .then(async (data: { frontmatter: Frontmatter; relativePath: string; originalPath?: string } | undefined) => {
116
+ if (cancelled.current || !data) return;
95
117
  const { content, toc } = await loadMdx(data.originalPath || data.relativePath);
96
118
  if (cancelled.current) return;
119
+ setErrorStatus(null);
97
120
  setPage({ slug, frontmatter: data.frontmatter, content, toc });
98
121
  })
99
- .catch(() => {});
122
+ .catch(() => {
123
+ if (!cancelled.current) {
124
+ setPage(null);
125
+ setErrorStatus(500);
126
+ }
127
+ });
100
128
 
101
129
  return () => { cancelled.current = true; };
102
130
  }, [pathname]);
103
131
 
104
132
  return (
105
133
  <PageContext.Provider
106
- value={{ config: initialConfig, tree, page, apiSpecs }}
134
+ value={{ config: initialConfig, tree, page, errorStatus, apiSpecs }}
107
135
  >
108
136
  {children}
109
137
  </PageContext.Provider>
@@ -1,5 +1,6 @@
1
1
  import { Head } from '@/lib/head';
2
2
  import { usePageContext } from '@/lib/page-context';
3
+ import { NotFound } from '@/pages/NotFound';
3
4
  import { getTheme } from '@/themes/registry';
4
5
 
5
6
  interface DocsPageProps {
@@ -7,8 +8,10 @@ interface DocsPageProps {
7
8
  }
8
9
 
9
10
  export function DocsPage({ slug }: DocsPageProps) {
10
- const { config, tree, page } = usePageContext();
11
+ const { config, tree, page, errorStatus } = usePageContext();
11
12
 
13
+ if (errorStatus === 404) return <NotFound />;
14
+ if (errorStatus) return <NotFound />;
12
15
  if (!page) return null;
13
16
 
14
17
  const { Page } = getTheme(config.theme?.name);
@@ -0,0 +1,3 @@
1
+ .emptyState {
2
+ justify-content: center;
3
+ }
@@ -1,17 +1,12 @@
1
- import { Flex, Headline, Text } from '@raystack/apsara';
1
+ import { EmptyState } from '@raystack/apsara';
2
+ import styles from './NotFound.module.css';
2
3
 
3
4
  export function NotFound() {
4
5
  return (
5
- <Flex
6
- direction='column'
7
- align='center'
8
- justify='center'
9
- style={{ minHeight: '60vh' }}
10
- >
11
- <Headline size='large' as='h1'>
12
- 404
13
- </Headline>
14
- <Text size={3}>Page not found</Text>
15
- </Flex>
6
+ <EmptyState
7
+ heading="404"
8
+ subHeading="Page not found"
9
+ classNames={{ container: styles.emptyState }}
10
+ />
16
11
  );
17
12
  }
@@ -2,8 +2,8 @@ import { defineHandler, HTTPError } from 'nitro';
2
2
  import { getPage, extractFrontmatter, getRelativePath, getOriginalPath } from '@/lib/source';
3
3
 
4
4
  export default defineHandler(async event => {
5
- const slugParam = event.context.params?.slug ?? '';
6
- const slug = slugParam ? slugParam.split('/') : [];
5
+ const slugParam = event.url.searchParams.get('slug') ?? '';
6
+ const slug = slugParam ? slugParam.split(',').filter(Boolean) : [];
7
7
  const page = await getPage(slug);
8
8
 
9
9
  if (!page) {
@@ -9,8 +9,8 @@ import { loadConfig } from '@/lib/config';
9
9
  import { loadApiSpecs } from '@/lib/openapi';
10
10
  import { PageProvider } from '@/lib/page-context';
11
11
  import { getPageTree, getPage, loadPageModule, extractFrontmatter, getRelativePath, getOriginalPath } from '@/lib/source';
12
+ import { useNitroApp } from 'nitro/app';
12
13
  import { App } from './App';
13
- import { recordSSRRender } from './telemetry';
14
14
 
15
15
  import clientAssets from './entry-client?assets=client';
16
16
  import serverAssets from './entry-server?assets=ssr';
@@ -98,7 +98,7 @@ export default {
98
98
  const isApiRoute = pathname.startsWith('/apis');
99
99
  const status = !page && !isApiRoute && slug.length > 0 ? 404 : 200;
100
100
 
101
- recordSSRRender(pathname, status, renderDuration);
101
+ useNitroApp().hooks.callHook('chronicle:ssr-rendered', pathname, status, renderDuration);
102
102
 
103
103
  return new Response(stream, {
104
104
  status,
@@ -1,21 +1,61 @@
1
+ import type { Counter, Histogram } from '@opentelemetry/api'
2
+ import { MeterProvider } from '@opentelemetry/sdk-metrics'
3
+ import { PrometheusExporter } from '@opentelemetry/exporter-prometheus'
4
+ import { resourceFromAttributes } from '@opentelemetry/resources'
5
+ import { ATTR_SERVICE_NAME } from '@opentelemetry/semantic-conventions'
6
+ import type { H3Event } from 'h3'
1
7
  import { definePlugin } from 'nitro'
2
8
  import { loadConfig } from '@/lib/config'
3
- import { initTelemetry, recordRequest } from '../telemetry'
9
+
10
+ declare module 'nitro/types' {
11
+ interface NitroRuntimeHooks {
12
+ 'chronicle:ssr-rendered': (route: string, status: number, durationMs: number) => void
13
+ }
14
+ }
4
15
 
5
16
  export default definePlugin((nitroApp) => {
6
17
  const config = loadConfig()
7
18
  if (!config.telemetry?.enabled) return
8
19
 
9
- initTelemetry(config)
20
+ const resource = resourceFromAttributes({
21
+ [ATTR_SERVICE_NAME]: config.telemetry?.serviceName ?? 'chronicle',
22
+ })
23
+
24
+ const port = config.telemetry?.port ?? 9090
25
+ const exporter = new PrometheusExporter({ port })
26
+ const provider = new MeterProvider({ resource, readers: [exporter] })
27
+ const meter = provider.getMeter('chronicle')
28
+
29
+ const requestCounter: Counter = meter.createCounter('http_server_request_total', {
30
+ description: 'Total HTTP requests',
31
+ })
32
+ const requestDuration: Histogram = meter.createHistogram('http_server_request_duration_ms', {
33
+ description: 'HTTP request duration in ms',
34
+ })
35
+ const ssrRenderDuration: Histogram = meter.createHistogram('http_server_ssr_render_duration_ms', {
36
+ description: 'SSR render duration in ms',
37
+ })
38
+
39
+ nitroApp.hooks.hook('close', async () => {
40
+ await provider.shutdown()
41
+ await exporter.shutdown()
42
+ })
43
+
44
+ nitroApp.hooks.hook('chronicle:ssr-rendered', (route, status, durationMs) => {
45
+ ssrRenderDuration.record(durationMs, { route, status })
46
+ })
10
47
 
11
48
  nitroApp.hooks.hook('request', (event) => {
12
- if (event.path === '/api/metrics') return
13
- event.context._requestStart = performance.now()
49
+ (event as H3Event).context._requestStart = performance.now()
14
50
  })
15
51
 
16
52
  nitroApp.hooks.hook('response', (res, event) => {
17
- if (!event.context._requestStart) return
18
- const duration = performance.now() - event.context._requestStart
19
- recordRequest(event.method, event.path, res.status, duration)
53
+ const start = (event as H3Event).context._requestStart as number | undefined
54
+ if (start === undefined) return
55
+ const duration = performance.now() - start
56
+ const method = event.req.method
57
+ 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 })
20
60
  })
21
61
  })
@@ -70,6 +70,7 @@ const analyticsSchema = z.object({
70
70
  const telemetrySchema = z.object({
71
71
  enabled: z.boolean().optional(),
72
72
  serviceName: z.string().optional(),
73
+ port: z.number().int().min(1).max(65535).default(9090),
73
74
  })
74
75
 
75
76
  export const chronicleConfigSchema = z.object({
@@ -1,23 +0,0 @@
1
- import type { IncomingMessage, ServerResponse } from 'node:http'
2
- import { defineHandler } from 'nitro'
3
- import { getExporter } from '../telemetry'
4
-
5
- export default defineHandler(async () => {
6
- const exporter = getExporter()
7
- if (!exporter) {
8
- return new Response('Telemetry not enabled', { status: 404 })
9
- }
10
-
11
- const metricsString = await new Promise<string>((resolve) => {
12
- const mockRes = {
13
- setHeader: () => mockRes,
14
- end: (data: string) => resolve(data),
15
- } as unknown as ServerResponse
16
-
17
- exporter.getMetricsRequestHandler({} as unknown as IncomingMessage, mockRes)
18
- })
19
-
20
- return new Response(metricsString, {
21
- headers: { 'Content-Type': 'text/plain; charset=utf-8' },
22
- })
23
- })
@@ -1 +0,0 @@
1
- export { default } from './[...slug]';
@@ -1,49 +0,0 @@
1
- import type { Counter, Histogram } from '@opentelemetry/api'
2
- import sdkMetrics from '@opentelemetry/sdk-metrics'
3
- import prometheusExporter from '@opentelemetry/exporter-prometheus'
4
- import resources from '@opentelemetry/resources'
5
- import semconv from '@opentelemetry/semantic-conventions'
6
- import type { ChronicleConfig } from '@/types/config'
7
-
8
- const { MeterProvider } = sdkMetrics
9
- const { PrometheusExporter } = prometheusExporter
10
- const { resourceFromAttributes } = resources
11
- const { ATTR_SERVICE_NAME } = semconv
12
-
13
- let exporter: PrometheusExporter
14
- let requestCounter: Counter
15
- let requestDuration: Histogram
16
- let ssrRenderDuration: Histogram
17
-
18
- export function initTelemetry(config: ChronicleConfig) {
19
- const resource = resourceFromAttributes({
20
- [ATTR_SERVICE_NAME]: config.telemetry?.serviceName ?? 'chronicle',
21
- })
22
-
23
- exporter = new PrometheusExporter({ preventServerStart: true })
24
- const provider = new MeterProvider({ resource, readers: [exporter] })
25
- const meter = provider.getMeter('chronicle')
26
-
27
- requestCounter = meter.createCounter('http_server_request_total', {
28
- description: 'Total HTTP requests',
29
- })
30
- requestDuration = meter.createHistogram('http_server_request_duration_ms', {
31
- description: 'HTTP request duration in ms',
32
- })
33
- ssrRenderDuration = meter.createHistogram('http_server_ssr_render_duration_ms', {
34
- description: 'SSR render duration in ms',
35
- })
36
- }
37
-
38
- export function getExporter() {
39
- return exporter
40
- }
41
-
42
- export function recordRequest(method: string, route: string, status: number, durationMs: number) {
43
- requestCounter?.add(1, { method, route, status })
44
- requestDuration?.record(durationMs, { method, route, status })
45
- }
46
-
47
- export function recordSSRRender(route: string, status: number, durationMs: number) {
48
- ssrRenderDuration?.record(durationMs, { route, status })
49
- }