botdocs 0.3.0 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (44) hide show
  1. package/README.md +20 -2
  2. package/dist/src/builder/chunker.d.ts +18 -0
  3. package/dist/src/builder/chunker.js +62 -1
  4. package/dist/src/builder/embedder.js +5 -2
  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 +34 -3
  8. package/dist/src/builder/markdown-processor.d.ts +8 -1
  9. package/dist/src/builder/markdown-processor.js +40 -7
  10. package/dist/src/builder/paths.d.ts +6 -0
  11. package/dist/src/builder/paths.js +13 -0
  12. package/dist/src/builder/site-generator.d.ts +19 -2
  13. package/dist/src/builder/site-generator.js +117 -34
  14. package/dist/src/builder/template-engine.d.ts +4 -4
  15. package/dist/src/builder/template-engine.js +5 -1
  16. package/dist/src/builder/vector-db-builder.d.ts +1 -0
  17. package/dist/src/builder/vector-db-builder.js +1 -0
  18. package/dist/src/cli/index.js +61 -18
  19. package/dist/src/cli/options.d.ts +2 -0
  20. package/dist/src/cli/options.js +2 -1
  21. package/dist/src/cli/server.d.ts +6 -0
  22. package/dist/src/cli/server.js +79 -0
  23. package/dist/src/cli/watcher.d.ts +7 -0
  24. package/dist/src/cli/watcher.js +40 -0
  25. package/dist/src/shared/site-root.d.ts +2 -0
  26. package/dist/src/shared/site-root.js +9 -0
  27. package/dist/src/types/config.d.ts +4 -0
  28. package/dist/src/types/config.js +2 -0
  29. package/dist/src/types/document.d.ts +1 -1
  30. package/dist-client/assets/chatbox-Dw_HFrfR.js +8 -0
  31. package/dist-client/assets/rag-engine-B9wYRzqT.js +1 -0
  32. package/dist-client/bundle.js +1 -1
  33. package/dist-client/wasm/ort-wasm-simd-threaded.asyncify.wasm +0 -0
  34. package/man/botdocs.1 +193 -0
  35. package/package.json +18 -2
  36. package/src/styles/chat.css +144 -0
  37. package/src/styles/themes/classic.css +29 -0
  38. package/src/styles/themes/material.css +29 -0
  39. package/src/styles/themes/minimal.css +34 -0
  40. package/src/styles/themes/modern.css +34 -0
  41. package/src/styles/themes/slate.css +32 -36
  42. package/src/templates/layout.html +21 -2
  43. package/dist-client/assets/chatbox-CmTTiB2Z.js +0 -1
  44. package/dist-client/assets/rag-engine-L2vj3Y0G.js +0 -7
package/README.md CHANGED
@@ -1,5 +1,7 @@
1
1
  # Botdocs
2
2
 
3
+ [![Read about the commits](https://img.shields.io/badge/commits-code%20blog-1a1a1a?style=flat-square)](https://wwel.sh/digest.html?repo=botdocs)
4
+
3
5
  [![npm version](https://img.shields.io/npm/v/botdocs.svg)](https://www.npmjs.com/package/botdocs)
4
6
 
5
7
  Convert markdown documentation into beautiful static sites with AI-powered semantic search — no backend required.
@@ -12,6 +14,8 @@ Convert markdown documentation into beautiful static sites with AI-powered seman
12
14
  - **Deep Links** - Search results link directly to sections
13
15
  - **No Backend** - Everything runs in the browser
14
16
  - **Fast** - Syntax highlighting with Shiki
17
+ - **Live Preview** - `--watch` rebuilds on save and serves the site locally
18
+ - **SEO** - Optional Open Graph/Twitter tags and `sitemap.xml` via `baseUrl`
15
19
 
16
20
  ## Installation
17
21
 
@@ -44,6 +48,9 @@ botdocs ./docs -c ./my-config.json
44
48
 
45
49
  # Combine multiple options
46
50
  botdocs ./docs -o ./public -t slate -v
51
+
52
+ # Live preview: rebuild on save + local server
53
+ botdocs ./docs --watch
47
54
  ```
48
55
 
49
56
  ### CLI Options
@@ -55,6 +62,8 @@ botdocs ./docs -o ./public -t slate -v
55
62
  | `--config <file>` | `-c` | Path to config file | `botdocs.config.json` |
56
63
  | `--theme <theme>` | `-t` | Theme to use | `classic` |
57
64
  | `--verbose` | `-v` | Enable verbose logging | `false` |
65
+ | `--watch` | `-w` | Rebuild on changes and serve the site for live preview | `false` |
66
+ | `--port <number>` | `-p` | Port for the preview server (with `--watch`) | `3000` |
58
67
 
59
68
  ### Available Themes
60
69
 
@@ -73,12 +82,16 @@ Create `botdocs.config.json` in your docs directory:
73
82
  "title": "My Documentation",
74
83
  "description": "Project docs",
75
84
  "theme": "classic",
85
+ "customCss": "custom.css",
76
86
  "attribution": true,
87
+ "baseUrl": "https://example.com/docs/",
77
88
  "chat": { "enabled": true },
78
89
  "build": {
79
90
  "chunkSize": 500,
80
91
  "chunkOverlap": 50,
81
- "topK": 3
92
+ "minChunkSize": 15,
93
+ "topK": 3,
94
+ "minScore": 0.75
82
95
  }
83
96
  }
84
97
  ```
@@ -90,12 +103,16 @@ Create `botdocs.config.json` in your docs directory:
90
103
  | `title` | string | `"Documentation"` | Site title |
91
104
  | `description` | string | `"Project documentation"` | Site description |
92
105
  | `theme` | string | `"classic"` | Theme to use (classic, material, minimal, slate, modern) |
106
+ | `customCss` | string | none | Path to a CSS file, resolved relative to the config file's directory. Appended after theme CSS in `bundle.css`, so same-specificity selectors override the theme without `!important` |
93
107
  | `attribution` | boolean | `true` | Show "Built with Botdocs" footer link |
108
+ | `baseUrl` | string | none | Canonical URL where the site is hosted. When set, pages get `rel=canonical` and Open Graph/Twitter card tags, and a `sitemap.xml` is generated |
94
109
  | `chat.enabled` | boolean | `true` | Enable AI chatbot |
95
110
  | `chat.welcomeMessage` | string | `"Ask me anything about the docs!"` | Chatbot welcome message |
96
111
  | `build.chunkSize` | number | `500` | Text chunk size for embeddings |
97
112
  | `build.chunkOverlap` | number | `50` | Overlap between chunks |
113
+ | `build.minChunkSize` | number | `15` | Chunks smaller than this (estimated tokens) get folded into a neighboring chunk instead of becoming a standalone, low-signal search result |
98
114
  | `build.topK` | number | `3` | Number of results to return |
115
+ | `build.minScore` | number | `0.75` | Minimum vector similarity (0-1) a result must reach to be returned at all, regardless of `topK` — filters out weak/off-topic matches instead of always padding results. The e5 embedding model has a fairly high similarity floor even for unrelated text, so this needs to sit well above 0.5 to actually gate anything |
99
116
 
100
117
  ## Front Matter
101
118
 
@@ -113,11 +130,12 @@ description: Quick start guide
113
130
  1. **Build**: Parses markdown → generates embeddings → creates `vector-db.json`
114
131
  2. **Runtime**: User query → embed → search vector DB → return relevant chunks
115
132
  3. **No LLM**: Pure semantic search, not AI text generation
133
+ 4. **Consent**: On first use, visitors are asked before the embedding model downloads to their browser, with a disclosure of what runs locally
116
134
 
117
135
  ## Architecture
118
136
 
119
137
  - **Embedding Model**: `e5-small-v2` (384-dim vectors, 2.2x faster than all-MiniLM-L6-v2)
120
- - **Search**: Cosine similarity, client-side only
138
+ - **Search**: Hybrid — vector cosine similarity fused with BM25 keyword scoring (Reciprocal Rank Fusion), gated by a minimum similarity threshold, client-side only
121
139
  - **Browser Bundle**: ~825KB (includes Transformers.js)
122
140
  - **Deployment**: Fully static, works on any host
123
141
 
@@ -7,6 +7,13 @@ export interface TextChunk {
7
7
  export interface ChunkerOptions {
8
8
  maxChunkSize: number;
9
9
  chunkOverlap: number;
10
+ /**
11
+ * Chunks smaller than this (in estimated tokens) are folded into a
12
+ * neighboring chunk instead of being kept as standalone, low-information
13
+ * entries in the vector DB (e.g. a heading followed by a single short
14
+ * line). Set to 0 to disable merging.
15
+ */
16
+ minChunkSize: number;
10
17
  }
11
18
  /**
12
19
  * Text chunker that splits documents by headings while respecting token limits
@@ -18,6 +25,17 @@ export declare class Chunker {
18
25
  * Chunk a document into semantically meaningful pieces
19
26
  */
20
27
  chunkDocument(doc: ProcessedDocument, fileHash?: string): TextChunk[];
28
+ /**
29
+ * Fold chunks smaller than minChunkSize into a neighbor so a heading with
30
+ * little or no body content doesn't become its own low-signal retrieval
31
+ * candidate.
32
+ */
33
+ private mergeSmallChunks;
34
+ /**
35
+ * True for a line that is entirely markdown badges/images/links with no
36
+ * other prose (a bare link line, a shields.io badge, or a chain of both).
37
+ */
38
+ private isBoilerplateLine;
21
39
  /**
22
40
  * Create a chunk with metadata
23
41
  */
@@ -1,3 +1,6 @@
1
+ // Matches a markdown image, a badge (image wrapped in a link), or a plain
2
+ // link, e.g. `![alt](url)`, `[![alt](url)](url)`, `[text](url)`.
3
+ const LINK_OR_IMAGE = /\[!\[[^\]]*\]\([^)]*\)\]\([^)]*\)|!?\[[^\]]*\]\([^)]*\)/g;
1
4
  /**
2
5
  * Text chunker that splits documents by headings while respecting token limits
3
6
  */
@@ -7,6 +10,7 @@ export class Chunker {
7
10
  this.options = {
8
11
  maxChunkSize: options.maxChunkSize || 500,
9
12
  chunkOverlap: options.chunkOverlap || 50,
13
+ minChunkSize: options.minChunkSize ?? 15,
10
14
  };
11
15
  }
12
16
  /**
@@ -54,6 +58,13 @@ export class Chunker {
54
58
  currentChunk = [line];
55
59
  }
56
60
  else {
61
+ // Badges and bare reference links carry no retrievable prose, but
62
+ // their markup is character-heavy enough to dodge minChunkSize and
63
+ // their repeated project-name alt-text then dominates BM25 matches
64
+ // on any query mentioning that name. Drop them before they're ever
65
+ // embedded or indexed rather than filtering by size after the fact.
66
+ if (this.isBoilerplateLine(line))
67
+ continue;
57
68
  currentChunk.push(line);
58
69
  // Check if chunk is getting too large
59
70
  const tokenCount = this.estimateTokens(currentChunk.join('\n'));
@@ -71,7 +82,57 @@ export class Chunker {
71
82
  if (currentChunk.length > 0) {
72
83
  chunks.push(this.createChunk(currentChunk.join('\n'), doc, currentHeading, fileHash));
73
84
  }
74
- return chunks.filter((chunk) => chunk.text.trim().length > 0);
85
+ const nonEmptyChunks = chunks.filter((chunk) => chunk.text.trim().length > 0);
86
+ return this.mergeSmallChunks(nonEmptyChunks);
87
+ }
88
+ /**
89
+ * Fold chunks smaller than minChunkSize into a neighbor so a heading with
90
+ * little or no body content doesn't become its own low-signal retrieval
91
+ * candidate.
92
+ */
93
+ mergeSmallChunks(chunks) {
94
+ const minTokens = this.options.minChunkSize;
95
+ if (minTokens <= 0 || chunks.length <= 1)
96
+ return chunks;
97
+ const merged = [];
98
+ for (const chunk of chunks) {
99
+ const prev = merged[merged.length - 1];
100
+ if (prev && this.estimateTokens(prev.text) < minTokens) {
101
+ // Absorb forward: the small chunk becomes the lead-in for the next
102
+ // section, which takes over as the chunk's heading/metadata.
103
+ merged[merged.length - 1] = {
104
+ text: `${prev.text}\n\n${chunk.text}`,
105
+ metadata: chunk.metadata,
106
+ };
107
+ }
108
+ else {
109
+ merged.push(chunk);
110
+ }
111
+ }
112
+ // A trailing chunk with nothing after it to absorb into gets folded
113
+ // backward instead, keeping the earlier (more substantial) heading.
114
+ if (merged.length > 1) {
115
+ const last = merged[merged.length - 1];
116
+ if (this.estimateTokens(last.text) < minTokens) {
117
+ const prev = merged[merged.length - 2];
118
+ merged[merged.length - 2] = {
119
+ text: `${prev.text}\n\n${last.text}`,
120
+ metadata: prev.metadata,
121
+ };
122
+ merged.pop();
123
+ }
124
+ }
125
+ return merged;
126
+ }
127
+ /**
128
+ * True for a line that is entirely markdown badges/images/links with no
129
+ * other prose (a bare link line, a shields.io badge, or a chain of both).
130
+ */
131
+ isBoilerplateLine(line) {
132
+ const trimmed = line.trim();
133
+ if (!trimmed)
134
+ return false;
135
+ return trimmed.replace(LINK_OR_IMAGE, '').trim().length === 0;
75
136
  }
76
137
  /**
77
138
  * Create a chunk with metadata
@@ -1,8 +1,8 @@
1
- import { pipeline, env } from '@xenova/transformers';
1
+ 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);
@@ -20,6 +23,11 @@ export async function build(options) {
20
23
  }
21
24
  // Load config
22
25
  const config = loadConfig(configPath, inputDir);
26
+ // customCss resolves relative to wherever the config file actually lives,
27
+ // not inputDir — inputDir is often a regenerated staging directory.
28
+ const configDir = configPath && existsSync(configPath)
29
+ ? dirname(resolve(configPath))
30
+ : inputDir;
23
31
  // Override chat enabled setting if specified in CLI
24
32
  if (chatEnabled !== undefined) {
25
33
  config.chat = config.chat || {};
@@ -53,8 +61,19 @@ export async function build(options) {
53
61
  const vectorDBBuilder = new VectorDBBuilder({
54
62
  chunkSize: config.build?.chunkSize,
55
63
  chunkOverlap: config.build?.chunkOverlap,
64
+ minChunkSize: config.build?.minChunkSize,
56
65
  });
57
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
+ }
58
77
  }
59
78
  // Phase 3: Build client-side code with Vite
60
79
  console.log('Building client-side code...');
@@ -109,8 +128,7 @@ export async function build(options) {
109
128
  }
110
129
  // Phase 4: Copy styles
111
130
  console.log('Copying styles...');
112
- // From dist/src/builder, go to project root, then to src/styles
113
- const stylesDir = resolve(__dirname, '../../../src/styles');
131
+ const stylesDir = underSrc(__dirname, 'styles');
114
132
  const themesDir = join(stylesDir, 'themes');
115
133
  const outputCssDir = join(assetsDir, 'css');
116
134
  // Determine which theme to use
@@ -139,6 +157,19 @@ export async function build(options) {
139
157
  bundledCss += readFileSync(fallbackPath, 'utf-8') + '\n\n';
140
158
  }
141
159
  }
160
+ // Append user-supplied custom CSS last so it overrides theme rules of equal specificity
161
+ if (config.customCss) {
162
+ const customCssPath = resolve(configDir, config.customCss);
163
+ if (existsSync(customCssPath)) {
164
+ bundledCss += readFileSync(customCssPath, 'utf-8') + '\n';
165
+ if (verbose) {
166
+ console.log(`Appended custom CSS: ${customCssPath}`);
167
+ }
168
+ }
169
+ else {
170
+ console.warn(`customCss file not found: ${customCssPath}`);
171
+ }
172
+ }
142
173
  writeFileSync(join(outputCssDir, 'bundle.css'), bundledCss, 'utf-8');
143
174
  if (verbose) {
144
175
  console.log('Styles copied');
@@ -4,13 +4,20 @@ export declare class MarkdownProcessor {
4
4
  private shikiInitialized;
5
5
  constructor();
6
6
  private escapeHtml;
7
+ private decodeHtmlEntities;
7
8
  private setupShiki;
8
9
  /**
9
10
  * Process a markdown file and extract front matter
10
11
  */
11
12
  processFile(filePath: string, inputDir: string, content: string): Promise<ProcessedDocument>;
12
13
  /**
13
- * Extract title from markdown content (first h1) or filename
14
+ * Extract title from markdown content (first h1) or filename.
15
+ *
16
+ * Checks, in order: a markdown `# ` heading, an HTML `<h1>` tag (common
17
+ * when the heading wraps a logo image), and a lone banner image's alt
18
+ * text (READMEs that open with `![Project Name](banner.svg)` instead of
19
+ * a text heading). All matching skips fenced code blocks so shell
20
+ * comments like `# Start the server:` aren't mistaken for headings.
14
21
  */
15
22
  private extractTitle;
16
23
  /**
@@ -9,7 +9,7 @@ import sub from 'markdown-it-sub';
9
9
  import sup from 'markdown-it-sup';
10
10
  import { bundledLanguages, getHighlighter } from 'shiki';
11
11
  import matter from 'gray-matter';
12
- import { relative, basename } from 'path';
12
+ import { relative, basename, dirname } from 'path';
13
13
  export class MarkdownProcessor {
14
14
  md;
15
15
  shikiInitialized = false;
@@ -65,6 +65,15 @@ export class MarkdownProcessor {
65
65
  .replace(/"/g, '&quot;')
66
66
  .replace(/'/g, '&#039;');
67
67
  }
68
+ decodeHtmlEntities(text) {
69
+ return text
70
+ .replace(/&nbsp;/g, ' ')
71
+ .replace(/&amp;/g, '&')
72
+ .replace(/&lt;/g, '<')
73
+ .replace(/&gt;/g, '>')
74
+ .replace(/&quot;/g, '"')
75
+ .replace(/&#0?39;/g, "'");
76
+ }
68
77
  async setupShiki() {
69
78
  if (this.shikiInitialized)
70
79
  return;
@@ -129,16 +138,40 @@ export class MarkdownProcessor {
129
138
  };
130
139
  }
131
140
  /**
132
- * Extract title from markdown content (first h1) or filename
141
+ * Extract title from markdown content (first h1) or filename.
142
+ *
143
+ * Checks, in order: a markdown `# ` heading, an HTML `<h1>` tag (common
144
+ * when the heading wraps a logo image), and a lone banner image's alt
145
+ * text (READMEs that open with `![Project Name](banner.svg)` instead of
146
+ * a text heading). All matching skips fenced code blocks so shell
147
+ * comments like `# Start the server:` aren't mistaken for headings.
133
148
  */
134
149
  extractTitle(content, relativePath) {
135
- const h1Match = content.match(/^#\s+(.+)$/m);
150
+ const withoutCodeFences = content.replace(/^```[\s\S]*?^```/gm, '');
151
+ const h1Match = withoutCodeFences.match(/^#\s+(.+)$/m);
136
152
  if (h1Match) {
137
- return h1Match[1];
153
+ return h1Match[1].trim();
154
+ }
155
+ const htmlH1Match = withoutCodeFences.match(/<h1[^>]*>([\s\S]*?)<\/h1>/i);
156
+ if (htmlH1Match) {
157
+ const text = this.decodeHtmlEntities(htmlH1Match[1].replace(/<[^>]+>/g, ''))
158
+ .replace(/\s+/g, ' ')
159
+ .trim();
160
+ if (text) {
161
+ return text;
162
+ }
163
+ }
164
+ const bannerImageMatch = withoutCodeFences.match(/^!\[([^\]]+)\]\([^)]*\)\s*$/m);
165
+ if (bannerImageMatch) {
166
+ return bannerImageMatch[1].trim();
138
167
  }
139
- // Fallback to filename
140
- return basename(relativePath, '.md')
141
- .replace(/-/g, ' ')
168
+ // Fallback to filename, or the parent directory name for README/index
169
+ // files where the filename itself carries no useful title.
170
+ const base = basename(relativePath, '.md');
171
+ const parentDir = basename(dirname(relativePath));
172
+ const name = /^(readme|index)$/i.test(base) && parentDir !== '.' ? parentDir : base;
173
+ return name
174
+ .replace(/[-_]/g, ' ')
142
175
  .replace(/\b\w/g, (char) => char.toUpperCase());
143
176
  }
144
177
  /**
@@ -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,14 +10,30 @@ export declare class SiteGenerator {
9
10
  * Generate the complete site
10
11
  */
11
12
  generate(inputDir: string, outputDir: string, config: BotdocsConfig): Promise<ProcessedDocument[]>;
13
+ private writeSitemap;
12
14
  /**
13
- * Build navigation structure from documents
15
+ * Build navigation structure from documents, grouping by top-level
16
+ * folder so e.g. every doc under `path-of-python/` (its README plus
17
+ * anything in `path-of-python/docs/`) nests under one "Path of Python"
18
+ * entry instead of interleaving flat with every other folder's pages.
19
+ * Files at the root (no folder) stay flat, top-level entries.
14
20
  */
15
21
  private buildNavigation;
16
22
  /**
17
- * Render navigation HTML
23
+ * Render navigation HTML. Folder groups only expand their children when
24
+ * the current page is the group's root or one of its children — every
25
+ * other page sees the group collapsed to a single link that leads to
26
+ * its root README.
18
27
  */
19
28
  private renderNavigation;
29
+ /**
30
+ * Compute prev/next page links that walk the top-level navigation
31
+ * (root README to root README) rather than the flat, alphabetical
32
+ * document list — so paging from a folder's root never dips into that
33
+ * folder's children. Paging from within a group's children walks those
34
+ * siblings first, then rolls into the next top-level entry.
35
+ */
36
+ private buildPageSequence;
20
37
  /**
21
38
  * Get all processed documents
22
39
  */