From c821515283aabfbae39c9bb0e5ab60ef93576911 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Kristers=20Du=C4=A3els?= <45801982+kristersd@users.noreply.github.com> Date: Tue, 6 Feb 2024 18:13:17 +0200 Subject: [PATCH] Implement search React documentation command #348 (#349) * feat: implement poc for react documentation * fix: mdn logo not found --- src/features/commands.ts | 83 +++++++++++++++++++++++--- src/helpers/react-docs.ts | 121 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 197 insertions(+), 7 deletions(-) create mode 100644 src/helpers/react-docs.ts diff --git a/src/features/commands.ts b/src/features/commands.ts index 3a27702c..526fa252 100644 --- a/src/features/commands.ts +++ b/src/features/commands.ts @@ -1,9 +1,13 @@ /* eslint-disable @typescript-eslint/no-use-before-define */ import fetch from "node-fetch"; -import { ChannelType, EmbedType, Message, TextChannel } from "discord.js"; +import { APIEmbed, ChannelType, EmbedType, Message, TextChannel } from "discord.js"; import cooldown from "./cooldown"; import { ChannelHandlers } from "../types"; import { isStaff } from "../helpers/discord"; +import { + getReactDocsContent, + getReactDocsSearchKey, +} from "../helpers/react-docs"; export const EMBED_COLOR = 7506394; @@ -233,7 +237,7 @@ Check out [this article on "Why and how to bind methods in your React component { title: "Lifting State Up", type: EmbedType.Rich, - description: `Often, several components need to reflect the same changing data. We recommend lifting the shared state up to their closest common ancestor. + description: `Often, several components need to reflect the same changing data. We recommend lifting the shared state up to their closest common ancestor. Learn more about lifting state in the [React.dev article about "Sharing State Between Components"](https://react.dev/learn/sharing-state-between-components).`, color: EMBED_COLOR, @@ -402,7 +406,7 @@ Here's an article explaining the difference between the two: https://goshakkk.na name: "MDN", url: "https://developer.mozilla.org", icon_url: - "https://developer.mozilla.org/static/img/opengraph-logo.72382e605ce3.png", + "https://developer.mozilla.org/favicon-48x48.cbbd161b.png", }, title, description, @@ -415,6 +419,55 @@ Here's an article explaining the difference between the two: https://goshakkk.na fetchMsg.delete(); }, }, + { + words: ["!react-docs", "!docs"], + help: "Allows you to search the React docs, usage: !docs useState", + category: "Web", + handleMessage: async (msg) => { + const [, search] = msg.content.split(" "); + + const searchKey = getReactDocsSearchKey(search); + + if (!searchKey) { + msg.channel.send({ + embeds: generateReactDocsErrorEmbeds(search), + }); + return; + } + + const [fetchMsg, content] = await Promise.all([ + msg.channel.send(`Looking up documentation for **'${search}'**...`), + getReactDocsContent(searchKey), + ]); + + if (!content) { + fetchMsg.edit({ + embeds: generateReactDocsErrorEmbeds(search), + }); + return; + } + + await msg.channel.send({ + embeds: [ + { + title: `${searchKey}`, + type: EmbedType.Rich, + description: content, + color: EMBED_COLOR, + url: `https://react.dev/reference/${searchKey}`, + author: { + name: "React documentation", + icon_url: + "https://upload.wikimedia.org/wikipedia/commons/thumb/a/a7/React-icon.svg/1150px-React-icon.svg.png", + url: "https://react.dev/", + }, + }, + ], + }); + + fetchMsg.delete(); + }, + }, { words: [`!appideas`], help: `provides a link to the best curated app ideas for beginners to advanced devs`, @@ -832,7 +885,7 @@ https://exploringjs.com/es6/ch_variables.html#_pitfall-const-does-not-make-the-v title: "Acquiring a remote position", type: EmbedType.Rich, description: ` -Below is a list of resources we commonly point to as an aid in a search for remote jobs. +Below is a list of resources we commonly point to as an aid in a search for remote jobs. NOTE: If you are looking for your first job in the field or are earlier in your career, then getting a remote job at this stage is incredibly rare. We recommend prioritizing getting a job local to the area you are in or possibly moving to an area for work if options are limited where you are. @@ -882,9 +935,9 @@ Remote work has the most competition, and thus is likely to be more difficult to title: "The importance of keys when rendering lists in React", type: EmbedType.Rich, description: ` -React depends on the use of stable and unique keys to identify items in a list so that it can perform correct and performant DOM updates. +React depends on the use of stable and unique keys to identify items in a list so that it can perform correct and performant DOM updates. -Keys are particularly important if the list can change over time. React will use the index in the array by default if no key is specified. You can use the index in the array if the list doesn't change and you don't have a stable and unique key available. +Keys are particularly important if the list can change over time. React will use the index in the array by default if no key is specified. You can use the index in the array if the list doesn't change and you don't have a stable and unique key available. Please see these resources for more information: @@ -946,7 +999,7 @@ _ _ name: "Meta Frameworks", value: ` - [Next.js](https://nextjs.org/) -- [Remix](https://remix.run/) +- [Remix](https://remix.run/) - [Astro](https://astro.build/) - [SvelteKit](https://kit.svelte.dev/) - [Nuxt](https://nuxtjs.org/) @@ -1117,4 +1170,20 @@ const commands: ChannelHandlers = { }, }; +const generateReactDocsErrorEmbeds = (search: string): APIEmbed[] => { + return [ + { + type: EmbedType.Rich, + description: `Could not find anything on React documentation for **'${search}'**`, + color: EMBED_COLOR, + author: { + name: "React documentation", + icon_url: + "https://upload.wikimedia.org/wikipedia/commons/thumb/a/a7/React-icon.svg/1150px-React-icon.svg.png", + url: "https://react.dev/", + }, + }, + ]; +}; + export default commands; diff --git a/src/helpers/react-docs.ts b/src/helpers/react-docs.ts new file mode 100644 index 00000000..41e382f8 --- /dev/null +++ b/src/helpers/react-docs.ts @@ -0,0 +1,121 @@ +import fetch from "node-fetch"; +import { gitHubToken } from "./env"; + +const LOOKUP_REGEX = /\s*(.*?)\s*<\/Intro>/gs; +const LINK_REGEX = /\[([^\]]+)\]\((?!https?:\/\/)([^)]+)\)/g; + +const BASE_URL = + "https://api.github.com/repos/reactjs/react.dev/contents/src/content/reference/"; + +export const getReactDocsContent = async (searchPath: string) => { + try { + const response = await fetch(`${BASE_URL}${searchPath}.md`, { + method: "GET", + headers: { + Accept: "application/vnd.github+json", + Authorization: `Bearer ${gitHubToken}`, + "X-GitHub-Api-Version": "2022-11-28", + }, + }); + const json = await response.json(); + const contentBase64 = json.content; + const decodedContent = Buffer.from(contentBase64, "base64").toString( + "utf8", + ); + return processReactDocumentation(decodedContent); + } catch (error) { + console.error("Error:", error); + return null; + } +}; + +export const getReactDocsSearchKey = (search: string) => { + const normalizedSearch = search.toLowerCase(); + + return REACT_AVAILABLE_DOCS.find((key) => { + const keyParts = key.split("/"); + const name = keyParts[keyParts.length - 1]; + const namespace = + keyParts.length <= 2 ? key : `${keyParts[0]}/${keyParts[2]}`; + + return ( + namespace.toLowerCase() === normalizedSearch || + name.toLowerCase() === normalizedSearch + ); + }); +}; + +const processReactDocumentation = (content: string) => { + const patchedContentLinks = content.replace(LINK_REGEX, (_, text, link) => { + return `[${text}](https://react.dev${link})`; + }); + + const matches = [...patchedContentLinks.matchAll(LOOKUP_REGEX)]; + + if (matches.length > 0) { + const [introContent] = matches.map(([, match]) => match.trim()); + return introContent; + } + + return null; +}; + +const REACT_AVAILABLE_DOCS = [ + "react/cache", + "react/Children", + "react/cloneElement", + "react/Component", + "react/createContext", + "react/createElement", + "react/createFactory", + "react/createRef", + "react/experimental_taintObjectReference", + "react/experimental_taintUniqueValue", + "react/experimental_useEffectEvent", + "react/forwardRef", + "react/Fragment", + "react/isValidElement", + "react/lazy", + "react/legacy", + "react/memo", + "react/Profiler", + "react/PureComponent", + "react/startTransition", + "react/StrictMode", + "react/Suspense", + "react/use-client", + "react/use-server", + "react/use", + "react/useCallback", + "react/useContext", + "react/useDebugValue", + "react/useDeferredValue", + "react/useEffect", + "react/useId", + "react/useImperativeHandle", + "react/useInsertionEffect", + "react/useLayoutEffect", + "react/useMemo", + "react/useOptimistic", + "react/useReducer", + "react/useRef", + "react/useState", + "react/useSyncExternalStore", + "react/useTransition", + "react-dom/client/createRoot", + "react-dom/client/hydrateRoot", + "react-dom/hooks/useFormState", + "react-dom/hooks/useFormStatus", + "react-dom/server/renderToNodeStream", + "react-dom/server/renderToPipeableStream", + "react-dom/server/renderToReadableStream", + "react-dom/server/renderToStaticMarkup", + "react-dom/server/renderToStaticNodeStream", + "react-dom/server/renderToString", + "react-dom/unmountComponentAtNode", + "react-dom/hydrate", + "react-dom/render", + "react-dom/createPortal", + "react-dom/findDOMNode", + "react-dom/flushSync", +];