Skip to content

Commit 67727fa

Browse files
committed
refactor(docs): use single docs command with meili multi search
1 parent e55f4d6 commit 67727fa

File tree

6 files changed

+254
-147
lines changed

6 files changed

+254
-147
lines changed

src/functions/autocomplete/docsAutoComplete.ts

Lines changed: 204 additions & 47 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,31 @@
1-
import process from 'node:process';
1+
import process, { versions } from 'node:process';
22
import type {
33
APIApplicationCommandInteractionDataOption,
44
APIApplicationCommandInteractionDataStringOption,
5-
APIApplicationCommandInteractionDataSubcommandOption,
65
} from 'discord-api-types/v10';
76
import { ApplicationCommandOptionType, InteractionResponseType } from 'discord-api-types/v10';
87
import type { Response } from 'polka';
9-
import { AUTOCOMPLETE_MAX_ITEMS } from '../../util/constants.js';
10-
import { getDjsVersions } from '../../util/djsdocs.js';
11-
import { logger } from '../../util/logger.js';
12-
import { queryDocs } from '../docs.js';
8+
import { AUTOCOMPLETE_MAX_ITEMS, DJS_QUERY_SEPARATOR } from '../../util/constants.js';
9+
import { getCurrentMainPackageVersion, getDjsVersions } from '../../util/djsdocs.js';
10+
import { truncate } from '../../util/truncate.js';
1311

12+
/**
13+
* Transform dotted versions into meili search compatible version keys, stripping unwanted characters
14+
* (^x.y.z -\> x-y-z)
15+
*
16+
* @param version - Dotted version string
17+
* @returns The meili search compatible version
18+
*/
19+
export function meiliVersion(version: string) {
20+
return version.replaceAll('^', '').split('.').join('-');
21+
}
22+
23+
/**
24+
* Dissect a discord.js documentation path into its parts
25+
*
26+
* @param path - The path to parse
27+
* @returns The path parts
28+
*/
1429
export function parseDocsPath(path: string) {
1530
// /0 /1 /2 /3 /4
1631
// /docs/packages/builders/main/EmbedBuilder:Class
@@ -36,32 +51,173 @@ export function parseDocsPath(path: string) {
3651
};
3752
}
3853

39-
function convertToDottedName(dashed: string) {
40-
return dashed.replaceAll('-', '.');
54+
const BASE_SEARCH = 'https://search.discordjs.dev/';
55+
56+
export const djsDocsDependencies = new Map<string, any>();
57+
58+
/**
59+
* Fetch the discord.js dependencies for a specific verison
60+
* Note: Tries to resolve from cache before hitting the API
61+
* Note: Information is resolved from the package.json file in the respective package root
62+
*
63+
* @param version - The version to retrieve dependencies for
64+
* @returns The package dependencies
65+
*/
66+
export async function fetchDjsDependencies(version: string) {
67+
const hit = djsDocsDependencies.get(version);
68+
const dependencies =
69+
hit ??
70+
(await fetch(`${process.env.DJS_BLOB_STORAGE_BASE}/rewrite/discord.js/${version}.dependencies.api.json`).then(
71+
async (res) => res.json(),
72+
));
73+
74+
if (!hit) {
75+
djsDocsDependencies.set(version, dependencies);
76+
}
77+
78+
return dependencies;
79+
}
80+
81+
/**
82+
* Fetch the version of a dependency based on a main package version and dependency package name
83+
*
84+
* @param mainPackageVersion - The main package version to use for dependencies
85+
* @param _package - The package to fetch the version for
86+
* @returns The version of the dependency package
87+
*/
88+
export async function fetchDependencyVersion(mainPackageVersion: string, _package: string) {
89+
const dependencies = await fetchDjsDependencies(mainPackageVersion);
90+
91+
const version = Object.entries(dependencies).find(([key, value]) => {
92+
if (typeof value !== 'string') return false;
93+
94+
const parts = key.split('/');
95+
const packageName = parts[1];
96+
return packageName === _package;
97+
})?.[1] as string | undefined;
98+
99+
return version?.replaceAll('^', '');
100+
}
101+
102+
/**
103+
* Build Meili search queries for the base package and all its dependencies as defined in the documentation
104+
*
105+
* @param query - The query term to use across packages
106+
* @param mainPackageVersion - The version to use across packages
107+
* @returns Meili query objects for the provided parameters
108+
*/
109+
export async function buildMeiliQueries(query: string, mainPackageVersion: string) {
110+
const dependencies = await fetchDjsDependencies(mainPackageVersion);
111+
const baseQuery = {
112+
// eslint-disable-next-line id-length -- Meili search denotes the query with a "q" key
113+
q: query,
114+
limit: 25,
115+
attributesToSearchOn: ['name'],
116+
sort: ['type:asc'],
117+
};
118+
119+
const queries = [
120+
{
121+
indexUid: `discord-js-${meiliVersion(mainPackageVersion)}`,
122+
...baseQuery,
123+
},
124+
];
125+
126+
for (const [dependencyPackageIdentifier, dependencyVersion] of Object.entries(dependencies)) {
127+
if (typeof dependencyVersion !== 'string') continue;
128+
129+
const packageName = dependencyPackageIdentifier.split('/')[1];
130+
const parts = [...packageName.split('.'), meiliVersion(dependencyVersion)];
131+
const indexUid = parts.join('-');
132+
133+
queries.push({
134+
indexUid,
135+
...baseQuery,
136+
});
137+
}
138+
139+
return queries;
140+
}
141+
142+
/**
143+
* Remove unwanted characters from autocomplete text
144+
*
145+
* @param text - The input to sanitize
146+
* @returns The sanitized text
147+
*/
148+
function sanitizeText(text: string) {
149+
return text.replaceAll('*', '');
150+
}
151+
152+
/**
153+
* Search the discord.js documentation using meilisearch multi package queries
154+
*
155+
* @param query - The query term to use across packages
156+
* @param version - The main package version to use
157+
* @returns Documentation results for the provided parameters
158+
*/
159+
export async function djsMeiliSearch(query: string, version: string) {
160+
const searchResult = await fetch(`${BASE_SEARCH}multi-search`, {
161+
method: 'post',
162+
body: JSON.stringify({
163+
queries: await buildMeiliQueries(query, version),
164+
}),
165+
headers: {
166+
'Content-Type': 'application/json',
167+
Authorization: `Bearer ${process.env.DJS_DOCS_BEARER!}`,
168+
},
169+
});
170+
171+
const docsResult = (await searchResult.json()) as any;
172+
const hits = docsResult.results.flatMap((res: any) => res.hits).sort((one: any, other: any) => one.id - other.id);
173+
174+
return {
175+
...docsResult,
176+
hits: hits.map((hit: any) => {
177+
const parsed = parseDocsPath(hit.path);
178+
const isMember = ['Property', 'Method', 'Event', 'PropertySignature', 'EnumMember'].includes(hit.kind);
179+
const parts = [parsed.package, parsed.item.toLocaleLowerCase(), parsed.kind];
180+
181+
if (isMember && parsed.method) {
182+
parts.push(parsed.method);
183+
}
184+
185+
return {
186+
...hit,
187+
autoCompleteName: truncate(`${hit.name}${hit.summary ? ` - ${sanitizeText(hit.summary)}` : ''}`, 100, ' '),
188+
autoCompleteValue: parts.join(DJS_QUERY_SEPARATOR),
189+
isMember,
190+
};
191+
}),
192+
};
41193
}
42194

195+
/**
196+
* Handle the command reponse for the discord.js docs command autocompletion
197+
*
198+
* @param res - Reponse to write
199+
* @param options - Command options
200+
* @returns The written response
201+
*/
43202
export async function djsAutoComplete(
44203
res: Response,
45204
options: APIApplicationCommandInteractionDataOption[],
46205
): Promise<Response> {
47-
const [option] = options;
48-
const interactionSubcommandData = option as APIApplicationCommandInteractionDataSubcommandOption;
49-
const queryOptionData = interactionSubcommandData.options?.find((option) => option.name === 'query') as
206+
res.setHeader('Content-Type', 'application/json');
207+
const defaultVersion = getCurrentMainPackageVersion();
208+
209+
const queryOptionData = options.find((option) => option.name === 'query') as
50210
| APIApplicationCommandInteractionDataStringOption
51211
| undefined;
52-
const versionOptionData = interactionSubcommandData.options?.find((option) => option.name === 'version') as
212+
const versionOptionData = options.find((option) => option.name === 'version') as
53213
| APIApplicationCommandInteractionDataStringOption
54214
| undefined;
55215

56-
const versions = getDjsVersions();
57-
res.setHeader('Content-Type', 'application/json');
58-
59216
if (!queryOptionData) {
60217
throw new Error('expected query option, none received');
61218
}
62219

63-
const version = versionOptionData?.value ?? versions.versions.get(convertToDottedName(option.name))?.at(1) ?? 'main';
64-
const docsResult = await queryDocs(queryOptionData.value, option.name, version);
220+
const docsResult = await djsMeiliSearch(queryOptionData.value, versionOptionData?.value ?? defaultVersion);
65221
const choices = [];
66222

67223
for (const hit of docsResult.hits) {
@@ -95,43 +251,37 @@ type DocsAutoCompleteData = {
95251
version: string;
96252
};
97253

98-
export function resolveOptionsToDocsAutoComplete(
254+
/**
255+
* Resolve the required options (with appropriate fallbacks) from the received command options
256+
*
257+
* @param options - The options to resolve
258+
* @returns Resolved options
259+
*/
260+
export async function resolveOptionsToDocsAutoComplete(
99261
options: APIApplicationCommandInteractionDataOption[],
100-
): DocsAutoCompleteData | undefined {
101-
const allversions = getDjsVersions();
102-
const [option] = options;
103-
const source = option.name;
104-
105-
const root = option as APIApplicationCommandInteractionDataSubcommandOption;
106-
if (!root.options) {
107-
return undefined;
108-
}
109-
110-
const versions = allversions.versions.get(convertToDottedName(source));
111-
262+
): Promise<DocsAutoCompleteData | undefined> {
112263
let query = 'Client';
113-
let version = versions?.at(1) ?? 'main';
114-
let ephemeral;
264+
let version = getCurrentMainPackageVersion();
265+
let ephemeral = false;
115266
let mention;
267+
let source = 'discord.js';
116268

117-
logger.debug(
118-
{
119-
data: {
120-
query,
121-
versions,
122-
version,
123-
ephemeral,
124-
mention,
125-
source,
126-
},
127-
},
128-
`Initial state before parsing options`,
129-
);
130-
131-
for (const opt of root.options) {
269+
for (const opt of options) {
132270
if (opt.type === ApplicationCommandOptionType.String) {
133271
if (opt.name === 'query' && opt.value.length) {
134272
query = opt.value;
273+
274+
if (query.includes(DJS_QUERY_SEPARATOR)) {
275+
source = query.split(DJS_QUERY_SEPARATOR)?.[0];
276+
} else {
277+
const searchResult = await djsMeiliSearch(query, version);
278+
const bestHit = searchResult.hits[0];
279+
280+
if (bestHit) {
281+
source = bestHit.autoCompleteValue.split(DJS_QUERY_SEPARATOR)[0];
282+
query = bestHit.autoCompleteValue;
283+
}
284+
}
135285
}
136286

137287
if (opt.name === 'version' && opt.value.length) {
@@ -144,6 +294,13 @@ export function resolveOptionsToDocsAutoComplete(
144294
}
145295
}
146296

297+
if (source !== 'discord.js') {
298+
const dependencyVersion = await fetchDependencyVersion(version, source);
299+
if (dependencyVersion) {
300+
version = dependencyVersion;
301+
}
302+
}
303+
147304
return {
148305
query,
149306
source,

0 commit comments

Comments
 (0)