@raystack/chronicle 0.1.0-canary.a638730 → 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 +385 -61
- 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 +1 -1
- 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,38 +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
|
-
await
|
|
256
|
-
runtime: "
|
|
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",
|
|
257
571
|
handler: "entry-vercel.js",
|
|
258
572
|
launcherType: "Nodejs"
|
|
259
573
|
}, null, 2));
|
|
260
|
-
await
|
|
574
|
+
await fs6.writeFile(path11.resolve(outputDir, "config.json"), JSON.stringify({
|
|
261
575
|
version: 3,
|
|
262
576
|
routes: [
|
|
263
577
|
{ handle: "filesystem" },
|
|
@@ -267,44 +581,44 @@ async function buildVercelOutput(options) {
|
|
|
267
581
|
console.log(chalk5.green("Vercel output generated →"), outputDir);
|
|
268
582
|
}
|
|
269
583
|
async function copyDir(src, dest) {
|
|
270
|
-
await
|
|
271
|
-
const entries = await
|
|
584
|
+
await fs6.mkdir(dest, { recursive: true });
|
|
585
|
+
const entries = await fs6.readdir(src, { withFileTypes: true });
|
|
272
586
|
for (const entry of entries) {
|
|
273
|
-
const srcPath =
|
|
274
|
-
const destPath =
|
|
587
|
+
const srcPath = path11.join(src, entry.name);
|
|
588
|
+
const destPath = path11.join(dest, entry.name);
|
|
275
589
|
if (entry.isDirectory()) {
|
|
276
590
|
await copyDir(srcPath, destPath);
|
|
277
591
|
} else {
|
|
278
|
-
await
|
|
592
|
+
await fs6.copyFile(srcPath, destPath);
|
|
279
593
|
}
|
|
280
594
|
}
|
|
281
595
|
}
|
|
282
596
|
async function copyContentAssets(contentDir, staticDir) {
|
|
283
|
-
const entries = await
|
|
597
|
+
const entries = await fs6.readdir(contentDir, { withFileTypes: true });
|
|
284
598
|
for (const entry of entries) {
|
|
285
|
-
const srcPath =
|
|
599
|
+
const srcPath = path11.join(contentDir, entry.name);
|
|
286
600
|
if (entry.isDirectory()) {
|
|
287
|
-
const destSubDir =
|
|
601
|
+
const destSubDir = path11.join(staticDir, entry.name);
|
|
288
602
|
await copyContentAssetsRecursive(srcPath, destSubDir);
|
|
289
603
|
} else {
|
|
290
|
-
const ext =
|
|
604
|
+
const ext = path11.extname(entry.name).toLowerCase();
|
|
291
605
|
if (CONTENT_EXTENSIONS.has(ext)) {
|
|
292
|
-
await
|
|
606
|
+
await fs6.copyFile(srcPath, path11.join(staticDir, entry.name));
|
|
293
607
|
}
|
|
294
608
|
}
|
|
295
609
|
}
|
|
296
610
|
}
|
|
297
611
|
async function copyContentAssetsRecursive(srcDir, destDir) {
|
|
298
|
-
const entries = await
|
|
612
|
+
const entries = await fs6.readdir(srcDir, { withFileTypes: true });
|
|
299
613
|
for (const entry of entries) {
|
|
300
|
-
const srcPath =
|
|
614
|
+
const srcPath = path11.join(srcDir, entry.name);
|
|
301
615
|
if (entry.isDirectory()) {
|
|
302
|
-
await copyContentAssetsRecursive(srcPath,
|
|
616
|
+
await copyContentAssetsRecursive(srcPath, path11.join(destDir, entry.name));
|
|
303
617
|
} else {
|
|
304
|
-
const ext =
|
|
618
|
+
const ext = path11.extname(entry.name).toLowerCase();
|
|
305
619
|
if (CONTENT_EXTENSIONS.has(ext)) {
|
|
306
|
-
await
|
|
307
|
-
await
|
|
620
|
+
await fs6.mkdir(destDir, { recursive: true });
|
|
621
|
+
await fs6.copyFile(srcPath, path11.join(destDir, entry.name));
|
|
308
622
|
}
|
|
309
623
|
}
|
|
310
624
|
}
|
|
@@ -332,11 +646,11 @@ var exports_prod = {};
|
|
|
332
646
|
__export(exports_prod, {
|
|
333
647
|
startProdServer: () => startProdServer
|
|
334
648
|
});
|
|
335
|
-
import
|
|
649
|
+
import path13 from "path";
|
|
336
650
|
import chalk7 from "chalk";
|
|
337
651
|
async function startProdServer(options) {
|
|
338
652
|
const { port, distDir } = options;
|
|
339
|
-
const serverEntry =
|
|
653
|
+
const serverEntry = path13.resolve(distDir, "server/entry-prod.js");
|
|
340
654
|
const { startServer } = await import(serverEntry);
|
|
341
655
|
console.log(chalk7.cyan("Starting production server..."));
|
|
342
656
|
return startServer({ port, distDir });
|
|
@@ -548,11 +862,16 @@ var devCommand = new Command2("dev").description("Start development server").opt
|
|
|
548
862
|
|
|
549
863
|
// src/cli/commands/build.ts
|
|
550
864
|
import { Command as Command3 } from "commander";
|
|
551
|
-
import
|
|
865
|
+
import path12 from "path";
|
|
552
866
|
import chalk6 from "chalk";
|
|
553
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) => {
|
|
554
868
|
const contentDir = resolveContentDir(options.content);
|
|
555
|
-
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
|
+
}
|
|
556
875
|
process.env.CHRONICLE_PROJECT_ROOT = process.cwd();
|
|
557
876
|
process.env.CHRONICLE_CONTENT_DIR = contentDir;
|
|
558
877
|
console.log(chalk6.cyan("Building for production..."));
|
|
@@ -563,14 +882,14 @@ var buildCommand = new Command3("build").description("Build for production").opt
|
|
|
563
882
|
await build({
|
|
564
883
|
...baseConfig,
|
|
565
884
|
build: {
|
|
566
|
-
outDir:
|
|
885
|
+
outDir: path12.join(outDir, "client"),
|
|
567
886
|
ssrManifest: true,
|
|
568
887
|
rolldownOptions: {
|
|
569
|
-
input:
|
|
888
|
+
input: path12.resolve(PACKAGE_ROOT, "src/server/index.html")
|
|
570
889
|
}
|
|
571
890
|
}
|
|
572
891
|
});
|
|
573
|
-
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");
|
|
574
893
|
console.log(chalk6.gray("Building server..."));
|
|
575
894
|
await build({
|
|
576
895
|
...baseConfig,
|
|
@@ -578,10 +897,15 @@ var buildCommand = new Command3("build").description("Build for production").opt
|
|
|
578
897
|
noExternal: true
|
|
579
898
|
},
|
|
580
899
|
build: {
|
|
581
|
-
outDir:
|
|
582
|
-
ssr: serverEntry
|
|
900
|
+
outDir: path12.join(outDir, "server"),
|
|
901
|
+
ssr: serverEntry,
|
|
902
|
+
target: "node22"
|
|
583
903
|
}
|
|
584
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`));
|
|
585
909
|
console.log(chalk6.green("Build complete →"), outDir);
|
|
586
910
|
if (options.adapter === "vercel") {
|
|
587
911
|
const { buildVercelOutput: buildVercelOutput2 } = await Promise.resolve().then(() => (init_vercel(), exports_vercel));
|
|
@@ -595,12 +919,12 @@ var buildCommand = new Command3("build").description("Build for production").opt
|
|
|
595
919
|
|
|
596
920
|
// src/cli/commands/start.ts
|
|
597
921
|
import { Command as Command4 } from "commander";
|
|
598
|
-
import
|
|
922
|
+
import path14 from "path";
|
|
599
923
|
import chalk8 from "chalk";
|
|
600
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) => {
|
|
601
925
|
const contentDir = resolveContentDir(options.content);
|
|
602
926
|
const port = parseInt(options.port, 10);
|
|
603
|
-
const distDir =
|
|
927
|
+
const distDir = path14.resolve(options.dist);
|
|
604
928
|
process.env.CHRONICLE_PROJECT_ROOT = process.cwd();
|
|
605
929
|
process.env.CHRONICLE_CONTENT_DIR = contentDir;
|
|
606
930
|
console.log(chalk8.cyan("Starting production server..."));
|
|
@@ -610,12 +934,12 @@ var startCommand = new Command4("start").description("Start production server").
|
|
|
610
934
|
|
|
611
935
|
// src/cli/commands/serve.ts
|
|
612
936
|
import { Command as Command5 } from "commander";
|
|
613
|
-
import
|
|
937
|
+
import path15 from "path";
|
|
614
938
|
import chalk9 from "chalk";
|
|
615
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) => {
|
|
616
940
|
const contentDir = resolveContentDir(options.content);
|
|
617
941
|
const port = parseInt(options.port, 10);
|
|
618
|
-
const outDir =
|
|
942
|
+
const outDir = path15.resolve(options.outDir);
|
|
619
943
|
process.env.CHRONICLE_PROJECT_ROOT = process.cwd();
|
|
620
944
|
process.env.CHRONICLE_CONTENT_DIR = contentDir;
|
|
621
945
|
console.log(chalk9.cyan("Building for production..."));
|
|
@@ -625,10 +949,10 @@ var serveCommand = new Command5("serve").description("Build and start production
|
|
|
625
949
|
await build({
|
|
626
950
|
...baseConfig,
|
|
627
951
|
build: {
|
|
628
|
-
outDir:
|
|
952
|
+
outDir: path15.join(outDir, "client"),
|
|
629
953
|
ssrManifest: true,
|
|
630
954
|
rolldownOptions: {
|
|
631
|
-
input:
|
|
955
|
+
input: path15.resolve(PACKAGE_ROOT, "src/server/index.html")
|
|
632
956
|
}
|
|
633
957
|
}
|
|
634
958
|
});
|
|
@@ -638,8 +962,8 @@ var serveCommand = new Command5("serve").description("Build and start production
|
|
|
638
962
|
noExternal: true
|
|
639
963
|
},
|
|
640
964
|
build: {
|
|
641
|
-
outDir:
|
|
642
|
-
ssr:
|
|
965
|
+
outDir: path15.join(outDir, "server"),
|
|
966
|
+
ssr: path15.resolve(PACKAGE_ROOT, "src/server/entry-prod.ts")
|
|
643
967
|
}
|
|
644
968
|
});
|
|
645
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 };
|
|
@@ -59,7 +59,7 @@ export async function buildVercelOutput(options: VercelAdapterOptions) {
|
|
|
59
59
|
await fs.writeFile(
|
|
60
60
|
path.resolve(funcDir, '.vc-config.json'),
|
|
61
61
|
JSON.stringify({
|
|
62
|
-
runtime: '
|
|
62
|
+
runtime: 'nodejs24.x',
|
|
63
63
|
handler: 'entry-vercel.js',
|
|
64
64
|
launcherType: 'Nodejs',
|
|
65
65
|
}, null, 2),
|
|
@@ -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
|
+
}
|