diff --git a/.env.example b/.env.example index 6abdd58..c6a682b 100644 --- a/.env.example +++ b/.env.example @@ -8,7 +8,10 @@ DDOCS_ALGOLIA_KEY= DTYPES_ALGOLIA_APP= DTYPES_ALGOLIA_KEY= -DATABASE_URL= +ORAMA_KEY= +ORAMA_ID= +ORAMA_CONTAINER= + DJS_DOCS_BEARER= DISCORD_PUBKEY= @@ -16,5 +19,9 @@ DISCORD_CLIENT_ID= DISCORD_TOKEN= DISCORD_DEVGUILD_ID= -DJS_BLOB_STORAGE_BASE= -POSTGRES_URL= +CF_STORAGE_BASE= +CF_ACCOUNT_ID= +CF_D1_DOCS_ID= +CF_D1_DOCS_API_KEY= + +ENVIRONMENT=debug \ No newline at end of file diff --git a/.github/workflows/validate.yml b/.github/workflows/validate.yml index 7799280..3b835f4 100644 --- a/.github/workflows/validate.yml +++ b/.github/workflows/validate.yml @@ -6,15 +6,15 @@ jobs: runs-on: macos-latest steps: - name: Checkout repository - uses: actions/checkout@v3 + uses: actions/checkout@v4 - - name: Install node20 runtime + - name: Install node22 runtime uses: actions/setup-node@v4 with: - node-version: 20 + node-version: 22 - name: Install dependencies - run: rm -rf node_modules && yarn install --frozen-lockfile + run: rm -rf node_modules && yarn install - name: Run ESLint run: yarn lint diff --git a/.github/workflows/validateWithLinks.yml b/.github/workflows/validateWithLinks.yml index a53b105..b82127a 100644 --- a/.github/workflows/validateWithLinks.yml +++ b/.github/workflows/validateWithLinks.yml @@ -7,15 +7,15 @@ jobs: runs-on: macos-latest steps: - name: Checkout repository - uses: actions/checkout@v3 + uses: actions/checkout@v4 - - name: Install node20 runtime + - name: Install node22 runtime uses: actions/setup-node@v4 with: - node-version: 20 + node-version: 22 - name: Install dependencies - run: rm -rf node_modules && yarn install --frozen-lockfile + run: rm -rf node_modules && yarn install - name: Run ESLint run: yarn lint diff --git a/.vscode/tags.code-snippets b/.vscode/tags.code-snippets index 0f339ab..9eb3f8b 100644 --- a/.vscode/tags.code-snippets +++ b/.vscode/tags.code-snippets @@ -7,7 +7,7 @@ "description": "A new tag", "body": [ "[$1]", - "keywords = [\"$1\", \"$2\"]", + "keywords = [\"$2\"]", "content = \"\"\"", "$3", "\"\"\"", @@ -29,7 +29,7 @@ "l" ], "description": "Markdown link", - "body": "[$1](<$2>)" + "body": "[$1]($2)" }, "learnmore": { "prefix": [ @@ -37,6 +37,6 @@ "external" ], "description": "Markdown link for extended resources", - "body": "[learn more](<$2>)" + "body": "[learn more]($2)" } } \ No newline at end of file diff --git a/package.json b/package.json index c8ee660..d4d7945 100644 --- a/package.json +++ b/package.json @@ -4,14 +4,14 @@ "description": "Slash command utilities for the discord.js support server.", "scripts": { "build": "rimraf dist && tsc", - "start": "node dist/index.js", + "start": "node --enable-source-maps dist/index.js", "lint": "eslint src --ext .ts", "lint:fix": "eslint src --ext .ts --fix", "prettier": "prettier --write **/*.{ts,js,toml}", "cmd:glob": "env-cmd node dist/deployFunctions/deployGlobal.js", "cmd:dev": "env-cmd node dist/deployFunctions/deployDev.js", "validate-tags": "node dist/workflowFunctions/validateTagsWithoutLinks.js", - "validate-tags:withlinks": "snode dist/workflowFunctions/validateTagsWithLinks.js", + "validate-tags:withlinks": "node dist/workflowFunctions/validateTagsWithLinks.js", "prepare": "is-ci || husky install" }, "type": "module", @@ -33,8 +33,9 @@ "@discordjs/rest": "^2.3.0", "@hapi/boom": "^10.0.1", "@ltd/j-toml": "^1.38.0", - "@vercel/postgres": "^0.8.0", "algoliasearch": "^4.19.1", + "cheerio": "^1.0.0-rc.12", + "cloudflare": "^4.2.0", "discord-api-types": "^0.37.83", "dotenv": "^16.3.1", "he": "^1.2.0", @@ -45,7 +46,7 @@ "readdirp": "^3.6.0", "reflect-metadata": "^0.2.2", "turndown": "^7.1.2", - "undici": "^5.28.3" + "undici": "^6.21.2" }, "devDependencies": { "@commitlint/cli": "^17.7.1", @@ -62,7 +63,7 @@ "eslint-config-prettier": "^9.0.0", "eslint-plugin-prettier": "^5.0.0", "husky": "^8.0.3", - "lint-staged": "^14.0.1", + "lint-staged": "^15.2.10", "prettier": "^3.0.2", "prettier-plugin-toml": "^1.0.0", "rimraf": "^5.0.1", diff --git a/src/deployFunctions/auxtypes.ts b/src/deployFunctions/auxtypes.ts index 35b2285..f8cd6f1 100644 --- a/src/deployFunctions/auxtypes.ts +++ b/src/deployFunctions/auxtypes.ts @@ -1,10 +1,10 @@ -export enum PrerepeaseApplicationCommandContextType { +export enum PreReleaseApplicationCommandContextType { Guild, BotDm, PrivateChannel, } -export enum PrerepeaseApplicationIntegrationType { +export enum PreReleaseApplicationIntegrationType { GuildInstall, UserInstall, } diff --git a/src/deployFunctions/deploy.ts b/src/deployFunctions/deploy.ts index 3b60720..a87135c 100644 --- a/src/deployFunctions/deploy.ts +++ b/src/deployFunctions/deploy.ts @@ -5,7 +5,7 @@ import { config } from 'dotenv'; import { fetch } from 'undici'; import { API_BASE_DISCORD } from '../util/constants.js'; import { logger } from '../util/logger.js'; -import { PrerepeaseApplicationCommandContextType, PrerepeaseApplicationIntegrationType } from './auxtypes.js'; +import { PreReleaseApplicationCommandContextType, PreReleaseApplicationIntegrationType } from './auxtypes.js'; config({ path: resolve(dirname(fileURLToPath(import.meta.url)), '../../.env') }); @@ -27,15 +27,15 @@ export async function deploy(data: any, dev = false) { : data.map((command: any) => ({ ...command, integration_types: [ - PrerepeaseApplicationIntegrationType.UserInstall, - PrerepeaseApplicationIntegrationType.GuildInstall, + PreReleaseApplicationIntegrationType.UserInstall, + PreReleaseApplicationIntegrationType.GuildInstall, ], contexts: [ - PrerepeaseApplicationCommandContextType.Guild, - PrerepeaseApplicationCommandContextType.PrivateChannel, - PrerepeaseApplicationCommandContextType.BotDm, + PreReleaseApplicationCommandContextType.Guild, + PreReleaseApplicationCommandContextType.PrivateChannel, + PreReleaseApplicationCommandContextType.BotDm, ], - })), + })), ), }).then(async (response) => response.json()); logger.info(res as string); diff --git a/src/deployFunctions/deployGlobal.ts b/src/deployFunctions/deployGlobal.ts index 0834857..3787154 100644 --- a/src/deployFunctions/deployGlobal.ts +++ b/src/deployFunctions/deployGlobal.ts @@ -9,13 +9,14 @@ import { TagCommand } from '../interactions/tag.js'; import { TestTagCommand } from '../interactions/testtag.js'; import { deploy } from './deploy.js'; -void deploy([ +const staticGlobalCommands = [ DiscordDocsCommand, - DocsCommand, GuideCommand, MdnCommand, NodeCommand, TagCommand, TestTagCommand, DTypesCommand, -]); +]; + +void deploy([...staticGlobalCommands, DocsCommand]); diff --git a/src/functions/algoliaResponse.ts b/src/functions/algoliaResponse.ts index 10930f3..f5dbbd5 100644 --- a/src/functions/algoliaResponse.ts +++ b/src/functions/algoliaResponse.ts @@ -20,8 +20,9 @@ export async function algoliaResponse( algoliaObjectId: string, emojiId: string, emojiName: string, - target?: string, + user?: string, ephemeral?: boolean, + type = 'documentation', ): Promise { const full = `http://${algoliaAppId}.${API_BASE_ALGOLIA}/1/indexes/${algoliaIndex}/${encodeURIComponent( expandAlgoliaObjectId(algoliaObjectId), @@ -37,17 +38,21 @@ export async function algoliaResponse( }).then(async (res) => res.json())) as AlgoliaHit; const docsBody = hit.url.includes('discord.com') ? await fetchDocsBody(hit.url) : null; - const headlineSuffix = docsBody?.heading ? inlineCode(`${docsBody.heading.verb} ${docsBody.heading.route}`) : null; + const headlineSuffix = + docsBody?.heading?.verb && docsBody?.heading.route + ? inlineCode(`${docsBody.heading.verb} ${docsBody.heading.route}`.replaceAll('\\', '')) + : null; const contentParts = [ - target ? `${italic(`Suggestion for ${userMention(target)}:`)}` : null, `<:${emojiName}:${emojiId}> ${bold(resolveHitToNamestring(hit))}${headlineSuffix ? ` ${headlineSuffix}` : ''}`, - hit.content?.length ? `${truncate(decode(hit.content), 300)}` : null, - docsBody?.lines.length ? docsBody.lines.at(0) : null, + hit.content?.length ? `${truncate(decode(hit.content), 300)}` : docsBody?.lines.at(0), `${hyperlink('read more', hideLinkEmbed(hit.url))}`, ].filter(Boolean) as string[]; - prepareResponse(res, contentParts.join('\n'), ephemeral ?? false, target ? [target] : []); + prepareResponse(res, contentParts.join('\n'), { + ephemeral, + suggestion: user ? { userId: user, kind: type } : undefined, + }); } catch { prepareErrorResponse(res, 'Invalid result. Make sure to select an entry from the autocomplete.'); } diff --git a/src/functions/autocomplete/algoliaAutoComplete.ts b/src/functions/autocomplete/algoliaAutoComplete.ts index 163863e..d8ef8eb 100644 --- a/src/functions/autocomplete/algoliaAutoComplete.ts +++ b/src/functions/autocomplete/algoliaAutoComplete.ts @@ -5,7 +5,7 @@ import type { Response } from 'polka'; import { fetch } from 'undici'; import type { AlgoliaHit, AlgoliaSearchResult } from '../../types/algolia.js'; import { compactAlgoliaObjectId } from '../../util/compactAlgoliaId.js'; -import { API_BASE_ALGOLIA, AUTOCOMPLETE_MAX_ITEMS } from '../../util/constants.js'; +import { API_BASE_ALGOLIA, AUTOCOMPLETE_MAX_ITEMS, AUTOCOMPLETE_MAX_NAME_LENGTH } from '../../util/constants.js'; import { dedupeAlgoliaHits } from '../../util/dedupe.js'; import { prepareHeader } from '../../util/respond.js'; import { truncate } from '../../util/truncate.js'; @@ -14,33 +14,52 @@ function removeDtypesPrefix(str: string | null) { return (str ?? '').replace('discord-api-types/', ''); } +function compressHeading(heading: string) { + return heading.toLowerCase().replaceAll(/[ ,.=_-]/g, ''); +} + +function headingIsSimilar(one: string, other: string) { + const one_ = compressHeading(one); + const other_ = compressHeading(other); + + return one_.startsWith(other_) || other_.startsWith(one_); +} + export function resolveHitToNamestring(hit: AlgoliaHit) { const { hierarchy } = hit; - const hierarchyOneExtendsZero = (hierarchy.lvl1 ?? '').startsWith(hierarchy.lvl0 ?? ''); - - const lvl0 = removeDtypesPrefix(hierarchy.lvl0); - const lvl1 = removeDtypesPrefix(hierarchy.lvl1); + const [lvl0, lvl1, ...restLevels] = Object.values(hierarchy) + .filter(Boolean) + .map((heading) => removeDtypesPrefix(heading)); - let value = hierarchyOneExtendsZero ? lvl1 : `${lvl0}${lvl1 ? `: ${lvl1}` : ''}`; + const headingParts = []; - if (hierarchy.lvl2) { - value += ` - ${hierarchy.lvl2}`; + if (headingIsSimilar(lvl0, lvl1)) { + headingParts.push(lvl1); + } else { + headingParts.push(`${lvl0}:`, lvl1); } - if (hierarchy.lvl3) { - value += ` > ${hierarchy.lvl3}`; + const mostSpecific = restLevels.at(-1); + if (mostSpecific?.length && mostSpecific !== lvl0 && mostSpecific !== lvl1) { + headingParts.push(`- ${mostSpecific}`); } - return decode(value)!; + return decode(headingParts.join(' '))!; } function autoCompleteMap(elements: AlgoliaHit[]) { const uniqueElements = elements.filter(dedupeAlgoliaHits()); - return uniqueElements.map((element) => ({ - name: truncate(resolveHitToNamestring(element), 90, ''), - value: compactAlgoliaObjectId(element.objectID), - })); + return uniqueElements + .filter((element) => { + const value = compactAlgoliaObjectId(element.objectID); + // API restriction. Cannot resolve from truncated, so filtering here. + return value.length <= AUTOCOMPLETE_MAX_NAME_LENGTH; + }) + .map((element) => ({ + name: truncate(resolveHitToNamestring(element), AUTOCOMPLETE_MAX_NAME_LENGTH, ''), + value: compactAlgoliaObjectId(element.objectID), + })); } export async function algoliaAutoComplete( diff --git a/src/functions/autocomplete/docsAutoComplete.ts b/src/functions/autocomplete/docsAutoComplete.ts index a22a917..520c96e 100644 --- a/src/functions/autocomplete/docsAutoComplete.ts +++ b/src/functions/autocomplete/docsAutoComplete.ts @@ -1,24 +1,33 @@ -import process from 'node:process'; +import process, { versions } from 'node:process'; import type { APIApplicationCommandInteractionDataOption, APIApplicationCommandInteractionDataStringOption, - APIApplicationCommandInteractionDataSubcommandOption, } from 'discord-api-types/v10'; import { ApplicationCommandOptionType, InteractionResponseType } from 'discord-api-types/v10'; import type { Response } from 'polka'; -import { fetch } from 'undici'; -import { AUTOCOMPLETE_MAX_ITEMS } from '../../util/constants.js'; -import { getDjsVersions } from '../../util/djsdocs.js'; +import { AUTOCOMPLETE_MAX_ITEMS, AUTOCOMPLETE_MAX_NAME_LENGTH, DJS_QUERY_SEPARATOR } from '../../util/constants.js'; +import { getCurrentMainPackageVersion, getDjsVersions } from '../../util/djsdocs.js'; import { logger } from '../../util/logger.js'; import { truncate } from '../../util/truncate.js'; -const BASE_SEARCH = `https://search.discordjs.dev/`; - -function searchURL(pack: string, version: string) { - return `${BASE_SEARCH}indexes/${pack}-${version.replaceAll('.', '-')}/search`; +/** + * Transform dotted versions into meili search compatible version keys, stripping unwanted characters + * (^x.y.z -\> x-y-z) + * + * @param version - Dotted version string + * @returns The meili search compatible version + */ +export function meiliVersion(version: string) { + return version.replaceAll('^', '').split('.').join('-'); } -function parseDocsPath(path: string) { +/** + * Dissect a discord.js documentation path into its parts + * + * @param path - The path to parse + * @returns The path parts + */ +export function parseDocsPath(path: string) { // /0 /1 /2 /3 /4 // /docs/packages/builders/main/EmbedBuilder:Class // /docs/packages/builders/main/EmbedImageData:Interface#proxyURL @@ -43,76 +52,224 @@ function parseDocsPath(path: string) { }; } -export async function djsAutoComplete( - res: Response, - options: APIApplicationCommandInteractionDataOption[], -): Promise { - const [option] = options; - const interactionSubcommandData = option as APIApplicationCommandInteractionDataSubcommandOption; - const queryOptionData = interactionSubcommandData.options?.find((option) => option.name === 'query') as - | APIApplicationCommandInteractionDataStringOption - | undefined; - const versionOptionData = interactionSubcommandData.options?.find((option) => option.name === 'version') as - | APIApplicationCommandInteractionDataStringOption - | undefined; +const BASE_SEARCH = 'https://search.discordjs.dev/'; + +export const djsDocsDependencies = new Map(); + +/** + * Fetch the discord.js dependencies for a specific verison + * Note: Tries to resolve from cache before hitting the API + * Note: Information is resolved from the package.json file in the respective package root + * + * @param version - The version to retrieve dependencies for + * @returns The package dependencies + */ +export async function fetchDjsDependencies(version: string) { + const hit = djsDocsDependencies.get(version); + const url = `${process.env.CF_STORAGE_BASE}/discord.js/${version}.dependencies.api.json`; + logger.debug(`Requesting dependencies from CF: ${url}`); + const dependencies = hit ?? (await fetch(url).then(async (res) => res.json())); + + if (!hit) { + djsDocsDependencies.set(version, dependencies); + } - const versions = getDjsVersions(); - res.setHeader('Content-Type', 'application/json'); + return dependencies; +} - if (!queryOptionData) { - throw new Error('expected query option, none received'); +/** + * Fetch the version of a dependency based on a main package version and dependency package name + * + * @param mainPackageVersion - The main package version to use for dependencies + * @param _package - The package to fetch the version for + * @returns The version of the dependency package + */ +export async function fetchDependencyVersion(mainPackageVersion: string, _package: string) { + const dependencies = await fetchDjsDependencies(mainPackageVersion); + + const version = Object.entries(dependencies).find(([key, value]) => { + if (typeof value !== 'string') return false; + + const parts = key.split('/'); + const packageName = parts[1]; + return packageName === _package; + })?.[1] as string | undefined; + + return version?.replaceAll('^', ''); +} + +/** + * Build Meili search queries for the base package and all its dependencies as defined in the documentation + * + * @param query - The query term to use across packages + * @param mainPackageVersion - The version to use across packages + * @returns Meili query objects for the provided parameters + */ +export async function buildMeiliQueries(query: string, mainPackageVersion: string) { + const dependencies = await fetchDjsDependencies(mainPackageVersion); + const baseQuery = { + // eslint-disable-next-line id-length -- Meili search denotes the query with a "q" key + q: query, + limit: 25, + attributesToSearchOn: ['name'], + sort: ['type:asc'], + }; + + const queries = [ + { + indexUid: `discord-js-${meiliVersion(mainPackageVersion)}`, + ...baseQuery, + }, + ]; + + for (const [dependencyPackageIdentifier, dependencyVersion] of Object.entries(dependencies)) { + if (typeof dependencyVersion !== 'string') continue; + + const packageName = dependencyPackageIdentifier.split('/')[1]; + const parts = [...packageName.split('.'), meiliVersion(dependencyVersion)]; + const indexUid = parts.join('-'); + + queries.push({ + indexUid, + ...baseQuery, + }); } - const version = versionOptionData?.value ?? versions.versions.get(option.name)?.at(1) ?? 'main'; + queries.push({ + indexUid: 'voice-main', + ...baseQuery, + }); + + return queries; +} - const searchRes = await fetch(searchURL(option.name, version), { +/** + * Remove unwanted characters from autocomplete text + * + * @param text - The input to sanitize + * @returns The sanitized text + */ +function sanitizeText(text: string) { + return text.replaceAll('*', ''); +} + +/** + * Search the discord.js documentation using meilisearch multi package queries + * + * @param query - The query term to use across packages + * @param version - The main package version to use + * @returns Documentation results for the provided parameters + */ +export async function djsMeiliSearch(query: string, version: string) { + const searchResult = await fetch(`${BASE_SEARCH}multi-search`, { method: 'post', body: JSON.stringify({ - limit: 100, - // eslint-disable-next-line id-length - q: queryOptionData.value, + queries: await buildMeiliQueries(query, version), }), headers: { - Authorization: `Bearer ${process.env.DJS_DOCS_BEARER!}`, 'Content-Type': 'application/json', + Authorization: `Bearer ${process.env.DJS_DOCS_BEARER!}`, }, }); - const docsResult = (await searchRes.json()) as any; - docsResult.hits.sort((one: any, other: any) => { - const oneScore = one.kind === 'Class' ? 1 : 0; - const otherScore = other.kind === 'Class' ? 1 : 0; + const docsResult = (await searchResult.json()) as any; - return otherScore - oneScore; - }); + const groupedHits = new Map(); - const choices = []; + for (const result of docsResult.results) { + const index = result.indexUid; + for (const hit of result.hits) { + const current = groupedHits.get(hit.name); + if (!current) { + groupedHits.set(hit.name, [[index, hit]]); + continue; + } - for (const hit of docsResult.hits) { - if (choices.length >= AUTOCOMPLETE_MAX_ITEMS) { - break; + current.push([index, hit]); } + } - const parsed = parseDocsPath(hit.path); + const hits = []; - let name = ''; - const isMember = ['Property', 'Method', 'Event', 'PropertySignature', 'EnumMember'].includes(hit.kind); - if (isMember) { - name += `${parsed.item}#${hit.name}${hit.kind === 'Method' ? '()' : ''}`; - } else { - name += hit.name; - } + for (const group of groupedHits.values()) { + const sorted = group.sort(([fstIndex], [sndIndex]) => { + if (fstIndex.startsWith('discord-js')) { + return 1; + } - const itemKind = isMember ? 'Class' : hit.kind; - const parts = [parsed.package, parsed.item.toLocaleLowerCase(), parsed.kind]; + if (sndIndex.startsWith('discord.js')) { + return -1; + } + + return 0; + }); - if (isMember) { - parts.push(hit.name); + hits.push(sorted[0][1]); + } + + return { + ...docsResult, + hits: hits.map((hit: any) => { + const parsed = parseDocsPath(hit.path); + const isMember = ['Property', 'Method', 'Event', 'PropertySignature', 'EnumMember', 'MethodSignature'].includes( + hit.kind, + ); + const parts = [parsed.package, parsed.item.toLocaleLowerCase(), parsed.kind]; + + if (isMember && parsed.method) { + parts.push(parsed.method); + } + + return { + ...hit, + autoCompleteName: truncate( + `${hit.name}${hit.summary ? ` - ${sanitizeText(hit.summary)}` : ''}`, + AUTOCOMPLETE_MAX_NAME_LENGTH, + ' ', + ), + autoCompleteValue: parts.join(DJS_QUERY_SEPARATOR), + isMember, + }; + }), + }; +} + +/** + * Handle the command reponse for the discord.js docs command autocompletion + * + * @param res - Reponse to write + * @param options - Command options + * @returns The written response + */ +export async function djsAutoComplete( + res: Response, + options: APIApplicationCommandInteractionDataOption[], +): Promise { + res.setHeader('Content-Type', 'application/json'); + const defaultVersion = getCurrentMainPackageVersion(); + + const queryOptionData = options.find((option) => option.name === 'query') as + | APIApplicationCommandInteractionDataStringOption + | undefined; + const versionOptionData = options.find((option) => option.name === 'version') as + | APIApplicationCommandInteractionDataStringOption + | undefined; + + if (!queryOptionData) { + throw new Error('expected query option, none received'); + } + + const docsResult = await djsMeiliSearch(queryOptionData.value, versionOptionData?.value ?? defaultVersion); + const choices = []; + + for (const hit of docsResult.hits) { + if (choices.length >= AUTOCOMPLETE_MAX_ITEMS) { + break; } choices.push({ - name: truncate(`${name}${hit.summary ? ` - ${hit.summary}` : ''}`, 100, ' '), - value: parts.join('|'), + name: hit.autoCompleteName, + value: hit.autoCompleteValue, }); } @@ -130,46 +287,43 @@ export async function djsAutoComplete( type DocsAutoCompleteData = { ephemeral?: boolean; + mention?: string; query: string; source: string; version: string; }; -export function resolveOptionsToDocsAutoComplete( +/** + * Resolve the required options (with appropriate fallbacks) from the received command options + * + * @param options - The options to resolve + * @returns Resolved options + */ +export async function resolveOptionsToDocsAutoComplete( options: APIApplicationCommandInteractionDataOption[], -): DocsAutoCompleteData | undefined { - const allversions = getDjsVersions(); - const [option] = options; - const source = option.name; - - const root = option as APIApplicationCommandInteractionDataSubcommandOption; - if (!root.options) { - return undefined; - } - - const versions = allversions.versions.get(source.replaceAll('-', '.')); - +): Promise { let query = 'Client'; - let version = versions?.at(1) ?? 'main'; - let ephemeral; + let version = getCurrentMainPackageVersion(); + let ephemeral = false; + let mention; + let source = 'discord.js'; - logger.debug( - { - data: { - query, - versions, - version, - ephemeral, - source, - }, - }, - `Initial state before parsing options`, - ); - - for (const opt of root.options) { + for (const opt of options) { if (opt.type === ApplicationCommandOptionType.String) { if (opt.name === 'query' && opt.value.length) { query = opt.value; + + if (query.includes(DJS_QUERY_SEPARATOR)) { + source = query.split(DJS_QUERY_SEPARATOR)?.[0]; + } else { + const searchResult = await djsMeiliSearch(query, version); + const bestHit = searchResult.hits[0]; + + if (bestHit) { + source = bestHit.autoCompleteValue.split(DJS_QUERY_SEPARATOR)[0]; + query = bestHit.autoCompleteValue; + } + } } if (opt.name === 'version' && opt.value.length) { @@ -177,6 +331,17 @@ export function resolveOptionsToDocsAutoComplete( } } else if (opt.type === ApplicationCommandOptionType.Boolean && opt.name === 'hide') { ephemeral = opt.value; + } else if (opt.type === ApplicationCommandOptionType.User && opt.name === 'mention') { + mention = opt.value; + } + } + + if (source !== 'discord.js') { + const dependencyVersion = await fetchDependencyVersion(version, source); + if (dependencyVersion) { + version = dependencyVersion; + } else { + version = 'main'; } } @@ -185,5 +350,6 @@ export function resolveOptionsToDocsAutoComplete( source, ephemeral, version, + mention, }; } diff --git a/src/functions/autocomplete/mdnAutoComplete.ts b/src/functions/autocomplete/mdnAutoComplete.ts index b7c657a..b7eebc1 100644 --- a/src/functions/autocomplete/mdnAutoComplete.ts +++ b/src/functions/autocomplete/mdnAutoComplete.ts @@ -3,8 +3,9 @@ import { InteractionResponseType } from 'discord-api-types/v10'; import type { Response } from 'polka'; import type { MdnCommand } from '../../interactions/mdn.js'; import type { MDNIndexEntry } from '../../types/mdn.js'; -import { AUTOCOMPLETE_MAX_ITEMS } from '../../util/constants.js'; +import { AUTOCOMPLETE_MAX_ITEMS, AUTOCOMPLETE_MAX_NAME_LENGTH } from '../../util/constants.js'; import { transformInteraction } from '../../util/interactionOptions.js'; +import { truncate } from '../../util/truncate.js'; type MDNCandidate = { entry: MDNIndexEntry; @@ -12,7 +13,10 @@ type MDNCandidate = { }; function autoCompleteMap(elements: MDNCandidate[]) { - return elements.map((element) => ({ name: element.entry.title, value: element.entry.url })); + return elements.map((element) => ({ + name: truncate(element.entry.title, AUTOCOMPLETE_MAX_NAME_LENGTH, ''), + value: element.entry.url, + })); } export function mdnAutoComplete( diff --git a/src/functions/autocomplete/nodeAutoComplete.ts b/src/functions/autocomplete/nodeAutoComplete.ts new file mode 100644 index 0000000..aa80cd5 --- /dev/null +++ b/src/functions/autocomplete/nodeAutoComplete.ts @@ -0,0 +1,86 @@ +import process from 'node:process'; +import { stringify } from 'node:querystring'; +import { InteractionResponseType } from 'discord-api-types/v10'; +import type { Response } from 'polka'; +import { fetch } from 'undici'; +import { API_BASE_ORAMA, AUTOCOMPLETE_MAX_ITEMS, AUTOCOMPLETE_MAX_NAME_LENGTH } from '../../util/constants.js'; +import { prepareHeader } from '../../util/respond.js'; +import { truncate } from '../../util/truncate.js'; + +type OramaDocument = { + id: string; + pageSectionTitle: string; + pageTitle: string; + path: string; + siteSection: string; +}; + +type OramaHit = { + document: OramaDocument; + id: string; + score: number; +}; + +type OramaResult = { + count: number; + elapsed: { formatted: string; raw: number }; + facets: { siteSection: { count: number; values: { docs: number } } }; + hits: OramaHit[]; +}; + +function autoCompleteMap(elements: OramaDocument[]) { + return elements.map((element) => { + const cleanSectionTitle = element.pageSectionTitle.replaceAll('`', ''); + const name = truncate(`${element.pageTitle} > ${cleanSectionTitle}`, 90, ''); + if (element.path.length > AUTOCOMPLETE_MAX_NAME_LENGTH) { + return { + name: truncate(`[path too long] ${element.pageTitle} > ${cleanSectionTitle}`, AUTOCOMPLETE_MAX_NAME_LENGTH, ''), + value: element.pageTitle, + }; + } + + return { + name, + // we cannot use the full url with the node api base appended here, since discord only allows string values of length 100 + // some of `crypto` results are longer, if prefixed + value: element.path, + }; + }); +} + +export async function nodeAutoComplete(res: Response, query: string): Promise { + const full = `${API_BASE_ORAMA}/indexes/${process.env.ORAMA_CONTAINER}/search?api-key=${process.env.ORAMA_KEY}`; + + const result = (await fetch(full, { + method: 'post', + body: stringify({ + version: '1.3.2', + id: process.env.ORAMA_ID, + // eslint-disable-next-line id-length + q: JSON.stringify({ + term: query, + mode: 'fulltext', + limit: 25, + threshold: 0, + boost: { pageSectionTitle: 4, pageSectionContent: 2.5, pageTitle: 1.5 }, + facets: { siteSection: {} }, + returning: ['path', 'pageSectionTitle', 'pageTitle', 'path', 'siteSection'], + }), + }), + headers: { + 'Content-Type': 'application/x-www-form-urlencoded', + }, + }).then(async (res) => res.json())) as OramaResult; + + prepareHeader(res); + res.write( + JSON.stringify({ + data: { + choices: autoCompleteMap(result.hits?.slice(0, AUTOCOMPLETE_MAX_ITEMS - 1).map((hit) => hit.document) ?? []), + }, + type: InteractionResponseType.ApplicationCommandAutocompleteResult, + }), + ); + + return res; +} diff --git a/src/functions/docs.ts b/src/functions/docs.ts index 9c7e51d..cc79044 100644 --- a/src/functions/docs.ts +++ b/src/functions/docs.ts @@ -1,6 +1,5 @@ import process from 'node:process'; import { bold, codeBlock, hyperlink, inlineCode, strikethrough, underline } from '@discordjs/builders'; -import { InteractionResponseType } from 'discord-api-types/v10'; import type { Response } from 'polka'; import { fetch } from 'undici'; import { @@ -28,11 +27,11 @@ import { prepareErrorResponse, prepareResponse } from '../util/respond.js'; import { truncate } from '../util/truncate.js'; /** - * Vercel blob store format + * Bucket format * * Format: path/pkg/item * Item: branch.itemName.itemKind.api.json - * Example: https://bpwrdvqzqnllsihg.public.blob.vercel-storage.com/rewrite/discord.js/main.actionrow.class.api.json + * Key Example: discord.js/main.actionrow.class.api.json */ type CacheEntry = { @@ -42,22 +41,31 @@ type CacheEntry = { const docsCache = new Map(); +/** + * Fetch a documentation page for a specific query + * + * @param _package - The package name + * @param version - The package version + * @param itemName - The item name + * @param itemKind - The type of the item as per the docs API + * @returns The documentation item + */ export async function fetchDocItem( _package: string, - branch: string, + version: string, itemName: string, itemKind: string, ): Promise { try { - const key = `rewrite/${_package}/${branch}.${itemName}.${itemKind}`; + const key = `${_package}/${version}.${itemName}.${itemKind}`; const hit = docsCache.get(key); if (hit) { return hit.value; } - const resourceLink = `${process.env.DJS_BLOB_STORAGE_BASE!}/${key}.api.json`; - logger.debug(`Requesting documentation from vercel: ${resourceLink}`); + const resourceLink = `${process.env.CF_STORAGE_BASE!}/${key}.api.json`; + logger.debug(`Requesting documentation from CF: ${resourceLink}`); const value = await fetch(resourceLink).then(async (result) => result.json()); docsCache.set(key, { @@ -71,6 +79,13 @@ export async function fetchDocItem( } } +/** + * Resolve item kind to the respective Discord app emoji + * + * @param itemKind - The type of item as per the docs API + * @param dev - Whether the item is from the dev branch (main) + * @returns + */ function itemKindEmoji(itemKind: string, dev = false): [string, string] { const lowerItemKind = itemKind.toLowerCase(); switch (itemKind) { @@ -104,23 +119,48 @@ function itemKindEmoji(itemKind: string, dev = false): [string, string] { } } +/** + * Build a discord.js documentation link + * + * @param item - The item to generate the link for + * @param _package - The package name + * @param version - The package version + * @param attribute - The attribute to link to, if any + * @returns The formatted link + */ function docsLink(item: any, _package: string, version: string, attribute?: string) { return `${DJS_DOCS_BASE}/packages/${_package}/${version}/${item.displayName}:${item.kind}${ attribute ? `#${attribute}` : '' }`; } -function preparePotential(potential: any, member: any, topLevelDisplayName: string): any | null { +/** + * Enriches item members of type "method" with a dynamically generated displayName property + * + * @param potential - The item to check and enrich + * @param member - The member to access + * @param topLevelDisplayName - The display name of the top level parent + * @returns The enriched item + */ +function enrichItem(potential: any, member: any, topLevelDisplayName: string): any | null { + const isMethod = potential.kind === 'Method'; if (potential.displayName?.toLowerCase() === member.toLowerCase()) { return { ...potential, - displayName: `${topLevelDisplayName}#${potential.displayName}`, + displayName: `${topLevelDisplayName}#${potential.displayName}${isMethod ? '()' : ''}`, }; } return null; } +/** + * Resolve an items specific member, if required. + * + * @param item - The base item to check + * @param member - The name of the member to access + * @returns The relevant item + */ function effectiveItem(item: any, member?: string) { if (!member) { return item; @@ -129,7 +169,7 @@ function effectiveItem(item: any, member?: string) { const iterable = Array.isArray(item.members); if (Array.isArray(item.members)) { for (const potential of item.members) { - const hit = preparePotential(potential, member, item.displayName); + const hit = enrichItem(potential, member, item.displayName); if (hit) { return hit; } @@ -137,7 +177,7 @@ function effectiveItem(item: any, member?: string) { } else { for (const category of Object.values(item.members)) { for (const potential of category as any) { - const hit = preparePotential(potential, member, item.displayName); + const hit = enrichItem(potential, member, item.displayName); if (hit) { return hit; } @@ -148,11 +188,21 @@ function effectiveItem(item: any, member?: string) { return item; } +/** + * Format documentation blocks to a summary string + * + * @param blocks - The documentation blocks to format + * @param _package - The package name of the package the blocks belong to + * @param version - The version of the package the blocks belong to + * @returns The formatted summary string + */ function formatSummary(blocks: any[], _package: string, version: string) { return blocks .map((block) => { - if (block.kind === 'LinkTag') { - return hyperlink(block.text, `${DJS_DOCS_BASE}/packages/${_package}/${version}/${block.uri}`); + if (block.kind === 'LinkTag' && block.uri) { + const isFullLink = block.uri.startsWith('http'); + const link = isFullLink ? block.uri : `${DJS_DOCS_BASE}/packages/${_package}/${version}/${block.uri}`; + return hyperlink(block.members ? `${block.text}${block.members}` : block.text, link); } return block.text; @@ -160,10 +210,63 @@ function formatSummary(blocks: any[], _package: string, version: string) { .join(''); } +/** + * Format documentation blocks to a code example string + * + * @param blocks - The documentation blocks to format + * @returns The formatted code example string + */ +function formatExample(blocks?: any[]) { + const comments: string[] = []; + + if (!blocks) { + return; + } + + for (const block of blocks) { + if (block.kind === 'PlainText' && block.text.length) { + comments.push(`// ${block.text}`); + continue; + } + + if (block.kind === 'FencedCode') { + return codeBlock(block.language, `${comments.join('\n')}\n${block.text}`); + } + } +} + +/** + * Format the provided docs source item to a source link, if available + * + * @param item - The docs source item to format + * @param _package - The package to use + * @param version - The version to use + * @returns The formatted link, if available, otherwise the provided versionstring + */ +function formatSourceURL(item: any, _package: string, version: string) { + const sourceUrl = item.sourceURL; + const versionString = inlineCode(`${_package}@${version}`); + + if (!item.sourceURL?.startsWith('http')) { + return versionString; + } + + const link = `${sourceUrl}${item.sourceLine ? `#L${item.sourceLine}` : ''}`; + return hyperlink(versionString, link, 'source code'); +} + +/** + * Format a documentation item to a string + * + * @param _item - The docs item to format + * @param _package - The package name of the packge the item belongs to + * @param version - The version of the package the item belongs to + * @param member - The specific item member to access, if any + * @returns The formatted documentation string for the provided item + */ function formatItem(_item: any, _package: string, version: string, member?: string) { const itemLink = docsLink(_item, _package, version, member); const item = effectiveItem(_item, member); - const sourceUrl = `${item.sourceURL}#L${item.sourceLine}`; const [emojiId, emojiName] = itemKindEmoji(item.kind, version === 'main'); @@ -179,19 +282,17 @@ function formatItem(_item: any, _package: string, version: string, member?: stri parts.push(underline(bold(hyperlink(item.displayName, itemLink)))); - if (item.extends) { - // TODO format extends - } - const head = `<:${emojiName}:${emojiId}>`; - const tail = ` ${hyperlink(inlineCode(`@${version}`), sourceUrl, 'source code')}`; + const tail = formatSourceURL(item, _package, version); const middlePart = item.isDeprecated ? strikethrough(parts.join(' ')) : parts.join(' '); const lines: string[] = [[head, middlePart, tail].join(' ')]; const summary = item.summary?.summarySection; + const defaultValueBlock = item.summary?.defaultValueBlock; const deprecationNote = item.summary?.deprecatedBlock; - const example = item.summary?.exampleBlocks?.[0]; + const example = formatExample(item.summary?.exampleBlocks); + const defaultValue = defaultValueBlock ? formatSummary(defaultValueBlock, _package, version) : null; if (deprecationNote?.length) { lines.push(`${bold('[DEPRECATED]')} ${formatSummary(deprecationNote, _package, version)}`); @@ -201,31 +302,35 @@ function formatItem(_item: any, _package: string, version: string, member?: stri } if (example) { - lines.push(codeBlock(example.language, example.text)); + lines.push(example); } } + if (defaultValue?.length) { + lines.push(`Default value: ${inlineCode(defaultValue)}`); + } + return lines.join('\n'); } -export async function djsDocs(res: Response, branch: string, query: string, ephemeral = false) { - const [_package, itemName, itemKind, member] = query.split('|'); - +export async function djsDocs(res: Response, version: string, query: string, user?: string, ephemeral?: boolean) { try { - const item = await fetchDocItem(_package, branch, itemName, itemKind.toLowerCase()); + if (!query) { + prepareErrorResponse(res, 'Cannot find any hits for the provided query - consider using auto complete.'); + return res.end(); + } + + const [_package, itemName, itemKind, member] = query.split('|'); + const item = await fetchDocItem(_package, version, itemName, itemKind.toLowerCase()); if (!item) { prepareErrorResponse(res, `Could not fetch doc entry for query ${inlineCode(query)}.`); return res.end(); } - prepareResponse( - res, - truncate(formatItem(item, _package, branch, member), MAX_MESSAGE_LENGTH), + prepareResponse(res, truncate(formatItem(item, _package, version, member), MAX_MESSAGE_LENGTH), { ephemeral, - [], - [], - InteractionResponseType.ChannelMessageWithSource, - ); + suggestion: user ? { userId: user, kind: 'documentation' } : undefined, + }); return res.end(); } catch (_error) { const error = _error as Error; diff --git a/src/functions/mdn.ts b/src/functions/mdn.ts index 52ed4e8..09751af 100644 --- a/src/functions/mdn.ts +++ b/src/functions/mdn.ts @@ -11,7 +11,7 @@ function escape(text: string) { return text.replaceAll('||', '|\u200B|').replaceAll('*', '\\*'); } -export async function mdnSearch(res: Response, query: string, target?: string, ephemeral?: boolean): Promise { +export async function mdnSearch(res: Response, query: string, user?: string, ephemeral?: boolean): Promise { const trimmedQuery = query.trim(); try { const qString = `${API_BASE_MDN}/${trimmedQuery}/index.json`; @@ -41,12 +41,10 @@ export async function mdnSearch(res: Response, query: string, target?: string, e intro, ]; - prepareResponse( - res, - `${target ? `${italic(`Documentation suggestion for ${userMention(target)}:`)}\n` : ''}${parts.join('\n')}`, - ephemeral ?? false, - target ? [target] : [], - ); + prepareResponse(res, parts.join('\n'), { + ephemeral, + suggestion: user ? { userId: user, kind: 'documentation' } : undefined, + }); return res; } catch (error) { diff --git a/src/functions/modals/testTagModalSubmit.ts b/src/functions/modals/testTagModalSubmit.ts index 694eb69..fef28ad 100644 --- a/src/functions/modals/testTagModalSubmit.ts +++ b/src/functions/modals/testTagModalSubmit.ts @@ -107,8 +107,8 @@ export async function testTagModalSubmit(res: Response, message: APIModalSubmitI color: hasErrors ? VALIDATION_FAIL_COLOR : hasWarnings - ? VALIDATION_WARNING_COLOR - : VALIDATION_SUCCESS_COLOR, + ? VALIDATION_WARNING_COLOR + : VALIDATION_SUCCESS_COLOR, description: [ `**Name:** \`${parsedTag.name}\``, `**Keywords:** ${parsedTag.body.keywords.map((key) => `\`${key}\``).join(', ')}`, @@ -124,7 +124,7 @@ export async function testTagModalSubmit(res: Response, message: APIModalSubmitI type: ComponentType.ActionRow, components: buttons, }, - ] + ] : [], }, }, diff --git a/src/functions/node.ts b/src/functions/node.ts index db1affb..ec46633 100644 --- a/src/functions/node.ts +++ b/src/functions/node.ts @@ -2,14 +2,19 @@ /* eslint-disable @typescript-eslint/no-unsafe-member-access */ /* eslint-disable @typescript-eslint/no-unsafe-assignment */ +import { URL } from 'node:url'; import { bold, hideLinkEmbed, hyperlink, inlineCode, italic, underscore, userMention } from '@discordjs/builders'; +import * as cheerio from 'cheerio'; import type { Response } from 'polka'; import TurndownService from 'turndown'; import { fetch } from 'undici'; import type { NodeDocs } from '../types/NodeDocs.js'; -import { API_BASE_NODE, EMOJI_ID_NODE } from '../util/constants.js'; +import { API_BASE_NODE, AUTOCOMPLETE_MAX_NAME_LENGTH, EMOJI_ID_NODE } from '../util/constants.js'; import { logger } from '../util/logger.js'; +import { toTitlecase } from '../util/misc.js'; import { prepareErrorResponse, prepareResponse } from '../util/respond.js'; +import { truncate } from '../util/truncate.js'; +import { urlOption } from '../util/url.js'; const td = new TurndownService({ codeBlockStyle: 'fenced' }); @@ -66,28 +71,103 @@ function docsUrl(version: string, source: string, anchorTextRaw: string) { return `${API_BASE_NODE}/docs/${version}/api/${parsePageFromSource(source)}.html#${formatAnchorText(anchorTextRaw)}`; } -const cache: Map = new Map(); +const jsonCache: Map = new Map(); +const docsCache: Map = new Map(); + +export async function nodeAutoCompleteResolve(res: Response, query: string, user?: string, ephemeral?: boolean) { + const url = urlOption(`${API_BASE_NODE}/${query}`); + + if (!url || !query.startsWith('docs')) { + return nodeSearch(res, query, undefined, user, ephemeral); + } + + const key = `${url.origin}${url.pathname}`; + let html = docsCache.get(key); + + if (!html) { + const data = await fetch(url.toString()).then(async (response) => response.text()); + docsCache.set(key, data); + html = data; + } + + const $ = cheerio.load(html); + + const possible = ['h1', 'h2', 'h3', 'h4', 'h5', 'h6']; + + const headingBaseSelectorParts = possible.map((prefix) => `${prefix}:has(${url.hash})`); + const heaidngSelector = headingBaseSelectorParts.join(', '); + const headingCodeSelector = headingBaseSelectorParts.map((part) => `${part} > code`).join(', '); + const paragraphSelector = headingBaseSelectorParts.join(', '); + + const heading = $(heaidngSelector).text().replaceAll('#', ''); + const headingCode = $(headingCodeSelector).text(); + const paragraph = $(paragraphSelector).nextUntil('h4', 'p'); + + const text = paragraph.text().split('\n').join(' '); + const sentence = text.split(/[!.?](\s|$)/)?.[0]; + const effectiveSentence = (sentence ?? truncate(text, AUTOCOMPLETE_MAX_NAME_LENGTH, '')).trim(); + + const contentParts = [ + `<:node:${EMOJI_ID_NODE}> ${hyperlink(inlineCode(headingCode.length ? headingCode : heading), url.toString())}`, + ]; + + if (effectiveSentence.length) { + contentParts.push(`${effectiveSentence}.`); + } + + prepareResponse(res, contentParts.join('\n'), { + ephemeral, + suggestion: user ? { userId: user, kind: 'documentation' } : undefined, + }); + + return res; +} + +function parsePathToPhrase(path: string) { + const [head, tail] = path.split('#'); + const _headPart = head?.length ? head.replaceAll('-', ' ') : undefined; + const headPart = _headPart?.split('/').at(-1); + const tailPart = tail?.length ? tail.replaceAll('-', ' ') : undefined; + + const parts: string[] = []; + if (headPart) { + parts.push(toTitlecase(headPart)); + } + + if (tailPart) { + parts.push(toTitlecase(tailPart)); + } + + return parts.join(' > '); +} export async function nodeSearch( res: Response, query: string, - version = 'latest-v18.x', - target?: string, + version = 'latest-v20.x', + user?: string, ephemeral?: boolean, ): Promise { const trimmedQuery = query.trim(); try { const url = `${API_BASE_NODE}/dist/${version}/docs/api/all.json`; - let allNodeData = cache.get(url); + let allNodeData = jsonCache.get(url); + + if (!query.startsWith('docs')) { + prepareResponse( + res, + `<:node:${EMOJI_ID_NODE}> ${bold('Learn more about Node.js:')}\n${hyperlink(parsePathToPhrase(trimmedQuery), `${API_BASE_NODE}/en/${trimmedQuery}`)}`, + { + ephemeral, + suggestion: user ? { userId: user, kind: 'documentation' } : undefined, + }, + ); + return res; + } if (!allNodeData) { - // Get the data for this version const data = (await fetch(url).then(async (response) => response.json())) as NodeDocs; - - // Set it to the map for caching - cache.set(url, data); - - // Set the local parameter for further processing + jsonCache.set(url, data); allNodeData = data; } @@ -117,12 +197,10 @@ export async function nodeSearch( .replaceAll(boldCodeBlockRegex, bold(inlineCode('$1'))), ); - prepareResponse( - res, - `${target ? `${italic(`Documentation suggestion for ${userMention(target)}:`)}\n` : ''}${parts.join('\n')}`, - ephemeral ?? false, - target ? [target] : [], - ); + prepareResponse(res, parts.join('\n'), { + ephemeral, + suggestion: user ? { userId: user, kind: 'documentation' } : undefined, + }); return res; } catch (error) { diff --git a/src/functions/tag.ts b/src/functions/tag.ts index 2c8fb83..c421939 100644 --- a/src/functions/tag.ts +++ b/src/functions/tag.ts @@ -1,4 +1,4 @@ -import { readFileSync } from 'node:fs'; +import { readFile } from 'node:fs/promises'; import { dirname, join } from 'node:path'; import { fileURLToPath } from 'node:url'; import type { Collection } from '@discordjs/collection'; @@ -9,7 +9,6 @@ import { fetch } from 'undici'; import { REMOTE_TAG_URL, PREFIX_SUCCESS } from '../util/constants.js'; import { logger } from '../util/logger.js'; import { prepareResponse, prepareErrorResponse } from '../util/respond.js'; -import { suggestionString } from '../util/suggestionString.js'; export type Tag = { content: string; @@ -39,7 +38,7 @@ export async function loadTags(tagCache: Collection, remote = false logger.error(error, error.message); return `# ${error.message}`; }) - : readFileSync(dir.fullPath, { encoding: 'utf8' }); + : await readFile(dir.fullPath, { encoding: 'utf8' }); parts.push(file); } @@ -49,11 +48,11 @@ export async function loadTags(tagCache: Collection, remote = false } } -export function findTag(tagCache: Collection, query: string, target?: string): string | null { +export function findTag(tagCache: Collection, query: string): string | null { const cleanQuery = query.replaceAll(/\s+/g, '-'); const tag = tagCache.get(cleanQuery) ?? tagCache.find((tag) => tag.keywords.includes(cleanQuery)); if (!tag) return null; - return suggestionString('tag', tag.content, target); + return tag.content; } export async function reloadTags(res: Response, tagCache: Collection, remote = true) { @@ -67,7 +66,7 @@ export async function reloadTags(res: Response, tagCache: Collection, - target?: string, + user?: string, ephemeral?: boolean, ): Response { const trimmedQuery = query.trim().toLowerCase(); - const content = findTag(tagCache, trimmedQuery, target); + const content = findTag(tagCache, trimmedQuery); + if (content) { - prepareResponse(res, content, ephemeral ?? false, target ? [target] : []); + prepareResponse(res, content, { ephemeral, suggestion: user ? { userId: user, kind: 'tag' } : undefined }); } else { prepareErrorResponse(res, `Could not find a tag with name or alias similar to \`${trimmedQuery}\`.`); } diff --git a/src/handling/handleApplicationCommand.ts b/src/handling/handleApplicationCommand.ts index 9132f8f..be8180c 100644 --- a/src/handling/handleApplicationCommand.ts +++ b/src/handling/handleApplicationCommand.ts @@ -1,32 +1,42 @@ import process from 'node:process'; -import { hideLinkEmbed, hyperlink, inlineCode } from '@discordjs/builders'; import type { Collection } from '@discordjs/collection'; import type { APIApplicationCommandInteraction } from 'discord-api-types/v10'; import { ApplicationCommandType } from 'discord-api-types/v10'; import type { Response } from 'polka'; -import { container } from 'tsyringe'; +import { deploy } from '../deployFunctions/deploy.js'; import { algoliaResponse } from '../functions/algoliaResponse.js'; import { resolveOptionsToDocsAutoComplete } from '../functions/autocomplete/docsAutoComplete.js'; import { djsDocs } from '../functions/docs.js'; import { mdnSearch } from '../functions/mdn.js'; -import { nodeSearch } from '../functions/node.js'; +import { nodeAutoCompleteResolve } from '../functions/node.js'; import type { Tag } from '../functions/tag.js'; import { showTag, reloadTags } from '../functions/tag.js'; import { testTag } from '../functions/testtag.js'; -import type { DiscordDocsCommand } from '../interactions/discorddocs.js'; -import type { DTypesCommand } from '../interactions/discordtypes.js'; -import type { GuideCommand } from '../interactions/guide.js'; -import type { MdnCommand } from '../interactions/mdn.js'; -import type { NodeCommand } from '../interactions/node.js'; -import type { TagCommand } from '../interactions/tag.js'; +import { DiscordDocsCommand } from '../interactions/discorddocs.js'; +import { DTypesCommand } from '../interactions/discordtypes.js'; +import { buildDocsCommand, DocsCommand } from '../interactions/docs.js'; +import { GuideCommand } from '../interactions/guide.js'; +import { MdnCommand } from '../interactions/mdn.js'; +import { NodeCommand } from '../interactions/node.js'; +import { TagCommand } from '../interactions/tag.js'; import type { TagReloadCommand } from '../interactions/tagreload.js'; -import type { TestTagCommand } from '../interactions/testtag.js'; +import { TestTagCommand } from '../interactions/testtag.js'; import type { ArgumentsOf } from '../util/argumentsOf.js'; import { EMOJI_ID_CLYDE_BLURPLE, EMOJI_ID_DTYPES, EMOJI_ID_GUIDE } from '../util/constants.js'; -import { fetchDjsVersions, kDjsVersions } from '../util/djsdocs.js'; +import { reloadDjsVersions } from '../util/djsdocs.js'; import { transformInteraction } from '../util/interactionOptions.js'; import { prepareErrorResponse, prepareResponse } from '../util/respond.js'; +const staticGlobalCommands = [ + DiscordDocsCommand, + GuideCommand, + MdnCommand, + NodeCommand, + TagCommand, + TestTagCommand, + DTypesCommand, +]; + type CommandName = | 'discorddocs' | 'docs' @@ -52,14 +62,14 @@ export async function handleApplicationCommand( switch (name) { case 'docs': { - const resolved = resolveOptionsToDocsAutoComplete(options); + const resolved = await resolveOptionsToDocsAutoComplete(options); if (!resolved) { prepareErrorResponse(res, `Payload looks different than expected`); break; } - const { query, version, ephemeral } = resolved; - await djsDocs(res, version, query, ephemeral); + const { query, version, ephemeral, mention } = resolved; + await djsDocs(res, version, query, mention, ephemeral); break; } @@ -73,7 +83,7 @@ export async function handleApplicationCommand( castArgs.query, EMOJI_ID_CLYDE_BLURPLE, 'discord', - castArgs.target, + castArgs.mention, castArgs.hide, ); break; @@ -81,7 +91,6 @@ export async function handleApplicationCommand( case 'dtypes': { const castArgs = args as ArgumentsOf; - await algoliaResponse( res, process.env.DTYPES_ALGOLIA_APP!, @@ -90,7 +99,7 @@ export async function handleApplicationCommand( castArgs.query, EMOJI_ID_DTYPES, 'dtypes', - castArgs.target, + castArgs.mention, castArgs.hide, ); @@ -107,27 +116,28 @@ export async function handleApplicationCommand( castArgs.query, EMOJI_ID_GUIDE, 'guide', - castArgs.target, + castArgs.mention, castArgs.hide, + 'guide', ); break; } case 'mdn': { const castArgs = args as ArgumentsOf; - await mdnSearch(res, castArgs.query, castArgs.target, castArgs.hide); + await mdnSearch(res, castArgs.query, castArgs.mention, castArgs.hide); break; } case 'node': { const castArgs = args as ArgumentsOf; - await nodeSearch(res, castArgs.query, castArgs.version, castArgs.target, castArgs.hide); + await nodeAutoCompleteResolve(res, castArgs.query, castArgs.mention, castArgs.hide); break; } case 'tag': { const castArgs = args as ArgumentsOf; - showTag(res, castArgs.query, tagCache, castArgs.target, castArgs.hide); + showTag(res, castArgs.query, tagCache, castArgs.mention, castArgs.hide); break; } @@ -144,10 +154,17 @@ export async function handleApplicationCommand( } case 'reloadversions': { - const versions = await fetchDjsVersions(); - container.register(kDjsVersions, { useValue: res }); + const versions = await reloadDjsVersions(); + const updatedDocsCommand = await buildDocsCommand(versions); + await deploy([...staticGlobalCommands, updatedDocsCommand]); - prepareResponse(res, `Reloaded versions for all ${inlineCode('@discordjs')} packages.`, true); + prepareResponse( + res, + "Reloaded versions for all supported packages (dependency of discord.js).\n-# Don't forget to refresh your client, so all changes take effect!", + { + ephemeral: true, + }, + ); break; } } diff --git a/src/handling/handleApplicationCommandAutocomplete.ts b/src/handling/handleApplicationCommandAutocomplete.ts index 56d42a3..22336b9 100644 --- a/src/handling/handleApplicationCommandAutocomplete.ts +++ b/src/handling/handleApplicationCommandAutocomplete.ts @@ -5,14 +5,16 @@ import type { Response } from 'polka'; import { algoliaAutoComplete } from '../functions/autocomplete/algoliaAutoComplete.js'; import { djsAutoComplete } from '../functions/autocomplete/docsAutoComplete.js'; import { mdnAutoComplete } from '../functions/autocomplete/mdnAutoComplete.js'; +import { nodeAutoComplete } from '../functions/autocomplete/nodeAutoComplete.js'; import { tagAutoComplete } from '../functions/autocomplete/tagAutoComplete.js'; import type { Tag } from '../functions/tag.js'; import type { DTypesCommand } from '../interactions/discordtypes.js'; import type { GuideCommand } from '../interactions/guide.js'; +import type { NodeCommand } from '../interactions/node.js'; import type { MDNIndexEntry } from '../types/mdn.js'; import { transformInteraction } from '../util/interactionOptions.js'; -type CommandAutoCompleteName = 'discorddocs' | 'docs' | 'dtypes' | 'guide' | 'mdn' | 'tag'; +type CommandAutoCompleteName = 'discorddocs' | 'docs' | 'dtypes' | 'guide' | 'mdn' | 'node' | 'tag'; export async function handleApplicationCommandAutocomplete( res: Response, @@ -23,6 +25,12 @@ export async function handleApplicationCommandAutocomplete( const data = message.data; const name = data.name as CommandAutoCompleteName; switch (name) { + case 'node': { + const args = transformInteraction(data.options); + await nodeAutoComplete(res, args.query); + break; + } + case 'docs': { await djsAutoComplete(res, data.options); break; diff --git a/src/index.ts b/src/index.ts index 2c4eb8a..9a24c1c 100644 --- a/src/index.ts +++ b/src/index.ts @@ -15,7 +15,7 @@ import { handleComponent } from './handling/handleComponents.js'; import { handleModalSubmit } from './handling/handleModalSubmit.js'; import type { MDNIndexEntry } from './types/mdn.js'; import { API_BASE_MDN, PREFIX_TEAPOT, PREFIX_BUG } from './util/constants.js'; -import { prepareDjsVersions } from './util/djsdocs.js'; +import { reloadDjsVersions } from './util/djsdocs.js'; import { jsonParser } from './util/jsonParser.js'; import { logger } from './util/logger.js'; import { prepareAck, prepareResponse } from './util/respond.js'; @@ -71,7 +71,7 @@ const tagCache = new Collection(); const mdnIndexCache: MDNIndexEntry[] = []; await loadTags(tagCache); logger.info(`Tag cache loaded with ${tagCache.size} entries.`); -await prepareDjsVersions(); +await reloadDjsVersions(); export async function start() { const mdnData = (await fetch(`${API_BASE_MDN}/en-US/search-index.json`) @@ -104,11 +104,13 @@ export async function start() { break; default: - prepareResponse(res, `${PREFIX_TEAPOT} This shouldn't be here...`, true); + prepareResponse(res, `${PREFIX_TEAPOT} This shouldn't be here...`, { ephemeral: true }); } } catch (error) { logger.error(error as Error); - prepareResponse(res, `${PREFIX_BUG} Looks like something went wrong here, please try again later!`, true); + prepareResponse(res, `${PREFIX_BUG} Looks like something went wrong here, please try again later!`, { + ephemeral: true, + }); } res.end(); @@ -122,7 +124,6 @@ process.on('uncaughtException', (err, origin) => { }); process.on('unhandledRejection', (reason, promise) => { - // eslint-disable-next-line no-console logger.error('Unhandled Rejection at:', promise, 'reason:', reason); }); diff --git a/src/interactions/discorddocs.ts b/src/interactions/discorddocs.ts index f50f606..4664a19 100644 --- a/src/interactions/discorddocs.ts +++ b/src/interactions/discorddocs.ts @@ -11,17 +11,17 @@ export const DiscordDocsCommand = { autocomplete: true, required: true, }, - { - type: ApplicationCommandOptionType.User, - name: 'target', - description: 'User to mention', - required: false, - }, { type: ApplicationCommandOptionType.Boolean, name: 'hide', description: 'Hide command output', required: false, }, + { + type: ApplicationCommandOptionType.User, + name: 'mention', + description: 'User to mention', + required: false, + }, ], } as const; diff --git a/src/interactions/discordtypes.ts b/src/interactions/discordtypes.ts index fac0041..6aa049a 100644 --- a/src/interactions/discordtypes.ts +++ b/src/interactions/discordtypes.ts @@ -1,7 +1,6 @@ import { ApplicationCommandOptionType } from 'discord-api-types/v10'; const QUERY_DESCRIPTION = 'Type, Enum or Interface to search for' as const; -const TARGET_DESCRIPTION = 'User to mention' as const; const VERSION_DESCRIPTION = 'Attempts to filter the results to the specified version' as const; const EPHEMERAL_DESCRIPTION = 'Hide command output' as const; @@ -16,12 +15,6 @@ export const DTypesCommand = { required: true, autocomplete: true, }, - { - type: ApplicationCommandOptionType.User, - name: 'target', - description: TARGET_DESCRIPTION, - required: false, - }, { type: ApplicationCommandOptionType.String, name: 'version', @@ -52,5 +45,11 @@ export const DTypesCommand = { description: EPHEMERAL_DESCRIPTION, required: false, }, + { + type: ApplicationCommandOptionType.User, + name: 'mention', + description: 'User to mention', + required: false, + }, ], } as const; diff --git a/src/interactions/docs.ts b/src/interactions/docs.ts index 7cde686..74495cd 100644 --- a/src/interactions/docs.ts +++ b/src/interactions/docs.ts @@ -1,61 +1,53 @@ import { ApplicationCommandOptionType } from 'discord-api-types/v10'; -import { prepareDjsVersions } from '../util/djsdocs.js'; +import { type DjsVersions, reloadDjsVersions } from '../util/djsdocs.js'; -const versions = await prepareDjsVersions(); -if (!versions.packages.length) { - throw new Error('Error while loading versions'); -} - -function buildSubCommandOptions(name: string) { - return [ - { - type: ApplicationCommandOptionType.String, - name: 'query', - description: 'Phrase to search for', - required: true, - autocomplete: true, - }, - { - type: ApplicationCommandOptionType.Boolean, - name: 'hide', - description: 'Hide command output (default: False)', - required: false, - }, - { - type: ApplicationCommandOptionType.String, - name: 'version', - description: 'Version of the package to use (default: Latest release)', - choices: versions.versions - .get(name) - ?.slice(0, 25) - .map((version) => { - return { - name: version, - value: version, - }; - }) ?? [ - { - name: 'main', - value: 'main', - }, - ], - }, - ] as const; -} - -function buildSubCommand(name: string) { - const cleanedName = name.replaceAll('.', '-'); +const versions = await reloadDjsVersions(); +export async function buildDocsCommand(versions: DjsVersions) { return { - type: ApplicationCommandOptionType.Subcommand, - name: cleanedName, - description: `Search documentation for discordjs@${cleanedName}`, - options: buildSubCommandOptions(name), - }; + name: 'docs', + description: 'Display discord.js documentation', + options: [ + { + type: ApplicationCommandOptionType.String, + name: 'query', + description: 'Phrase to search for', + required: true, + autocomplete: true, + }, + { + type: ApplicationCommandOptionType.Boolean, + name: 'hide', + description: 'Hide command output (default: False)', + required: false, + }, + { + type: ApplicationCommandOptionType.String, + name: 'version', + description: 'Version of discord.js to use (default: Latest release)', + choices: versions.versions + .get('discord.js') + ?.slice(0, 25) + .map((version) => { + return { + name: version, + value: version, + }; + }) ?? [ + { + name: 'main', + value: 'main', + }, + ], + required: false, + }, + { + type: ApplicationCommandOptionType.User, + name: 'mention', + description: 'User to mention', + required: false, + }, + ], + } as const; } -export const DocsCommand = { - name: 'docs', - description: 'Display discord.js documentation', - options: versions.packages.map((name) => buildSubCommand(name)), - default_member_permissions: '0', -} as const; +export const DocsCommand = await buildDocsCommand(versions); diff --git a/src/interactions/guide.ts b/src/interactions/guide.ts index fc298e4..e96303b 100644 --- a/src/interactions/guide.ts +++ b/src/interactions/guide.ts @@ -11,17 +11,17 @@ export const GuideCommand = { autocomplete: true, required: true, }, - { - type: ApplicationCommandOptionType.User, - name: 'target', - description: 'User to mention', - required: false, - }, { type: ApplicationCommandOptionType.Boolean, name: 'hide', description: 'Hide command output', required: false, }, + { + type: ApplicationCommandOptionType.User, + name: 'mention', + description: 'User to mention', + required: false, + }, ], } as const; diff --git a/src/interactions/mdn.ts b/src/interactions/mdn.ts index 11316f7..6cc19db 100644 --- a/src/interactions/mdn.ts +++ b/src/interactions/mdn.ts @@ -11,17 +11,17 @@ export const MdnCommand = { required: true, autocomplete: true, }, - { - type: ApplicationCommandOptionType.User, - name: 'target', - description: 'User to mention', - required: false, - }, { type: ApplicationCommandOptionType.Boolean, name: 'hide', description: 'Hide command output', required: false, }, + { + type: ApplicationCommandOptionType.User, + name: 'mention', + description: 'User to mention', + required: false, + }, ], } as const; diff --git a/src/interactions/node.ts b/src/interactions/node.ts index 16f7e8e..763e6c6 100644 --- a/src/interactions/node.ts +++ b/src/interactions/node.ts @@ -7,40 +7,21 @@ export const NodeCommand = { { type: ApplicationCommandOptionType.String, name: 'query', - description: 'Class, method or event to search for', + description: 'Phrase to search for', required: true, + autocomplete: true, }, { - type: ApplicationCommandOptionType.String, - name: 'version', - description: 'Node.js version to search documentation for', + type: ApplicationCommandOptionType.Boolean, + name: 'hide', + description: 'Hide command output', required: false, - choices: [ - { - name: 'v16', - value: 'latest-v16.x', - }, - { - name: 'v18 (default)', - value: 'latest-v18.x', - }, - { - name: 'v20 (current)', - value: 'latest-v20.x', - }, - ], }, { type: ApplicationCommandOptionType.User, - name: 'target', + name: 'mention', description: 'User to mention', required: false, }, - { - type: ApplicationCommandOptionType.Boolean, - name: 'hide', - description: 'Hide command output', - required: false, - }, ], } as const; diff --git a/src/interactions/tag.ts b/src/interactions/tag.ts index 9293941..bb00d77 100644 --- a/src/interactions/tag.ts +++ b/src/interactions/tag.ts @@ -11,17 +11,17 @@ export const TagCommand = { required: true, autocomplete: true, }, - { - type: ApplicationCommandOptionType.User, - name: 'target', - description: 'User to mention', - required: false, - }, { type: ApplicationCommandOptionType.Boolean, name: 'hide', description: 'Hide command output', required: false, }, + { + type: ApplicationCommandOptionType.User, + name: 'mention', + description: 'User to mention', + required: false, + }, ], } as const; diff --git a/src/util/argumentsOf.ts b/src/util/argumentsOf.ts index e053534..b28fdf2 100644 --- a/src/util/argumentsOf.ts +++ b/src/util/argumentsOf.ts @@ -41,26 +41,26 @@ type UnionToIntersection = (U extends any ? (k: U) => void : never) extends ( type TypeIdToType = T extends ApplicationCommandOptionType.Subcommand ? ArgumentsOfRaw : T extends ApplicationCommandOptionType.SubcommandGroup - ? ArgumentsOfRaw - : T extends ApplicationCommandOptionType.String - ? C extends readonly { value: string }[] - ? C[number]['value'] - : string - : T extends ApplicationCommandOptionType.Integer | ApplicationCommandOptionType.Number - ? C extends readonly { value: number }[] - ? C[number]['value'] - : number - : T extends ApplicationCommandOptionType.Boolean - ? boolean - : T extends ApplicationCommandOptionType.User - ? string - : T extends ApplicationCommandOptionType.Channel - ? string - : T extends ApplicationCommandOptionType.Role - ? string - : T extends ApplicationCommandOptionType.Mentionable - ? string - : never; + ? ArgumentsOfRaw + : T extends ApplicationCommandOptionType.String + ? C extends readonly { value: string }[] + ? C[number]['value'] + : string + : T extends ApplicationCommandOptionType.Integer | ApplicationCommandOptionType.Number + ? C extends readonly { value: number }[] + ? C[number]['value'] + : number + : T extends ApplicationCommandOptionType.Boolean + ? boolean + : T extends ApplicationCommandOptionType.User + ? string + : T extends ApplicationCommandOptionType.Channel + ? string + : T extends ApplicationCommandOptionType.Role + ? string + : T extends ApplicationCommandOptionType.Mentionable + ? string + : never; // eslint-disable-next-line @typescript-eslint/no-unused-vars type OptionToObject = O extends { @@ -74,8 +74,8 @@ type OptionToObject = O extends { ? R extends true ? { [k in K]: TypeIdToType } : T extends ApplicationCommandOptionType.Subcommand | ApplicationCommandOptionType.SubcommandGroup - ? { [k in K]: TypeIdToType } - : { [k in K]?: TypeIdToType } + ? { [k in K]: TypeIdToType } + : { [k in K]?: TypeIdToType } : never : never; diff --git a/src/util/constants.ts b/src/util/constants.ts index 0567d80..a6b6af0 100644 --- a/src/util/constants.ts +++ b/src/util/constants.ts @@ -2,33 +2,35 @@ export const PREFIX_FAIL = '`❌`' as const; export const PREFIX_SUCCESS = '`✅`' as const; export const PREFIX_BUG = '`🐞`' as const; export const PREFIX_TEAPOT = '`🍵418`' as const; -export const EMOJI_ID_DJS = '851461487498493952' as const; -export const EMOJI_ID_DJS_DEV = '851461195554619442' as const; -export const EMOJI_ID_GUIDE = '862626783890636830' as const; -export const EMOJI_ID_MDN = '957801942573256854' as const; -export const EMOJI_ID_NODE = '818292297644245103' as const; -export const EMOJI_ID_FIELD = '874569322742308864' as const; -export const EMOJI_ID_METHOD = '965916906311802930' as const; -export const EMOJI_ID_CLASS = '874569296821501952' as const; -export const EMOJI_ID_EVENT = '874569360642019349' as const; -export const EMOJI_ID_ENUM = '874569310025179188' as const; -export const EMOJI_ID_INTERFACE = '874569310025179188' as const; -export const EMOJI_ID_FIELD_DEV = '874573879153160212' as const; -export const EMOJI_ID_METHOD_DEV = '874573924988518500' as const; -export const EMOJI_ID_CLASS_DEV = '874573855715385394' as const; -export const EMOJI_ID_EVENT_DEV = '874573950796066816' as const; -export const EMOJI_ID_ENUM_DEV = '874573867572662273' as const; -export const EMOJI_ID_INTERFACE_DEV = '874573940956217415' as const; -export const EMOJI_ID_VARIABLE_DEV = '1094646525080109097' as const; -export const EMOJI_ID_VARIABLE = '1094646531879075880' as const; -export const EMOJI_ID_CLYDE_BLURPLE = '876041770423701554' as const; -export const EMOJI_ID_NO_TEST = '1145295017464840192' as const; -export const EMOJI_ID_DTYPES = '978751874591232080' as const; +export const EMOJI_ID_DJS = '1263786117040177182' as const; +export const EMOJI_ID_DJS_DEV = '1263786105082347560' as const; +export const EMOJI_ID_GUIDE = '1263786094135345293' as const; +export const EMOJI_ID_MDN = '1263782067620151296' as const; +export const EMOJI_ID_NODE = '1263782050322714695' as const; +export const EMOJI_ID_FIELD = '1263782602939174954' as const; +export const EMOJI_ID_METHOD = '1263782570915659898' as const; +export const EMOJI_ID_CLASS = '1263782539366109186' as const; +export const EMOJI_ID_EVENT = '1263782515202719744' as const; +export const EMOJI_ID_ENUM = '1263782475755290635' as const; +export const EMOJI_ID_INTERFACE = '1263782426203787275' as const; +export const EMOJI_ID_FIELD_DEV = '1263782260730105887' as const; +export const EMOJI_ID_METHOD_DEV = '1263782243424272384' as const; +export const EMOJI_ID_CLASS_DEV = '1263782223245738006' as const; +export const EMOJI_ID_EVENT_DEV = '1263782209412796469' as const; +export const EMOJI_ID_ENUM_DEV = '1263782170833588340' as const; +export const EMOJI_ID_INTERFACE_DEV = '1263782120334299249' as const; +export const EMOJI_ID_VARIABLE_DEV = '1263782097286463538' as const; +export const EMOJI_ID_VARIABLE = '1263782405865472102' as const; +export const EMOJI_ID_CLYDE_BLURPLE = '1263782079833968692' as const; +export const EMOJI_ID_NO_TEST = '1263785410853605397' as const; +export const EMOJI_ID_DTYPES = '1263786781669724232' as const; export const API_BASE_MDN = 'https://developer.mozilla.org' as const; export const API_BASE_NODE = 'https://nodejs.org' as const; export const API_BASE_ALGOLIA = 'algolia.net' as const; export const API_BASE_DISCORD = 'https://discord.com/api/v9' as const; +export const API_BASE_ORAMA = 'https://cloud.orama.run/v1' as const; export const AUTOCOMPLETE_MAX_ITEMS = 25; +export const AUTOCOMPLETE_MAX_NAME_LENGTH = 100; export const MAX_MESSAGE_LENGTH = 4_000; export const REMOTE_TAG_URL = 'https://raw.githubusercontent.com/discordjs/discord-utils-bot/main/tags' as const; export const WEBSITE_URL_ROOT = 'https://discordjs.dev'; @@ -37,3 +39,4 @@ export const DEFAULT_DOCS_BRANCH = 'stable' as const; export const VALIDATION_FAIL_COLOR = 0xed4245 as const; export const VALIDATION_SUCCESS_COLOR = 0x3ba55d as const; export const VALIDATION_WARNING_COLOR = 0xffdb5c as const; +export const DJS_QUERY_SEPARATOR = '|' as const; diff --git a/src/util/discordDocs.ts b/src/util/discordDocs.ts index 4ab8f06..79acaf5 100644 --- a/src/util/discordDocs.ts +++ b/src/util/discordDocs.ts @@ -1,10 +1,11 @@ +import { toTitlecase } from './misc.js'; import { urlOption } from './url.js'; export function toMdFilename(name: string) { return name .split('-') - .map((part) => `${part.at(0)?.toUpperCase()}${part.slice(1).toLowerCase()}`) - .join(''); + .map((part) => toTitlecase(part)) + .join('_'); } export function resolveResourceFromDocsURL(link: string) { @@ -34,21 +35,22 @@ type Heading = { }; function parseHeadline(text: string): Heading | null { - const match = /#{1,7} (?