@raystack/chronicle 0.1.0-canary.49fe67c → 0.1.0-canary.4a4a3f8

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.
@@ -33,7 +33,7 @@ export function DocsPage({ slug }: DocsPageProps) {
33
33
  slug,
34
34
  frontmatter: page.frontmatter,
35
35
  content: page.content,
36
- toc: []
36
+ toc: page.toc
37
37
  }}
38
38
  config={config}
39
39
  tree={tree}
@@ -1,6 +1,5 @@
1
- import path from 'node:path';
2
1
  import { defineHandler, HTTPError } from 'nitro';
3
- import { getPage } from '@/lib/source';
2
+ import { getPage, extractFrontmatter, getRelativePath } from '@/lib/source';
4
3
 
5
4
  export default defineHandler(async event => {
6
5
  const slugParam = event.context.params?.slug ?? '';
@@ -11,8 +10,8 @@ export default defineHandler(async event => {
11
10
  throw new HTTPError({ status: 404, message: 'Page not found' });
12
11
  }
13
12
 
14
- const contentDir = __CHRONICLE_CONTENT_DIR__;
15
- const relativePath = path.relative(contentDir, page.filePath);
16
-
17
- return { frontmatter: page.frontmatter, relativePath };
13
+ return {
14
+ frontmatter: extractFrontmatter(page, slug[slug.length - 1]),
15
+ relativePath: getRelativePath(page),
16
+ };
18
17
  });
@@ -1,17 +1,16 @@
1
1
  import '@vitejs/plugin-react/preamble';
2
- import React from 'react';
3
2
  import { hydrateRoot } from 'react-dom/client';
4
3
  import { BrowserRouter } from 'react-router';
5
- import { mdxComponents } from '@/components/mdx';
4
+ import { ReactRouterProvider } from 'fumadocs-core/framework/react-router';
5
+ import { loadMdxModule } from '@/lib/mdx-loader';
6
6
  import { PageProvider } from '@/lib/page-context';
7
- import type { ChronicleConfig, Frontmatter, PageTree } from '@/types';
7
+ import type { ChronicleConfig, Frontmatter, Root } from '@/types';
8
8
  import type { ApiSpec } from '@/lib/openapi';
9
- import type { ReactNode } from 'react';
10
9
  import { App } from './App';
11
10
 
12
11
  interface EmbeddedData {
13
12
  config: ChronicleConfig;
14
- tree: PageTree;
13
+ tree: Root;
15
14
  slug: string[];
16
15
  frontmatter: Frontmatter;
17
16
  relativePath: string;
@@ -26,7 +25,7 @@ async function hydrate() {
26
25
  const config: ChronicleConfig = embedded?.config ?? {
27
26
  title: 'Documentation'
28
27
  };
29
- const tree: PageTree = embedded?.tree ?? { name: 'root', children: [] };
28
+ const tree: Root = embedded?.tree ?? { name: 'root', children: [] };
30
29
  const isApiPage =
31
30
  window.location.pathname.startsWith('/apis') && !!config.api?.length;
32
31
  const apiSpecs: ApiSpec[] = isApiPage
@@ -36,20 +35,26 @@ async function hydrate() {
36
35
  : [];
37
36
 
38
37
  const page = embedded?.relativePath
39
- ? await loadPage(embedded)
38
+ ? {
39
+ slug: embedded.slug,
40
+ frontmatter: embedded.frontmatter,
41
+ ...(await loadMdxModule(embedded.relativePath)),
42
+ }
40
43
  : null;
41
44
 
42
45
  hydrateRoot(
43
46
  document.getElementById('root') as HTMLElement,
44
47
  <BrowserRouter>
45
- <PageProvider
46
- initialConfig={config}
47
- initialTree={tree}
48
- initialPage={page}
49
- initialApiSpecs={apiSpecs}
50
- >
51
- <App />
52
- </PageProvider>
48
+ <ReactRouterProvider>
49
+ <PageProvider
50
+ initialConfig={config}
51
+ initialTree={tree}
52
+ initialPage={page}
53
+ initialApiSpecs={apiSpecs}
54
+ >
55
+ <App />
56
+ </PageProvider>
57
+ </ReactRouterProvider>
53
58
  </BrowserRouter>
54
59
  );
55
60
  } catch (err) {
@@ -57,14 +62,4 @@ async function hydrate() {
57
62
  }
58
63
  }
59
64
 
60
- async function loadPage(
61
- embedded: EmbeddedData
62
- ): Promise<{ slug: string[]; frontmatter: Frontmatter; content: ReactNode }> {
63
- const mod = await import(/* @vite-ignore */ `/.content/${embedded.relativePath}`);
64
- const content = mod.default
65
- ? React.createElement(mod.default, { components: mdxComponents })
66
- : null;
67
- return { slug: embedded.slug, frontmatter: embedded.frontmatter, content };
68
- }
69
-
70
65
  hydrate();
@@ -1,19 +1,17 @@
1
1
  import '@raystack/apsara/normalize.css';
2
2
  import '@raystack/apsara/style.css';
3
- import path from 'node:path';
4
3
  import React from 'react';
5
4
  import { renderToReadableStream } from 'react-dom/server.edge';
6
5
  import { StaticRouter } from 'react-router';
6
+ import { ReactRouterProvider } from 'fumadocs-core/framework/react-router';
7
7
  import { mdxComponents } from '@/components/mdx';
8
8
  import { loadConfig } from '@/lib/config';
9
9
  import { loadApiSpecs } from '@/lib/openapi';
10
10
  import { PageProvider } from '@/lib/page-context';
11
- import { buildPageTree, getPage, loadPageComponent } from '@/lib/source';
11
+ import { getPageTree, getPage, loadPageModule, extractFrontmatter, getRelativePath } from '@/lib/source';
12
12
  import { App } from './App';
13
13
 
14
- // @ts-expect-error virtual import from Nitro
15
14
  import clientAssets from './entry-client?assets=client';
16
- // @ts-expect-error virtual import from Nitro
17
15
  import serverAssets from './entry-server?assets=ssr';
18
16
 
19
17
  export default {
@@ -27,25 +25,25 @@ export default {
27
25
  ? await loadApiSpecs(config.api).catch(() => [])
28
26
  : [];
29
27
 
30
- const [tree, sourcePage] = await Promise.all([
31
- buildPageTree(),
28
+ const [tree, page] = await Promise.all([
29
+ getPageTree(),
32
30
  getPage(slug),
33
31
  ]);
34
32
 
35
- const pageData = sourcePage
33
+ const relativePath = page ? getRelativePath(page) : null;
34
+ const mdxModule = relativePath ? await loadPageModule(relativePath) : null;
35
+
36
+ const pageData = page
36
37
  ? {
37
38
  slug,
38
- frontmatter: sourcePage.frontmatter,
39
- content: await loadPageComponent(sourcePage).then(component =>
40
- component ? React.createElement(component, { components: mdxComponents }) : null
41
- ),
39
+ frontmatter: extractFrontmatter(page, slug[slug.length - 1]),
40
+ content: mdxModule?.default
41
+ ? React.createElement(mdxModule.default, { components: mdxComponents })
42
+ : null,
43
+ toc: mdxModule?.toc ?? [],
42
44
  }
43
45
  : null;
44
46
 
45
- const relativePath = sourcePage
46
- ? path.relative(__CHRONICLE_CONTENT_DIR__, sourcePage.filePath)
47
- : null;
48
-
49
47
  const embeddedData = {
50
48
  config,
51
49
  tree,
@@ -74,14 +72,16 @@ export default {
74
72
  <body>
75
73
  <div id="root">
76
74
  <StaticRouter location={pathname}>
77
- <PageProvider
78
- initialConfig={config}
79
- initialTree={tree}
80
- initialPage={pageData}
81
- initialApiSpecs={apiSpecs}
82
- >
83
- <App />
84
- </PageProvider>
75
+ <ReactRouterProvider>
76
+ <PageProvider
77
+ initialConfig={config}
78
+ initialTree={tree}
79
+ initialPage={pageData}
80
+ initialApiSpecs={apiSpecs}
81
+ >
82
+ <App />
83
+ </PageProvider>
84
+ </ReactRouterProvider>
85
85
  </StaticRouter>
86
86
  </div>
87
87
  </body>
@@ -16,8 +16,9 @@ export default defineHandler(async event => {
16
16
 
17
17
  const pages = await getPages();
18
18
  const docPages = pages.map(page => {
19
- const lastmod = page.frontmatter.lastModified
20
- ? `<lastmod>${new Date(page.frontmatter.lastModified).toISOString()}</lastmod>`
19
+ const data = page.data as Record<string, unknown>;
20
+ const lastmod = data.lastModified
21
+ ? `<lastmod>${new Date(data.lastModified as string).toISOString()}</lastmod>`
21
22
  : '';
22
23
  return `<url><loc>${baseUrl}/${page.slugs.join('/')}</loc>${lastmod}</url>`;
23
24
  });
@@ -1,8 +1,17 @@
1
1
  import react from '@vitejs/plugin-react';
2
+ import { remarkDirectiveAdmonition, remarkMdxMermaid } from 'fumadocs-core/mdx-plugins';
3
+ import { defineConfig as defineFumadocsConfig } from 'fumadocs-mdx/config';
2
4
  import mdx from 'fumadocs-mdx/vite';
3
5
  import { nitro } from 'nitro/vite';
4
6
  import path from 'node:path';
7
+ import remarkDirective from 'remark-directive';
5
8
  import { type InlineConfig } from 'vite';
9
+ import remarkUnusedDirectives from '../lib/remark-unused-directives';
10
+
11
+ function resolveOutputDir(projectRoot: string, preset?: string): string {
12
+ if (preset === 'vercel' || preset === 'vercel-static') return path.resolve(projectRoot, '.vercel/output');
13
+ return path.resolve(projectRoot, '.output');
14
+ }
6
15
 
7
16
  export interface ViteConfigOptions {
8
17
  packageRoot: string;
@@ -23,28 +32,42 @@ export async function createViteConfig(
23
32
  nitro({
24
33
  serverDir: path.resolve(packageRoot, 'src/server'),
25
34
  ...(preset && { preset }),
26
- alias: {
27
- '@content': path.resolve(packageRoot, '.content'),
28
- },
29
35
  }),
30
- mdx({}, { index: false }),
31
- react(),
32
- {
33
- name: 'chronicle:content-alias',
34
- resolveId(id) {
35
- if (id.startsWith('@content/')) {
36
- return path.resolve(packageRoot, '.content', id.slice('@content/'.length));
37
- }
38
- },
39
- }
36
+ mdx({
37
+ default: defineFumadocsConfig({
38
+ mdxOptions: {
39
+ remarkPlugins: [
40
+ remarkDirective,
41
+ [remarkDirectiveAdmonition, {
42
+ tags: {
43
+ CalloutContainer: 'Callout',
44
+ CalloutTitle: 'CalloutTitle',
45
+ CalloutDescription: 'CalloutDescription',
46
+ },
47
+ types: {
48
+ note: 'accent',
49
+ tip: 'accent',
50
+ info: 'accent',
51
+ warn: 'attention',
52
+ warning: 'attention',
53
+ danger: 'alert',
54
+ caution: 'alert',
55
+ success: 'success',
56
+ },
57
+ }],
58
+ remarkUnusedDirectives,
59
+ remarkMdxMermaid,
60
+ ],
61
+ },
62
+ }),
63
+ }, { index: false }),
64
+ react()
40
65
  ],
41
66
  resolve: {
42
67
  alias: {
43
68
  '@': path.resolve(packageRoot, 'src'),
44
- '@content': path.resolve(packageRoot, '.content'),
45
69
  },
46
70
  conditions: ['module-sync', 'import', 'node'],
47
- preserveSymlinks: true,
48
71
  dedupe: [
49
72
  'react',
50
73
  'react-dom',
@@ -60,8 +83,7 @@ export async function createViteConfig(
60
83
  },
61
84
  define: {
62
85
  __CHRONICLE_CONTENT_DIR__: JSON.stringify(contentDir),
63
- __CHRONICLE_PROJECT_ROOT__: JSON.stringify(projectRoot),
64
- __CHRONICLE_PACKAGE_ROOT__: JSON.stringify(packageRoot)
86
+ __CHRONICLE_PROJECT_ROOT__: JSON.stringify(projectRoot)
65
87
  },
66
88
  css: {
67
89
  modules: {
@@ -79,6 +101,11 @@ export async function createViteConfig(
79
101
  }
80
102
  }
81
103
  }
82
- }
104
+ },
105
+ nitro: {
106
+ output: {
107
+ dir: resolveOutputDir(projectRoot, preset),
108
+ },
109
+ },
83
110
  };
84
111
  }
@@ -14,7 +14,8 @@ import { MethodBadge } from '@/components/api/method-badge';
14
14
  import { ClientThemeSwitcher } from '@/components/ui/client-theme-switcher';
15
15
  import { Footer } from '@/components/ui/footer';
16
16
  import { Search } from '@/components/ui/search';
17
- import type { PageTreeItem, ThemeLayoutProps } from '@/types';
17
+ import type { Node } from 'fumadocs-core/page-tree';
18
+ import type { ThemeLayoutProps } from '@/types';
18
19
  import styles from './Layout.module.css';
19
20
 
20
21
  const iconMap: Record<string, React.ReactNode> = {
@@ -96,9 +97,9 @@ export function Layout({
96
97
  className={cx(styles.sidebar, classNames?.sidebar)}
97
98
  >
98
99
  <Sidebar.Main ref={scrollRef}>
99
- {tree.children.map(item => (
100
+ {tree.children.map((item, i) => (
100
101
  <SidebarNode
101
- key={item.url ?? item.name}
102
+ key={item.type === 'page' ? item.url : (item.name?.toString() ?? i)}
102
103
  item={item}
103
104
  pathname={pathname}
104
105
  />
@@ -118,23 +119,24 @@ function SidebarNode({
118
119
  item,
119
120
  pathname
120
121
  }: {
121
- item: PageTreeItem;
122
+ item: Node;
122
123
  pathname: string;
123
124
  }) {
124
125
  if (item.type === 'separator') {
125
126
  return null;
126
127
  }
127
128
 
128
- if (item.type === 'folder' && item.children) {
129
+ if (item.type === 'folder') {
130
+ const icon = typeof item.icon === 'string' ? iconMap[item.icon] : item.icon;
129
131
  return (
130
132
  <Sidebar.Group
131
- label={item.name}
132
- leadingIcon={item.icon ? iconMap[item.icon] : undefined}
133
+ label={item.name?.toString() ?? ''}
134
+ leadingIcon={icon ?? undefined}
133
135
  classNames={{ items: styles.groupItems }}
134
136
  >
135
- {item.children.map(child => (
137
+ {item.children.map((child, i) => (
136
138
  <SidebarNode
137
- key={child.url ?? child.name}
139
+ key={child.type === 'page' ? child.url : (child.name?.toString() ?? i)}
138
140
  item={child}
139
141
  pathname={pathname}
140
142
  />
@@ -145,13 +147,14 @@ function SidebarNode({
145
147
 
146
148
  const isActive = pathname === item.url;
147
149
  const href = item.url ?? '#';
150
+ const icon = typeof item.icon === 'string' ? iconMap[item.icon] : item.icon;
148
151
  const link = useMemo(() => <RouterLink to={href} />, [href]);
149
152
 
150
153
  return (
151
154
  <Sidebar.Item
152
155
  href={href}
153
156
  active={isActive}
154
- leadingIcon={item.icon ? iconMap[item.icon] : undefined}
157
+ leadingIcon={icon ?? undefined}
155
158
  as={link}
156
159
  >
157
160
  {item.name}
@@ -38,9 +38,53 @@
38
38
  margin-bottom: var(--rs-space-3);
39
39
  }
40
40
 
41
+ .content img {
42
+ max-width: 100%;
43
+ height: auto;
44
+ }
45
+
41
46
  .content table {
42
47
  display: block;
43
48
  max-width: 100%;
44
49
  overflow-x: auto;
45
50
  margin-bottom: var(--rs-space-5);
46
51
  }
52
+
53
+ .content details {
54
+ border: 1px solid var(--rs-color-border-base-primary);
55
+ border-radius: var(--rs-radius-2);
56
+ margin: var(--rs-space-5) 0;
57
+ overflow: hidden;
58
+ }
59
+
60
+ .content details summary {
61
+ padding: var(--rs-space-4) var(--rs-space-5);
62
+ cursor: pointer;
63
+ font-weight: 500;
64
+ font-size: var(--rs-font-size-small);
65
+ color: var(--rs-color-text-base-primary);
66
+ background: var(--rs-color-background-base-secondary);
67
+ list-style: none;
68
+ display: flex;
69
+ align-items: center;
70
+ gap: var(--rs-space-3);
71
+ }
72
+
73
+ .content details summary::-webkit-details-marker {
74
+ display: none;
75
+ }
76
+
77
+ .content details summary::before {
78
+ content: '▶';
79
+ font-size: 10px;
80
+ transition: transform 0.2s ease;
81
+ color: var(--rs-color-text-base-secondary);
82
+ }
83
+
84
+ .content details[open] > summary::before {
85
+ transform: rotate(90deg);
86
+ }
87
+
88
+ .content details > :not(summary) {
89
+ padding: var(--rs-space-4) var(--rs-space-5);
90
+ }
@@ -1,46 +1,30 @@
1
1
  'use client';
2
2
 
3
3
  import { Text } from '@raystack/apsara';
4
- import { useEffect, useState } from 'react';
5
- import type { TocItem } from '@/types';
4
+ import { AnchorProvider, useActiveAnchor } from 'fumadocs-core/toc';
5
+ import type { TableOfContents, TOCItemType } from 'fumadocs-core/toc';
6
6
  import styles from './Toc.module.css';
7
7
 
8
8
  interface TocProps {
9
- items: TocItem[];
9
+ items: TableOfContents;
10
10
  }
11
11
 
12
12
  export function Toc({ items }: TocProps) {
13
- const [activeId, setActiveId] = useState<string>('');
14
-
15
- // Filter to only show h2 and h3 headings
16
13
  const filteredItems = items.filter(
17
14
  item => item.depth >= 2 && item.depth <= 3
18
15
  );
19
16
 
20
- useEffect(() => {
21
- const headingIds = filteredItems.map(item => item.url.replace('#', ''));
22
-
23
- const observer = new IntersectionObserver(
24
- entries => {
25
- entries.forEach(entry => {
26
- if (entry.isIntersecting) {
27
- setActiveId(entry.target.id);
28
- }
29
- });
30
- },
31
- // -80px top: offset for fixed header, -80% bottom: trigger when heading is in top 20% of viewport
32
- { rootMargin: '-80px 0px -80% 0px' }
33
- );
34
-
35
- headingIds.forEach(id => {
36
- const element = document.getElementById(id);
37
- if (element) observer.observe(element);
38
- });
17
+ if (filteredItems.length === 0) return null;
39
18
 
40
- return () => observer.disconnect();
41
- }, [filteredItems]);
19
+ return (
20
+ <AnchorProvider toc={filteredItems} single>
21
+ <TocContent items={filteredItems} />
22
+ </AnchorProvider>
23
+ );
24
+ }
42
25
 
43
- if (filteredItems.length === 0) return null;
26
+ function TocContent({ items }: { items: TOCItemType[] }) {
27
+ const activeAnchor = useActiveAnchor();
44
28
 
45
29
  return (
46
30
  <aside className={styles.toc}>
@@ -48,9 +32,9 @@ export function Toc({ items }: TocProps) {
48
32
  On this page
49
33
  </Text>
50
34
  <nav className={styles.nav}>
51
- {filteredItems.map(item => {
35
+ {items.map(item => {
52
36
  const id = item.url.replace('#', '');
53
- const isActive = activeId === id;
37
+ const isActive = activeAnchor === id;
54
38
  const isNested = item.depth > 2;
55
39
  return (
56
40
  <a
@@ -1,6 +1,6 @@
1
1
  import { Link as RouterLink, useLocation } from 'react-router';
2
2
  import { MethodBadge } from '@/components/api/method-badge';
3
- import type { PageTree, PageTreeItem } from '@/types';
3
+ import type { Root, Node } from 'fumadocs-core/page-tree';
4
4
  import styles from './ChapterNav.module.css';
5
5
 
6
6
  const iconMap: Record<string, React.ReactNode> = {
@@ -12,16 +12,16 @@ const iconMap: Record<string, React.ReactNode> = {
12
12
  };
13
13
 
14
14
  interface ChapterNavProps {
15
- tree: PageTree;
15
+ tree: Root;
16
16
  }
17
17
 
18
18
  function buildChapterIndices(
19
- children: PageTreeItem[]
20
- ): Map<PageTreeItem, number> {
21
- const indices = new Map<PageTreeItem, number>();
19
+ children: Node[]
20
+ ): Map<Node, number> {
21
+ const indices = new Map<Node, number>();
22
22
  let index = 0;
23
23
  for (const item of children) {
24
- if (item.type === 'folder' && item.children) {
24
+ if (item.type === 'folder') {
25
25
  index++;
26
26
  indices.set(item, index);
27
27
  }
@@ -39,17 +39,17 @@ export function ChapterNav({ tree }: ChapterNavProps) {
39
39
  {tree.children.map(item => {
40
40
  if (item.type === 'separator') return null;
41
41
 
42
- if (item.type === 'folder' && item.children) {
42
+ if (item.type === 'folder') {
43
43
  const chapterIndex = chapterIndices.get(item) ?? 0;
44
44
  return (
45
- <li key={item.name} className={styles.chapter}>
45
+ <li key={item.name?.toString()} className={styles.chapter}>
46
46
  <span className={styles.chapterLabel}>
47
47
  {String(chapterIndex).padStart(2, '0')}. {item.name}
48
48
  </span>
49
49
  <ul className={styles.chapterItems}>
50
50
  {item.children.map(child => (
51
51
  <ChapterItem
52
- key={child.url ?? child.name}
52
+ key={child.type === 'page' ? child.url : (child.name?.toString() ?? '')}
53
53
  item={child}
54
54
  pathname={pathname}
55
55
  />
@@ -61,7 +61,7 @@ export function ChapterNav({ tree }: ChapterNavProps) {
61
61
 
62
62
  return (
63
63
  <ChapterItem
64
- key={item.url ?? item.name}
64
+ key={item.url ?? item.name?.toString() ?? ''}
65
65
  item={item}
66
66
  pathname={pathname}
67
67
  />
@@ -76,19 +76,19 @@ function ChapterItem({
76
76
  item,
77
77
  pathname
78
78
  }: {
79
- item: PageTreeItem;
79
+ item: Node;
80
80
  pathname: string;
81
81
  }) {
82
82
  if (item.type === 'separator') return null;
83
83
 
84
- if (item.type === 'folder' && item.children) {
84
+ if (item.type === 'folder') {
85
85
  return (
86
86
  <li>
87
87
  <span className={styles.subLabel}>{item.name}</span>
88
88
  <ul className={styles.chapterItems}>
89
89
  {item.children.map(child => (
90
90
  <ChapterItem
91
- key={child.url ?? child.name}
91
+ key={child.type === 'page' ? child.url : (child.name?.toString() ?? '')}
92
92
  item={child}
93
93
  pathname={pathname}
94
94
  />
@@ -99,7 +99,7 @@ function ChapterItem({
99
99
  }
100
100
 
101
101
  const isActive = pathname === item.url;
102
- const icon = item.icon ? iconMap[item.icon] : null;
102
+ const icon = typeof item.icon === 'string' ? iconMap[item.icon] : item.icon;
103
103
 
104
104
  return (
105
105
  <li>
@@ -174,6 +174,11 @@
174
174
  margin-bottom: var(--rs-space-3);
175
175
  }
176
176
 
177
+ .content img {
178
+ max-width: 100%;
179
+ height: auto;
180
+ }
181
+
177
182
  .content blockquote {
178
183
  margin: 1rem 0;
179
184
  padding-left: 1rem;