1- import process from 'node:process' ;
1+ import process , { versions } from 'node:process' ;
22import type {
33 APIApplicationCommandInteractionDataOption ,
44 APIApplicationCommandInteractionDataStringOption ,
5- APIApplicationCommandInteractionDataSubcommandOption ,
65} from 'discord-api-types/v10' ;
76import { ApplicationCommandOptionType , InteractionResponseType } from 'discord-api-types/v10' ;
87import 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+ */
1429export 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+ */
43202export 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