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.
- package/README.md +21 -2
- package/dist/src/builder/chunker.d.ts +18 -0
- package/dist/src/builder/chunker.js +62 -1
- package/dist/src/builder/embedder.js +4 -1
- package/dist/src/builder/index-size.d.ts +3 -0
- package/dist/src/builder/index-size.js +16 -0
- package/dist/src/builder/index.js +16 -3
- package/dist/src/builder/paths.d.ts +6 -0
- package/dist/src/builder/paths.js +13 -0
- package/dist/src/builder/site-generator.d.ts +4 -0
- package/dist/src/builder/site-generator.js +63 -11
- package/dist/src/builder/template-engine.d.ts +4 -4
- package/dist/src/builder/template-engine.js +5 -1
- package/dist/src/builder/vector-db-builder.d.ts +1 -0
- package/dist/src/builder/vector-db-builder.js +1 -0
- package/dist/src/cli/index.js +60 -17
- package/dist/src/cli/options.d.ts +2 -0
- package/dist/src/cli/options.js +2 -0
- package/dist/src/cli/server.d.ts +6 -0
- package/dist/src/cli/server.js +79 -0
- package/dist/src/cli/watcher.d.ts +7 -0
- package/dist/src/cli/watcher.js +40 -0
- package/dist/src/shared/site-root.d.ts +2 -0
- package/dist/src/shared/site-root.js +9 -0
- package/dist/src/types/config.d.ts +3 -0
- package/dist/src/types/config.js +2 -0
- package/dist/src/types/document.d.ts +1 -1
- package/dist-client/assets/chatbox-Dw_HFrfR.js +8 -0
- package/dist-client/assets/rag-engine-B9wYRzqT.js +1 -0
- package/dist-client/bundle.js +1 -1
- package/man/botdocs.1 +84 -3
- package/package.json +10 -1
- package/src/styles/chat.css +13 -0
- package/src/templates/layout.html +14 -2
- package/dist-client/assets/chatbox-P1j6YP1y.js +0 -7
- package/dist-client/assets/rag-engine-CU9vjCAg.js +0 -1
package/README.md
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
# Botdocs
|
|
2
2
|
|
|
3
|
+
[](https://wwel.sh/digest.html?repo=botdocs)
|
|
4
|
+
|
|
3
5
|
[](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
|
-
"
|
|
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**:
|
|
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. ``, `[](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
|
-
|
|
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,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 {
|
|
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
|
-
|
|
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,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,
|
|
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
|
|
50
|
-
//
|
|
51
|
-
const templatesDir =
|
|
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
|
-
|
|
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,
|
|
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,
|
|
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,
|
|
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,
|
|
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
|
|
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
|
package/dist/src/cli/index.js
CHANGED
|
@@ -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 {
|
|
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
|
-
//
|
|
11
|
-
|
|
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
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
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
|
|
47
|
+
verbose,
|
|
40
48
|
theme: options.theme,
|
|
41
49
|
});
|
|
42
|
-
|
|
43
|
-
|
|
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);
|