@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.
- package/dist/cli/index.js +481 -42
- package/package.json +1 -1
- package/src/cli/commands/build.ts +30 -2
- package/src/components/ui/search.tsx +10 -6
- package/src/server/adapters/vercel.ts +133 -0
- package/src/server/build-search-index.ts +107 -0
- package/src/server/dev.ts +5 -3
- package/src/server/entry-prod.ts +21 -50
- package/src/server/entry-vercel.ts +28 -0
- package/src/server/handlers/apis-proxy.ts +5 -0
- package/src/server/handlers/search.ts +46 -14
- package/src/server/request-handler.ts +64 -0
- package/src/server/utils/safe-path.ts +14 -0
- package/src/server/vite-config.ts +1 -1
- package/src/themes/default/Page.module.css +8 -4
|
@@ -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
|
|
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:
|
|
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
|
-
|
|
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 () =>
|
|
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 =
|
|
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
|
|
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'))
|
package/src/server/entry-prod.ts
CHANGED
|
@@ -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 {
|
|
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,
|
|
39
|
+
// API routes — handled by shared request handler
|
|
40
|
+
const routeHandler = matchRoute(new URL(url, baseUrl).href)
|
|
32
41
|
if (routeHandler) {
|
|
33
|
-
const
|
|
34
|
-
|
|
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 =
|
|
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
|
-
//
|
|
71
|
-
const
|
|
72
|
-
|
|
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(
|
|
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
|
|
95
|
-
|
|
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
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
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
|
-
|
|
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
|
|
123
|
-
const suggestions =
|
|
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
|
+
}
|