├── .gitignore ├── .prettierrc ├── CONTRIBUTING.md ├── LICENSE ├── README.md ├── dist ├── icons │ ├── graphql-128.png │ ├── graphql-16.png │ ├── graphql-48.png │ ├── graphql-true.png │ ├── icon-graphql-no-128.png │ ├── icon-graphql-no-16.png │ ├── icon-graphql-no-48.png │ └── icon-graphql-yes.png ├── manifest.json └── popup.html ├── package-lock.json ├── package.json ├── src ├── app │ ├── background.ts │ └── content.ts ├── styles │ ├── font.css │ └── popup.css └── ui │ └── popup.tsx ├── tsconfig.json └── webpack.config.js /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | dist/js/* 3 | node_modules/ 4 | dist.* -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | {} -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contribute to the "Is this GraphQL?" extension 2 | 3 | Hello folks, happy to have you here! This Google Chrome extension is open source, so if you encounter any problems or have ideas on how to improve its functionality: we would love to see your contributions. 4 | Whether it's raising issues, starting a discussion or contributing lines of code. It all has value. 5 | 6 | 7 | ## Deployment 8 | 9 | > **Info** 10 | > Only Stellate employees can publish a new version of the extension. There are some manual steps involved. 11 | > 12 | > External contributors: please file a Pull Request and we will take care of releasing it once it has been merged. 13 | > 14 | > Stellate employees, check out this [Gain access to "Is this GraphQL?" extension in Chrome Web Store](https://www.notion.so/stellatehq/Gain-access-to-Is-this-GraphQL-extension-in-Chrome-Web-Store-26bb5d55055541918133b86a81f3d8f8?pvs=4) document. 15 | 16 | * Checkout the repository 17 | * Create new branch 18 | * `git checkout -b v1.5.0` 19 | * Bump the version to a new semver version of the upcoming release. This involves two steps 20 | 1. Bump `dist/manifest.json` version and commmit manually 21 | 2. `npm version ` (commits & tags automatically) 22 | * ⚠️ Use Node 16 (it's EOL, we know, sorry!) 23 | * Build the ZIP bundle of the new release 24 | 1. `npm run build` 25 | 2. `cd dist` 26 | 3. `zip -r ../is-this-graphql-extension.zip *` 27 | 4. `open ..` (to show you the folder where the ZIP file is located) 28 | * Navigate to the "Is this GraphQL" extension in the Chrome Developer Dashboard 29 | * Select "Package" on the left menu 30 | * Top right "Upload new package" and select the newly created ZIP file from before 31 | * "Save draft" 32 | * Check in "Package" that the draft shows the new version number 33 | * Go to "Distribution" and hit "Submit for review" 34 | * Open up PR on the GitHub repo with the version bumps 35 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | BSD 3-Clause License 2 | 3 | Copyright (c) 2018, Maximilian Stoiber 4 | All rights reserved. 5 | 6 | Redistribution and use in source and binary forms, with or without 7 | modification, are permitted provided that the following conditions are met: 8 | 9 | * Redistributions of source code must retain the above copyright notice, this 10 | list of conditions and the following disclaimer. 11 | 12 | * Redistributions in binary form must reproduce the above copyright notice, 13 | this list of conditions and the following disclaimer in the documentation 14 | and/or other materials provided with the distribution. 15 | 16 | * Neither the name of the copyright holder nor the names of its 17 | contributors may be used to endorse or promote products derived from 18 | this software without specific prior written permission. 19 | 20 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" 21 | AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE 22 | IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE 23 | DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE 24 | FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL 25 | DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR 26 | SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER 27 | CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, 28 | OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE 29 | OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 30 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Is This GraphQL? 2 | 3 | Chrome extension based on [duo-labs/chrome-extension-boilerplate](https://github.com/duo-labs/chrome-extension-boilerplate) that detects whether a website is using GraphQL. 4 | 5 | **Install it from the [Chrome Web Store](https://chrome.google.com/webstore/detail/is-this-graphql/bpddjcoknlkjonemmdokaeeplmjhhnhh)** 6 | 7 | ## Get started 8 | 9 | Clone this repository, and then, in this directory: 10 | 11 | 1. `npm install` 12 | 2. `npm run dev` 13 | 14 | Your unpacked Chrome extension will be compiled into `dist/`. You can load it into Chrome by enabling developer mode on the "Extensions" page, hitting "Load unpacked", and selecting the `dist/` folder. You can pack the extension into a `.crx` by using the "Pack extension" button on the same page. 15 | 16 | Use `npx build` to build manually. 17 | -------------------------------------------------------------------------------- /dist/icons/graphql-128.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/StellateHQ/is-this-graphql-extension/7161b5514d821fba4511c75b0609294af9eaa4a1/dist/icons/graphql-128.png -------------------------------------------------------------------------------- /dist/icons/graphql-16.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/StellateHQ/is-this-graphql-extension/7161b5514d821fba4511c75b0609294af9eaa4a1/dist/icons/graphql-16.png -------------------------------------------------------------------------------- /dist/icons/graphql-48.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/StellateHQ/is-this-graphql-extension/7161b5514d821fba4511c75b0609294af9eaa4a1/dist/icons/graphql-48.png -------------------------------------------------------------------------------- /dist/icons/graphql-true.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/StellateHQ/is-this-graphql-extension/7161b5514d821fba4511c75b0609294af9eaa4a1/dist/icons/graphql-true.png -------------------------------------------------------------------------------- /dist/icons/icon-graphql-no-128.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/StellateHQ/is-this-graphql-extension/7161b5514d821fba4511c75b0609294af9eaa4a1/dist/icons/icon-graphql-no-128.png -------------------------------------------------------------------------------- /dist/icons/icon-graphql-no-16.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/StellateHQ/is-this-graphql-extension/7161b5514d821fba4511c75b0609294af9eaa4a1/dist/icons/icon-graphql-no-16.png -------------------------------------------------------------------------------- /dist/icons/icon-graphql-no-48.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/StellateHQ/is-this-graphql-extension/7161b5514d821fba4511c75b0609294af9eaa4a1/dist/icons/icon-graphql-no-48.png -------------------------------------------------------------------------------- /dist/icons/icon-graphql-yes.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/StellateHQ/is-this-graphql-extension/7161b5514d821fba4511c75b0609294af9eaa4a1/dist/icons/icon-graphql-yes.png -------------------------------------------------------------------------------- /dist/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "Is This GraphQL", 3 | "version": "1.5.1", 4 | "manifest_version": 2, 5 | "description": "See whether a website is using GraphQL at a glance", 6 | "homepage_url": "https://stellate.co", 7 | "icons": { 8 | "16": "icons/icon-graphql-no-16.png", 9 | "48": "icons/icon-graphql-no-48.png", 10 | "128": "icons/icon-graphql-no-128.png" 11 | }, 12 | "browser_action": { 13 | "default_title": "GraphQL", 14 | "default_popup": "popup.html" 15 | }, 16 | "background": { 17 | "scripts": [ 18 | "js/background.js" 19 | ], 20 | "persistent": true 21 | }, 22 | "permissions": [ 23 | "webRequest", 24 | "https://*/*" 25 | ] 26 | } 27 | 28 | -------------------------------------------------------------------------------- /dist/popup.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 |
8 | 9 | 10 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "is-this-graphql", 3 | "version": "1.5.1", 4 | "description": "Check whether a website is using GraphQL or not at a glance!", 5 | "main": "index.js", 6 | "scripts": { 7 | "dev": "nodemon --watch src -e ts,tsx,js,jsx,json,css --exec \"npm run build\"", 8 | "build": "webpack", 9 | "test": "echo \"Error: no test specified\" && exit 1" 10 | }, 11 | "keywords": [], 12 | "author": "", 13 | "license": "BSD-3-Clause", 14 | "devDependencies": { 15 | "@types/chrome": "0.0.75", 16 | "@types/react": "^16.7.8", 17 | "@types/react-dom": "^16.0.11", 18 | "css-loader": "^1.0.1", 19 | "nodemon": "^2.0.7", 20 | "style-loader": "^0.23.1", 21 | "ts-loader": "^5.3.1", 22 | "typescript": "^3.1.6", 23 | "webpack": "^4.26.1", 24 | "webpack-cli": "^3.1.2" 25 | }, 26 | "dependencies": { 27 | "@types/psl": "^1.1.0", 28 | "@use-it/interval": "^1.0.0", 29 | "graphql": "^16.8.1", 30 | "psl": "^1.8.0", 31 | "react": "^16.6.3", 32 | "react-dom": "^16.6.3" 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /src/app/background.ts: -------------------------------------------------------------------------------- 1 | import * as psl from "psl"; 2 | import { parse } from 'graphql'; 3 | 4 | const enc = new TextDecoder("utf-8"); 5 | 6 | type Heuristics = { 7 | body?: boolean; 8 | param?: boolean; 9 | contentType?: boolean; 10 | isStellate?: boolean; 11 | }; 12 | const requests = new Map(); 13 | const tabs = new Map(); 14 | 15 | /** 16 | * On Message is used for communicating between the popup and this background script 17 | */ 18 | chrome.runtime.onMessage.addListener(function (message, sender, sendResponse) { 19 | sendResponse(handlers[message.type]?.(message) || null); 20 | }); 21 | const handlers = { 22 | getResult: (message) => tabs.get(message.tabId), 23 | }; 24 | 25 | chrome.tabs.onUpdated.addListener((tabId, changeInfo, tab) => { 26 | if (changeInfo.status === "complete" && tab?.status === "complete" && tab.url !== undefined) { 27 | chrome.tabs.executeScript( 28 | tabId, 29 | { code: "[...document.querySelectorAll('script')].some((script) => script.textContent.includes('__typename'))" }, 30 | async (results) => { 31 | if (!results[0]) return; 32 | 33 | if (await checkKnownFalsePositive(tab.url)) return; 34 | 35 | chrome.browserAction.setIcon({ 36 | tabId: tab.id, 37 | path: "/icons/icon-graphql-yes.png" 38 | }); 39 | 40 | chrome.browserAction.getPopup({ tabId }, (url) => { 41 | const parsed = new URL(url); 42 | const params = new URLSearchParams(parsed.search.replace(/^\?/, "")); 43 | params.set("is-ssr", "true"); 44 | params.set("is-graphql", "true"); 45 | chrome.browserAction.setPopup({ 46 | tabId, 47 | popup: "popup.html?" + params.toString() 48 | }); 49 | }); 50 | } 51 | ); 52 | } 53 | }); 54 | 55 | /** 56 | * On Before Request checks for GraphQL-related data in the body 57 | */ 58 | chrome.webRequest.onBeforeRequest.addListener( 59 | async (details) => { 60 | try { 61 | if (!(await checkFirstPartyRequest(details))) return; 62 | 63 | const isBody = isJSONGraphQLBody(details); 64 | const isParam = isGraphQLQueryParam(details); 65 | if (!isBody && !isParam) return; 66 | 67 | requests.set(details.requestId, { 68 | ...(requests.get(details.requestId) ?? {}), 69 | body: isBody, 70 | param: isParam, 71 | }); 72 | } catch (err) { 73 | console.error(err); 74 | } 75 | }, 76 | { urls: [""] }, 77 | ["requestBody"] 78 | ); 79 | 80 | /** 81 | * On Headers Received checks for the X-Powered-By: Stellate header 82 | */ 83 | chrome.webRequest.onHeadersReceived.addListener( 84 | async (details) => { 85 | try { 86 | if (!(await checkFirstPartyRequest(details))) return; 87 | 88 | const isStellate = !!details.responseHeaders?.find( 89 | (header) => 90 | header.name === "x-powered-by" && 91 | header.value.toLowerCase() === "stellate" 92 | ); 93 | if (!isStellate) return; 94 | 95 | requests.set(details.requestId, { 96 | ...(requests.get(details.requestId) ?? {}), 97 | isStellate, 98 | }); 99 | } catch (err) { 100 | console.error(err); 101 | } 102 | }, 103 | { urls: [""] }, 104 | ["responseHeaders"] 105 | ); 106 | 107 | /** 108 | * On Send Headers checks for GraphQL-related headers 109 | */ 110 | chrome.webRequest.onSendHeaders.addListener( 111 | async (details) => { 112 | try { 113 | if (!(await checkFirstPartyRequest(details))) return; 114 | 115 | const isContentType = isGraphQLContentType(details); 116 | if (!isContentType) return; 117 | 118 | requests.set(details.requestId, { 119 | ...(requests.get(details.requestId) ?? {}), 120 | contentType: isContentType, 121 | }); 122 | } catch (err) { 123 | console.error(err); 124 | } 125 | }, 126 | { urls: [""] }, 127 | ["requestHeaders"] 128 | ); 129 | 130 | /** 131 | * On Completed checks whether any heuristics were true and, if they were, double-checks whether it's a false positive 132 | */ 133 | chrome.webRequest.onCompleted.addListener( 134 | async (details) => { 135 | try { 136 | const request = requests.get(details.requestId) ?? {}; 137 | const tab = tabs.get(details.tabId) ?? {}; 138 | const isGraphQL = Object.values(request).some((value) => value === true); 139 | 140 | // Delete the per-request result... 141 | requests.delete(details.requestId); 142 | // ...and store it per-tab instead 143 | tabs.set(details.tabId, { 144 | // If the tab already has true in any keys, keep it there 145 | // only overwrite potential false results 146 | body: tab.body || request.body, 147 | contentType: tab.contentType || request.contentType, 148 | param: tab.param || request.param, 149 | isStellate: tab.isStellate || request.isStellate, 150 | }); 151 | 152 | if (!isGraphQL) return; 153 | 154 | const isFalsePositive = await checkKnownFalsePositive( 155 | details.url, 156 | details.initiator 157 | ); 158 | 159 | if (isFalsePositive) return; 160 | 161 | chrome.browserAction.setIcon({ 162 | tabId: details.tabId, 163 | path: "/icons/icon-graphql-yes.png", 164 | }); 165 | 166 | chrome.browserAction.getPopup({ tabId: details.tabId }, (url) => { 167 | const parsed = new URL(url); 168 | const params = new URLSearchParams(parsed.search.replace(/^\?/, "")); 169 | const apis = params.getAll("graphql-api"); 170 | params.set("is-graphql", "true"); 171 | if (tab.isStellate || request.isStellate) { 172 | params.set("is-stellate", "true"); 173 | } 174 | params.delete("graphql-api"); 175 | // Only do each unique API once 176 | [...new Set([...apis, details.url])].forEach((api) => { 177 | params.append("graphql-api", api); 178 | }); 179 | chrome.browserAction.setPopup({ 180 | tabId: details.tabId, 181 | popup: "popup.html?" + params.toString(), 182 | }); 183 | }); 184 | } catch (err) { 185 | console.error(err); 186 | } 187 | }, 188 | { urls: [""] } 189 | ); 190 | 191 | /** 192 | * Detect application/graphql GraphQL usage 193 | */ 194 | function isGraphQLContentType( 195 | details: chrome.webRequest.WebRequestHeadersDetails 196 | ) { 197 | try { 198 | const contentType = 199 | details.requestHeaders.find( 200 | ({ name }) => name.toLowerCase() === `content-type` 201 | )?.value ?? ""; 202 | 203 | return contentType.indexOf(`graphql`) > -1; 204 | } catch (err) { 205 | return false; 206 | } 207 | } 208 | 209 | /** 210 | * Detect ?query={me{id}}-style GraphQL usage 211 | */ 212 | function isGraphQLQueryParam(details: chrome.webRequest.WebRequestDetails) { 213 | try { 214 | const params = new URL(details.url).searchParams; 215 | const query = params.get("query") ?? ""; 216 | const extensions = params.get("extensions") ?? ""; 217 | 218 | try { 219 | const result = query && !!parse(query as string); 220 | return !!result 221 | } catch (e) {} 222 | 223 | return ( 224 | query.indexOf(`{`) === 0 || 225 | query.indexOf(`query`) === 0 || 226 | query.indexOf(`mutation`) === 0 || 227 | extensions.indexOf(`persistedQuery`) > -1 228 | ); 229 | } catch (err) { 230 | return false; 231 | } 232 | } 233 | 234 | /** 235 | * Detect JSON POST GraphQL usage 236 | */ 237 | function isJSONGraphQLBody(details: chrome.webRequest.WebRequestBodyDetails) { 238 | try { 239 | if (!details.requestBody?.raw?.[0]?.bytes) return false; 240 | 241 | const body = enc 242 | .decode(details.requestBody.raw[0].bytes) ; 243 | const stringified = body.replace(/\s/g, ""); 244 | 245 | if (typeof body !== "string") return; 246 | 247 | const json = tryParseJson(body); 248 | if (json && typeof json.query === 'string') { 249 | return !!parse(json.query as string); 250 | } else if (json && json.extensions && typeof json.extensions === 'object' && 'persistedQuery' in json.extensions) { 251 | return true; 252 | } 253 | 254 | return ( 255 | stringified.includes('"query":"{') || 256 | stringified.includes('"query":"query') || 257 | stringified.includes('"query":"mutation') || 258 | stringified.includes(`"persistedQuery"`) 259 | ); 260 | } catch (err) { 261 | return false; 262 | } 263 | } 264 | 265 | function tryParseJson(x: string): Record { 266 | try { 267 | return JSON.parse(x) 268 | } catch (e) { 269 | return undefined 270 | } 271 | } 272 | 273 | // tabId: The ID of the tab in which the request takes place. 274 | // Set to -1 if the request isn't related to a tab. 275 | const NO_TAB = -1; 276 | 277 | /** 278 | * To avoid tracking embeds/iframes/other third-party scripts on a page we make sure the page itself sent the request 279 | */ 280 | function checkFirstPartyRequest( 281 | details: chrome.webRequest.WebRequestDetails 282 | ): Promise { 283 | try { 284 | return new Promise((res) => { 285 | if (details.tabId === NO_TAB) return res(false); 286 | 287 | chrome.tabs.get(details.tabId, (tab) => { 288 | if (!tab.url) return res(false); 289 | 290 | const initiator = psl.parse(new URL(details.initiator).hostname); 291 | const url = psl.parse(new URL(tab.url).hostname); 292 | // @ts-ignore the TS types for the psl module are incorrect, .domain does exist. 293 | res(initiator.domain === url.domain); 294 | }); 295 | }); 296 | } catch (err) { 297 | return Promise.resolve(false); 298 | } 299 | } 300 | 301 | /** 302 | * Check the "known false positive" list to ensure the website is actually using GraphQL 303 | */ 304 | function checkKnownFalsePositive( 305 | requestUrl: string, 306 | initiatorUrl?: string 307 | ): Promise { 308 | // Check the false positive db 309 | let isGraphQLAPIUrl = new URL(`https://is-this-graphql.recc.workers.dev`); 310 | 311 | if (initiatorUrl) 312 | isGraphQLAPIUrl.searchParams.set(`i`, new URL(initiatorUrl).hostname); 313 | 314 | isGraphQLAPIUrl.searchParams.set( 315 | `r`, 316 | new URL(requestUrl).hostname + new URL(requestUrl).pathname 317 | ); 318 | 319 | return fetch(isGraphQLAPIUrl.toString()) 320 | .then((res) => res.json()) 321 | .then((data) => { 322 | // Known false positive! 323 | if (data === false) return true; 324 | 325 | return false; 326 | }) 327 | .catch((err) => { 328 | return false; 329 | }); 330 | } 331 | -------------------------------------------------------------------------------- /src/app/content.ts: -------------------------------------------------------------------------------- 1 | chrome.runtime.onMessage.addListener(function (request, sender) { 2 | console.log( 3 | sender.tab 4 | ? "from a content script:" + sender.tab.url 5 | : "from the extension" 6 | ); 7 | }); 8 | -------------------------------------------------------------------------------- /src/styles/font.css: -------------------------------------------------------------------------------- 1 | 2 | /* 3 | Copyright (C) 2011-2021 Hoefler & Co. 4 | This software is the property of Hoefler & Co. (H&Co). 5 | Your right to access and use this software is subject to the 6 | applicable License Agreement, or Terms of Service, that exists 7 | between you and H&Co. If no such agreement exists, you may not 8 | access or use this software for any purpose. 9 | This software may only be hosted at the locations specified in 10 | the applicable License Agreement or Terms of Service, and only 11 | for the purposes expressly set forth therein. You may not copy, 12 | modify, convert, create derivative works from or distribute this 13 | software in any way, or make it accessible to any third party, 14 | without first obtaining the written permission of H&Co. 15 | For more information, please visit us at http://typography.com. 16 | 492065-141487-20210826 17 | */ 18 | 19 | @font-face{ font-family: "Gotham A"; src: url(data:application/x-font-woff2;base64,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); font-weight:500; font-style:normal; } -------------------------------------------------------------------------------- /src/styles/popup.css: -------------------------------------------------------------------------------- 1 | :root { 2 | --lightgrey: #F1F5F8; 3 | --grey: #3B4C6A; 4 | --white: #fff; 5 | --pink: #D345A7; 6 | --stellate: #5f8bd8; 7 | } 8 | 9 | * { 10 | box-sizing: border-box; 11 | } 12 | 13 | body { 14 | width: 390px; 15 | margin: 0; 16 | font-family: "Gotham", sans-serif; 17 | font-style: normal; 18 | font-weight: 500; 19 | -webkit-font-smoothing: antialiased; 20 | } 21 | 22 | h1, h2, p, a { 23 | margin: 0; 24 | } 25 | 26 | h2 { 27 | margin: 0; 28 | font-size: 28px; 29 | line-height: 1.3; 30 | font-weight: 500; 31 | } 32 | 33 | #root { 34 | width: 100%; 35 | height: 100%; 36 | } 37 | 38 | /* Basic Popup Styles */ 39 | 40 | .popup { 41 | --subline-opacity: 0.6; 42 | 43 | background-color: var(--lightgrey); 44 | color: var(--grey); 45 | } 46 | 47 | /* Success Variation */ 48 | .popup-success { 49 | --subline-opacity: 0.7; 50 | background-color: var(--pink); 51 | color: var(--white); 52 | } 53 | 54 | .popup-stellate { 55 | background-color: var(--stellate); 56 | } 57 | 58 | .popup-stellate .api { 59 | color: var(--stellate); 60 | } 61 | 62 | .popup-stellate h2 { 63 | font-size: 24px; 64 | } 65 | 66 | .hero { 67 | display: flex; 68 | align-items: center; 69 | padding: 32px; 70 | } 71 | 72 | .hero-icon { 73 | flex: 0 0 auto; 74 | color: currentColor; 75 | margin-right: 32px; 76 | overflow: visible; 77 | } 78 | 79 | /* Subline */ 80 | 81 | .subline { 82 | display: flex; 83 | justify-content: space-between; 84 | align-items: center; 85 | padding: 0 24px 24px; 86 | } 87 | 88 | .made-by { 89 | display: flex; 90 | align-items: center; 91 | line-height: 1; 92 | } 93 | 94 | .made-by span { 95 | opacity: var(--subline-opacity); 96 | } 97 | 98 | .stellate-logo { 99 | display: flex; 100 | align-items: center; 101 | margin-left: 8px; 102 | color: inherit; 103 | text-decoration: none; 104 | opacity: var(--subline-opacity); 105 | transition: opacity 0.15s ease; 106 | } 107 | 108 | .stellate-logo:hover { 109 | opacity: 1; 110 | } 111 | 112 | .stellate-logo svg { 113 | margin-right: 5px; 114 | } 115 | 116 | .report-link { 117 | font-size: 12px; 118 | opacity: var(--subline-opacity); 119 | color: inherit; 120 | transition: opacity 0.15s ease; 121 | } 122 | 123 | .report-link:hover { 124 | opacity: 1; 125 | } 126 | 127 | /* APIs */ 128 | .ssr { 129 | font-size: 16px; 130 | font-weight: 600; 131 | padding: 0 24px 24px; 132 | display: flex; 133 | flex-direction: column; 134 | align-items: start; 135 | } 136 | 137 | .apis { 138 | padding: 0 24px 24px; 139 | display: flex; 140 | flex-direction: column; 141 | align-items: start; 142 | } 143 | 144 | .api { 145 | border-radius: 5px; 146 | overflow: hidden; 147 | max-width: 100%; 148 | white-space: nowrap; 149 | padding: 8px 12px; 150 | color: var(--pink); 151 | font-size: 18px; 152 | text-decoration: none; 153 | text-overflow: ellipsis; 154 | background: linear-gradient(180deg, #FFFFFF 0%, #F6F7F8 100%); 155 | box-shadow: 0px 9px 42px rgba(59, 76, 106, 0.08), 156 | 0px 2.71px 12.66px rgba(59, 76, 106, 0.05), 157 | 0px 1.13px 5.26px rgba(59, 76, 106, 0.04), 158 | 0px 0.41px 1.9px rgba(59, 76, 106, 0.028); 159 | transition: color 0.15s ease; 160 | } 161 | 162 | .api:hover { 163 | color: var(--grey); 164 | } 165 | 166 | .api + .api { 167 | margin-top: 12px; 168 | } 169 | -------------------------------------------------------------------------------- /src/ui/popup.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | import * as ReactDOM from "react-dom"; 3 | 4 | import "../styles/font.css"; 5 | import "../styles/popup.css"; 6 | 7 | function Hello() { 8 | const params = new URLSearchParams(window.location.search.replace(/^\?/, "")); 9 | const isGraphQL = params.get("is-graphql") === "true"; 10 | const isStellate = params.get("is-stellate") === "true"; 11 | const isSSR = params.get("is-ssr") === "true"; 12 | const apis = params.getAll("graphql-api"); 13 | 14 | return ( 15 |
20 | {isGraphQL ? ( 21 | <> 22 |
23 | {isStellate ? ( 24 | 25 | ) : ( 26 | 27 | )} 28 | {isStellate ? ( 29 |

Yes, it’s GraphQL & powered by Stellate

30 | ) : ( 31 |

Yes, it’s GraphQL

32 | )} 33 |
34 | {isSSR && ( 35 |
36 | HTML body contains GraphQL response. 37 |
38 | )} 39 | {apis.length > 0 && ( 40 |
41 | {apis.map((url) => ( 42 | 43 | {url} 44 | 45 | ))} 46 |
47 | )} 48 | 49 | ) : ( 50 | <> 51 |
52 | 53 |

No, it’s not GraphQL (yet)

54 |
55 | 56 | )} 57 |
58 |

59 | Made by 60 | 65 | Stellate 66 | 67 |

68 | { 70 | evt.preventDefault(); 71 | chrome.tabs.query( 72 | { active: true, currentWindow: true }, 73 | function (tabs) { 74 | chrome.tabs.create({ 75 | url: `mailto:extension@stellate.co?subject=${encodeURIComponent( 76 | `False ${isGraphQL ? "positive" : "negative"} for ${ 77 | tabs[0]?.url || "ENTER URL HERE" 78 | }` 79 | )}&body=${encodeURIComponent( 80 | `How do you know this website actually ${ 81 | isGraphQL ? "does not" : "does" 82 | } use GraphQL?` 83 | )}`, 84 | }); 85 | } 86 | ); 87 | }} 88 | // No-JS fallback 89 | href={`mailto:extension@stellate.co?subject=${encodeURIComponent( 90 | `False ${isGraphQL ? "positive" : "negative"} for ENTER URL HERE` 91 | )}&body=${encodeURIComponent( 92 | `How do you know this website actually ${ 93 | isGraphQL ? "does not" : "does" 94 | } use GraphQL?` 95 | )}`} 96 | target="_blank" 97 | className="report-link" 98 | > 99 | Report incorrect result 100 | 101 |
102 |
103 | ); 104 | } 105 | 106 | function NoGraphQLIcon(props) { 107 | return ( 108 | 116 | 122 | 123 | ); 124 | } 125 | 126 | function YesGraphQLIcon(props) { 127 | return ( 128 | 136 | 143 | 144 | 145 | 146 | 147 | 148 | 149 | 150 | 151 | 152 | 153 | 154 | 155 | 156 | 157 | 158 | 159 | 160 | 166 | 167 | ); 168 | } 169 | 170 | function YesStellateIcon(props) { 171 | return ( 172 | 180 | 181 | 188 | 194 | 201 | 207 | 214 | 215 | 216 | 217 | 218 | 219 | 220 | 221 | 222 | 223 | ); 224 | } 225 | 226 | function StellateIcon(props) { 227 | return ( 228 | 236 | 240 | 241 | 245 | 246 | 247 | ); 248 | } 249 | 250 | // -------------- 251 | 252 | ReactDOM.render(, document.getElementById("root")); 253 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "lib": ["ES2020", "dom"], 4 | "sourceMap": true, 5 | "target": "es6", 6 | "module": "commonjs", 7 | "jsx": "react" 8 | }, 9 | "exclude": [ 10 | "node_modules", 11 | ] 12 | } 13 | -------------------------------------------------------------------------------- /webpack.config.js: -------------------------------------------------------------------------------- 1 | const path = require("path"); 2 | 3 | module.exports = { 4 | mode: "development", 5 | devtool: "inline-source-map", 6 | 7 | entry: { 8 | // content: './src/app/content.ts', 9 | background: "./src/app/background.ts", 10 | popup: "./src/ui/popup.tsx", 11 | }, 12 | 13 | output: { 14 | path: path.resolve(__dirname, "dist/js"), 15 | filename: "[name].js", 16 | }, 17 | 18 | resolve: { 19 | extensions: [".ts", ".tsx", ".js"], 20 | }, 21 | 22 | module: { 23 | rules: [ 24 | { test: /\.tsx?$/, loader: "ts-loader" }, 25 | { test: /\.css$/, use: ["style-loader", "css-loader"] }, 26 | ], 27 | }, 28 | }; 29 | --------------------------------------------------------------------------------