@raystack/chronicle 0.1.0-canary.c5d277e → 0.1.0-canary.cb102e9

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.
@@ -8,10 +8,17 @@ export const buildCommand = new Command('build')
8
8
  .description('Build for production')
9
9
  .option('-c, --content <path>', 'Content directory')
10
10
  .option('-o, --outDir <path>', 'Output directory', 'dist')
11
+ .option('--adapter <adapter>', 'Deploy adapter (vercel)')
11
12
  .action(async (options) => {
12
13
  const contentDir = resolveContentDir(options.content)
13
14
  const outDir = path.resolve(options.outDir)
14
15
 
16
+ const VALID_ADAPTERS = ['vercel']
17
+ if (options.adapter && !VALID_ADAPTERS.includes(options.adapter)) {
18
+ console.error(chalk.red(`Unknown adapter: ${options.adapter}. Valid adapters: ${VALID_ADAPTERS.join(', ')}`))
19
+ process.exit(1)
20
+ }
21
+
15
22
  process.env.CHRONICLE_PROJECT_ROOT = process.cwd()
16
23
  process.env.CHRONICLE_CONTENT_DIR = contentDir
17
24
 
@@ -35,7 +42,11 @@ export const buildCommand = new Command('build')
35
42
  },
36
43
  })
37
44
 
38
- // Build server bundle (noExternal: true to bundle all deps for portability)
45
+ // Build server bundle
46
+ const serverEntry = options.adapter === 'vercel'
47
+ ? path.resolve(PACKAGE_ROOT, 'src/server/entry-vercel.ts')
48
+ : path.resolve(PACKAGE_ROOT, 'src/server/entry-prod.ts')
49
+
39
50
  console.log(chalk.gray('Building server...'))
40
51
  await build({
41
52
  ...baseConfig,
@@ -44,9 +55,26 @@ export const buildCommand = new Command('build')
44
55
  },
45
56
  build: {
46
57
  outDir: path.join(outDir, 'server'),
47
- ssr: path.resolve(PACKAGE_ROOT, 'src/server/entry-prod.ts'),
58
+ ssr: serverEntry,
59
+ target: 'node22',
48
60
  },
49
61
  })
50
62
 
63
+ // Generate search index
64
+ console.log(chalk.gray('Building search index...'))
65
+ const { generateSearchIndex } = await import('@/server/build-search-index')
66
+ const docCount = await generateSearchIndex(contentDir, path.join(outDir, 'server'))
67
+ console.log(chalk.gray(` Indexed ${docCount} documents`))
68
+
51
69
  console.log(chalk.green('Build complete →'), outDir)
70
+
71
+ // Run Vercel adapter post-build
72
+ if (options.adapter === 'vercel') {
73
+ const { buildVercelOutput } = await import('@/server/adapters/vercel')
74
+ await buildVercelOutput({
75
+ distDir: outDir,
76
+ contentDir,
77
+ projectRoot: process.cwd(),
78
+ })
79
+ }
52
80
  })
@@ -18,23 +18,27 @@ interface SearchResult {
18
18
  function useSearch(query: string) {
19
19
  const [results, setResults] = useState<SearchResult[]>([]);
20
20
  const [isLoading, setIsLoading] = useState(false);
21
- const timerRef = useRef<ReturnType<typeof setTimeout>>();
21
+ const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
22
22
 
23
23
  useEffect(() => {
24
- clearTimeout(timerRef.current);
24
+ let cancelled = false;
25
+ if (timerRef.current) clearTimeout(timerRef.current);
25
26
  timerRef.current = setTimeout(async () => {
26
27
  setIsLoading(true);
27
28
  try {
28
29
  const params = new URLSearchParams();
29
30
  if (query) params.set("query", query);
30
31
  const res = await fetch(`/api/search?${params}`);
31
- setResults(await res.json());
32
+ if (!cancelled) setResults(await res.json());
32
33
  } catch {
33
- setResults([]);
34
+ if (!cancelled) setResults([]);
34
35
  }
35
- setIsLoading(false);
36
+ if (!cancelled) setIsLoading(false);
36
37
  }, 100);
37
- return () => clearTimeout(timerRef.current);
38
+ return () => {
39
+ cancelled = true;
40
+ if (timerRef.current) clearTimeout(timerRef.current);
41
+ };
38
42
  }, [query]);
39
43
 
40
44
  return { results, isLoading };
@@ -0,0 +1,133 @@
1
+ import path from 'path'
2
+ import fs from 'fs/promises'
3
+ import { existsSync } from 'fs'
4
+ import chalk from 'chalk'
5
+
6
+ interface VercelAdapterOptions {
7
+ distDir: string
8
+ contentDir: string
9
+ projectRoot: string
10
+ }
11
+
12
+ const CONTENT_EXTENSIONS = new Set([
13
+ '.png', '.jpg', '.jpeg', '.gif', '.svg', '.webp', '.ico',
14
+ '.pdf', '.json', '.yaml', '.yml', '.txt',
15
+ ])
16
+
17
+ export async function buildVercelOutput(options: VercelAdapterOptions) {
18
+ const { distDir, contentDir, projectRoot } = options
19
+ const outputDir = path.resolve(projectRoot, '.vercel/output')
20
+
21
+ console.log(chalk.gray('Generating Vercel output...'))
22
+
23
+ // Clean previous output
24
+ await fs.rm(outputDir, { recursive: true, force: true })
25
+
26
+ // Create output directories
27
+ const staticDir = path.resolve(outputDir, 'static')
28
+ const funcDir = path.resolve(outputDir, 'functions/index.func')
29
+ await fs.mkdir(staticDir, { recursive: true })
30
+ await fs.mkdir(funcDir, { recursive: true })
31
+
32
+ // 1. Copy client assets → .vercel/output/static/
33
+ const clientDir = path.resolve(distDir, 'client')
34
+ await copyDir(clientDir, staticDir)
35
+ console.log(chalk.gray(' Copied client assets to static/'))
36
+
37
+ // 2. Copy content dir assets (images, etc.) → .vercel/output/static/
38
+ if (existsSync(contentDir)) {
39
+ await copyContentAssets(contentDir, staticDir)
40
+ console.log(chalk.gray(' Copied content assets to static/'))
41
+ }
42
+
43
+ // 3. Copy server bundle → .vercel/output/functions/index.func/
44
+ const serverDir = path.resolve(distDir, 'server')
45
+ await copyDir(serverDir, funcDir)
46
+ console.log(chalk.gray(' Copied server bundle to functions/'))
47
+
48
+ // 4. Copy HTML template into function dir (not accessible from static/ at runtime)
49
+ const templateSrc = path.resolve(clientDir, 'src/server/index.html')
50
+ await fs.copyFile(templateSrc, path.resolve(funcDir, 'index.html'))
51
+
52
+ // 5. Write package.json for ESM support
53
+ await fs.writeFile(
54
+ path.resolve(funcDir, 'package.json'),
55
+ JSON.stringify({ type: 'module' }, null, 2),
56
+ )
57
+
58
+ // 6. Write .vc-config.json
59
+ await fs.writeFile(
60
+ path.resolve(funcDir, '.vc-config.json'),
61
+ JSON.stringify({
62
+ runtime: 'nodejs24.x',
63
+ handler: 'entry-vercel.js',
64
+ launcherType: 'Nodejs',
65
+ }, null, 2),
66
+ )
67
+
68
+ // 7. Write config.json
69
+ await fs.writeFile(
70
+ path.resolve(outputDir, 'config.json'),
71
+ JSON.stringify({
72
+ version: 3,
73
+ routes: [
74
+ { handle: 'filesystem' },
75
+ { src: '/(.*)', dest: '/index' },
76
+ ],
77
+ }, null, 2),
78
+ )
79
+
80
+ console.log(chalk.green('Vercel output generated →'), outputDir)
81
+ }
82
+
83
+ async function copyDir(src: string, dest: string) {
84
+ await fs.mkdir(dest, { recursive: true })
85
+ const entries = await fs.readdir(src, { withFileTypes: true })
86
+
87
+ for (const entry of entries) {
88
+ const srcPath = path.join(src, entry.name)
89
+ const destPath = path.join(dest, entry.name)
90
+
91
+ if (entry.isDirectory()) {
92
+ await copyDir(srcPath, destPath)
93
+ } else {
94
+ await fs.copyFile(srcPath, destPath)
95
+ }
96
+ }
97
+ }
98
+
99
+ async function copyContentAssets(contentDir: string, staticDir: string) {
100
+ const entries = await fs.readdir(contentDir, { withFileTypes: true })
101
+
102
+ for (const entry of entries) {
103
+ const srcPath = path.join(contentDir, entry.name)
104
+
105
+ if (entry.isDirectory()) {
106
+ const destSubDir = path.join(staticDir, entry.name)
107
+ await copyContentAssetsRecursive(srcPath, destSubDir)
108
+ } else {
109
+ const ext = path.extname(entry.name).toLowerCase()
110
+ if (CONTENT_EXTENSIONS.has(ext)) {
111
+ await fs.copyFile(srcPath, path.join(staticDir, entry.name))
112
+ }
113
+ }
114
+ }
115
+ }
116
+
117
+ async function copyContentAssetsRecursive(srcDir: string, destDir: string) {
118
+ const entries = await fs.readdir(srcDir, { withFileTypes: true })
119
+
120
+ for (const entry of entries) {
121
+ const srcPath = path.join(srcDir, entry.name)
122
+
123
+ if (entry.isDirectory()) {
124
+ await copyContentAssetsRecursive(srcPath, path.join(destDir, entry.name))
125
+ } else {
126
+ const ext = path.extname(entry.name).toLowerCase()
127
+ if (CONTENT_EXTENSIONS.has(ext)) {
128
+ await fs.mkdir(destDir, { recursive: true })
129
+ await fs.copyFile(srcPath, path.join(destDir, entry.name))
130
+ }
131
+ }
132
+ }
133
+ }
@@ -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
+ }
package/src/server/dev.ts CHANGED
@@ -5,6 +5,7 @@ import { createReadStream } from 'fs'
5
5
  import path from 'path'
6
6
  import chalk from 'chalk'
7
7
  import { createViteConfig } from './vite-config'
8
+ import { safePath } from './utils/safe-path'
8
9
 
9
10
  export interface DevServerOptions {
10
11
  port: number
@@ -38,8 +39,8 @@ export async function startDevServer(options: DevServerOptions) {
38
39
  }
39
40
 
40
41
  // Serve static files from content dir (skip .md/.mdx)
41
- const contentFile = path.join(contentDir, decodeURIComponent(url.split('?')[0]))
42
- if (!url.endsWith('.md') && !url.endsWith('.mdx')) {
42
+ const contentFile = safePath(contentDir, url)
43
+ if (contentFile && !url.endsWith('.md') && !url.endsWith('.mdx')) {
43
44
  try {
44
45
  const stat = await fsPromises.stat(contentFile)
45
46
  if (stat.isFile()) {
@@ -119,7 +120,8 @@ export async function startDevServer(options: DevServerOptions) {
119
120
  template = await vite.transformIndexHtml(url, template)
120
121
 
121
122
  // Embed page data for client hydration
122
- const dataScript = `<script>window.__PAGE_DATA__ = ${JSON.stringify(embeddedData)}</script>`
123
+ const safeJson = JSON.stringify(embeddedData).replace(/</g, '\\u003c')
124
+ const dataScript = `<script>window.__PAGE_DATA__ = ${safeJson}</script>`
123
125
  template = template.replace('<!--head-outlet-->', `<!--head-outlet-->${dataScript}`)
124
126
 
125
127
  const { render } = await vite.ssrLoadModule(path.resolve(root, 'src/server/entry-server.tsx'))
@@ -3,16 +3,23 @@ import { createServer } from 'http'
3
3
  import { readFileSync, createReadStream } from 'fs'
4
4
  import fsPromises from 'fs/promises'
5
5
  import path from 'path'
6
- import React from 'react'
7
6
  import { render } from './entry-server'
8
7
  import { matchRoute } from './router'
9
8
  import { loadConfig } from '@/lib/config'
10
9
  import { loadApiSpecs } from '@/lib/openapi'
11
10
  import { getPage, loadPageComponent, buildPageTree } from '@/lib/source'
12
- import { mdxComponents } from '@/components/mdx'
11
+ import { handleRequest } from './request-handler'
12
+ import { safePath } from './utils/safe-path'
13
13
 
14
14
  export { render, matchRoute, loadConfig, loadApiSpecs, getPage, loadPageComponent, buildPageTree }
15
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
+
16
23
  export async function startServer(options: { port: number; distDir: string }) {
17
24
  const { port, distDir } = options
18
25
 
@@ -23,26 +30,24 @@ export async function startServer(options: { port: number; distDir: string }) {
23
30
  const sirv = (await import('sirv')).default
24
31
  const assets = sirv(clientDir, { gzip: true })
25
32
 
33
+ const baseUrl = `http://localhost:${port}`
34
+
26
35
  const server = createServer(async (req, res) => {
27
36
  const url = req.url || '/'
28
37
 
29
38
  try {
30
- // API routes
31
- const routeHandler = matchRoute(new URL(url, `http://localhost:${port}`).href)
39
+ // API routes — handled by shared request handler
40
+ const routeHandler = matchRoute(new URL(url, baseUrl).href)
32
41
  if (routeHandler) {
33
- const request = new Request(new URL(url, `http://localhost:${port}`))
34
- const response = await routeHandler(request)
35
- res.statusCode = response.status
36
- response.headers.forEach((value: string, key: string) => res.setHeader(key, value))
37
- const body = await response.text()
38
- res.end(body)
42
+ const response = await routeHandler(new Request(new URL(url, baseUrl)))
43
+ await writeResponse(res, response)
39
44
  return
40
45
  }
41
46
 
42
47
  // Serve static files from content dir (skip .md/.mdx)
43
48
  const contentDir = process.env.CHRONICLE_CONTENT_DIR || process.cwd()
44
- const contentFile = path.join(contentDir, decodeURIComponent(url.split('?')[0]))
45
- if (!url.endsWith('.md') && !url.endsWith('.mdx')) {
49
+ const contentFile = safePath(contentDir, url)
50
+ if (contentFile && !url.endsWith('.md') && !url.endsWith('.mdx')) {
46
51
  try {
47
52
  const stat = await fsPromises.stat(contentFile)
48
53
  if (stat.isFile()) {
@@ -67,47 +72,13 @@ export async function startServer(options: { port: number; distDir: string }) {
67
72
  })
68
73
  if (assetHandled) return
69
74
 
70
- // Resolve page data
71
- const pathname = new URL(url, `http://localhost:${port}`).pathname
72
- const slug = pathname === '/' ? [] : pathname.slice(1).split('/').filter(Boolean)
73
-
74
- const config = loadConfig()
75
- const apiSpecs = config.api?.length ? loadApiSpecs(config.api) : []
76
-
77
- const [tree, sourcePage] = await Promise.all([
78
- buildPageTree(),
79
- getPage(slug),
80
- ])
81
-
82
- let pageData = null
83
- let embeddedData: any = { config, tree, slug, frontmatter: null, filePath: null }
84
-
85
- if (sourcePage) {
86
- const component = await loadPageComponent(sourcePage)
87
- pageData = {
88
- slug,
89
- frontmatter: sourcePage.frontmatter,
90
- content: component ? React.createElement(component, { components: mdxComponents }) : null,
91
- }
92
- embeddedData.frontmatter = sourcePage.frontmatter
93
- embeddedData.filePath = sourcePage.filePath
94
- }
95
-
96
- // SSR render
97
- const html = render(url, { config, tree, page: pageData, apiSpecs })
98
-
99
- const dataScript = `<script>window.__PAGE_DATA__ = ${JSON.stringify(embeddedData)}</script>`
100
- const finalHtml = template
101
- .replace('<!--head-outlet-->', `<!--head-outlet-->${dataScript}`)
102
- .replace('<!--ssr-outlet-->', html)
103
-
104
- res.setHeader('Content-Type', 'text/html')
105
- res.statusCode = 200
106
- res.end(finalHtml)
75
+ // SSR render — handled by shared request handler
76
+ const response = await handleRequest(url, { template, baseUrl })
77
+ await writeResponse(res, response)
107
78
  } catch (e) {
108
79
  console.error(e)
109
80
  res.statusCode = 500
110
- res.end((e as Error).message)
81
+ res.end('Internal Server Error')
111
82
  }
112
83
  })
113
84
 
@@ -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
+ }
@@ -20,6 +20,11 @@ export async function handleApisProxy(req: Request): Promise<Response> {
20
20
  return Response.json({ error: `Unknown spec: ${specName}` }, { status: 404 })
21
21
  }
22
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
+
23
28
  const url = spec.server.url + path
24
29
 
25
30
  try {
@@ -16,7 +16,35 @@ interface SearchDocument {
16
16
  }
17
17
 
18
18
  let searchIndex: MiniSearch<SearchDocument> | null = null
19
+ let cachedDocs: SearchDocument[] | null = null
19
20
 
21
+ function createIndex(docs: SearchDocument[]): MiniSearch<SearchDocument> {
22
+ const index = new MiniSearch<SearchDocument>({
23
+ fields: ['title', 'content'],
24
+ storeFields: ['url', 'title', 'type'],
25
+ searchOptions: {
26
+ boost: { title: 2 },
27
+ fuzzy: 0.2,
28
+ prefix: true,
29
+ },
30
+ })
31
+ index.addAll(docs)
32
+ return index
33
+ }
34
+
35
+ // Try loading pre-built search index (generated at build time)
36
+ async function loadPrebuiltIndex(): Promise<SearchDocument[] | null> {
37
+ try {
38
+ // In bundled server, search-index.json is next to the entry file
39
+ const indexPath = path.resolve(__dirname, 'search-index.json')
40
+ const raw = await fs.readFile(indexPath, 'utf-8')
41
+ return JSON.parse(raw)
42
+ } catch {
43
+ return null
44
+ }
45
+ }
46
+
47
+ // Fallback: scan filesystem at runtime (dev mode)
20
48
  function getContentDir(): string {
21
49
  return process.env.CHRONICLE_CONTENT_DIR || path.join(process.cwd(), 'content')
22
50
  }
@@ -91,25 +119,29 @@ function buildApiDocs(): SearchDocument[] {
91
119
  return docs
92
120
  }
93
121
 
94
- async function getIndex(): Promise<MiniSearch<SearchDocument>> {
95
- if (searchIndex) return searchIndex
122
+ async function loadDocuments(): Promise<SearchDocument[]> {
123
+ // Try pre-built index first
124
+ const prebuilt = await loadPrebuiltIndex()
125
+ if (prebuilt) return prebuilt
96
126
 
127
+ // Fallback to filesystem scanning (dev mode)
97
128
  const [contentDocs, apiDocs] = await Promise.all([
98
129
  scanContent(),
99
130
  Promise.resolve(buildApiDocs()),
100
131
  ])
132
+ return [...contentDocs, ...apiDocs]
133
+ }
101
134
 
102
- searchIndex = new MiniSearch<SearchDocument>({
103
- fields: ['title', 'content'],
104
- storeFields: ['url', 'title', 'type'],
105
- searchOptions: {
106
- boost: { title: 2 },
107
- fuzzy: 0.2,
108
- prefix: true,
109
- },
110
- })
135
+ async function getDocs(): Promise<SearchDocument[]> {
136
+ if (cachedDocs) return cachedDocs
137
+ cachedDocs = await loadDocuments()
138
+ return cachedDocs
139
+ }
111
140
 
112
- searchIndex.addAll([...contentDocs, ...apiDocs])
141
+ async function getIndex(): Promise<MiniSearch<SearchDocument>> {
142
+ if (searchIndex) return searchIndex
143
+ const docs = await getDocs()
144
+ searchIndex = createIndex(docs)
113
145
  return searchIndex
114
146
  }
115
147
 
@@ -119,8 +151,8 @@ export async function handleSearch(req: Request): Promise<Response> {
119
151
  const index = await getIndex()
120
152
 
121
153
  if (!query) {
122
- const contentDocs = await scanContent()
123
- const suggestions = contentDocs.slice(0, 8).map((d) => ({
154
+ const docs = await getDocs()
155
+ const suggestions = docs.filter(d => d.type === 'page').slice(0, 8).map((d) => ({
124
156
  id: d.id,
125
157
  url: d.url,
126
158
  type: d.type,
@@ -0,0 +1,64 @@
1
+ // Shared request handler for API routes + SSR rendering
2
+ // Used by entry-prod.ts (Node) and entry-vercel.ts (Vercel)
3
+ import React from 'react'
4
+ import { render } from './entry-server'
5
+ import { matchRoute } from './router'
6
+ import { loadConfig } from '@/lib/config'
7
+ import { loadApiSpecs } from '@/lib/openapi'
8
+ import { getPage, loadPageComponent, buildPageTree } from '@/lib/source'
9
+ import { mdxComponents } from '@/components/mdx'
10
+
11
+ export interface RequestHandlerOptions {
12
+ template: string
13
+ baseUrl: string
14
+ }
15
+
16
+ export async function handleRequest(url: string, options: RequestHandlerOptions): Promise<Response> {
17
+ const { template, baseUrl } = options
18
+ const fullUrl = new URL(url, baseUrl).href
19
+
20
+ // API routes
21
+ const routeHandler = matchRoute(fullUrl)
22
+ if (routeHandler) {
23
+ return routeHandler(new Request(fullUrl))
24
+ }
25
+
26
+ // SSR render
27
+ const pathname = new URL(url, baseUrl).pathname
28
+ const slug = pathname === '/' ? [] : pathname.slice(1).split('/').filter(Boolean)
29
+
30
+ const config = loadConfig()
31
+ const apiSpecs = config.api?.length ? loadApiSpecs(config.api) : []
32
+
33
+ const [tree, sourcePage] = await Promise.all([
34
+ buildPageTree(),
35
+ getPage(slug),
36
+ ])
37
+
38
+ let pageData = null
39
+ let embeddedData: any = { config, tree, slug, frontmatter: null, filePath: null }
40
+
41
+ if (sourcePage) {
42
+ const component = await loadPageComponent(sourcePage)
43
+ pageData = {
44
+ slug,
45
+ frontmatter: sourcePage.frontmatter,
46
+ content: component ? React.createElement(component, { components: mdxComponents }) : null,
47
+ }
48
+ embeddedData.frontmatter = sourcePage.frontmatter
49
+ embeddedData.filePath = sourcePage.filePath
50
+ }
51
+
52
+ const html = render(url, { config, tree, page: pageData, apiSpecs })
53
+
54
+ const safeJson = JSON.stringify(embeddedData).replace(/</g, '\\u003c')
55
+ const dataScript = `<script>window.__PAGE_DATA__ = ${safeJson}</script>`
56
+ const finalHtml = template
57
+ .replace('<!--head-outlet-->', `<!--head-outlet-->${dataScript}`)
58
+ .replace('<!--ssr-outlet-->', html)
59
+
60
+ return new Response(finalHtml, {
61
+ status: 200,
62
+ headers: { 'Content-Type': 'text/html' },
63
+ })
64
+ }