@raystack/chronicle 0.1.0-canary.0efaef0 → 0.1.0-canary.1e5fdae

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
@@ -16,15 +16,48 @@ var __export = (target, all) => {
16
16
  var __esm = (fn, res) => () => (fn && (res = fn(fn = 0)), res);
17
17
  var __require = /* @__PURE__ */ createRequire(import.meta.url);
18
18
 
19
+ // src/lib/remark-unused-directives.ts
20
+ import { visit } from "unist-util-visit";
21
+ var remarkUnusedDirectives = () => {
22
+ return (tree) => {
23
+ visit(tree, ["textDirective"], (node) => {
24
+ const directive = node;
25
+ if (!directive.data) {
26
+ const hasAttributes = directive.attributes && Object.keys(directive.attributes).length > 0;
27
+ const hasChildren = directive.children && directive.children.length > 0;
28
+ if (!hasAttributes && !hasChildren) {
29
+ const name = directive.name;
30
+ if (!name)
31
+ return;
32
+ Object.keys(directive).forEach((key) => delete directive[key]);
33
+ directive.type = "text";
34
+ directive.value = `:${name}`;
35
+ }
36
+ }
37
+ });
38
+ };
39
+ }, remark_unused_directives_default;
40
+ var init_remark_unused_directives = __esm(() => {
41
+ remark_unused_directives_default = remarkUnusedDirectives;
42
+ });
43
+
19
44
  // src/server/vite-config.ts
20
45
  var exports_vite_config = {};
21
46
  __export(exports_vite_config, {
22
47
  createViteConfig: () => createViteConfig
23
48
  });
24
49
  import react from "@vitejs/plugin-react";
50
+ import { remarkDirectiveAdmonition, remarkMdxMermaid } from "fumadocs-core/mdx-plugins";
51
+ import { defineConfig as defineFumadocsConfig } from "fumadocs-mdx/config";
25
52
  import mdx from "fumadocs-mdx/vite";
26
53
  import { nitro } from "nitro/vite";
27
54
  import path4 from "node:path";
55
+ import remarkDirective from "remark-directive";
56
+ function resolveOutputDir(projectRoot, preset) {
57
+ if (preset === "vercel" || preset === "vercel-static")
58
+ return path4.resolve(projectRoot, ".vercel/output");
59
+ return path4.resolve(projectRoot, ".output");
60
+ }
28
61
  async function createViteConfig(options) {
29
62
  const { packageRoot, projectRoot, contentDir, preset } = options;
30
63
  return {
@@ -35,7 +68,34 @@ async function createViteConfig(options) {
35
68
  serverDir: path4.resolve(packageRoot, "src/server"),
36
69
  ...preset && { preset }
37
70
  }),
38
- mdx({}, { index: false }),
71
+ mdx({
72
+ default: defineFumadocsConfig({
73
+ mdxOptions: {
74
+ remarkPlugins: [
75
+ remarkDirective,
76
+ [remarkDirectiveAdmonition, {
77
+ tags: {
78
+ CalloutContainer: "Callout",
79
+ CalloutTitle: "CalloutTitle",
80
+ CalloutDescription: "CalloutDescription"
81
+ },
82
+ types: {
83
+ note: "accent",
84
+ tip: "accent",
85
+ info: "accent",
86
+ warn: "attention",
87
+ warning: "attention",
88
+ danger: "alert",
89
+ caution: "alert",
90
+ success: "success"
91
+ }
92
+ }],
93
+ remark_unused_directives_default,
94
+ remarkMdxMermaid
95
+ ]
96
+ }
97
+ })
98
+ }, { index: false }),
39
99
  react()
40
100
  ],
41
101
  resolve: {
@@ -58,8 +118,7 @@ async function createViteConfig(options) {
58
118
  },
59
119
  define: {
60
120
  __CHRONICLE_CONTENT_DIR__: JSON.stringify(contentDir),
61
- __CHRONICLE_PROJECT_ROOT__: JSON.stringify(projectRoot),
62
- __CHRONICLE_PACKAGE_ROOT__: JSON.stringify(packageRoot)
121
+ __CHRONICLE_PROJECT_ROOT__: JSON.stringify(projectRoot)
63
122
  },
64
123
  css: {
65
124
  modules: {
@@ -77,10 +136,17 @@ async function createViteConfig(options) {
77
136
  }
78
137
  }
79
138
  }
139
+ },
140
+ nitro: {
141
+ output: {
142
+ dir: resolveOutputDir(projectRoot, preset)
143
+ }
80
144
  }
81
145
  };
82
146
  }
83
- var init_vite_config = () => {};
147
+ var init_vite_config = __esm(() => {
148
+ init_remark_unused_directives();
149
+ });
84
150
 
85
151
  // src/cli/index.ts
86
152
  import { Command as Command6 } from "commander";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@raystack/chronicle",
3
- "version": "0.1.0-canary.0efaef0",
3
+ "version": "0.1.0-canary.1e5fdae",
4
4
  "description": "Config-driven documentation framework",
5
5
  "license": "Apache-2.0",
6
6
  "type": "module",
@@ -1,6 +1,7 @@
1
1
  'use client'
2
2
 
3
- import type { ComponentProps } from 'react'
3
+ import { type ComponentProps, isValidElement, Children } from 'react'
4
+ import { Mermaid } from './mermaid'
4
5
  import styles from './code.module.css'
5
6
 
6
7
  type PreProps = ComponentProps<'pre'> & {
@@ -16,6 +17,14 @@ export function MdxCode({ children, className, ...props }: ComponentProps<'code'
16
17
  }
17
18
 
18
19
  export function MdxPre({ children, title, className, ...props }: PreProps) {
20
+ // Detect mermaid code blocks
21
+ if (isValidElement(children)) {
22
+ const childProps = children.props as { className?: string; children?: string }
23
+ if (childProps.className?.includes('language-mermaid') && typeof childProps.children === 'string') {
24
+ return <Mermaid chart={childProps.children} />
25
+ }
26
+ }
27
+
19
28
  return (
20
29
  <div className={styles.codeBlock}>
21
30
  {title && <div className={styles.codeHeader}>{title}</div>}
@@ -1,35 +1,10 @@
1
1
  .details {
2
- border: 1px solid var(--rs-color-border-base-primary);
3
- border-radius: var(--rs-radius-2);
4
2
  margin: var(--rs-space-5) 0;
5
3
  }
6
4
 
7
- .summary {
8
- padding: var(--rs-space-4) var(--rs-space-5);
9
- cursor: pointer;
5
+ .trigger {
10
6
  font-weight: 500;
11
7
  font-size: var(--rs-font-size-small);
12
- color: var(--rs-color-text-base-primary);
13
- background: var(--rs-color-background-base-secondary);
14
- list-style: none;
15
- display: flex;
16
- align-items: center;
17
- gap: var(--rs-space-3);
18
- }
19
-
20
- .summary::-webkit-details-marker {
21
- display: none;
22
- }
23
-
24
- .summary::before {
25
- content: '▶';
26
- font-size: 10px;
27
- transition: transform 0.2s ease;
28
- color: var(--rs-color-text-base-secondary);
29
- }
30
-
31
- .details[open] > .summary::before {
32
- transform: rotate(90deg);
33
8
  }
34
9
 
35
10
  .content {
@@ -1,9 +1,8 @@
1
1
  import type { ComponentProps } from 'react'
2
- import styles from './details.module.css'
3
2
 
4
3
  export function MdxDetails({ children, className, ...props }: ComponentProps<'details'>) {
5
4
  return (
6
- <details className={`${styles.details} ${className ?? ''}`} {...props}>
5
+ <details className={className} {...props}>
7
6
  {children}
8
7
  </details>
9
8
  )
@@ -11,7 +10,7 @@ export function MdxDetails({ children, className, ...props }: ComponentProps<'de
11
10
 
12
11
  export function MdxSummary({ children, className, ...props }: ComponentProps<'summary'>) {
13
12
  return (
14
- <summary className={`${styles.summary} ${className ?? ''}`} {...props}>
13
+ <summary className={className} {...props}>
15
14
  {children}
16
15
  </summary>
17
16
  )
@@ -8,6 +8,20 @@ import { Mermaid } from './mermaid'
8
8
  import { MdxParagraph } from './paragraph'
9
9
  import { CalloutContainer, CalloutTitle, CalloutDescription, MdxBlockquote } from '@/components/common/callout'
10
10
  import { Tabs } from '@raystack/apsara'
11
+ import { type ComponentProps, useEffect, useState } from 'react'
12
+
13
+ function ClientOnly({ children }: { children: React.ReactNode }) {
14
+ const [mounted, setMounted] = useState(false)
15
+ useEffect(() => setMounted(true), [])
16
+ return mounted ? <>{children}</> : null
17
+ }
18
+
19
+ function MdxTabs(props: ComponentProps<typeof Tabs>) {
20
+ return <ClientOnly><Tabs {...props} /></ClientOnly>
21
+ }
22
+ MdxTabs.List = Tabs.List
23
+ MdxTabs.Trigger = Tabs.Trigger
24
+ MdxTabs.Content = Tabs.Content
11
25
 
12
26
  export const mdxComponents: MDXComponents = {
13
27
  p: MdxParagraph,
@@ -27,7 +41,7 @@ export const mdxComponents: MDXComponents = {
27
41
  Callout: CalloutContainer,
28
42
  CalloutTitle,
29
43
  CalloutDescription,
30
- Tabs,
44
+ Tabs: MdxTabs,
31
45
  Mermaid,
32
46
  }
33
47
 
@@ -1,53 +1,19 @@
1
1
  'use client'
2
2
 
3
3
  import { Breadcrumb } from '@raystack/apsara'
4
- import type { PageTree, PageTreeItem } from '@/types'
4
+ import { getBreadcrumbItems } from 'fumadocs-core/breadcrumb'
5
+ import type { Root } from 'fumadocs-core/page-tree'
5
6
 
6
7
  interface BreadcrumbsProps {
7
8
  slug: string[]
8
- tree: PageTree
9
- }
10
-
11
- function findInTree(items: PageTreeItem[], targetPath: string): PageTreeItem | undefined {
12
- for (const item of items) {
13
- const itemUrl = item.url || `/${item.name.toLowerCase().replace(/\s+/g, '-')}`
14
- if (itemUrl === targetPath || itemUrl === `/${targetPath}`) {
15
- return item
16
- }
17
- if (item.children) {
18
- const found = findInTree(item.children, targetPath)
19
- if (found) return found
20
- }
21
- }
22
- return undefined
23
- }
24
-
25
- function getFirstPageUrl(item: PageTreeItem): string | undefined {
26
- if (item.type === 'page' && item.url) {
27
- return item.url
28
- }
29
- if (item.children) {
30
- for (const child of item.children) {
31
- const url = getFirstPageUrl(child)
32
- if (url) return url
33
- }
34
- }
35
- return undefined
9
+ tree: Root
36
10
  }
37
11
 
38
12
  export function Breadcrumbs({ slug, tree }: BreadcrumbsProps) {
39
- const items: { label: string; href: string }[] = []
13
+ const url = slug.length === 0 ? '/' : `/${slug.join('/')}`
14
+ const items = getBreadcrumbItems(url, tree, { includePage: true })
40
15
 
41
- for (let i = 0; i < slug.length; i++) {
42
- const currentPath = `/${slug.slice(0, i + 1).join('/')}`
43
- const node = findInTree(tree.children, currentPath)
44
- const href = node?.url || (node && getFirstPageUrl(node)) || currentPath
45
- const label = node?.name ?? slug[i]
46
- items.push({
47
- label: label.charAt(0).toUpperCase() + label.slice(1),
48
- href,
49
- })
50
- }
16
+ if (items.length === 0) return null
51
17
 
52
18
  return (
53
19
  <Breadcrumb size="small">
@@ -55,10 +21,10 @@ export function Breadcrumbs({ slug, tree }: BreadcrumbsProps) {
55
21
  const breadcrumbItem = (
56
22
  <Breadcrumb.Item
57
23
  key={`item-${index}`}
58
- href={item.href}
24
+ href={item.url}
59
25
  current={index === items.length - 1}
60
26
  >
61
- {item.label}
27
+ {item.name}
62
28
  </Breadcrumb.Item>
63
29
  )
64
30
  if (index === 0) return [breadcrumbItem]
@@ -1,6 +1,6 @@
1
1
  import type { OpenAPIV3 } from 'openapi-types'
2
2
  import slugify from 'slugify'
3
- import type { PageTree, PageTreeItem } from '@/types/content'
3
+ import type { Root, Node, Item, Folder } from 'fumadocs-core/page-tree'
4
4
  import type { ApiSpec } from './openapi'
5
5
 
6
6
  export function getSpecSlug(spec: ApiSpec): string {
@@ -56,16 +56,15 @@ export function findApiOperation(specs: ApiSpec[], slug: string[]): ApiRouteMatc
56
56
  return null
57
57
  }
58
58
 
59
- export function buildApiPageTree(specs: ApiSpec[]): PageTree {
60
- const children: PageTreeItem[] = []
59
+ export function buildApiPageTree(specs: ApiSpec[]): Root {
60
+ const children: Node[] = []
61
61
 
62
62
  for (const spec of specs) {
63
63
  const specSlug = getSpecSlug(spec)
64
64
  const paths = spec.document.paths ?? {}
65
65
  const tags = spec.document.tags ?? []
66
66
 
67
- // Group operations by tag (case-insensitive to avoid duplicates)
68
- const opsByTag = new Map<string, PageTreeItem[]>()
67
+ const opsByTag = new Map<string, Item[]>()
69
68
  const tagDisplayName = new Map<string, string>()
70
69
 
71
70
  for (const [, pathItem] of Object.entries(paths)) {
@@ -90,7 +89,6 @@ export function buildApiPageTree(specs: ApiSpec[]): PageTree {
90
89
  }
91
90
  }
92
91
 
93
- // Use doc.tags display names where available
94
92
  for (const t of tags) {
95
93
  const key = t.name.toLowerCase()
96
94
  if (opsByTag.has(key)) {
@@ -98,7 +96,7 @@ export function buildApiPageTree(specs: ApiSpec[]): PageTree {
98
96
  }
99
97
  }
100
98
 
101
- const tagFolders: PageTreeItem[] = Array.from(opsByTag.entries()).map(([key, ops]) => ({
99
+ const tagFolders: Folder[] = Array.from(opsByTag.entries()).map(([key, ops]) => ({
102
100
  type: 'folder' as const,
103
101
  name: tagDisplayName.get(key) ?? key,
104
102
  icon: 'rectangle-stack',
@@ -110,7 +108,7 @@ export function buildApiPageTree(specs: ApiSpec[]): PageTree {
110
108
  type: 'folder',
111
109
  name: spec.name,
112
110
  children: tagFolders,
113
- })
111
+ } as Folder)
114
112
  } else {
115
113
  children.push(...tagFolders)
116
114
  }
@@ -1,4 +1,4 @@
1
- import React, {
1
+ import {
2
2
  createContext,
3
3
  type ReactNode,
4
4
  useContext,
@@ -6,19 +6,21 @@ import React, {
6
6
  useState
7
7
  } from 'react';
8
8
  import { useLocation } from 'react-router';
9
- import { mdxComponents } from '@/components/mdx';
10
9
  import type { ApiSpec } from '@/lib/openapi';
11
- import type { ChronicleConfig, Frontmatter, PageTree } from '@/types';
10
+ import type { ChronicleConfig, Frontmatter, Root, TableOfContents } from '@/types';
11
+
12
+ export type MdxLoader = (relativePath: string) => Promise<{ content: ReactNode; toc: TableOfContents }>;
12
13
 
13
14
  interface PageData {
14
15
  slug: string[];
15
16
  frontmatter: Frontmatter;
16
17
  content: ReactNode;
18
+ toc: TableOfContents;
17
19
  }
18
20
 
19
21
  interface PageContextValue {
20
22
  config: ChronicleConfig;
21
- tree: PageTree;
23
+ tree: Root;
22
24
  page: PageData | null;
23
25
  apiSpecs: ApiSpec[];
24
26
  }
@@ -31,7 +33,7 @@ export function usePageContext(): PageContextValue {
31
33
  console.error('usePageContext: no context found!');
32
34
  return {
33
35
  config: { title: 'Documentation' },
34
- tree: { name: 'root', children: [] },
36
+ tree: { name: 'root', children: [] } as Root,
35
37
  page: null,
36
38
  apiSpecs: []
37
39
  };
@@ -41,31 +43,23 @@ export function usePageContext(): PageContextValue {
41
43
 
42
44
  interface PageProviderProps {
43
45
  initialConfig: ChronicleConfig;
44
- initialTree: PageTree;
46
+ initialTree: Root;
45
47
  initialPage: PageData | null;
46
48
  initialApiSpecs: ApiSpec[];
49
+ loadMdx: MdxLoader;
47
50
  children: ReactNode;
48
51
  }
49
52
 
50
- async function loadMdxComponent(relativePath: string): Promise<ReactNode> {
51
- const withoutExt = relativePath.replace(/\.(mdx|md)$/, '');
52
- const mod = relativePath.endsWith('.md')
53
- ? await import(`../../.content/${withoutExt}.md`)
54
- : await import(`../../.content/${withoutExt}.mdx`);
55
- return mod.default
56
- ? React.createElement(mod.default, { components: mdxComponents })
57
- : null;
58
- }
59
-
60
53
  export function PageProvider({
61
54
  initialConfig,
62
55
  initialTree,
63
56
  initialPage,
64
57
  initialApiSpecs,
58
+ loadMdx,
65
59
  children
66
60
  }: PageProviderProps) {
67
61
  const { pathname } = useLocation();
68
- const [tree] = useState<PageTree>(initialTree);
62
+ const [tree] = useState<Root>(initialTree);
69
63
  const [page, setPage] = useState<PageData | null>(initialPage);
70
64
  const [apiSpecs, setApiSpecs] = useState<ApiSpec[]>(initialApiSpecs);
71
65
  const [currentPath, setCurrentPath] = useState(pathname);
@@ -98,9 +92,9 @@ export function PageProvider({
98
92
  .then(res => res.json())
99
93
  .then(async (data: { frontmatter: Frontmatter; relativePath: string }) => {
100
94
  if (cancelled.current) return;
101
- const content = await loadMdxComponent(data.relativePath);
95
+ const { content, toc } = await loadMdx(data.relativePath);
102
96
  if (cancelled.current) return;
103
- setPage({ slug, frontmatter: data.frontmatter, content });
97
+ setPage({ slug, frontmatter: data.frontmatter, content, toc });
104
98
  })
105
99
  .catch(() => {});
106
100
 
package/src/lib/source.ts CHANGED
@@ -1,16 +1,11 @@
1
1
  import fs from 'node:fs/promises';
2
2
  import path from 'node:path';
3
3
  import { loader } from 'fumadocs-core/source';
4
+ import type { Root, Node, Folder } from 'fumadocs-core/page-tree';
4
5
  import matter from 'gray-matter';
5
6
  import type { MDXContent } from 'mdx/types';
6
- import type { Frontmatter, PageTree, PageTreeItem } from '@/types';
7
-
8
- export interface SourcePage {
9
- url: string;
10
- slugs: string[];
11
- filePath: string;
12
- frontmatter: Frontmatter;
13
- }
7
+ import type { TableOfContents } from 'fumadocs-core/toc';
8
+ import type { Frontmatter } from '@/types';
14
9
 
15
10
  function getContentDir(): string {
16
11
  return __CHRONICLE_CONTENT_DIR__ || path.join(process.cwd(), 'content');
@@ -43,14 +38,18 @@ async function scanFiles(contentDir: string) {
43
38
  files.push({
44
39
  type: 'page',
45
40
  path: relativePath,
46
- data: { ...data, _absolutePath: fullPath }
41
+ data: { ...data, _relativePath: relativePath }
47
42
  });
48
43
  } else if (entry.name === 'meta.json' || entry.name === 'meta.yaml') {
49
- const raw = await fs.readFile(fullPath, 'utf-8');
50
- const data = entry.name.endsWith('.json')
51
- ? JSON.parse(raw)
52
- : matter(raw).data;
53
- files.push({ type: 'meta', path: relativePath, data });
44
+ try {
45
+ const raw = await fs.readFile(fullPath, 'utf-8');
46
+ const data = entry.name.endsWith('.json')
47
+ ? JSON.parse(raw)
48
+ : matter(raw).data;
49
+ files.push({ type: 'meta', path: relativePath, data });
50
+ } catch {
51
+ /* malformed meta file */
52
+ }
54
53
  }
55
54
  }
56
55
  } catch {
@@ -63,7 +62,6 @@ async function scanFiles(contentDir: string) {
63
62
  }
64
63
 
65
64
  let cachedSource: ReturnType<typeof loader> | null = null;
66
- let cachedPages: SourcePage[] | null = null;
67
65
 
68
66
  async function getSource() {
69
67
  if (cachedSource) return cachedSource;
@@ -76,111 +74,87 @@ async function getSource() {
76
74
  return cachedSource;
77
75
  }
78
76
 
77
+ export { getSource as source };
78
+
79
79
  export function invalidate() {
80
80
  cachedSource = null;
81
- cachedPages = null;
82
81
  }
83
82
 
84
- export async function getPages(): Promise<SourcePage[]> {
85
- if (cachedPages) return cachedPages;
86
-
87
- const s = await getSource();
88
- cachedPages = s.getPages().map(page => {
89
- const data = page.data as Record<string, unknown>;
90
- return {
91
- url: page.url,
92
- slugs: page.slugs,
93
- filePath: (data._absolutePath as string) ?? '',
94
- frontmatter: {
95
- title:
96
- (data.title as string) ??
97
- page.slugs[page.slugs.length - 1] ??
98
- 'Untitled',
99
- description: data.description as string | undefined,
100
- order: data.order as number | undefined,
101
- icon: data.icon as string | undefined,
102
- lastModified: data.lastModified as string | undefined
103
- }
104
- };
105
- });
106
-
107
- return cachedPages;
83
+ function getOrder(node: Node, orderMap: Map<string, number>): number | undefined {
84
+ if (node.type === 'page') return orderMap.get(node.url);
85
+ if (node.type === 'folder' && node.index) return orderMap.get(node.index.url);
86
+ return undefined;
108
87
  }
109
88
 
110
- export async function getPage(slug?: string[]): Promise<SourcePage | null> {
111
- const pages = await getPages();
112
- const targetUrl = !slug || slug.length === 0 ? '/' : `/${slug.join('/')}`;
113
- return pages.find(p => p.url === targetUrl) ?? null;
89
+ function sortNodes(nodes: Node[], orderMap: Map<string, number>): Node[] {
90
+ return [...nodes]
91
+ .map(n =>
92
+ n.type === 'folder'
93
+ ? ({ ...n, children: sortNodes(n.children, orderMap) } as Folder)
94
+ : n
95
+ )
96
+ .sort(
97
+ (a, b) =>
98
+ (getOrder(a, orderMap) ?? Number.MAX_SAFE_INTEGER) -
99
+ (getOrder(b, orderMap) ?? Number.MAX_SAFE_INTEGER)
100
+ );
114
101
  }
115
102
 
116
- export async function loadPageComponent(
117
- page: SourcePage
118
- ): Promise<MDXContent | null> {
119
- if (!page.filePath) return null;
120
- try {
121
- await fs.access(page.filePath);
122
- } catch {
123
- return null;
103
+ function sortTreeByOrder(tree: Root, pages: { url: string; data: unknown }[]): Root {
104
+ const orderMap = new Map<string, number>();
105
+ for (const page of pages) {
106
+ const d = page.data as Record<string, unknown>;
107
+ const order = d.order as number | undefined;
108
+ if (order !== undefined) orderMap.set(page.url, order);
109
+ if (page.url === '/') orderMap.set('/', order ?? 0);
124
110
  }
125
- const contentDir = getContentDir();
126
- const relativePath = path.relative(contentDir, page.filePath);
127
- const withoutExt = relativePath.replace(/\.(mdx|md)$/, '');
128
- const mod = relativePath.endsWith('.md')
129
- ? await import(`../../.content/${withoutExt}.md`)
130
- : await import(`../../.content/${withoutExt}.mdx`);
131
- return mod.default;
111
+ return { ...tree, children: sortNodes(tree.children, orderMap) };
112
+ }
113
+
114
+ export async function getPageTree(): Promise<Root> {
115
+ const s = await getSource();
116
+ return sortTreeByOrder(s.pageTree as Root, s.getPages());
132
117
  }
133
118
 
134
- export async function buildPageTree(): Promise<PageTree> {
119
+ export async function getPages() {
135
120
  const s = await getSource();
136
- const pages = s.getPages();
137
- const folders = new Map<string, PageTreeItem[]>();
138
- const rootPages: PageTreeItem[] = [];
121
+ return s.getPages();
122
+ }
139
123
 
140
- for (const page of pages) {
141
- const data = page.data as Record<string, unknown>;
142
- const isIndex = page.url === '/';
143
- const item: PageTreeItem = {
144
- type: 'page',
145
- name: (data.title as string) ?? page.slugs.join('/') ?? 'Untitled',
146
- url: page.url,
147
- order: (data.order as number | undefined) ?? (isIndex ? 0 : undefined)
148
- };
149
-
150
- if (page.slugs.length > 1) {
151
- const folder = page.slugs[0];
152
- if (!folders.has(folder)) {
153
- folders.set(folder, []);
154
- }
155
- folders.get(folder)?.push(item);
156
- } else {
157
- rootPages.push(item);
158
- }
159
- }
124
+ export async function getPage(slugs?: string[]) {
125
+ const s = await getSource();
126
+ return s.getPage(slugs);
127
+ }
160
128
 
161
- const sortByOrder = (items: PageTreeItem[]) =>
162
- items.sort(
163
- (a, b) =>
164
- (a.order ?? Number.MAX_SAFE_INTEGER) -
165
- (b.order ?? Number.MAX_SAFE_INTEGER)
166
- );
129
+ export function extractFrontmatter(page: { data: unknown }, fallbackTitle?: string): Frontmatter {
130
+ const d = page.data as Record<string, unknown>;
131
+ return {
132
+ title: (d.title as string) ?? fallbackTitle ?? 'Untitled',
133
+ description: d.description as string | undefined,
134
+ order: d.order as number | undefined,
135
+ icon: d.icon as string | undefined,
136
+ lastModified: d.lastModified as string | undefined,
137
+ };
138
+ }
167
139
 
168
- const children: PageTreeItem[] = sortByOrder(rootPages);
169
-
170
- const folderItems: PageTreeItem[] = [];
171
- for (const [folder, items] of folders) {
172
- const sorted = sortByOrder(items);
173
- const indexPage = items.find(item => item.url === `/${folder}`);
174
- const folderOrder = indexPage?.order ?? sorted[0]?.order;
175
- folderItems.push({
176
- type: 'folder',
177
- name: `${folder.charAt(0).toUpperCase()}${folder.slice(1)}`,
178
- order: folderOrder,
179
- children: sorted
180
- });
181
- }
140
+ export function getRelativePath(page: { data: unknown }): string {
141
+ return ((page.data as Record<string, unknown>)._relativePath as string) ?? '';
142
+ }
182
143
 
183
- children.push(...sortByOrder(folderItems));
144
+ const ssrModules = import.meta.glob<{ default?: MDXContent; toc?: TableOfContents }>(
145
+ '../../.content/**/*.{mdx,md}'
146
+ );
184
147
 
185
- return { name: 'root', children };
148
+ export async function loadPageModule(
149
+ relativePath: string
150
+ ): Promise<{ default: MDXContent | null; toc: TableOfContents }> {
151
+ if (!relativePath || relativePath.includes('..')) return { default: null, toc: [] };
152
+ const withoutExt = relativePath.replace(/\.(mdx|md)$/, '');
153
+ const key = relativePath.endsWith('.md')
154
+ ? `../../.content/${withoutExt}.md`
155
+ : `../../.content/${withoutExt}.mdx`;
156
+ const loader = ssrModules[key];
157
+ if (!loader) return { default: null, toc: [] };
158
+ const mod = await loader();
159
+ return { default: mod.default ?? null, toc: mod.toc ?? [] };
186
160
  }