@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 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 path6 from "path";
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 = path6.resolve(root, "src/server/index.html");
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 = path6.join(contentDir, decodeURIComponent(url.split("?")[0]));
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 = path6.extname(contentFile).toLowerCase();
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(path6.resolve(root, "src/server/router.ts"));
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(path6.resolve(root, "src/lib/source.ts"));
165
- const { mdxComponents } = await vite.ssrLoadModule(path6.resolve(root, "src/components/mdx/index.tsx"));
166
- const { loadConfig } = await vite.ssrLoadModule(path6.resolve(root, "src/lib/config.ts"));
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(path6.resolve(root, "src/lib/openapi.ts"));
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 dataScript = `<script>window.__PAGE_DATA__ = ${JSON.stringify(embeddedData)}</script>`;
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(path6.resolve(root, "src/server/entry-server.tsx"));
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 path7 from "path";
230
- import fs3 from "fs/promises";
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 = path7.resolve(projectRoot, ".vercel/output");
549
+ const outputDir = path11.resolve(projectRoot, ".vercel/output");
236
550
  console.log(chalk5.gray("Generating Vercel output..."));
237
- await fs3.rm(outputDir, { recursive: true, force: true });
238
- const staticDir = path7.resolve(outputDir, "static");
239
- const funcDir = path7.resolve(outputDir, "functions/index.func");
240
- await fs3.mkdir(staticDir, { recursive: true });
241
- await fs3.mkdir(funcDir, { recursive: true });
242
- const clientDir = path7.resolve(distDir, "client");
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 = path7.resolve(distDir, "server");
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 = path7.resolve(clientDir, "src/server/index.html");
253
- await fs3.copyFile(templateSrc, path7.resolve(funcDir, "index.html"));
254
- await fs3.writeFile(path7.resolve(funcDir, ".vc-config.json"), JSON.stringify({
255
- runtime: "nodejs22.x",
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 fs3.writeFile(path7.resolve(outputDir, "config.json"), JSON.stringify({
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 fs3.mkdir(dest, { recursive: true });
270
- const entries = await fs3.readdir(src, { withFileTypes: true });
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 = path7.join(src, entry.name);
273
- const destPath = path7.join(dest, entry.name);
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 fs3.copyFile(srcPath, destPath);
592
+ await fs6.copyFile(srcPath, destPath);
278
593
  }
279
594
  }
280
595
  }
281
596
  async function copyContentAssets(contentDir, staticDir) {
282
- const entries = await fs3.readdir(contentDir, { withFileTypes: true });
597
+ const entries = await fs6.readdir(contentDir, { withFileTypes: true });
283
598
  for (const entry of entries) {
284
- const srcPath = path7.join(contentDir, entry.name);
599
+ const srcPath = path11.join(contentDir, entry.name);
285
600
  if (entry.isDirectory()) {
286
- const destSubDir = path7.join(staticDir, entry.name);
601
+ const destSubDir = path11.join(staticDir, entry.name);
287
602
  await copyContentAssetsRecursive(srcPath, destSubDir);
288
603
  } else {
289
- const ext = path7.extname(entry.name).toLowerCase();
604
+ const ext = path11.extname(entry.name).toLowerCase();
290
605
  if (CONTENT_EXTENSIONS.has(ext)) {
291
- await fs3.copyFile(srcPath, path7.join(staticDir, entry.name));
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 fs3.readdir(srcDir, { withFileTypes: true });
612
+ const entries = await fs6.readdir(srcDir, { withFileTypes: true });
298
613
  for (const entry of entries) {
299
- const srcPath = path7.join(srcDir, entry.name);
614
+ const srcPath = path11.join(srcDir, entry.name);
300
615
  if (entry.isDirectory()) {
301
- await copyContentAssetsRecursive(srcPath, path7.join(destDir, entry.name));
616
+ await copyContentAssetsRecursive(srcPath, path11.join(destDir, entry.name));
302
617
  } else {
303
- const ext = path7.extname(entry.name).toLowerCase();
618
+ const ext = path11.extname(entry.name).toLowerCase();
304
619
  if (CONTENT_EXTENSIONS.has(ext)) {
305
- await fs3.mkdir(destDir, { recursive: true });
306
- await fs3.copyFile(srcPath, path7.join(destDir, entry.name));
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 path9 from "path";
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 = path9.resolve(distDir, "server/entry-prod.js");
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 path8 from "path";
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 = path8.resolve(options.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: path8.join(outDir, "client"),
885
+ outDir: path12.join(outDir, "client"),
566
886
  ssrManifest: true,
567
887
  rolldownOptions: {
568
- input: path8.resolve(PACKAGE_ROOT, "src/server/index.html")
888
+ input: path12.resolve(PACKAGE_ROOT, "src/server/index.html")
569
889
  }
570
890
  }
571
891
  });
572
- const serverEntry = options.adapter === "vercel" ? path8.resolve(PACKAGE_ROOT, "src/server/entry-vercel.ts") : path8.resolve(PACKAGE_ROOT, "src/server/entry-prod.ts");
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: path8.join(outDir, "server"),
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 path10 from "path";
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 = path10.resolve(options.dist);
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 path11 from "path";
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 = path11.resolve(options.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: path11.join(outDir, "client"),
952
+ outDir: path15.join(outDir, "client"),
628
953
  ssrManifest: true,
629
954
  rolldownOptions: {
630
- input: path11.resolve(PACKAGE_ROOT, "src/server/index.html")
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: path11.join(outDir, "server"),
641
- ssr: path11.resolve(PACKAGE_ROOT, "src/server/entry-prod.ts")
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@raystack/chronicle",
3
- "version": "0.1.0-canary.d9f273b",
3
+ "version": "0.1.0-canary.f0d9bde",
4
4
  "description": "Config-driven documentation framework",
5
5
  "license": "Apache-2.0",
6
6
  "type": "module",
@@ -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
- clearTimeout(timerRef.current);
24
+ let cancelled = false;
25
+ if (timerRef.current) clearTimeout(timerRef.current);
25
26
  timerRef.current = setTimeout(async () => {
26
27
  setIsLoading(true);
27
28
  try {
28
29
  const params = new URLSearchParams();
29
30
  if (query) params.set("query", query);
30
31
  const res = await fetch(`/api/search?${params}`);
31
- setResults(await res.json());
32
+ if (!cancelled) setResults(await res.json());
32
33
  } catch {
33
- setResults([]);
34
+ if (!cancelled) setResults([]);
34
35
  }
35
- setIsLoading(false);
36
+ if (!cancelled) setIsLoading(false);
36
37
  }, 100);
37
- return () => clearTimeout(timerRef.current);
38
+ return () => {
39
+ cancelled = true;
40
+ if (timerRef.current) clearTimeout(timerRef.current);
41
+ };
38
42
  }, [query]);
39
43
 
40
44
  return { results, isLoading };
@@ -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 .vc-config.json
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: 'nodejs22.x',
62
+ runtime: 'nodejs24.x',
57
63
  handler: 'entry-vercel.js',
58
64
  launcherType: 'Nodejs',
59
65
  }, null, 2),
60
66
  )
61
67
 
62
- // 6. Write config.json
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 = path.join(contentDir, decodeURIComponent(url.split('?')[0]))
42
- if (!url.endsWith('.md') && !url.endsWith('.mdx')) {
42
+ const contentFile = safePath(contentDir, url)
43
+ if (contentFile && !url.endsWith('.md') && !url.endsWith('.mdx')) {
43
44
  try {
44
45
  const stat = await fsPromises.stat(contentFile)
45
46
  if (stat.isFile()) {
@@ -119,7 +120,8 @@ export async function startDevServer(options: DevServerOptions) {
119
120
  template = await vite.transformIndexHtml(url, template)
120
121
 
121
122
  // Embed page data for client hydration
122
- const dataScript = `<script>window.__PAGE_DATA__ = ${JSON.stringify(embeddedData)}</script>`
123
+ const safeJson = JSON.stringify(embeddedData).replace(/</g, '\\u003c')
124
+ const dataScript = `<script>window.__PAGE_DATA__ = ${safeJson}</script>`
123
125
  template = template.replace('<!--head-outlet-->', `<!--head-outlet-->${dataScript}`)
124
126
 
125
127
  const { render } = await vite.ssrLoadModule(path.resolve(root, 'src/server/entry-server.tsx'))
@@ -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 = path.join(contentDir, decodeURIComponent(url.split('?')[0]))
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((e as Error).message)
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((e as Error).message)
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 getIndex(): Promise<MiniSearch<SearchDocument>> {
95
- if (searchIndex) return searchIndex
122
+ async function loadDocuments(): Promise<SearchDocument[]> {
123
+ // Try pre-built index first
124
+ const prebuilt = await loadPrebuiltIndex()
125
+ if (prebuilt) return prebuilt
96
126
 
127
+ // Fallback to filesystem scanning (dev mode)
97
128
  const [contentDocs, apiDocs] = await Promise.all([
98
129
  scanContent(),
99
130
  Promise.resolve(buildApiDocs()),
100
131
  ])
132
+ return [...contentDocs, ...apiDocs]
133
+ }
101
134
 
102
- searchIndex = new MiniSearch<SearchDocument>({
103
- fields: ['title', 'content'],
104
- storeFields: ['url', 'title', 'type'],
105
- searchOptions: {
106
- boost: { title: 2 },
107
- fuzzy: 0.2,
108
- prefix: true,
109
- },
110
- })
135
+ async function getDocs(): Promise<SearchDocument[]> {
136
+ if (cachedDocs) return cachedDocs
137
+ cachedDocs = await loadDocuments()
138
+ return cachedDocs
139
+ }
111
140
 
112
- searchIndex.addAll([...contentDocs, ...apiDocs])
141
+ async function getIndex(): Promise<MiniSearch<SearchDocument>> {
142
+ if (searchIndex) return searchIndex
143
+ const docs = await getDocs()
144
+ searchIndex = createIndex(docs)
113
145
  return searchIndex
114
146
  }
115
147
 
@@ -119,8 +151,8 @@ export async function handleSearch(req: Request): Promise<Response> {
119
151
  const index = await getIndex()
120
152
 
121
153
  if (!query) {
122
- const contentDocs = await scanContent()
123
- const suggestions = contentDocs.slice(0, 8).map((d) => ({
154
+ const docs = await getDocs()
155
+ const suggestions = docs.filter(d => d.type === 'page').slice(0, 8).map((d) => ({
124
156
  id: d.id,
125
157
  url: d.url,
126
158
  type: d.type,
@@ -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 dataScript = `<script>window.__PAGE_DATA__ = ${JSON.stringify(embeddedData)}</script>`
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
+ }