botdocs 0.1.1 → 0.3.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.
package/README.md CHANGED
@@ -1,5 +1,7 @@
1
1
  # Botdocs
2
2
 
3
+ [![npm version](https://img.shields.io/npm/v/botdocs.svg)](https://www.npmjs.com/package/botdocs)
4
+
3
5
  Convert markdown documentation into beautiful static sites with AI-powered semantic search — no backend required.
4
6
 
5
7
  ## Features
@@ -33,8 +35,35 @@ botdocs ./docs -o ./public
33
35
 
34
36
  # Verbose logging
35
37
  botdocs ./docs -v
38
+
39
+ # Use a specific theme
40
+ botdocs ./docs -t material
41
+
42
+ # Custom config file
43
+ botdocs ./docs -c ./my-config.json
44
+
45
+ # Combine multiple options
46
+ botdocs ./docs -o ./public -t slate -v
36
47
  ```
37
48
 
49
+ ### CLI Options
50
+
51
+ | Option | Alias | Description | Default |
52
+ |--------|-------|-------------|---------|
53
+ | `--output <dir>` | `-o` | Output directory for generated site | `output` |
54
+ | `--no-chat` | | Disable AI chatbot functionality | `false` |
55
+ | `--config <file>` | `-c` | Path to config file | `botdocs.config.json` |
56
+ | `--theme <theme>` | `-t` | Theme to use | `classic` |
57
+ | `--verbose` | `-v` | Enable verbose logging | `false` |
58
+
59
+ ### Available Themes
60
+
61
+ - **classic** - Clean, professional theme (default)
62
+ - **material** - Material Design theme
63
+ - **minimal** - Clean, minimalist theme
64
+ - **slate** - Dark slate theme
65
+ - **modern** - Modern documentation theme
66
+
38
67
  ## Configuration
39
68
 
40
69
  Create `botdocs.config.json` in your docs directory:
@@ -43,15 +72,31 @@ Create `botdocs.config.json` in your docs directory:
43
72
  {
44
73
  "title": "My Documentation",
45
74
  "description": "Project docs",
75
+ "theme": "classic",
76
+ "attribution": true,
46
77
  "chat": { "enabled": true },
47
78
  "build": {
48
79
  "chunkSize": 500,
49
80
  "chunkOverlap": 50,
50
- "topK": 5
81
+ "topK": 3
51
82
  }
52
83
  }
53
84
  ```
54
85
 
86
+ ### Configuration Options
87
+
88
+ | Option | Type | Default | Description |
89
+ |--------|------|---------|-------------|
90
+ | `title` | string | `"Documentation"` | Site title |
91
+ | `description` | string | `"Project documentation"` | Site description |
92
+ | `theme` | string | `"classic"` | Theme to use (classic, material, minimal, slate, modern) |
93
+ | `attribution` | boolean | `true` | Show "Built with Botdocs" footer link |
94
+ | `chat.enabled` | boolean | `true` | Enable AI chatbot |
95
+ | `chat.welcomeMessage` | string | `"Ask me anything about the docs!"` | Chatbot welcome message |
96
+ | `build.chunkSize` | number | `500` | Text chunk size for embeddings |
97
+ | `build.chunkOverlap` | number | `50` | Overlap between chunks |
98
+ | `build.topK` | number | `3` | Number of results to return |
99
+
55
100
  ## Front Matter
56
101
 
57
102
  ```markdown
@@ -17,7 +17,7 @@ export declare class Chunker {
17
17
  /**
18
18
  * Chunk a document into semantically meaningful pieces
19
19
  */
20
- chunkDocument(doc: ProcessedDocument): TextChunk[];
20
+ chunkDocument(doc: ProcessedDocument, fileHash?: string): TextChunk[];
21
21
  /**
22
22
  * Create a chunk with metadata
23
23
  */
@@ -37,5 +37,5 @@ export declare class Chunker {
37
37
  /**
38
38
  * Chunk multiple documents
39
39
  */
40
- chunkDocuments(documents: ProcessedDocument[]): TextChunk[];
40
+ chunkDocuments(documents: ProcessedDocument[], fileHashes?: Map<string, string>): TextChunk[];
41
41
  }
@@ -12,7 +12,7 @@ export class Chunker {
12
12
  /**
13
13
  * Chunk a document into semantically meaningful pieces
14
14
  */
15
- chunkDocument(doc) {
15
+ chunkDocument(doc, fileHash) {
16
16
  const chunks = [];
17
17
  const lines = doc.content.split('\n');
18
18
  let currentChunk = [];
@@ -47,7 +47,7 @@ export class Chunker {
47
47
  if (headingMatch) {
48
48
  // Save current chunk if it exists
49
49
  if (currentChunk.length > 0) {
50
- chunks.push(this.createChunk(currentChunk.join('\n'), doc, currentHeading));
50
+ chunks.push(this.createChunk(currentChunk.join('\n'), doc, currentHeading, fileHash));
51
51
  }
52
52
  // Start new chunk with this heading
53
53
  currentHeading = headingMatch[2];
@@ -60,7 +60,7 @@ export class Chunker {
60
60
  if (tokenCount >= this.options.maxChunkSize) {
61
61
  // Split chunk
62
62
  const chunkText = currentChunk.join('\n');
63
- chunks.push(this.createChunk(chunkText, doc, currentHeading));
63
+ chunks.push(this.createChunk(chunkText, doc, currentHeading, fileHash));
64
64
  // Create overlap for next chunk
65
65
  const overlapLines = this.getOverlapLines(currentChunk, this.options.chunkOverlap);
66
66
  currentChunk = overlapLines;
@@ -69,14 +69,14 @@ export class Chunker {
69
69
  }
70
70
  // Add final chunk
71
71
  if (currentChunk.length > 0) {
72
- chunks.push(this.createChunk(currentChunk.join('\n'), doc, currentHeading));
72
+ chunks.push(this.createChunk(currentChunk.join('\n'), doc, currentHeading, fileHash));
73
73
  }
74
74
  return chunks.filter((chunk) => chunk.text.trim().length > 0);
75
75
  }
76
76
  /**
77
77
  * Create a chunk with metadata
78
78
  */
79
- createChunk(text, doc, heading) {
79
+ createChunk(text, doc, heading, fileHash) {
80
80
  return {
81
81
  text: text.trim(),
82
82
  metadata: {
@@ -85,6 +85,7 @@ export class Chunker {
85
85
  heading,
86
86
  headingId: heading ? this.slugify(heading) : undefined,
87
87
  url: doc.url,
88
+ fileHash,
88
89
  },
89
90
  };
90
91
  }
@@ -128,10 +129,11 @@ export class Chunker {
128
129
  /**
129
130
  * Chunk multiple documents
130
131
  */
131
- chunkDocuments(documents) {
132
+ chunkDocuments(documents, fileHashes) {
132
133
  const allChunks = [];
133
134
  for (const doc of documents) {
134
- const chunks = this.chunkDocument(doc);
135
+ const fileHash = fileHashes?.get(doc.relativePath);
136
+ const chunks = this.chunkDocument(doc, fileHash);
135
137
  allChunks.push(...chunks);
136
138
  }
137
139
  return allChunks;
@@ -13,7 +13,7 @@ const __dirname = dirname(__filename);
13
13
  * Main build orchestrator
14
14
  */
15
15
  export async function build(options) {
16
- const { inputDir, outputDir, chatEnabled, configPath, verbose } = options;
16
+ const { inputDir, outputDir, chatEnabled, configPath, verbose, theme } = options;
17
17
  // Validate input directory
18
18
  if (!existsSync(inputDir)) {
19
19
  throw new Error(`Input directory does not exist: ${inputDir}`);
@@ -25,6 +25,10 @@ export async function build(options) {
25
25
  config.chat = config.chat || {};
26
26
  config.chat.enabled = chatEnabled;
27
27
  }
28
+ // Override theme if specified in CLI
29
+ if (theme) {
30
+ config.theme = theme;
31
+ }
28
32
  if (verbose) {
29
33
  console.log('Configuration:', JSON.stringify(config, null, 2));
30
34
  }
@@ -107,9 +111,12 @@ export async function build(options) {
107
111
  console.log('Copying styles...');
108
112
  // From dist/src/builder, go to project root, then to src/styles
109
113
  const stylesDir = resolve(__dirname, '../../../src/styles');
114
+ const themesDir = join(stylesDir, 'themes');
110
115
  const outputCssDir = join(assetsDir, 'css');
111
- // Combine all CSS files into one bundle
112
- const cssFiles = ['main.css', 'themes.css', 'chat.css'];
116
+ // Determine which theme to use
117
+ const selectedTheme = config.theme || defaultConfig.theme || 'classic';
118
+ // Combine CSS files: base styles + selected theme + chat
119
+ const cssFiles = ['themes.css', 'chat.css'];
113
120
  let bundledCss = '';
114
121
  for (const cssFile of cssFiles) {
115
122
  const cssPath = join(stylesDir, cssFile);
@@ -117,6 +124,21 @@ export async function build(options) {
117
124
  bundledCss += readFileSync(cssPath, 'utf-8') + '\n\n';
118
125
  }
119
126
  }
127
+ // Add selected theme CSS
128
+ const themePath = join(themesDir, `${selectedTheme}.css`);
129
+ if (existsSync(themePath)) {
130
+ bundledCss += readFileSync(themePath, 'utf-8') + '\n\n';
131
+ if (verbose) {
132
+ console.log(`Using theme: ${selectedTheme}`);
133
+ }
134
+ }
135
+ else {
136
+ console.warn(`Theme '${selectedTheme}' not found, falling back to classic`);
137
+ const fallbackPath = join(themesDir, 'classic.css');
138
+ if (existsSync(fallbackPath)) {
139
+ bundledCss += readFileSync(fallbackPath, 'utf-8') + '\n\n';
140
+ }
141
+ }
120
142
  writeFileSync(join(outputCssDir, 'bundle.css'), bundledCss, 'utf-8');
121
143
  if (verbose) {
122
144
  console.log('Styles copied');
@@ -92,6 +92,7 @@ export class SiteGenerator {
92
92
  content,
93
93
  navigation: this.renderNavigation(navigation, doc.url),
94
94
  chatEnabled: config.chat?.enabled,
95
+ attribution: config.attribution !== false, // defaults to true
95
96
  });
96
97
  // Write HTML file
97
98
  const outputPath = join(outputDir, doc.relativePath.replace(/\.md$/, '.html'));
@@ -10,9 +10,21 @@ export declare class VectorDBBuilder {
10
10
  private embedder;
11
11
  constructor(options?: VectorDBBuilderOptions);
12
12
  /**
13
- * Build vector database from documents
13
+ * Build vector database from documents (with incremental build support)
14
14
  */
15
15
  build(documents: ProcessedDocument[], outputDir: string, verbose?: boolean): Promise<VectorDatabase>;
16
+ /**
17
+ * Load existing vector database from disk
18
+ */
19
+ private loadExistingDatabase;
20
+ /**
21
+ * Build a map of sourceFile -> chunks for quick lookup
22
+ */
23
+ private buildCacheMap;
24
+ /**
25
+ * Hash file content for change detection
26
+ */
27
+ private hashContent;
16
28
  /**
17
29
  * Generate unique ID for a chunk
18
30
  */
@@ -1,6 +1,6 @@
1
1
  import { Chunker } from './chunker.js';
2
2
  import { Embedder } from './embedder.js';
3
- import { writeFileSync } from 'fs';
3
+ import { writeFileSync, existsSync, readFileSync } from 'fs';
4
4
  import { join } from 'path';
5
5
  import { createHash } from 'crypto';
6
6
  export class VectorDBBuilder {
@@ -14,43 +14,130 @@ export class VectorDBBuilder {
14
14
  this.embedder = new Embedder(options.modelName);
15
15
  }
16
16
  /**
17
- * Build vector database from documents
17
+ * Build vector database from documents (with incremental build support)
18
18
  */
19
19
  async build(documents, outputDir, verbose = false) {
20
20
  if (verbose) {
21
21
  console.log('Building vector database...');
22
22
  }
23
- // Step 1: Chunk documents
24
- console.log('Chunking documents...');
25
- const chunks = this.chunker.chunkDocuments(documents);
26
- console.log(`Created ${chunks.length} chunks`);
27
- // Step 2: Generate embeddings
28
- console.log('Generating embeddings...');
29
- await this.embedder.initialize();
30
- const texts = chunks.map((chunk) => chunk.text);
31
- const embeddings = await this.embedder.embedBatch(texts, 10);
32
- // Step 3: Create document chunks with embeddings
33
- const documentChunks = chunks.map((chunk, index) => ({
34
- id: this.generateChunkId(chunk.text, chunk.metadata.sourceFile, index),
35
- text: chunk.text,
36
- embedding: embeddings[index],
37
- metadata: chunk.metadata,
38
- }));
39
- // Step 4: Create vector database
23
+ const outputPath = join(outputDir, 'vector-db.json');
24
+ // Step 1: Load existing vector database for incremental builds
25
+ const existingDB = this.loadExistingDatabase(outputPath, verbose);
26
+ const cachedChunks = this.buildCacheMap(existingDB);
27
+ // Step 2: Hash documents and determine what needs rebuilding
28
+ const fileHashes = new Map();
29
+ const changedDocs = [];
30
+ const unchangedDocs = [];
31
+ for (const doc of documents) {
32
+ const hash = this.hashContent(doc.content);
33
+ fileHashes.set(doc.relativePath, hash);
34
+ const cachedDocChunks = cachedChunks.get(doc.relativePath);
35
+ if (cachedDocChunks && cachedDocChunks[0]?.metadata.fileHash === hash) {
36
+ unchangedDocs.push(doc);
37
+ if (verbose) {
38
+ console.log(` ✓ Cached: ${doc.relativePath}`);
39
+ }
40
+ }
41
+ else {
42
+ changedDocs.push(doc);
43
+ if (verbose) {
44
+ console.log(` ⟳ Changed: ${doc.relativePath}`);
45
+ }
46
+ }
47
+ }
48
+ console.log(`Unchanged: ${unchangedDocs.length}, Changed: ${changedDocs.length}, Total: ${documents.length}`);
49
+ // Step 3: Reuse cached chunks for unchanged documents
50
+ const reusedChunks = [];
51
+ for (const doc of unchangedDocs) {
52
+ const chunks = cachedChunks.get(doc.relativePath);
53
+ if (chunks) {
54
+ reusedChunks.push(...chunks);
55
+ }
56
+ }
57
+ // Step 4: Process changed documents only
58
+ let newChunks = [];
59
+ if (changedDocs.length > 0) {
60
+ console.log('Chunking changed documents...');
61
+ const chunks = this.chunker.chunkDocuments(changedDocs, fileHashes);
62
+ console.log(`Created ${chunks.length} chunks from ${changedDocs.length} changed documents`);
63
+ // Generate embeddings only for changed documents
64
+ console.log('Generating embeddings for changed documents...');
65
+ await this.embedder.initialize();
66
+ const texts = chunks.map((chunk) => chunk.text);
67
+ const embeddings = await this.embedder.embedBatch(texts, 10);
68
+ // Create document chunks with embeddings
69
+ newChunks = chunks.map((chunk, index) => ({
70
+ id: this.generateChunkId(chunk.text, chunk.metadata.sourceFile, index),
71
+ text: chunk.text,
72
+ embedding: embeddings[index],
73
+ metadata: chunk.metadata,
74
+ }));
75
+ }
76
+ else {
77
+ console.log('No changes detected - reusing all cached embeddings');
78
+ }
79
+ // Step 5: Merge cached and new chunks
80
+ const allChunks = [...reusedChunks, ...newChunks];
81
+ // Step 6: Create vector database
40
82
  const vectorDB = {
41
83
  version: '1.0',
42
84
  model: this.embedder.getModelName(),
43
85
  dimension: this.embedder.getDimension(),
44
- chunks: documentChunks,
86
+ chunks: allChunks,
45
87
  };
46
- // Step 5: Write to file
47
- const outputPath = join(outputDir, 'vector-db.json');
88
+ // Step 7: Write to file
48
89
  writeFileSync(outputPath, JSON.stringify(vectorDB, null, 2), 'utf-8');
49
90
  console.log(`Vector database saved: ${outputPath}`);
50
- console.log(`Total chunks: ${vectorDB.chunks.length}`);
91
+ console.log(`Total chunks: ${vectorDB.chunks.length} (${reusedChunks.length} cached, ${newChunks.length} new)`);
51
92
  console.log(`Embedding dimension: ${vectorDB.dimension}`);
52
93
  return vectorDB;
53
94
  }
95
+ /**
96
+ * Load existing vector database from disk
97
+ */
98
+ loadExistingDatabase(outputPath, verbose) {
99
+ if (!existsSync(outputPath)) {
100
+ if (verbose) {
101
+ console.log('No existing vector database found - performing full build');
102
+ }
103
+ return null;
104
+ }
105
+ try {
106
+ const content = readFileSync(outputPath, 'utf-8');
107
+ const db = JSON.parse(content);
108
+ if (verbose) {
109
+ console.log(`Loaded existing vector database with ${db.chunks.length} chunks`);
110
+ }
111
+ return db;
112
+ }
113
+ catch (error) {
114
+ console.warn('Failed to load existing vector database - performing full build');
115
+ return null;
116
+ }
117
+ }
118
+ /**
119
+ * Build a map of sourceFile -> chunks for quick lookup
120
+ */
121
+ buildCacheMap(db) {
122
+ const cache = new Map();
123
+ if (!db) {
124
+ return cache;
125
+ }
126
+ for (const chunk of db.chunks) {
127
+ const sourceFile = chunk.metadata.sourceFile;
128
+ if (!cache.has(sourceFile)) {
129
+ cache.set(sourceFile, []);
130
+ }
131
+ cache.get(sourceFile).push(chunk);
132
+ }
133
+ return cache;
134
+ }
135
+ /**
136
+ * Hash file content for change detection
137
+ */
138
+ hashContent(content) {
139
+ return createHash('md5').update(content).digest('hex');
140
+ }
54
141
  /**
55
142
  * Generate unique ID for a chunk
56
143
  */
@@ -18,6 +18,7 @@ program
18
18
  .option('-o, --output <dir>', 'Output directory for generated site', defaultOptions.output)
19
19
  .option('--no-chat', 'Disable AI chatbot functionality')
20
20
  .option('-c, --config <file>', 'Path to config file (botdocs.config.json)')
21
+ .option('-t, --theme <theme>', 'Theme to use (classic, material, minimal, slate, modern)', defaultOptions.theme)
21
22
  .option('-v, --verbose', 'Enable verbose logging')
22
23
  .action(async (input, options) => {
23
24
  try {
@@ -28,6 +29,7 @@ program
28
29
  console.log(`Input: ${inputDir}`);
29
30
  console.log(`Output: ${outputDir}`);
30
31
  console.log(`Chat enabled: ${!options.noChat}`);
32
+ console.log(`Theme: ${options.theme || defaultOptions.theme}`);
31
33
  }
32
34
  await build({
33
35
  inputDir,
@@ -35,6 +37,7 @@ program
35
37
  chatEnabled: !options.noChat,
36
38
  configPath: options.config,
37
39
  verbose: options.verbose || false,
40
+ theme: options.theme,
38
41
  });
39
42
  console.log('Build complete!');
40
43
  console.log(`Site generated at: ${outputDir}`);
@@ -1,7 +1,9 @@
1
+ export type Theme = 'classic' | 'material' | 'minimal' | 'slate' | 'modern';
1
2
  export interface CliOptions {
2
3
  output?: string;
3
4
  noChat?: boolean;
4
5
  config?: string;
5
6
  verbose?: boolean;
7
+ theme?: Theme;
6
8
  }
7
9
  export declare const defaultOptions: Partial<CliOptions>;
@@ -2,4 +2,5 @@ export const defaultOptions = {
2
2
  output: 'output',
3
3
  noChat: false,
4
4
  verbose: false,
5
+ theme: 'classic',
5
6
  };
@@ -1,9 +1,9 @@
1
+ export type Theme = 'classic' | 'material' | 'minimal' | 'slate' | 'modern';
1
2
  export interface BotdocsConfig {
2
3
  title?: string;
3
4
  description?: string;
4
- theme?: {
5
- primaryColor?: string;
6
- };
5
+ theme?: Theme;
6
+ attribution?: boolean;
7
7
  chat?: {
8
8
  enabled?: boolean;
9
9
  welcomeMessage?: string;
@@ -20,5 +20,6 @@ export interface BuildOptions {
20
20
  chatEnabled: boolean;
21
21
  configPath?: string;
22
22
  verbose: boolean;
23
+ theme?: Theme;
23
24
  }
24
25
  export declare const defaultConfig: BotdocsConfig;
@@ -1,9 +1,8 @@
1
1
  export const defaultConfig = {
2
2
  title: 'Documentation',
3
3
  description: 'Project documentation',
4
- theme: {
5
- primaryColor: '#3b82f6',
6
- },
4
+ theme: 'classic',
5
+ attribution: true,
7
6
  chat: {
8
7
  enabled: true,
9
8
  welcomeMessage: 'Ask me anything about the docs!',
@@ -11,6 +10,6 @@ export const defaultConfig = {
11
10
  build: {
12
11
  chunkSize: 500,
13
12
  chunkOverlap: 50,
14
- topK: 5,
13
+ topK: 3,
15
14
  },
16
15
  };
@@ -6,6 +6,7 @@ export interface ChunkMetadata {
6
6
  url: string;
7
7
  startLine?: number;
8
8
  endLine?: number;
9
+ fileHash?: string;
9
10
  }
10
11
  export interface DocumentChunk {
11
12
  id: string;
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "botdocs",
3
- "version": "0.1.1",
4
- "description": "An npm CLI tool that converts markdown documentation into a static website with an optional client-side AI chatbot",
3
+ "version": "0.3.0",
4
+ "description": "CLI tool that converts markdown documentation into a static website with an optional client-side AI chatbot",
5
5
  "author": "usr-wwelsh <https://wwel.sh>",
6
6
  "license": "MIT",
7
7
  "bin": {
@@ -503,6 +503,29 @@ img {
503
503
  font-size: 0.875rem;
504
504
  }
505
505
 
506
+ /* Footer Attribution */
507
+ .botdocs-attribution {
508
+ margin-top: 4rem;
509
+ padding-top: 2rem;
510
+ border-top: 1px solid var(--border-color);
511
+ text-align: center;
512
+ font-size: 0.875rem;
513
+ color: var(--text-secondary);
514
+ opacity: 0.8;
515
+ }
516
+
517
+ .botdocs-attribution a {
518
+ color: var(--text-secondary);
519
+ text-decoration: none;
520
+ font-weight: 500;
521
+ transition: color 0.2s;
522
+ }
523
+
524
+ .botdocs-attribution a:hover {
525
+ color: var(--link-color);
526
+ text-decoration: none;
527
+ }
528
+
506
529
  /* Responsive */
507
530
  @media (max-width: 768px) {
508
531
  .sidebar {