├── .gitignore ├── README.md ├── config-overrides.js ├── example ├── README.md ├── config-overrides.js ├── package.json ├── public │ ├── extension-manifest.json │ ├── favicon.ico │ ├── icon128.png │ ├── icon16.png │ ├── icon48.png │ ├── index.html │ ├── logo192.png │ ├── logo512.png │ ├── robots.txt │ └── web-app-manifest.json ├── src │ ├── App.tsx │ ├── extension.tsx │ ├── graphql │ │ ├── graphql-client.ts │ │ └── queries.ts │ ├── helpers.ts │ ├── index.css │ ├── index.tsx │ ├── login │ │ └── index.tsx │ ├── potential-reviewers │ │ └── index.tsx │ ├── project-setup.ts │ ├── react-app-env.d.ts │ ├── reportWebVitals.ts │ ├── required-token-scopes.png │ ├── services │ │ ├── assets-service.ts │ │ ├── auth-service.ts │ │ ├── environment-service.ts │ │ └── local-storage-service.ts │ └── setupTests.ts ├── tsconfig.json └── yarn.lock ├── package.json ├── public ├── extension-manifest.json ├── favicon.ico ├── icon128.png ├── icon16.png ├── icon48.png ├── index.html ├── logo192.png ├── logo512.png ├── robots.txt └── web-app-manifest.json ├── src ├── App.css ├── App.test.tsx ├── App.tsx ├── background │ └── index.ts ├── index.css ├── index.tsx ├── logo.svg ├── project-setup.ts ├── react-app-env.d.ts ├── reportWebVitals.ts ├── services │ ├── assets-service.ts │ └── environment-service.ts └── setupTests.ts ├── tsconfig.json └── yarn.lock /.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | /.pnp 6 | .pnp.js 7 | 8 | # testing 9 | /coverage 10 | 11 | # production 12 | /build 13 | 14 | # misc 15 | .DS_Store 16 | .env.local 17 | .env.development.local 18 | .env.test.local 19 | .env.production.local 20 | .eslintcache 21 | 22 | npm-debug.log* 23 | yarn-debug.log* 24 | yarn-error.log* 25 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Create React Chrome Extension - TS 2 | 3 | A lightweight boilerplate for building a **Chrome extension** and a standard **web app** with React, TypeScript and Webpack **at the same time**. 4 | 5 | ## How to use the boilerplate 6 | 7 | If you already know React, you can start coding your Chrome extension straight away with no build configuration. 8 | 9 | Watch a short demo on YouTube: [How to Build a Chrome Extension with React and TypeScript in 3 Minutes](https://youtu.be/qIuaHkXU0zM) 10 | 11 | ### Setup options 12 | 13 | You can change the options used in the `setupProject` function in [src/index.tsx](https://github.com/pixochi/create-react-chrome-extension-ts/blob/main/src/index.tsx) to specify your React root component, and where the React root component will be rendered. 14 | 15 | `rootElement` - your React root component that will be either rendered as a standard web app or injected to a web page by your Chrome extension 16 | 17 | `injectExtensionTo` - a CSS selector for an element on a web page to which the extension will be injected to 18 | 19 | `injectWebAppTo` - a CSS selector for an element to which the web app will be rendered if the app runs in development mode with `yarn start` or is built as a standard web app with `yarn build:web-app` 20 | 21 | **Default options:** 22 | ```javascript 23 | setupProject({ 24 | rootElement: ( 25 | 26 | 27 | 28 | ), 29 | injectExtensionTo: "body", 30 | injectWebAppTo: "#root", 31 | }); 32 | ``` 33 | 34 | ### Manifest files 35 | 36 | The boilerplate contains 2 manifest files: an extension manifest for Google Chrome and a web app manifest if you want to make a PWA build. 37 | 38 | `extension manifest` - [extension-manifest.json](https://github.com/pixochi/create-react-chrome-extension-ts/blob/main/public/extension-manifest.json) 39 | 40 | `web app manifest` - [web-app-manifest.json](https://github.com/pixochi/create-react-chrome-extension-ts/blob/main/public/web-app-manifest.json) 41 | 42 | The `build` folder will contain either of them depending on which build script you run - `yarn build:extension` or `yarn build:web-app` 43 | 44 | ### Background scripts 45 | 46 | If your Chrome extension needs to use background scripts, add them to `src/background/index.ts`. 47 | 48 | ## Available Scripts 49 | 50 | In the project directory, you can run: 51 | 52 | ### `yarn start` 53 | 54 | Runs the app in the development mode.\ 55 | Open [http://localhost:3000](http://localhost:3000) to view it in the browser. 56 | 57 | The page will reload if you make edits.\ 58 | You will also see any lint errors in the console. 59 | 60 | ### `yarn build:extension` 61 | 62 | **Builds the extension** for production to the `build` folder.\ 63 | It correctly bundles React in production mode and optimizes the build for the best performance. 64 | 65 | The build is minified and your extension is ready to be used in Developer mode or published to the Google Web Store!. 66 | 67 | #### Open the extension in Developer mode 68 | 69 | 1. Open the Extension Management page by navigating to [chrome://extensions](chrome://extensions). 70 | 2. Enable Developer Mode by clicking the toggle switch next to **Developer mode**. 71 | 3. Click the **LOAD UNPACKED** button and select the extension directory. 72 | 73 | ### `yarn build:web-app` 74 | 75 | **Builds the web app** for production to the `build` folder.\ 76 | It correctly bundles React in production mode and optimizes the build for the best performance. 77 | 78 | The build is minified and the filenames include the hashes.\ 79 | Your app is ready to be deployed! 80 | 81 | See the section about [deployment](https://facebook.github.io/create-react-app/docs/deployment) for more information. 82 | 83 | ### `yarn test` 84 | 85 | Launches the test runner in the interactive watch mode.\ 86 | See the section about [running tests](https://facebook.github.io/create-react-app/docs/running-tests) for more information. 87 | 88 | ## Learn More 89 | 90 | You can read more in the Medium article: [How to Build a Chrome Extension with React, TypeScript and Webpack: From creating a boilerplate to publishing a complete extension to Chrome Web Store](https://jakub-kozak.medium.com/how-to-build-a-chrome-extension-with-react-typescript-and-webpack-92e806ce2e16). 91 | 92 | -------------------------------------------------------------------------------- /config-overrides.js: -------------------------------------------------------------------------------- 1 | const MiniCssExtractPlugin = require("mini-css-extract-plugin"); 2 | const FileManagerPlugin = require("filemanager-webpack-plugin"); 3 | 4 | // Adds a manifest file to the build according to the current context, 5 | // and deletes files from the build that are not needed in the current context 6 | const getFileManagerPlugin = () => { 7 | const isExtensionBuild = process.env.REACT_APP_BUILD_TARGET === "extension"; 8 | const webAppBuildFiles = [ 9 | "index.html", 10 | "favicon.ico", 11 | "logo192.png", 12 | "logo512.png", 13 | "robots.txt", 14 | "asset-manifest.json", 15 | ]; 16 | const extensionBuildFiles = ["icon16.png", "icon48.png", "icon128.png"]; 17 | 18 | const manifestFiles = { 19 | webApp: "build/web-app-manifest.json", 20 | extension: "build/extension-manifest.json", 21 | }; 22 | 23 | return new FileManagerPlugin({ 24 | events: { 25 | onEnd: { 26 | copy: [ 27 | { 28 | source: isExtensionBuild 29 | ? manifestFiles.extension 30 | : manifestFiles.webApp, 31 | destination: "build/manifest.json", 32 | }, 33 | ], 34 | delete: Object.values(manifestFiles).concat( 35 | (isExtensionBuild ? webAppBuildFiles : extensionBuildFiles).map( 36 | (filename) => `build/${filename}` 37 | ) 38 | ), 39 | }, 40 | }, 41 | }); 42 | }; 43 | 44 | module.exports = { 45 | webpack: function (config) { 46 | const isExtensionBuild = process.env.REACT_APP_BUILD_TARGET === "extension"; 47 | 48 | // The default webpack configuration from `Create React App` can be used 49 | // if the app is not built as a chrome extension with the `build:extension` script. 50 | if (!isExtensionBuild) { 51 | config.plugins = config.plugins.concat(getFileManagerPlugin()); 52 | return config; 53 | } 54 | // The webpack configuration will be updated 55 | // for the production build of the extension. 56 | else { 57 | // Disable bundle splitting, 58 | // a single bundle file has to loaded as `content_script`. 59 | config.optimization.splitChunks = { 60 | cacheGroups: { 61 | default: false, 62 | }, 63 | }; 64 | 65 | // `false`: each entry chunk embeds runtime. 66 | // The extension is built with a single entry including all JS. 67 | // https://symfonycasts.com/screencast/webpack-encore/single-runtime-chunk 68 | config.optimization.runtimeChunk = false; 69 | 70 | config.entry = { 71 | // web extension 72 | main: "./src/index.tsx", 73 | // background script that has to be referenced in the extension manifest 74 | background: "./src/background/index.ts", 75 | }; 76 | 77 | // Filenames of bundles must not include `[contenthash]`, so that they can be referenced in `extension-manifest.json`. 78 | // The `[name]` is taken from `config.entry` properties, so if we have `main` and `background` as properties, we get 2 output files - main.js and background.js. 79 | config.output.filename = "[name].js"; 80 | 81 | // `MiniCssExtractPlugin` is used by the default CRA webpack configuration for 82 | // extracting CSS into separate files. The plugin has to be removed because it 83 | // uses `[contenthash]` in filenames of the separate CSS files. 84 | config.plugins = config.plugins 85 | .filter((plugin) => !(plugin instanceof MiniCssExtractPlugin)) 86 | .concat( 87 | // `MiniCssExtractPlugin` is used with its default config instead, 88 | // which doesn't contain `[contenthash]`. 89 | new MiniCssExtractPlugin(), 90 | getFileManagerPlugin() 91 | ); 92 | 93 | return config; 94 | } 95 | }, 96 | }; 97 | -------------------------------------------------------------------------------- /example/README.md: -------------------------------------------------------------------------------- 1 | # Extension built with the Create React Chrome Extension boilerplate 2 | 3 | The extension is also published in the Google Web Store: 4 | [GitHub PR reviewers](https://chrome.google.com/webstore/detail/github-pr-reviewers/lfhipcniiclmedbnbmkgdpoamecaheii) 5 | 6 | ## About 7 | The extension was bootstraped with [Create React Chrome Extension](https://github.com/pixochi/create-react-chrome-extension-ts) and adds : 8 | - local storage to keep users logged in 9 | - [Apollo Client](https://www.apollographql.com/docs/react/) for using the [GitHub GraphQL API](https://docs.github.com/en/free-pro-team@latest/graphql) 10 | - [Semantic UI React](https://react.semantic-ui.com/) for styling 11 | 12 | ## Available Scripts 13 | 14 | In the project directory, you can run: 15 | 16 | ### `yarn start` 17 | 18 | Runs the app in the development mode.\ 19 | Open [http://localhost:3000](http://localhost:3000) to view it in the browser. 20 | 21 | The page will reload if you make edits.\ 22 | You will also see any lint errors in the console. 23 | 24 | ### `yarn build:extension` 25 | 26 | **Builds the extension** for production to the `build` folder.\ 27 | It correctly bundles React in production mode and optimizes the build for the best performance. 28 | 29 | The build is minified and your extension is ready to be used in Developer mode or published to the Google Web Store!.\ 30 | 31 | #### Open the extension in Developer mode 32 | 33 | 1. Open the Extension Management page by navigating to [chrome://extensions](chrome://extensions). 34 | 2. Enable Developer Mode by clicking the toggle switch next to **Developer mode**. 35 | 3. Click the **LOAD UNPACKED** button and select the extension directory. 36 | 37 | ### `yarn build:web-app` 38 | 39 | **Builds the web app** for production to the `build` folder.\ 40 | It correctly bundles React in production mode and optimizes the build for the best performance. 41 | 42 | The build is minified and the filenames include the hashes.\ 43 | Your app is ready to be deployed! 44 | 45 | See the section about [deployment](https://facebook.github.io/create-react-app/docs/deployment) for more information. 46 | 47 | ### `yarn test` 48 | 49 | Launches the test runner in the interactive watch mode.\ 50 | See the section about [running tests](https://facebook.github.io/create-react-app/docs/running-tests) for more information. 51 | 52 | ## Learn More 53 | 54 | You can learn more in the [Create React Chrome Extension documentation](https://github.com/pixochi/create-react-chrome-extension-ts). 55 | -------------------------------------------------------------------------------- /example/config-overrides.js: -------------------------------------------------------------------------------- 1 | const MiniCssExtractPlugin = require("mini-css-extract-plugin"); 2 | const FileManagerPlugin = require("filemanager-webpack-plugin"); 3 | 4 | // Adds a manifest file to the build according to the current context, 5 | // and deletes files from the build that are not needed in the current context 6 | const getFileManagerPlugin = () => { 7 | const isExtensionBuild = process.env.REACT_APP_BUILD_TARGET === "extension"; 8 | const webAppBuildFiles = [ 9 | "index.html", 10 | "favicon.ico", 11 | "logo192.png", 12 | "logo512.png", 13 | "robots.txt", 14 | "asset-manifest.json", 15 | ]; 16 | const extensionBuildFiles = ["icon16.png", "icon48.png", "icon128.png"]; 17 | 18 | const manifestFiles = { 19 | webApp: "build/web-app-manifest.json", 20 | extension: "build/extension-manifest.json", 21 | }; 22 | 23 | return new FileManagerPlugin({ 24 | events: { 25 | onEnd: { 26 | copy: [ 27 | { 28 | source: isExtensionBuild 29 | ? manifestFiles.extension 30 | : manifestFiles.webApp, 31 | destination: "build/manifest.json", 32 | }, 33 | ], 34 | delete: Object.values(manifestFiles).concat( 35 | (isExtensionBuild ? webAppBuildFiles : extensionBuildFiles).map( 36 | (filename) => `build/${filename}` 37 | ) 38 | ), 39 | }, 40 | }, 41 | }); 42 | }; 43 | 44 | module.exports = { 45 | webpack: function (config) { 46 | const isExtensionBuild = process.env.REACT_APP_BUILD_TARGET === "extension"; 47 | 48 | // The default webpack configuration from `Create React App` can be used 49 | // if the app is not built as a chrome extension with the `build:extension` script. 50 | if (!isExtensionBuild) { 51 | config.plugins = config.plugins.concat(getFileManagerPlugin()); 52 | return config; 53 | } 54 | // The webpack configuration will be updated 55 | // for the production build of the extension. 56 | else { 57 | // Disable bundle splitting, 58 | // a single bundle file has to loaded as `content_script`. 59 | config.optimization.splitChunks = { 60 | cacheGroups: { 61 | default: false, 62 | }, 63 | }; 64 | 65 | // `false`: each entry chunk embeds runtime. 66 | // The extension is built with a single entry including all JS. 67 | // https://symfonycasts.com/screencast/webpack-encore/single-runtime-chunk 68 | config.optimization.runtimeChunk = false; 69 | 70 | // The name of the extension bundle must not include `[contenthash]`, 71 | // so it can be referenced in `manifest.json` as `content_script`. 72 | config.output.filename = "main.js"; 73 | 74 | // `MiniCssExtractPlugin` is used by the default CRA webpack configuration for 75 | // extracting CSS into separate files. The plugin has to be removed because it 76 | // uses `[contenthash]` in filenames of the separate CSS files. 77 | config.plugins = config.plugins 78 | .filter((plugin) => !(plugin instanceof MiniCssExtractPlugin)) 79 | .concat( 80 | // `MiniCssExtractPlugin` is used with its default config instead, 81 | // which doesn't contain `[contenthash]`. 82 | new MiniCssExtractPlugin(), 83 | getFileManagerPlugin() 84 | ); 85 | 86 | return config; 87 | } 88 | }, 89 | }; 90 | -------------------------------------------------------------------------------- /example/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "create-react-chrome-extension-ts", 3 | "version": "0.1.0", 4 | "private": true, 5 | "dependencies": { 6 | "@apollo/client": "3.3.4", 7 | "@testing-library/jest-dom": "^5.11.4", 8 | "@testing-library/react": "^11.1.0", 9 | "@testing-library/user-event": "^12.1.10", 10 | "@types/chrome": "0.0.126", 11 | "@types/jest": "^26.0.15", 12 | "@types/node": "^12.0.0", 13 | "@types/react": "^16.9.53", 14 | "@types/react-dom": "^16.9.8", 15 | "filemanager-webpack-plugin": "3.0.0-beta.0", 16 | "graphql": "15.4.0", 17 | "react": "^17.0.1", 18 | "react-app-rewired": "2.1.6", 19 | "react-dom": "^17.0.1", 20 | "react-scripts": "4.0.1", 21 | "semantic-ui-css": "2.4.1", 22 | "semantic-ui-react": "2.0.1", 23 | "typescript": "4.1.2", 24 | "web-vitals": "^0.2.4" 25 | }, 26 | "scripts": { 27 | "start": "react-app-rewired start", 28 | "build:extension": "REACT_APP_BUILD_TARGET=extension react-app-rewired build", 29 | "build:web-app": "REACT_APP_BUILD_TARGET=web-app react-app-rewired build", 30 | "test": "react-app-rewired test" 31 | }, 32 | "eslintConfig": { 33 | "extends": [ 34 | "react-app", 35 | "react-app/jest" 36 | ] 37 | }, 38 | "browserslist": { 39 | "production": [ 40 | ">0.2%", 41 | "not dead", 42 | "not op_mini all" 43 | ], 44 | "development": [ 45 | "last 1 chrome version", 46 | "last 1 firefox version", 47 | "last 1 safari version" 48 | ] 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /example/public/extension-manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "manifest_version": 2, 3 | "name": "Create React Chrome Extension - TypeScript", 4 | "version": "0.1.0", 5 | "description": "A lightweight boilerplate for building a Chrome extension with React, TypeScript and Webpack.", 6 | "author": "Jakub Pixochi Kozak", 7 | "icons": { 8 | "16": "icon16.png", 9 | "48": "icon48.png", 10 | "128": "icon128.png" 11 | }, 12 | "content_scripts": [ 13 | { 14 | "matches": [ 15 | "https://github.com/*/*/pull/*", 16 | "https://github.com/*/*/compare/*" 17 | ], 18 | "js": ["main.js"], 19 | "css": ["main.css"] 20 | } 21 | ], 22 | "web_accessible_resources": ["static/*"], 23 | "permissions": ["storage"] 24 | } 25 | -------------------------------------------------------------------------------- /example/public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pixochi/create-react-chrome-extension-ts/db05e81d73d5a955cd000862beb92342202e1a7a/example/public/favicon.ico -------------------------------------------------------------------------------- /example/public/icon128.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pixochi/create-react-chrome-extension-ts/db05e81d73d5a955cd000862beb92342202e1a7a/example/public/icon128.png -------------------------------------------------------------------------------- /example/public/icon16.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pixochi/create-react-chrome-extension-ts/db05e81d73d5a955cd000862beb92342202e1a7a/example/public/icon16.png -------------------------------------------------------------------------------- /example/public/icon48.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pixochi/create-react-chrome-extension-ts/db05e81d73d5a955cd000862beb92342202e1a7a/example/public/icon48.png -------------------------------------------------------------------------------- /example/public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 12 | 13 | 17 | 18 | 27 | React App 28 | 29 | 30 | 31 |
32 | 42 | 43 | 44 | -------------------------------------------------------------------------------- /example/public/logo192.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pixochi/create-react-chrome-extension-ts/db05e81d73d5a955cd000862beb92342202e1a7a/example/public/logo192.png -------------------------------------------------------------------------------- /example/public/logo512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pixochi/create-react-chrome-extension-ts/db05e81d73d5a955cd000862beb92342202e1a7a/example/public/logo512.png -------------------------------------------------------------------------------- /example/public/robots.txt: -------------------------------------------------------------------------------- 1 | # https://www.robotstxt.org/robotstxt.html 2 | User-agent: * 3 | Disallow: 4 | -------------------------------------------------------------------------------- /example/public/web-app-manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "short_name": "React App", 3 | "name": "Create React App Sample", 4 | "icons": [ 5 | { 6 | "src": "favicon.ico", 7 | "sizes": "64x64 32x32 24x24 16x16", 8 | "type": "image/x-icon" 9 | }, 10 | { 11 | "src": "logo192.png", 12 | "type": "image/png", 13 | "sizes": "192x192" 14 | }, 15 | { 16 | "src": "logo512.png", 17 | "type": "image/png", 18 | "sizes": "512x512" 19 | } 20 | ], 21 | "start_url": ".", 22 | "display": "standalone", 23 | "theme_color": "#000000", 24 | "background_color": "#ffffff" 25 | } 26 | -------------------------------------------------------------------------------- /example/src/App.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { ApolloProvider } from "@apollo/client"; 3 | 4 | import GraphQLClient from "./graphql/graphql-client"; 5 | import Extension from "./extension"; 6 | 7 | function App() { 8 | return ( 9 | 10 | 11 | 12 | ); 13 | } 14 | 15 | export default App; 16 | -------------------------------------------------------------------------------- /example/src/extension.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { Button, Header, Grid, Modal } from "semantic-ui-react"; 3 | 4 | import * as AuthService from "./services/auth-service"; 5 | import Login from "./login"; 6 | import PotentialReviewers from "./potential-reviewers"; 7 | import * as Helpers from "./helpers"; 8 | 9 | const getStoredAccessToken = async () => { 10 | const accessToken = await AuthService.getAccessToken(); 11 | return accessToken; 12 | }; 13 | 14 | function Extension() { 15 | const [accessToken, setAccessToken] = React.useState(null); 16 | const [open, setOpen] = React.useState(false); 17 | const [isLoggingOut, setIsLoggingOut] = React.useState(false); 18 | 19 | // Gets the current user from local storage. 20 | React.useEffect(() => { 21 | async function getInitialUserLogin() { 22 | const accessToken = await getStoredAccessToken(); 23 | setAccessToken(accessToken); 24 | } 25 | getInitialUserLogin(); 26 | }, []); 27 | 28 | const handleLogOut = React.useCallback(() => { 29 | async function clearStorage() { 30 | setIsLoggingOut(true); 31 | await AuthService.logOut(); 32 | setAccessToken(null); 33 | setIsLoggingOut(false); 34 | } 35 | clearStorage(); 36 | }, []); 37 | 38 | const modalContent = React.useMemo(() => { 39 | if (!accessToken) { 40 | return ; 41 | } 42 | 43 | return ; 44 | }, [accessToken]); 45 | 46 | return ( 47 | setOpen(false)} 49 | onOpen={() => setOpen(true)} 50 | open={open} 51 | trigger={ 74 | 75 | 76 | 77 | ) : null} 78 | 79 | ); 80 | } 81 | 82 | export default Extension; 83 | -------------------------------------------------------------------------------- /example/src/graphql/graphql-client.ts: -------------------------------------------------------------------------------- 1 | import { ApolloClient, createHttpLink, InMemoryCache } from "@apollo/client"; 2 | import { setContext } from "@apollo/client/link/context"; 3 | 4 | import * as AuthService from "../services/auth-service"; 5 | 6 | const GITHUB_API_URI = "https://api.github.com/graphql"; 7 | 8 | const httpLink = createHttpLink({ 9 | uri: GITHUB_API_URI, 10 | }); 11 | 12 | const authLink = setContext(async (_, { headers }) => { 13 | // Get the authentication token from local storage if it exists. 14 | const accessToken = await AuthService.getAccessToken(); 15 | 16 | // Return the headers to the context so httpLink can read them. 17 | return { 18 | headers: { 19 | ...headers, 20 | authorization: accessToken ? `Bearer ${accessToken}` : "", 21 | }, 22 | }; 23 | }); 24 | 25 | const client = new ApolloClient({ 26 | link: authLink.concat(httpLink), 27 | cache: new InMemoryCache(), 28 | }); 29 | 30 | export default client; 31 | -------------------------------------------------------------------------------- /example/src/graphql/queries.ts: -------------------------------------------------------------------------------- 1 | import { gql } from "@apollo/client"; 2 | 3 | const UserFragment = gql` 4 | fragment UserFragment on User { 5 | login 6 | name 7 | avatarUrl 8 | url 9 | } 10 | `; 11 | 12 | const RequestedReviewersFragment = gql` 13 | fragment RequestedReviewersFragment on Repository { 14 | pullRequests(first: 100, states: [OPEN]) { 15 | nodes { 16 | url 17 | title 18 | reviewRequests(first: 100) { 19 | nodes { 20 | requestedReviewer { 21 | ...UserFragment 22 | } 23 | } 24 | } 25 | } 26 | } 27 | } 28 | ${UserFragment} 29 | `; 30 | 31 | const AssignableUsersFragment = gql` 32 | fragment AssignableUsersFragment on Repository { 33 | assignableUsers(first: 100) { 34 | nodes { 35 | ...UserFragment 36 | } 37 | } 38 | } 39 | ${UserFragment} 40 | `; 41 | 42 | export const RepoReviewersQuery = gql` 43 | query RepoReviewersQuery($repoOwner: String!, $repoName: String!) { 44 | repository(owner: $repoOwner, name: $repoName) { 45 | ...AssignableUsersFragment 46 | ...RequestedReviewersFragment 47 | } 48 | } 49 | ${AssignableUsersFragment} 50 | ${RequestedReviewersFragment} 51 | `; 52 | 53 | export const CurrentUserQuery = gql` 54 | query CurrentUserQuery { 55 | viewer { 56 | login 57 | } 58 | } 59 | `; 60 | -------------------------------------------------------------------------------- /example/src/helpers.ts: -------------------------------------------------------------------------------- 1 | export const getRepoOwner = () => { 2 | const [, repoOwner] = window.location.pathname.split("/"); 3 | return repoOwner; 4 | }; 5 | 6 | export const getRepoName = () => { 7 | const [, , repoName] = window.location.pathname.split("/"); 8 | return repoName; 9 | }; 10 | 11 | export const getRepoIdentifier = () => { 12 | return `${getRepoOwner()}/${getRepoName()}`; 13 | }; 14 | -------------------------------------------------------------------------------- /example/src/index.css: -------------------------------------------------------------------------------- 1 | body { 2 | margin: 0; 3 | font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen', 4 | 'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue', 5 | sans-serif; 6 | -webkit-font-smoothing: antialiased; 7 | -moz-osx-font-smoothing: grayscale; 8 | } 9 | 10 | code { 11 | font-family: source-code-pro, Menlo, Monaco, Consolas, 'Courier New', 12 | monospace; 13 | } 14 | -------------------------------------------------------------------------------- /example/src/index.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import "semantic-ui-css/semantic.min.css"; 3 | 4 | import reportWebVitals from "./reportWebVitals"; 5 | import { setupProject } from "./project-setup"; 6 | import App from "./App"; 7 | import "./index.css"; 8 | 9 | // Renders the web-app/extension content to DOM. 10 | setupProject({ 11 | rootElement: ( 12 | 13 | 14 | 15 | ), 16 | injectExtensionTo: ".sidebar-assignee", 17 | injectWebAppTo: "#root", 18 | }); 19 | 20 | // If you want to start measuring performance in your app, pass a function 21 | // to log results (for example: reportWebVitals(console.log)) 22 | // or send to an analytics endpoint. Learn more: https://bit.ly/CRA-vitals 23 | reportWebVitals(); 24 | -------------------------------------------------------------------------------- /example/src/login/index.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { useLazyQuery } from "@apollo/client"; 3 | import { Button, Form, Message, Popup, Image } from "semantic-ui-react"; 4 | 5 | import * as Queries from "../graphql/queries"; 6 | import * as AuthService from "../services/auth-service"; 7 | import * as AssetsService from "../services/assets-service"; 8 | import requiredTokenScopesImg from "../required-token-scopes.png"; 9 | 10 | const Login: React.FC<{ onSuccessLogin: (accessToken: string) => void }> = ({ 11 | onSuccessLogin, 12 | }) => { 13 | const [accessTokenInput, setAccessTokenInput] = React.useState( 14 | null 15 | ); 16 | 17 | const [getCurrentUser, { loading, data, error }] = useLazyQuery( 18 | Queries.CurrentUserQuery, 19 | { 20 | fetchPolicy: "no-cache", 21 | } 22 | ); 23 | 24 | const handleTokenInputChange = React.useCallback((e) => { 25 | setAccessTokenInput(e.target.value); 26 | }, []); 27 | 28 | const connectToGitHub = React.useCallback(async () => { 29 | if (accessTokenInput) { 30 | await AuthService.saveAccessToken(accessTokenInput); 31 | getCurrentUser(); 32 | } 33 | }, [getCurrentUser, accessTokenInput]); 34 | 35 | React.useEffect(() => { 36 | if (data?.viewer.login && accessTokenInput) { 37 | onSuccessLogin(accessTokenInput); 38 | } 39 | }, [data?.viewer.login, onSuccessLogin, accessTokenInput]); 40 | 41 | return ( 42 |
43 | 44 | 45 | 49 | 50 | 58 | 67 | 73 | } 74 | header="Required scopes" 75 | style={{ maxWidth: "100vw" }} 76 | trigger={ 77 | 86 | } 87 | /> 88 | 89 | ); 90 | }; 91 | 92 | export default Login; 93 | -------------------------------------------------------------------------------- /example/src/potential-reviewers/index.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { useQuery } from "@apollo/client"; 3 | import { List, Image, Header, Dimmer, Loader } from "semantic-ui-react"; 4 | 5 | import * as Queries from "../graphql/queries"; 6 | import * as Helpers from "../helpers"; 7 | 8 | const getInitialPotentialReviewers = (assignableUserNodes) => { 9 | if (!assignableUserNodes) { 10 | return {}; 11 | } 12 | 13 | return assignableUserNodes?.reduce((reviewersAcc, assignableUser) => { 14 | const reviewer = { 15 | user: { 16 | login: assignableUser.login, 17 | name: assignableUser.name, 18 | avatarUrl: assignableUser.avatarUrl, 19 | url: assignableUser.url, 20 | }, 21 | reviewRequests: [], 22 | }; 23 | 24 | reviewersAcc[assignableUser.login] = reviewer; 25 | 26 | return reviewersAcc; 27 | }, {}); 28 | }; 29 | 30 | function PotentialReviewers() { 31 | const { 32 | data: repoReviewersData, 33 | loading: loadingRepoReviewers, 34 | error: repoReviewersError, 35 | } = useQuery(Queries.RepoReviewersQuery, { 36 | variables: { 37 | repoOwner: Helpers.getRepoOwner(), 38 | repoName: Helpers.getRepoName(), 39 | }, 40 | }); 41 | 42 | const potentialReviewers = repoReviewersData?.repository?.pullRequests.nodes.reduce( 43 | (reviewersAcc, pullRequestNode) => { 44 | pullRequestNode.reviewRequests.nodes.forEach((reviewRequestNode) => { 45 | const reviewerLogin = reviewRequestNode.requestedReviewer.login; 46 | const potentialReviewer = reviewersAcc[reviewerLogin] || { 47 | user: {}, 48 | reviewRequests: [], 49 | }; 50 | 51 | const updatedReviewRequests = potentialReviewer.reviewRequests.concat({ 52 | pullRequestUrl: pullRequestNode.url, 53 | pullRequestTitle: pullRequestNode.title, 54 | }); 55 | 56 | reviewersAcc[reviewerLogin] = { 57 | user: { 58 | login: reviewRequestNode.requestedReviewer.login, 59 | name: reviewRequestNode.requestedReviewer.name, 60 | avatarUrl: reviewRequestNode.requestedReviewer.avatarUrl, 61 | url: reviewRequestNode.requestedReviewer.url, 62 | }, 63 | reviewRequests: updatedReviewRequests, 64 | }; 65 | }); 66 | 67 | return reviewersAcc; 68 | }, 69 | getInitialPotentialReviewers( 70 | repoReviewersData?.repository?.assignableUsers.nodes 71 | ) 72 | ); 73 | 74 | if (loadingRepoReviewers) { 75 | return ( 76 | 77 | Loading 78 | 79 | ); 80 | } 81 | 82 | if (repoReviewersError) { 83 | return ( 84 |
85 | {repoReviewersError?.message} 86 |
87 | ); 88 | } 89 | 90 | if (!potentialReviewers) { 91 | return ( 92 |
93 | No potential reviewers found 94 |
95 | ); 96 | } 97 | 98 | return ( 99 | 100 | {Object.keys(potentialReviewers) 101 | .sort((userLogin1, userLogin2) => { 102 | const reviewer1 = potentialReviewers[userLogin1]; 103 | const reviewer2 = potentialReviewers[userLogin2]; 104 | 105 | return ( 106 | reviewer2.reviewRequests.length - reviewer1.reviewRequests.length 107 | ); 108 | }) 109 | .map((userLogin) => ( 110 | 111 | 112 | 113 | {`${userLogin} (${potentialReviewers[userLogin].reviewRequests.length})`} 118 | 119 | 120 | {potentialReviewers[userLogin].reviewRequests.map( 121 | (request) => ( 122 | 128 | {request.pullRequestTitle} 129 | 130 | ) 131 | )} 132 | 133 | 134 | 135 | 136 | ))} 137 | 138 | ); 139 | } 140 | 141 | export default PotentialReviewers; 142 | -------------------------------------------------------------------------------- /example/src/project-setup.ts: -------------------------------------------------------------------------------- 1 | import ReactDOM, { Renderer } from "react-dom"; 2 | 3 | import { checkIsExtension } from "./services/environment-service"; 4 | 5 | type RootElement = Parameters["0"][0]; 6 | type ContainerSelector = string; 7 | 8 | interface AppSetupConfig { 9 | rootElement: RootElement; 10 | injectExtensionTo: ContainerSelector; 11 | injectWebAppTo: ContainerSelector; 12 | } 13 | 14 | const findElementInDOM = (selector: ContainerSelector) => { 15 | return document.querySelector(selector); 16 | }; 17 | 18 | const renderAppToDOM = (element: RootElement, selector: ContainerSelector) => { 19 | ReactDOM.render(element, document.querySelector(selector)); 20 | }; 21 | 22 | const injectExtensionToDOM = ( 23 | element: RootElement, 24 | selector: ContainerSelector 25 | ) => { 26 | const rootElementId = "root"; 27 | 28 | const appContainer = document.createElement("div"); 29 | appContainer.id = rootElementId; 30 | 31 | const elementInDOM = findElementInDOM(selector); 32 | 33 | if (elementInDOM) { 34 | elementInDOM.append(appContainer); 35 | renderAppToDOM(element, `#${rootElementId}`); 36 | } 37 | }; 38 | 39 | const initExtension = (element: RootElement, selector: ContainerSelector) => { 40 | const interval = setInterval(() => { 41 | // Can't inject the extension to DOM. 42 | if (!findElementInDOM(selector)) { 43 | return; 44 | } 45 | 46 | clearInterval(interval); 47 | injectExtensionToDOM(element, selector); 48 | }, 100); 49 | }; // check every 100ms 50 | 51 | const setupProject = (config: AppSetupConfig) => { 52 | if (checkIsExtension()) { 53 | initExtension(config.rootElement, config.injectExtensionTo); 54 | } else { 55 | renderAppToDOM(config.rootElement, config.injectWebAppTo); 56 | } 57 | }; 58 | 59 | export { setupProject }; 60 | -------------------------------------------------------------------------------- /example/src/react-app-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | -------------------------------------------------------------------------------- /example/src/reportWebVitals.ts: -------------------------------------------------------------------------------- 1 | import { 2 | ReportHandler, 3 | getCLS, 4 | getFID, 5 | getFCP, 6 | getLCP, 7 | getTTFB, 8 | } from "web-vitals"; 9 | 10 | const reportWebVitals = (onPerfEntry?: ReportHandler) => { 11 | if (onPerfEntry && onPerfEntry instanceof Function) { 12 | getCLS(onPerfEntry); 13 | getFID(onPerfEntry); 14 | getFCP(onPerfEntry); 15 | getLCP(onPerfEntry); 16 | getTTFB(onPerfEntry); 17 | } 18 | }; 19 | 20 | export default reportWebVitals; 21 | -------------------------------------------------------------------------------- /example/src/required-token-scopes.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pixochi/create-react-chrome-extension-ts/db05e81d73d5a955cd000862beb92342202e1a7a/example/src/required-token-scopes.png -------------------------------------------------------------------------------- /example/src/services/assets-service.ts: -------------------------------------------------------------------------------- 1 | import { checkIsExtension } from "./environment-service"; 2 | 3 | /** 4 | * Converts a relative path to a resource to a path depending on the context, 5 | * in which the resource is loaded. 6 | * @param {string} path A relative path to a resource 7 | * @example 8 | * import logo from "./logo.svg"; 9 | * 10 | * function ImageComponent() { 11 | * return ( 12 | * logo 16 | * ); 17 | * } 18 | * 19 | */ 20 | export const getResourceURL = (path: string) => { 21 | if (checkIsExtension()) { 22 | return window.chrome.runtime.getURL(path); 23 | } 24 | 25 | return path; 26 | }; 27 | -------------------------------------------------------------------------------- /example/src/services/auth-service.ts: -------------------------------------------------------------------------------- 1 | import * as LocalStorageService from "./local-storage-service"; 2 | 3 | const ACCESS_TOKEN_KEY = "pixochi_github_pr_reviewers_access_token"; 4 | 5 | export const saveAccessToken = async (accessToken: string) => { 6 | await LocalStorageService.saveToLocalStorage({ 7 | [ACCESS_TOKEN_KEY]: accessToken, 8 | }); 9 | 10 | return; 11 | }; 12 | 13 | export const getAccessToken = async () => { 14 | const localStorageItems = await LocalStorageService.getFromLocalStorage([ 15 | ACCESS_TOKEN_KEY, 16 | ]); 17 | 18 | return localStorageItems[ACCESS_TOKEN_KEY]; 19 | }; 20 | 21 | export const logOut = async () => { 22 | await LocalStorageService.removeFromLocalStorage([ACCESS_TOKEN_KEY]); 23 | 24 | return; 25 | }; 26 | -------------------------------------------------------------------------------- /example/src/services/environment-service.ts: -------------------------------------------------------------------------------- 1 | export const checkIsExtension = () => { 2 | return process.env.REACT_APP_BUILD_TARGET === "extension"; 3 | }; 4 | -------------------------------------------------------------------------------- /example/src/services/local-storage-service.ts: -------------------------------------------------------------------------------- 1 | import { checkIsExtension } from "./environment-service"; 2 | 3 | export const saveToLocalStorage = (items: Object) => { 4 | return new Promise((resolve) => { 5 | // Chrome local storage has to be used when in the extension. 6 | if (checkIsExtension()) { 7 | window.chrome.storage.local.set(items, resolve); 8 | return; 9 | } 10 | 11 | // Standard local storage is used when outside of the extension. 12 | Object.entries(items).forEach(([key, value]) => { 13 | localStorage.setItem(key, value); 14 | }); 15 | resolve(); 16 | }); 17 | }; 18 | 19 | export const getFromLocalStorage = (keys: string[]) => { 20 | return new Promise<{ [key: string]: any }>((resolve) => { 21 | // Chrome local storage has to be used when in the extension. 22 | if (checkIsExtension()) { 23 | window.chrome.storage.local.get(keys, resolve); 24 | return; 25 | } 26 | 27 | // Standard local storage is used when outside of the extension. 28 | const keyValueItems = keys.map((key) => [key, localStorage.getItem(key)]); 29 | const items = Object.fromEntries(keyValueItems); 30 | resolve(items); 31 | }); 32 | }; 33 | 34 | export const removeFromLocalStorage = (keys: string[]) => { 35 | return new Promise((resolve) => { 36 | // Chrome local storage has to be used when in the extension. 37 | if (checkIsExtension()) { 38 | window.chrome.storage.local.remove(keys, resolve); 39 | return; 40 | } 41 | 42 | // Standard local storage is used when outside of the extension. 43 | keys.forEach((key) => localStorage.removeItem(key)); 44 | resolve(); 45 | }); 46 | }; 47 | -------------------------------------------------------------------------------- /example/src/setupTests.ts: -------------------------------------------------------------------------------- 1 | // jest-dom adds custom jest matchers for asserting on DOM nodes. 2 | // allows you to do things like: 3 | // expect(element).toHaveTextContent(/react/i) 4 | // learn more: https://github.com/testing-library/jest-dom 5 | import '@testing-library/jest-dom'; 6 | -------------------------------------------------------------------------------- /example/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es5", 4 | "lib": [ 5 | "dom", 6 | "dom.iterable", 7 | "esnext" 8 | ], 9 | "allowJs": true, 10 | "skipLibCheck": true, 11 | "esModuleInterop": true, 12 | "allowSyntheticDefaultImports": true, 13 | "strict": true, 14 | "forceConsistentCasingInFileNames": true, 15 | "noFallthroughCasesInSwitch": true, 16 | "module": "esnext", 17 | "moduleResolution": "node", 18 | "resolveJsonModule": true, 19 | "isolatedModules": true, 20 | "noEmit": true, 21 | "jsx": "react-jsx", 22 | "noImplicitAny": false, 23 | }, 24 | "include": [ 25 | "src" 26 | ] 27 | } 28 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "create-react-chrome-extension-ts", 3 | "version": "0.1.0", 4 | "private": true, 5 | "dependencies": { 6 | "@testing-library/jest-dom": "^5.11.4", 7 | "@testing-library/react": "^11.1.0", 8 | "@testing-library/user-event": "^12.1.10", 9 | "@types/chrome": "0.0.126", 10 | "@types/jest": "^26.0.15", 11 | "@types/node": "^12.0.0", 12 | "@types/react": "^16.9.53", 13 | "@types/react-dom": "^16.9.8", 14 | "filemanager-webpack-plugin": "3.0.0-beta.0", 15 | "react": "^17.0.1", 16 | "react-app-rewired": "2.1.6", 17 | "react-dom": "^17.0.1", 18 | "react-scripts": "4.0.1", 19 | "typescript": "4.1.2", 20 | "web-vitals": "^0.2.4" 21 | }, 22 | "scripts": { 23 | "start": "react-app-rewired start", 24 | "build:extension": "REACT_APP_BUILD_TARGET=extension react-app-rewired build", 25 | "build:web-app": "REACT_APP_BUILD_TARGET=web-app react-app-rewired build", 26 | "test": "react-app-rewired test" 27 | }, 28 | "eslintConfig": { 29 | "extends": [ 30 | "react-app", 31 | "react-app/jest" 32 | ] 33 | }, 34 | "browserslist": { 35 | "production": [ 36 | ">0.2%", 37 | "not dead", 38 | "not op_mini all" 39 | ], 40 | "development": [ 41 | "last 1 chrome version", 42 | "last 1 firefox version", 43 | "last 1 safari version" 44 | ] 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /public/extension-manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "manifest_version": 2, 3 | "name": "Create React Chrome Extension - TypeScript", 4 | "version": "0.1.0", 5 | "description": "A lightweight boilerplate for building a Chrome extension with React, TypeScript and Webpack.", 6 | "author": "Jakub Pixochi Kozak", 7 | "icons": { 8 | "16": "icon16.png", 9 | "48": "icon48.png", 10 | "128": "icon128.png" 11 | }, 12 | "content_scripts": [ 13 | { 14 | "matches": [""], 15 | "js": ["main.js"], 16 | "css": ["main.css"] 17 | } 18 | ], 19 | "web_accessible_resources": ["static/*"], 20 | "background": { 21 | "scripts": ["background.js"], 22 | "persistent": false 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pixochi/create-react-chrome-extension-ts/db05e81d73d5a955cd000862beb92342202e1a7a/public/favicon.ico -------------------------------------------------------------------------------- /public/icon128.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pixochi/create-react-chrome-extension-ts/db05e81d73d5a955cd000862beb92342202e1a7a/public/icon128.png -------------------------------------------------------------------------------- /public/icon16.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pixochi/create-react-chrome-extension-ts/db05e81d73d5a955cd000862beb92342202e1a7a/public/icon16.png -------------------------------------------------------------------------------- /public/icon48.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pixochi/create-react-chrome-extension-ts/db05e81d73d5a955cd000862beb92342202e1a7a/public/icon48.png -------------------------------------------------------------------------------- /public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 12 | 13 | 17 | 18 | 27 | React App 28 | 29 | 30 | 31 |
32 | 42 | 43 | 44 | -------------------------------------------------------------------------------- /public/logo192.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pixochi/create-react-chrome-extension-ts/db05e81d73d5a955cd000862beb92342202e1a7a/public/logo192.png -------------------------------------------------------------------------------- /public/logo512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pixochi/create-react-chrome-extension-ts/db05e81d73d5a955cd000862beb92342202e1a7a/public/logo512.png -------------------------------------------------------------------------------- /public/robots.txt: -------------------------------------------------------------------------------- 1 | # https://www.robotstxt.org/robotstxt.html 2 | User-agent: * 3 | Disallow: 4 | -------------------------------------------------------------------------------- /public/web-app-manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "short_name": "React App", 3 | "name": "Create React App Sample", 4 | "icons": [ 5 | { 6 | "src": "favicon.ico", 7 | "sizes": "64x64 32x32 24x24 16x16", 8 | "type": "image/x-icon" 9 | }, 10 | { 11 | "src": "logo192.png", 12 | "type": "image/png", 13 | "sizes": "192x192" 14 | }, 15 | { 16 | "src": "logo512.png", 17 | "type": "image/png", 18 | "sizes": "512x512" 19 | } 20 | ], 21 | "start_url": ".", 22 | "display": "standalone", 23 | "theme_color": "#000000", 24 | "background_color": "#ffffff" 25 | } 26 | -------------------------------------------------------------------------------- /src/App.css: -------------------------------------------------------------------------------- 1 | .App { 2 | text-align: center; 3 | } 4 | 5 | .App-logo { 6 | height: 40vmin; 7 | pointer-events: none; 8 | } 9 | 10 | @media (prefers-reduced-motion: no-preference) { 11 | .App-logo { 12 | animation: App-logo-spin infinite 20s linear; 13 | } 14 | } 15 | 16 | .App-header { 17 | background-color: #282c34; 18 | min-height: 100vh; 19 | display: flex; 20 | flex-direction: column; 21 | align-items: center; 22 | justify-content: center; 23 | font-size: calc(10px + 2vmin); 24 | color: white; 25 | } 26 | 27 | .App-link { 28 | color: #61dafb; 29 | } 30 | 31 | @keyframes App-logo-spin { 32 | from { 33 | transform: rotate(0deg); 34 | } 35 | to { 36 | transform: rotate(360deg); 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /src/App.test.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { render, screen } from '@testing-library/react'; 3 | import App from './App'; 4 | 5 | test('renders learn react link', () => { 6 | render(); 7 | const linkElement = screen.getByText(/learn react/i); 8 | expect(linkElement).toBeInTheDocument(); 9 | }); 10 | -------------------------------------------------------------------------------- /src/App.tsx: -------------------------------------------------------------------------------- 1 | import logo from "./logo.svg"; 2 | import "./App.css"; 3 | import * as AssetsService from "./services/assets-service"; 4 | 5 | function App() { 6 | return ( 7 |
8 |
9 | logo 14 |

15 | Edit src/App.tsx and save to reload. 16 |

17 | 23 | Learn React 24 | 25 |
26 |
27 | ); 28 | } 29 | 30 | export default App; 31 | -------------------------------------------------------------------------------- /src/background/index.ts: -------------------------------------------------------------------------------- 1 | // A basic example how you can use background scripts in your Chrome extension. 2 | // More on: https://developer.chrome.com/docs/extensions/mv2/background_pages/. 3 | 4 | window.chrome.runtime.onInstalled.addListener(() => { 5 | console.log( 6 | "'Create React Chrome Extension - TypeScript' installed/updated..." 7 | ); 8 | }); 9 | 10 | export default {}; 11 | -------------------------------------------------------------------------------- /src/index.css: -------------------------------------------------------------------------------- 1 | body { 2 | margin: 0; 3 | font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen', 4 | 'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue', 5 | sans-serif; 6 | -webkit-font-smoothing: antialiased; 7 | -moz-osx-font-smoothing: grayscale; 8 | } 9 | 10 | code { 11 | font-family: source-code-pro, Menlo, Monaco, Consolas, 'Courier New', 12 | monospace; 13 | } 14 | -------------------------------------------------------------------------------- /src/index.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | 3 | import reportWebVitals from "./reportWebVitals"; 4 | import { setupProject } from "./project-setup"; 5 | import App from "./App"; 6 | import "./index.css"; 7 | 8 | // Renders the web-app/extension content to DOM. 9 | setupProject({ 10 | rootElement: ( 11 | 12 | 13 | 14 | ), 15 | injectExtensionTo: "body", 16 | injectWebAppTo: "#root", 17 | }); 18 | 19 | // If you want to start measuring performance in your app, pass a function 20 | // to log results (for example: reportWebVitals(console.log)) 21 | // or send to an analytics endpoint. Learn more: https://bit.ly/CRA-vitals 22 | reportWebVitals(); 23 | -------------------------------------------------------------------------------- /src/logo.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/project-setup.ts: -------------------------------------------------------------------------------- 1 | import ReactDOM, { Renderer } from "react-dom"; 2 | 3 | import { checkIsExtension } from "./services/environment-service"; 4 | 5 | type RootElement = Parameters["0"][0]; 6 | type ContainerSelector = string; 7 | 8 | interface AppSetupConfig { 9 | rootElement: RootElement; 10 | injectExtensionTo: ContainerSelector; 11 | injectWebAppTo: ContainerSelector; 12 | } 13 | 14 | const findElementInDOM = (selector: ContainerSelector) => { 15 | return document.querySelector(selector); 16 | }; 17 | 18 | const renderAppToDOM = (element: RootElement, selector: ContainerSelector) => { 19 | ReactDOM.render(element, document.querySelector(selector)); 20 | }; 21 | 22 | const injectExtensionToDOM = ( 23 | element: RootElement, 24 | selector: ContainerSelector 25 | ) => { 26 | const rootElementId = "root"; 27 | 28 | const appContainer = document.createElement("div"); 29 | appContainer.id = rootElementId; 30 | 31 | const elementInDOM = findElementInDOM(selector); 32 | 33 | if (elementInDOM) { 34 | elementInDOM.append(appContainer); 35 | renderAppToDOM(element, `#${rootElementId}`); 36 | } 37 | }; 38 | 39 | const initExtension = (element: RootElement, selector: ContainerSelector) => { 40 | const interval = setInterval(() => { 41 | // Can't inject the extension to DOM. 42 | if (!findElementInDOM(selector)) { 43 | return; 44 | } 45 | 46 | clearInterval(interval); 47 | injectExtensionToDOM(element, selector); 48 | }, 100); 49 | }; // check every 100ms 50 | 51 | const setupProject = (config: AppSetupConfig) => { 52 | if (checkIsExtension()) { 53 | initExtension(config.rootElement, config.injectExtensionTo); 54 | } else { 55 | renderAppToDOM(config.rootElement, config.injectWebAppTo); 56 | } 57 | }; 58 | 59 | export { setupProject }; 60 | -------------------------------------------------------------------------------- /src/react-app-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | -------------------------------------------------------------------------------- /src/reportWebVitals.ts: -------------------------------------------------------------------------------- 1 | import { 2 | ReportHandler, 3 | getCLS, 4 | getFID, 5 | getFCP, 6 | getLCP, 7 | getTTFB, 8 | } from "web-vitals"; 9 | 10 | const reportWebVitals = (onPerfEntry?: ReportHandler) => { 11 | if (onPerfEntry && onPerfEntry instanceof Function) { 12 | getCLS(onPerfEntry); 13 | getFID(onPerfEntry); 14 | getFCP(onPerfEntry); 15 | getLCP(onPerfEntry); 16 | getTTFB(onPerfEntry); 17 | } 18 | }; 19 | 20 | export default reportWebVitals; 21 | -------------------------------------------------------------------------------- /src/services/assets-service.ts: -------------------------------------------------------------------------------- 1 | import { checkIsExtension } from "./environment-service"; 2 | 3 | /** 4 | * Converts a relative path to a resource to a path depending on the context, 5 | * in which the resource is loaded. 6 | * @param {string} path A relative path to a resource 7 | * @example 8 | * import logo from "./logo.svg"; 9 | * 10 | * function ImageComponent() { 11 | * return ( 12 | * logo 16 | * ); 17 | * } 18 | * 19 | */ 20 | export const getResourceURL = (path: string) => { 21 | if (checkIsExtension()) { 22 | return window.chrome.runtime.getURL(path); 23 | } 24 | 25 | return path; 26 | }; 27 | -------------------------------------------------------------------------------- /src/services/environment-service.ts: -------------------------------------------------------------------------------- 1 | export const checkIsExtension = () => { 2 | return process.env.REACT_APP_BUILD_TARGET === "extension"; 3 | }; 4 | -------------------------------------------------------------------------------- /src/setupTests.ts: -------------------------------------------------------------------------------- 1 | // jest-dom adds custom jest matchers for asserting on DOM nodes. 2 | // allows you to do things like: 3 | // expect(element).toHaveTextContent(/react/i) 4 | // learn more: https://github.com/testing-library/jest-dom 5 | import '@testing-library/jest-dom'; 6 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es5", 4 | "lib": [ 5 | "dom", 6 | "dom.iterable", 7 | "esnext" 8 | ], 9 | "allowJs": true, 10 | "skipLibCheck": true, 11 | "esModuleInterop": true, 12 | "allowSyntheticDefaultImports": true, 13 | "strict": true, 14 | "forceConsistentCasingInFileNames": true, 15 | "noFallthroughCasesInSwitch": true, 16 | "module": "esnext", 17 | "moduleResolution": "node", 18 | "resolveJsonModule": true, 19 | "isolatedModules": true, 20 | "noEmit": true, 21 | "jsx": "react-jsx", 22 | "noImplicitAny": false, 23 | }, 24 | "include": [ 25 | "src" 26 | ] 27 | } 28 | --------------------------------------------------------------------------------