botdocs 0.4.0 → 0.5.0

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.
Files changed (36) hide show
  1. package/README.md +20 -2
  2. package/dist/src/builder/chunker.d.ts +18 -0
  3. package/dist/src/builder/chunker.js +62 -1
  4. package/dist/src/builder/embedder.js +4 -1
  5. package/dist/src/builder/index-size.d.ts +3 -0
  6. package/dist/src/builder/index-size.js +16 -0
  7. package/dist/src/builder/index.js +16 -3
  8. package/dist/src/builder/paths.d.ts +6 -0
  9. package/dist/src/builder/paths.js +13 -0
  10. package/dist/src/builder/site-generator.d.ts +2 -0
  11. package/dist/src/builder/site-generator.js +39 -11
  12. package/dist/src/builder/template-engine.d.ts +4 -4
  13. package/dist/src/builder/template-engine.js +5 -1
  14. package/dist/src/builder/vector-db-builder.d.ts +1 -0
  15. package/dist/src/builder/vector-db-builder.js +1 -0
  16. package/dist/src/cli/index.js +60 -17
  17. package/dist/src/cli/options.d.ts +2 -0
  18. package/dist/src/cli/options.js +2 -0
  19. package/dist/src/cli/server.d.ts +6 -0
  20. package/dist/src/cli/server.js +79 -0
  21. package/dist/src/cli/watcher.d.ts +7 -0
  22. package/dist/src/cli/watcher.js +40 -0
  23. package/dist/src/shared/site-root.d.ts +2 -0
  24. package/dist/src/shared/site-root.js +9 -0
  25. package/dist/src/types/config.d.ts +3 -0
  26. package/dist/src/types/config.js +2 -0
  27. package/dist/src/types/document.d.ts +1 -1
  28. package/dist-client/assets/chatbox-Dw_HFrfR.js +8 -0
  29. package/dist-client/assets/rag-engine-B9wYRzqT.js +1 -0
  30. package/dist-client/bundle.js +1 -1
  31. package/man/botdocs.1 +74 -3
  32. package/package.json +10 -1
  33. package/src/styles/chat.css +13 -0
  34. package/src/templates/layout.html +14 -2
  35. package/dist-client/assets/chatbox-P1j6YP1y.js +0 -7
  36. package/dist-client/assets/rag-engine-CU9vjCAg.js +0 -1
package/README.md CHANGED
@@ -1,5 +1,7 @@
1
1
  # Botdocs
2
2
 
3
+ [![Read about the commits](https://img.shields.io/badge/commits-code%20blog-1a1a1a?style=flat-square)](https://wwel.sh/digest.html?repo=botdocs)
4
+
3
5
  [![npm version](https://img.shields.io/npm/v/botdocs.svg)](https://www.npmjs.com/package/botdocs)
4
6
 
5
7
  Convert markdown documentation into beautiful static sites with AI-powered semantic search — no backend required.
@@ -12,6 +14,8 @@ Convert markdown documentation into beautiful static sites with AI-powered seman
12
14
  - **Deep Links** - Search results link directly to sections
13
15
  - **No Backend** - Everything runs in the browser
14
16
  - **Fast** - Syntax highlighting with Shiki
17
+ - **Live Preview** - `--watch` rebuilds on save and serves the site locally
18
+ - **SEO** - Optional Open Graph/Twitter tags and `sitemap.xml` via `baseUrl`
15
19
 
16
20
  ## Installation
17
21
 
@@ -44,6 +48,9 @@ botdocs ./docs -c ./my-config.json
44
48
 
45
49
  # Combine multiple options
46
50
  botdocs ./docs -o ./public -t slate -v
51
+
52
+ # Live preview: rebuild on save + local server
53
+ botdocs ./docs --watch
47
54
  ```
48
55
 
49
56
  ### CLI Options
@@ -55,6 +62,8 @@ botdocs ./docs -o ./public -t slate -v
55
62
  | `--config <file>` | `-c` | Path to config file | `botdocs.config.json` |
56
63
  | `--theme <theme>` | `-t` | Theme to use | `classic` |
57
64
  | `--verbose` | `-v` | Enable verbose logging | `false` |
65
+ | `--watch` | `-w` | Rebuild on changes and serve the site for live preview | `false` |
66
+ | `--port <number>` | `-p` | Port for the preview server (with `--watch`) | `3000` |
58
67
 
59
68
  ### Available Themes
60
69
 
@@ -73,12 +82,16 @@ Create `botdocs.config.json` in your docs directory:
73
82
  "title": "My Documentation",
74
83
  "description": "Project docs",
75
84
  "theme": "classic",
85
+ "customCss": "custom.css",
76
86
  "attribution": true,
87
+ "baseUrl": "https://example.com/docs/",
77
88
  "chat": { "enabled": true },
78
89
  "build": {
79
90
  "chunkSize": 500,
80
91
  "chunkOverlap": 50,
81
- "topK": 3
92
+ "minChunkSize": 15,
93
+ "topK": 3,
94
+ "minScore": 0.75
82
95
  }
83
96
  }
84
97
  ```
@@ -90,12 +103,16 @@ Create `botdocs.config.json` in your docs directory:
90
103
  | `title` | string | `"Documentation"` | Site title |
91
104
  | `description` | string | `"Project documentation"` | Site description |
92
105
  | `theme` | string | `"classic"` | Theme to use (classic, material, minimal, slate, modern) |
106
+ | `customCss` | string | none | Path to a CSS file, resolved relative to the config file's directory. Appended after theme CSS in `bundle.css`, so same-specificity selectors override the theme without `!important` |
93
107
  | `attribution` | boolean | `true` | Show "Built with Botdocs" footer link |
108
+ | `baseUrl` | string | none | Canonical URL where the site is hosted. When set, pages get `rel=canonical` and Open Graph/Twitter card tags, and a `sitemap.xml` is generated |
94
109
  | `chat.enabled` | boolean | `true` | Enable AI chatbot |
95
110
  | `chat.welcomeMessage` | string | `"Ask me anything about the docs!"` | Chatbot welcome message |
96
111
  | `build.chunkSize` | number | `500` | Text chunk size for embeddings |
97
112
  | `build.chunkOverlap` | number | `50` | Overlap between chunks |
113
+ | `build.minChunkSize` | number | `15` | Chunks smaller than this (estimated tokens) get folded into a neighboring chunk instead of becoming a standalone, low-signal search result |
98
114
  | `build.topK` | number | `3` | Number of results to return |
115
+ | `build.minScore` | number | `0.75` | Minimum vector similarity (0-1) a result must reach to be returned at all, regardless of `topK` — filters out weak/off-topic matches instead of always padding results. The e5 embedding model has a fairly high similarity floor even for unrelated text, so this needs to sit well above 0.5 to actually gate anything |
99
116
 
100
117
  ## Front Matter
101
118
 
@@ -113,11 +130,12 @@ description: Quick start guide
113
130
  1. **Build**: Parses markdown → generates embeddings → creates `vector-db.json`
114
131
  2. **Runtime**: User query → embed → search vector DB → return relevant chunks
115
132
  3. **No LLM**: Pure semantic search, not AI text generation
133
+ 4. **Consent**: On first use, visitors are asked before the embedding model downloads to their browser, with a disclosure of what runs locally
116
134
 
117
135
  ## Architecture
118
136
 
119
137
  - **Embedding Model**: `e5-small-v2` (384-dim vectors, 2.2x faster than all-MiniLM-L6-v2)
120
- - **Search**: Cosine similarity, client-side only
138
+ - **Search**: Hybrid — vector cosine similarity fused with BM25 keyword scoring (Reciprocal Rank Fusion), gated by a minimum similarity threshold, client-side only
121
139
  - **Browser Bundle**: ~825KB (includes Transformers.js)
122
140
  - **Deployment**: Fully static, works on any host
123
141
 
@@ -7,6 +7,13 @@ export interface TextChunk {
7
7
  export interface ChunkerOptions {
8
8
  maxChunkSize: number;
9
9
  chunkOverlap: number;
10
+ /**
11
+ * Chunks smaller than this (in estimated tokens) are folded into a
12
+ * neighboring chunk instead of being kept as standalone, low-information
13
+ * entries in the vector DB (e.g. a heading followed by a single short
14
+ * line). Set to 0 to disable merging.
15
+ */
16
+ minChunkSize: number;
10
17
  }
11
18
  /**
12
19
  * Text chunker that splits documents by headings while respecting token limits
@@ -18,6 +25,17 @@ export declare class Chunker {
18
25
  * Chunk a document into semantically meaningful pieces
19
26
  */
20
27
  chunkDocument(doc: ProcessedDocument, fileHash?: string): TextChunk[];
28
+ /**
29
+ * Fold chunks smaller than minChunkSize into a neighbor so a heading with
30
+ * little or no body content doesn't become its own low-signal retrieval
31
+ * candidate.
32
+ */
33
+ private mergeSmallChunks;
34
+ /**
35
+ * True for a line that is entirely markdown badges/images/links with no
36
+ * other prose (a bare link line, a shields.io badge, or a chain of both).
37
+ */
38
+ private isBoilerplateLine;
21
39
  /**
22
40
  * Create a chunk with metadata
23
41
  */
@@ -1,3 +1,6 @@
1
+ // Matches a markdown image, a badge (image wrapped in a link), or a plain
2
+ // link, e.g. `![alt](url)`, `[![alt](url)](url)`, `[text](url)`.
3
+ const LINK_OR_IMAGE = /\[!\[[^\]]*\]\([^)]*\)\]\([^)]*\)|!?\[[^\]]*\]\([^)]*\)/g;
1
4
  /**
2
5
  * Text chunker that splits documents by headings while respecting token limits
3
6
  */
@@ -7,6 +10,7 @@ export class Chunker {
7
10
  this.options = {
8
11
  maxChunkSize: options.maxChunkSize || 500,
9
12
  chunkOverlap: options.chunkOverlap || 50,
13
+ minChunkSize: options.minChunkSize ?? 15,
10
14
  };
11
15
  }
12
16
  /**
@@ -54,6 +58,13 @@ export class Chunker {
54
58
  currentChunk = [line];
55
59
  }
56
60
  else {
61
+ // Badges and bare reference links carry no retrievable prose, but
62
+ // their markup is character-heavy enough to dodge minChunkSize and
63
+ // their repeated project-name alt-text then dominates BM25 matches
64
+ // on any query mentioning that name. Drop them before they're ever
65
+ // embedded or indexed rather than filtering by size after the fact.
66
+ if (this.isBoilerplateLine(line))
67
+ continue;
57
68
  currentChunk.push(line);
58
69
  // Check if chunk is getting too large
59
70
  const tokenCount = this.estimateTokens(currentChunk.join('\n'));
@@ -71,7 +82,57 @@ export class Chunker {
71
82
  if (currentChunk.length > 0) {
72
83
  chunks.push(this.createChunk(currentChunk.join('\n'), doc, currentHeading, fileHash));
73
84
  }
74
- return chunks.filter((chunk) => chunk.text.trim().length > 0);
85
+ const nonEmptyChunks = chunks.filter((chunk) => chunk.text.trim().length > 0);
86
+ return this.mergeSmallChunks(nonEmptyChunks);
87
+ }
88
+ /**
89
+ * Fold chunks smaller than minChunkSize into a neighbor so a heading with
90
+ * little or no body content doesn't become its own low-signal retrieval
91
+ * candidate.
92
+ */
93
+ mergeSmallChunks(chunks) {
94
+ const minTokens = this.options.minChunkSize;
95
+ if (minTokens <= 0 || chunks.length <= 1)
96
+ return chunks;
97
+ const merged = [];
98
+ for (const chunk of chunks) {
99
+ const prev = merged[merged.length - 1];
100
+ if (prev && this.estimateTokens(prev.text) < minTokens) {
101
+ // Absorb forward: the small chunk becomes the lead-in for the next
102
+ // section, which takes over as the chunk's heading/metadata.
103
+ merged[merged.length - 1] = {
104
+ text: `${prev.text}\n\n${chunk.text}`,
105
+ metadata: chunk.metadata,
106
+ };
107
+ }
108
+ else {
109
+ merged.push(chunk);
110
+ }
111
+ }
112
+ // A trailing chunk with nothing after it to absorb into gets folded
113
+ // backward instead, keeping the earlier (more substantial) heading.
114
+ if (merged.length > 1) {
115
+ const last = merged[merged.length - 1];
116
+ if (this.estimateTokens(last.text) < minTokens) {
117
+ const prev = merged[merged.length - 2];
118
+ merged[merged.length - 2] = {
119
+ text: `${prev.text}\n\n${last.text}`,
120
+ metadata: prev.metadata,
121
+ };
122
+ merged.pop();
123
+ }
124
+ }
125
+ return merged;
126
+ }
127
+ /**
128
+ * True for a line that is entirely markdown badges/images/links with no
129
+ * other prose (a bare link line, a shields.io badge, or a chain of both).
130
+ */
131
+ isBoilerplateLine(line) {
132
+ const trimmed = line.trim();
133
+ if (!trimmed)
134
+ return false;
135
+ return trimmed.replace(LINK_OR_IMAGE, '').trim().length === 0;
75
136
  }
76
137
  /**
77
138
  * Create a chunk with metadata
@@ -2,7 +2,7 @@ import { pipeline, env } from '@huggingface/transformers';
2
2
  // Disable local model loading - use Hugging Face
3
3
  env.allowLocalModels = false;
4
4
  export class Embedder {
5
- model;
5
+ model = null;
6
6
  modelName;
7
7
  dimension;
8
8
  constructor(modelName = 'Xenova/e5-small-v2') {
@@ -24,6 +24,9 @@ export class Embedder {
24
24
  if (!this.model) {
25
25
  await this.initialize();
26
26
  }
27
+ if (!this.model) {
28
+ throw new Error('Failed to initialize embedding model');
29
+ }
27
30
  // Prepend "passage: " prefix for e5 models
28
31
  const prefixedText = `passage: ${text}`;
29
32
  // Generate embedding
@@ -0,0 +1,3 @@
1
+ export declare const INDEX_SIZE_WARN_BYTES: number;
2
+ export declare function formatBytes(bytes: number): string;
3
+ export declare function isIndexSizeWarning(bytes: number): boolean;
@@ -0,0 +1,16 @@
1
+ export const INDEX_SIZE_WARN_BYTES = 2 * 1024 * 1024;
2
+ export function formatBytes(bytes) {
3
+ if (bytes < 1024)
4
+ return `${bytes} B`;
5
+ const units = ['KB', 'MB', 'GB'];
6
+ let value = bytes;
7
+ let unit = -1;
8
+ do {
9
+ value /= 1024;
10
+ unit++;
11
+ } while (value >= 1024 && unit < units.length - 1);
12
+ return `${value.toFixed(1)} ${units[unit]}`;
13
+ }
14
+ export function isIndexSizeWarning(bytes) {
15
+ return bytes >= INDEX_SIZE_WARN_BYTES;
16
+ }
@@ -1,11 +1,14 @@
1
1
  import { defaultConfig } from '../types/config.js';
2
2
  import { SiteGenerator } from './site-generator.js';
3
3
  import { VectorDBBuilder } from './vector-db-builder.js';
4
- import { existsSync, readFileSync, writeFileSync, copyFileSync, mkdirSync, cpSync } from 'fs';
4
+ import { formatBytes, isIndexSizeWarning } from './index-size.js';
5
+ import { existsSync, readFileSync, writeFileSync, copyFileSync, mkdirSync, cpSync, statSync } from 'fs';
5
6
  import { join, dirname, resolve } from 'path';
6
7
  import { fileURLToPath } from 'url';
7
8
  import { exec } from 'child_process';
8
9
  import { promisify } from 'util';
10
+ import { INDEX_SIZE_WARN_BYTES } from './index-size.js';
11
+ import { underSrc } from './paths.js';
9
12
  const execAsync = promisify(exec);
10
13
  const __filename = fileURLToPath(import.meta.url);
11
14
  const __dirname = dirname(__filename);
@@ -58,8 +61,19 @@ export async function build(options) {
58
61
  const vectorDBBuilder = new VectorDBBuilder({
59
62
  chunkSize: config.build?.chunkSize,
60
63
  chunkOverlap: config.build?.chunkOverlap,
64
+ minChunkSize: config.build?.minChunkSize,
61
65
  });
62
66
  await vectorDBBuilder.build(documents, outputDir, verbose);
67
+ const dbPath = join(outputDir, 'vector-db.json');
68
+ if (existsSync(dbPath)) {
69
+ const { size } = statSync(dbPath);
70
+ console.log(`Search index: ${formatBytes(size)}`);
71
+ if (isIndexSizeWarning(size)) {
72
+ console.warn(`Warning: search index exceeds ${formatBytes(INDEX_SIZE_WARN_BYTES)} — ` +
73
+ 'it loads fully in the browser, so large indexes slow first paint. ' +
74
+ 'Consider fewer/smaller docs or raising build.minChunkSize.');
75
+ }
76
+ }
63
77
  }
64
78
  // Phase 3: Build client-side code with Vite
65
79
  console.log('Building client-side code...');
@@ -114,8 +128,7 @@ export async function build(options) {
114
128
  }
115
129
  // Phase 4: Copy styles
116
130
  console.log('Copying styles...');
117
- // From dist/src/builder, go to project root, then to src/styles
118
- const stylesDir = resolve(__dirname, '../../../src/styles');
131
+ const stylesDir = underSrc(__dirname, 'styles');
119
132
  const themesDir = join(stylesDir, 'themes');
120
133
  const outputCssDir = join(assetsDir, 'css');
121
134
  // Determine which theme to use
@@ -0,0 +1,6 @@
1
+ /**
2
+ * Resolve a directory under the project's src/ tree regardless of whether
3
+ * this module is executing compiled (dist/src/...) or from source (src/...)
4
+ * via tsx.
5
+ */
6
+ export declare function underSrc(moduleDir: string, name: string): string;
@@ -0,0 +1,13 @@
1
+ import { existsSync } from 'fs';
2
+ import { resolve } from 'path';
3
+ /**
4
+ * Resolve a directory under the project's src/ tree regardless of whether
5
+ * this module is executing compiled (dist/src/...) or from source (src/...)
6
+ * via tsx.
7
+ */
8
+ export function underSrc(moduleDir, name) {
9
+ const inSourceTree = resolve(moduleDir, '..', name);
10
+ if (existsSync(inSourceTree))
11
+ return inSourceTree;
12
+ return resolve(moduleDir, '..', '..', '..', 'src', name);
13
+ }
@@ -1,5 +1,6 @@
1
1
  import { ProcessedDocument } from '../types/document.js';
2
2
  import { BotdocsConfig } from '../types/config.js';
3
+ export declare function absoluteUrl(baseUrl: string, urlPath: string): string;
3
4
  export declare class SiteGenerator {
4
5
  private processor;
5
6
  private templateEngine;
@@ -9,6 +10,7 @@ export declare class SiteGenerator {
9
10
  * Generate the complete site
10
11
  */
11
12
  generate(inputDir: string, outputDir: string, config: BotdocsConfig): Promise<ProcessedDocument[]>;
13
+ private writeSitemap;
12
14
  /**
13
15
  * Build navigation structure from documents, grouping by top-level
14
16
  * folder so e.g. every doc under `path-of-python/` (its README plus
@@ -1,13 +1,18 @@
1
1
  import { readFileSync, writeFileSync, readdirSync, mkdirSync, cpSync } from 'fs';
2
- import { join, dirname, resolve, basename } from 'path';
2
+ import { join, dirname, basename } from 'path';
3
3
  import { MarkdownProcessor } from './markdown-processor.js';
4
4
  import { TemplateEngine } from './template-engine.js';
5
+ import { underSrc } from './paths.js';
6
+ import { rootPrefix, relativeUrl } from '../shared/site-root.js';
5
7
  import { fileURLToPath } from 'url';
6
8
  const __filename = fileURLToPath(import.meta.url);
7
9
  const __dirname = dirname(__filename);
8
10
  function stripHtml(value) {
9
11
  return value.replace(/<[^>]*>/g, '');
10
12
  }
13
+ export function absoluteUrl(baseUrl, urlPath) {
14
+ return `${baseUrl.replace(/\/+$/, '')}${urlPath.startsWith('/') ? urlPath : `/${urlPath}`}`;
15
+ }
11
16
  export class SiteGenerator {
12
17
  processor;
13
18
  templateEngine;
@@ -46,9 +51,9 @@ export class SiteGenerator {
46
51
  // Generate navigation
47
52
  const navigation = this.buildNavigation(this.documents);
48
53
  const pageSequence = this.buildPageSequence(navigation);
49
- // Load templates from source directory (not dist)
50
- // From dist/src/builder, go to project root, then to src/templates
51
- const templatesDir = resolve(__dirname, '../../../src/templates');
54
+ // Load templates from the project's src/templates, whether running
55
+ // compiled from dist/ or directly from src/ via tsx.
56
+ const templatesDir = underSrc(__dirname, 'templates');
52
57
  const layoutTemplate = readFileSync(join(templatesDir, 'layout.html'), 'utf-8');
53
58
  const docPageTemplate = readFileSync(join(templatesDir, 'doc-page.html'), 'utf-8');
54
59
  const indexTemplate = readFileSync(join(templatesDir, 'index.html'), 'utf-8');
@@ -57,8 +62,11 @@ export class SiteGenerator {
57
62
  for (let i = 0; i < this.documents.length; i++) {
58
63
  const doc = this.documents[i];
59
64
  const isIndex = doc.relativePath === 'README.md' || doc.relativePath === 'index.md';
65
+ const pageUrl = isIndex ? '/' : doc.url;
60
66
  // Prepare navigation data
61
67
  const adjacent = pageSequence.get(doc.url);
68
+ const root = rootPrefix(doc.url);
69
+ const relativeLink = (link) => link && { ...link, url: relativeUrl(root, link.url) };
62
70
  // Render document content
63
71
  const contentTemplate = isIndex ? indexTemplate : docPageTemplate;
64
72
  const content = this.templateEngine.renderWithLoops(contentTemplate, {
@@ -70,11 +78,11 @@ export class SiteGenerator {
70
78
  ? this.documents.filter(d => d !== doc).map(d => ({
71
79
  title: d.metadata.title,
72
80
  description: d.metadata.description,
73
- url: d.url,
81
+ url: relativeUrl(root, d.url),
74
82
  }))
75
83
  : undefined,
76
- prevPage: adjacent?.prev,
77
- nextPage: adjacent?.next,
84
+ prevPage: relativeLink(adjacent?.prev),
85
+ nextPage: relativeLink(adjacent?.next),
78
86
  });
79
87
  // Render full page with layout
80
88
  const html = this.templateEngine.renderWithLoops(layoutTemplate, {
@@ -87,8 +95,14 @@ export class SiteGenerator {
87
95
  siteTitle: config.title || 'Documentation',
88
96
  siteDescription: config.description || '',
89
97
  content,
90
- navigation: this.renderNavigation(navigation, doc.url),
98
+ root,
99
+ navigation: this.renderNavigation(navigation, root, doc.url),
91
100
  chatEnabled: config.chat?.enabled,
101
+ ogUrl: config.baseUrl ? absoluteUrl(config.baseUrl, pageUrl) : undefined,
102
+ searchConfigJson: JSON.stringify({
103
+ topK: config.build?.topK ?? 3,
104
+ minScore: config.build?.minScore ?? 0.75,
105
+ }),
92
106
  attribution: config.attribution !== false, // defaults to true
93
107
  });
94
108
  // Write HTML file
@@ -106,8 +120,22 @@ export class SiteGenerator {
106
120
  }
107
121
  }
108
122
  console.log(`Generated ${this.documents.length} HTML pages`);
123
+ if (config.baseUrl) {
124
+ this.writeSitemap(outputDir, config.baseUrl);
125
+ }
109
126
  return this.documents;
110
127
  }
128
+ writeSitemap(outputDir, baseUrl) {
129
+ const urls = this.documents
130
+ .map((doc) => {
131
+ const isRootIndex = doc.relativePath === 'README.md' || doc.relativePath === 'index.md';
132
+ return absoluteUrl(baseUrl, isRootIndex ? '/' : doc.url);
133
+ }).map((loc) => ` <url><loc>${loc}</loc></url>`)
134
+ .join('\n');
135
+ const sitemap = `<?xml version="1.0" encoding="UTF-8"?>\n<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">\n${urls}\n</urlset>\n`;
136
+ writeFileSync(join(outputDir, 'sitemap.xml'), sitemap, 'utf-8');
137
+ console.log('Generated sitemap.xml');
138
+ }
111
139
  /**
112
140
  * Build navigation structure from documents, grouping by top-level
113
141
  * folder so e.g. every doc under `path-of-python/` (its README plus
@@ -156,7 +184,7 @@ export class SiteGenerator {
156
184
  * other page sees the group collapsed to a single link that leads to
157
185
  * its root README.
158
186
  */
159
- renderNavigation(items, currentUrl) {
187
+ renderNavigation(items, root, currentUrl) {
160
188
  if (items.length === 0)
161
189
  return '';
162
190
  let html = '<ul class="nav-list">';
@@ -168,9 +196,9 @@ export class SiteGenerator {
168
196
  .filter(Boolean)
169
197
  .join(' ');
170
198
  html += `<li${liClasses ? ` class="${liClasses}"` : ''}>`;
171
- html += `<a href="${item.url}"${isActive ? ' class="active"' : ''}>${item.title}</a>`;
199
+ html += `<a href="${relativeUrl(root, item.url)}"${isActive ? ' class="active"' : ''}>${item.title}</a>`;
172
200
  if (isExpanded) {
173
- html += this.renderNavigation(item.children, currentUrl);
201
+ html += this.renderNavigation(item.children, root, currentUrl);
174
202
  }
175
203
  html += '</li>';
176
204
  }
@@ -6,12 +6,12 @@ export declare class TemplateEngine {
6
6
  * Render a template with variables
7
7
  * Replaces {{variableName}} with values from data object
8
8
  */
9
- render(template: string, data: Record<string, any>): string;
9
+ render(template: string, data: Record<string, unknown>): string;
10
10
  /**
11
11
  * Render a template with nested variables
12
12
  * Supports {{object.property}} syntax
13
13
  */
14
- renderAdvanced(template: string, data: Record<string, any>): string;
14
+ renderAdvanced(template: string, data: Record<string, unknown>): string;
15
15
  /**
16
16
  * Get nested property from object using dot notation
17
17
  */
@@ -21,10 +21,10 @@ export declare class TemplateEngine {
21
21
  * Supports {{#if variable}}...{{/if}} syntax
22
22
  * Handles nested conditionals recursively
23
23
  */
24
- renderWithConditionals(template: string, data: Record<string, any>): string;
24
+ renderWithConditionals(template: string, data: Record<string, unknown>): string;
25
25
  /**
26
26
  * Render template with loops
27
27
  * Supports {{#each items}}...{{/each}} syntax
28
28
  */
29
- renderWithLoops(template: string, data: Record<string, any>): string;
29
+ renderWithLoops(template: string, data: Record<string, unknown>): string;
30
30
  }
@@ -25,7 +25,11 @@ export class TemplateEngine {
25
25
  * Get nested property from object using dot notation
26
26
  */
27
27
  getNestedProperty(obj, path) {
28
- return path.split('.').reduce((current, key) => current?.[key], obj);
28
+ return path
29
+ .split('.')
30
+ .reduce((current, key) => current && typeof current === 'object'
31
+ ? current[key]
32
+ : undefined, obj);
29
33
  }
30
34
  /**
31
35
  * Render template with conditional blocks
@@ -3,6 +3,7 @@ import { VectorDatabase } from '../types/vector-db.js';
3
3
  export interface VectorDBBuilderOptions {
4
4
  chunkSize?: number;
5
5
  chunkOverlap?: number;
6
+ minChunkSize?: number;
6
7
  modelName?: string;
7
8
  }
8
9
  export declare class VectorDBBuilder {
@@ -10,6 +10,7 @@ export class VectorDBBuilder {
10
10
  this.chunker = new Chunker({
11
11
  maxChunkSize: options.chunkSize || 500,
12
12
  chunkOverlap: options.chunkOverlap || 50,
13
+ minChunkSize: options.minChunkSize,
13
14
  });
14
15
  this.embedder = new Embedder(options.modelName);
15
16
  }
@@ -1,14 +1,26 @@
1
1
  import { Command } from 'commander';
2
2
  import { build } from '../builder/index.js';
3
3
  import { defaultOptions } from './options.js';
4
- import { readFileSync } from 'fs';
4
+ import { startServer } from './server.js';
5
+ import { watchDocs } from './watcher.js';
6
+ import { readFileSync, existsSync } from 'fs';
5
7
  import { resolve, dirname } from 'path';
6
8
  import { fileURLToPath } from 'url';
7
9
  const __filename = fileURLToPath(import.meta.url);
8
10
  const __dirname = dirname(__filename);
9
- // Read package.json for version
10
- // From dist/src/cli/index.js, go up 3 levels to project root
11
- const packageJson = JSON.parse(readFileSync(resolve(__dirname, '../../../package.json'), 'utf-8'));
11
+ // Read package.json for version. Walks up from this file so it resolves
12
+ // both from dist/src/cli (installed) and src/cli (tsx).
13
+ function findPackageJson(startDir) {
14
+ let dir = startDir;
15
+ while (dir !== dirname(dir)) {
16
+ const candidate = resolve(dir, 'package.json');
17
+ if (existsSync(candidate))
18
+ return candidate;
19
+ dir = dirname(dir);
20
+ }
21
+ throw new Error('package.json not found');
22
+ }
23
+ const packageJson = JSON.parse(readFileSync(findPackageJson(__dirname), 'utf-8'));
12
24
  const program = new Command();
13
25
  program
14
26
  .name('botdocs')
@@ -20,27 +32,58 @@ program
20
32
  .option('-c, --config <file>', 'Path to config file (botdocs.config.json)')
21
33
  .option('-t, --theme <theme>', 'Theme to use (classic, material, minimal, slate, modern); overrides config file if set')
22
34
  .option('-v, --verbose', 'Enable verbose logging')
35
+ .option('-w, --watch', 'Rebuild on changes and serve the site for live preview')
36
+ .option('-p, --port <number>', 'Port for the preview server (with --watch)', parseInt)
23
37
  .action(async (input, options) => {
24
- try {
25
- const inputDir = resolve(process.cwd(), input);
26
- const outputDir = resolve(process.cwd(), options.output || defaultOptions.output);
27
- if (options.verbose) {
28
- console.log('Botdocs starting...');
29
- console.log(`Input: ${inputDir}`);
30
- console.log(`Output: ${outputDir}`);
31
- console.log(`Chat enabled: ${!options.noChat}`);
32
- console.log(`Theme override: ${options.theme || '(none — using config file or default)'}`);
33
- }
38
+ const inputDir = resolve(process.cwd(), input);
39
+ const outputDir = resolve(process.cwd(), options.output || defaultOptions.output);
40
+ const verbose = options.verbose || false;
41
+ const runBuild = async () => {
34
42
  await build({
35
43
  inputDir,
36
44
  outputDir,
37
45
  chatEnabled: !options.noChat,
38
46
  configPath: options.config,
39
- verbose: options.verbose || false,
47
+ verbose,
40
48
  theme: options.theme,
41
49
  });
42
- console.log('Build complete!');
43
- console.log(`Site generated at: ${outputDir}`);
50
+ };
51
+ try {
52
+ if (verbose) {
53
+ console.log('Botdocs starting...');
54
+ console.log(`Input: ${inputDir}`);
55
+ console.log(`Output: ${outputDir}`);
56
+ console.log(`Chat enabled: ${!options.noChat}`);
57
+ console.log(`Theme override: ${options.theme || '(none — using config file or default)'}`);
58
+ }
59
+ await runBuild();
60
+ if (options.watch) {
61
+ const server = await startServer(outputDir, options.port ?? defaultOptions.port);
62
+ console.log(`Site generated at: ${outputDir}`);
63
+ console.log(`Preview at: ${server.url}`);
64
+ console.log('Watching for changes... Press Ctrl+C to stop.');
65
+ const watcher = watchDocs(inputDir, (changes) => {
66
+ const names = [...new Set(changes.map((c) => c.path.split('/').pop()))].join(', ');
67
+ console.log(`Changed: ${names} — rebuilding...`);
68
+ runBuild().catch((error) => {
69
+ console.error('Rebuild failed:', error);
70
+ });
71
+ });
72
+ let shuttingDown = false;
73
+ const shutdown = () => {
74
+ if (shuttingDown)
75
+ return;
76
+ shuttingDown = true;
77
+ watcher.close();
78
+ server.close().finally(() => process.exit(0));
79
+ };
80
+ process.on('SIGINT', shutdown);
81
+ process.on('SIGTERM', shutdown);
82
+ }
83
+ else {
84
+ console.log('Build complete!');
85
+ console.log(`Site generated at: ${outputDir}`);
86
+ }
44
87
  }
45
88
  catch (error) {
46
89
  console.error('Build failed:', error);
@@ -5,5 +5,7 @@ export interface CliOptions {
5
5
  config?: string;
6
6
  verbose?: boolean;
7
7
  theme?: Theme;
8
+ watch?: boolean;
9
+ port?: number;
8
10
  }
9
11
  export declare const defaultOptions: Partial<CliOptions>;
@@ -2,4 +2,6 @@ export const defaultOptions = {
2
2
  output: 'output',
3
3
  noChat: false,
4
4
  verbose: false,
5
+ watch: false,
6
+ port: 3000,
5
7
  };
@@ -0,0 +1,6 @@
1
+ export interface RunningServer {
2
+ url: string;
3
+ port: number;
4
+ close(): Promise<void>;
5
+ }
6
+ export declare function startServer(rootDir: string, port?: number): Promise<RunningServer>;