@raystack/chronicle 0.1.0-canary.e11f924 → 0.1.0-canary.f0d9bde

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 (81) hide show
  1. package/dist/cli/index.js +878 -9684
  2. package/package.json +17 -12
  3. package/src/cli/__tests__/config.test.ts +25 -0
  4. package/src/cli/__tests__/scaffold.test.ts +10 -0
  5. package/src/cli/commands/build.ts +66 -19
  6. package/src/cli/commands/dev.ts +9 -22
  7. package/src/cli/commands/init.ts +107 -11
  8. package/src/cli/commands/serve.ts +36 -35
  9. package/src/cli/commands/start.ts +11 -21
  10. package/src/cli/utils/config.ts +2 -2
  11. package/src/cli/utils/index.ts +1 -1
  12. package/src/cli/utils/resolve.ts +6 -0
  13. package/src/cli/utils/scaffold.ts +20 -0
  14. package/src/components/mdx/code.tsx +10 -1
  15. package/src/components/mdx/details.module.css +1 -24
  16. package/src/components/mdx/details.tsx +2 -3
  17. package/src/components/mdx/image.tsx +5 -19
  18. package/src/components/mdx/index.tsx +3 -3
  19. package/src/components/mdx/link.tsx +10 -11
  20. package/src/components/ui/footer.tsx +3 -2
  21. package/src/components/ui/search.module.css +7 -0
  22. package/src/components/ui/search.tsx +62 -87
  23. package/src/lib/config.ts +9 -0
  24. package/src/lib/head.tsx +45 -0
  25. package/src/lib/page-context.tsx +95 -0
  26. package/src/lib/source.ts +92 -21
  27. package/src/{app/apis/[[...slug]]/layout.tsx → pages/ApiLayout.tsx} +10 -7
  28. package/src/pages/ApiPage.tsx +68 -0
  29. package/src/pages/DocsLayout.tsx +18 -0
  30. package/src/pages/DocsPage.tsx +43 -0
  31. package/src/pages/NotFound.tsx +10 -0
  32. package/src/pages/__tests__/head.test.tsx +57 -0
  33. package/src/server/App.tsx +59 -0
  34. package/src/server/__tests__/entry-server.test.tsx +35 -0
  35. package/src/server/__tests__/handlers.test.ts +77 -0
  36. package/src/server/__tests__/og.test.ts +23 -0
  37. package/src/server/__tests__/router.test.ts +72 -0
  38. package/src/server/__tests__/vite-config.test.ts +25 -0
  39. package/src/server/adapters/vercel.ts +133 -0
  40. package/src/server/build-search-index.ts +107 -0
  41. package/src/server/dev.ts +158 -0
  42. package/src/server/entry-client.tsx +74 -0
  43. package/src/server/entry-prod.ts +98 -0
  44. package/src/server/entry-server.tsx +35 -0
  45. package/src/server/entry-vercel.ts +28 -0
  46. package/src/server/handlers/apis-proxy.ts +57 -0
  47. package/src/{app/api/health/route.ts → server/handlers/health.ts} +1 -1
  48. package/src/server/handlers/llms.ts +58 -0
  49. package/src/server/handlers/og.ts +87 -0
  50. package/src/server/handlers/robots.ts +11 -0
  51. package/src/server/handlers/search.ts +172 -0
  52. package/src/server/handlers/sitemap.ts +39 -0
  53. package/src/server/handlers/specs.ts +9 -0
  54. package/src/server/index.html +12 -0
  55. package/src/server/prod.ts +18 -0
  56. package/src/server/request-handler.ts +64 -0
  57. package/src/server/router.ts +42 -0
  58. package/src/server/utils/safe-path.ts +14 -0
  59. package/src/server/vite-config.ts +71 -0
  60. package/src/themes/default/Layout.tsx +9 -10
  61. package/src/themes/default/Page.module.css +60 -0
  62. package/src/themes/default/font.ts +4 -6
  63. package/src/themes/paper/ChapterNav.tsx +5 -6
  64. package/src/themes/paper/Page.tsx +8 -9
  65. package/src/types/config.ts +11 -0
  66. package/src/types/content.ts +1 -0
  67. package/tsconfig.json +29 -0
  68. package/next.config.mjs +0 -10
  69. package/source.config.ts +0 -50
  70. package/src/app/[[...slug]]/layout.tsx +0 -15
  71. package/src/app/[[...slug]]/page.tsx +0 -57
  72. package/src/app/api/apis-proxy/route.ts +0 -59
  73. package/src/app/api/search/route.ts +0 -90
  74. package/src/app/apis/[[...slug]]/page.tsx +0 -57
  75. package/src/app/layout.tsx +0 -26
  76. package/src/app/llms-full.txt/route.ts +0 -18
  77. package/src/app/llms.txt/route.ts +0 -15
  78. package/src/app/providers.tsx +0 -8
  79. package/src/cli/utils/process.ts +0 -7
  80. package/src/lib/get-llm-text.ts +0 -10
  81. /package/src/{app/apis/[[...slug]]/layout.module.css → pages/ApiLayout.module.css} +0 -0
@@ -0,0 +1,107 @@
1
+ import fs from 'fs/promises'
2
+ import path from 'path'
3
+ import matter from 'gray-matter'
4
+ import { loadConfig } from '@/lib/config'
5
+ import { loadApiSpecs } from '@/lib/openapi'
6
+ import { getSpecSlug } from '@/lib/api-routes'
7
+ import type { OpenAPIV3 } from 'openapi-types'
8
+
9
+ interface SearchDocument {
10
+ id: string
11
+ url: string
12
+ title: string
13
+ content: string
14
+ type: 'page' | 'api'
15
+ }
16
+
17
+ function extractHeadings(markdown: string): string {
18
+ const headingRegex = /^#{1,6}\s+(.+)$/gm
19
+ const headings: string[] = []
20
+ let match
21
+ while ((match = headingRegex.exec(markdown)) !== null) {
22
+ headings.push(match[1].trim())
23
+ }
24
+ return headings.join(' ')
25
+ }
26
+
27
+ async function scanContent(contentDir: string): Promise<SearchDocument[]> {
28
+ const docs: SearchDocument[] = []
29
+
30
+ async function scan(dir: string, prefix: string[] = []) {
31
+ let entries
32
+ try { entries = await fs.readdir(dir, { withFileTypes: true }) }
33
+ catch { return }
34
+
35
+ for (const entry of entries) {
36
+ if (entry.name.startsWith('.') || entry.name === 'node_modules') continue
37
+ const fullPath = path.join(dir, entry.name)
38
+
39
+ if (entry.isDirectory()) {
40
+ await scan(fullPath, [...prefix, entry.name])
41
+ continue
42
+ }
43
+
44
+ if (!entry.name.endsWith('.mdx') && !entry.name.endsWith('.md')) continue
45
+
46
+ const raw = await fs.readFile(fullPath, 'utf-8')
47
+ const { data: fm, content } = matter(raw)
48
+ const baseName = entry.name.replace(/\.(mdx|md)$/, '')
49
+ const slugs = baseName === 'index' ? prefix : [...prefix, baseName]
50
+ const url = slugs.length === 0 ? '/' : '/' + slugs.join('/')
51
+
52
+ docs.push({
53
+ id: url,
54
+ url,
55
+ title: fm.title ?? baseName,
56
+ content: extractHeadings(content),
57
+ type: 'page',
58
+ })
59
+ }
60
+ }
61
+
62
+ await scan(contentDir)
63
+ return docs
64
+ }
65
+
66
+ function buildApiDocs(): SearchDocument[] {
67
+ const config = loadConfig()
68
+ if (!config.api?.length) return []
69
+
70
+ const docs: SearchDocument[] = []
71
+ const specs = loadApiSpecs(config.api)
72
+
73
+ for (const spec of specs) {
74
+ const specSlug = getSpecSlug(spec)
75
+ const paths = spec.document.paths ?? {}
76
+ for (const [, pathItem] of Object.entries(paths)) {
77
+ if (!pathItem) continue
78
+ for (const method of ['get', 'post', 'put', 'delete', 'patch'] as const) {
79
+ const op = pathItem[method] as OpenAPIV3.OperationObject | undefined
80
+ if (!op?.operationId) continue
81
+ const url = `/apis/${specSlug}/${encodeURIComponent(op.operationId)}`
82
+ docs.push({
83
+ id: url,
84
+ url,
85
+ title: `${method.toUpperCase()} ${op.summary ?? op.operationId}`,
86
+ content: op.description ?? '',
87
+ type: 'api',
88
+ })
89
+ }
90
+ }
91
+ }
92
+
93
+ return docs
94
+ }
95
+
96
+ export async function generateSearchIndex(contentDir: string, outDir: string) {
97
+ const [contentDocs, apiDocs] = await Promise.all([
98
+ scanContent(contentDir),
99
+ Promise.resolve(buildApiDocs()),
100
+ ])
101
+
102
+ const documents = [...contentDocs, ...apiDocs]
103
+ const outPath = path.join(outDir, 'search-index.json')
104
+ await fs.writeFile(outPath, JSON.stringify(documents))
105
+
106
+ return documents.length
107
+ }
@@ -0,0 +1,158 @@
1
+ import { createServer as createViteServer } from 'vite'
2
+ import { createServer } from 'http'
3
+ import fsPromises from 'fs/promises'
4
+ import { createReadStream } from 'fs'
5
+ import path from 'path'
6
+ import chalk from 'chalk'
7
+ import { createViteConfig } from './vite-config'
8
+ import { safePath } from './utils/safe-path'
9
+
10
+ export interface DevServerOptions {
11
+ port: number
12
+ root: string
13
+ contentDir: string
14
+ }
15
+
16
+ export async function startDevServer(options: DevServerOptions) {
17
+ const { port, root, contentDir } = options
18
+
19
+ const viteConfig = await createViteConfig({ root, contentDir, isDev: true })
20
+ const vite = await createViteServer({
21
+ ...viteConfig,
22
+ server: { middlewareMode: true },
23
+ appType: 'custom',
24
+ })
25
+
26
+ const templatePath = path.resolve(root, 'src/server/index.html')
27
+
28
+ const server = createServer(async (req, res) => {
29
+ const url = req.url || '/'
30
+
31
+ try {
32
+ // Let Vite handle its own requests (HMR, modules)
33
+ if (url.startsWith('/@') || url.startsWith('/__vite') || url.startsWith('/node_modules/')) {
34
+ vite.middlewares(req, res, () => {
35
+ res.statusCode = 404
36
+ res.end()
37
+ })
38
+ return
39
+ }
40
+
41
+ // Serve static files from content dir (skip .md/.mdx)
42
+ const contentFile = safePath(contentDir, url)
43
+ if (contentFile && !url.endsWith('.md') && !url.endsWith('.mdx')) {
44
+ try {
45
+ const stat = await fsPromises.stat(contentFile)
46
+ if (stat.isFile()) {
47
+ const ext = path.extname(contentFile).toLowerCase()
48
+ const mimeTypes: Record<string, string> = {
49
+ '.png': 'image/png', '.jpg': 'image/jpeg', '.jpeg': 'image/jpeg',
50
+ '.gif': 'image/gif', '.svg': 'image/svg+xml', '.webp': 'image/webp',
51
+ '.ico': 'image/x-icon', '.pdf': 'application/pdf', '.json': 'application/json',
52
+ '.yaml': 'text/yaml', '.yml': 'text/yaml', '.txt': 'text/plain',
53
+ }
54
+ res.setHeader('Content-Type', mimeTypes[ext] || 'application/octet-stream')
55
+ createReadStream(contentFile).pipe(res)
56
+ return
57
+ }
58
+ } catch { /* fall through to SSR */ }
59
+ }
60
+
61
+ // Let Vite handle JS/CSS/TS module requests and other static assets
62
+ if (/\.(js|ts|tsx|css|map)(\?|$)/.test(url)) {
63
+ vite.middlewares(req, res, () => {
64
+ res.statusCode = 404
65
+ res.end()
66
+ })
67
+ return
68
+ }
69
+
70
+ // Check API/static routes (load through Vite SSR for import.meta.glob support)
71
+ const { matchRoute } = await vite.ssrLoadModule(path.resolve(root, 'src/server/router.ts'))
72
+ const routeHandler = matchRoute(new URL(url, `http://localhost:${port}`).href)
73
+ if (routeHandler) {
74
+ const request = new Request(new URL(url, `http://localhost:${port}`))
75
+ const response = await routeHandler(request)
76
+ res.statusCode = response.status
77
+ response.headers.forEach((value: string, key: string) => res.setHeader(key, value))
78
+ const body = await response.text()
79
+ res.end(body)
80
+ return
81
+ }
82
+
83
+ // Resolve page data before SSR render
84
+ const pathname = new URL(url, `http://localhost:${port}`).pathname
85
+ const slug = pathname === '/' ? [] : pathname.slice(1).split('/').filter(Boolean)
86
+
87
+ const source = await vite.ssrLoadModule(path.resolve(root, 'src/lib/source.ts'))
88
+ const { mdxComponents } = await vite.ssrLoadModule(path.resolve(root, 'src/components/mdx/index.tsx'))
89
+ const { loadConfig } = await vite.ssrLoadModule(path.resolve(root, 'src/lib/config.ts'))
90
+
91
+ const config = loadConfig()
92
+
93
+ const { loadApiSpecs } = await vite.ssrLoadModule(path.resolve(root, 'src/lib/openapi.ts'))
94
+ const apiSpecs = config.api?.length ? loadApiSpecs(config.api) : []
95
+
96
+ const [tree, sourcePage] = await Promise.all([
97
+ source.buildPageTree(),
98
+ source.getPage(slug),
99
+ ])
100
+
101
+ let pageData = null
102
+ // Don't embed apiSpecs — too large. Client fetches via /api/specs
103
+ let embeddedData: any = { config, tree, slug, frontmatter: null, filePath: null }
104
+
105
+ if (sourcePage) {
106
+ const component = await source.loadPageComponent(sourcePage)
107
+ const React = await import('react')
108
+ const MDXBody = component
109
+ pageData = {
110
+ slug,
111
+ frontmatter: sourcePage.frontmatter,
112
+ content: MDXBody ? React.createElement(MDXBody, { components: mdxComponents }) : null,
113
+ }
114
+ embeddedData.frontmatter = sourcePage.frontmatter
115
+ embeddedData.filePath = sourcePage.filePath
116
+ }
117
+
118
+ // SSR render
119
+ let template = await fsPromises.readFile(templatePath, 'utf-8')
120
+ template = await vite.transformIndexHtml(url, template)
121
+
122
+ // Embed page data for client hydration
123
+ const safeJson = JSON.stringify(embeddedData).replace(/</g, '\\u003c')
124
+ const dataScript = `<script>window.__PAGE_DATA__ = ${safeJson}</script>`
125
+ template = template.replace('<!--head-outlet-->', `<!--head-outlet-->${dataScript}`)
126
+
127
+ const { render } = await vite.ssrLoadModule(path.resolve(root, 'src/server/entry-server.tsx'))
128
+
129
+ const html = render(url, { config, tree, page: pageData, apiSpecs })
130
+ const finalHtml = template.replace('<!--ssr-outlet-->', html)
131
+
132
+ res.setHeader('Content-Type', 'text/html')
133
+ res.statusCode = 200
134
+ res.end(finalHtml)
135
+ } catch (e) {
136
+ vite.ssrFixStacktrace(e as Error)
137
+ console.error(e)
138
+ res.statusCode = 500
139
+ res.end((e as Error).message)
140
+ }
141
+ })
142
+
143
+ server.listen(port, () => {
144
+ console.log(chalk.cyan(`\n Chronicle dev server running at:`))
145
+ console.log(chalk.green(` http://localhost:${port}\n`))
146
+ })
147
+
148
+ // Graceful shutdown
149
+ const shutdown = () => {
150
+ vite.close()
151
+ server.close()
152
+ process.exit(0)
153
+ }
154
+ process.on('SIGINT', shutdown)
155
+ process.on('SIGTERM', shutdown)
156
+
157
+ return { server, vite }
158
+ }
@@ -0,0 +1,74 @@
1
+ import { hydrateRoot } from 'react-dom/client'
2
+ import { BrowserRouter } from 'react-router-dom'
3
+ import { PageProvider } from '@/lib/page-context'
4
+ import { App } from './App'
5
+ import { getPage, loadPageComponent, buildPageTree } from '@/lib/source'
6
+ import { mdxComponents } from '@/components/mdx'
7
+ import type { ChronicleConfig, PageTree } from '@/types'
8
+ import type { ApiSpec } from '@/lib/openapi'
9
+ import type { ReactNode } from 'react'
10
+ import React from 'react'
11
+
12
+ interface EmbeddedData {
13
+ config: ChronicleConfig
14
+ tree: PageTree
15
+ slug: string[]
16
+ frontmatter: { title: string; description?: string; order?: number }
17
+ filePath: string
18
+ }
19
+
20
+ async function hydrate() {
21
+ try {
22
+ const embedded: EmbeddedData | undefined = (window as any).__PAGE_DATA__
23
+
24
+ let config: ChronicleConfig = { title: 'Documentation' }
25
+ let tree: PageTree = { name: 'root', children: [] }
26
+ let page: { slug: string[]; frontmatter: any; content: ReactNode } | null = null
27
+ let apiSpecs: ApiSpec[] = []
28
+
29
+ if (embedded) {
30
+ config = embedded.config
31
+ tree = embedded.tree
32
+
33
+ // Fetch API specs if on /apis route
34
+ const isApiRoute = window.location.pathname.startsWith('/apis')
35
+ if (isApiRoute && config.api?.length) {
36
+ try {
37
+ const res = await fetch('/api/specs')
38
+ apiSpecs = await res.json()
39
+ } catch { /* will load on demand */ }
40
+ }
41
+
42
+ const sourcePage = await getPage(embedded.slug)
43
+ if (sourcePage) {
44
+ const component = await loadPageComponent(sourcePage)
45
+ page = {
46
+ slug: embedded.slug,
47
+ frontmatter: embedded.frontmatter,
48
+ content: component ? React.createElement(component, { components: mdxComponents }) : null,
49
+ }
50
+ } else {
51
+ page = {
52
+ slug: embedded.slug,
53
+ frontmatter: embedded.frontmatter,
54
+ content: null,
55
+ }
56
+ }
57
+ } else {
58
+ tree = await buildPageTree()
59
+ }
60
+
61
+ hydrateRoot(
62
+ document.getElementById('root') as HTMLElement,
63
+ <BrowserRouter>
64
+ <PageProvider initialConfig={config} initialTree={tree} initialPage={page} initialApiSpecs={apiSpecs}>
65
+ <App />
66
+ </PageProvider>
67
+ </BrowserRouter>,
68
+ )
69
+ } catch (err) {
70
+ console.error('Hydration failed:', err)
71
+ }
72
+ }
73
+
74
+ hydrate()
@@ -0,0 +1,98 @@
1
+ // Production server entry — built by Vite, loaded by prod.ts at runtime
2
+ import { createServer } from 'http'
3
+ import { readFileSync, createReadStream } from 'fs'
4
+ import fsPromises from 'fs/promises'
5
+ import path from 'path'
6
+ import { render } from './entry-server'
7
+ import { matchRoute } from './router'
8
+ import { loadConfig } from '@/lib/config'
9
+ import { loadApiSpecs } from '@/lib/openapi'
10
+ import { getPage, loadPageComponent, buildPageTree } from '@/lib/source'
11
+ import { handleRequest } from './request-handler'
12
+ import { safePath } from './utils/safe-path'
13
+
14
+ export { render, matchRoute, loadConfig, loadApiSpecs, getPage, loadPageComponent, buildPageTree }
15
+
16
+ async function writeResponse(res: import('http').ServerResponse, response: Response) {
17
+ res.statusCode = response.status
18
+ response.headers.forEach((value: string, key: string) => res.setHeader(key, value))
19
+ const body = await response.text()
20
+ res.end(body)
21
+ }
22
+
23
+ export async function startServer(options: { port: number; distDir: string }) {
24
+ const { port, distDir } = options
25
+
26
+ const clientDir = path.resolve(distDir, 'client')
27
+ const templatePath = path.resolve(clientDir, 'src/server/index.html')
28
+ const template = readFileSync(templatePath, 'utf-8')
29
+
30
+ const sirv = (await import('sirv')).default
31
+ const assets = sirv(clientDir, { gzip: true })
32
+
33
+ const baseUrl = `http://localhost:${port}`
34
+
35
+ const server = createServer(async (req, res) => {
36
+ const url = req.url || '/'
37
+
38
+ try {
39
+ // API routes — handled by shared request handler
40
+ const routeHandler = matchRoute(new URL(url, baseUrl).href)
41
+ if (routeHandler) {
42
+ const response = await routeHandler(new Request(new URL(url, baseUrl)))
43
+ await writeResponse(res, response)
44
+ return
45
+ }
46
+
47
+ // Serve static files from content dir (skip .md/.mdx)
48
+ const contentDir = process.env.CHRONICLE_CONTENT_DIR || process.cwd()
49
+ const contentFile = safePath(contentDir, url)
50
+ if (contentFile && !url.endsWith('.md') && !url.endsWith('.mdx')) {
51
+ try {
52
+ const stat = await fsPromises.stat(contentFile)
53
+ if (stat.isFile()) {
54
+ const ext = path.extname(contentFile).toLowerCase()
55
+ const mimeTypes: Record<string, string> = {
56
+ '.png': 'image/png', '.jpg': 'image/jpeg', '.jpeg': 'image/jpeg',
57
+ '.gif': 'image/gif', '.svg': 'image/svg+xml', '.webp': 'image/webp',
58
+ '.ico': 'image/x-icon', '.pdf': 'application/pdf', '.json': 'application/json',
59
+ '.yaml': 'text/yaml', '.yml': 'text/yaml', '.txt': 'text/plain',
60
+ }
61
+ res.setHeader('Content-Type', mimeTypes[ext] || 'application/octet-stream')
62
+ createReadStream(contentFile).pipe(res)
63
+ return
64
+ }
65
+ } catch { /* fall through */ }
66
+ }
67
+
68
+ // Static assets from dist/client
69
+ const assetHandled = await new Promise<boolean>((resolve) => {
70
+ assets(req, res, () => resolve(false))
71
+ res.on('close', () => resolve(true))
72
+ })
73
+ if (assetHandled) return
74
+
75
+ // SSR render — handled by shared request handler
76
+ const response = await handleRequest(url, { template, baseUrl })
77
+ await writeResponse(res, response)
78
+ } catch (e) {
79
+ console.error(e)
80
+ res.statusCode = 500
81
+ res.end('Internal Server Error')
82
+ }
83
+ })
84
+
85
+ server.listen(port, () => {
86
+ console.log(`\n Chronicle production server running at:`)
87
+ console.log(` http://localhost:${port}\n`)
88
+ })
89
+
90
+ const shutdown = () => {
91
+ server.close()
92
+ process.exit(0)
93
+ }
94
+ process.on('SIGINT', shutdown)
95
+ process.on('SIGTERM', shutdown)
96
+
97
+ return server
98
+ }
@@ -0,0 +1,35 @@
1
+ import { renderToString } from 'react-dom/server'
2
+ import { StaticRouter } from 'react-router-dom'
3
+ import { PageProvider } from '@/lib/page-context'
4
+ import { App } from './App'
5
+ import type { ReactNode } from 'react'
6
+ import type { ChronicleConfig, Frontmatter, PageTree } from '@/types'
7
+ import type { ApiSpec } from '@/lib/openapi'
8
+
9
+ export interface SSRData {
10
+ config: ChronicleConfig
11
+ tree: PageTree
12
+ page: {
13
+ slug: string[]
14
+ frontmatter: Frontmatter
15
+ content: ReactNode
16
+ } | null
17
+ apiSpecs: ApiSpec[]
18
+ }
19
+
20
+ export function render(url: string, data: SSRData): string {
21
+ const pathname = new URL(url, 'http://localhost').pathname
22
+
23
+ return renderToString(
24
+ <StaticRouter location={pathname}>
25
+ <PageProvider
26
+ initialConfig={data.config}
27
+ initialTree={data.tree}
28
+ initialPage={data.page}
29
+ initialApiSpecs={data.apiSpecs}
30
+ >
31
+ <App />
32
+ </PageProvider>
33
+ </StaticRouter>,
34
+ )
35
+ }
@@ -0,0 +1,28 @@
1
+ // Vercel serverless function entry — built by Vite, deployed as catch-all function
2
+ import type { IncomingMessage, ServerResponse } from 'http'
3
+ import { readFileSync } from 'fs'
4
+ import { fileURLToPath } from 'url'
5
+ import path from 'path'
6
+ import { handleRequest } from './request-handler'
7
+
8
+ const __dirname = path.dirname(fileURLToPath(import.meta.url))
9
+ const templatePath = path.resolve(__dirname, 'index.html')
10
+ const template = readFileSync(templatePath, 'utf-8')
11
+
12
+ export default async function handler(req: IncomingMessage, res: ServerResponse) {
13
+ const url = req.url || '/'
14
+ const baseUrl = `https://${req.headers.host || 'localhost'}`
15
+
16
+ try {
17
+ const response = await handleRequest(url, { template, baseUrl })
18
+
19
+ res.statusCode = response.status
20
+ response.headers.forEach((value: string, key: string) => res.setHeader(key, value))
21
+ const body = await response.text()
22
+ res.end(body)
23
+ } catch (e) {
24
+ console.error(e)
25
+ res.statusCode = 500
26
+ res.end('Internal Server Error')
27
+ }
28
+ }
@@ -0,0 +1,57 @@
1
+ import { loadConfig } from '@/lib/config'
2
+ import { loadApiSpecs } from '@/lib/openapi'
3
+
4
+ export async function handleApisProxy(req: Request): Promise<Response> {
5
+ if (req.method !== 'POST') {
6
+ return Response.json({ error: 'Method not allowed' }, { status: 405 })
7
+ }
8
+
9
+ const { specName, method, path, headers, body } = await req.json()
10
+
11
+ if (!specName || !method || !path) {
12
+ return Response.json({ error: 'Missing specName, method, or path' }, { status: 400 })
13
+ }
14
+
15
+ const config = loadConfig()
16
+ const specs = loadApiSpecs(config.api ?? [])
17
+ const spec = specs.find((s) => s.name === specName)
18
+
19
+ if (!spec) {
20
+ return Response.json({ error: `Unknown spec: ${specName}` }, { status: 404 })
21
+ }
22
+
23
+ // Validate path doesn't contain protocol or escape the base URL
24
+ if (/^[a-z]+:\/\//i.test(path) || path.includes('..')) {
25
+ return Response.json({ error: 'Invalid path' }, { status: 400 })
26
+ }
27
+
28
+ const url = spec.server.url + path
29
+
30
+ try {
31
+ const response = await fetch(url, {
32
+ method,
33
+ headers,
34
+ body: body ? JSON.stringify(body) : undefined,
35
+ })
36
+
37
+ const contentType = response.headers.get('content-type') ?? ''
38
+ const responseBody = contentType.includes('application/json')
39
+ ? await response.json()
40
+ : await response.text()
41
+
42
+ return Response.json({
43
+ status: response.status,
44
+ statusText: response.statusText,
45
+ body: responseBody,
46
+ }, { status: response.status })
47
+ } catch (error) {
48
+ const message = error instanceof Error
49
+ ? `${error.message}${error.cause ? `: ${(error.cause as Error).message}` : ''}`
50
+ : 'Request failed'
51
+ return Response.json({
52
+ status: 502,
53
+ statusText: 'Bad Gateway',
54
+ body: `Could not reach ${url}\n${message}`,
55
+ }, { status: 502 })
56
+ }
57
+ }
@@ -1,3 +1,3 @@
1
- export function GET() {
1
+ export function handleHealth(): Response {
2
2
  return Response.json({ status: 'ok' })
3
3
  }
@@ -0,0 +1,58 @@
1
+ import fs from 'fs/promises'
2
+ import path from 'path'
3
+ import matter from 'gray-matter'
4
+ import { loadConfig } from '@/lib/config'
5
+
6
+ function getContentDir(): string {
7
+ return process.env.CHRONICLE_CONTENT_DIR || path.join(process.cwd(), 'content')
8
+ }
9
+
10
+ async function scanPages(): Promise<{ title: string; url: string }[]> {
11
+ const contentDir = getContentDir()
12
+ const pages: { title: string; url: string }[] = []
13
+
14
+ async function scan(dir: string, prefix: string[] = []) {
15
+ let entries
16
+ try { entries = await fs.readdir(dir, { withFileTypes: true }) }
17
+ catch { return }
18
+
19
+ for (const entry of entries) {
20
+ if (entry.name.startsWith('.') || entry.name === 'node_modules') continue
21
+ const fullPath = path.join(dir, entry.name)
22
+
23
+ if (entry.isDirectory()) {
24
+ await scan(fullPath, [...prefix, entry.name])
25
+ continue
26
+ }
27
+
28
+ if (!entry.name.endsWith('.mdx') && !entry.name.endsWith('.md')) continue
29
+
30
+ const raw = await fs.readFile(fullPath, 'utf-8')
31
+ const { data: fm } = matter(raw)
32
+ const baseName = entry.name.replace(/\.(mdx|md)$/, '')
33
+ const slugs = baseName === 'index' ? prefix : [...prefix, baseName]
34
+ const url = slugs.length === 0 ? '/' : '/' + slugs.join('/')
35
+
36
+ pages.push({ title: fm.title ?? baseName, url })
37
+ }
38
+ }
39
+
40
+ await scan(contentDir)
41
+ return pages
42
+ }
43
+
44
+ export async function handleLlms(): Promise<Response> {
45
+ const config = loadConfig()
46
+
47
+ if (!config.llms?.enabled) {
48
+ return new Response('Not Found', { status: 404 })
49
+ }
50
+
51
+ const pages = await scanPages()
52
+ const index = pages.map((p) => `- [${p.title}](${p.url})`).join('\n')
53
+ const body = `# ${config.title}\n\n${config.description ?? ''}\n\n${index}`
54
+
55
+ return new Response(body, {
56
+ headers: { 'Content-Type': 'text/plain' },
57
+ })
58
+ }