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.
- package/README.md +20 -2
- package/dist/src/builder/chunker.d.ts +18 -0
- package/dist/src/builder/chunker.js +62 -1
- package/dist/src/builder/embedder.js +5 -2
- 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 +34 -3
- package/dist/src/builder/markdown-processor.d.ts +8 -1
- package/dist/src/builder/markdown-processor.js +40 -7
- 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 +19 -2
- package/dist/src/builder/site-generator.js +117 -34
- 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 +61 -18
- package/dist/src/cli/options.d.ts +2 -0
- package/dist/src/cli/options.js +2 -1
- 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 +4 -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/dist-client/wasm/ort-wasm-simd-threaded.asyncify.wasm +0 -0
- package/man/botdocs.1 +193 -0
- package/package.json +18 -2
- package/src/styles/chat.css +144 -0
- package/src/styles/themes/classic.css +29 -0
- package/src/styles/themes/material.css +29 -0
- package/src/styles/themes/minimal.css +34 -0
- package/src/styles/themes/modern.css +34 -0
- package/src/styles/themes/slate.css +32 -36
- package/src/templates/layout.html +21 -2
- package/dist-client/assets/chatbox-CmTTiB2Z.js +0 -1
- package/dist-client/assets/rag-engine-L2vj3Y0G.js +0 -7
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,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
|
-
"
|
|
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**:
|
|
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. ``, `[](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
|
|
@@ -1,8 +1,8 @@
|
|
|
1
|
-
import { pipeline, env } from '@
|
|
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,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);
|
|
@@ -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
|
-
|
|
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 `` 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, '"')
|
|
66
66
|
.replace(/'/g, ''');
|
|
67
67
|
}
|
|
68
|
+
decodeHtmlEntities(text) {
|
|
69
|
+
return text
|
|
70
|
+
.replace(/ /g, ' ')
|
|
71
|
+
.replace(/&/g, '&')
|
|
72
|
+
.replace(/</g, '<')
|
|
73
|
+
.replace(/>/g, '>')
|
|
74
|
+
.replace(/"/g, '"')
|
|
75
|
+
.replace(/�?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 `` 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
|
|
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
|
-
|
|
141
|
-
|
|
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,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
|
*/
|