diff --git a/src/commands/mdn/__snapshots__/api.test.ts.snap b/src/commands/mdn/__snapshots__/api.test.ts.snap new file mode 100644 index 00000000..d00177da --- /dev/null +++ b/src/commands/mdn/__snapshots__/api.test.ts.snap @@ -0,0 +1,47 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`updatedMDNQuery should work 1`] = ` +Array [ + Array [ + Object { + "embed": Object { + "author": null, + "color": 8638706, + "description": "1. [**Document directive** - CSP document directives are used in a Conten...](https://developer.mozilla.org/Glossary/Document_directive) +2. [**document environment** - When the JavaScript global environment is ...](https://developer.mozilla.org/Glossary/document_environment) +3. [**DOM (Document Object Model)** - The DOM (Document Object Model) is ...](https://developer.mozilla.org/Glossary/DOM) +4. [**Archived open Web documentation** - The documentation listed below ...](https://developer.mozilla.org/Archive/Web) +5. [**Document.documentElement** - Document.documentElement returns the E...](https://developer.mozilla.org/Web/API/Document/documentElement) +6. [**Document.documentURI** - The documentURI read-only property of the ...](https://developer.mozilla.org/Web/API/Document/documentURI) +7. [**Document.documentURIObject** - The Document.documentURIObject read-...](https://developer.mozilla.org/Web/API/Document/documentURIObject) +8. [**Document** - The Document interface represents any web page loaded ...](https://developer.mozilla.org/Web/API/Document) +9. [**Document()** - The Document constructor creates a new Document obje...](https://developer.mozilla.org/Web/API/Document/Document) +10. [**@document** - The @document CSS at-rule restricts the style rules c...](https://developer.mozilla.org/Web/CSS/@document) + +:bulb: *react with a number (:one:, :two:, ...) to filter your result* +:neutral_face: *react with \`❌\` to delete* +:point_up: *supports \`!mdn\`, \`!github\`, \`!caniuse\`, \`!npm\`, \`!composer\`, \`!bundlephobia\`, and \`!php\`* +:gear: *issues? feature requests? head over to [github](https://github.com/ljosberinn/webdev-support-bot)*", + "fields": Array [], + "footer": Object { + "iconURL": "https://avatars0.githubusercontent.com/u/7565578", + "text": "10 results found", + }, + "title": "MDN results for *Search Term*", + "url": "https://developer.mozilla.org/en-US/search?q=Search%20Term", + }, + }, + ], +] +`; + +exports[`updatedMDNQuery should work 2`] = ` +Array [ + Array [ + "https://developer.mozilla.org/Glossary/DOM", + Object { + "embed": null, + }, + ], +] +`; diff --git a/src/commands/mdn/__snapshots__/index.test.ts.snap b/src/commands/mdn/__snapshots__/dom.test.ts.snap similarity index 100% rename from src/commands/mdn/__snapshots__/index.test.ts.snap rename to src/commands/mdn/__snapshots__/dom.test.ts.snap diff --git a/src/commands/mdn/api.test.ts b/src/commands/mdn/api.test.ts new file mode 100644 index 00000000..f1714b89 --- /dev/null +++ b/src/commands/mdn/api.test.ts @@ -0,0 +1,145 @@ +import { updatedQueryBuilder } from './api'; + +import useData from '../../utils/useData'; +import { getChosenResult } from '../../utils/discordTools'; + +const searchResponse = { + query: 'document', + locale: 'en-US', + page: 1, + pages: 383, + start: 1, + end: 10, + next: + 'https://developer.mozilla.org/api/v1/search/en-US?highlight=false&page=2&q=document', + previous: null, + count: 3823, + filters: [ + { + name: 'Topics', + slug: 'topic', + options: [ + { + name: 'APIs and DOM', + slug: 'api', + count: 2609, + active: true, + urls: { + active: '/api/v1/search/en-US?highlight=false&q=document&topic=api', + inactive: '/api/v1/search/en-US?highlight=false&q=document', + }, + }, + ], + }, + ], + documents: [ + { + title: 'Document directive', + slug: 'Glossary/Document_directive', + locale: 'en-US', + excerpt: + 'CSP document directives are used in a Content-Security-Policy header and govern the properties of a document or worker environment to which a policy applies.', + }, + { + title: 'document environment', + slug: 'Glossary/document_environment', + locale: 'en-US', + excerpt: + "When the JavaScript global environment is a window or an iframe, it is called a document environment. A global environment is an environment that doesn't have an outer environment.", + }, + { + title: 'DOM (Document Object Model)', + slug: 'Glossary/DOM', + locale: 'en-US', + excerpt: + 'The DOM (Document Object Model) is an API that represents and interacts with any HTML or XML document. The DOM is a document model loaded in the browser and representing the document as a node tree, where each node represents part of the document (e.g. an element, text string, or comment).', + }, + { + title: 'Archived open Web documentation', + slug: 'Archive/Web', + locale: 'en-US', + excerpt: + 'The documentation listed below is archived, obsolete material about open Web topics.', + }, + { + title: 'Document.documentElement', + slug: 'Web/API/Document/documentElement', + locale: 'en-US', + excerpt: + 'Document.documentElement returns the Element that is the root element of the document (for example, the html element for HTML documents).', + }, + { + title: 'Document.documentURI', + slug: 'Web/API/Document/documentURI', + locale: 'en-US', + excerpt: + 'The documentURI read-only property of the Document interface returns the document location as a string.', + }, + { + title: 'Document.documentURIObject', + slug: 'Web/API/Document/documentURIObject', + locale: 'en-US', + excerpt: + 'The Document.documentURIObject read-only property returns an nsIURI object representing the URI of the document.', + }, + { + title: 'Document', + slug: 'Web/API/Document', + locale: 'en-US', + excerpt: + "The Document interface represents any web page loaded in the browser and serves as an entry point into the web page's content, which is the DOM tree.", + }, + { + title: 'Document()', + slug: 'Web/API/Document/Document', + locale: 'en-US', + excerpt: + "The Document constructor creates a new Document object that is a web page loaded in the browser and serving as an entry point into the page's content.", + }, + { + title: '@document', + slug: 'Web/CSS/@document', + locale: 'en-US', + excerpt: + 'The @document CSS at-rule restricts the style rules contained within it based on the URL of the document. It is designed primarily for user-defined style sheets, though it can be used on author-defined style sheets, too.', + }, + ], +}; + +describe('updatedMDNQuery', () => { + const mockUseData: jest.MockedFunction = jest.fn(); + const mockChoose: jest.MockedFunction = jest.fn(); + + const editMsg = { + edit: jest.fn(), + }; + const sendMock = jest.fn(); + const replyMock = jest.fn(); + const msg: any = { + channel: { send: sendMock }, + reply: replyMock, + }; + + test('should work', async () => { + mockUseData.mockResolvedValueOnce({ + error: false, + text: null, + json: searchResponse, + }); + + mockChoose.mockResolvedValueOnce({ + title: 'DOM (Document Object Model)', + slug: 'Glossary/DOM', + locale: 'en-US', + excerpt: + 'The DOM (Document Object Model) is an API that represents and interacts with any HTML or XML document. The DOM is a document model loaded in the browser and representing the document as a node tree, where each node represents part of the document (e.g. an element, text string, or comment).', + }); + + sendMock.mockResolvedValue(editMsg); + const handler = updatedQueryBuilder(mockUseData, mockChoose); + + await handler(msg, 'Search Term'); + expect(msg.channel.send.mock.calls).toMatchSnapshot(); + expect(editMsg.edit.mock.calls).toMatchSnapshot(); + }); +}); diff --git a/src/commands/mdn/api.ts b/src/commands/mdn/api.ts new file mode 100644 index 00000000..6667222d --- /dev/null +++ b/src/commands/mdn/api.ts @@ -0,0 +1,119 @@ +/* eslint-disable unicorn/prefer-query-selector */ +import { Message } from 'discord.js'; + +import delayedMessageAutoDeletion from '../../utils/delayedMessageAutoDeletion'; +import { + adjustTitleLength, + attemptEdit, + BASE_DESCRIPTION, + createDescription, + createListEmbed, + createMarkdownLink, + createMarkdownListItem, + getChosenResult, +} from '../../utils/discordTools'; +import * as errors from '../../utils/errors'; +import { buildDirectUrl, getSearchUrl } from '../../utils/urlTools'; +import useData from '../../utils/useData'; + +const provider = 'mdn'; + +interface SearchResponse { + query: string; + locale: string; + page: number; + pages: number; + starts: number; + end: number; + next: string; + previous: string | null; + count: number; + filter: Array<{ + name: string; + slug: string; + options: Array<{ + name: string; + slug: string; + count: number; + active: boolean; + urls: { + active: string; + inactive: string; + }; + }>; + }>; + documents: Array<{ + title: string; + slug: string; + locale: string; + excerpt: string; + }>; +} + +export const updatedQueryBuilder = ( + fetch: typeof useData = useData, + waitForChosenResult: typeof getChosenResult = getChosenResult +) => async (msg: Message, searchTerm: string) => { + try { + const url = getSearchUrl(provider, searchTerm); + const { error, json } = await fetch(url, 'json'); + if (error) { + return msg.reply(errors.invalidResponse); + } + + if (json.documents.length === 0) { + const sentMsg = await msg.reply(errors.noResults(searchTerm)); + return delayedMessageAutoDeletion(sentMsg); + } + + let preparedDescription = json.documents.map( + ({ title, excerpt, slug }, index) => + createMarkdownListItem( + index, + createMarkdownLink( + adjustTitleLength([`**${title}**`, excerpt].join(' - ')), + buildDirectUrl(provider, slug) + ) + ) + ); + + const expectedLength = preparedDescription.reduce( + (sum, item) => sum + item.length, + 0 + ); + if (expectedLength + BASE_DESCRIPTION.length + 10 * '\n'.length > 2048) { + preparedDescription = preparedDescription.map(string => { + // split at markdown link ending + const [title, ...rest] = string.split('...]'); + + // split title on title - excerpt glue + // concat with rest + // fix broken markdown link ending + return [title.split(' - ')[0], rest.join('')].join(']'); + }); + } + + const sentMsg = await msg.channel.send( + createListEmbed({ + description: createDescription(preparedDescription), + footerText: `${json.documents.length} results found`, + provider, + searchTerm, + url, + }) + ); + + const result = await waitForChosenResult(sentMsg, msg, json.documents); + if (!result) { + return; + } + + const editableUrl = buildDirectUrl(provider, result.slug); + await attemptEdit(sentMsg, editableUrl, { embed: null }); + } catch (error) { + console.error(error); + await msg.reply(errors.unknownError); + } +}; + +export default updatedQueryBuilder(); diff --git a/src/commands/mdn/index.test.ts b/src/commands/mdn/dom.test.ts similarity index 94% rename from src/commands/mdn/index.test.ts rename to src/commands/mdn/dom.test.ts index a2de48f7..eb5f867a 100644 --- a/src/commands/mdn/index.test.ts +++ b/src/commands/mdn/dom.test.ts @@ -4,7 +4,8 @@ import * as errors from '../../utils/errors'; import { getSearchUrl } from '../../utils/urlTools'; import useData from '../../utils/useData'; -import { queryBuilder } from '.'; +import { queryBuilder } from './dom'; +import { getChosenResult } from '../../utils/discordTools'; jest.mock('dom-parser'); jest.mock('../../utils/urlTools'); @@ -12,6 +13,7 @@ jest.mock('../../utils/useData'); const mockGetSearchUrl: jest.MockedFunction = getSearchUrl as any; const mockUseData: jest.MockedFunction = useData as any; +const mockChoose: jest.MockedFunction = getChosenResult as any; describe('handleMDNQuery', () => { const sendMock = jest.fn(); diff --git a/src/commands/mdn/index.ts b/src/commands/mdn/dom.ts similarity index 89% rename from src/commands/mdn/index.ts rename to src/commands/mdn/dom.ts index e6e1a8c5..475bcccc 100644 --- a/src/commands/mdn/index.ts +++ b/src/commands/mdn/dom.ts @@ -27,6 +27,38 @@ interface ParserResult { meta: string; } +interface SearchResponse { + query: string; + locale: string; + page: number; + pages: number; + starts: number; + end: number; + next: string; + previous: string | null; + count: number; + filter: Array<{ + name: string; + slug: string; + options: Array<{ + name: string; + slug: string; + count: number; + active: boolean; + urls: { + active: string; + inactive: string; + }; + }>; + }>; + documents: Array<{ + title: string; + slug: string; + locale: string; + excerpt: string; + }>; +} + interface ResultMeta { getElementsByClassName(cls: string): DOMParser.Node[]; } diff --git a/src/index.ts b/src/index.ts index a269445e..0cfb693e 100644 --- a/src/index.ts +++ b/src/index.ts @@ -10,7 +10,7 @@ import handleFormattingRequest from './commands/formatting'; import handleGithubQuery from './commands/github'; import handleJQueryCommand from './commands/jquery'; import handleLeaderboardRequest from './commands/leaderboard'; -import handleMDNQuery from './commands/mdn'; +import handleMDNQuery from './commands/mdn/api'; import handleNPMQuery from './commands/npm'; import handlePHPQuery from './commands/php'; import handlePointsRequest from './commands/points'; diff --git a/src/utils/urlTools.ts b/src/utils/urlTools.ts index c2d94611..4643387d 100644 --- a/src/utils/urlTools.ts +++ b/src/utils/urlTools.ts @@ -65,7 +65,7 @@ export const providers: ProviderMap = { mdn: { color: 0x83d0f2, createTitle: (searchTerm: string) => `MDN results for *${searchTerm}*`, - direct: `https://developer.mozilla.org${TERM}`, + direct: `https://developer.mozilla.org/${TERM}`, help: '!mdn localStorage', icon: 'https://avatars0.githubusercontent.com/u/7565578', search: `https://developer.mozilla.org/en-US/search?q=${SEARCH_TERM}`, @@ -75,7 +75,7 @@ export const providers: ProviderMap = { createTitle: (searchTerm: string) => `NPM results for *${searchTerm}*`, help: '!npm react', icon: 'https://avatars0.githubusercontent.com/u/6078720', - search: `https://www.npmjs.com/search/suggestions?q=${SEARCH_TERM}`, + search: `https://developer.mozilla.org/api/v1/search/en-US?highlight=false&q=${SEARCH_TERM}`, }, php: { color: 0x8892bf, @@ -123,7 +123,7 @@ export const buildDirectUrl = (provider: Provider, href: string) => { return providers[provider].direct.replace(TERM, href); } - throw new Error(`provider not implemeted: ${provider}`); + throw new Error(`provider not implemented: ${provider}`); }; export const getExtendedInfoUrl = (provider: Provider, term: string) => {