@raystack/chronicle 0.5.3 → 0.6.0

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.
Files changed (74) hide show
  1. package/dist/cli/index.js +260 -81
  2. package/package.json +8 -6
  3. package/src/cli/commands/build.ts +5 -8
  4. package/src/cli/commands/dev.ts +5 -6
  5. package/src/cli/commands/init.test.ts +77 -0
  6. package/src/cli/commands/init.ts +73 -40
  7. package/src/cli/commands/serve.ts +6 -9
  8. package/src/cli/commands/start.ts +5 -5
  9. package/src/cli/utils/config.ts +6 -12
  10. package/src/cli/utils/scaffold.test.ts +179 -0
  11. package/src/cli/utils/scaffold.ts +70 -9
  12. package/src/components/api/field-row.tsx +1 -1
  13. package/src/components/api/field-section.tsx +2 -2
  14. package/src/components/mdx/index.tsx +1 -1
  15. package/src/components/mdx/mermaid.tsx +24 -21
  16. package/src/components/ui/breadcrumbs.tsx +4 -2
  17. package/src/components/ui/client-theme-switcher.tsx +21 -4
  18. package/src/components/ui/search.module.css +16 -41
  19. package/src/components/ui/search.tsx +30 -41
  20. package/src/lib/config.test.ts +493 -0
  21. package/src/lib/config.ts +123 -22
  22. package/src/lib/head.tsx +23 -5
  23. package/src/lib/llms.test.ts +94 -0
  24. package/src/lib/llms.ts +41 -0
  25. package/src/lib/navigation.test.ts +94 -0
  26. package/src/lib/navigation.ts +51 -0
  27. package/src/lib/page-context.tsx +79 -32
  28. package/src/lib/route-resolver.test.ts +173 -0
  29. package/src/lib/route-resolver.ts +73 -0
  30. package/src/lib/source.ts +94 -1
  31. package/src/lib/version-source.test.ts +163 -0
  32. package/src/lib/version-source.ts +101 -0
  33. package/src/pages/ApiPage.tsx +1 -1
  34. package/src/pages/DocsLayout.tsx +24 -3
  35. package/src/pages/DocsPage.tsx +7 -7
  36. package/src/pages/LandingPage.module.css +56 -0
  37. package/src/pages/LandingPage.tsx +39 -0
  38. package/src/pages/NotFound.module.css +3 -0
  39. package/src/pages/NotFound.tsx +9 -12
  40. package/src/server/App.tsx +21 -23
  41. package/src/server/api/{page/[...slug].ts → page.ts} +7 -3
  42. package/src/server/api/search.ts +51 -24
  43. package/src/server/api/specs.ts +17 -5
  44. package/src/server/entry-client.tsx +42 -14
  45. package/src/server/entry-server.tsx +35 -13
  46. package/src/server/plugins/telemetry.ts +47 -7
  47. package/src/server/routes/[...slug].md.ts +0 -6
  48. package/src/server/routes/[version]/llms.txt.ts +26 -0
  49. package/src/server/routes/llms.txt.ts +10 -13
  50. package/src/server/routes/og.tsx +2 -2
  51. package/src/server/routes/sitemap.xml.ts +14 -6
  52. package/src/server/vite-config.ts +5 -5
  53. package/src/themes/default/ContentDirButtons.tsx +66 -0
  54. package/src/themes/default/Layout.module.css +187 -40
  55. package/src/themes/default/Layout.tsx +166 -65
  56. package/src/themes/default/OpenInAI.tsx +112 -0
  57. package/src/themes/default/Page.module.css +30 -0
  58. package/src/themes/default/Page.tsx +1 -3
  59. package/src/themes/default/SidebarLogo.tsx +26 -0
  60. package/src/themes/default/Toc.module.css +102 -25
  61. package/src/themes/default/Toc.tsx +56 -10
  62. package/src/themes/default/VersionSwitcher.tsx +59 -0
  63. package/src/themes/paper/ContentDirDropdown.tsx +47 -0
  64. package/src/themes/paper/Layout.module.css +7 -0
  65. package/src/themes/paper/Layout.tsx +20 -13
  66. package/src/themes/paper/VersionSwitcher.tsx +60 -0
  67. package/src/types/config.ts +146 -23
  68. package/src/types/content.ts +11 -1
  69. package/src/types/theme.ts +1 -0
  70. package/src/components/ui/footer.module.css +0 -27
  71. package/src/components/ui/footer.tsx +0 -30
  72. package/src/server/api/metrics.ts +0 -23
  73. package/src/server/api/page/index.ts +0 -1
  74. package/src/server/telemetry.ts +0 -49
@@ -1,49 +1,47 @@
1
1
  import '@raystack/apsara/normalize.css';
2
2
  import '@raystack/apsara/style.css';
3
3
  import { ThemeProvider } from '@raystack/apsara';
4
- import { useLocation } from 'react-router';
4
+ import { Navigate, useLocation } from 'react-router';
5
5
  import { Head } from '@/lib/head';
6
6
  import { usePageContext } from '@/lib/page-context';
7
+ import { resolveRoute, RouteType } from '@/lib/route-resolver';
7
8
  import { ApiLayout } from '@/pages/ApiLayout';
8
9
  import { ApiPage } from '@/pages/ApiPage';
9
10
  import { DocsLayout } from '@/pages/DocsLayout';
10
11
  import { DocsPage } from '@/pages/DocsPage';
12
+ import { LandingPage } from '@/pages/LandingPage';
11
13
  import type { ChronicleConfig } from '@/types';
12
14
  import { getThemeConfig } from '@/themes/registry';
13
15
 
14
- function resolveRoute(pathname: string) {
15
- if (pathname.startsWith('/apis')) {
16
- const slug = pathname
17
- .replace(/^\/apis\/?/, '')
18
- .split('/')
19
- .filter(Boolean);
20
- return { type: 'api' as const, slug };
21
- }
22
-
23
- const slug =
24
- pathname === '/' ? [] : pathname.slice(1).split('/').filter(Boolean);
25
- return { type: 'docs' as const, slug };
26
- }
27
-
28
16
  export function App() {
29
17
  const { pathname } = useLocation();
30
18
  const { config } = usePageContext();
31
- const route = resolveRoute(pathname);
19
+ const route = resolveRoute(pathname, config);
32
20
  const themeConfig = getThemeConfig(config.theme?.name);
33
21
 
22
+ if (route.type === RouteType.Redirect) {
23
+ return <Navigate to={route.to} replace />;
24
+ }
25
+
26
+ const isApi =
27
+ route.type === RouteType.ApiIndex || route.type === RouteType.ApiPage;
28
+ const apiSlug = route.type === RouteType.ApiPage ? route.slug : [];
29
+ const docsSlug = route.type === RouteType.DocsPage ? route.slug : [];
30
+ const isLanding = route.type === RouteType.DocsIndex;
31
+
34
32
  return (
35
33
  <ThemeProvider
36
34
  enableSystem={themeConfig.enableSystem}
37
35
  forcedTheme={themeConfig.forcedTheme}
38
36
  >
39
37
  <RootHead config={config} />
40
- {route.type === 'api' ? (
38
+ {isApi ? (
41
39
  <ApiLayout>
42
- <ApiPage slug={route.slug} />
40
+ <ApiPage slug={apiSlug} />
43
41
  </ApiLayout>
44
42
  ) : (
45
43
  <DocsLayout>
46
- <DocsPage slug={route.slug} />
44
+ {isLanding ? <LandingPage /> : <DocsPage slug={docsSlug} />}
47
45
  </DocsLayout>
48
46
  )}
49
47
  </ThemeProvider>
@@ -53,16 +51,16 @@ export function App() {
53
51
  function RootHead({ config }: { config: ChronicleConfig }) {
54
52
  return (
55
53
  <Head
56
- title={config.title}
57
- description={config.description}
54
+ title={config.site.title}
55
+ description={config.site.description}
58
56
  config={config}
59
57
  jsonLd={
60
58
  config.url
61
59
  ? {
62
60
  '@context': 'https://schema.org',
63
61
  '@type': 'WebSite',
64
- name: config.title,
65
- description: config.description,
62
+ name: config.site.title,
63
+ description: config.site.description,
66
64
  url: config.url
67
65
  }
68
66
  : undefined
@@ -1,18 +1,22 @@
1
1
  import { defineHandler, HTTPError } from 'nitro';
2
- import { getPage, extractFrontmatter, getRelativePath, getOriginalPath } from '@/lib/source';
2
+ import { getPage, getPageNav, 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) {
10
10
  throw new HTTPError({ status: 404, message: 'Page not found' });
11
11
  }
12
12
 
13
+ const nav = await getPageNav(slug);
14
+
13
15
  return {
14
16
  frontmatter: extractFrontmatter(page, slug[slug.length - 1]),
15
17
  relativePath: getRelativePath(page),
16
18
  originalPath: getOriginalPath(page),
19
+ prev: nav.prev,
20
+ next: nav.next,
17
21
  };
18
22
  });
@@ -1,10 +1,11 @@
1
1
  import MiniSearch from 'minisearch';
2
- import { defineHandler } from 'nitro';
2
+ import { defineHandler, HTTPError } from 'nitro';
3
3
  import type { OpenAPIV3 } from 'openapi-types';
4
4
  import { getSpecSlug } from '@/lib/api-routes';
5
- import { loadConfig } from '@/lib/config';
5
+ import { getApiConfigsForVersion, loadConfig } from '@/lib/config';
6
6
  import { loadApiSpecs } from '@/lib/openapi';
7
- import { getPages, extractFrontmatter } from '@/lib/source';
7
+ import { extractFrontmatter, getPagesForVersion } from '@/lib/source';
8
+ import { LATEST_CONTEXT, type VersionContext } from '@/lib/version-source';
8
9
 
9
10
  interface SearchDocument {
10
11
  id: string;
@@ -14,8 +15,12 @@ interface SearchDocument {
14
15
  type: 'page' | 'api';
15
16
  }
16
17
 
17
- let searchIndex: MiniSearch<SearchDocument> | null = null;
18
- let cachedDocs: SearchDocument[] | null = null;
18
+ const indexCache = new Map<string, MiniSearch<SearchDocument>>();
19
+ const docsCache = new Map<string, SearchDocument[]>();
20
+
21
+ function keyFor(ctx: VersionContext): string {
22
+ return ctx.dir ?? '__latest__';
23
+ }
19
24
 
20
25
  function createIndex(docs: SearchDocument[]): MiniSearch<SearchDocument> {
21
26
  const index = new MiniSearch<SearchDocument>({
@@ -31,8 +36,8 @@ function createIndex(docs: SearchDocument[]): MiniSearch<SearchDocument> {
31
36
  return index;
32
37
  }
33
38
 
34
- async function scanContent(): Promise<SearchDocument[]> {
35
- const pages = await getPages();
39
+ async function scanContent(ctx: VersionContext): Promise<SearchDocument[]> {
40
+ const pages = await getPagesForVersion(ctx);
36
41
  return pages.map(p => {
37
42
  const fm = extractFrontmatter(p);
38
43
  return {
@@ -45,12 +50,13 @@ async function scanContent(): Promise<SearchDocument[]> {
45
50
  });
46
51
  }
47
52
 
48
- async function buildApiDocs(): Promise<SearchDocument[]> {
53
+ async function buildApiDocs(ctx: VersionContext): Promise<SearchDocument[]> {
49
54
  const config = loadConfig();
50
- if (!config.api?.length) return [];
55
+ const apiConfigs = getApiConfigsForVersion(config, ctx.dir);
56
+ if (!apiConfigs.length) return [];
51
57
 
52
58
  const docs: SearchDocument[] = [];
53
- const specs = await loadApiSpecs(config.api);
59
+ const specs = await loadApiSpecs(apiConfigs);
54
60
 
55
61
  for (const spec of specs) {
56
62
  const specSlug = getSpecSlug(spec);
@@ -60,7 +66,7 @@ async function buildApiDocs(): Promise<SearchDocument[]> {
60
66
  for (const method of ['get', 'post', 'put', 'delete', 'patch'] as const) {
61
67
  const op = pathItem[method] as OpenAPIV3.OperationObject | undefined;
62
68
  if (!op?.operationId) continue;
63
- const url = `/apis/${specSlug}/${encodeURIComponent(op.operationId)}`;
69
+ const url = `${ctx.urlPrefix}/apis/${specSlug}/${encodeURIComponent(op.operationId)}`;
64
70
  docs.push({
65
71
  id: url,
66
72
  url,
@@ -75,29 +81,50 @@ async function buildApiDocs(): Promise<SearchDocument[]> {
75
81
  return docs;
76
82
  }
77
83
 
78
- async function getDocs(): Promise<SearchDocument[]> {
79
- if (cachedDocs) return cachedDocs;
84
+ async function getDocs(ctx: VersionContext): Promise<SearchDocument[]> {
85
+ const key = keyFor(ctx);
86
+ const cached = docsCache.get(key);
87
+ if (cached) return cached;
80
88
  const [contentDocs, apiDocs] = await Promise.all([
81
- scanContent(),
82
- buildApiDocs()
89
+ scanContent(ctx),
90
+ buildApiDocs(ctx)
83
91
  ]);
84
- cachedDocs = [...contentDocs, ...apiDocs];
85
- return cachedDocs;
92
+ const docs = [...contentDocs, ...apiDocs];
93
+ docsCache.set(key, docs);
94
+ return docs;
95
+ }
96
+
97
+ async function getIndex(ctx: VersionContext): Promise<MiniSearch<SearchDocument>> {
98
+ const key = keyFor(ctx);
99
+ const cached = indexCache.get(key);
100
+ if (cached) return cached;
101
+ const docs = await getDocs(ctx);
102
+ const index = createIndex(docs);
103
+ indexCache.set(key, index);
104
+ return index;
86
105
  }
87
106
 
88
- async function getIndex(): Promise<MiniSearch<SearchDocument>> {
89
- if (searchIndex) return searchIndex;
90
- const docs = await getDocs();
91
- searchIndex = createIndex(docs);
92
- return searchIndex;
107
+ function resolveCtx(tag: string | null): VersionContext {
108
+ if (!tag) return LATEST_CONTEXT;
109
+ const config = loadConfig();
110
+ const version = config.versions?.find(v => v.dir === tag);
111
+ if (!version) {
112
+ throw new HTTPError({
113
+ status: 400,
114
+ message: `Unknown version tag: ${tag}`,
115
+ });
116
+ }
117
+ return { dir: version.dir, urlPrefix: `/${version.dir}` };
93
118
  }
94
119
 
95
120
  export default defineHandler(async event => {
96
121
  const query = event.url.searchParams.get('query') ?? '';
97
- const index = await getIndex();
122
+ const tag = event.url.searchParams.get('tag');
123
+ const ctx = resolveCtx(tag);
124
+ const index = await getIndex(ctx);
98
125
 
99
126
  if (!query) {
100
- const docs = await getDocs();
127
+ const docs = await getDocs(ctx);
101
128
  return docs
102
129
  .filter(d => d.type === 'page')
103
130
  .slice(0, 8)
@@ -1,9 +1,21 @@
1
- import { defineHandler } from 'nitro';
2
- import { loadConfig } from '@/lib/config';
1
+ import { defineHandler, HTTPError } from 'nitro';
2
+ import { getApiConfigsForVersion, loadConfig } from '@/lib/config';
3
3
  import { loadApiSpecs } from '@/lib/openapi';
4
4
 
5
- export default defineHandler(async () => {
5
+ export default defineHandler(async event => {
6
+ const versionParam = event.url.searchParams.get('version');
7
+ const versionDir = versionParam === null || versionParam === '' ? null : versionParam;
8
+
6
9
  const config = loadConfig();
7
- const specs = config.api?.length ? await loadApiSpecs(config.api) : [];
8
- return specs;
10
+ if (versionDir && !config.versions?.some(v => v.dir === versionDir)) {
11
+ throw new HTTPError({
12
+ status: 400,
13
+ message: `Unknown version: ${versionDir}`,
14
+ });
15
+ }
16
+
17
+ const apiConfigs = getApiConfigsForVersion(config, versionDir);
18
+ if (!apiConfigs.length) return [];
19
+
20
+ return loadApiSpecs(apiConfigs);
9
21
  });
@@ -4,8 +4,11 @@ import { hydrateRoot } from 'react-dom/client';
4
4
  import { BrowserRouter } from 'react-router';
5
5
  import { ReactRouterProvider } from 'fumadocs-core/framework/react-router';
6
6
  import { mdxComponents } from '@/components/mdx';
7
+ import { getApiConfigsForVersion } from '@/lib/config';
7
8
  import { PageProvider } from '@/lib/page-context';
8
- import type { ChronicleConfig, Frontmatter, Root, TableOfContents } from '@/types';
9
+ import { resolveRoute, RouteType } from '@/lib/route-resolver';
10
+ import { resolveVersionFromUrl, type VersionContext } from '@/lib/version-source';
11
+ import type { ChronicleConfig, Frontmatter, PageNavLink, Root, TableOfContents } from '@/types';
9
12
  import type { ApiSpec } from '@/lib/openapi';
10
13
  import type { ReactNode } from 'react';
11
14
  import { App } from './App';
@@ -14,11 +17,19 @@ interface EmbeddedData {
14
17
  config: ChronicleConfig;
15
18
  tree: Root;
16
19
  slug: string[];
17
- frontmatter: Frontmatter;
18
- relativePath: string;
19
- originalPath?: string;
20
+ version: VersionContext;
21
+ frontmatter: Frontmatter | null;
22
+ relativePath: string | null;
23
+ originalPath: string | null;
24
+ prev: PageNavLink | null;
25
+ next: PageNavLink | null;
20
26
  }
21
27
 
28
+ const defaultConfig: ChronicleConfig = {
29
+ site: { title: 'Documentation' },
30
+ content: [{ dir: 'docs', label: 'Docs' }],
31
+ };
32
+
22
33
  const contentModules = import.meta.glob<{ default?: React.ComponentType<any>; toc?: TableOfContents }>(
23
34
  '../../.content/**/*.{mdx,md}'
24
35
  );
@@ -43,23 +54,39 @@ async function hydrate() {
43
54
  window as unknown as { __PAGE_DATA__?: EmbeddedData }
44
55
  ).__PAGE_DATA__;
45
56
 
46
- const config: ChronicleConfig = embedded?.config ?? {
47
- title: 'Documentation'
48
- };
57
+ const config: ChronicleConfig = embedded?.config ?? defaultConfig;
49
58
  const tree: Root = embedded?.tree ?? { name: 'root', children: [] };
50
- const isApiPage =
51
- window.location.pathname.startsWith('/apis') && !!config.api?.length;
52
- const apiSpecs: ApiSpec[] = isApiPage
53
- ? await fetch('/api/specs')
59
+
60
+ const route = resolveRoute(window.location.pathname, config);
61
+ // resolveVersionFromUrl always returns a valid context — even for redirect
62
+ // targets (e.g. /v1 -> /v1/docs) where route.version isn't on the union.
63
+ const routeVersion: VersionContext = resolveVersionFromUrl(
64
+ window.location.pathname,
65
+ config,
66
+ );
67
+ const version: VersionContext = embedded?.version ?? routeVersion;
68
+
69
+ const isApiRoute =
70
+ route.type === RouteType.ApiIndex || route.type === RouteType.ApiPage;
71
+ const apiConfigs = isApiRoute
72
+ ? getApiConfigsForVersion(config, routeVersion.dir)
73
+ : [];
74
+ const specsUrl = routeVersion.dir
75
+ ? `/api/specs?version=${encodeURIComponent(routeVersion.dir)}`
76
+ : '/api/specs';
77
+ const apiSpecs: ApiSpec[] = apiConfigs.length
78
+ ? await fetch(specsUrl)
54
79
  .then(r => r.json())
55
80
  .catch(() => [])
56
81
  : [];
57
82
 
58
83
  const mdxPath = embedded?.originalPath || embedded?.relativePath;
59
- const page = mdxPath
84
+ const page = embedded && mdxPath && embedded.frontmatter
60
85
  ? {
61
- slug: embedded!.slug,
62
- frontmatter: embedded!.frontmatter,
86
+ slug: embedded.slug,
87
+ frontmatter: embedded.frontmatter,
88
+ prev: embedded.prev,
89
+ next: embedded.next,
63
90
  ...(await loadMdxModule(mdxPath)),
64
91
  }
65
92
  : null;
@@ -73,6 +100,7 @@ async function hydrate() {
73
100
  initialTree={tree}
74
101
  initialPage={page}
75
102
  initialApiSpecs={apiSpecs}
103
+ initialVersion={version}
76
104
  loadMdx={loadMdxModule}
77
105
  >
78
106
  <App />
@@ -5,12 +5,13 @@ import { renderToReadableStream } from 'react-dom/server.edge';
5
5
  import { StaticRouter } from 'react-router';
6
6
  import { ReactRouterProvider } from 'fumadocs-core/framework/react-router';
7
7
  import { mdxComponents } from '@/components/mdx';
8
- import { loadConfig } from '@/lib/config';
8
+ import { getApiConfigsForVersion, loadConfig } from '@/lib/config';
9
9
  import { loadApiSpecs } from '@/lib/openapi';
10
10
  import { PageProvider } from '@/lib/page-context';
11
- import { getPageTree, getPage, loadPageModule, extractFrontmatter, getRelativePath, getOriginalPath } from '@/lib/source';
11
+ import { resolveRoute, RouteType } from '@/lib/route-resolver';
12
+ import { getPageTree, getPage, getPageNav, loadPageModule, extractFrontmatter, getRelativePath, getOriginalPath } from '@/lib/source';
13
+ import { useNitroApp } from 'nitro/app';
12
14
  import { App } from './App';
13
- import { recordSSRRender } from './telemetry';
14
15
 
15
16
  import clientAssets from './entry-client?assets=client';
16
17
  import serverAssets from './entry-server?assets=ssr';
@@ -19,17 +20,32 @@ export default {
19
20
  async fetch(req: Request) {
20
21
  const url = new URL(req.url);
21
22
  const pathname = url.pathname;
22
- const slug = pathname === '/' ? [] : pathname.slice(1).split('/').filter(Boolean);
23
23
 
24
24
  const config = loadConfig();
25
- const apiSpecs = config.api?.length
26
- ? await loadApiSpecs(config.api).catch(() => [])
25
+ const route = resolveRoute(pathname, config);
26
+
27
+ if (route.type === RouteType.Redirect) {
28
+ // biome-ignore lint/correctness/useHookAtTopLevel: useNitroApp is a Nitro DI accessor, not a React hook
29
+ useNitroApp().hooks.callHook('chronicle:ssr-rendered', pathname, route.status, 0);
30
+ return new Response(null, {
31
+ status: route.status,
32
+ headers: { Location: route.to },
33
+ });
34
+ }
35
+
36
+ const isApiRoute = route.type === RouteType.ApiIndex || route.type === RouteType.ApiPage;
37
+ const pageSlug = route.type === RouteType.DocsPage ? route.slug : [];
38
+
39
+ const apiConfigs = isApiRoute
40
+ ? getApiConfigsForVersion(config, route.version.dir)
27
41
  : [];
42
+ const apiSpecs = apiConfigs.length ? await loadApiSpecs(apiConfigs) : [];
28
43
 
29
44
  const [tree, page] = await Promise.all([
30
45
  getPageTree(),
31
- getPage(slug),
46
+ route.type === RouteType.DocsPage ? getPage(route.slug) : Promise.resolve(null),
32
47
  ]);
48
+ const nav = page ? await getPageNav(pageSlug, tree) : { prev: null, next: null };
33
49
 
34
50
  const relativePath = page ? getRelativePath(page) : null;
35
51
  const originalPath = page ? getOriginalPath(page) : null;
@@ -37,22 +53,27 @@ export default {
37
53
 
38
54
  const pageData = page
39
55
  ? {
40
- slug,
41
- frontmatter: extractFrontmatter(page, slug[slug.length - 1]),
56
+ slug: pageSlug,
57
+ frontmatter: extractFrontmatter(page, pageSlug[pageSlug.length - 1]),
42
58
  content: mdxModule?.default
43
59
  ? React.createElement(mdxModule.default, { components: mdxComponents })
44
60
  : null,
45
61
  toc: mdxModule?.toc ?? [],
62
+ prev: nav.prev,
63
+ next: nav.next,
46
64
  }
47
65
  : null;
48
66
 
49
67
  const embeddedData = {
50
68
  config,
51
69
  tree,
52
- slug,
70
+ slug: pageSlug,
71
+ version: route.version,
53
72
  frontmatter: pageData?.frontmatter ?? null,
54
73
  relativePath,
55
74
  originalPath,
75
+ prev: pageData?.prev ?? null,
76
+ next: pageData?.next ?? null,
56
77
  };
57
78
  const safeJson = JSON.stringify(embeddedData).replace(/</g, '\\u003c');
58
79
 
@@ -82,6 +103,7 @@ export default {
82
103
  initialTree={tree}
83
104
  initialPage={pageData}
84
105
  initialApiSpecs={apiSpecs}
106
+ initialVersion={route.version}
85
107
  loadMdx={async () => ({ content: null, toc: [] })}
86
108
  >
87
109
  <App />
@@ -95,10 +117,10 @@ export default {
95
117
 
96
118
  const renderDuration = performance.now() - renderStart;
97
119
 
98
- const isApiRoute = pathname.startsWith('/apis');
99
- const status = !page && !isApiRoute && slug.length > 0 ? 404 : 200;
120
+ const status = route.type === RouteType.DocsPage && !page ? 404 : 200;
100
121
 
101
- recordSSRRender(pathname, status, renderDuration);
122
+ // biome-ignore lint/correctness/useHookAtTopLevel: useNitroApp is a Nitro DI accessor, not a React hook
123
+ useNitroApp().hooks.callHook('chronicle:ssr-rendered', pathname, status, renderDuration);
102
124
 
103
125
  return new Response(stream, {
104
126
  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
  })
@@ -1,7 +1,6 @@
1
1
  import fs from 'node:fs/promises';
2
2
  import matter from 'gray-matter';
3
3
  import { defineHandler, HTTPError } from 'nitro';
4
- import { loadConfig } from '@/lib/config';
5
4
  import { getPage, getOriginalPath } from '@/lib/source';
6
5
  import { safePath } from '@/server/utils/safe-path';
7
6
 
@@ -9,11 +8,6 @@ export default defineHandler(async event => {
9
8
  const pathname = event.path || event.req.url?.split('?')[0] || '';
10
9
  if (!pathname.endsWith('.md')) return;
11
10
 
12
- const config = loadConfig();
13
- if (!config.llms?.enabled) {
14
- throw new HTTPError({ status: 404, message: 'Not Found' });
15
- }
16
-
17
11
  const stripped = pathname.replace(/\.md$/, '');
18
12
  const parts = stripped === '/index' || stripped === '/'
19
13
  ? []
@@ -0,0 +1,26 @@
1
+ import { getRouterParam } from 'h3';
2
+ import { defineHandler, HTTPError } from 'nitro';
3
+ import { loadConfig } from '@/lib/config';
4
+ import { buildLlmsTxt } from '@/lib/llms';
5
+ import { extractFrontmatter, getPagesForVersion } from '@/lib/source';
6
+
7
+ export default defineHandler(async event => {
8
+ const config = loadConfig();
9
+
10
+ const versionDir = getRouterParam(event, 'version');
11
+ const version = config.versions?.find(v => v.dir === versionDir);
12
+ if (!version) {
13
+ throw new HTTPError({ status: 404, message: 'Not Found' });
14
+ }
15
+
16
+ const ctx = { dir: version.dir, urlPrefix: `/${version.dir}` };
17
+ const pages = await getPagesForVersion(ctx);
18
+ const body = buildLlmsTxt(
19
+ config,
20
+ pages.map(p => ({ url: p.url, title: extractFrontmatter(p).title })),
21
+ ctx,
22
+ );
23
+
24
+ event.res.headers.set('Content-Type', 'text/plain');
25
+ return body;
26
+ });
@@ -1,21 +1,18 @@
1
- import { defineHandler, HTTPError } from 'nitro';
1
+ import { defineHandler } from 'nitro';
2
2
  import { loadConfig } from '@/lib/config';
3
- import { getPages, extractFrontmatter } from '@/lib/source';
3
+ import { buildLlmsTxt } from '@/lib/llms';
4
+ import { extractFrontmatter, getPagesForVersion } from '@/lib/source';
5
+ import { LATEST_CONTEXT } from '@/lib/version-source';
4
6
 
5
7
  export default defineHandler(async event => {
6
8
  const config = loadConfig();
7
9
 
8
- if (!config.llms?.enabled) {
9
- throw new HTTPError({ status: 404, message: 'Not Found' });
10
- }
11
-
12
- const pages = await getPages();
13
- const index = pages.map(p => {
14
- const fm = extractFrontmatter(p);
15
- const mdUrl = p.url === '/' ? '/index.md' : `${p.url}.md`;
16
- return `- [${fm.title}](${mdUrl})`;
17
- }).join('\n');
18
- const body = `# ${config.title}\n\n${config.description ?? ''}\n\n${index}`;
10
+ const pages = await getPagesForVersion(LATEST_CONTEXT);
11
+ const body = buildLlmsTxt(
12
+ config,
13
+ pages.map(p => ({ url: p.url, title: extractFrontmatter(p).title })),
14
+ LATEST_CONTEXT,
15
+ );
19
16
 
20
17
  event.res.headers.set('Content-Type', 'text/plain');
21
18
  return body;
@@ -22,9 +22,9 @@ async function loadFont(): Promise<ArrayBuffer> {
22
22
 
23
23
  export default defineHandler(async event => {
24
24
  const config = loadConfig();
25
- const title = event.url.searchParams.get('title') ?? config.title;
25
+ const title = event.url.searchParams.get('title') ?? config.site.title;
26
26
  const description = event.url.searchParams.get('description') ?? '';
27
- const siteName = config.title;
27
+ const siteName = config.site.title;
28
28
 
29
29
  const font = await loadFont();
30
30
 
@@ -1,6 +1,6 @@
1
1
  import { defineHandler } from 'nitro';
2
2
  import { buildApiRoutes } from '@/lib/api-routes';
3
- import { loadConfig } from '@/lib/config';
3
+ import { getAllVersions, getApiConfigsForVersion, loadConfig } from '@/lib/config';
4
4
  import { loadApiSpecs } from '@/lib/openapi';
5
5
  import { getPages } from '@/lib/source';
6
6
 
@@ -23,11 +23,19 @@ export default defineHandler(async event => {
23
23
  return `<url><loc>${baseUrl}/${page.slugs.join('/')}</loc>${lastmod}</url>`;
24
24
  });
25
25
 
26
- const apiPages = config.api?.length
27
- ? buildApiRoutes(await loadApiSpecs(config.api)).map(
28
- route => `<url><loc>${baseUrl}/apis/${route.slug.join('/')}</loc></url>`
29
- )
30
- : [];
26
+ const apiPages: string[] = [];
27
+ for (const v of getAllVersions(config)) {
28
+ const versionDir = v.isLatest ? null : v.dir;
29
+ const apiConfigs = getApiConfigsForVersion(config, versionDir);
30
+ if (!apiConfigs.length) continue;
31
+ const prefix = versionDir ? `/${versionDir}` : '';
32
+ const routes = buildApiRoutes(await loadApiSpecs(apiConfigs));
33
+ for (const route of routes) {
34
+ apiPages.push(
35
+ `<url><loc>${baseUrl}${prefix}/apis/${route.slug.join('/')}</loc></url>`,
36
+ );
37
+ }
38
+ }
31
39
 
32
40
  const xml = `<?xml version="1.0" encoding="UTF-8"?>
33
41
  <urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">