diff --git a/core/badge-urls/make-badge-url.d.ts b/core/badge-urls/make-badge-url.d.ts index 0da9d898988cb96024beb2409ca0a09c34bc982f..bf925cf72970eebc0fedb8bdbf476d677089fbed 100644 --- a/core/badge-urls/make-badge-url.d.ts +++ b/core/badge-urls/make-badge-url.d.ts @@ -14,24 +14,6 @@ export function badgeUrlFromPath({ longCache?: boolean }): string -export function badgeUrlFromPattern({ - baseUrl, - pattern, - namedParams, - queryParams, - style, - format, - longCache, -}: { - baseUrl?: string - pattern: string - namedParams: { [k: string]: string } - queryParams: { [k: string]: string | number | boolean } - style?: string - format?: string - longCache?: boolean -}): string - export function encodeField(s: string): string export function staticBadgeUrl({ diff --git a/core/badge-urls/make-badge-url.js b/core/badge-urls/make-badge-url.js index 82e3f5fa251240453945a9814af32d13fdbd37fc..8870c9acea44ca736c5480bcc537346fcb35a703 100644 --- a/core/badge-urls/make-badge-url.js +++ b/core/badge-urls/make-badge-url.js @@ -1,7 +1,6 @@ // Avoid "Attempted import error: 'URL' is not exported from 'url'" in frontend. import url from 'url' import queryString from 'query-string' -import { compile } from 'path-to-regexp' function badgeUrlFromPath({ baseUrl = '', @@ -23,33 +22,6 @@ function badgeUrlFromPath({ return `${baseUrl}${path}${outExt}${suffix}` } -function badgeUrlFromPattern({ - baseUrl = '', - pattern, - namedParams, - queryParams, - style, - format = '', - longCache = false, -}) { - const toPath = compile(pattern, { - strict: true, - sensitive: true, - encode: encodeURIComponent, - }) - - const path = toPath(namedParams) - - return badgeUrlFromPath({ - baseUrl, - path, - queryParams, - style, - format, - longCache, - }) -} - function encodeField(s) { return encodeURIComponent(s.replace(/-/g, '--').replace(/_/g, '__')) } @@ -154,7 +126,6 @@ function rasterRedirectUrl({ rasterUrl }, badgeUrl) { export { badgeUrlFromPath, - badgeUrlFromPattern, encodeField, staticBadgeUrl, queryStringStaticBadgeUrl, diff --git a/core/badge-urls/make-badge-url.spec.js b/core/badge-urls/make-badge-url.spec.js index da0b156d5ea71d93608e9e66b30551e955b8a843..c8c3fcbb1bd87fa685a1feff2f02631b3c4f3e06 100644 --- a/core/badge-urls/make-badge-url.spec.js +++ b/core/badge-urls/make-badge-url.spec.js @@ -1,7 +1,6 @@ import { test, given } from 'sazerac' import { badgeUrlFromPath, - badgeUrlFromPattern, encodeField, staticBadgeUrl, queryStringStaticBadgeUrl, @@ -20,18 +19,6 @@ describe('Badge URL generation functions', function () { ) }) - test(badgeUrlFromPattern, () => { - given({ - baseUrl: 'http://example.com', - pattern: '/npm/v/:packageName', - namedParams: { packageName: 'gh-badges' }, - style: 'flat-square', - longCache: true, - }).expect( - 'http://example.com/npm/v/gh-badges?cacheSeconds=2592000&style=flat-square' - ) - }) - test(encodeField, () => { given('foo').expect('foo') given('').expect('') diff --git a/core/server/server.js b/core/server/server.js index 75c9e176bfb8c7ced8fa29d239a90d4995ee5e70..28aae21c66774b71a6fa4451289754e298183381 100644 --- a/core/server/server.js +++ b/core/server/server.js @@ -11,7 +11,6 @@ import originalJoi from 'joi' import makeBadge from '../../badge-maker/lib/make-badge.js' import GithubConstellation from '../../services/github/github-constellation.js' import LibrariesIoConstellation from '../../services/librariesio/librariesio-constellation.js' -import { setRoutes } from '../../services/suggest.js' import { loadServiceClasses } from '../base-service/loader.js' import { makeSend } from '../base-service/legacy-result-sender.js' import { handleRequest } from '../base-service/legacy-request-handler.js' @@ -113,6 +112,9 @@ const publicConfigSchema = Joi.object({ redirectUrl: optionalUrl, rasterUrl: optionalUrl, cors: { + // This doesn't actually do anything + // TODO: maybe remove in future? + // https://github.com/badges/shields/pull/8311#discussion_r945337530 allowedOrigin: Joi.array().items(optionalUrl).required(), }, services: Joi.object({ @@ -488,7 +490,6 @@ class Server { const { bind: { port, address: hostname }, ssl: { isSecure: secure, cert, key }, - cors: { allowedOrigin }, requireCloudflare, } = this.config.public @@ -521,9 +522,6 @@ class Server { } } - const { apiProvider: githubApiProvider } = this.githubConstellation - setRoutes(allowedOrigin, githubApiProvider, camp) - // https://github.com/badges/shields/issues/3273 camp.handle((req, res, next) => { res.setHeader('Access-Control-Allow-Origin', '*') diff --git a/cypress/e2e/main-page.cy.js b/cypress/e2e/main-page.cy.js index 76d6e33eac493f2c68370ac904280a69a4484178..14a9c216137ad1d88fe9ab4e13b4e5296ba9c9ab 100644 --- a/cypress/e2e/main-page.cy.js +++ b/cypress/e2e/main-page.cy.js @@ -4,7 +4,7 @@ registerCommand() describe('Main page', function () { const backendUrl = Cypress.env('backend_url') - const SEARCH_INPUT = 'input[placeholder="search / project URL"]' + const SEARCH_INPUT = 'input[placeholder="search"]' function expectBadgeExample(title, previewUrl, pattern) { cy.contains('tr', `${title}:`).find('code').should('have.text', pattern) @@ -36,35 +36,15 @@ describe('Main page', function () { ) }) - it('Suggest badges', function () { - const badgeUrl = `${backendUrl}/github/issues/badges/shields` + it('Customizate badges', function () { visitAndWait('/') - cy.get(SEARCH_INPUT).type('https://github.com/badges/shields') - cy.contains('Suggest badges').click() + cy.get(SEARCH_INPUT).type('issues') - expectBadgeExample('GitHub issues', badgeUrl, badgeUrl) - }) - - it('Customization form is filled with suggested badge details', function () { - const badgeUrl = `${backendUrl}/github/issues/badges/shields` - visitAndWait('/') - cy.get(SEARCH_INPUT).type('https://github.com/badges/shields') - cy.contains('Suggest badges').click() - - cy.contains(badgeUrl).click() - - cy.get('input[name="user"]').should('have.value', 'badges') - cy.get('input[name="repo"]').should('have.value', 'shields') - }) - - it('Customizate suggested badge', function () { - const badgeUrl = `${backendUrl}/github/issues/badges/shields` - visitAndWait('/') - cy.get(SEARCH_INPUT).type('https://github.com/badges/shields') - cy.contains('Suggest badges').click() - cy.contains(badgeUrl).click() + cy.contains('/github/issues/:user/:repo').click() + cy.get('input[name="user"]').type('badges') + cy.get('input[name="repo"]').type('shields') cy.get('table input[name="color"]').type('orange') cy.get(`img[src='${backendUrl}/github/issues/badges/shields?color=orange']`) diff --git a/doc/code-walkthrough.md b/doc/code-walkthrough.md index d72a7208d8be87954e820e867d68d91563d74057..0561d9664f82d07b6c5b20a96b0c694f43ed4e30 100644 --- a/doc/code-walkthrough.md +++ b/doc/code-walkthrough.md @@ -20,8 +20,6 @@ The Shields codebase is divided into several parts: 1. `*.js` in the root of [`services`][services] 7. The services themselves (about 80% of the code) 1. `*.js` in the folders of [`services`][services] -8. The badge suggestion endpoint (Note: it's tested as if it’s a service.) - 1. [`lib/suggest.js`][suggest] [frontend]: https://github.com/badges/shields/tree/master/frontend [badge-maker]: https://github.com/badges/shields/tree/master/badge-maker @@ -29,7 +27,6 @@ The Shields codebase is divided into several parts: [server]: https://github.com/badges/shields/tree/master/core/server [token-pooling]: https://github.com/badges/shields/tree/master/core/token-pooling [services]: https://github.com/badges/shields/tree/master/services -[suggest]: https://github.com/badges/shields/tree/master/lib/suggest.js The tests are also divided into several parts: diff --git a/doc/self-hosting.md b/doc/self-hosting.md index 68843fbf19fa4fb89bee63317680b9c9bf14d63e..3a6d6ec9cb5c5355b314d597f59c6e0f10353293 100644 --- a/doc/self-hosting.md +++ b/doc/self-hosting.md @@ -153,15 +153,6 @@ Then copy the contents of the `build/` folder to your static hosting / CDN. There are also a couple settings you should configure on the server. -If you want to use server suggestions, you should also set `ALLOWED_ORIGIN`: - -```sh -ALLOWED_ORIGIN=http://my-custom-shields.s3.amazonaws.com,https://my-custom-shields.s3.amazonaws.com -``` - -This should be a comma-separated list of allowed origin headers. They should -not have paths or trailing slashes. - To help out users, you can make the Shields server redirect the server root. Set the `REDIRECT_URI` environment variable: diff --git a/frontend/components/badge-examples.tsx b/frontend/components/badge-examples.tsx index 8c7a23bff316d18da92589f0ff03a8c9fa30c882..7d17b81827e2c2b3841e4b0d0526cce74963daca 100644 --- a/frontend/components/badge-examples.tsx +++ b/frontend/components/badge-examples.tsx @@ -2,13 +2,11 @@ import React from 'react' import styled from 'styled-components' import { badgeUrlFromPath, - badgeUrlFromPattern, staticBadgeUrl, } from '../../core/badge-urls/make-badge-url' import { removeRegexpFromPattern } from '../lib/pattern-helpers' import { Example as ExampleData, - Suggestion, RenderableExample, } from '../lib/service-definitions' import { Badge } from './common' @@ -36,49 +34,34 @@ function Example({ baseUrl, onClick, exampleData, - isBadgeSuggestion, }: { baseUrl?: string - onClick: (example: RenderableExample, isSuggestion: boolean) => void + onClick: (example: RenderableExample) => void exampleData: RenderableExample - isBadgeSuggestion: boolean }): JSX.Element { const handleClick = React.useCallback( function (): void { - onClick(exampleData, isBadgeSuggestion) + onClick(exampleData) }, - [exampleData, isBadgeSuggestion, onClick] + [exampleData, onClick] ) - let exampleUrl, previewUrl - if (isBadgeSuggestion) { - const { - example: { pattern, namedParams, queryParams }, - } = exampleData as Suggestion - exampleUrl = previewUrl = badgeUrlFromPattern({ - baseUrl, - pattern, - namedParams, - queryParams, - }) - } else { - const { - example: { pattern, queryParams }, - preview: { label, message, color, style, namedLogo }, - } = exampleData as ExampleData - previewUrl = staticBadgeUrl({ - baseUrl, - label: label || '', - message, - color, - style, - namedLogo, - }) - exampleUrl = badgeUrlFromPath({ - path: removeRegexpFromPattern(pattern), - queryParams, - }) - } + const { + example: { pattern, queryParams }, + preview: { label, message, color, style, namedLogo }, + } = exampleData as ExampleData + const previewUrl = staticBadgeUrl({ + baseUrl, + label: label || '', + message, + color, + style, + namedLogo, + }) + const exampleUrl = badgeUrlFromPath({ + path: removeRegexpFromPattern(pattern), + queryParams, + }) const { title } = exampleData return ( @@ -101,14 +84,12 @@ function Example({ export function BadgeExamples({ examples, - areBadgeSuggestions, baseUrl, onClick, }: { examples: RenderableExample[] - areBadgeSuggestions: boolean baseUrl?: string - onClick: (exampleData: RenderableExample, isSuggestion: boolean) => void + onClick: (exampleData: RenderableExample) => void }): JSX.Element { return ( <ExampleTable> @@ -117,7 +98,6 @@ export function BadgeExamples({ <Example baseUrl={baseUrl} exampleData={exampleData} - isBadgeSuggestion={areBadgeSuggestions} key={`${exampleData.title} ${exampleData.example.pattern}`} onClick={onClick} /> diff --git a/frontend/components/customizer/customizer.tsx b/frontend/components/customizer/customizer.tsx index 663c38cfa08ec12b8872c0f65b1de97e94b4246f..503efc6741978b854217236944fcd35106014faa 100644 --- a/frontend/components/customizer/customizer.tsx +++ b/frontend/components/customizer/customizer.tsx @@ -18,8 +18,6 @@ export default function Customizer({ exampleNamedParams, exampleQueryParams, initialStyle, - isPrefilled, - link = '', }: { baseUrl: string title: string @@ -27,8 +25,6 @@ export default function Customizer({ exampleNamedParams: { [k: string]: string } exampleQueryParams: { [k: string]: string } initialStyle?: string - isPrefilled: boolean - link?: string }): JSX.Element { // https://github.com/DefinitelyTyped/DefinitelyTyped/issues/35572 // https://github.com/DefinitelyTyped/DefinitelyTyped/issues/28884#issuecomment-471341041 @@ -75,7 +71,6 @@ export default function Customizer({ const builtBadgeUrl = generateBuiltBadgeUrl() const markup = generateMarkup({ badgeUrl: builtBadgeUrl, - link, title, markupFormat, }) @@ -93,7 +88,7 @@ export default function Customizer({ indicatorRef.current.trigger() } }, - [generateBuiltBadgeUrl, link, title, setMessage, setMarkup] + [generateBuiltBadgeUrl, title, setMessage, setMarkup] ) function renderMarkupAndLivePreview(): JSX.Element { @@ -147,7 +142,6 @@ export default function Customizer({ <form action=""> <PathBuilder exampleParams={exampleNamedParams} - isPrefilled={isPrefilled} onChange={handlePathChange} pattern={pattern} /> diff --git a/frontend/components/customizer/path-builder.tsx b/frontend/components/customizer/path-builder.tsx index 46e7d3cb676524f19f5684e20b44d7987d662cf8..5e4d10f653f9e12aae4b9c8ad6e0349235f6b647 100644 --- a/frontend/components/customizer/path-builder.tsx +++ b/frontend/components/customizer/path-builder.tsx @@ -112,7 +112,6 @@ export default function PathBuilder({ pattern, exampleParams, onChange, - isPrefilled, }: { pattern: string exampleParams: { [k: string]: string } @@ -123,22 +122,19 @@ export default function PathBuilder({ path: string isComplete: boolean }) => void - isPrefilled: boolean }): JSX.Element { const [tokens] = useState(() => parse(pattern)) const [namedParams, setNamedParams] = useState(() => - isPrefilled - ? exampleParams - : // `pathToRegexp.parse()` returns a mixed array of strings for literals - // and objects for parameters. Filter out the literals and work with the - // objects. - tokens - .filter(t => typeof t !== 'string') - .map(t => t as Key) - .reduce((accum, { name }) => { - accum[name] = '' - return accum - }, {} as { [k: string]: string }) + // `pathToRegexp.parse()` returns a mixed array of strings for literals + // and objects for parameters. Filter out the literals and work with the + // objects. + tokens + .filter(t => typeof t !== 'string') + .map(t => t as Key) + .reduce((accum, { name }) => { + accum[name] = '' + return accum + }, {} as { [k: string]: string }) ) useEffect(() => { @@ -195,11 +191,11 @@ export default function PathBuilder({ onChange={handleTokenChange} value={value} > - <option disabled={isPrefilled} key="empty" value=""> + <option key="empty" value=""> {' '} </option> {options.map(option => ( - <option disabled={isPrefilled} key={option} value={option}> + <option key={option} value={option}> {option} </option> ))} @@ -208,7 +204,6 @@ export default function PathBuilder({ } else { return ( <NamedParamInput - disabled={isPrefilled} name={name} onChange={handleTokenChange} type="text" @@ -239,11 +234,9 @@ export default function PathBuilder({ {optional ? <BuilderLabel>(optional)</BuilderLabel> : null} </NamedParamLabelContainer> {renderNamedParamInput(token)} - {!isPrefilled && ( - <NamedParamCaption> - {namedParamIndex === 0 ? `e.g. ${exampleValue}` : exampleValue} - </NamedParamCaption> - )} + <NamedParamCaption> + {namedParamIndex === 0 ? `e.g. ${exampleValue}` : exampleValue} + </NamedParamCaption> </PathBuilderColumn> </React.Fragment> ) diff --git a/frontend/components/main.tsx b/frontend/components/main.tsx index 99276c5aa494c7608f9149d3f09f85c0cfe787ad..e10acfe242f61e8443ccc5ddc00d2a7841f8a764 100644 --- a/frontend/components/main.tsx +++ b/frontend/components/main.tsx @@ -14,7 +14,7 @@ import ServiceDefinitionSetHelper from '../lib/service-definitions/service-defin import { getBaseUrl } from '../constants' import Meta from './meta' import Header from './header' -import SuggestionAndSearch from './suggestion-and-search' +import Search from './search' import DonateBox from './donate' import { MarkupModal } from './markup-modal' import Usage from './usage' @@ -49,8 +49,6 @@ export default function Main({ [k: string]: ServiceDefinition[] }>() const [selectedExample, setSelectedExample] = useState<RenderableExample>() - const [selectedExampleIsSuggestion, setSelectedExampleIsSuggestion] = - useState(false) const searchTimeout = useRef(0) const baseUrl = getBaseUrl() @@ -92,14 +90,6 @@ export default function Main({ [setSearchIsInProgress, performSearch] ) - const exampleClicked = React.useCallback( - function (example: RenderableExample, isSuggestion: boolean): void { - setSelectedExample(example) - setSelectedExampleIsSuggestion(isSuggestion) - }, - [setSelectedExample, setSelectedExampleIsSuggestion] - ) - const dismissMarkupModal = React.useCallback( function (): void { setSelectedExample(undefined) @@ -123,7 +113,6 @@ export default function Main({ <div> <CategoryHeading category={category} /> <BadgeExamples - areBadgeSuggestions={false} baseUrl={baseUrl} examples={flattened} onClick={setSelectedExample} @@ -182,15 +171,10 @@ export default function Main({ <MarkupModal baseUrl={baseUrl} example={selectedExample} - isBadgeSuggestion={selectedExampleIsSuggestion} onRequestClose={dismissMarkupModal} /> <section> - <SuggestionAndSearch - baseUrl={baseUrl} - onBadgeClick={exampleClicked} - queryChanged={searchQueryChanged} - /> + <Search queryChanged={searchQueryChanged} /> <DonateBox /> </section> {renderMain()} diff --git a/frontend/components/markup-modal/index.tsx b/frontend/components/markup-modal/index.tsx index 238a8e644c79c9c370ce1de8e8e88ac2feb9d4b2..eb23fa05b1b1a04182243a0901ba8e0fb259184d 100644 --- a/frontend/components/markup-modal/index.tsx +++ b/frontend/components/markup-modal/index.tsx @@ -11,12 +11,10 @@ const ContentContainer = styled(BaseFont)` export function MarkupModal({ example, - isBadgeSuggestion, baseUrl, onRequestClose, }: { example: RenderableExample | undefined - isBadgeSuggestion: boolean baseUrl: string onRequestClose: () => void }): JSX.Element { @@ -29,11 +27,7 @@ export function MarkupModal({ > {example !== undefined && ( <ContentContainer> - <MarkupModalContent - baseUrl={baseUrl} - example={example} - isBadgeSuggestion={isBadgeSuggestion} - /> + <MarkupModalContent baseUrl={baseUrl} example={example} /> </ContentContainer> )} </Modal> diff --git a/frontend/components/markup-modal/markup-modal-content.tsx b/frontend/components/markup-modal/markup-modal-content.tsx index 6247e088a28bb6b905a6d983a031144af960a767..3736778807eeaacfb987c53994275ee46a198302 100644 --- a/frontend/components/markup-modal/markup-modal-content.tsx +++ b/frontend/components/markup-modal/markup-modal-content.tsx @@ -1,10 +1,6 @@ import React from 'react' import styled from 'styled-components' -import { - Example, - Suggestion, - RenderableExample, -} from '../../lib/service-definitions' +import { Example, RenderableExample } from '../../lib/service-definitions' import { H3 } from '../common' import Customizer from '../customizer/customizer' @@ -16,20 +12,12 @@ const Documentation = styled.div` export function MarkupModalContent({ example, - isBadgeSuggestion, baseUrl, }: { example: RenderableExample - isBadgeSuggestion: boolean baseUrl: string }): JSX.Element { - let documentation: { __html: string } | undefined - let link: string | undefined - if (isBadgeSuggestion) { - ;({ link } = example as Suggestion) - } else { - ;({ documentation } = example as Example) - } + const { documentation } = example as Example const { title, @@ -48,8 +36,6 @@ export function MarkupModalContent({ exampleNamedParams={namedParams} exampleQueryParams={queryParams} initialStyle={initialStyle} - isPrefilled={isBadgeSuggestion} - link={link} pattern={pattern} title={title} /> diff --git a/frontend/components/search.tsx b/frontend/components/search.tsx new file mode 100644 index 0000000000000000000000000000000000000000..d440e23c1a0f7ceb4fe0fe4919892636b444c768 --- /dev/null +++ b/frontend/components/search.tsx @@ -0,0 +1,37 @@ +import React, { useRef, ChangeEvent } from 'react' +import debounce from 'lodash.debounce' +import { BlockInput } from './common' + +export default function Search({ + queryChanged, +}: { + queryChanged: (query: string) => void +}): JSX.Element { + const queryChangedDebounced = useRef( + debounce(queryChanged, 50, { leading: true }) + ) + + const onQueryChanged = React.useCallback( + function ({ + target: { value: query }, + }: ChangeEvent<HTMLInputElement>): void { + queryChangedDebounced.current(query) + }, + [queryChangedDebounced] + ) + + // TODO: Warning: A future version of React will block javascript: URLs as a security precaution + // how else to do this? + return ( + <section> + <form action="javascript:void 0" autoComplete="off"> + <BlockInput + autoComplete="off" + autoFocus + onChange={onQueryChanged} + placeholder="search" + /> + </form> + </section> + ) +} diff --git a/frontend/components/suggestion-and-search.tsx b/frontend/components/suggestion-and-search.tsx deleted file mode 100644 index 7f62c0f84895897b6051fbf806290bb1ce4b5a96..0000000000000000000000000000000000000000 --- a/frontend/components/suggestion-and-search.tsx +++ /dev/null @@ -1,133 +0,0 @@ -import React, { useRef, useState, ChangeEvent } from 'react' -import fetchPonyfill from 'fetch-ponyfill' -import debounce from 'lodash.debounce' -import { RenderableExample } from '../lib/service-definitions' -import { BadgeExamples } from './badge-examples' -import { BlockInput } from './common' - -interface SuggestionItem { - title: string - link: string - example: { - pattern: string - namedParams: { [k: string]: string } - queryParams?: { [k: string]: string } - } - preview: - | { - style?: string - } - | undefined -} - -interface SuggestionResponse { - suggestions: SuggestionItem[] -} - -export default function SuggestionAndSearch({ - queryChanged, - onBadgeClick, - baseUrl, -}: { - queryChanged: (query: string) => void - onBadgeClick: (example: RenderableExample, isSuggestion: boolean) => void - baseUrl: string -}): JSX.Element { - const queryChangedDebounced = useRef( - debounce(queryChanged, 50, { leading: true }) - ) - const [isUrl, setIsUrl] = useState(false) - const [inProgress, setInProgress] = useState(false) - const [projectUrl, setProjectUrl] = useState<string>() - const [suggestions, setSuggestions] = useState<SuggestionItem[]>([]) - - const onQueryChanged = React.useCallback( - function ({ - target: { value: query }, - }: ChangeEvent<HTMLInputElement>): void { - const isUrl = query.startsWith('https://') || query.startsWith('http://') - setIsUrl(isUrl) - setProjectUrl(isUrl ? query : undefined) - - queryChangedDebounced.current(query) - }, - [setIsUrl, setProjectUrl, queryChangedDebounced] - ) - - const getSuggestions = React.useCallback( - async function (): Promise<void> { - if (!projectUrl) { - setSuggestions([]) - return - } - - setInProgress(true) - - const fetch = window.fetch || fetchPonyfill - const res = await fetch( - `${baseUrl}/$suggest/v1?url=${encodeURIComponent(projectUrl)}` - ) - let suggestions = [] as SuggestionItem[] - try { - const json = (await res.json()) as SuggestionResponse - // This doesn't validate the response. The default value here prevents - // a crash if the server returns {"err":"Disallowed"}. - suggestions = json.suggestions || [] - } catch (e) { - suggestions = [] - } - - setInProgress(false) - setSuggestions(suggestions) - }, - [setSuggestions, setInProgress, baseUrl, projectUrl] - ) - - function renderSuggestions(): JSX.Element | null { - if (suggestions.length === 0) { - return null - } - - const transformed = suggestions.map( - ({ title, link, example, preview }) => ({ - title, - link, - example: { - ...example, - queryParams: example.queryParams || {}, - }, - preview: preview || {}, - isBadgeSuggestion: true, - }) - ) - - return ( - <BadgeExamples - areBadgeSuggestions - baseUrl={baseUrl} - examples={transformed} - onClick={onBadgeClick} - /> - ) - } - - // TODO: Warning: A future version of React will block javascript: URLs as a security precaution - // how else to do this? - return ( - <section> - <form action="javascript:void 0" autoComplete="off"> - <BlockInput - autoComplete="off" - autoFocus - onChange={onQueryChanged} - placeholder="search / project URL" - /> - <br /> - <button disabled={inProgress} hidden={!isUrl} onClick={getSuggestions}> - Suggest badges - </button> - </form> - {renderSuggestions()} - </section> - ) -} diff --git a/frontend/lib/generate-image-markup.spec.ts b/frontend/lib/generate-image-markup.spec.ts index 786eb211f7c8ef9fa7f8a0e8cc6ef9b22675fbb8..64740e44b60d7c167ed7bf64cde9b1b9125f08bc 100644 --- a/frontend/lib/generate-image-markup.spec.ts +++ b/frontend/lib/generate-image-markup.spec.ts @@ -18,48 +18,30 @@ test(bareLink, () => { }) test(html, () => { - given( - 'https://img.shields.io/badge', - 'https://example.com/example', - 'Example' - ).expect( - '<a href="https://example.com/example"><img alt="Example" src="https://img.shields.io/badge"></a>' + given('https://img.shields.io/badge', 'Example').expect( + '<img alt="Example" src="https://img.shields.io/badge">' ) - given('https://img.shields.io/badge', undefined, undefined).expect( + given('https://img.shields.io/badge', undefined).expect( '<img src="https://img.shields.io/badge">' ) }) test(markdown, () => { - given('https://img.shields.io/badge', undefined, 'Example').expect( + given('https://img.shields.io/badge', 'Example').expect( '' ) - given( - 'https://img.shields.io/badge', - 'https://example.com/example', - 'Example' - ).expect( - '[](https://example.com/example)' - ) - given('https://img.shields.io/badge', undefined, undefined).expect( + given('https://img.shields.io/badge', undefined).expect( '' ) }) test(reStructuredText, () => { - given('https://img.shields.io/badge', undefined, undefined).expect( + given('https://img.shields.io/badge', undefined).expect( '.. image:: https://img.shields.io/badge' ) - given('https://img.shields.io/badge', undefined, 'Example').expect( + given('https://img.shields.io/badge', 'Example').expect( '.. image:: https://img.shields.io/badge\n :alt: Example' ) - given( - 'https://img.shields.io/badge', - 'https://example.com/example', - 'Example' - ).expect( - '.. image:: https://img.shields.io/badge\n :alt: Example\n :target: https://example.com/example' - ) }) test(renderAsciiDocAttributes, () => { @@ -70,33 +52,21 @@ test(renderAsciiDocAttributes, () => { }) test(asciiDoc, () => { - given('https://img.shields.io/badge', undefined, undefined).expect( + given('https://img.shields.io/badge', undefined).expect( 'image:https://img.shields.io/badge[]' ) - given('https://img.shields.io/badge', undefined, 'Example').expect( + given('https://img.shields.io/badge', 'Example').expect( 'image:https://img.shields.io/badge[Example]' ) - given( - 'https://img.shields.io/badge', - undefined, - 'Example, with comma' - ).expect('image:https://img.shields.io/badge["Example, with comma"]') - given( - 'https://img.shields.io/badge', - 'https://example.com/example', - 'Example' - ).expect( - 'image:https://img.shields.io/badge["Example",link="https://example.com/example"]' + given('https://img.shields.io/badge', 'Example, with comma').expect( + 'image:https://img.shields.io/badge["Example, with comma"]' ) }) test(generateMarkup, () => { given({ badgeUrl: 'https://img.shields.io/badge', - link: 'https://example.com/example', title: 'Example', markupFormat: 'markdown', - }).expect( - '[](https://example.com/example)' - ) + }).expect('') }) diff --git a/frontend/lib/generate-image-markup.ts b/frontend/lib/generate-image-markup.ts index f9e4f291eebf171708bbb115432a67c2ad4ed9e8..b83964fe3ec82eaefa44b0ab9bce3a4ea5a564ed 100644 --- a/frontend/lib/generate-image-markup.ts +++ b/frontend/lib/generate-image-markup.ts @@ -2,42 +2,21 @@ export function bareLink(badgeUrl: string, link?: string, title = ''): string { return badgeUrl } -export function html(badgeUrl: string, link?: string, title?: string): string { +export function html(badgeUrl: string, title?: string): string { // To be more robust, this should escape the title. const alt = title ? ` alt="${title}"` : '' - const img = `<img${alt} src="${badgeUrl}">` - if (link) { - return `<a href="${link}">${img}</a>` - } else { - return img - } + return `<img${alt} src="${badgeUrl}">` } -export function markdown( - badgeUrl: string, - link?: string, - title?: string -): string { - const withoutLink = `` - if (link) { - return `[${withoutLink}](${link})` - } else { - return withoutLink - } +export function markdown(badgeUrl: string, title?: string): string { + return `` } -export function reStructuredText( - badgeUrl: string, - link?: string, - title?: string -): string { +export function reStructuredText(badgeUrl: string, title?: string): string { let result = `.. image:: ${badgeUrl}` if (title) { result += `\n :alt: ${title}` } - if (link) { - result += `\n :target: ${link}` - } return result } @@ -91,13 +70,9 @@ export function renderAsciiDocAttributes( } } -export function asciiDoc( - badgeUrl: string, - link?: string, - title?: string -): string { +export function asciiDoc(badgeUrl: string, title?: string): string { const positional = title ? [title] : [] - const named = link ? { link } : ({} as { [k: string]: string }) + const named = {} as { [k: string]: string } const attrs = renderAsciiDocAttributes(positional, named) return `image:${badgeUrl}${attrs}` } @@ -106,12 +81,10 @@ export type MarkupFormat = 'markdown' | 'rst' | 'asciidoc' | 'link' | 'html' export function generateMarkup({ badgeUrl, - link, title, markupFormat, }: { badgeUrl: string - link?: string title?: string markupFormat: MarkupFormat }): string { @@ -122,5 +95,5 @@ export function generateMarkup({ link: bareLink, html, }[markupFormat] - return generatorFn(badgeUrl, link, title) + return generatorFn(badgeUrl, title) } diff --git a/frontend/lib/service-definitions/index.ts b/frontend/lib/service-definitions/index.ts index 31fe5ed8b4250a0870165c763e24244c4c336979..bea8dc4abde7165fe5bd75254edf0c61105865d5 100644 --- a/frontend/lib/service-definitions/index.ts +++ b/frontend/lib/service-definitions/index.ts @@ -64,13 +64,4 @@ export function getDefinitionsForCategory( return byCategory[category] || [] } -export interface Suggestion { - title: string - link: string - example: ExampleSignature - preview: { - style?: string - } -} - -export type RenderableExample = Example | Suggestion +export type RenderableExample = Example diff --git a/frontend/pages/endpoint.tsx b/frontend/pages/endpoint.tsx index aef1c029a72494d89ad8f63363900b7d178cf733..fc93aa756b62445e4c206336a7154cf45a61b211 100644 --- a/frontend/pages/endpoint.tsx +++ b/frontend/pages/endpoint.tsx @@ -244,7 +244,6 @@ export default function EndpointPage(): JSX.Element { exampleQueryParams={{ url: 'https://shields.redsparr0w.com/2473/monday', }} - isPrefilled={false} pattern="/endpoint" title="Custom badge" /> diff --git a/services/suggest.integration.js b/services/suggest.integration.js deleted file mode 100644 index 68efaafcaae4f7bcbc7a3ab999742a69d2a305db..0000000000000000000000000000000000000000 --- a/services/suggest.integration.js +++ /dev/null @@ -1,270 +0,0 @@ -import { expect } from 'chai' -import Camp from '@shields_io/camp' -import portfinder from 'portfinder' -import config from 'config' -import got from '../core/got-test-client.js' -import { setRoutes } from './suggest.js' -import GithubApiProvider from './github/github-api-provider.js' - -describe('Badge suggestions for', function () { - const githubApiBaseUrl = process.env.GITHUB_URL || 'https://api.github.com' - - let token, apiProvider - before(function () { - token = config.util.toObject().private.gh_token - if (!token) { - throw Error('The integration tests require a gh_token to be set') - } - apiProvider = new GithubApiProvider({ - baseUrl: githubApiBaseUrl, - globalToken: token, - withPooling: false, - }) - }) - - let port, baseUrl - before(async function () { - port = await portfinder.getPortPromise() - baseUrl = `http://127.0.0.1:${port}` - }) - - let camp - before(async function () { - camp = Camp.start({ port, hostname: '::' }) - await new Promise(resolve => camp.on('listening', () => resolve())) - }) - after(async function () { - if (camp) { - await new Promise(resolve => camp.close(resolve)) - camp = undefined - } - }) - - const origin = 'https://example.test' - before(function () { - setRoutes([origin], apiProvider, camp) - }) - describe('GitHub', function () { - context('with an existing project', function () { - it('returns the expected suggestions', async function () { - const { statusCode, body } = await got( - `${baseUrl}/$suggest/v1?url=${encodeURIComponent( - 'https://github.com/atom/atom' - )}`, - { - responseType: 'json', - } - ) - expect(statusCode).to.equal(200) - expect(body).to.deep.equal({ - suggestions: [ - { - title: 'GitHub issues', - link: 'https://github.com/atom/atom/issues', - example: { - pattern: '/github/issues/:user/:repo', - namedParams: { user: 'atom', repo: 'atom' }, - queryParams: {}, - }, - }, - { - title: 'GitHub forks', - link: 'https://github.com/atom/atom/network', - example: { - pattern: '/github/forks/:user/:repo', - namedParams: { user: 'atom', repo: 'atom' }, - queryParams: {}, - }, - }, - { - title: 'GitHub stars', - link: 'https://github.com/atom/atom/stargazers', - example: { - pattern: '/github/stars/:user/:repo', - namedParams: { user: 'atom', repo: 'atom' }, - queryParams: {}, - }, - }, - { - title: 'GitHub license', - link: 'https://github.com/atom/atom/blob/master/LICENSE.md', - example: { - pattern: '/github/license/:user/:repo', - namedParams: { user: 'atom', repo: 'atom' }, - queryParams: {}, - }, - }, - { - title: 'Twitter', - link: 'https://twitter.com/intent/tweet?text=Wow:&url=https%3A%2F%2Fgithub.com%2Fatom%2Fatom', - example: { - pattern: '/twitter/url', - namedParams: {}, - queryParams: { - url: 'https://github.com/atom/atom', - }, - }, - preview: { - style: 'social', - }, - }, - ], - }) - }) - }) - - context('with a non-existent project', function () { - it('returns the expected suggestions', async function () { - this.timeout(5000) - - const { statusCode, body } = await got( - `${baseUrl}/$suggest/v1?url=${encodeURIComponent( - 'https://github.com/badges/not-a-real-project' - )}`, - { - responseType: 'json', - } - ) - expect(statusCode).to.equal(200) - expect(body).to.deep.equal({ - suggestions: [ - { - title: 'GitHub issues', - link: 'https://github.com/badges/not-a-real-project/issues', - example: { - pattern: '/github/issues/:user/:repo', - namedParams: { user: 'badges', repo: 'not-a-real-project' }, - queryParams: {}, - }, - }, - { - title: 'GitHub forks', - link: 'https://github.com/badges/not-a-real-project/network', - example: { - pattern: '/github/forks/:user/:repo', - namedParams: { user: 'badges', repo: 'not-a-real-project' }, - queryParams: {}, - }, - }, - { - title: 'GitHub stars', - link: 'https://github.com/badges/not-a-real-project/stargazers', - example: { - pattern: '/github/stars/:user/:repo', - namedParams: { user: 'badges', repo: 'not-a-real-project' }, - queryParams: {}, - }, - }, - { - title: 'GitHub license', - link: 'https://github.com/badges/not-a-real-project', - example: { - pattern: '/github/license/:user/:repo', - namedParams: { user: 'badges', repo: 'not-a-real-project' }, - queryParams: {}, - }, - }, - { - title: 'Twitter', - link: 'https://twitter.com/intent/tweet?text=Wow:&url=https%3A%2F%2Fgithub.com%2Fbadges%2Fnot-a-real-project', - example: { - pattern: '/twitter/url', - namedParams: {}, - queryParams: { - url: 'https://github.com/badges/not-a-real-project', - }, - }, - preview: { - style: 'social', - }, - }, - ], - }) - }) - }) - }) - - describe('GitLab', function () { - context('with an existing project', function () { - it('returns the expected suggestions', async function () { - const { statusCode, body } = await got( - `${baseUrl}/$suggest/v1?url=${encodeURIComponent( - 'https://gitlab.com/gitlab-org/gitlab' - )}`, - { - responseType: 'json', - } - ) - expect(statusCode).to.equal(200) - expect(body).to.deep.equal({ - suggestions: [ - { - title: 'GitLab pipeline', - link: 'https://gitlab.com/gitlab-org/gitlab/builds', - example: { - pattern: '/gitlab/pipeline/:user/:repo', - namedParams: { user: 'gitlab-org', repo: 'gitlab' }, - queryParams: {}, - }, - }, - { - title: 'Twitter', - link: 'https://twitter.com/intent/tweet?text=Wow:&url=https%3A%2F%2Fgitlab.com%2Fgitlab-org%2Fgitlab', - example: { - pattern: '/twitter/url', - namedParams: {}, - queryParams: { - url: 'https://gitlab.com/gitlab-org/gitlab', - }, - }, - preview: { - style: 'social', - }, - }, - ], - }) - }) - }) - - context('with an nonexisting project', function () { - it('returns the expected suggestions', async function () { - const { statusCode, body } = await got( - `${baseUrl}/$suggest/v1?url=${encodeURIComponent( - 'https://gitlab.com/gitlab-org/not-gitlab' - )}`, - { - responseType: 'json', - } - ) - expect(statusCode).to.equal(200) - expect(body).to.deep.equal({ - suggestions: [ - { - title: 'GitLab pipeline', - link: 'https://gitlab.com/gitlab-org/not-gitlab/builds', - example: { - pattern: '/gitlab/pipeline/:user/:repo', - namedParams: { user: 'gitlab-org', repo: 'not-gitlab' }, - queryParams: {}, - }, - }, - { - title: 'Twitter', - link: 'https://twitter.com/intent/tweet?text=Wow:&url=https%3A%2F%2Fgitlab.com%2Fgitlab-org%2Fnot-gitlab', - example: { - pattern: '/twitter/url', - namedParams: {}, - queryParams: { - url: 'https://gitlab.com/gitlab-org/not-gitlab', - }, - }, - preview: { - style: 'social', - }, - }, - ], - }) - }) - }) - }) -}) diff --git a/services/suggest.js b/services/suggest.js deleted file mode 100644 index 6e2936265b729474d57e53d285b92e2667bc84d8..0000000000000000000000000000000000000000 --- a/services/suggest.js +++ /dev/null @@ -1,201 +0,0 @@ -// Suggestion API -// -// eg. /$suggest/v1?url=https://github.com/badges/shields -// -// This endpoint is called from frontend/components/suggestion-and-search.js. - -import { URL } from 'url' -import { fetch } from '../core/base-service/got.js' - -function twitterPage(url) { - if (url.protocol === null) { - return null - } - - const schema = url.protocol.slice(0, -1) - const host = url.host - const path = url.pathname - return { - title: 'Twitter', - link: `https://twitter.com/intent/tweet?text=Wow:&url=${encodeURIComponent( - url.href - )}`, - example: { - pattern: '/twitter/url', - namedParams: {}, - queryParams: { url: `${schema}://${host}${path}` }, - }, - preview: { - style: 'social', - }, - } -} - -function githubIssues(user, repo) { - const repoSlug = `${user}/${repo}` - return { - title: 'GitHub issues', - link: `https://github.com/${repoSlug}/issues`, - example: { - pattern: '/github/issues/:user/:repo', - namedParams: { user, repo }, - queryParams: {}, - }, - } -} - -function githubForks(user, repo) { - const repoSlug = `${user}/${repo}` - return { - title: 'GitHub forks', - link: `https://github.com/${repoSlug}/network`, - example: { - pattern: '/github/forks/:user/:repo', - namedParams: { user, repo }, - queryParams: {}, - }, - } -} - -function githubStars(user, repo) { - const repoSlug = `${user}/${repo}` - return { - title: 'GitHub stars', - link: `https://github.com/${repoSlug}/stargazers`, - example: { - pattern: '/github/stars/:user/:repo', - namedParams: { user, repo }, - queryParams: {}, - }, - } -} - -async function githubLicense(githubApiProvider, user, repo) { - const repoSlug = `${user}/${repo}` - - let link = `https://github.com/${repoSlug}` - - const { buffer } = await githubApiProvider.fetch( - fetch, - `/repos/${repoSlug}/license` - ) - try { - const data = JSON.parse(buffer) - if ('html_url' in data) { - link = data.html_url - } - } catch (e) {} - - return { - title: 'GitHub license', - link, - example: { - pattern: '/github/license/:user/:repo', - namedParams: { user, repo }, - queryParams: {}, - }, - } -} - -function gitlabPipeline(user, repo) { - const repoSlug = `${user}/${repo}` - return { - title: 'GitLab pipeline', - link: `https://gitlab.com/${repoSlug}/builds`, - example: { - pattern: '/gitlab/pipeline/:user/:repo', - namedParams: { user, repo }, - queryParams: {}, - }, - } -} - -async function findSuggestions(githubApiProvider, url) { - let promises = [] - if (url.hostname === 'github.com' || url.hostname === 'gitlab.com') { - const userRepo = url.pathname.slice(1).split('/') - const user = userRepo[0] - const repo = userRepo[1] - if (url.hostname === 'github.com') { - promises = promises.concat([ - githubIssues(user, repo), - githubForks(user, repo), - githubStars(user, repo), - githubLicense(githubApiProvider, user, repo), - ]) - } else { - promises = promises.concat([gitlabPipeline(user, repo)]) - } - } - promises.push(twitterPage(url)) - - const suggestions = await Promise.all(promises) - - return suggestions.filter(b => b != null) -} - -// data: {url}, JSON-serializable object. -// end: function(json), with json of the form: -// - suggestions: list of objects of the form: -// - title: string -// - link: target as a string URL -// - example: object -// - pattern: string -// - namedParams: object -// - queryParams: object (optional) -// - link: target as a string URL -// - preview: object (optional) -// - style: string -function setRoutes(allowedOrigin, githubApiProvider, server) { - server.ajax.on('suggest/v1', (data, end, ask) => { - // The typical dev and production setups are cross-origin. However, in - // Heroku deploys and some self-hosted deploys these requests may come from - // the same host. Chrome does not send an Origin header on same-origin - // requests, but Firefox does. - // - // It would be better to solve this problem using some well-tested - // middleware. - const origin = ask.req.headers.origin - if (origin) { - let host - try { - host = new URL(origin).hostname - } catch (e) { - ask.res.setHeader('Access-Control-Allow-Origin', 'null') - end({ err: 'Disallowed' }) - return - } - - if (host !== ask.req.headers.host) { - if (allowedOrigin.includes(origin)) { - ask.res.setHeader('Access-Control-Allow-Origin', origin) - } else { - ask.res.setHeader('Access-Control-Allow-Origin', 'null') - end({ err: 'Disallowed' }) - return - } - } - } - - let url - try { - url = new URL(data.url) - } catch (e) { - end({ err: `${e}` }) - return - } - - findSuggestions(githubApiProvider, url) - // This interacts with callback code and can't use async/await. - // eslint-disable-next-line promise/prefer-await-to-then - .then(suggestions => { - end({ suggestions }) - }) - // eslint-disable-next-line promise/prefer-await-to-then - .catch(err => { - end({ suggestions: [], err }) - }) - }) -} - -export { findSuggestions, githubLicense, setRoutes } diff --git a/services/suggest.spec.js b/services/suggest.spec.js deleted file mode 100644 index 3e2a9c8f495c11cd56be0222c1385bd9df2cbccb..0000000000000000000000000000000000000000 --- a/services/suggest.spec.js +++ /dev/null @@ -1,177 +0,0 @@ -import Camp from '@shields_io/camp' -import { expect } from 'chai' -import nock from 'nock' -import portfinder from 'portfinder' -import got from '../core/got-test-client.js' -import { setRoutes, githubLicense } from './suggest.js' -import GithubApiProvider from './github/github-api-provider.js' - -describe('Badge suggestions', function () { - const githubApiBaseUrl = 'https://api.github.test' - const apiProvider = new GithubApiProvider({ - baseUrl: githubApiBaseUrl, - globalToken: 'fake-token', - withPooling: false, - }) - - describe('GitHub license', function () { - context('When html_url included in response', function () { - it('Should link to it', async function () { - const scope = nock(githubApiBaseUrl) - .get('/repos/atom/atom/license') - .reply(200, { - html_url: 'https://github.com/atom/atom/blob/master/LICENSE.md', - license: { - key: 'mit', - name: 'MIT License', - spdx_id: 'MIT', - url: 'https://api.github.com/licenses/mit', - node_id: 'MDc6TGljZW5zZTEz', - }, - }) - - expect(await githubLicense(apiProvider, 'atom', 'atom')).to.deep.equal({ - title: 'GitHub license', - link: 'https://github.com/atom/atom/blob/master/LICENSE.md', - example: { - pattern: '/github/license/:user/:repo', - namedParams: { user: 'atom', repo: 'atom' }, - queryParams: {}, - }, - }) - - scope.done() - }) - }) - - context('When html_url not included in response', function () { - it('Should link to the repo', async function () { - const scope = nock(githubApiBaseUrl) - .get('/repos/atom/atom/license') - .reply(200, { - license: { key: 'mit' }, - }) - - expect(await githubLicense(apiProvider, 'atom', 'atom')).to.deep.equal({ - title: 'GitHub license', - link: 'https://github.com/atom/atom', - example: { - pattern: '/github/license/:user/:repo', - namedParams: { user: 'atom', repo: 'atom' }, - queryParams: {}, - }, - }) - - scope.done() - }) - }) - }) - - describe('Scoutcamp integration', function () { - let port, baseUrl - before(async function () { - port = await portfinder.getPortPromise() - baseUrl = `http://127.0.0.1:${port}` - }) - - let camp - before(async function () { - camp = Camp.start({ port, hostname: '::' }) - await new Promise(resolve => camp.on('listening', () => resolve())) - }) - after(async function () { - if (camp) { - await new Promise(resolve => camp.close(resolve)) - camp = undefined - } - }) - - const origin = 'https://example.test' - before(function () { - setRoutes([origin], apiProvider, camp) - }) - - context('without an origin header', function () { - it('returns the expected suggestions', async function () { - const scope = nock(githubApiBaseUrl) - .get('/repos/atom/atom/license') - .reply(200, { - html_url: 'https://github.com/atom/atom/blob/master/LICENSE.md', - license: { - key: 'mit', - name: 'MIT License', - spdx_id: 'MIT', - url: 'https://api.github.com/licenses/mit', - node_id: 'MDc6TGljZW5zZTEz', - }, - }) - - const { statusCode, body } = await got( - `${baseUrl}/$suggest/v1?url=${encodeURIComponent( - 'https://github.com/atom/atom' - )}`, - { - responseType: 'json', - } - ) - expect(statusCode).to.equal(200) - expect(body).to.deep.equal({ - suggestions: [ - { - title: 'GitHub issues', - link: 'https://github.com/atom/atom/issues', - example: { - pattern: '/github/issues/:user/:repo', - namedParams: { user: 'atom', repo: 'atom' }, - queryParams: {}, - }, - }, - { - title: 'GitHub forks', - link: 'https://github.com/atom/atom/network', - example: { - pattern: '/github/forks/:user/:repo', - namedParams: { user: 'atom', repo: 'atom' }, - queryParams: {}, - }, - }, - { - title: 'GitHub stars', - link: 'https://github.com/atom/atom/stargazers', - example: { - pattern: '/github/stars/:user/:repo', - namedParams: { user: 'atom', repo: 'atom' }, - queryParams: {}, - }, - }, - { - title: 'GitHub license', - link: 'https://github.com/atom/atom/blob/master/LICENSE.md', - example: { - pattern: '/github/license/:user/:repo', - namedParams: { user: 'atom', repo: 'atom' }, - queryParams: {}, - }, - }, - { - title: 'Twitter', - link: 'https://twitter.com/intent/tweet?text=Wow:&url=https%3A%2F%2Fgithub.com%2Fatom%2Fatom', - example: { - pattern: '/twitter/url', - namedParams: {}, - queryParams: { - url: 'https://github.com/atom/atom', - }, - }, - preview: { - style: 'social', - }, - }, - ], - }) - - scope.done() - }) - }) - }) -})