Skip to content

Commit 3e58cd9

Browse files
rsbhclaude
andcommitted
fix: replace runtime fs calls with build-time data for serverless compatibility
Runtime fs.readdir/readFile calls break on Vercel serverless where content files don't exist in the function bundle. Replace with import.meta.glob (build-time) for page tree and baked-in config for chronicle.yaml. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 1e5fdae commit 3e58cd9

6 files changed

Lines changed: 65 additions & 189 deletions

File tree

packages/chronicle/src/lib/config.ts

Lines changed: 2 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -1,45 +1,19 @@
1-
import fs from 'node:fs';
2-
import path from 'node:path';
31
import { parse } from 'yaml';
42
import type { ChronicleConfig } from '@/types';
53

6-
const CONFIG_FILE = 'chronicle.yaml';
7-
84
const defaultConfig: ChronicleConfig = {
95
title: 'Documentation',
106
theme: { name: 'default' },
117
search: { enabled: true, placeholder: 'Search...' }
128
};
139

14-
function resolveConfigPath(): string | null {
15-
const projectRoot =
16-
typeof __CHRONICLE_PROJECT_ROOT__ !== 'undefined'
17-
? __CHRONICLE_PROJECT_ROOT__
18-
: process.cwd();
19-
20-
const rootPath = path.join(projectRoot, CONFIG_FILE);
21-
if (fs.existsSync(rootPath)) return rootPath;
22-
23-
const contentDir =
24-
typeof __CHRONICLE_CONTENT_DIR__ !== 'undefined'
25-
? __CHRONICLE_CONTENT_DIR__
26-
: undefined;
27-
if (contentDir) {
28-
const contentPath = path.join(contentDir, CONFIG_FILE);
29-
if (fs.existsSync(contentPath)) return contentPath;
30-
}
31-
32-
return null;
33-
}
34-
3510
export function loadConfig(): ChronicleConfig {
36-
const configPath = resolveConfigPath();
11+
const raw = typeof __CHRONICLE_CONFIG_RAW__ !== 'undefined' ? __CHRONICLE_CONFIG_RAW__ : null;
3712

38-
if (!configPath) {
13+
if (!raw) {
3914
return defaultConfig;
4015
}
4116

42-
const raw = fs.readFileSync(configPath, 'utf-8');
4317
const userConfig = parse(raw) as Partial<ChronicleConfig>;
4418

4519
return {

packages/chronicle/src/lib/source.ts

Lines changed: 25 additions & 47 deletions
Original file line numberDiff line numberDiff line change
@@ -1,72 +1,50 @@
1-
import fs from 'node:fs/promises';
2-
import path from 'node:path';
31
import { loader } from 'fumadocs-core/source';
42
import type { Root, Node, Folder } from 'fumadocs-core/page-tree';
5-
import matter from 'gray-matter';
63
import type { MDXContent } from 'mdx/types';
74
import type { TableOfContents } from 'fumadocs-core/toc';
85
import type { Frontmatter } from '@/types';
96

10-
function getContentDir(): string {
11-
return __CHRONICLE_CONTENT_DIR__ || path.join(process.cwd(), 'content');
12-
}
7+
const CONTENT_PREFIX = '../../.content/';
8+
9+
const frontmatterGlob: Record<string, Record<string, unknown>> = import.meta.glob(
10+
'../../.content/**/*.{mdx,md}',
11+
{ eager: true, import: 'frontmatter' }
12+
);
1313

14-
async function scanFiles(contentDir: string) {
14+
const metaGlob: Record<string, Record<string, unknown>> = import.meta.glob(
15+
'../../.content/**/meta.json',
16+
{ eager: true }
17+
);
18+
19+
function buildFiles() {
1520
const files: {
1621
type: 'page' | 'meta';
1722
path: string;
1823
data: Record<string, unknown>;
1924
}[] = [];
2025

21-
async function scan(dir: string, prefix: string[] = []) {
22-
try {
23-
const entries = await fs.readdir(dir, { withFileTypes: true });
24-
for (const entry of entries) {
25-
if (entry.name.startsWith('.') || entry.name === 'node_modules')
26-
continue;
27-
const fullPath = path.join(dir, entry.name);
28-
const relativePath = [...prefix, entry.name].join('/');
29-
30-
if (entry.isDirectory()) {
31-
await scan(fullPath, [...prefix, entry.name]);
32-
continue;
33-
}
34-
35-
if (entry.name.endsWith('.mdx') || entry.name.endsWith('.md')) {
36-
const raw = await fs.readFile(fullPath, 'utf-8');
37-
const { data } = matter(raw);
38-
files.push({
39-
type: 'page',
40-
path: relativePath,
41-
data: { ...data, _relativePath: relativePath }
42-
});
43-
} else if (entry.name === 'meta.json' || entry.name === 'meta.yaml') {
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-
}
53-
}
54-
}
55-
} catch {
56-
/* directory not readable */
57-
}
26+
for (const [key, data] of Object.entries(frontmatterGlob)) {
27+
const relativePath = key.slice(CONTENT_PREFIX.length);
28+
files.push({
29+
type: 'page',
30+
path: relativePath,
31+
data: { ...data, _relativePath: relativePath }
32+
});
33+
}
34+
35+
for (const [key, data] of Object.entries(metaGlob)) {
36+
const relativePath = key.slice(CONTENT_PREFIX.length);
37+
files.push({ type: 'meta', path: relativePath, data: data ?? {} });
5838
}
5939

60-
await scan(contentDir);
6140
return files;
6241
}
6342

6443
let cachedSource: ReturnType<typeof loader> | null = null;
6544

6645
async function getSource() {
6746
if (cachedSource) return cachedSource;
68-
const contentDir = getContentDir();
69-
const files = await scanFiles(contentDir);
47+
const files = buildFiles();
7048
cachedSource = loader({
7149
source: { files },
7250
baseUrl: '/'

packages/chronicle/src/server/api/search.ts

Lines changed: 15 additions & 67 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,10 @@
1-
import fs from 'node:fs/promises';
2-
import matter from 'gray-matter';
31
import MiniSearch from 'minisearch';
42
import { defineHandler } from 'nitro';
53
import type { OpenAPIV3 } from 'openapi-types';
6-
import path from 'node:path';
74
import { getSpecSlug } from '@/lib/api-routes';
85
import { loadConfig } from '@/lib/config';
96
import { loadApiSpecs } from '@/lib/openapi';
7+
import { getPages, extractFrontmatter } from '@/lib/source';
108

119
interface SearchDocument {
1210
id: string;
@@ -33,61 +31,18 @@ function createIndex(docs: SearchDocument[]): MiniSearch<SearchDocument> {
3331
return index;
3432
}
3533

36-
async function loadPrebuiltIndex(): Promise<SearchDocument[] | null> {
37-
try {
38-
const indexPath = path.resolve(__dirname, 'search-index.json');
39-
const raw = await fs.readFile(indexPath, 'utf-8');
40-
return JSON.parse(raw);
41-
} catch {
42-
return null;
43-
}
44-
}
45-
46-
function getContentDir(): string {
47-
return __CHRONICLE_CONTENT_DIR__ || path.join(process.cwd(), 'content');
48-
}
49-
5034
async function scanContent(): Promise<SearchDocument[]> {
51-
const contentDir = getContentDir();
52-
const docs: SearchDocument[] = [];
53-
54-
async function scan(dir: string, prefix: string[] = []) {
55-
try {
56-
const entries = await fs.readdir(dir, { withFileTypes: true });
57-
for (const entry of entries) {
58-
if (entry.name.startsWith('.') || entry.name === 'node_modules')
59-
continue;
60-
const fullPath = path.join(dir, entry.name);
61-
62-
if (entry.isDirectory()) {
63-
await scan(fullPath, [...prefix, entry.name]);
64-
continue;
65-
}
66-
67-
if (!entry.name.endsWith('.mdx') && !entry.name.endsWith('.md'))
68-
continue;
69-
70-
const raw = await fs.readFile(fullPath, 'utf-8');
71-
const { data: fm, content } = matter(raw);
72-
const baseName = entry.name.replace(/\.(mdx|md)$/, '');
73-
const slugs = baseName === 'index' ? prefix : [...prefix, baseName];
74-
const url = slugs.length === 0 ? '/' : `/${slugs.join('/')}`;
75-
76-
docs.push({
77-
id: url,
78-
url,
79-
title: fm.title ?? baseName,
80-
content: content.slice(0, 5000),
81-
type: 'page'
82-
});
83-
}
84-
} catch {
85-
/* directory not readable */
86-
}
87-
}
88-
89-
await scan(contentDir);
90-
return docs;
35+
const pages = await getPages();
36+
return pages.map(p => {
37+
const fm = extractFrontmatter(p);
38+
return {
39+
id: p.url,
40+
url: p.url,
41+
title: fm.title,
42+
content: fm.description ?? '',
43+
type: 'page' as const
44+
};
45+
});
9146
}
9247

9348
async function buildApiDocs(): Promise<SearchDocument[]> {
@@ -120,20 +75,13 @@ async function buildApiDocs(): Promise<SearchDocument[]> {
12075
return docs;
12176
}
12277

123-
async function loadDocuments(): Promise<SearchDocument[]> {
124-
const prebuilt = await loadPrebuiltIndex();
125-
if (prebuilt) return prebuilt;
126-
78+
async function getDocs(): Promise<SearchDocument[]> {
79+
if (cachedDocs) return cachedDocs;
12780
const [contentDocs, apiDocs] = await Promise.all([
12881
scanContent(),
12982
buildApiDocs()
13083
]);
131-
return [...contentDocs, ...apiDocs];
132-
}
133-
134-
async function getDocs(): Promise<SearchDocument[]> {
135-
if (cachedDocs) return cachedDocs;
136-
cachedDocs = await loadDocuments();
84+
cachedDocs = [...contentDocs, ...apiDocs];
13785
return cachedDocs;
13886
}
13987

packages/chronicle/src/server/routes/llms.txt.ts

Lines changed: 6 additions & 46 deletions
Original file line numberDiff line numberDiff line change
@@ -1,49 +1,6 @@
1-
import fs from 'node:fs/promises';
2-
import path from 'node:path';
3-
import matter from 'gray-matter';
41
import { defineHandler, HTTPError } from 'nitro';
52
import { loadConfig } from '@/lib/config';
6-
7-
function getContentDir(): string {
8-
return __CHRONICLE_CONTENT_DIR__ || path.join(process.cwd(), 'content');
9-
}
10-
11-
async function scanPages(): Promise<{ title: string; url: string }[]> {
12-
const contentDir = getContentDir();
13-
const pages: { title: string; url: string }[] = [];
14-
15-
async function scan(dir: string, prefix: string[] = []) {
16-
try {
17-
const entries = await fs.readdir(dir, { withFileTypes: true });
18-
for (const entry of entries) {
19-
if (entry.name.startsWith('.') || entry.name === 'node_modules')
20-
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'))
29-
continue;
30-
31-
const raw = await fs.readFile(fullPath, 'utf-8');
32-
const { data: fm } = matter(raw);
33-
const baseName = entry.name.replace(/\.(mdx|md)$/, '');
34-
const slugs = baseName === 'index' ? prefix : [...prefix, baseName];
35-
const url = slugs.length === 0 ? '/' : `/${slugs.join('/')}`;
36-
37-
pages.push({ title: fm.title ?? baseName, url });
38-
}
39-
} catch {
40-
/* directory not readable */
41-
}
42-
}
43-
44-
await scan(contentDir);
45-
return pages;
46-
}
3+
import { getPages, extractFrontmatter } from '@/lib/source';
474

485
export default defineHandler(async event => {
496
const config = loadConfig();
@@ -52,8 +9,11 @@ export default defineHandler(async event => {
529
throw new HTTPError({ status: 404, message: 'Not Found' });
5310
}
5411

55-
const pages = await scanPages();
56-
const index = pages.map(p => `- [${p.title}](${p.url})`).join('\n');
12+
const pages = await getPages();
13+
const index = pages.map(p => {
14+
const fm = extractFrontmatter(p);
15+
return `- [${fm.title}](${p.url})`;
16+
}).join('\n');
5717
const body = `# ${config.title}\n\n${config.description ?? ''}\n\n${index}`;
5818

5919
event.res.headers.set('Content-Type', 'text/plain');

packages/chronicle/src/server/vite-config.ts

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import { remarkDirectiveAdmonition, remarkMdxMermaid } from 'fumadocs-core/mdx-p
33
import { defineConfig as defineFumadocsConfig } from 'fumadocs-mdx/config';
44
import mdx from 'fumadocs-mdx/vite';
55
import { nitro } from 'nitro/vite';
6+
import fs from 'node:fs/promises';
67
import path from 'node:path';
78
import remarkDirective from 'remark-directive';
89
import { type InlineConfig } from 'vite';
@@ -20,10 +21,23 @@ export interface ViteConfigOptions {
2021
preset?: string;
2122
}
2223

24+
async function readChronicleConfig(projectRoot: string, contentDir: string): Promise<string | null> {
25+
for (const dir of [projectRoot, contentDir]) {
26+
const filePath = path.join(dir, 'chronicle.yaml');
27+
try {
28+
return await fs.readFile(filePath, 'utf-8');
29+
} catch {
30+
// not found, try next
31+
}
32+
}
33+
return null;
34+
}
35+
2336
export async function createViteConfig(
2437
options: ViteConfigOptions
2538
): Promise<InlineConfig> {
2639
const { packageRoot, projectRoot, contentDir, preset } = options;
40+
const rawConfig = await readChronicleConfig(projectRoot, contentDir);
2741

2842
return {
2943
root: packageRoot,
@@ -83,7 +97,8 @@ export async function createViteConfig(
8397
},
8498
define: {
8599
__CHRONICLE_CONTENT_DIR__: JSON.stringify(contentDir),
86-
__CHRONICLE_PROJECT_ROOT__: JSON.stringify(projectRoot)
100+
__CHRONICLE_PROJECT_ROOT__: JSON.stringify(projectRoot),
101+
__CHRONICLE_CONFIG_RAW__: JSON.stringify(rawConfig),
87102
},
88103
css: {
89104
modules: {
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
11
// Vite build-time constants (injected via define in vite-config.ts)
22
declare const __CHRONICLE_CONTENT_DIR__: string
33
declare const __CHRONICLE_PROJECT_ROOT__: string
4+
declare const __CHRONICLE_CONFIG_RAW__: string | null

0 commit comments

Comments
 (0)