@enokdev/springdocs-mcp 1.2.7 → 1.4.1
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 +108 -40
- package/build/config.d.ts +9 -0
- package/build/config.d.ts.map +1 -0
- package/build/config.js +32 -0
- package/build/config.js.map +1 -0
- package/build/format.d.ts +6 -0
- package/build/format.d.ts.map +1 -0
- package/build/format.js +23 -0
- package/build/format.js.map +1 -0
- package/build/http-server.d.ts +15 -0
- package/build/http-server.d.ts.map +1 -0
- package/build/http-server.js +131 -0
- package/build/http-server.js.map +1 -0
- package/build/index.js +184 -69
- package/build/index.js.map +1 -1
- package/build/prompts.d.ts +23 -0
- package/build/prompts.d.ts.map +1 -0
- package/build/prompts.js +112 -0
- package/build/prompts.js.map +1 -0
- package/build/resources.d.ts +31 -0
- package/build/resources.d.ts.map +1 -0
- package/build/resources.js +66 -0
- package/build/resources.js.map +1 -0
- package/build/services/advanced-features.d.ts +19 -9
- package/build/services/advanced-features.d.ts.map +1 -1
- package/build/services/advanced-features.js +266 -191
- package/build/services/advanced-features.js.map +1 -1
- package/build/services/boot-wiki.d.ts +25 -0
- package/build/services/boot-wiki.d.ts.map +1 -0
- package/build/services/boot-wiki.js +103 -0
- package/build/services/boot-wiki.js.map +1 -0
- package/build/services/cache.d.ts +18 -3
- package/build/services/cache.d.ts.map +1 -1
- package/build/services/cache.js +84 -3
- package/build/services/cache.js.map +1 -1
- package/build/services/dependency-finder.d.ts +46 -0
- package/build/services/dependency-finder.d.ts.map +1 -0
- package/build/services/dependency-finder.js +181 -0
- package/build/services/dependency-finder.js.map +1 -0
- package/build/services/diagnosis.d.ts +46 -0
- package/build/services/diagnosis.d.ts.map +1 -0
- package/build/services/diagnosis.js +481 -0
- package/build/services/diagnosis.js.map +1 -0
- package/build/services/guides-list.d.ts +16 -0
- package/build/services/guides-list.d.ts.map +1 -0
- package/build/services/guides-list.js +45 -0
- package/build/services/guides-list.js.map +1 -0
- package/build/services/http.d.ts +21 -0
- package/build/services/http.d.ts.map +1 -0
- package/build/services/http.js +168 -0
- package/build/services/http.js.map +1 -0
- package/build/services/initializr.d.ts +41 -0
- package/build/services/initializr.d.ts.map +1 -0
- package/build/services/initializr.js +135 -0
- package/build/services/initializr.js.map +1 -0
- package/build/services/markdown.d.ts +56 -0
- package/build/services/markdown.d.ts.map +1 -0
- package/build/services/markdown.js +146 -0
- package/build/services/markdown.js.map +1 -0
- package/build/services/migration-sources.d.ts +13 -0
- package/build/services/migration-sources.d.ts.map +1 -0
- package/build/services/migration-sources.js +48 -0
- package/build/services/migration-sources.js.map +1 -0
- package/build/services/release-notes.d.ts +23 -0
- package/build/services/release-notes.d.ts.map +1 -0
- package/build/services/release-notes.js +94 -0
- package/build/services/release-notes.js.map +1 -0
- package/build/services/search-index.d.ts +33 -0
- package/build/services/search-index.d.ts.map +1 -0
- package/build/services/search-index.js +147 -0
- package/build/services/search-index.js.map +1 -0
- package/build/services/spring-projects-config.d.ts +98 -0
- package/build/services/spring-projects-config.d.ts.map +1 -0
- package/build/services/spring-projects-config.js +397 -0
- package/build/services/spring-projects-config.js.map +1 -0
- package/build/services/springboot-docs-optimized.d.ts +72 -21
- package/build/services/springboot-docs-optimized.d.ts.map +1 -1
- package/build/services/springboot-docs-optimized.js +354 -208
- package/build/services/springboot-docs-optimized.js.map +1 -1
- package/build/services/url.d.ts +15 -0
- package/build/services/url.d.ts.map +1 -0
- package/build/services/url.js +40 -0
- package/build/services/url.js.map +1 -0
- package/build/tools/index.d.ts +393 -2
- package/build/tools/index.d.ts.map +1 -1
- package/build/tools/index.js +188 -15
- package/build/tools/index.js.map +1 -1
- package/build/validation.d.ts +7 -0
- package/build/validation.d.ts.map +1 -0
- package/build/validation.js +50 -0
- package/build/validation.js.map +1 -0
- package/build/version.d.ts +3 -0
- package/build/version.d.ts.map +1 -0
- package/build/version.js +6 -0
- package/build/version.js.map +1 -0
- package/package.json +9 -7
- package/build/debug.d.ts +0 -3
- package/build/debug.d.ts.map +0 -1
- package/build/debug.js +0 -292
- package/build/debug.js.map +0 -1
- package/build/services/advanced-features-old.d.ts +0 -45
- package/build/services/advanced-features-old.d.ts.map +0 -1
- package/build/services/advanced-features-old.js +0 -792
- package/build/services/advanced-features-old.js.map +0 -1
- package/build/services/springboot-docs-optimized-old.d.ts +0 -55
- package/build/services/springboot-docs-optimized-old.d.ts.map +0 -1
- package/build/services/springboot-docs-optimized-old.js +0 -588
- package/build/services/springboot-docs-optimized-old.js.map +0 -1
- package/build/services/springboot-docs.d.ts +0 -65
- package/build/services/springboot-docs.d.ts.map +0 -1
- package/build/services/springboot-docs.js +0 -708
- package/build/services/springboot-docs.js.map +0 -1
|
@@ -1,87 +1,46 @@
|
|
|
1
|
-
import fetch from 'node-fetch';
|
|
2
1
|
import * as cheerio from 'cheerio';
|
|
3
|
-
import TurndownService from 'turndown';
|
|
4
2
|
import { CacheService } from './cache.js';
|
|
3
|
+
import { fetchWithRetry } from './http.js';
|
|
4
|
+
import { turndownService, extractContent, pageMarkdown } from './markdown.js';
|
|
5
|
+
import { absoluteSpringUrl, assertSafeSegment, normalizeVersion } from './url.js';
|
|
6
|
+
import { fetchSpringGuidesList } from './guides-list.js';
|
|
7
|
+
import { resolveWikiDocument, wikiPageName, wikiPageUrl, expectedWikiTitle, extractWikiMarkdown, selectSections } from './boot-wiki.js';
|
|
8
|
+
import { springProjectsConfig } from './spring-projects-config.js';
|
|
9
|
+
import { SearchIndex } from './search-index.js';
|
|
10
|
+
import { assertRawMarkdown, isMigrationProject, projectDisplayName, rawWikiTitle, rawWikiUrl, resolveRawDocument } from './migration-sources.js';
|
|
11
|
+
/** Below this size an extracted guide is considered empty (a real guide is far longer; the smallest fixture is ~190). */
|
|
12
|
+
const MIN_GUIDE_CHARS = 100;
|
|
5
13
|
/**
|
|
6
|
-
*
|
|
7
|
-
*
|
|
14
|
+
* Spring Documentation Service - Supports multiple Spring projects (Boot, AI, Framework, etc.)
|
|
15
|
+
* Uses ONLY real Spring documentation APIs - no mock data
|
|
16
|
+
*
|
|
17
|
+
* Architecture: Configuration-driven multi-project support via SpringProjectsConfig
|
|
8
18
|
*/
|
|
9
19
|
export class SpringBootDocsServiceOptimized {
|
|
10
20
|
baseUrl = 'https://docs.spring.io';
|
|
11
21
|
springProjectsUrl = 'https://spring.io/projects';
|
|
12
22
|
springGuideUrl = 'https://spring.io/guides';
|
|
13
|
-
|
|
14
|
-
turndownService;
|
|
23
|
+
projectsConfig;
|
|
15
24
|
cache;
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
this.
|
|
20
|
-
|
|
21
|
-
codeBlockStyle: 'fenced',
|
|
22
|
-
});
|
|
23
|
-
this.cache = new CacheService();
|
|
24
|
-
// Cleanup cache every hour
|
|
25
|
-
setInterval(() => this.cache.cleanup(), 60 * 60 * 1000);
|
|
25
|
+
searchIndex;
|
|
26
|
+
constructor(projectsConfig = springProjectsConfig, cache = new CacheService(), searchIndex = new SearchIndex()) {
|
|
27
|
+
this.projectsConfig = projectsConfig;
|
|
28
|
+
this.cache = cache;
|
|
29
|
+
this.searchIndex = searchIndex;
|
|
26
30
|
}
|
|
27
|
-
/**
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
extractIntelligentContent(markdown, detailLevel = 'medium') {
|
|
32
|
-
const limits = {
|
|
33
|
-
'summary': 1500,
|
|
34
|
-
'medium': 4000,
|
|
35
|
-
'full': 8000
|
|
36
|
-
};
|
|
37
|
-
const maxLength = limits[detailLevel] || limits['medium'];
|
|
38
|
-
// If content is smaller than limit, return as-is
|
|
39
|
-
if (markdown.length <= maxLength) {
|
|
40
|
-
return markdown;
|
|
41
|
-
}
|
|
42
|
-
// Extract important sections
|
|
43
|
-
const lines = markdown.split('\n');
|
|
44
|
-
let result = '';
|
|
45
|
-
let inCodeBlock = false;
|
|
46
|
-
let codeBlockContent = '';
|
|
47
|
-
let currentLength = 0;
|
|
48
|
-
for (const line of lines) {
|
|
49
|
-
// Track code blocks
|
|
50
|
-
if (line.trim().startsWith('```')) {
|
|
51
|
-
inCodeBlock = !inCodeBlock;
|
|
52
|
-
if (!inCodeBlock && codeBlockContent) {
|
|
53
|
-
// Keep complete code blocks
|
|
54
|
-
const blockToAdd = codeBlockContent + line + '\n';
|
|
55
|
-
if (currentLength + blockToAdd.length <= maxLength * 0.8) { // Reserve 20% for text
|
|
56
|
-
result += blockToAdd;
|
|
57
|
-
currentLength += blockToAdd.length;
|
|
58
|
-
}
|
|
59
|
-
codeBlockContent = '';
|
|
60
|
-
}
|
|
61
|
-
else {
|
|
62
|
-
codeBlockContent = line + '\n';
|
|
63
|
-
}
|
|
64
|
-
continue;
|
|
65
|
-
}
|
|
66
|
-
if (inCodeBlock) {
|
|
67
|
-
codeBlockContent += line + '\n';
|
|
68
|
-
continue;
|
|
69
|
-
}
|
|
70
|
-
// Keep headers, important lines
|
|
71
|
-
if (line.startsWith('#') || line.startsWith('-') || line.startsWith('*') || line.trim().startsWith('>')) {
|
|
72
|
-
if (currentLength + line.length + 1 <= maxLength) {
|
|
73
|
-
result += line + '\n';
|
|
74
|
-
currentLength += line.length + 1;
|
|
75
|
-
}
|
|
76
|
-
}
|
|
77
|
-
else if (line.trim() && currentLength + line.length + 1 <= maxLength) {
|
|
78
|
-
result += line + '\n';
|
|
79
|
-
currentLength += line.length + 1;
|
|
80
|
-
}
|
|
81
|
-
if (currentLength >= maxLength)
|
|
82
|
-
break;
|
|
31
|
+
/** Feeds the full-text index with a page already fetched; never breaks the read path. */
|
|
32
|
+
indexPage(docId, title, url, text) {
|
|
33
|
+
try {
|
|
34
|
+
this.searchIndex.add(docId, { title, url, text });
|
|
83
35
|
}
|
|
84
|
-
|
|
36
|
+
catch (error) {
|
|
37
|
+
console.error(`Content index failure for ${docId}:`, error instanceof Error ? error.message : error);
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
/** Re-feeds the index from the cache when a page was evicted or read before the index existed. */
|
|
41
|
+
reindexIfMissing(docId, title, url, text) {
|
|
42
|
+
if (!this.searchIndex.has(docId))
|
|
43
|
+
this.indexPage(docId, title, url, text);
|
|
85
44
|
}
|
|
86
45
|
/**
|
|
87
46
|
* Search Spring projects with caching and retry logic - REAL API ONLY
|
|
@@ -107,14 +66,14 @@ export class SpringBootDocsServiceOptimized {
|
|
|
107
66
|
const $project = $(element);
|
|
108
67
|
const title = $project.find('h2, h3, .title, .project-title').first().text().trim();
|
|
109
68
|
const description = $project.find('p, .description, .summary').first().text().trim();
|
|
110
|
-
const
|
|
111
|
-
if (title && (title.toLowerCase().includes(query.toLowerCase()) ||
|
|
69
|
+
const url = absoluteSpringUrl($project.find('a').first().attr('href'));
|
|
70
|
+
if (url && title && (title.toLowerCase().includes(query.toLowerCase()) ||
|
|
112
71
|
description.toLowerCase().includes(query.toLowerCase()))) {
|
|
113
72
|
projects.push({
|
|
114
73
|
type: 'spring-project',
|
|
115
74
|
title: title,
|
|
116
75
|
description: description,
|
|
117
|
-
url:
|
|
76
|
+
url: url,
|
|
118
77
|
});
|
|
119
78
|
}
|
|
120
79
|
});
|
|
@@ -124,22 +83,24 @@ export class SpringBootDocsServiceOptimized {
|
|
|
124
83
|
}
|
|
125
84
|
catch (error) {
|
|
126
85
|
console.error('Error searching Spring projects:', error);
|
|
127
|
-
|
|
86
|
+
throw error;
|
|
128
87
|
}
|
|
129
88
|
}
|
|
130
89
|
/**
|
|
131
|
-
* Get Spring project
|
|
90
|
+
* Get the full markdown of a Spring project page (cached) - REAL API ONLY
|
|
132
91
|
*/
|
|
133
|
-
async
|
|
92
|
+
async getProjectMarkdown(projectName) {
|
|
93
|
+
const slug = assertSafeSegment(projectName.toLowerCase().replace(/\s+/g, '-'), 'project name');
|
|
134
94
|
const cacheKey = `project:${projectName}`;
|
|
135
95
|
const cached = this.cache.get(cacheKey);
|
|
136
96
|
if (cached) {
|
|
137
97
|
console.error(`✅ Cache hit for project: ${projectName}`);
|
|
98
|
+
this.reindexIfMissing(`project:${slug}`, projectName, cached.url, cached.markdown);
|
|
138
99
|
return cached;
|
|
139
100
|
}
|
|
140
101
|
console.error(`🔍 Fetching project: ${projectName}`);
|
|
141
102
|
try {
|
|
142
|
-
const url = `${this.springProjectsUrl}/${
|
|
103
|
+
const url = `${this.springProjectsUrl}/${slug}`;
|
|
143
104
|
const response = await this.fetchWithRetry(url);
|
|
144
105
|
if (!response.ok) {
|
|
145
106
|
throw new Error(`Project not found: ${projectName}`);
|
|
@@ -150,17 +111,25 @@ export class SpringBootDocsServiceOptimized {
|
|
|
150
111
|
if (content.length === 0) {
|
|
151
112
|
throw new Error('No content found for project');
|
|
152
113
|
}
|
|
153
|
-
const markdown =
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
114
|
+
const markdown = turndownService.turndown(content.html() || '');
|
|
115
|
+
// Cache the full markdown so later pages need no new fetch
|
|
116
|
+
this.indexPage(`project:${slug}`, projectName, url, markdown);
|
|
117
|
+
const entry = { markdown, url };
|
|
118
|
+
this.cache.setLongTerm(cacheKey, entry);
|
|
119
|
+
return entry;
|
|
158
120
|
}
|
|
159
121
|
catch (error) {
|
|
160
122
|
console.error(`Error fetching project ${projectName}:`, error);
|
|
161
123
|
throw error;
|
|
162
124
|
}
|
|
163
125
|
}
|
|
126
|
+
/**
|
|
127
|
+
* Get Spring project details - REAL API ONLY
|
|
128
|
+
*/
|
|
129
|
+
async getSpringProject(projectName, offset = 0) {
|
|
130
|
+
const { markdown, url } = await this.getProjectMarkdown(projectName);
|
|
131
|
+
return this.formatPage(projectName, markdown, url, offset, 'For complete project info, visit');
|
|
132
|
+
}
|
|
164
133
|
/**
|
|
165
134
|
* Get all Spring guides - REAL API ONLY
|
|
166
135
|
*/
|
|
@@ -173,57 +142,45 @@ export class SpringBootDocsServiceOptimized {
|
|
|
173
142
|
}
|
|
174
143
|
console.error(`🔍 Fetching guides for category: ${category || 'all'}`);
|
|
175
144
|
try {
|
|
176
|
-
const
|
|
177
|
-
if (!response.ok) {
|
|
178
|
-
throw new Error('Unable to access Spring guides page');
|
|
179
|
-
}
|
|
180
|
-
const html = await response.text();
|
|
181
|
-
const $ = cheerio.load(html);
|
|
182
|
-
const guides = [];
|
|
183
|
-
// Parse actual Spring guides page
|
|
184
|
-
$('.guide-item, .card, .guide-card, .list-item, .guide').each((_, element) => {
|
|
185
|
-
const $guide = $(element);
|
|
186
|
-
const title = $guide.find('h2, h3, .title, .guide-title, a').first().text().trim();
|
|
187
|
-
const description = $guide.find('p, .description, .summary').first().text().trim();
|
|
188
|
-
const guideCategory = $guide.find('.category, .badge, .label').first().text().trim();
|
|
189
|
-
const link = $guide.find('a').first().attr('href');
|
|
190
|
-
if (title) {
|
|
191
|
-
if (!category || guideCategory.toLowerCase().includes(category.toLowerCase())) {
|
|
192
|
-
guides.push({
|
|
193
|
-
type: 'spring-guide',
|
|
194
|
-
title: title,
|
|
195
|
-
description: description || 'Spring guide',
|
|
196
|
-
category: guideCategory || 'General',
|
|
197
|
-
url: link?.startsWith('http') ? link : `https://spring.io${link}`,
|
|
198
|
-
});
|
|
199
|
-
}
|
|
200
|
-
}
|
|
201
|
-
});
|
|
145
|
+
const guides = await fetchSpringGuidesList(category);
|
|
202
146
|
const results = guides.slice(0, limit);
|
|
203
147
|
this.cache.set(cacheKey, results);
|
|
204
148
|
return results;
|
|
205
149
|
}
|
|
206
150
|
catch (error) {
|
|
207
151
|
console.error('Error retrieving Spring guides:', error);
|
|
208
|
-
|
|
152
|
+
throw error;
|
|
209
153
|
}
|
|
210
154
|
}
|
|
211
155
|
/**
|
|
212
156
|
* Get specific guide content - REAL API ONLY
|
|
213
157
|
*/
|
|
214
158
|
async getGuide(guideId, detailLevel = 'medium') {
|
|
215
|
-
|
|
159
|
+
if (!/^[A-Za-z0-9][A-Za-z0-9._-]*$/.test(guideId) || guideId.includes('..')) {
|
|
160
|
+
throw new Error(`Invalid guideId "${guideId}": only letters, digits, '.', '_' and '-' are allowed`);
|
|
161
|
+
}
|
|
162
|
+
// Getting-started guides live under /guides/gs/<name>/; accept the repository-style "gs-<name>" too
|
|
163
|
+
const name = guideId.replace(/^gs-/, '');
|
|
164
|
+
if (!name) {
|
|
165
|
+
throw new Error(`Invalid guideId "${guideId}": the guide name is empty`);
|
|
166
|
+
}
|
|
167
|
+
const safeId = encodeURIComponent(name);
|
|
168
|
+
const cacheKey = `guide:${name}:${detailLevel}`;
|
|
169
|
+
const rawKey = `guide-md:${name}`;
|
|
216
170
|
const cached = this.cache.get(cacheKey);
|
|
217
171
|
if (cached) {
|
|
218
172
|
console.error(`✅ Cache hit for guide: ${guideId} (${detailLevel})`);
|
|
173
|
+
// The formatted text depends on detailLevel: the index is fed from the full markdown kept apart
|
|
174
|
+
const full = this.cache.get(rawKey);
|
|
175
|
+
if (full)
|
|
176
|
+
this.reindexIfMissing(`guide:${name}`, `Guide: ${name}`, full.url, full.markdown);
|
|
219
177
|
return cached;
|
|
220
178
|
}
|
|
221
179
|
console.error(`🔍 Fetching guide: ${guideId} with detail level: ${detailLevel}`);
|
|
222
|
-
//
|
|
180
|
+
// Fallbacks tried in order: getting-started guide, then top-level guide path
|
|
223
181
|
const sources = [
|
|
224
|
-
{ name: 'Spring.io', url: `${this.springGuideUrl}/${
|
|
225
|
-
{ name: '
|
|
226
|
-
{ name: 'Spring.io alt', url: `${this.springGuideUrl}/gs/${guideId}/` }
|
|
182
|
+
{ name: 'Spring.io getting-started', url: `${this.springGuideUrl}/gs/${safeId}/` },
|
|
183
|
+
{ name: 'Spring.io', url: `${this.springGuideUrl}/${safeId}/` }
|
|
227
184
|
];
|
|
228
185
|
let content = '';
|
|
229
186
|
let sourceUrl = '';
|
|
@@ -249,7 +206,11 @@ export class SpringBootDocsServiceOptimized {
|
|
|
249
206
|
throw new Error(`Guide not found: ${guideId}`);
|
|
250
207
|
}
|
|
251
208
|
try {
|
|
252
|
-
const
|
|
209
|
+
const markdown = this.extractGuideMarkdown(content, sourceUrl);
|
|
210
|
+
const result = this.formatGuide(markdown, guideId, sourceUrl, detailLevel);
|
|
211
|
+
// Index the complete text, whatever the detail level of this read
|
|
212
|
+
this.indexPage(`guide:${name}`, `Guide: ${name}`, sourceUrl, markdown);
|
|
213
|
+
this.cache.setLongTerm(rawKey, { markdown, url: sourceUrl });
|
|
253
214
|
this.cache.setLongTerm(cacheKey, result);
|
|
254
215
|
return result;
|
|
255
216
|
}
|
|
@@ -259,63 +220,202 @@ export class SpringBootDocsServiceOptimized {
|
|
|
259
220
|
}
|
|
260
221
|
}
|
|
261
222
|
/**
|
|
262
|
-
* Get Spring reference documentation -
|
|
223
|
+
* Get Spring reference documentation - backward compatibility (defaults to Spring Boot)
|
|
224
|
+
* @deprecated Use getSpringReference('boot', section) instead
|
|
263
225
|
*/
|
|
264
226
|
async getReference(section, subsection) {
|
|
265
|
-
|
|
227
|
+
console.warn('getReference() is deprecated. Use getSpringReference("boot", section) instead');
|
|
228
|
+
return this.getSpringReference('boot', section, subsection);
|
|
266
229
|
}
|
|
267
230
|
/**
|
|
268
|
-
* Get Spring reference documentation
|
|
231
|
+
* Get Spring reference documentation for any Spring project
|
|
232
|
+
* Supports: Spring Boot, Spring AI, Spring Framework, and future projects
|
|
233
|
+
*
|
|
234
|
+
* @param projectId - Project identifier ('boot', 'ai', 'framework', etc.)
|
|
235
|
+
* @param section - Documentation section (e.g., 'web', 'chatclient', 'core')
|
|
236
|
+
* @param subsection - Optional subsection for deeper navigation
|
|
237
|
+
* @param offset - Character offset for paginated reads
|
|
238
|
+
* @param version - Optional documentation version ('3.4' or '3.4.2'; omitted/'current' = latest)
|
|
239
|
+
* @returns Formatted markdown documentation with source URL
|
|
269
240
|
*/
|
|
270
|
-
async getSpringReference(section, subsection) {
|
|
271
|
-
const
|
|
241
|
+
async getSpringReference(projectId, section, subsection, offset = 0, version) {
|
|
242
|
+
const safeSection = assertSafeSegment(section, 'section');
|
|
243
|
+
const safeSubsection = subsection ? assertSafeSegment(subsection, 'subsection') : undefined;
|
|
244
|
+
// Validate the version before any cache or network access
|
|
245
|
+
const normalizedVersion = normalizeVersion(version);
|
|
246
|
+
const baseKey = `reference:${projectId}:${section}:${subsection || 'main'}`;
|
|
247
|
+
const cacheKey = normalizedVersion ? `${baseKey}:v${normalizedVersion}` : baseKey;
|
|
272
248
|
const cached = this.cache.get(cacheKey);
|
|
273
249
|
if (cached) {
|
|
274
|
-
console.error(`✅ Cache hit for reference: ${section}`);
|
|
275
|
-
|
|
250
|
+
console.error(`✅ Cache hit for reference: ${projectId}/${section}`);
|
|
251
|
+
this.reindexIfMissing(`reference:${projectId}:${normalizedVersion ?? 'current'}:${section}:${subsection ?? 'main'}`, this.referenceTitle(projectId, section, subsection), cached.url, cached.markdown);
|
|
252
|
+
return this.formatPage(this.referenceTitle(projectId, section, subsection), cached.markdown, cached.url, offset, 'For complete reference, visit');
|
|
276
253
|
}
|
|
277
|
-
console.error(`🔍 Fetching reference: ${section}`);
|
|
254
|
+
console.error(`🔍 Fetching reference: ${projectId}/${section}`);
|
|
278
255
|
try {
|
|
279
|
-
|
|
256
|
+
// Validate project exists
|
|
257
|
+
const project = this.projectsConfig.getProject(projectId);
|
|
258
|
+
// Validate section if project defines allowed sections
|
|
259
|
+
if (!this.projectsConfig.validateSection(projectId, section)) {
|
|
260
|
+
const availableSections = project.referenceSections?.join(', ') || 'any';
|
|
261
|
+
throw new Error(`Invalid section "${section}" for ${project.displayName}. Available sections: ${availableSections}`);
|
|
262
|
+
}
|
|
263
|
+
// Build URL using configuration
|
|
264
|
+
const url = this.projectsConfig.buildReferenceUrl(projectId, safeSection, safeSubsection, normalizedVersion);
|
|
280
265
|
const response = await this.fetchWithRetry(url);
|
|
281
266
|
if (!response.ok) {
|
|
282
|
-
|
|
267
|
+
if (normalizedVersion) {
|
|
268
|
+
throw new Error(`Reference not found for ${project.displayName} version ${normalizedVersion} (this version may not be published at the current documentation site; omit 'version' for the latest or try a more recent one)`);
|
|
269
|
+
}
|
|
270
|
+
throw new Error(`Reference section not found: ${project.displayName} / ${section}`);
|
|
283
271
|
}
|
|
284
272
|
const html = await response.text();
|
|
285
273
|
const $ = cheerio.load(html);
|
|
286
|
-
|
|
274
|
+
// Extract content (selector may vary by project)
|
|
275
|
+
const content = $('.content, .section, main, article').first();
|
|
287
276
|
if (content.length === 0) {
|
|
288
|
-
throw new Error(
|
|
277
|
+
throw new Error(`No content found in ${project.displayName} reference documentation`);
|
|
289
278
|
}
|
|
290
|
-
const markdown =
|
|
291
|
-
const
|
|
292
|
-
this.
|
|
293
|
-
|
|
279
|
+
const markdown = turndownService.turndown(content.html() || '');
|
|
280
|
+
const entry = { markdown, url };
|
|
281
|
+
this.indexPage(`reference:${projectId}:${normalizedVersion ?? 'current'}:${section}:${subsection ?? 'main'}`, this.referenceTitle(projectId, section, subsection), url, markdown);
|
|
282
|
+
// Use project-specific cache strategy
|
|
283
|
+
const cacheTTL = this.projectsConfig.getCacheTTL(projectId);
|
|
284
|
+
if (project.cacheStrategy === 'long') {
|
|
285
|
+
this.cache.setLongTerm(cacheKey, entry);
|
|
286
|
+
}
|
|
287
|
+
else {
|
|
288
|
+
this.cache.set(cacheKey, entry, cacheTTL);
|
|
289
|
+
}
|
|
290
|
+
return this.formatPage(this.referenceTitle(projectId, section, subsection), markdown, url, offset, 'For complete reference, visit');
|
|
294
291
|
}
|
|
295
292
|
catch (error) {
|
|
296
|
-
console.error(`Error fetching reference ${section}:`, error);
|
|
293
|
+
console.error(`Error fetching reference ${projectId}/${section}:`, error);
|
|
297
294
|
throw error;
|
|
298
295
|
}
|
|
299
296
|
}
|
|
297
|
+
/**
|
|
298
|
+
* Get the Spring Boot migration guide or upgrade release notes from the GitHub wiki
|
|
299
|
+
*
|
|
300
|
+
* @param version - Target version ('3.0', '3.4' or '3.4.2'; the patch is ignored)
|
|
301
|
+
* @param document - 'auto' (migration guide for x.0, release notes otherwise) or an explicit document
|
|
302
|
+
* @param section - Optional keyword; only matching headings (with sub-sections) are returned
|
|
303
|
+
* @param offset - Character offset for paginated reads
|
|
304
|
+
*/
|
|
305
|
+
async getMigrationGuide(version, document = 'auto', section, offset = 0, project = 'spring-boot') {
|
|
306
|
+
if (!isMigrationProject(project)) {
|
|
307
|
+
throw new Error(`Unknown project "${project}" for migration guides`);
|
|
308
|
+
}
|
|
309
|
+
// Resolve version and document before any cache or network access
|
|
310
|
+
const normalizedVersion = normalizeVersion(version);
|
|
311
|
+
if (!normalizedVersion) {
|
|
312
|
+
throw new Error(`A target ${projectDisplayName(project)} version is required (e.g. "3.0", "3.4" or "4.0")`);
|
|
313
|
+
}
|
|
314
|
+
const resolved = project === 'spring-boot'
|
|
315
|
+
? resolveWikiDocument(normalizedVersion, document)
|
|
316
|
+
: resolveRawDocument(project, document);
|
|
317
|
+
const keyword = section?.trim() || undefined;
|
|
318
|
+
const cacheKey = project === 'spring-boot'
|
|
319
|
+
? `migration:${resolved}:${normalizedVersion}`
|
|
320
|
+
: `migration:${project}:${resolved}:${normalizedVersion}`;
|
|
321
|
+
let entry = this.cache.get(cacheKey);
|
|
322
|
+
if (entry) {
|
|
323
|
+
console.error(`✅ Cache hit for migration page: ${cacheKey}`);
|
|
324
|
+
this.reindexIfMissing(cacheKey, entry.title, entry.url, entry.markdown);
|
|
325
|
+
}
|
|
326
|
+
else {
|
|
327
|
+
entry = project === 'spring-boot'
|
|
328
|
+
? await this.fetchBootWikiPage(normalizedVersion, resolved)
|
|
329
|
+
: await this.fetchRawWikiPage(project, normalizedVersion);
|
|
330
|
+
// Only successful, verified pages are cached and indexed (whole page, not just the requested section)
|
|
331
|
+
this.indexPage(cacheKey, entry.title, entry.url, entry.markdown);
|
|
332
|
+
this.cache.setLongTerm(cacheKey, entry);
|
|
333
|
+
}
|
|
334
|
+
const markdown = keyword ? selectSections(entry.markdown, keyword, entry.title) : entry.markdown;
|
|
335
|
+
const title = keyword ? `${entry.title} (section: ${keyword})` : entry.title;
|
|
336
|
+
return this.formatPage(title, markdown, entry.url, offset, 'For the complete page, visit', Boolean(keyword));
|
|
337
|
+
}
|
|
338
|
+
async fetchBootWikiPage(version, document) {
|
|
339
|
+
const url = wikiPageUrl(wikiPageName(version, document));
|
|
340
|
+
console.error(`🔍 Fetching migration page: ${url}`);
|
|
341
|
+
const response = await this.fetchWithRetry(url);
|
|
342
|
+
if (!response.ok) {
|
|
343
|
+
throw new Error(`Failed to fetch Spring Boot wiki page: ${response.status}`);
|
|
344
|
+
}
|
|
345
|
+
const html = await response.text();
|
|
346
|
+
const { markdown, title } = extractWikiMarkdown(html, expectedWikiTitle(version, document), url);
|
|
347
|
+
return { markdown, url, title };
|
|
348
|
+
}
|
|
349
|
+
/** Raw markdown wiki page (Framework, Batch): a missing page is a real 404, an HTML body is never a guide. */
|
|
350
|
+
async fetchRawWikiPage(project, version) {
|
|
351
|
+
const url = rawWikiUrl(project, version);
|
|
352
|
+
const title = rawWikiTitle(project, version);
|
|
353
|
+
console.error(`🔍 Fetching migration page: ${url}`);
|
|
354
|
+
const response = await this.fetchWithRetry(url);
|
|
355
|
+
if (response.status === 404) {
|
|
356
|
+
throw new Error(`Wiki page not found: ${title} (${url})`);
|
|
357
|
+
}
|
|
358
|
+
if (!response.ok) {
|
|
359
|
+
throw new Error(`Failed to fetch ${title}: ${response.status}`);
|
|
360
|
+
}
|
|
361
|
+
const markdown = assertRawMarkdown(await response.text(), title, url);
|
|
362
|
+
return { markdown, url, title };
|
|
363
|
+
}
|
|
364
|
+
referenceTitle(projectId, section, subsection) {
|
|
365
|
+
const project = this.projectsConfig.getProject(projectId);
|
|
366
|
+
return `${project.displayName} Reference: ${subsection ? `${section}/${subsection}` : section}`;
|
|
367
|
+
}
|
|
368
|
+
/**
|
|
369
|
+
* Format one page of a full markdown document with a pagination footer
|
|
370
|
+
*/
|
|
371
|
+
formatPage(title, markdown, url, offset, linkLabel, filtered = false) {
|
|
372
|
+
const page = pageMarkdown(markdown, offset);
|
|
373
|
+
if (offset >= page.total) {
|
|
374
|
+
return `No content at offset ${offset} (total: ${page.total} characters).`;
|
|
375
|
+
}
|
|
376
|
+
let result = `# ${title}\n\n${page.content}`;
|
|
377
|
+
if (page.nextOffset !== null) {
|
|
378
|
+
const note = filtered ? ' The offset applies to the filtered text (the selected section), not to the full page.' : '';
|
|
379
|
+
result += `\n\n---\nPart ${page.start}–${page.end} of ${page.total} characters. To continue, call again with offset=${page.nextOffset}.${note}`;
|
|
380
|
+
}
|
|
381
|
+
return `${result}\n\n${linkLabel}: ${url}`;
|
|
382
|
+
}
|
|
300
383
|
/**
|
|
301
384
|
* Search Spring concepts - alias for backward compatibility
|
|
302
385
|
*/
|
|
303
|
-
async searchConcepts(concept,
|
|
304
|
-
return this.searchSpringConcepts(concept,
|
|
386
|
+
async searchConcepts(concept, version) {
|
|
387
|
+
return this.searchSpringConcepts(concept, version);
|
|
388
|
+
}
|
|
389
|
+
/**
|
|
390
|
+
* Entry page of the Spring Boot reference documentation: the current docs (legacy URL, unchanged)
|
|
391
|
+
* or the page of a published version ("3.4" or "3.4.2"; the patch is ignored).
|
|
392
|
+
*/
|
|
393
|
+
bootReferenceIndexUrl(normalizedVersion) {
|
|
394
|
+
return normalizedVersion
|
|
395
|
+
? `${this.baseUrl}/spring-boot/${normalizedVersion}/reference/index.html`
|
|
396
|
+
: `${this.baseUrl}/spring-boot/docs/current/reference/html/`;
|
|
397
|
+
}
|
|
398
|
+
bootVersionNotFound(normalizedVersion) {
|
|
399
|
+
return new Error(`Spring Boot documentation for version ${normalizedVersion} not found (this version may not be published at the current documentation site; omit 'version' for the latest or try a more recent one)`);
|
|
305
400
|
}
|
|
306
401
|
/**
|
|
307
402
|
* Search Spring concepts - using real documentation
|
|
403
|
+
*
|
|
404
|
+
* @param version - Optional Spring Boot documentation version ('3.4' or '3.4.2'; omitted/'current' = latest)
|
|
308
405
|
*/
|
|
309
|
-
async searchSpringConcepts(concept,
|
|
310
|
-
const
|
|
406
|
+
async searchSpringConcepts(concept, version) {
|
|
407
|
+
const normalizedVersion = normalizeVersion(version);
|
|
408
|
+
const cacheKey = normalizedVersion ? `concepts:${concept}:v${normalizedVersion}` : `concepts:${concept}`;
|
|
311
409
|
const cached = this.cache.get(cacheKey);
|
|
312
410
|
if (cached)
|
|
313
411
|
return cached;
|
|
314
412
|
try {
|
|
315
413
|
// Search in Spring Boot reference documentation
|
|
316
|
-
const searchUrl =
|
|
414
|
+
const searchUrl = this.bootReferenceIndexUrl(normalizedVersion);
|
|
317
415
|
const response = await this.fetchWithRetry(searchUrl);
|
|
318
416
|
if (!response.ok) {
|
|
417
|
+
if (normalizedVersion)
|
|
418
|
+
throw this.bootVersionNotFound(normalizedVersion);
|
|
319
419
|
throw new Error('Unable to access Spring Boot documentation');
|
|
320
420
|
}
|
|
321
421
|
const html = await response.text();
|
|
@@ -331,7 +431,7 @@ export class SpringBootDocsServiceOptimized {
|
|
|
331
431
|
const headingText = heading.text().toLowerCase();
|
|
332
432
|
if (headingText.includes(concept.toLowerCase())) {
|
|
333
433
|
const section = heading.parent();
|
|
334
|
-
const sectionMarkdown =
|
|
434
|
+
const sectionMarkdown = turndownService.turndown(section.html() || '');
|
|
335
435
|
conceptContent += sectionMarkdown.substring(0, 500) + '\n\n';
|
|
336
436
|
foundSections++;
|
|
337
437
|
}
|
|
@@ -347,53 +447,110 @@ export class SpringBootDocsServiceOptimized {
|
|
|
347
447
|
}
|
|
348
448
|
catch (error) {
|
|
349
449
|
console.error('Error searching concepts:', error);
|
|
350
|
-
|
|
450
|
+
throw error;
|
|
351
451
|
}
|
|
352
452
|
}
|
|
353
453
|
/**
|
|
354
454
|
* Search documentation with real API - alias for backward compatibility
|
|
355
455
|
*/
|
|
356
|
-
async searchDocumentation(query, docType = 'all', limit = 10) {
|
|
357
|
-
return this.searchSpringDocs(query, docType, limit);
|
|
456
|
+
async searchDocumentation(query, docType = 'all', limit = 10, version) {
|
|
457
|
+
return this.searchSpringDocs(query, docType, limit, version);
|
|
358
458
|
}
|
|
359
459
|
/**
|
|
360
460
|
* Search documentation with real API
|
|
461
|
+
*
|
|
462
|
+
* @param version - Optional Spring Boot documentation version for the Boot reference part
|
|
463
|
+
* ('3.4' or '3.4.2'; omitted/'current' = latest). Guides and projects are not versioned and ignore it.
|
|
361
464
|
*/
|
|
362
|
-
async searchSpringDocs(query, docType = 'all', limit = 10) {
|
|
363
|
-
const
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
const
|
|
465
|
+
async searchSpringDocs(query, docType = 'all', limit = 10, version) {
|
|
466
|
+
const allowedDocTypes = ['guides', 'reference', 'projects', 'content', 'all'];
|
|
467
|
+
if (!allowedDocTypes.includes(docType)) {
|
|
468
|
+
throw new Error(`Invalid docType "${docType}". Allowed: ${allowedDocTypes.join(', ')}`);
|
|
469
|
+
}
|
|
470
|
+
const normalizedVersion = normalizeVersion(version);
|
|
471
|
+
const baseKey = `docs:${query}:${docType}:${limit}`;
|
|
472
|
+
const cacheKey = normalizedVersion ? `${baseKey}:v${normalizedVersion}` : baseKey;
|
|
473
|
+
// Only the title sources are cached: content hits depend on the current state of the index
|
|
474
|
+
const titles = this.cache.get(cacheKey) ?? await this.searchTitleSources(query, docType, limit, cacheKey, normalizedVersion);
|
|
475
|
+
if (docType !== 'all' && docType !== 'content') {
|
|
476
|
+
return titles.slice(0, limit);
|
|
477
|
+
}
|
|
478
|
+
return this.mergeContentResults(titles, query, limit, normalizedVersion);
|
|
479
|
+
}
|
|
480
|
+
mergeContentResults(titles, query, limit, normalizedVersion) {
|
|
481
|
+
const seen = new Set(titles.map(result => result.url));
|
|
482
|
+
let content = [];
|
|
368
483
|
try {
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
484
|
+
// With a version, Boot reference pages of other versions are left out (each page is indexed
|
|
485
|
+
// under its own versioned docId); guides, projects and other projects' pages are not versioned
|
|
486
|
+
const sameVersion = (docId) => !normalizedVersion || !docId.startsWith('reference:boot:') || docId.startsWith(`reference:boot:${normalizedVersion}:`);
|
|
487
|
+
content = this.searchIndex.search(query, limit, sameVersion)
|
|
488
|
+
.filter(hit => !seen.has(hit.url))
|
|
489
|
+
.map(hit => ({ type: 'content', title: hit.title, url: hit.url, description: hit.snippet, score: hit.score }));
|
|
490
|
+
}
|
|
491
|
+
catch (error) {
|
|
492
|
+
console.error('Content search failed:', error instanceof Error ? error.message : error);
|
|
493
|
+
}
|
|
494
|
+
// Content takes the slots titles leave free, and at most half of them otherwise
|
|
495
|
+
const contentSlots = Math.min(content.length, Math.max(Math.floor(limit / 2), limit - titles.length));
|
|
496
|
+
const merged = [...titles.slice(0, limit - contentSlots), ...content.slice(0, contentSlots)];
|
|
497
|
+
if (this.searchIndex.size === 0) {
|
|
498
|
+
merged.push({
|
|
499
|
+
type: 'note',
|
|
500
|
+
title: 'Content index is empty',
|
|
501
|
+
description: 'Full-text search covers pages already read: read a page first (get_spring_project, get_spring_reference or get_spring_guide), then search again.'
|
|
502
|
+
});
|
|
503
|
+
}
|
|
504
|
+
return merged;
|
|
505
|
+
}
|
|
506
|
+
async searchTitleSources(query, docType, limit, cacheKey, normalizedVersion) {
|
|
507
|
+
const results = [];
|
|
508
|
+
const sources = [];
|
|
509
|
+
if (docType === 'all' || docType === 'guides') {
|
|
510
|
+
sources.push(['guides', async () => {
|
|
511
|
+
const guides = await this.getAllSpringGuides(undefined, Number.MAX_SAFE_INTEGER);
|
|
512
|
+
return guides.filter(guide => guide.title.toLowerCase().includes(query.toLowerCase()) ||
|
|
513
|
+
guide.description.toLowerCase().includes(query.toLowerCase())).slice(0, limit);
|
|
514
|
+
}]);
|
|
515
|
+
}
|
|
516
|
+
if (docType === 'all' || docType === 'projects') {
|
|
517
|
+
sources.push(['projects', () => this.searchSpringProjects(query, limit)]);
|
|
518
|
+
}
|
|
519
|
+
if (docType === 'all' || docType === 'reference') {
|
|
520
|
+
sources.push(['reference', () => this.searchInReference(query, limit, normalizedVersion)]);
|
|
521
|
+
}
|
|
522
|
+
// A failing source must not be hidden nor cached: fail if all fail, otherwise return partial results uncached
|
|
523
|
+
const failures = [];
|
|
524
|
+
const settled = await Promise.allSettled(sources.map(([, run]) => run()));
|
|
525
|
+
settled.forEach((outcome, index) => {
|
|
526
|
+
if (outcome.status === 'fulfilled') {
|
|
527
|
+
results.push(...outcome.value);
|
|
378
528
|
}
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
results.push(...refResults);
|
|
529
|
+
else {
|
|
530
|
+
failures.push(sources[index][0]);
|
|
531
|
+
console.error(`Documentation source "${sources[index][0]}" failed:`, outcome.reason instanceof Error ? outcome.reason.message : outcome.reason);
|
|
383
532
|
}
|
|
384
|
-
|
|
385
|
-
|
|
533
|
+
});
|
|
534
|
+
if (sources.length > 0 && failures.length === sources.length) {
|
|
535
|
+
const reasons = settled
|
|
536
|
+
.map(outcome => (outcome.status === 'rejected' ? (outcome.reason instanceof Error ? outcome.reason.message : String(outcome.reason)) : ''))
|
|
537
|
+
.filter(Boolean);
|
|
538
|
+
throw new Error(`Unable to search documentation: all sources failed (${failures.join(', ')}): ${reasons.join('; ')}`);
|
|
386
539
|
}
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
return [];
|
|
540
|
+
if (failures.length === 0) {
|
|
541
|
+
this.cache.set(cacheKey, results);
|
|
390
542
|
}
|
|
543
|
+
return results.slice(0, limit);
|
|
391
544
|
}
|
|
392
|
-
async searchInReference(query, limit) {
|
|
545
|
+
async searchInReference(query, limit, normalizedVersion) {
|
|
393
546
|
try {
|
|
394
|
-
const
|
|
395
|
-
|
|
396
|
-
|
|
547
|
+
const pageUrl = this.bootReferenceIndexUrl(normalizedVersion);
|
|
548
|
+
const response = await this.fetchWithRetry(pageUrl);
|
|
549
|
+
if (!response.ok) {
|
|
550
|
+
if (normalizedVersion)
|
|
551
|
+
throw this.bootVersionNotFound(normalizedVersion);
|
|
552
|
+
throw new Error('Unable to access Spring Boot reference documentation');
|
|
553
|
+
}
|
|
397
554
|
const html = await response.text();
|
|
398
555
|
const $ = cheerio.load(html);
|
|
399
556
|
const results = [];
|
|
@@ -405,7 +562,9 @@ export class SpringBootDocsServiceOptimized {
|
|
|
405
562
|
results.push({
|
|
406
563
|
type: 'reference',
|
|
407
564
|
title: `Spring Boot: ${text}`,
|
|
408
|
-
url:
|
|
565
|
+
url: normalizedVersion
|
|
566
|
+
? new URL(href, pageUrl).href // nav hrefs of the versioned site are relative to the page
|
|
567
|
+
: href.startsWith('http') ? href : `${this.baseUrl}/spring-boot/docs/current/reference/html/${href}`,
|
|
409
568
|
description: `Reference documentation section`
|
|
410
569
|
});
|
|
411
570
|
}
|
|
@@ -414,55 +573,42 @@ export class SpringBootDocsServiceOptimized {
|
|
|
414
573
|
}
|
|
415
574
|
catch (error) {
|
|
416
575
|
console.error('Error searching reference:', error);
|
|
417
|
-
|
|
576
|
+
throw error;
|
|
418
577
|
}
|
|
419
578
|
}
|
|
420
|
-
|
|
421
|
-
console.
|
|
579
|
+
extractGuideMarkdown(content, sourceUrl) {
|
|
580
|
+
console.error('Processing HTML guide content...');
|
|
422
581
|
const $ = cheerio.load(content);
|
|
423
582
|
// Remove navigation and footer elements
|
|
424
583
|
$('nav, footer, .navbar, .sidebar, #js-sidebar').remove();
|
|
425
584
|
// Get main content
|
|
426
|
-
|
|
585
|
+
// Selectors are tried in priority order: a single comma list would pick the first match in
|
|
586
|
+
// document order (on spring.io an unrelated <article> card comes before the guide body)
|
|
587
|
+
const selectors = ['.ascii-doc', '.content', '.guide-content', 'main', '.markdown-body', '.guide-body', 'article'];
|
|
588
|
+
const mainContent = selectors.map(selector => $(selector).first()).find(match => match.length > 0) ?? $();
|
|
427
589
|
let markdown;
|
|
428
590
|
if (mainContent.length === 0) {
|
|
429
|
-
console.
|
|
591
|
+
console.error('No main content found, using body');
|
|
430
592
|
$('script, style').remove();
|
|
431
|
-
markdown =
|
|
593
|
+
markdown = turndownService.turndown($('body').html() || '');
|
|
432
594
|
}
|
|
433
595
|
else {
|
|
434
|
-
markdown =
|
|
596
|
+
markdown = turndownService.turndown(mainContent.html() || '');
|
|
597
|
+
}
|
|
598
|
+
const trimmed = markdown.trim();
|
|
599
|
+
// A near-empty page or a JSON excerpt (client-rendered page data) is not a guide
|
|
600
|
+
if (trimmed.length < MIN_GUIDE_CHARS || /^[{[]\s*"/.test(trimmed)) {
|
|
601
|
+
throw new Error(`No content could be extracted from the guide page: ${sourceUrl}`);
|
|
435
602
|
}
|
|
603
|
+
return markdown;
|
|
604
|
+
}
|
|
605
|
+
formatGuide(markdown, guideId, sourceUrl, detailLevel) {
|
|
436
606
|
// Use intelligent extraction
|
|
437
|
-
const extractedContent =
|
|
438
|
-
|
|
439
|
-
return `# Spring Guide: ${guideId}\n\n**Source:** ${sourceUrl}\n**Detail Level:** ${detailLevel}\n\n${extractedContent}${needsTruncation ? '\n\n---\n*Content truncated for brevity. Use detail_level="full" for complete guide or visit the link above.*' : ''}`;
|
|
607
|
+
const { content: extractedContent, truncated } = extractContent(markdown, detailLevel);
|
|
608
|
+
return `# Spring Guide: ${guideId}\n\n**Source:** ${sourceUrl}\n**Detail Level:** ${detailLevel}\n\n${extractedContent}${truncated ? (detailLevel === 'full' ? '\n\n---\n*Content truncated at 50,000 characters even in full mode. Visit the link above for the complete guide.*' : '\n\n---\n*Content truncated for brevity. Use detail_level="full" for complete guide or visit the link above.*') : ''}`;
|
|
440
609
|
}
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
const controller = new AbortController();
|
|
444
|
-
const timeoutId = setTimeout(() => controller.abort(), timeout);
|
|
445
|
-
try {
|
|
446
|
-
const response = await fetch(url, {
|
|
447
|
-
signal: controller.signal,
|
|
448
|
-
headers: {
|
|
449
|
-
'User-Agent': 'Spring-Docs-MCP/1.2.4',
|
|
450
|
-
'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8',
|
|
451
|
-
'Accept-Language': 'en-US,en;q=0.9'
|
|
452
|
-
}
|
|
453
|
-
});
|
|
454
|
-
clearTimeout(timeoutId);
|
|
455
|
-
return response;
|
|
456
|
-
}
|
|
457
|
-
catch (error) {
|
|
458
|
-
clearTimeout(timeoutId);
|
|
459
|
-
if (attempt === retries)
|
|
460
|
-
throw error;
|
|
461
|
-
console.error(`Retry ${attempt}/${retries} for ${url}:`, error instanceof Error ? error.message : 'Unknown error');
|
|
462
|
-
await new Promise(resolve => setTimeout(resolve, 1000 * attempt)); // Exponential backoff
|
|
463
|
-
}
|
|
464
|
-
}
|
|
465
|
-
throw new Error('All retry attempts failed');
|
|
610
|
+
fetchWithRetry(url, timeout, retries) {
|
|
611
|
+
return fetchWithRetry(url, timeout, retries);
|
|
466
612
|
}
|
|
467
613
|
}
|
|
468
614
|
//# sourceMappingURL=springboot-docs-optimized.js.map
|