@raystack/chronicle 0.1.0-canary.d9f273b → 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.
- package/dist/cli/index.js +385 -60
- package/package.json +1 -1
- package/src/cli/commands/build.ts +13 -0
- package/src/components/ui/search.tsx +10 -6
- package/src/server/adapters/vercel.ts +9 -3
- package/src/server/build-search-index.ts +107 -0
- package/src/server/dev.ts +5 -3
- package/src/server/entry-prod.ts +4 -3
- package/src/server/entry-vercel.ts +3 -1
- package/src/server/handlers/apis-proxy.ts +5 -0
- package/src/server/handlers/search.ts +46 -14
- package/src/server/request-handler.ts +2 -1
- package/src/server/utils/safe-path.ts +14 -0
package/dist/cli/index.js
CHANGED
|
@@ -85,6 +85,18 @@ async function createViteConfig(options) {
|
|
|
85
85
|
}
|
|
86
86
|
var init_vite_config = () => {};
|
|
87
87
|
|
|
88
|
+
// src/server/utils/safe-path.ts
|
|
89
|
+
import path6 from "path";
|
|
90
|
+
function safePath(baseDir, urlPath) {
|
|
91
|
+
const decoded = decodeURIComponent(urlPath.split("?")[0]);
|
|
92
|
+
const resolved = path6.resolve(baseDir, "." + decoded);
|
|
93
|
+
if (!resolved.startsWith(path6.resolve(baseDir) + path6.sep) && resolved !== path6.resolve(baseDir)) {
|
|
94
|
+
return null;
|
|
95
|
+
}
|
|
96
|
+
return resolved;
|
|
97
|
+
}
|
|
98
|
+
var init_safe_path = () => {};
|
|
99
|
+
|
|
88
100
|
// src/server/dev.ts
|
|
89
101
|
var exports_dev = {};
|
|
90
102
|
__export(exports_dev, {
|
|
@@ -94,7 +106,7 @@ import { createServer as createViteServer } from "vite";
|
|
|
94
106
|
import { createServer } from "http";
|
|
95
107
|
import fsPromises from "fs/promises";
|
|
96
108
|
import { createReadStream } from "fs";
|
|
97
|
-
import
|
|
109
|
+
import path7 from "path";
|
|
98
110
|
import chalk3 from "chalk";
|
|
99
111
|
async function startDevServer(options) {
|
|
100
112
|
const { port, root, contentDir } = options;
|
|
@@ -104,7 +116,7 @@ async function startDevServer(options) {
|
|
|
104
116
|
server: { middlewareMode: true },
|
|
105
117
|
appType: "custom"
|
|
106
118
|
});
|
|
107
|
-
const templatePath =
|
|
119
|
+
const templatePath = path7.resolve(root, "src/server/index.html");
|
|
108
120
|
const server = createServer(async (req, res) => {
|
|
109
121
|
const url = req.url || "/";
|
|
110
122
|
try {
|
|
@@ -115,12 +127,12 @@ async function startDevServer(options) {
|
|
|
115
127
|
});
|
|
116
128
|
return;
|
|
117
129
|
}
|
|
118
|
-
const contentFile =
|
|
119
|
-
if (!url.endsWith(".md") && !url.endsWith(".mdx")) {
|
|
130
|
+
const contentFile = safePath(contentDir, url);
|
|
131
|
+
if (contentFile && !url.endsWith(".md") && !url.endsWith(".mdx")) {
|
|
120
132
|
try {
|
|
121
133
|
const stat = await fsPromises.stat(contentFile);
|
|
122
134
|
if (stat.isFile()) {
|
|
123
|
-
const ext =
|
|
135
|
+
const ext = path7.extname(contentFile).toLowerCase();
|
|
124
136
|
const mimeTypes = {
|
|
125
137
|
".png": "image/png",
|
|
126
138
|
".jpg": "image/jpeg",
|
|
@@ -148,7 +160,7 @@ async function startDevServer(options) {
|
|
|
148
160
|
});
|
|
149
161
|
return;
|
|
150
162
|
}
|
|
151
|
-
const { matchRoute } = await vite.ssrLoadModule(
|
|
163
|
+
const { matchRoute } = await vite.ssrLoadModule(path7.resolve(root, "src/server/router.ts"));
|
|
152
164
|
const routeHandler = matchRoute(new URL(url, `http://localhost:${port}`).href);
|
|
153
165
|
if (routeHandler) {
|
|
154
166
|
const request = new Request(new URL(url, `http://localhost:${port}`));
|
|
@@ -161,11 +173,11 @@ async function startDevServer(options) {
|
|
|
161
173
|
}
|
|
162
174
|
const pathname = new URL(url, `http://localhost:${port}`).pathname;
|
|
163
175
|
const slug = pathname === "/" ? [] : pathname.slice(1).split("/").filter(Boolean);
|
|
164
|
-
const source = await vite.ssrLoadModule(
|
|
165
|
-
const { mdxComponents } = await vite.ssrLoadModule(
|
|
166
|
-
const { loadConfig } = await vite.ssrLoadModule(
|
|
176
|
+
const source = await vite.ssrLoadModule(path7.resolve(root, "src/lib/source.ts"));
|
|
177
|
+
const { mdxComponents } = await vite.ssrLoadModule(path7.resolve(root, "src/components/mdx/index.tsx"));
|
|
178
|
+
const { loadConfig } = await vite.ssrLoadModule(path7.resolve(root, "src/lib/config.ts"));
|
|
167
179
|
const config = loadConfig();
|
|
168
|
-
const { loadApiSpecs } = await vite.ssrLoadModule(
|
|
180
|
+
const { loadApiSpecs } = await vite.ssrLoadModule(path7.resolve(root, "src/lib/openapi.ts"));
|
|
169
181
|
const apiSpecs = config.api?.length ? loadApiSpecs(config.api) : [];
|
|
170
182
|
const [tree, sourcePage] = await Promise.all([
|
|
171
183
|
source.buildPageTree(),
|
|
@@ -187,9 +199,10 @@ async function startDevServer(options) {
|
|
|
187
199
|
}
|
|
188
200
|
let template = await fsPromises.readFile(templatePath, "utf-8");
|
|
189
201
|
template = await vite.transformIndexHtml(url, template);
|
|
190
|
-
const
|
|
202
|
+
const safeJson = JSON.stringify(embeddedData).replace(/</g, "\\u003c");
|
|
203
|
+
const dataScript = `<script>window.__PAGE_DATA__ = ${safeJson}</script>`;
|
|
191
204
|
template = template.replace("<!--head-outlet-->", `<!--head-outlet-->${dataScript}`);
|
|
192
|
-
const { render } = await vite.ssrLoadModule(
|
|
205
|
+
const { render } = await vite.ssrLoadModule(path7.resolve(root, "src/server/entry-server.tsx"));
|
|
193
206
|
const html = render(url, { config, tree, page: pageData, apiSpecs });
|
|
194
207
|
const finalHtml = template.replace("<!--ssr-outlet-->", html);
|
|
195
208
|
res.setHeader("Content-Type", "text/html");
|
|
@@ -219,6 +232,307 @@ async function startDevServer(options) {
|
|
|
219
232
|
}
|
|
220
233
|
var init_dev = __esm(() => {
|
|
221
234
|
init_vite_config();
|
|
235
|
+
init_safe_path();
|
|
236
|
+
});
|
|
237
|
+
|
|
238
|
+
// src/lib/config.ts
|
|
239
|
+
import fs3 from "fs";
|
|
240
|
+
import path8 from "path";
|
|
241
|
+
import { parse as parse2 } from "yaml";
|
|
242
|
+
function resolveConfigPath() {
|
|
243
|
+
const projectRoot = process.env.CHRONICLE_PROJECT_ROOT;
|
|
244
|
+
if (projectRoot) {
|
|
245
|
+
const rootPath = path8.join(projectRoot, CONFIG_FILE);
|
|
246
|
+
if (fs3.existsSync(rootPath))
|
|
247
|
+
return rootPath;
|
|
248
|
+
}
|
|
249
|
+
const cwdPath = path8.join(process.cwd(), CONFIG_FILE);
|
|
250
|
+
if (fs3.existsSync(cwdPath))
|
|
251
|
+
return cwdPath;
|
|
252
|
+
const contentDir = process.env.CHRONICLE_CONTENT_DIR;
|
|
253
|
+
if (contentDir) {
|
|
254
|
+
const contentPath = path8.join(contentDir, CONFIG_FILE);
|
|
255
|
+
if (fs3.existsSync(contentPath))
|
|
256
|
+
return contentPath;
|
|
257
|
+
}
|
|
258
|
+
return null;
|
|
259
|
+
}
|
|
260
|
+
function loadConfig() {
|
|
261
|
+
const configPath = resolveConfigPath();
|
|
262
|
+
if (!configPath) {
|
|
263
|
+
return defaultConfig;
|
|
264
|
+
}
|
|
265
|
+
const raw = fs3.readFileSync(configPath, "utf-8");
|
|
266
|
+
const userConfig = parse2(raw);
|
|
267
|
+
return {
|
|
268
|
+
...defaultConfig,
|
|
269
|
+
...userConfig,
|
|
270
|
+
theme: {
|
|
271
|
+
name: userConfig.theme?.name ?? defaultConfig.theme.name,
|
|
272
|
+
colors: { ...defaultConfig.theme?.colors, ...userConfig.theme?.colors }
|
|
273
|
+
},
|
|
274
|
+
search: { ...defaultConfig.search, ...userConfig.search },
|
|
275
|
+
footer: userConfig.footer,
|
|
276
|
+
api: userConfig.api,
|
|
277
|
+
llms: { enabled: false, ...userConfig.llms },
|
|
278
|
+
analytics: { enabled: false, ...userConfig.analytics }
|
|
279
|
+
};
|
|
280
|
+
}
|
|
281
|
+
var CONFIG_FILE = "chronicle.yaml", defaultConfig;
|
|
282
|
+
var init_config = __esm(() => {
|
|
283
|
+
defaultConfig = {
|
|
284
|
+
title: "Documentation",
|
|
285
|
+
theme: { name: "default" },
|
|
286
|
+
search: { enabled: true, placeholder: "Search..." }
|
|
287
|
+
};
|
|
288
|
+
});
|
|
289
|
+
// src/lib/openapi.ts
|
|
290
|
+
import fs4 from "fs";
|
|
291
|
+
import path9 from "path";
|
|
292
|
+
import { parse as parseYaml } from "yaml";
|
|
293
|
+
function loadApiSpecs(apiConfigs) {
|
|
294
|
+
const contentDir = process.env.CHRONICLE_CONTENT_DIR ?? process.cwd();
|
|
295
|
+
return apiConfigs.map((config) => loadApiSpec(config, contentDir));
|
|
296
|
+
}
|
|
297
|
+
function loadApiSpec(config, contentDir) {
|
|
298
|
+
const specPath = path9.resolve(contentDir, config.spec);
|
|
299
|
+
const raw = fs4.readFileSync(specPath, "utf-8");
|
|
300
|
+
const isYaml = specPath.endsWith(".yaml") || specPath.endsWith(".yml");
|
|
301
|
+
const doc = isYaml ? parseYaml(raw) : JSON.parse(raw);
|
|
302
|
+
let v3Doc;
|
|
303
|
+
if ("swagger" in doc && doc.swagger === "2.0") {
|
|
304
|
+
v3Doc = convertV2toV3(doc);
|
|
305
|
+
} else if ("openapi" in doc && doc.openapi.startsWith("3.")) {
|
|
306
|
+
v3Doc = resolveDocument(doc);
|
|
307
|
+
} else {
|
|
308
|
+
throw new Error(`Unsupported spec version in ${config.spec}`);
|
|
309
|
+
}
|
|
310
|
+
return {
|
|
311
|
+
name: config.name,
|
|
312
|
+
basePath: config.basePath,
|
|
313
|
+
server: config.server,
|
|
314
|
+
auth: config.auth,
|
|
315
|
+
document: v3Doc
|
|
316
|
+
};
|
|
317
|
+
}
|
|
318
|
+
function resolveRef(ref, root) {
|
|
319
|
+
const parts = ref.replace(/^#\//, "").split("/");
|
|
320
|
+
let current = root;
|
|
321
|
+
for (const part of parts) {
|
|
322
|
+
if (current && typeof current === "object" && !Array.isArray(current)) {
|
|
323
|
+
current = current[part];
|
|
324
|
+
} else {
|
|
325
|
+
throw new Error(`Cannot resolve $ref: ${ref}`);
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
return current;
|
|
329
|
+
}
|
|
330
|
+
function deepResolveRefs(obj, root, stack = new Set, cache = new Map) {
|
|
331
|
+
if (obj === null || obj === undefined || typeof obj !== "object")
|
|
332
|
+
return obj;
|
|
333
|
+
if (Array.isArray(obj)) {
|
|
334
|
+
return obj.map((item) => deepResolveRefs(item, root, stack, cache));
|
|
335
|
+
}
|
|
336
|
+
const record = obj;
|
|
337
|
+
if (typeof record.$ref === "string") {
|
|
338
|
+
const ref = record.$ref;
|
|
339
|
+
if (cache.has(ref))
|
|
340
|
+
return cache.get(ref);
|
|
341
|
+
if (stack.has(ref))
|
|
342
|
+
return { type: "object", description: "[circular]" };
|
|
343
|
+
stack.add(ref);
|
|
344
|
+
const resolved = deepResolveRefs(resolveRef(ref, root), root, stack, cache);
|
|
345
|
+
stack.delete(ref);
|
|
346
|
+
cache.set(ref, resolved);
|
|
347
|
+
return resolved;
|
|
348
|
+
}
|
|
349
|
+
const result = {};
|
|
350
|
+
for (const [key, value] of Object.entries(record)) {
|
|
351
|
+
result[key] = deepResolveRefs(value, root, stack, cache);
|
|
352
|
+
}
|
|
353
|
+
return result;
|
|
354
|
+
}
|
|
355
|
+
function resolveDocument(doc) {
|
|
356
|
+
const root = doc;
|
|
357
|
+
return deepResolveRefs(doc, root);
|
|
358
|
+
}
|
|
359
|
+
function convertV2toV3(doc) {
|
|
360
|
+
const root = doc;
|
|
361
|
+
const resolved = deepResolveRefs(doc, root);
|
|
362
|
+
const v3Paths = {};
|
|
363
|
+
for (const [pathStr, pathItem] of Object.entries(resolved.paths ?? {})) {
|
|
364
|
+
if (!pathItem)
|
|
365
|
+
continue;
|
|
366
|
+
const v3PathItem = {};
|
|
367
|
+
for (const method of ["get", "post", "put", "delete", "patch"]) {
|
|
368
|
+
const op = pathItem[method];
|
|
369
|
+
if (!op)
|
|
370
|
+
continue;
|
|
371
|
+
v3PathItem[method] = convertV2Operation(op);
|
|
372
|
+
}
|
|
373
|
+
v3Paths[pathStr] = v3PathItem;
|
|
374
|
+
}
|
|
375
|
+
return {
|
|
376
|
+
openapi: "3.0.0",
|
|
377
|
+
info: resolved.info,
|
|
378
|
+
paths: v3Paths,
|
|
379
|
+
tags: resolved.tags ?? []
|
|
380
|
+
};
|
|
381
|
+
}
|
|
382
|
+
function convertV2Operation(op) {
|
|
383
|
+
const params = op.parameters ?? [];
|
|
384
|
+
const v3Params = params.filter((p) => p.in !== "body").map((p) => ({
|
|
385
|
+
name: p.name,
|
|
386
|
+
in: p.in,
|
|
387
|
+
required: p.required ?? false,
|
|
388
|
+
description: p.description,
|
|
389
|
+
schema: { type: p.type ?? "string", format: p.format }
|
|
390
|
+
}));
|
|
391
|
+
const bodyParam = params.find((p) => p.in === "body");
|
|
392
|
+
let requestBody;
|
|
393
|
+
if (bodyParam?.schema) {
|
|
394
|
+
requestBody = {
|
|
395
|
+
required: bodyParam.required ?? false,
|
|
396
|
+
content: {
|
|
397
|
+
"application/json": {
|
|
398
|
+
schema: bodyParam.schema
|
|
399
|
+
}
|
|
400
|
+
}
|
|
401
|
+
};
|
|
402
|
+
}
|
|
403
|
+
const v3Responses = {};
|
|
404
|
+
for (const [status, resp] of Object.entries(op.responses ?? {})) {
|
|
405
|
+
const v2Resp = resp;
|
|
406
|
+
const v3Resp = {
|
|
407
|
+
description: v2Resp.description ?? ""
|
|
408
|
+
};
|
|
409
|
+
if (v2Resp.schema) {
|
|
410
|
+
v3Resp.content = {
|
|
411
|
+
"application/json": {
|
|
412
|
+
schema: v2Resp.schema
|
|
413
|
+
}
|
|
414
|
+
};
|
|
415
|
+
}
|
|
416
|
+
v3Responses[status] = v3Resp;
|
|
417
|
+
}
|
|
418
|
+
const result = {
|
|
419
|
+
operationId: op.operationId,
|
|
420
|
+
summary: op.summary,
|
|
421
|
+
description: op.description,
|
|
422
|
+
tags: op.tags,
|
|
423
|
+
parameters: v3Params,
|
|
424
|
+
responses: v3Responses
|
|
425
|
+
};
|
|
426
|
+
if (requestBody) {
|
|
427
|
+
result.requestBody = requestBody;
|
|
428
|
+
}
|
|
429
|
+
return result;
|
|
430
|
+
}
|
|
431
|
+
var init_openapi = () => {};
|
|
432
|
+
|
|
433
|
+
// src/lib/api-routes.ts
|
|
434
|
+
import slugify from "slugify";
|
|
435
|
+
function getSpecSlug(spec) {
|
|
436
|
+
return slugify(spec.name, { lower: true, strict: true });
|
|
437
|
+
}
|
|
438
|
+
var init_api_routes = () => {};
|
|
439
|
+
|
|
440
|
+
// src/server/build-search-index.ts
|
|
441
|
+
var exports_build_search_index = {};
|
|
442
|
+
__export(exports_build_search_index, {
|
|
443
|
+
generateSearchIndex: () => generateSearchIndex
|
|
444
|
+
});
|
|
445
|
+
import fs5 from "fs/promises";
|
|
446
|
+
import path10 from "path";
|
|
447
|
+
import matter from "gray-matter";
|
|
448
|
+
function extractHeadings(markdown) {
|
|
449
|
+
const headingRegex = /^#{1,6}\s+(.+)$/gm;
|
|
450
|
+
const headings = [];
|
|
451
|
+
let match;
|
|
452
|
+
while ((match = headingRegex.exec(markdown)) !== null) {
|
|
453
|
+
headings.push(match[1].trim());
|
|
454
|
+
}
|
|
455
|
+
return headings.join(" ");
|
|
456
|
+
}
|
|
457
|
+
async function scanContent(contentDir) {
|
|
458
|
+
const docs = [];
|
|
459
|
+
async function scan(dir, prefix = []) {
|
|
460
|
+
let entries;
|
|
461
|
+
try {
|
|
462
|
+
entries = await fs5.readdir(dir, { withFileTypes: true });
|
|
463
|
+
} catch {
|
|
464
|
+
return;
|
|
465
|
+
}
|
|
466
|
+
for (const entry of entries) {
|
|
467
|
+
if (entry.name.startsWith(".") || entry.name === "node_modules")
|
|
468
|
+
continue;
|
|
469
|
+
const fullPath = path10.join(dir, entry.name);
|
|
470
|
+
if (entry.isDirectory()) {
|
|
471
|
+
await scan(fullPath, [...prefix, entry.name]);
|
|
472
|
+
continue;
|
|
473
|
+
}
|
|
474
|
+
if (!entry.name.endsWith(".mdx") && !entry.name.endsWith(".md"))
|
|
475
|
+
continue;
|
|
476
|
+
const raw = await fs5.readFile(fullPath, "utf-8");
|
|
477
|
+
const { data: fm, content } = matter(raw);
|
|
478
|
+
const baseName = entry.name.replace(/\.(mdx|md)$/, "");
|
|
479
|
+
const slugs = baseName === "index" ? prefix : [...prefix, baseName];
|
|
480
|
+
const url = slugs.length === 0 ? "/" : "/" + slugs.join("/");
|
|
481
|
+
docs.push({
|
|
482
|
+
id: url,
|
|
483
|
+
url,
|
|
484
|
+
title: fm.title ?? baseName,
|
|
485
|
+
content: extractHeadings(content),
|
|
486
|
+
type: "page"
|
|
487
|
+
});
|
|
488
|
+
}
|
|
489
|
+
}
|
|
490
|
+
await scan(contentDir);
|
|
491
|
+
return docs;
|
|
492
|
+
}
|
|
493
|
+
function buildApiDocs() {
|
|
494
|
+
const config = loadConfig();
|
|
495
|
+
if (!config.api?.length)
|
|
496
|
+
return [];
|
|
497
|
+
const docs = [];
|
|
498
|
+
const specs = loadApiSpecs(config.api);
|
|
499
|
+
for (const spec of specs) {
|
|
500
|
+
const specSlug = getSpecSlug(spec);
|
|
501
|
+
const paths = spec.document.paths ?? {};
|
|
502
|
+
for (const [, pathItem] of Object.entries(paths)) {
|
|
503
|
+
if (!pathItem)
|
|
504
|
+
continue;
|
|
505
|
+
for (const method of ["get", "post", "put", "delete", "patch"]) {
|
|
506
|
+
const op = pathItem[method];
|
|
507
|
+
if (!op?.operationId)
|
|
508
|
+
continue;
|
|
509
|
+
const url = `/apis/${specSlug}/${encodeURIComponent(op.operationId)}`;
|
|
510
|
+
docs.push({
|
|
511
|
+
id: url,
|
|
512
|
+
url,
|
|
513
|
+
title: `${method.toUpperCase()} ${op.summary ?? op.operationId}`,
|
|
514
|
+
content: op.description ?? "",
|
|
515
|
+
type: "api"
|
|
516
|
+
});
|
|
517
|
+
}
|
|
518
|
+
}
|
|
519
|
+
}
|
|
520
|
+
return docs;
|
|
521
|
+
}
|
|
522
|
+
async function generateSearchIndex(contentDir, outDir) {
|
|
523
|
+
const [contentDocs, apiDocs] = await Promise.all([
|
|
524
|
+
scanContent(contentDir),
|
|
525
|
+
Promise.resolve(buildApiDocs())
|
|
526
|
+
]);
|
|
527
|
+
const documents = [...contentDocs, ...apiDocs];
|
|
528
|
+
const outPath = path10.join(outDir, "search-index.json");
|
|
529
|
+
await fs5.writeFile(outPath, JSON.stringify(documents));
|
|
530
|
+
return documents.length;
|
|
531
|
+
}
|
|
532
|
+
var init_build_search_index = __esm(() => {
|
|
533
|
+
init_config();
|
|
534
|
+
init_openapi();
|
|
535
|
+
init_api_routes();
|
|
222
536
|
});
|
|
223
537
|
|
|
224
538
|
// src/server/adapters/vercel.ts
|
|
@@ -226,37 +540,38 @@ var exports_vercel = {};
|
|
|
226
540
|
__export(exports_vercel, {
|
|
227
541
|
buildVercelOutput: () => buildVercelOutput
|
|
228
542
|
});
|
|
229
|
-
import
|
|
230
|
-
import
|
|
543
|
+
import path11 from "path";
|
|
544
|
+
import fs6 from "fs/promises";
|
|
231
545
|
import { existsSync } from "fs";
|
|
232
546
|
import chalk5 from "chalk";
|
|
233
547
|
async function buildVercelOutput(options) {
|
|
234
548
|
const { distDir, contentDir, projectRoot } = options;
|
|
235
|
-
const outputDir =
|
|
549
|
+
const outputDir = path11.resolve(projectRoot, ".vercel/output");
|
|
236
550
|
console.log(chalk5.gray("Generating Vercel output..."));
|
|
237
|
-
await
|
|
238
|
-
const staticDir =
|
|
239
|
-
const funcDir =
|
|
240
|
-
await
|
|
241
|
-
await
|
|
242
|
-
const clientDir =
|
|
551
|
+
await fs6.rm(outputDir, { recursive: true, force: true });
|
|
552
|
+
const staticDir = path11.resolve(outputDir, "static");
|
|
553
|
+
const funcDir = path11.resolve(outputDir, "functions/index.func");
|
|
554
|
+
await fs6.mkdir(staticDir, { recursive: true });
|
|
555
|
+
await fs6.mkdir(funcDir, { recursive: true });
|
|
556
|
+
const clientDir = path11.resolve(distDir, "client");
|
|
243
557
|
await copyDir(clientDir, staticDir);
|
|
244
558
|
console.log(chalk5.gray(" Copied client assets to static/"));
|
|
245
559
|
if (existsSync(contentDir)) {
|
|
246
560
|
await copyContentAssets(contentDir, staticDir);
|
|
247
561
|
console.log(chalk5.gray(" Copied content assets to static/"));
|
|
248
562
|
}
|
|
249
|
-
const serverDir =
|
|
563
|
+
const serverDir = path11.resolve(distDir, "server");
|
|
250
564
|
await copyDir(serverDir, funcDir);
|
|
251
565
|
console.log(chalk5.gray(" Copied server bundle to functions/"));
|
|
252
|
-
const templateSrc =
|
|
253
|
-
await
|
|
254
|
-
await
|
|
255
|
-
|
|
566
|
+
const templateSrc = path11.resolve(clientDir, "src/server/index.html");
|
|
567
|
+
await fs6.copyFile(templateSrc, path11.resolve(funcDir, "index.html"));
|
|
568
|
+
await fs6.writeFile(path11.resolve(funcDir, "package.json"), JSON.stringify({ type: "module" }, null, 2));
|
|
569
|
+
await fs6.writeFile(path11.resolve(funcDir, ".vc-config.json"), JSON.stringify({
|
|
570
|
+
runtime: "nodejs24.x",
|
|
256
571
|
handler: "entry-vercel.js",
|
|
257
572
|
launcherType: "Nodejs"
|
|
258
573
|
}, null, 2));
|
|
259
|
-
await
|
|
574
|
+
await fs6.writeFile(path11.resolve(outputDir, "config.json"), JSON.stringify({
|
|
260
575
|
version: 3,
|
|
261
576
|
routes: [
|
|
262
577
|
{ handle: "filesystem" },
|
|
@@ -266,44 +581,44 @@ async function buildVercelOutput(options) {
|
|
|
266
581
|
console.log(chalk5.green("Vercel output generated →"), outputDir);
|
|
267
582
|
}
|
|
268
583
|
async function copyDir(src, dest) {
|
|
269
|
-
await
|
|
270
|
-
const entries = await
|
|
584
|
+
await fs6.mkdir(dest, { recursive: true });
|
|
585
|
+
const entries = await fs6.readdir(src, { withFileTypes: true });
|
|
271
586
|
for (const entry of entries) {
|
|
272
|
-
const srcPath =
|
|
273
|
-
const destPath =
|
|
587
|
+
const srcPath = path11.join(src, entry.name);
|
|
588
|
+
const destPath = path11.join(dest, entry.name);
|
|
274
589
|
if (entry.isDirectory()) {
|
|
275
590
|
await copyDir(srcPath, destPath);
|
|
276
591
|
} else {
|
|
277
|
-
await
|
|
592
|
+
await fs6.copyFile(srcPath, destPath);
|
|
278
593
|
}
|
|
279
594
|
}
|
|
280
595
|
}
|
|
281
596
|
async function copyContentAssets(contentDir, staticDir) {
|
|
282
|
-
const entries = await
|
|
597
|
+
const entries = await fs6.readdir(contentDir, { withFileTypes: true });
|
|
283
598
|
for (const entry of entries) {
|
|
284
|
-
const srcPath =
|
|
599
|
+
const srcPath = path11.join(contentDir, entry.name);
|
|
285
600
|
if (entry.isDirectory()) {
|
|
286
|
-
const destSubDir =
|
|
601
|
+
const destSubDir = path11.join(staticDir, entry.name);
|
|
287
602
|
await copyContentAssetsRecursive(srcPath, destSubDir);
|
|
288
603
|
} else {
|
|
289
|
-
const ext =
|
|
604
|
+
const ext = path11.extname(entry.name).toLowerCase();
|
|
290
605
|
if (CONTENT_EXTENSIONS.has(ext)) {
|
|
291
|
-
await
|
|
606
|
+
await fs6.copyFile(srcPath, path11.join(staticDir, entry.name));
|
|
292
607
|
}
|
|
293
608
|
}
|
|
294
609
|
}
|
|
295
610
|
}
|
|
296
611
|
async function copyContentAssetsRecursive(srcDir, destDir) {
|
|
297
|
-
const entries = await
|
|
612
|
+
const entries = await fs6.readdir(srcDir, { withFileTypes: true });
|
|
298
613
|
for (const entry of entries) {
|
|
299
|
-
const srcPath =
|
|
614
|
+
const srcPath = path11.join(srcDir, entry.name);
|
|
300
615
|
if (entry.isDirectory()) {
|
|
301
|
-
await copyContentAssetsRecursive(srcPath,
|
|
616
|
+
await copyContentAssetsRecursive(srcPath, path11.join(destDir, entry.name));
|
|
302
617
|
} else {
|
|
303
|
-
const ext =
|
|
618
|
+
const ext = path11.extname(entry.name).toLowerCase();
|
|
304
619
|
if (CONTENT_EXTENSIONS.has(ext)) {
|
|
305
|
-
await
|
|
306
|
-
await
|
|
620
|
+
await fs6.mkdir(destDir, { recursive: true });
|
|
621
|
+
await fs6.copyFile(srcPath, path11.join(destDir, entry.name));
|
|
307
622
|
}
|
|
308
623
|
}
|
|
309
624
|
}
|
|
@@ -331,11 +646,11 @@ var exports_prod = {};
|
|
|
331
646
|
__export(exports_prod, {
|
|
332
647
|
startProdServer: () => startProdServer
|
|
333
648
|
});
|
|
334
|
-
import
|
|
649
|
+
import path13 from "path";
|
|
335
650
|
import chalk7 from "chalk";
|
|
336
651
|
async function startProdServer(options) {
|
|
337
652
|
const { port, distDir } = options;
|
|
338
|
-
const serverEntry =
|
|
653
|
+
const serverEntry = path13.resolve(distDir, "server/entry-prod.js");
|
|
339
654
|
const { startServer } = await import(serverEntry);
|
|
340
655
|
console.log(chalk7.cyan("Starting production server..."));
|
|
341
656
|
return startServer({ port, distDir });
|
|
@@ -547,11 +862,16 @@ var devCommand = new Command2("dev").description("Start development server").opt
|
|
|
547
862
|
|
|
548
863
|
// src/cli/commands/build.ts
|
|
549
864
|
import { Command as Command3 } from "commander";
|
|
550
|
-
import
|
|
865
|
+
import path12 from "path";
|
|
551
866
|
import chalk6 from "chalk";
|
|
552
867
|
var buildCommand = new Command3("build").description("Build for production").option("-c, --content <path>", "Content directory").option("-o, --outDir <path>", "Output directory", "dist").option("--adapter <adapter>", "Deploy adapter (vercel)").action(async (options) => {
|
|
553
868
|
const contentDir = resolveContentDir(options.content);
|
|
554
|
-
const outDir =
|
|
869
|
+
const outDir = path12.resolve(options.outDir);
|
|
870
|
+
const VALID_ADAPTERS = ["vercel"];
|
|
871
|
+
if (options.adapter && !VALID_ADAPTERS.includes(options.adapter)) {
|
|
872
|
+
console.error(chalk6.red(`Unknown adapter: ${options.adapter}. Valid adapters: ${VALID_ADAPTERS.join(", ")}`));
|
|
873
|
+
process.exit(1);
|
|
874
|
+
}
|
|
555
875
|
process.env.CHRONICLE_PROJECT_ROOT = process.cwd();
|
|
556
876
|
process.env.CHRONICLE_CONTENT_DIR = contentDir;
|
|
557
877
|
console.log(chalk6.cyan("Building for production..."));
|
|
@@ -562,14 +882,14 @@ var buildCommand = new Command3("build").description("Build for production").opt
|
|
|
562
882
|
await build({
|
|
563
883
|
...baseConfig,
|
|
564
884
|
build: {
|
|
565
|
-
outDir:
|
|
885
|
+
outDir: path12.join(outDir, "client"),
|
|
566
886
|
ssrManifest: true,
|
|
567
887
|
rolldownOptions: {
|
|
568
|
-
input:
|
|
888
|
+
input: path12.resolve(PACKAGE_ROOT, "src/server/index.html")
|
|
569
889
|
}
|
|
570
890
|
}
|
|
571
891
|
});
|
|
572
|
-
const serverEntry = options.adapter === "vercel" ?
|
|
892
|
+
const serverEntry = options.adapter === "vercel" ? path12.resolve(PACKAGE_ROOT, "src/server/entry-vercel.ts") : path12.resolve(PACKAGE_ROOT, "src/server/entry-prod.ts");
|
|
573
893
|
console.log(chalk6.gray("Building server..."));
|
|
574
894
|
await build({
|
|
575
895
|
...baseConfig,
|
|
@@ -577,10 +897,15 @@ var buildCommand = new Command3("build").description("Build for production").opt
|
|
|
577
897
|
noExternal: true
|
|
578
898
|
},
|
|
579
899
|
build: {
|
|
580
|
-
outDir:
|
|
581
|
-
ssr: serverEntry
|
|
900
|
+
outDir: path12.join(outDir, "server"),
|
|
901
|
+
ssr: serverEntry,
|
|
902
|
+
target: "node22"
|
|
582
903
|
}
|
|
583
904
|
});
|
|
905
|
+
console.log(chalk6.gray("Building search index..."));
|
|
906
|
+
const { generateSearchIndex: generateSearchIndex2 } = await Promise.resolve().then(() => (init_build_search_index(), exports_build_search_index));
|
|
907
|
+
const docCount = await generateSearchIndex2(contentDir, path12.join(outDir, "server"));
|
|
908
|
+
console.log(chalk6.gray(` Indexed ${docCount} documents`));
|
|
584
909
|
console.log(chalk6.green("Build complete →"), outDir);
|
|
585
910
|
if (options.adapter === "vercel") {
|
|
586
911
|
const { buildVercelOutput: buildVercelOutput2 } = await Promise.resolve().then(() => (init_vercel(), exports_vercel));
|
|
@@ -594,12 +919,12 @@ var buildCommand = new Command3("build").description("Build for production").opt
|
|
|
594
919
|
|
|
595
920
|
// src/cli/commands/start.ts
|
|
596
921
|
import { Command as Command4 } from "commander";
|
|
597
|
-
import
|
|
922
|
+
import path14 from "path";
|
|
598
923
|
import chalk8 from "chalk";
|
|
599
924
|
var startCommand = new Command4("start").description("Start production server").option("-p, --port <port>", "Port number", "3000").option("-c, --content <path>", "Content directory").option("-d, --dist <path>", "Dist directory", "dist").action(async (options) => {
|
|
600
925
|
const contentDir = resolveContentDir(options.content);
|
|
601
926
|
const port = parseInt(options.port, 10);
|
|
602
|
-
const distDir =
|
|
927
|
+
const distDir = path14.resolve(options.dist);
|
|
603
928
|
process.env.CHRONICLE_PROJECT_ROOT = process.cwd();
|
|
604
929
|
process.env.CHRONICLE_CONTENT_DIR = contentDir;
|
|
605
930
|
console.log(chalk8.cyan("Starting production server..."));
|
|
@@ -609,12 +934,12 @@ var startCommand = new Command4("start").description("Start production server").
|
|
|
609
934
|
|
|
610
935
|
// src/cli/commands/serve.ts
|
|
611
936
|
import { Command as Command5 } from "commander";
|
|
612
|
-
import
|
|
937
|
+
import path15 from "path";
|
|
613
938
|
import chalk9 from "chalk";
|
|
614
939
|
var serveCommand = new Command5("serve").description("Build and start production server").option("-p, --port <port>", "Port number", "3000").option("-c, --content <path>", "Content directory").option("-o, --outDir <path>", "Output directory", "dist").action(async (options) => {
|
|
615
940
|
const contentDir = resolveContentDir(options.content);
|
|
616
941
|
const port = parseInt(options.port, 10);
|
|
617
|
-
const outDir =
|
|
942
|
+
const outDir = path15.resolve(options.outDir);
|
|
618
943
|
process.env.CHRONICLE_PROJECT_ROOT = process.cwd();
|
|
619
944
|
process.env.CHRONICLE_CONTENT_DIR = contentDir;
|
|
620
945
|
console.log(chalk9.cyan("Building for production..."));
|
|
@@ -624,10 +949,10 @@ var serveCommand = new Command5("serve").description("Build and start production
|
|
|
624
949
|
await build({
|
|
625
950
|
...baseConfig,
|
|
626
951
|
build: {
|
|
627
|
-
outDir:
|
|
952
|
+
outDir: path15.join(outDir, "client"),
|
|
628
953
|
ssrManifest: true,
|
|
629
954
|
rolldownOptions: {
|
|
630
|
-
input:
|
|
955
|
+
input: path15.resolve(PACKAGE_ROOT, "src/server/index.html")
|
|
631
956
|
}
|
|
632
957
|
}
|
|
633
958
|
});
|
|
@@ -637,8 +962,8 @@ var serveCommand = new Command5("serve").description("Build and start production
|
|
|
637
962
|
noExternal: true
|
|
638
963
|
},
|
|
639
964
|
build: {
|
|
640
|
-
outDir:
|
|
641
|
-
ssr:
|
|
965
|
+
outDir: path15.join(outDir, "server"),
|
|
966
|
+
ssr: path15.resolve(PACKAGE_ROOT, "src/server/entry-prod.ts")
|
|
642
967
|
}
|
|
643
968
|
});
|
|
644
969
|
console.log(chalk9.cyan("Starting production server..."));
|
package/package.json
CHANGED
|
@@ -13,6 +13,12 @@ export const buildCommand = new Command('build')
|
|
|
13
13
|
const contentDir = resolveContentDir(options.content)
|
|
14
14
|
const outDir = path.resolve(options.outDir)
|
|
15
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
|
+
|
|
16
22
|
process.env.CHRONICLE_PROJECT_ROOT = process.cwd()
|
|
17
23
|
process.env.CHRONICLE_CONTENT_DIR = contentDir
|
|
18
24
|
|
|
@@ -50,9 +56,16 @@ export const buildCommand = new Command('build')
|
|
|
50
56
|
build: {
|
|
51
57
|
outDir: path.join(outDir, 'server'),
|
|
52
58
|
ssr: serverEntry,
|
|
59
|
+
target: 'node22',
|
|
53
60
|
},
|
|
54
61
|
})
|
|
55
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
|
+
|
|
56
69
|
console.log(chalk.green('Build complete →'), outDir)
|
|
57
70
|
|
|
58
71
|
// Run Vercel adapter post-build
|
|
@@ -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 };
|
|
@@ -49,17 +49,23 @@ export async function buildVercelOutput(options: VercelAdapterOptions) {
|
|
|
49
49
|
const templateSrc = path.resolve(clientDir, 'src/server/index.html')
|
|
50
50
|
await fs.copyFile(templateSrc, path.resolve(funcDir, 'index.html'))
|
|
51
51
|
|
|
52
|
-
// 5. Write .
|
|
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
|
|
53
59
|
await fs.writeFile(
|
|
54
60
|
path.resolve(funcDir, '.vc-config.json'),
|
|
55
61
|
JSON.stringify({
|
|
56
|
-
runtime: '
|
|
62
|
+
runtime: 'nodejs24.x',
|
|
57
63
|
handler: 'entry-vercel.js',
|
|
58
64
|
launcherType: 'Nodejs',
|
|
59
65
|
}, null, 2),
|
|
60
66
|
)
|
|
61
67
|
|
|
62
|
-
//
|
|
68
|
+
// 7. Write config.json
|
|
63
69
|
await fs.writeFile(
|
|
64
70
|
path.resolve(outputDir, 'config.json'),
|
|
65
71
|
JSON.stringify({
|
|
@@ -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
|
@@ -9,6 +9,7 @@ import { loadConfig } from '@/lib/config'
|
|
|
9
9
|
import { loadApiSpecs } from '@/lib/openapi'
|
|
10
10
|
import { getPage, loadPageComponent, buildPageTree } from '@/lib/source'
|
|
11
11
|
import { handleRequest } from './request-handler'
|
|
12
|
+
import { safePath } from './utils/safe-path'
|
|
12
13
|
|
|
13
14
|
export { render, matchRoute, loadConfig, loadApiSpecs, getPage, loadPageComponent, buildPageTree }
|
|
14
15
|
|
|
@@ -45,8 +46,8 @@ export async function startServer(options: { port: number; distDir: string }) {
|
|
|
45
46
|
|
|
46
47
|
// Serve static files from content dir (skip .md/.mdx)
|
|
47
48
|
const contentDir = process.env.CHRONICLE_CONTENT_DIR || process.cwd()
|
|
48
|
-
const contentFile =
|
|
49
|
-
if (!url.endsWith('.md') && !url.endsWith('.mdx')) {
|
|
49
|
+
const contentFile = safePath(contentDir, url)
|
|
50
|
+
if (contentFile && !url.endsWith('.md') && !url.endsWith('.mdx')) {
|
|
50
51
|
try {
|
|
51
52
|
const stat = await fsPromises.stat(contentFile)
|
|
52
53
|
if (stat.isFile()) {
|
|
@@ -77,7 +78,7 @@ export async function startServer(options: { port: number; distDir: string }) {
|
|
|
77
78
|
} catch (e) {
|
|
78
79
|
console.error(e)
|
|
79
80
|
res.statusCode = 500
|
|
80
|
-
res.end(
|
|
81
|
+
res.end('Internal Server Error')
|
|
81
82
|
}
|
|
82
83
|
})
|
|
83
84
|
|
|
@@ -1,9 +1,11 @@
|
|
|
1
1
|
// Vercel serverless function entry — built by Vite, deployed as catch-all function
|
|
2
2
|
import type { IncomingMessage, ServerResponse } from 'http'
|
|
3
3
|
import { readFileSync } from 'fs'
|
|
4
|
+
import { fileURLToPath } from 'url'
|
|
4
5
|
import path from 'path'
|
|
5
6
|
import { handleRequest } from './request-handler'
|
|
6
7
|
|
|
8
|
+
const __dirname = path.dirname(fileURLToPath(import.meta.url))
|
|
7
9
|
const templatePath = path.resolve(__dirname, 'index.html')
|
|
8
10
|
const template = readFileSync(templatePath, 'utf-8')
|
|
9
11
|
|
|
@@ -21,6 +23,6 @@ export default async function handler(req: IncomingMessage, res: ServerResponse)
|
|
|
21
23
|
} catch (e) {
|
|
22
24
|
console.error(e)
|
|
23
25
|
res.statusCode = 500
|
|
24
|
-
res.end(
|
|
26
|
+
res.end('Internal Server Error')
|
|
25
27
|
}
|
|
26
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,
|
|
@@ -51,7 +51,8 @@ export async function handleRequest(url: string, options: RequestHandlerOptions)
|
|
|
51
51
|
|
|
52
52
|
const html = render(url, { config, tree, page: pageData, apiSpecs })
|
|
53
53
|
|
|
54
|
-
const
|
|
54
|
+
const safeJson = JSON.stringify(embeddedData).replace(/</g, '\\u003c')
|
|
55
|
+
const dataScript = `<script>window.__PAGE_DATA__ = ${safeJson}</script>`
|
|
55
56
|
const finalHtml = template
|
|
56
57
|
.replace('<!--head-outlet-->', `<!--head-outlet-->${dataScript}`)
|
|
57
58
|
.replace('<!--ssr-outlet-->', html)
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import path from 'path'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Resolve a URL path within a base directory, preventing path traversal.
|
|
5
|
+
* Returns null if the resolved path escapes the base directory.
|
|
6
|
+
*/
|
|
7
|
+
export function safePath(baseDir: string, urlPath: string): string | null {
|
|
8
|
+
const decoded = decodeURIComponent(urlPath.split('?')[0])
|
|
9
|
+
const resolved = path.resolve(baseDir, '.' + decoded)
|
|
10
|
+
if (!resolved.startsWith(path.resolve(baseDir) + path.sep) && resolved !== path.resolve(baseDir)) {
|
|
11
|
+
return null
|
|
12
|
+
}
|
|
13
|
+
return resolved
|
|
14
|
+
}
|