botdocs 0.4.0 → 0.6.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 +21 -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 +4 -0
  11. package/dist/src/builder/site-generator.js +63 -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 +84 -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,9 @@ 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, `sitemap.xml` and `robots.txt` via `baseUrl`
19
+ - **Agent Friendly** - Raw `.md` source published next to every page, plus an `llms.txt` index
15
20
 
16
21
  ## Installation
17
22
 
@@ -44,6 +49,9 @@ botdocs ./docs -c ./my-config.json
44
49
 
45
50
  # Combine multiple options
46
51
  botdocs ./docs -o ./public -t slate -v
52
+
53
+ # Live preview: rebuild on save + local server
54
+ botdocs ./docs --watch
47
55
  ```
48
56
 
49
57
  ### CLI Options
@@ -55,6 +63,8 @@ botdocs ./docs -o ./public -t slate -v
55
63
  | `--config <file>` | `-c` | Path to config file | `botdocs.config.json` |
56
64
  | `--theme <theme>` | `-t` | Theme to use | `classic` |
57
65
  | `--verbose` | `-v` | Enable verbose logging | `false` |
66
+ | `--watch` | `-w` | Rebuild on changes and serve the site for live preview | `false` |
67
+ | `--port <number>` | `-p` | Port for the preview server (with `--watch`) | `3000` |
58
68
 
59
69
  ### Available Themes
60
70
 
@@ -73,12 +83,16 @@ Create `botdocs.config.json` in your docs directory:
73
83
  "title": "My Documentation",
74
84
  "description": "Project docs",
75
85
  "theme": "classic",
86
+ "customCss": "custom.css",
76
87
  "attribution": true,
88
+ "baseUrl": "https://example.com/docs/",
77
89
  "chat": { "enabled": true },
78
90
  "build": {
79
91
  "chunkSize": 500,
80
92
  "chunkOverlap": 50,
81
- "topK": 3
93
+ "minChunkSize": 15,
94
+ "topK": 3,
95
+ "minScore": 0.75
82
96
  }
83
97
  }
84
98
  ```
@@ -90,12 +104,16 @@ Create `botdocs.config.json` in your docs directory:
90
104
  | `title` | string | `"Documentation"` | Site title |
91
105
  | `description` | string | `"Project documentation"` | Site description |
92
106
  | `theme` | string | `"classic"` | Theme to use (classic, material, minimal, slate, modern) |
107
+ | `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
108
  | `attribution` | boolean | `true` | Show "Built with Botdocs" footer link |
109
+ | `baseUrl` | string | none | Canonical URL where the site is hosted. When set, pages get `rel=canonical` and Open Graph/Twitter card tags, a `sitemap.xml` and `robots.txt` are generated, and `llms.txt` links become absolute |
94
110
  | `chat.enabled` | boolean | `true` | Enable AI chatbot |
95
111
  | `chat.welcomeMessage` | string | `"Ask me anything about the docs!"` | Chatbot welcome message |
96
112
  | `build.chunkSize` | number | `500` | Text chunk size for embeddings |
97
113
  | `build.chunkOverlap` | number | `50` | Overlap between chunks |
114
+ | `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
115
  | `build.topK` | number | `3` | Number of results to return |
116
+ | `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
117
 
100
118
  ## Front Matter
101
119
 
@@ -113,11 +131,12 @@ description: Quick start guide
113
131
  1. **Build**: Parses markdown → generates embeddings → creates `vector-db.json`
114
132
  2. **Runtime**: User query → embed → search vector DB → return relevant chunks
115
133
  3. **No LLM**: Pure semantic search, not AI text generation
134
+ 4. **Consent**: On first use, visitors are asked before the embedding model downloads to their browser, with a disclosure of what runs locally
116
135
 
117
136
  ## Architecture
118
137
 
119
138
  - **Embedding Model**: `e5-small-v2` (384-dim vectors, 2.2x faster than all-MiniLM-L6-v2)
120
- - **Search**: Cosine similarity, client-side only
139
+ - **Search**: Hybrid — vector cosine similarity fused with BM25 keyword scoring (Reciprocal Rank Fusion), gated by a minimum similarity threshold, client-side only
121
140
  - **Browser Bundle**: ~825KB (includes Transformers.js)
122
141
  - **Deployment**: Fully static, works on any host
123
142
 
@@ -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,9 @@ 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 writeLlmsTxt;
14
+ private writeRobotsTxt;
15
+ private writeSitemap;
12
16
  /**
13
17
  * Build navigation structure from documents, grouping by top-level
14
18
  * 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,14 +95,21 @@ 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
95
109
  const outputPath = join(outputDir, doc.relativePath.replace(/\.md$/, '.html'));
96
110
  mkdirSync(dirname(outputPath), { recursive: true });
97
111
  writeFileSync(outputPath, html, 'utf-8');
112
+ cpSync(doc.filePath, join(outputDir, doc.relativePath));
98
113
  }
99
114
  // Copy index.html if README.md exists
100
115
  const readmeDoc = this.documents.find((d) => d.relativePath === 'README.md' || d.relativePath === 'index.md');
@@ -106,8 +121,45 @@ export class SiteGenerator {
106
121
  }
107
122
  }
108
123
  console.log(`Generated ${this.documents.length} HTML pages`);
124
+ this.writeLlmsTxt(outputDir, config);
125
+ if (config.baseUrl) {
126
+ this.writeSitemap(outputDir, config.baseUrl);
127
+ this.writeRobotsTxt(outputDir, config.baseUrl);
128
+ }
109
129
  return this.documents;
110
130
  }
131
+ writeLlmsTxt(outputDir, config) {
132
+ const oneLine = (value) => stripHtml(value).replace(/\s+/g, ' ').trim();
133
+ const isRootIndex = (doc) => doc.relativePath === 'README.md' || doc.relativePath === 'index.md';
134
+ const pages = [...this.documents.filter(isRootIndex), ...this.documents.filter((doc) => !isRootIndex(doc))]
135
+ .map((doc) => {
136
+ const title = oneLine(doc.metadata.title || basename(doc.relativePath, '.md'));
137
+ const url = config.baseUrl ? absoluteUrl(config.baseUrl, doc.relativePath) : doc.relativePath;
138
+ const description = oneLine(doc.metadata.description || '');
139
+ return `- [${title}](${url})${description ? `: ${description}` : ''}`;
140
+ })
141
+ .join('\n');
142
+ const summary = oneLine(config.description || '');
143
+ const llms = `# ${oneLine(config.title || 'Documentation')}\n\n${summary ? `> ${summary}\n\n` : ''}## Docs\n\n${pages}\n`;
144
+ writeFileSync(join(outputDir, 'llms.txt'), llms, 'utf-8');
145
+ console.log('Generated llms.txt');
146
+ }
147
+ writeRobotsTxt(outputDir, baseUrl) {
148
+ const robots = `User-agent: *\nAllow: /\n\nSitemap: ${absoluteUrl(baseUrl, 'sitemap.xml')}\n`;
149
+ writeFileSync(join(outputDir, 'robots.txt'), robots, 'utf-8');
150
+ console.log('Generated robots.txt');
151
+ }
152
+ writeSitemap(outputDir, baseUrl) {
153
+ const urls = this.documents
154
+ .map((doc) => {
155
+ const isRootIndex = doc.relativePath === 'README.md' || doc.relativePath === 'index.md';
156
+ return absoluteUrl(baseUrl, isRootIndex ? '/' : doc.url);
157
+ }).map((loc) => ` <url><loc>${loc}</loc></url>`)
158
+ .join('\n');
159
+ 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`;
160
+ writeFileSync(join(outputDir, 'sitemap.xml'), sitemap, 'utf-8');
161
+ console.log('Generated sitemap.xml');
162
+ }
111
163
  /**
112
164
  * Build navigation structure from documents, grouping by top-level
113
165
  * folder so e.g. every doc under `path-of-python/` (its README plus
@@ -156,7 +208,7 @@ export class SiteGenerator {
156
208
  * other page sees the group collapsed to a single link that leads to
157
209
  * its root README.
158
210
  */
159
- renderNavigation(items, currentUrl) {
211
+ renderNavigation(items, root, currentUrl) {
160
212
  if (items.length === 0)
161
213
  return '';
162
214
  let html = '<ul class="nav-list">';
@@ -168,9 +220,9 @@ export class SiteGenerator {
168
220
  .filter(Boolean)
169
221
  .join(' ');
170
222
  html += `<li${liClasses ? ` class="${liClasses}"` : ''}>`;
171
- html += `<a href="${item.url}"${isActive ? ' class="active"' : ''}>${item.title}</a>`;
223
+ html += `<a href="${relativeUrl(root, item.url)}"${isActive ? ' class="active"' : ''}>${item.title}</a>`;
172
224
  if (isExpanded) {
173
- html += this.renderNavigation(item.children, currentUrl);
225
+ html += this.renderNavigation(item.children, root, currentUrl);
174
226
  }
175
227
  html += '</li>';
176
228
  }
@@ -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>;