botdocs 0.1.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 (37) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +93 -0
  3. package/bin/botdocs.js +6 -0
  4. package/dist/src/builder/chunker.d.ts +41 -0
  5. package/dist/src/builder/chunker.js +139 -0
  6. package/dist/src/builder/embedder.d.ts +26 -0
  7. package/dist/src/builder/embedder.js +75 -0
  8. package/dist/src/builder/index.d.ts +5 -0
  9. package/dist/src/builder/index.js +142 -0
  10. package/dist/src/builder/markdown-processor.d.ts +26 -0
  11. package/dist/src/builder/markdown-processor.js +161 -0
  12. package/dist/src/builder/site-generator.d.ts +24 -0
  13. package/dist/src/builder/site-generator.js +152 -0
  14. package/dist/src/builder/template-engine.d.ts +30 -0
  15. package/dist/src/builder/template-engine.js +77 -0
  16. package/dist/src/builder/vector-db-builder.d.ts +20 -0
  17. package/dist/src/builder/vector-db-builder.js +63 -0
  18. package/dist/src/cli/index.d.ts +1 -0
  19. package/dist/src/cli/index.js +47 -0
  20. package/dist/src/cli/options.d.ts +7 -0
  21. package/dist/src/cli/options.js +5 -0
  22. package/dist/src/types/config.d.ts +24 -0
  23. package/dist/src/types/config.js +16 -0
  24. package/dist/src/types/document.d.ts +20 -0
  25. package/dist/src/types/document.js +1 -0
  26. package/dist/src/types/vector-db.d.ts +21 -0
  27. package/dist/src/types/vector-db.js +1 -0
  28. package/dist-client/assets/chatbox-CmTTiB2Z.js +1 -0
  29. package/dist-client/assets/rag-engine-L2vj3Y0G.js +7 -0
  30. package/dist-client/bundle.js +1 -0
  31. package/package.json +71 -0
  32. package/src/styles/chat.css +420 -0
  33. package/src/styles/main.css +532 -0
  34. package/src/styles/themes.css +78 -0
  35. package/src/templates/doc-page.html +27 -0
  36. package/src/templates/index.html +28 -0
  37. package/src/templates/layout.html +59 -0
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 usr-wwelsh
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,93 @@
1
+ # Botdocs
2
+
3
+ Convert markdown documentation into beautiful static sites with AI-powered semantic search — no backend required.
4
+
5
+ ## Features
6
+
7
+ - **Markdown to HTML** - Converts `.md` files into polished static sites
8
+ - **Semantic Search** - Client-side vector search using Transformers.js
9
+ - **Dark Mode** - Built-in theme switching
10
+ - **Deep Links** - Search results link directly to sections
11
+ - **No Backend** - Everything runs in the browser
12
+ - **Fast** - Syntax highlighting with Shiki
13
+
14
+ ## Installation
15
+
16
+ Install globally via npm:
17
+
18
+ ```bash
19
+ npm install -g botdocs
20
+ ```
21
+
22
+ ## Usage
23
+
24
+ ```bash
25
+ # Generate site from markdown
26
+ botdocs ./docs
27
+
28
+ # Disable chatbot
29
+ botdocs ./docs --no-chat
30
+
31
+ # Custom output directory
32
+ botdocs ./docs -o ./public
33
+
34
+ # Verbose logging
35
+ botdocs ./docs -v
36
+ ```
37
+
38
+ ## Configuration
39
+
40
+ Create `botdocs.config.json` in your docs directory:
41
+
42
+ ```json
43
+ {
44
+ "title": "My Documentation",
45
+ "description": "Project docs",
46
+ "chat": { "enabled": true },
47
+ "build": {
48
+ "chunkSize": 500,
49
+ "chunkOverlap": 50,
50
+ "topK": 5
51
+ }
52
+ }
53
+ ```
54
+
55
+ ## Front Matter
56
+
57
+ ```markdown
58
+ ---
59
+ title: Getting Started
60
+ description: Quick start guide
61
+ ---
62
+
63
+ # Your content here
64
+ ```
65
+
66
+ ## How It Works
67
+
68
+ 1. **Build**: Parses markdown → generates embeddings → creates `vector-db.json`
69
+ 2. **Runtime**: User query → embed → search vector DB → return relevant chunks
70
+ 3. **No LLM**: Pure semantic search, not AI text generation
71
+
72
+ ## Architecture
73
+
74
+ - **Embedding Model**: `e5-small-v2` (384-dim vectors, 2.2x faster than all-MiniLM-L6-v2)
75
+ - **Search**: Cosine similarity, client-side only
76
+ - **Browser Bundle**: ~825KB (includes Transformers.js)
77
+ - **Deployment**: Fully static, works on any host
78
+
79
+ ## Development
80
+
81
+ Building from source:
82
+
83
+ ```bash
84
+ git clone https://github.com/usr-wwelsh/botdocs.git
85
+ cd botdocs
86
+ npm install
87
+ npm run build && npm run build:client
88
+ botdocs ./test-docs
89
+ ```
90
+
91
+ ## License
92
+
93
+ MIT © [usr-wwelsh](https://github.com/usr-wwelsh)
package/bin/botdocs.js ADDED
@@ -0,0 +1,6 @@
1
+ #!/usr/bin/env node
2
+
3
+ import('../dist/src/cli/index.js').catch((err) => {
4
+ console.error('Failed to load botdocs CLI:', err);
5
+ process.exit(1);
6
+ });
@@ -0,0 +1,41 @@
1
+ import { ProcessedDocument } from '../types/document.js';
2
+ import { ChunkMetadata } from '../types/vector-db.js';
3
+ export interface TextChunk {
4
+ text: string;
5
+ metadata: ChunkMetadata;
6
+ }
7
+ export interface ChunkerOptions {
8
+ maxChunkSize: number;
9
+ chunkOverlap: number;
10
+ }
11
+ /**
12
+ * Text chunker that splits documents by headings while respecting token limits
13
+ */
14
+ export declare class Chunker {
15
+ private options;
16
+ constructor(options?: Partial<ChunkerOptions>);
17
+ /**
18
+ * Chunk a document into semantically meaningful pieces
19
+ */
20
+ chunkDocument(doc: ProcessedDocument): TextChunk[];
21
+ /**
22
+ * Create a chunk with metadata
23
+ */
24
+ private createChunk;
25
+ /**
26
+ * Convert heading text to anchor ID (matches markdown-it-anchor behavior)
27
+ */
28
+ private slugify;
29
+ /**
30
+ * Estimate token count (rough approximation: ~4 chars = 1 token)
31
+ */
32
+ private estimateTokens;
33
+ /**
34
+ * Get last N tokens worth of lines for overlap
35
+ */
36
+ private getOverlapLines;
37
+ /**
38
+ * Chunk multiple documents
39
+ */
40
+ chunkDocuments(documents: ProcessedDocument[]): TextChunk[];
41
+ }
@@ -0,0 +1,139 @@
1
+ /**
2
+ * Text chunker that splits documents by headings while respecting token limits
3
+ */
4
+ export class Chunker {
5
+ options;
6
+ constructor(options = {}) {
7
+ this.options = {
8
+ maxChunkSize: options.maxChunkSize || 500,
9
+ chunkOverlap: options.chunkOverlap || 50,
10
+ };
11
+ }
12
+ /**
13
+ * Chunk a document into semantically meaningful pieces
14
+ */
15
+ chunkDocument(doc) {
16
+ const chunks = [];
17
+ const lines = doc.content.split('\n');
18
+ let currentChunk = [];
19
+ let currentHeading;
20
+ let inCodeBlock = false;
21
+ let codeBlockLines = [];
22
+ for (let i = 0; i < lines.length; i++) {
23
+ const line = lines[i];
24
+ // Handle code blocks
25
+ if (line.trim().startsWith('```')) {
26
+ if (!inCodeBlock) {
27
+ // Start of code block
28
+ inCodeBlock = true;
29
+ codeBlockLines = [line];
30
+ }
31
+ else {
32
+ // End of code block
33
+ inCodeBlock = false;
34
+ codeBlockLines.push(line);
35
+ // Add complete code block to current chunk
36
+ currentChunk.push(...codeBlockLines);
37
+ codeBlockLines = [];
38
+ }
39
+ continue;
40
+ }
41
+ if (inCodeBlock) {
42
+ codeBlockLines.push(line);
43
+ continue;
44
+ }
45
+ // Detect headings
46
+ const headingMatch = line.match(/^(#{1,3})\s+(.+)$/);
47
+ if (headingMatch) {
48
+ // Save current chunk if it exists
49
+ if (currentChunk.length > 0) {
50
+ chunks.push(this.createChunk(currentChunk.join('\n'), doc, currentHeading));
51
+ }
52
+ // Start new chunk with this heading
53
+ currentHeading = headingMatch[2];
54
+ currentChunk = [line];
55
+ }
56
+ else {
57
+ currentChunk.push(line);
58
+ // Check if chunk is getting too large
59
+ const tokenCount = this.estimateTokens(currentChunk.join('\n'));
60
+ if (tokenCount >= this.options.maxChunkSize) {
61
+ // Split chunk
62
+ const chunkText = currentChunk.join('\n');
63
+ chunks.push(this.createChunk(chunkText, doc, currentHeading));
64
+ // Create overlap for next chunk
65
+ const overlapLines = this.getOverlapLines(currentChunk, this.options.chunkOverlap);
66
+ currentChunk = overlapLines;
67
+ }
68
+ }
69
+ }
70
+ // Add final chunk
71
+ if (currentChunk.length > 0) {
72
+ chunks.push(this.createChunk(currentChunk.join('\n'), doc, currentHeading));
73
+ }
74
+ return chunks.filter((chunk) => chunk.text.trim().length > 0);
75
+ }
76
+ /**
77
+ * Create a chunk with metadata
78
+ */
79
+ createChunk(text, doc, heading) {
80
+ return {
81
+ text: text.trim(),
82
+ metadata: {
83
+ sourceFile: doc.relativePath,
84
+ title: doc.metadata.title || 'Untitled',
85
+ heading,
86
+ headingId: heading ? this.slugify(heading) : undefined,
87
+ url: doc.url,
88
+ },
89
+ };
90
+ }
91
+ /**
92
+ * Convert heading text to anchor ID (matches markdown-it-anchor behavior)
93
+ */
94
+ slugify(text) {
95
+ return text
96
+ .toLowerCase()
97
+ .trim()
98
+ .replace(/[\s+]/g, '-')
99
+ .replace(/[^\w\-]+/g, '')
100
+ .replace(/\-\-+/g, '-')
101
+ .replace(/^-+/, '')
102
+ .replace(/-+$/, '');
103
+ }
104
+ /**
105
+ * Estimate token count (rough approximation: ~4 chars = 1 token)
106
+ */
107
+ estimateTokens(text) {
108
+ return Math.ceil(text.length / 4);
109
+ }
110
+ /**
111
+ * Get last N tokens worth of lines for overlap
112
+ */
113
+ getOverlapLines(lines, targetTokens) {
114
+ const result = [];
115
+ let tokenCount = 0;
116
+ // Work backwards from end
117
+ for (let i = lines.length - 1; i >= 0; i--) {
118
+ const line = lines[i];
119
+ const lineTokens = this.estimateTokens(line);
120
+ if (tokenCount + lineTokens > targetTokens) {
121
+ break;
122
+ }
123
+ result.unshift(line);
124
+ tokenCount += lineTokens;
125
+ }
126
+ return result;
127
+ }
128
+ /**
129
+ * Chunk multiple documents
130
+ */
131
+ chunkDocuments(documents) {
132
+ const allChunks = [];
133
+ for (const doc of documents) {
134
+ const chunks = this.chunkDocument(doc);
135
+ allChunks.push(...chunks);
136
+ }
137
+ return allChunks;
138
+ }
139
+ }
@@ -0,0 +1,26 @@
1
+ export declare class Embedder {
2
+ private model;
3
+ private modelName;
4
+ private dimension;
5
+ constructor(modelName?: string);
6
+ /**
7
+ * Initialize the embedding model
8
+ */
9
+ initialize(): Promise<void>;
10
+ /**
11
+ * Generate embedding for a single text
12
+ */
13
+ embed(text: string): Promise<number[]>;
14
+ /**
15
+ * Generate embeddings for multiple texts (batched for efficiency)
16
+ */
17
+ embedBatch(texts: string[], batchSize?: number, onProgress?: (current: number, total: number) => void): Promise<number[][]>;
18
+ /**
19
+ * Get embedding dimension
20
+ */
21
+ getDimension(): number;
22
+ /**
23
+ * Get model name
24
+ */
25
+ getModelName(): string;
26
+ }
@@ -0,0 +1,75 @@
1
+ import { pipeline, env } from '@xenova/transformers';
2
+ // Disable local model loading - use Hugging Face
3
+ env.allowLocalModels = false;
4
+ export class Embedder {
5
+ model;
6
+ modelName;
7
+ dimension;
8
+ constructor(modelName = 'Xenova/e5-small-v2') {
9
+ this.modelName = modelName;
10
+ this.dimension = 384; // e5-small-v2 produces 384-dimensional embeddings
11
+ }
12
+ /**
13
+ * Initialize the embedding model
14
+ */
15
+ async initialize() {
16
+ console.log(`Loading embedding model: ${this.modelName}...`);
17
+ this.model = await pipeline('feature-extraction', this.modelName);
18
+ console.log('Embedding model loaded');
19
+ }
20
+ /**
21
+ * Generate embedding for a single text
22
+ */
23
+ async embed(text) {
24
+ if (!this.model) {
25
+ await this.initialize();
26
+ }
27
+ // Prepend "passage: " prefix for e5 models
28
+ const prefixedText = `passage: ${text}`;
29
+ // Generate embedding
30
+ const output = await this.model(prefixedText, {
31
+ pooling: 'mean',
32
+ normalize: true,
33
+ });
34
+ // Convert to array
35
+ const embedding = Array.from(output.data);
36
+ return embedding;
37
+ }
38
+ /**
39
+ * Generate embeddings for multiple texts (batched for efficiency)
40
+ */
41
+ async embedBatch(texts, batchSize = 10, onProgress) {
42
+ if (!this.model) {
43
+ await this.initialize();
44
+ }
45
+ const embeddings = [];
46
+ const total = texts.length;
47
+ for (let i = 0; i < texts.length; i += batchSize) {
48
+ const batch = texts.slice(i, i + batchSize);
49
+ // Process batch in parallel
50
+ const batchEmbeddings = await Promise.all(batch.map((text) => this.embed(text)));
51
+ embeddings.push(...batchEmbeddings);
52
+ // Report progress
53
+ const current = Math.min(i + batchSize, total);
54
+ if (onProgress) {
55
+ onProgress(current, total);
56
+ }
57
+ else {
58
+ console.log(`Embedded ${current}/${total} chunks`);
59
+ }
60
+ }
61
+ return embeddings;
62
+ }
63
+ /**
64
+ * Get embedding dimension
65
+ */
66
+ getDimension() {
67
+ return this.dimension;
68
+ }
69
+ /**
70
+ * Get model name
71
+ */
72
+ getModelName() {
73
+ return this.modelName;
74
+ }
75
+ }
@@ -0,0 +1,5 @@
1
+ import { BuildOptions } from '../types/config.js';
2
+ /**
3
+ * Main build orchestrator
4
+ */
5
+ export declare function build(options: BuildOptions): Promise<void>;
@@ -0,0 +1,142 @@
1
+ import { defaultConfig } from '../types/config.js';
2
+ import { SiteGenerator } from './site-generator.js';
3
+ import { VectorDBBuilder } from './vector-db-builder.js';
4
+ import { existsSync, readFileSync, writeFileSync, copyFileSync } from 'fs';
5
+ import fs from 'fs-extra';
6
+ import { join, dirname, resolve } from 'path';
7
+ import { fileURLToPath } from 'url';
8
+ import { exec } from 'child_process';
9
+ import { promisify } from 'util';
10
+ const execAsync = promisify(exec);
11
+ const __filename = fileURLToPath(import.meta.url);
12
+ const __dirname = dirname(__filename);
13
+ /**
14
+ * Main build orchestrator
15
+ */
16
+ export async function build(options) {
17
+ const { inputDir, outputDir, chatEnabled, configPath, verbose } = options;
18
+ // Validate input directory
19
+ if (!existsSync(inputDir)) {
20
+ throw new Error(`Input directory does not exist: ${inputDir}`);
21
+ }
22
+ // Load config
23
+ const config = loadConfig(configPath, inputDir);
24
+ // Override chat enabled setting if specified in CLI
25
+ if (chatEnabled !== undefined) {
26
+ config.chat = config.chat || {};
27
+ config.chat.enabled = chatEnabled;
28
+ }
29
+ if (verbose) {
30
+ console.log('Configuration:', JSON.stringify(config, null, 2));
31
+ }
32
+ // Prepare output directory
33
+ fs.ensureDirSync(outputDir);
34
+ if (verbose) {
35
+ console.log(`Cleaning output directory: ${outputDir}`);
36
+ }
37
+ // Create site generator
38
+ const generator = new SiteGenerator();
39
+ // Generate site
40
+ const documents = await generator.generate(inputDir, outputDir, config);
41
+ // Create assets directories
42
+ const assetsDir = join(outputDir, 'assets');
43
+ fs.ensureDirSync(join(assetsDir, 'css'));
44
+ fs.ensureDirSync(join(assetsDir, 'js'));
45
+ if (verbose) {
46
+ console.log(`Processed ${documents.length} documents`);
47
+ }
48
+ // Phase 2: Generate vector database (if chat enabled)
49
+ if (config.chat?.enabled) {
50
+ const vectorDBBuilder = new VectorDBBuilder({
51
+ chunkSize: config.build?.chunkSize,
52
+ chunkOverlap: config.build?.chunkOverlap,
53
+ });
54
+ await vectorDBBuilder.build(documents, outputDir, verbose);
55
+ }
56
+ // Phase 3: Build client-side code with Vite
57
+ console.log('Building client-side code...');
58
+ // From dist/src/builder, go up 3 levels to project root
59
+ const projectRoot = resolve(__dirname, '../../..');
60
+ try {
61
+ // Skip rebuild if already built
62
+ const distClientDir = join(projectRoot, 'dist-client');
63
+ const bundleExists = existsSync(join(distClientDir, 'bundle.js'));
64
+ if (!bundleExists) {
65
+ await execAsync('npm run build:client', { cwd: projectRoot });
66
+ }
67
+ // Copy bundled JS files
68
+ if (existsSync(distClientDir)) {
69
+ const jsOutputDir = join(assetsDir, 'js');
70
+ if (verbose) {
71
+ console.log(`Copying client code from: ${distClientDir}`);
72
+ console.log(`Copying to: ${jsOutputDir}`);
73
+ }
74
+ // Copy bundle.js
75
+ const sourceBundlePath = join(distClientDir, 'bundle.js');
76
+ if (existsSync(sourceBundlePath)) {
77
+ copyFileSync(sourceBundlePath, join(jsOutputDir, 'bundle.js'));
78
+ }
79
+ // Copy bundle.js.map if exists
80
+ const sourceMapPath = join(distClientDir, 'bundle.js.map');
81
+ if (existsSync(sourceMapPath)) {
82
+ copyFileSync(sourceMapPath, join(jsOutputDir, 'bundle.js.map'));
83
+ }
84
+ // Copy assets folder (for code-split chunks)
85
+ const distAssetsDir = join(distClientDir, 'assets');
86
+ if (existsSync(distAssetsDir)) {
87
+ const outputAssetsDir = join(jsOutputDir, 'assets');
88
+ fs.copySync(distAssetsDir, outputAssetsDir);
89
+ if (verbose) {
90
+ console.log('Copied code-split chunks');
91
+ }
92
+ }
93
+ if (verbose) {
94
+ console.log('Client code copy complete');
95
+ }
96
+ }
97
+ else if (verbose) {
98
+ console.log(`dist-client directory not found: ${distClientDir}`);
99
+ }
100
+ }
101
+ catch (error) {
102
+ console.warn('Client build skipped (run npm run build:client manually)');
103
+ if (verbose) {
104
+ console.error(error);
105
+ }
106
+ }
107
+ // Phase 4: Copy styles
108
+ console.log('Copying styles...');
109
+ // From dist/src/builder, go to project root, then to src/styles
110
+ const stylesDir = resolve(__dirname, '../../../src/styles');
111
+ const outputCssDir = join(assetsDir, 'css');
112
+ // Combine all CSS files into one bundle
113
+ const cssFiles = ['main.css', 'themes.css', 'chat.css'];
114
+ let bundledCss = '';
115
+ for (const cssFile of cssFiles) {
116
+ const cssPath = join(stylesDir, cssFile);
117
+ if (existsSync(cssPath)) {
118
+ bundledCss += readFileSync(cssPath, 'utf-8') + '\n\n';
119
+ }
120
+ }
121
+ writeFileSync(join(outputCssDir, 'bundle.css'), bundledCss, 'utf-8');
122
+ if (verbose) {
123
+ console.log('Styles copied');
124
+ }
125
+ console.log('Site generation complete!');
126
+ }
127
+ /**
128
+ * Load configuration from file or use defaults
129
+ */
130
+ function loadConfig(configPath, inputDir) {
131
+ // Try explicit config path
132
+ if (configPath && existsSync(configPath)) {
133
+ return JSON.parse(readFileSync(configPath, 'utf-8'));
134
+ }
135
+ // Try default config in input directory
136
+ const defaultConfigPath = join(inputDir, 'botdocs.config.json');
137
+ if (existsSync(defaultConfigPath)) {
138
+ return JSON.parse(readFileSync(defaultConfigPath, 'utf-8'));
139
+ }
140
+ // Use defaults
141
+ return { ...defaultConfig };
142
+ }
@@ -0,0 +1,26 @@
1
+ import { ProcessedDocument } from '../types/document.js';
2
+ export declare class MarkdownProcessor {
3
+ private md;
4
+ private shikiInitialized;
5
+ constructor();
6
+ private escapeHtml;
7
+ private setupShiki;
8
+ /**
9
+ * Process a markdown file and extract front matter
10
+ */
11
+ processFile(filePath: string, inputDir: string, content: string): Promise<ProcessedDocument>;
12
+ /**
13
+ * Extract title from markdown content (first h1) or filename
14
+ */
15
+ private extractTitle;
16
+ /**
17
+ * Generate URL from relative file path
18
+ * e.g., "getting-started.md" -> "/getting-started.html"
19
+ * e.g., "api/overview.md" -> "/api/overview.html"
20
+ */
21
+ private generateUrl;
22
+ /**
23
+ * Render markdown string to HTML
24
+ */
25
+ render(markdown: string): string;
26
+ }