Skip to content

Commit 740fd90

Browse 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 a2346bf commit 740fd90

5 files changed

Lines changed: 155 additions & 217 deletions

File tree

packages/angular/cli/BUILD.bazel

Lines 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",

packages/angular/cli/package.json

Lines 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.55.2",
2322
"jsonc-parser": "3.3.1",
2423
"listr2": "10.2.2",
2524
"npm-package-arg": "14.0.0",

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

Lines changed: 48 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,74 @@ 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'),
115-
);
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+
});
138+
139+
if (!response.ok) {
140+
throw new Error(`Search request failed: ${response.statusText}`);
116141
}
117142

143+
const data = (await response.json()) as { hits: Record<string, unknown>[] };
144+
145+
return data.hits;
146+
}
147+
148+
return async ({ query, includeTopContent, version }: DocSearchInput) => {
118149
let finalSearchedVersion = Math.max(
119150
version ?? LATEST_KNOWN_DOCS_VERSION,
120151
MIN_SUPPORTED_DOCS_VERSION,
121152
);
122-
let searchResults;
153+
154+
let allHits: Record<string, unknown>[] | undefined;
123155
try {
124-
searchResults = await client.search(createSearchArguments(query, finalSearchedVersion));
156+
allHits = await performSearch(query, finalSearchedVersion);
125157
} catch {}
126158

127159
// If the initial search for a newer-than-stable version returns no results, it may be because
128160
// the index for that version doesn't exist yet. In this case, fall back to the latest known
129161
// stable version.
130-
if (!searchResults && finalSearchedVersion > LATEST_KNOWN_DOCS_VERSION) {
162+
if ((!allHits || allHits.length === 0) && finalSearchedVersion > LATEST_KNOWN_DOCS_VERSION) {
131163
finalSearchedVersion = LATEST_KNOWN_DOCS_VERSION;
132-
searchResults = await client.search(createSearchArguments(query, finalSearchedVersion));
164+
try {
165+
allHits = await performSearch(query, finalSearchedVersion);
166+
} catch {}
133167
}
134168

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

283315
return { title, breadcrumb };
284316
}
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-
}
Lines 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)