@raystack/chronicle 0.5.1 → 0.5.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/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,10 @@ 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
+ });
257
261
  var chronicleConfigSchema = z.object({
258
262
  title: z.string(),
259
263
  description: z.string().optional(),
@@ -267,7 +271,8 @@ var chronicleConfigSchema = z.object({
267
271
  footer: footerSchema.optional(),
268
272
  api: z.array(apiSchema).optional(),
269
273
  llms: llmsSchema.optional(),
270
- analytics: analyticsSchema.optional()
274
+ analytics: analyticsSchema.optional(),
275
+ telemetry: telemetrySchema.optional()
271
276
  });
272
277
  // src/cli/utils/config.ts
273
278
  function resolveConfigPath(configPath) {
@@ -365,7 +370,7 @@ var buildCommand = new Command("build").description("Build for production").opti
365
370
  // src/cli/commands/dev.ts
366
371
  import chalk3 from "chalk";
367
372
  import { Command as Command2 } from "commander";
368
- var devCommand = new Command2("dev").description("Start development server").option("-p, --port <port>", "Port number", "3000").option("--content <path>", "Content directory").option("--config <path>", "Path to chronicle.yaml").action(async (options) => {
373
+ var devCommand = new Command2("dev").description("Start development server").option("-p, --port <port>", "Port number", "3000").option("--content <path>", "Content directory").option("--config <path>", "Path to chronicle.yaml").option("--host <host>", "Host address", "localhost").action(async (options) => {
369
374
  const { contentDir, configPath } = await loadCLIConfig(options.config, { content: options.content });
370
375
  const port = parseInt(options.port, 10);
371
376
  await linkContent(contentDir);
@@ -375,7 +380,7 @@ var devCommand = new Command2("dev").description("Start development server").opt
375
380
  const config2 = await createViteConfig2({ packageRoot: PACKAGE_ROOT, projectRoot: process.cwd(), contentDir, configPath });
376
381
  const server = await createServer({
377
382
  ...config2,
378
- server: { ...config2.server, port }
383
+ server: { ...config2.server, port, host: options.host }
379
384
  });
380
385
  await server.listen();
381
386
  server.printUrls();
@@ -450,7 +455,7 @@ Run`, chalk4.cyan("chronicle dev"), "to start development server");
450
455
  // src/cli/commands/serve.ts
451
456
  import chalk5 from "chalk";
452
457
  import { Command as Command4 } from "commander";
453
- var serveCommand = new Command4("serve").description("Build and start production server").option("-p, --port <port>", "Port number", "3000").option("--content <path>", "Content directory").option("--config <path>", "Path to chronicle.yaml").option("--preset <preset>", "Deploy preset (vercel, cloudflare, node-server)").action(async (options) => {
458
+ var serveCommand = new Command4("serve").description("Build and start production server").option("-p, --port <port>", "Port number", "3000").option("--content <path>", "Content directory").option("--config <path>", "Path to chronicle.yaml").option("--host <host>", "Host address", "localhost").option("--preset <preset>", "Deploy preset (vercel, cloudflare, node-server)").action(async (options) => {
454
459
  const { contentDir, configPath, preset } = await loadCLIConfig(options.config, {
455
460
  content: options.content,
456
461
  preset: options.preset
@@ -471,7 +476,7 @@ var serveCommand = new Command4("serve").description("Build and start production
471
476
  console.log(chalk5.cyan("Starting production server..."));
472
477
  const server = await preview({
473
478
  ...config2,
474
- preview: { port }
479
+ preview: { port, host: options.host }
475
480
  });
476
481
  server.printUrls();
477
482
  });
@@ -479,7 +484,7 @@ var serveCommand = new Command4("serve").description("Build and start production
479
484
  // src/cli/commands/start.ts
480
485
  import chalk6 from "chalk";
481
486
  import { Command as Command5 } from "commander";
482
- var startCommand = new Command5("start").description("Start production server").option("-p, --port <port>", "Port number", "3000").option("--content <path>", "Content directory").action(async (options) => {
487
+ var startCommand = new Command5("start").description("Start production server").option("-p, --port <port>", "Port number", "3000").option("--content <path>", "Content directory").option("--host <host>", "Host address", "localhost").action(async (options) => {
483
488
  const { contentDir, configPath } = await loadCLIConfig(undefined, { content: options.content });
484
489
  const port = parseInt(options.port, 10);
485
490
  await linkContent(contentDir);
@@ -489,7 +494,7 @@ var startCommand = new Command5("start").description("Start production server").
489
494
  const config2 = await createViteConfig2({ packageRoot: PACKAGE_ROOT, projectRoot: process.cwd(), contentDir, configPath });
490
495
  const server = await preview({
491
496
  ...config2,
492
- preview: { port }
497
+ preview: { port, host: options.host }
493
498
  });
494
499
  server.printUrls();
495
500
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@raystack/chronicle",
3
- "version": "0.5.1",
3
+ "version": "0.5.3",
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",
@@ -9,6 +9,7 @@ export const devCommand = new Command('dev')
9
9
  .option('-p, --port <port>', 'Port number', '3000')
10
10
  .option('--content <path>', 'Content directory')
11
11
  .option('--config <path>', 'Path to chronicle.yaml')
12
+ .option('--host <host>', 'Host address', 'localhost')
12
13
  .action(async options => {
13
14
  const { contentDir, configPath } = await loadCLIConfig(options.config, { content: options.content });
14
15
  const port = parseInt(options.port, 10);
@@ -23,7 +24,7 @@ export const devCommand = new Command('dev')
23
24
  const config = await createViteConfig({ packageRoot: PACKAGE_ROOT, projectRoot: process.cwd(), contentDir, configPath });
24
25
  const server = await createServer({
25
26
  ...config,
26
- server: { ...config.server, port }
27
+ server: { ...config.server, port, host: options.host }
27
28
  });
28
29
 
29
30
  await server.listen();
@@ -9,6 +9,7 @@ export const serveCommand = new Command('serve')
9
9
  .option('-p, --port <port>', 'Port number', '3000')
10
10
  .option('--content <path>', 'Content directory')
11
11
  .option('--config <path>', 'Path to chronicle.yaml')
12
+ .option('--host <host>', 'Host address', 'localhost')
12
13
  .option(
13
14
  '--preset <preset>',
14
15
  'Deploy preset (vercel, cloudflare, node-server)'
@@ -38,7 +39,7 @@ export const serveCommand = new Command('serve')
38
39
  console.log(chalk.cyan('Starting production server...'));
39
40
  const server = await preview({
40
41
  ...config,
41
- preview: { port }
42
+ preview: { port, host: options.host }
42
43
  });
43
44
 
44
45
  server.printUrls();
@@ -8,6 +8,7 @@ export const startCommand = new Command('start')
8
8
  .description('Start production server')
9
9
  .option('-p, --port <port>', 'Port number', '3000')
10
10
  .option('--content <path>', 'Content directory')
11
+ .option('--host <host>', 'Host address', 'localhost')
11
12
  .action(async options => {
12
13
  const { contentDir, configPath } = await loadCLIConfig(undefined, { content: options.content });
13
14
  const port = parseInt(options.port, 10);
@@ -21,7 +22,7 @@ export const startCommand = new Command('start')
21
22
  const config = await createViteConfig({ packageRoot: PACKAGE_ROOT, projectRoot: process.cwd(), contentDir, configPath });
22
23
  const server = await preview({
23
24
  ...config,
24
- preview: { port }
25
+ preview: { port, host: options.host }
25
26
  });
26
27
 
27
28
  server.printUrls();
@@ -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
  }
@@ -30,7 +30,7 @@ export function DocsPage({ slug }: DocsPageProps) {
30
30
  />
31
31
  <Page
32
32
  page={{
33
- slug,
33
+ slug: page.slug,
34
34
  frontmatter: page.frontmatter,
35
35
  content: page.content,
36
36
  toc: page.toc
@@ -0,0 +1,23 @@
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
+ })
@@ -0,0 +1 @@
1
+ export { default } from './[...slug]';
@@ -10,6 +10,7 @@ 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
12
  import { App } from './App';
13
+ import { recordSSRRender } from './telemetry';
13
14
 
14
15
  import clientAssets from './entry-client?assets=client';
15
16
  import serverAssets from './entry-server?assets=ssr';
@@ -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
+ recordSSRRender(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,21 @@
1
+ import { definePlugin } from 'nitro'
2
+ import { loadConfig } from '@/lib/config'
3
+ import { initTelemetry, recordRequest } from '../telemetry'
4
+
5
+ export default definePlugin((nitroApp) => {
6
+ const config = loadConfig()
7
+ if (!config.telemetry?.enabled) return
8
+
9
+ initTelemetry(config)
10
+
11
+ nitroApp.hooks.hook('request', (event) => {
12
+ if (event.path === '/api/metrics') return
13
+ event.context._requestStart = performance.now()
14
+ })
15
+
16
+ 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)
20
+ })
21
+ })
@@ -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
 
@@ -0,0 +1,49 @@
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
+ }
@@ -67,6 +67,11 @@ 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
+ })
74
+
70
75
  export const chronicleConfigSchema = z.object({
71
76
  title: z.string(),
72
77
  description: z.string().optional(),
@@ -81,6 +86,7 @@ export const chronicleConfigSchema = z.object({
81
86
  api: z.array(apiSchema).optional(),
82
87
  llms: llmsSchema.optional(),
83
88
  analytics: analyticsSchema.optional(),
89
+ telemetry: telemetrySchema.optional(),
84
90
  })
85
91
 
86
92
  export type ChronicleConfig = z.infer<typeof chronicleConfigSchema>
@@ -97,3 +103,4 @@ export type FooterConfig = z.infer<typeof footerSchema>
97
103
  export type LlmsConfig = z.infer<typeof llmsSchema>
98
104
  export type AnalyticsConfig = z.infer<typeof analyticsSchema>
99
105
  export type GoogleAnalyticsConfig = z.infer<typeof googleAnalyticsSchema>
106
+ export type TelemetryConfig = z.infer<typeof telemetrySchema>