Skip to content

Navigation Menu

Sign in
Appearance settings

Search code, repositories, users, issues, pull requests...

Provide feedback

We read every piece of feedback, and take your input very seriously.

Saved searches

Use saved searches to filter your results more quickly

Appearance settings

Commit c963ecb

Browse filesBrowse files
committed
fix(@angular/cli): remove algoliasearch dependency and support latest docs versions
Eliminate the client library dependency on `algoliasearch` by replacing the Algolia query calls with native `fetch` requests. This removes 15 transitively installed npm packages from the CLI dependencies. Also, bump `LATEST_KNOWN_DOCS_VERSION` to 22 and add a unit test suite to verify the documentation search and fallback version logic.
1 parent 917393a commit c963ecb
Copy full SHA for c963ecb

5 files changed

+245-295Lines changed: 245 additions & 295 deletions

File tree

Expand file treeCollapse file tree
Open diff view settings
Filter options
Expand file treeCollapse file tree
Open diff view settings
Collapse file

‎packages/angular/cli/BUILD.bazel‎

Copy file name to clipboardExpand all lines: packages/angular/cli/BUILD.bazel
-1Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -61,7 +61,6 @@ ts_project(
6161
":node_modules/@inquirer/prompts",
6262
":node_modules/@listr2/prompt-adapter-inquirer",
6363
":node_modules/@modelcontextprotocol/sdk",
64-
":node_modules/algoliasearch",
6564
":node_modules/jsonc-parser",
6665
":node_modules/listr2",
6766
":node_modules/npm-package-arg",
Collapse file

‎packages/angular/cli/package.json‎

Copy file name to clipboardExpand all lines: packages/angular/cli/package.json
-1Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,6 @@
1919
"@listr2/prompt-adapter-inquirer": "4.2.4",
2020
"@modelcontextprotocol/sdk": "1.29.0",
2121
"@schematics/angular": "workspace:0.0.0-PLACEHOLDER",
22-
"algoliasearch": "5.56.0",
2322
"jsonc-parser": "3.3.1",
2423
"listr2": "10.2.2",
2524
"npm-package-arg": "14.0.0",
Collapse file

‎packages/angular/cli/src/commands/mcp/tools/doc-search.ts‎

Copy file name to clipboardExpand all lines: packages/angular/cli/src/commands/mcp/tools/doc-search.ts
+60-51Lines changed: 60 additions & 51 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,6 @@
66
* found in the LICENSE file at https://angular.dev/license
77
*/
88

9-
import type { LegacySearchMethodProps, SearchResponse } from 'algoliasearch';
109
import { createDecipheriv } from 'node:crypto';
1110
import { Readable } from 'node:stream';
1211
import { z } from 'zod';
@@ -32,7 +31,7 @@ const MIN_SUPPORTED_DOCS_VERSION = 17;
3231
* condition where a newly released CLI might default to searching for a documentation index that
3332
* doesn't exist yet.
3433
*/
35-
const LATEST_KNOWN_DOCS_VERSION = 20;
34+
const LATEST_KNOWN_DOCS_VERSION = 22;
3635

3736
const docSearchInputSchema = z.object({
3837
query: z
@@ -99,41 +98,86 @@ Searches the official Angular documentation (angular.dev) to answer questions ab
9998
});
10099

101100
function createDocSearchHandler({ logger }: McpToolContext) {
102-
let client: import('algoliasearch').SearchClient | undefined;
101+
let apiKey: string | undefined;
103102

104-
return async ({ query, includeTopContent, version }: DocSearchInput) => {
105-
if (!client) {
103+
async function performSearch(query: string, version: number) {
104+
if (!apiKey) {
106105
const dcip = createDecipheriv(
107106
'aes-256-gcm',
108107
(k1 + ALGOLIA_APP_ID).padEnd(32, '^'),
109108
iv,
110109
).setAuthTag(Buffer.from(at, 'base64'));
111-
const { searchClient } = await import('algoliasearch');
112-
client = searchClient(
113-
ALGOLIA_APP_ID,
114-
dcip.update(ALGOLIA_API_E, 'hex', 'utf-8') + dcip.final('utf-8'),
110+
apiKey = dcip.update(ALGOLIA_API_E, 'hex', 'utf-8') + dcip.final('utf-8');
111+
}
112+
113+
const url = `https://${ALGOLIA_APP_ID}-dsn.algolia.net/1/indexes/angular_v${version}/query`;
114+
const response = await fetch(url, {
115+
method: 'POST',
116+
headers: {
117+
'Content-Type': 'application/json',
118+
'X-Algolia-Application-Id': ALGOLIA_APP_ID,
119+
'X-Algolia-API-Key': apiKey,
120+
},
121+
body: JSON.stringify({
122+
query,
123+
attributesToRetrieve: [
124+
'hierarchy.lvl0',
125+
'hierarchy.lvl1',
126+
'hierarchy.lvl2',
127+
'hierarchy.lvl3',
128+
'hierarchy.lvl4',
129+
'hierarchy.lvl5',
130+
'hierarchy.lvl6',
131+
'content',
132+
'type',
133+
'url',
134+
],
135+
hitsPerPage: 10,
136+
}),
137+
signal: AbortSignal.timeout(5000), // Timeout after 5 seconds
138+
});
139+
140+
if (!response.ok) {
141+
throw new Error(
142+
`Search request failed with status ${response.status} (${response.statusText})`,
115143
);
116144
}
117145

146+
const data = (await response.json()) as { hits: Record<string, unknown>[] };
147+
148+
return data.hits;
149+
}
150+
151+
return async ({ query, includeTopContent, version }: DocSearchInput) => {
118152
let finalSearchedVersion = Math.max(
119153
version ?? LATEST_KNOWN_DOCS_VERSION,
120154
MIN_SUPPORTED_DOCS_VERSION,
121155
);
122-
let searchResults;
156+
157+
let allHits: Record<string, unknown>[] | undefined;
123158
try {
124-
searchResults = await client.search(createSearchArguments(query, finalSearchedVersion));
125-
} catch {}
159+
allHits = await performSearch(query, finalSearchedVersion);
160+
} catch (error) {
161+
logger.warn(`Error searching Angular v${finalSearchedVersion} documentation: ${error}`);
162+
}
126163

127164
// If the initial search for a newer-than-stable version returns no results, it may be because
128165
// the index for that version doesn't exist yet. In this case, fall back to the latest known
129166
// stable version.
130-
if (!searchResults && finalSearchedVersion > LATEST_KNOWN_DOCS_VERSION) {
167+
if ((!allHits || allHits.length === 0) && finalSearchedVersion > LATEST_KNOWN_DOCS_VERSION) {
168+
logger.warn(
169+
`Documentation index for v${finalSearchedVersion} not found or empty. Falling back to v${LATEST_KNOWN_DOCS_VERSION}.`,
170+
);
131171
finalSearchedVersion = LATEST_KNOWN_DOCS_VERSION;
132-
searchResults = await client.search(createSearchArguments(query, finalSearchedVersion));
172+
try {
173+
allHits = await performSearch(query, finalSearchedVersion);
174+
} catch (error) {
175+
logger.warn(
176+
`Error searching fallback Angular v${finalSearchedVersion} documentation: ${error}`,
177+
);
178+
}
133179
}
134180

135-
const allHits = searchResults?.results.flatMap((result) => (result as SearchResponse).hits);
136-
137181
if (!allHits?.length) {
138182
return {
139183
content: [
@@ -282,38 +326,3 @@ function formatHitToParts(hit: Record<string, unknown>): { title: string; breadc
282326

283327
return { title, breadcrumb };
284328
}
285-
286-
/**
287-
* Creates the search arguments for an Algolia search.
288-
*
289-
* The arguments are based on the search implementation in `adev`.
290-
*
291-
* @param query The search query string.
292-
* @returns The search arguments for the Algolia client.
293-
*/
294-
function createSearchArguments(query: string, version: number): LegacySearchMethodProps {
295-
// Search arguments are based on adev's search service:
296-
// https://github.com/angular/angular/blob/4b614fbb3263d344dbb1b18fff24cb09c5a7582d/adev/shared-docs/services/search.service.ts#L58
297-
return [
298-
{
299-
indexName: `angular_v${version}`,
300-
params: {
301-
query,
302-
attributesToRetrieve: [
303-
'hierarchy.lvl0',
304-
'hierarchy.lvl1',
305-
'hierarchy.lvl2',
306-
'hierarchy.lvl3',
307-
'hierarchy.lvl4',
308-
'hierarchy.lvl5',
309-
'hierarchy.lvl6',
310-
'content',
311-
'type',
312-
'url',
313-
],
314-
hitsPerPage: 10,
315-
},
316-
type: 'default',
317-
},
318-
];
319-
}
Collapse file
+107Lines changed: 107 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,107 @@
1+
/**
2+
* @license
3+
* Copyright Google LLC All Rights Reserved.
4+
*
5+
* Use of this source code is governed by an MIT-style license that can be
6+
* found in the LICENSE file at https://angular.dev/license
7+
*/
8+
9+
import { createMockContext } from '../testing/test-utils';
10+
import { DOC_SEARCH_TOOL } from './doc-search';
11+
12+
describe('Doc Search Tool', () => {
13+
let mockContext: ReturnType<typeof createMockContext>['context'];
14+
let fetchSpy: jasmine.Spy;
15+
16+
beforeEach(() => {
17+
const { context } = createMockContext();
18+
mockContext = context;
19+
20+
fetchSpy = spyOn(globalThis, 'fetch');
21+
});
22+
23+
it('should query the correct Algolia endpoint with headers and payload', async () => {
24+
fetchSpy.and.resolveTo(
25+
new Response(
26+
JSON.stringify({
27+
hits: [
28+
{
29+
hierarchy: {
30+
lvl0: 'Docs',
31+
lvl1: 'Standalone Components',
32+
},
33+
url: 'https://angular.dev/guide/standalone-components',
34+
},
35+
],
36+
}),
37+
{ status: 200, statusText: 'OK' },
38+
),
39+
);
40+
41+
const handler = DOC_SEARCH_TOOL.factory(mockContext);
42+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
43+
const result = await (handler as any)({
44+
query: 'standalone',
45+
version: 22,
46+
});
47+
48+
expect(fetchSpy).toHaveBeenCalledTimes(1);
49+
const [calledUrl, calledInit] = fetchSpy.calls.first().args;
50+
expect(calledUrl).toBe('https://L1XWT2UJ7F-dsn.algolia.net/1/indexes/angular_v22/query');
51+
expect(calledInit.method).toBe('POST');
52+
expect(calledInit.headers['X-Algolia-Application-Id']).toBe('L1XWT2UJ7F');
53+
expect(calledInit.headers['X-Algolia-API-Key']).toBeDefined();
54+
55+
const body = JSON.parse(calledInit.body);
56+
expect(body.query).toBe('standalone');
57+
58+
expect(result.structuredContent.searchedVersion).toBe(22);
59+
expect(result.structuredContent.results.length).toBe(1);
60+
expect(result.structuredContent.results[0].title).toBe('Standalone Components');
61+
});
62+
63+
it('should fallback to latest known version if initial search returns no hits', async () => {
64+
// First call returns empty hits
65+
fetchSpy.and.returnValues(
66+
Promise.resolve(
67+
new Response(JSON.stringify({ hits: [] }), { status: 200, statusText: 'OK' }),
68+
),
69+
// Second fallback call returns a hit
70+
Promise.resolve(
71+
new Response(
72+
JSON.stringify({
73+
hits: [
74+
{
75+
hierarchy: {
76+
lvl0: 'Docs',
77+
lvl1: 'Fallback Guide',
78+
},
79+
url: 'https://angular.dev/guide/fallback',
80+
},
81+
],
82+
}),
83+
{ status: 200, statusText: 'OK' },
84+
),
85+
),
86+
);
87+
88+
const handler = DOC_SEARCH_TOOL.factory(mockContext);
89+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
90+
const result = await (handler as any)({
91+
query: 'some-query',
92+
version: 24,
93+
});
94+
95+
expect(fetchSpy).toHaveBeenCalledTimes(2);
96+
expect(fetchSpy.calls.first().args[0]).toBe(
97+
'https://L1XWT2UJ7F-dsn.algolia.net/1/indexes/angular_v24/query',
98+
);
99+
expect(fetchSpy.calls.mostRecent().args[0]).toBe(
100+
'https://L1XWT2UJ7F-dsn.algolia.net/1/indexes/angular_v22/query',
101+
);
102+
103+
expect(result.structuredContent.searchedVersion).toBe(22);
104+
expect(result.structuredContent.results.length).toBe(1);
105+
expect(result.structuredContent.results[0].title).toBe('Fallback Guide');
106+
});
107+
});

0 commit comments

Comments
0 (0)
Morty Proxy This is a proxified and sanitized view of the page, visit original site.