@raystack/chronicle 0.5.2 → 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/README.md ADDED
@@ -0,0 +1,98 @@
1
+ # Chronicle
2
+
3
+ Config-driven documentation framework built on Vite, Nitro, and Apsara UI.
4
+
5
+ ## Features
6
+
7
+ - **Config-driven** — Single `chronicle.yaml` for all site configuration
8
+ - **Themeable** — Built-in themes: `default` (sidebar + TOC) and `paper` (book-style)
9
+ - **MDX** — Write docs in MDX with callouts, tabs, mermaid diagrams, and syntax highlighting
10
+ - **API docs** — Interactive OpenAPI documentation with "Try it out" panel
11
+ - **LLMs** — Auto-generate `/llms.txt` and `/llms-full.txt` for AI consumption
12
+ - **CLI** — `init`, `dev`, `build`, `start`, `serve` commands
13
+
14
+ ## Quick Start
15
+
16
+ ### Install
17
+
18
+ ```bash
19
+ npm install -g @raystack/chronicle
20
+ ```
21
+
22
+ ### Initialize
23
+
24
+ ```bash
25
+ chronicle init
26
+ ```
27
+
28
+ Creates a `chronicle.yaml` and sample `index.mdx`.
29
+
30
+ ### Develop
31
+
32
+ ```bash
33
+ chronicle dev
34
+ ```
35
+
36
+ Open [http://localhost:3000](http://localhost:3000).
37
+
38
+ ### Build for production
39
+
40
+ ```bash
41
+ chronicle build
42
+ chronicle start
43
+ ```
44
+
45
+ ## Contributing
46
+
47
+ We welcome contributions! Here's how to get started:
48
+
49
+ ### Prerequisites
50
+
51
+ - [Node.js](https://nodejs.org/) >= 22
52
+ - [Bun](https://bun.sh/) >= 1.3
53
+
54
+ ### Running Locally
55
+
56
+ 1. Fork and clone the repository
57
+
58
+ ```bash
59
+ git clone https://github.com/<your-username>/chronicle.git
60
+ cd chronicle
61
+ ```
62
+
63
+ 2. Install dependencies
64
+
65
+ ```bash
66
+ bun install
67
+ ```
68
+
69
+ 3. Build the CLI
70
+
71
+ ```bash
72
+ bun run build:cli
73
+ ```
74
+
75
+ 4. Run the docs site locally
76
+
77
+ ```bash
78
+ bun run dev:docs
79
+ ```
80
+
81
+ Open [http://localhost:3000](http://localhost:3000) to see the docs site.
82
+
83
+ You can also run the CLI directly:
84
+
85
+ ```bash
86
+ ./packages/chronicle/bin/chronicle.js dev --config docs/chronicle.yaml
87
+ ```
88
+
89
+ ### Making Changes
90
+
91
+ 1. Create a branch from `main`
92
+ 2. Make your changes
93
+ 3. Test locally from the `docs/` directory
94
+ 4. Open a pull request
95
+
96
+ ## License
97
+
98
+ [Apache-2.0](LICENSE)
package/dist/cli/index.js CHANGED
@@ -254,6 +254,11 @@ var analyticsSchema = z.object({
254
254
  enabled: z.boolean().optional(),
255
255
  googleAnalytics: googleAnalyticsSchema.optional()
256
256
  });
257
+ var telemetrySchema = z.object({
258
+ enabled: z.boolean().optional(),
259
+ serviceName: z.string().optional(),
260
+ port: z.number().int().min(1).max(65535).default(9090)
261
+ });
257
262
  var chronicleConfigSchema = z.object({
258
263
  title: z.string(),
259
264
  description: z.string().optional(),
@@ -267,7 +272,8 @@ var chronicleConfigSchema = z.object({
267
272
  footer: footerSchema.optional(),
268
273
  api: z.array(apiSchema).optional(),
269
274
  llms: llmsSchema.optional(),
270
- analytics: analyticsSchema.optional()
275
+ analytics: analyticsSchema.optional(),
276
+ telemetry: telemetrySchema.optional()
271
277
  });
272
278
  // src/cli/utils/config.ts
273
279
  function resolveConfigPath(configPath) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@raystack/chronicle",
3
- "version": "0.5.2",
3
+ "version": "0.5.4",
4
4
  "description": "Config-driven documentation framework",
5
5
  "license": "Apache-2.0",
6
6
  "type": "module",
@@ -36,6 +36,11 @@
36
36
  "@codemirror/theme-one-dark": "^6.1.3",
37
37
  "@codemirror/view": "^6.39.14",
38
38
  "@heroicons/react": "^2.2.0",
39
+ "@opentelemetry/api": "^1.9.1",
40
+ "@opentelemetry/exporter-prometheus": "^0.214.0",
41
+ "@opentelemetry/resources": "^2.6.1",
42
+ "@opentelemetry/sdk-metrics": "^2.6.1",
43
+ "@opentelemetry/semantic-conventions": "^1.40.0",
39
44
  "@raystack/apsara": "0.55.1",
40
45
  "@shikijs/rehype": "^4.0.2",
41
46
  "@vitejs/plugin-react": "^6.0.1",
@@ -44,7 +49,7 @@
44
49
  "codemirror": "^6.0.2",
45
50
  "commander": "^14.0.2",
46
51
  "fumadocs-core": "16.6.15",
47
- "fumadocs-mdx": "^14.2.6",
52
+ "fumadocs-mdx": "14.2.6",
48
53
  "glob": "^11.0.0",
49
54
  "gray-matter": "^4.0.3",
50
55
  "h3": "^2.0.1-rc.16",
@@ -1,10 +1,12 @@
1
1
  import { Link as ApsaraLink } from '@raystack/apsara';
2
- import type { ComponentProps } from 'react';
3
- import { Link as RouterLink } from 'react-router';
2
+ import type { ComponentProps, MouseEvent } from 'react';
3
+ import { useNavigate } from 'react-router';
4
4
 
5
5
  type LinkProps = ComponentProps<'a'>;
6
6
 
7
- export function Link({ href, children, ...props }: LinkProps) {
7
+ export function Link({ href, children, onClick: onClickProp, ...props }: LinkProps) {
8
+ const navigate = useNavigate();
9
+
8
10
  if (!href) {
9
11
  return <span {...props}>{children}</span>;
10
12
  }
@@ -12,14 +14,6 @@ export function Link({ href, children, ...props }: LinkProps) {
12
14
  const isExternal = href.startsWith('http://') || href.startsWith('https://');
13
15
  const isAnchor = href.startsWith('#');
14
16
 
15
- if (isAnchor) {
16
- return (
17
- <ApsaraLink href={href} {...props}>
18
- {children}
19
- </ApsaraLink>
20
- );
21
- }
22
-
23
17
  if (isExternal) {
24
18
  return (
25
19
  <ApsaraLink
@@ -33,9 +27,36 @@ export function Link({ href, children, ...props }: LinkProps) {
33
27
  );
34
28
  }
35
29
 
30
+ if (isAnchor) {
31
+ return (
32
+ <ApsaraLink href={href} {...props}>
33
+ {children}
34
+ </ApsaraLink>
35
+ );
36
+ }
37
+
38
+ const onClick = (e: MouseEvent<HTMLAnchorElement>) => {
39
+ if (
40
+ e.defaultPrevented ||
41
+ e.button !== 0 ||
42
+ e.metaKey ||
43
+ e.ctrlKey ||
44
+ e.shiftKey ||
45
+ e.altKey
46
+ ) {
47
+ return;
48
+ }
49
+
50
+ onClickProp?.(e);
51
+ if (e.defaultPrevented) return;
52
+
53
+ e.preventDefault();
54
+ navigate(href);
55
+ };
56
+
36
57
  return (
37
- <RouterLink to={href} className={props.className}>
58
+ <ApsaraLink href={href} {...props} onClick={onClick}>
38
59
  {children}
39
- </RouterLink>
60
+ </ApsaraLink>
40
61
  );
41
62
  }
@@ -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);
@@ -30,7 +33,7 @@ export function DocsPage({ slug }: DocsPageProps) {
30
33
  />
31
34
  <Page
32
35
  page={{
33
- slug,
36
+ slug: page.slug,
34
37
  frontmatter: page.frontmatter,
35
38
  content: page.content,
36
39
  toc: page.toc
@@ -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,6 +9,7 @@ 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
14
 
14
15
  import clientAssets from './entry-client?assets=client';
@@ -57,6 +58,7 @@ export default {
57
58
 
58
59
  const assets = clientAssets.merge(serverAssets);
59
60
 
61
+ const renderStart = performance.now();
60
62
  const stream = await renderToReadableStream(
61
63
  <html lang="en">
62
64
  <head>
@@ -91,9 +93,13 @@ export default {
91
93
  </html>,
92
94
  );
93
95
 
96
+ const renderDuration = performance.now() - renderStart;
97
+
94
98
  const isApiRoute = pathname.startsWith('/apis');
95
99
  const status = !page && !isApiRoute && slug.length > 0 ? 404 : 200;
96
100
 
101
+ useNitroApp().hooks.callHook('chronicle:ssr-rendered', pathname, status, renderDuration);
102
+
97
103
  return new Response(stream, {
98
104
  status,
99
105
  headers: { 'Content-Type': 'text/html;charset=utf-8' },
@@ -0,0 +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'
7
+ import { definePlugin } from 'nitro'
8
+ import { loadConfig } from '@/lib/config'
9
+
10
+ declare module 'nitro/types' {
11
+ interface NitroRuntimeHooks {
12
+ 'chronicle:ssr-rendered': (route: string, status: number, durationMs: number) => void
13
+ }
14
+ }
15
+
16
+ export default definePlugin((nitroApp) => {
17
+ const config = loadConfig()
18
+ if (!config.telemetry?.enabled) return
19
+
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
+ })
47
+
48
+ nitroApp.hooks.hook('request', (event) => {
49
+ (event as H3Event).context._requestStart = performance.now()
50
+ })
51
+
52
+ nitroApp.hooks.hook('response', (res, event) => {
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 })
60
+ })
61
+ })
@@ -0,0 +1,45 @@
1
+ import fs from 'node:fs/promises';
2
+ import matter from 'gray-matter';
3
+ import { defineHandler, HTTPError } from 'nitro';
4
+ import { loadConfig } from '@/lib/config';
5
+ import { getPage, getOriginalPath } from '@/lib/source';
6
+ import { safePath } from '@/server/utils/safe-path';
7
+
8
+ export default defineHandler(async event => {
9
+ const pathname = event.path || event.req.url?.split('?')[0] || '';
10
+ if (!pathname.endsWith('.md')) return;
11
+
12
+ const config = loadConfig();
13
+ if (!config.llms?.enabled) {
14
+ throw new HTTPError({ status: 404, message: 'Not Found' });
15
+ }
16
+
17
+ const stripped = pathname.replace(/\.md$/, '');
18
+ const parts = stripped === '/index' || stripped === '/'
19
+ ? []
20
+ : stripped.slice(1).split('/').filter(Boolean);
21
+ const page = await getPage(parts);
22
+
23
+ if (!page) {
24
+ throw new HTTPError({ status: 404, message: 'Not Found' });
25
+ }
26
+
27
+ const originalPath = getOriginalPath(page);
28
+ if (!originalPath) {
29
+ throw new HTTPError({ status: 404, message: 'Not Found' });
30
+ }
31
+
32
+ const contentDir = __CHRONICLE_CONTENT_DIR__;
33
+ const filePath = safePath(contentDir, '/' + originalPath);
34
+ if (!filePath) {
35
+ throw new HTTPError({ status: 404, message: 'Not Found' });
36
+ }
37
+
38
+ const raw = await fs.readFile(filePath, 'utf-8').catch(() => null);
39
+ if (!raw) {
40
+ throw new HTTPError({ status: 404, message: 'Not Found' });
41
+ }
42
+
43
+ event.res.headers.set('Content-Type', 'text/markdown; charset=utf-8');
44
+ return matter(raw).content;
45
+ });
@@ -12,7 +12,8 @@ export default defineHandler(async event => {
12
12
  const pages = await getPages();
13
13
  const index = pages.map(p => {
14
14
  const fm = extractFrontmatter(p);
15
- return `- [${fm.title}](${p.url})`;
15
+ const mdUrl = p.url === '/' ? '/index.md' : `${p.url}.md`;
16
+ return `- [${fm.title}](${mdUrl})`;
16
17
  }).join('\n');
17
18
  const body = `# ${config.title}\n\n${config.description ?? ''}\n\n${index}`;
18
19
 
@@ -67,6 +67,12 @@ const analyticsSchema = z.object({
67
67
  googleAnalytics: googleAnalyticsSchema.optional(),
68
68
  })
69
69
 
70
+ const telemetrySchema = z.object({
71
+ enabled: z.boolean().optional(),
72
+ serviceName: z.string().optional(),
73
+ port: z.number().int().min(1).max(65535).default(9090),
74
+ })
75
+
70
76
  export const chronicleConfigSchema = z.object({
71
77
  title: z.string(),
72
78
  description: z.string().optional(),
@@ -81,6 +87,7 @@ export const chronicleConfigSchema = z.object({
81
87
  api: z.array(apiSchema).optional(),
82
88
  llms: llmsSchema.optional(),
83
89
  analytics: analyticsSchema.optional(),
90
+ telemetry: telemetrySchema.optional(),
84
91
  })
85
92
 
86
93
  export type ChronicleConfig = z.infer<typeof chronicleConfigSchema>
@@ -97,3 +104,4 @@ export type FooterConfig = z.infer<typeof footerSchema>
97
104
  export type LlmsConfig = z.infer<typeof llmsSchema>
98
105
  export type AnalyticsConfig = z.infer<typeof analyticsSchema>
99
106
  export type GoogleAnalyticsConfig = z.infer<typeof googleAnalyticsSchema>
107
+ export type TelemetryConfig = z.infer<typeof telemetrySchema>