using my boilerplate yuh

This commit is contained in:
Sriram Hariharan
2023-02-22 22:51:38 -06:00
parent 21d7056aae
commit bce2717088
91 changed files with 32400 additions and 0 deletions

1
.env.development Normal file
View File

@@ -0,0 +1 @@
MANIFEST_KEY=

0
.env.production Normal file
View File

95
.eslintignore Normal file
View File

@@ -0,0 +1,95 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
# Runtime data
pids
*.pid
*.seed
*.pid.lock
# Directory for instrumented libs generated by jscoverage/JSCover
lib-cov
# Coverage directory used by tools like istanbul
coverage
# nyc test coverage
.nyc_output
# Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files)
.grunt
# Bower dependency directory (https://bower.io/)
bower_components
# node-waf configuration
.lock-wscript
# Compiled binary addons (https://nodejs.org/api/addons.html)
build/Release
# Dependency directories and management
node_modules/
jspm_packages/
package.json
package-lock.json
# TypeScript v1 declaration files
typings/
# Optional npm cache directory
.npm
# Optional eslint cache
.eslintcache
# Optional REPL history
.node_repl_history
# Output of 'npm pack'
*.tgz
# Yarn Integrity file
.yarn-integrity
# next.js build output
.next
# Webpack-built output
/dist
# Extension archives
/build
# VsCode
.vscode/*
!.vscode/launch.json
!.vscode/tasks.json
# macOS
.DS_Store
# Terraform
.terraform
# development dependencies
.dev/vue-devtools
.dev/browser-profiles
# IntelliJ
.idea
# Sylelint IntelliJ Plugin Requirement
.stylelintrc.json
# Local environment settings
.env.local
*.svg
config
.eslintrc.js

208
.eslintrc Normal file
View File

@@ -0,0 +1,208 @@
{
"root": true,
"env": {
"browser": true,
"es6": true,
"node": true,
"webextensions": true
},
"extends": [
"eslint:recommended",
"plugin:react/recommended",
"plugin:react-hooks/recommended",
"airbnb-base",
"airbnb/rules/react",
"airbnb-typescript",
"prettier"
],
"plugins": [
"import",
"jsdoc",
"react-prefer-function-component"
],
"globals": {
"Atomics": "readonly",
"SharedArrayBuffer": "readonly",
"debugger": true,
"browser": true,
"context": true,
"JSX": true
},
"parser": "@typescript-eslint/parser",
"parserOptions": {
"project": "./tsconfig.json",
"ecmaVersion": 2022,
"sourceType": "module",
"ecmaFeatures": {
"jsx": true,
"modules": true,
"experimentalObjectRestSpread": true
}
},
"settings": {
"react": {
"version": "detect"
},
"jsdoc": {
"mode": "typescript"
},
"import/parsers": {
"@typescript-eslint/parser": [
".ts",
".tsx"
]
},
"import/resolver": {
"typescript": {
"alwaysTryTypes": true,
"project": "./tsconfig.json"
}
}
},
"rules": {
"prefer-const": [
"off",
{
"destructuring": "any",
"ignoreReadBeforeAssign": false
}
],
"no-inner-declarations": "off",
"sort-imports": "off",
"no-case-declarations": "off",
"no-unreachable": "warn",
"no-constant-condition": "error",
"space-before-function-paren": "off",
"no-undef": "off",
"no-return-await": "off",
"@typescript-eslint/return-await": "off",
"@typescript-eslint/no-shadow": [
"off"
],
"@typescript-eslint/no-use-before-define": [
"off"
],
"class-methods-use-this": "off",
"react-hooks/exhaustive-deps": "warn",
"@typescript-eslint/lines-between-class-members": "off",
"no-param-reassign": [
"error",
{
"props": false
}
],
"no-console": "off",
"consistent-return": "off",
"react/destructuring-assignment": "off",
"import/prefer-default-export": "off",
"no-promise-executor-return": "off",
"import/no-cycle": "off",
"import/no-extraneous-dependencies": "off",
"react/jsx-props-no-spreading": "off",
"keyword-spacing": [
"error",
{
"before": true,
"after": true
}
],
"no-continue": "off",
"space-before-blocks": [
"error",
{
"functions": "always",
"keywords": "always",
"classes": "always"
}
],
"react/jsx-filename-extension": [
1,
{
"extensions": [
".tsx"
]
}
],
"react/no-deprecated": "warn",
"react/prop-types": "off",
"react-prefer-function-component/react-prefer-function-component": [
"warn",
{
"allowComponentDidCatch": false
}
],
"react/function-component-definition": "off",
"react/button-has-type": "off",
"jsdoc/require-param-type": "off",
"jsdoc/require-returns-type": "off",
"jsdoc/newline-after-description": "off",
"react/require-default-props": "off",
"jsdoc/require-jsdoc": [
"warn",
{
"enableFixer": true,
"publicOnly": true,
"checkConstructors": false,
"require": {
"ArrowFunctionExpression": true,
"ClassDeclaration": true,
"ClassExpression": true,
"FunctionExpression": true
},
"contexts": [
"MethodDefinition:not([key.name=\"componentDidMount\"]):not([key.name=\"render\"])",
"ArrowFunctionExpression",
"ClassDeclaration",
"ClassExpression",
"ClassProperty:not([key.name=\"state\"]):not([key.name=\"componentDidMount\"])",
"FunctionDeclaration",
"FunctionExpression",
"TSDeclareFunction",
"TSEnumDeclaration",
"TSInterfaceDeclaration",
"TSMethodSignature",
"TSModuleDeclaration",
"TSTypeAliasDeclaration"
]
}
],
"@typescript-eslint/no-explicit-any": "off",
"@typescript-eslint/no-unused-vars": "warn",
"@typescript-eslint/naming-convention": "off",
"@typescript-eslint/space-before-function-paren": "off",
"@typescript-eslint/ban-ts-comment": "off",
"@typescript-eslint/no-empty-interface": "warn",
"import/no-restricted-paths": [
"error",
{
"zones": [
{
"target": "./src/background",
"from": "./src/views",
"message": "You cannot import into the `background` directory from the `views` directory (i.e. content script files) because it will break the build!"
},
{
"target": "./src/views",
"from": "./src/background",
"message": "You cannot import into the `views` directory from the `background` directory (i.e. background script files) because it will break the build!"
},
{
"target": "./src/shared",
"from": "./",
"except": [
"./src/shared",
"./node_modules"
],
"message": "You cannot import into `shared` from an external directory."
}
]
}
],
"no-restricted-syntax": [
"error",
"ForInStatement",
"LabeledStatement",
"WithStatement"
]
}
}

25
.github/workflows/release.yml vendored Normal file
View File

@@ -0,0 +1,25 @@
name: Create Release
on:
push:
branches:
- production
- preview
jobs:
build:
name: build extension & create release
runs-on: ubuntu-latest
concurrency:
group: ${{ github.ref }}
cancel-in-progress: true
steps:
- uses: actions/checkout@master
- name: Get file permission
run: chmod -R 777 .
- name: Install dependencies
run: npm ci
- name: Release with semantic-release
id: semantic-release
run: npx --no-install semantic-release
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}

43
.github/workflows/validate-pr.yml vendored Normal file
View File

@@ -0,0 +1,43 @@
name: Validate PR Title
# thank you ben limmer for this workflow:
# https://github.com/blimmer/semantic-release-demo-2/blob/main/.github/workflows/lint-pr.yml
on:
pull_request_target:
types:
- opened
- reopened
- edited
- synchronize
jobs:
main:
runs-on: ubuntu-latest
steps:
- uses: amannn/action-semantic-pull-request@v3.2.6
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: Post Conventional Commit Comment (on failure)
uses: jungwinter/comment@v1
id: conventional-commit-help
with:
type: create
issue_number: ${{ github.event.pull_request.number }}
token: ${{ secrets.GITHUB_TOKEN }}
body: |
Your pull request title did not conform to [conventional commits](https://www.conventionalcommits.org/en/v1.0.0/) standards. Our upcoming automated release pipeline will automatically determine
the proper release version based on your pull request title.
**Cheat Sheet**
- feat: A new feature
- fix: A bug fix
- docs: Documentation only changes
- style: Changes that do not affect the meaning of the code (white-space, formatting, missing semi-colons, etc)
- refactor: A code change that neither fixes a bug nor adds a feature
- perf: A code change that improves performance
- test: Adding missing tests or correcting existing tests
- build: Changes that affect the build system or external dependencies (example scopes: gulp, broccoli, npm)
- ci: Changes to our CI configuration files and scripts (example scopes: Travis, Circle, BrowserStack, SauceLabs)
- chore: Other changes that don't modify src or test files
- revert: Reverts a previous commit
if: ${{ failure() }}

214
.gitignore vendored Normal file
View File

@@ -0,0 +1,214 @@
# File created using '.gitignore Generator' for Visual Studio Code: https://bit.ly/vscode-gig
# Created by https://www.toptal.com/developers/gitignore/api/visualstudiocode,macos,node,react,storybookjs
# Edit at https://www.toptal.com/developers/gitignore?templates=visualstudiocode,macos,node,react,storybookjs
### macOS ###
# General
.DS_Store
.AppleDouble
.LSOverride
# Icon must end with two \r
Icon
# Thumbnails
._*
# Files that might appear in the root of a volume
.DocumentRevisions-V100
.fseventsd
.Spotlight-V100
.TemporaryItems
.Trashes
.VolumeIcon.icns
.com.apple.timemachine.donotpresent
# Directories potentially created on remote AFP share
.AppleDB
.AppleDesktop
Network Trash Folder
Temporary Items
.apdisk
### macOS Patch ###
# iCloud generated files
*.icloud
### Node ###
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
lerna-debug.log*
.pnpm-debug.log*
# Diagnostic reports (https://nodejs.org/api/report.html)
report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json
# Runtime data
pids
*.pid
*.seed
*.pid.lock
# Directory for instrumented libs generated by jscoverage/JSCover
lib-cov
# Coverage directory used by tools like istanbul
coverage
*.lcov
# nyc test coverage
.nyc_output
# Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files)
.grunt
# Bower dependency directory (https://bower.io/)
bower_components
# node-waf configuration
.lock-wscript
# Compiled binary addons (https://nodejs.org/api/addons.html)
build
# Dependency directories
node_modules/
jspm_packages/
# Snowpack dependency directory (https://snowpack.dev/)
web_modules/
# TypeScript cache
*.tsbuildinfo
# Optional npm cache directory
.npm
# Optional eslint cache
.eslintcache
# Optional stylelint cache
.stylelintcache
# Microbundle cache
.rpt2_cache/
.rts2_cache_cjs/
.rts2_cache_es/
.rts2_cache_umd/
# Optional REPL history
.node_repl_history
# Output of 'npm pack'
*.tgz
# Yarn Integrity file
.yarn-integrity
# dotenv environment variable files
.env
.env.development.local
.env.test.local
.env.production.local
.env.local
# parcel-bundler cache (https://parceljs.org/)
.cache
.parcel-cache
# Next.js build output
.next
out
# Nuxt.js build / generate output
.nuxt
dist
# Gatsby files
.cache/
# Comment in the public line in if your project uses Gatsby and not Next.js
# https://nextjs.org/blog/next-9-1#public-directory-support
# public
# vuepress build output
.vuepress/dist
# vuepress v2.x temp and cache directory
.temp
# Docusaurus cache and generated files
.docusaurus
# Serverless directories
.serverless/
# FuseBox cache
.fusebox/
# DynamoDB Local files
.dynamodb/
# TernJS port file
.tern-port
# Stores VSCode versions used for testing VSCode extensions
.vscode-test
# yarn v2
.yarn/cache
.yarn/unplugged
.yarn/build-state.yml
.yarn/install-state.gz
.pnp.*
### Node Patch ###
# Serverless Webpack directories
.webpack/
# Optional stylelint cache
# SvelteKit build / generate output
.svelte-kit
### react ###
.DS_*
**/*.backup.*
**/*.back.*
node_modules
*.sublime*
psd
thumb
sketch
### VisualStudioCode ###
.vscode/*
!.vscode/settings.json
!.vscode/tasks.json
!.vscode/launch.json
!.vscode/extensions.json
!.vscode/*.code-snippets
# Local History for Visual Studio Code
.history/
# Built Visual Studio Code Extensions
*.vsix
### VisualStudioCode Patch ###
# Ignore all local history of files
.history
.ionide
# End of https://www.toptal.com/developers/gitignore/api/visualstudiocode,macos,node,react,storybookjs
# Custom rules (everything added below won't be overriden by 'Generate .gitignore File' if you use 'Update' option)

1
.nvmrc Normal file
View File

@@ -0,0 +1 @@
v18.12.1

19
.prettierignore Normal file
View File

@@ -0,0 +1,19 @@
*.css
# macOS
.DS_Store
# Webpack-built output
/dist
# Extension archives
/build
# Optional npm cache directory
.npm
# Dependency directories and management
node_modules/
jspm_packages/
package.json
package-lock.json
# Coverage directory used by tools like istanbul
coverage

12
.prettierrc.json Normal file
View File

@@ -0,0 +1,12 @@
{
"useTabs": false,
"printWidth": 120,
"tabWidth": 4,
"singleQuote": true,
"trailingComma": "es5",
"arrowParens": "avoid",
"bracketSpacing": true,
"bracketSameLine": false,
"semi": true,
"jsxSingleQuote": true
}

37
.releaserc.json Normal file
View File

@@ -0,0 +1,37 @@
{
"branches": [
"production",
{
"name": "preview",
"channel": "alpha",
"prerelease": "alpha"
}
],
"plugins": [
[
"@semantic-release/commit-analyzer",
{
"preset": "conventionalcommits"
}
],
[
"@semantic-release/release-notes-generator",
{
"preset": "conventionalcommits"
}
],
[
"@semantic-release/exec",
{
"prepareCmd": "SEMANTIC_VERSION=${nextRelease.version} npm run build"
}
],
[
"@semantic-release/github",
{
"assets": "build/**/artifacts/*.*",
"failComment": false
}
]
]
}

18
.vscode/launch.json vendored Normal file
View File

@@ -0,0 +1,18 @@
{
"version": "0.2.0",
"configurations": [
{
"type": "node",
"request": "launch",
"name": "Run current script",
"runtimeExecutable": "npx",
"runtimeArgs": [
"tsx"
],
"program": "${file}",
"skipFiles": [
"<node_internals>/**"
],
}
]
}

36
.vscode/settings.json vendored Normal file
View File

@@ -0,0 +1,36 @@
{
"editor.formatOnSave": true,
"editor.defaultFormatter": "esbenp.prettier-vscode",
"[javascript]": {
"editor.defaultFormatter": "esbenp.prettier-vscode"
},
"[yaml]": {
"editor.defaultFormatter": "esbenp.prettier-vscode"
},
"[typescriptreact]": {
"editor.defaultFormatter": "esbenp.prettier-vscode"
},
"[typescript]": {
"editor.defaultFormatter": "esbenp.prettier-vscode"
},
"[svg]": {
"editor.defaultFormatter": "jock.svg"
},
"material-icon-theme.activeIconPack": "react",
"material-icon-theme.folders.associations": {
"analytics": "Json",
"background": "Delta",
"navigation": "Routes",
"logging": "log",
"popup": "Layout",
"storage": "Database",
},
"material-icon-theme.files.associations": {
"tsconfig.extension.json": "tsconfig",
"tsconfig.build.json": "tsconfig",
"tsconfig.test.json": "tsconfig"
},
"[html]": {
"editor.defaultFormatter": "esbenp.prettier-vscode"
},
}

13
@types/css-imports.d.ts vendored Normal file
View File

@@ -0,0 +1,13 @@
declare module "*.module.css" {
const classes: { [key: string]: string };
export default classes;
}
declare module "*.module.scss" {
const classes: { [key: string]: string };
export default classes;
}
declare module "*.mp3" {
const src: string;
export default src;
}

22
@types/environment.d.ts vendored Normal file
View File

@@ -0,0 +1,22 @@
declare global {
namespace NodeJS {
interface ProcessEnv {
NODE_ENV: 'development' | 'production';
CI?: string;
/** set this to make sure the extension id is the same for unpacked extensions
* @see https://developer.chrome.com/docs/apps/app_identity/#copy_key */
MANIFEST_KEY?: string;
/**
* The Node semantic versioning-compatible version of the extension. For preview-style releases, this variable
* converts versions like 1.0.0.100 to 1.0.0-beta.1.
*/
SEMANTIC_VERSION?: string;
}
}
type Environment = typeof process.env.NODE_ENV;
}
// If this file has no import/export statements (i.e. is a script)
// convert it into a module by adding an empty export statement.
export {};

6
@types/svg-import.d.ts vendored Normal file
View File

@@ -0,0 +1,6 @@
declare module "*.svg" {
import { ReactElement, SVGProps } from "react";
const ReactComponent: (props: SVGProps<SVGElement>) => ReactElement;
export default ReactComponent;
}

19
README.md Normal file
View File

@@ -0,0 +1,19 @@
# UT Registration Plus
## Built Using:
- React 18
- Typescript
- Webpack 5 (esbuild-loader)
- Eslint
- Prettier
- Semantic-Release
- Custom Messaging & Storage wrappers
## Getting Started
1. Clone this repo
2. Run `npm install`
3. Run `npm start` to start the development server
4. Run `npm run build` to build the extension for production
5. Run `npm run release` to release a new version of the extension in CI (either preview or production)

29944
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

77
package.json Normal file
View File

@@ -0,0 +1,77 @@
{
"name": "ut-registration-plus",
"version": "0.0.0",
"description": "The UT Registration Plus extension is a Chrome extension that allows students to easily register for classes at The University of Texas at Austin.",
"private": true,
"homepage": "sriramhariharan.com",
"type": "module",
"scripts": {
"start": "tsx webpack/development.ts",
"build": "tsx webpack/production.ts",
"release": "tsx webpack/release.ts",
"devtools": "react-devtools",
"lint": "eslint ./ --ext .ts,.tsx"
},
"dependencies": {
"chrome-extension-toolkit": "^0.0.21",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"sass": "^1.57.1"
},
"devDependencies": {
"@semantic-release/exec": "^6.0.3",
"@svgr/webpack": "^6.5.1",
"@types/chrome": "^0.0.204",
"@types/node": "^18.11.17",
"@types/prompts": "^2.4.2",
"@types/react": "^18.0.26",
"@types/react-dom": "^18.0.9",
"@types/semver": "^7.3.13",
"@typescript-eslint/eslint-plugin": "^5.47.0",
"@typescript-eslint/parser": "^5.47.0",
"archiver": "^5.3.1",
"case-sensitive-paths-webpack-plugin": "^2.4.0",
"chalk": "^5.2.0",
"conventional-changelog-conventionalcommits": "^5.0.0",
"copy-webpack-plugin": "^11.0.0",
"create-file-webpack": "^1.0.2",
"css-loader": "^6.7.3",
"dotenv": "^16.0.3",
"esbuild-loader": "^2.20.0",
"eslint": "^8.30.0",
"eslint-config-airbnb": "^19.0.4",
"eslint-config-airbnb-typescript": "^17.0.0",
"eslint-config-prettier": "^8.5.0",
"eslint-import-resolver-typescript": "^3.5.2",
"eslint-plugin-import": "^2.26.0",
"eslint-plugin-jsdoc": "^39.6.4",
"eslint-plugin-prettier": "^4.2.1",
"eslint-plugin-react": "^7.31.11",
"eslint-plugin-react-hooks": "^4.6.0",
"eslint-plugin-react-prefer-function-component": "^3.1.0",
"file-loader": "^6.2.0",
"fork-ts-checker-webpack-plugin": "^7.2.14",
"html-webpack-plugin": "^5.5.0",
"mini-css-extract-plugin": "^2.7.2",
"path": "^0.12.7",
"prettier": "^2.8.1",
"prompts": "^2.4.2",
"react-dev-utils": "^12.0.1",
"react-devtools": "^4.27.1",
"sass-loader": "^13.2.0",
"semantic-release": "^19.0.5",
"semver": "^7.3.8",
"simple-git": "^3.15.1",
"socket.io": "^4.5.4",
"socket.io-client": "^4.5.4",
"terser-webpack-plugin": "^5.3.6",
"ts-node": "^10.9.1",
"tsconfig-paths-webpack-plugin": "^4.0.0",
"tsx": "^3.12.1",
"typescript": "^4.9.4",
"url-loader": "^4.1.1",
"webpack": "^5.75.0",
"webpack-build-notifier": "^2.3.0",
"webpack-dev-server": "^4.11.1"
}
}

5
public/LICENSE.txt Normal file
View File

@@ -0,0 +1,5 @@
Copyright © 2021 <Company>, All rights reserved
All information contained herein is, and remains the property of <COMPANY>. The intellectual and technical concepts contained
herein are proprietary to <Company> and may be covered by U.S. and Foreign Patents, patents in process, and are protected by trade secret or copyright law.
Dissemination of this information or reproduction of this material is strictly forbidden unless prior written permission is obtained from <Company>.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 430 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 849 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

3
public/robots.txt Normal file
View File

@@ -0,0 +1,3 @@
# https://www.robotstxt.org/robotstxt.html
User-agent: *
Disallow:

View File

@@ -0,0 +1,50 @@
import { MessageListener } from 'chrome-extension-toolkit';
import { BACKGROUND_MESSAGES } from 'src/shared/messages';
import { generateRandomId } from 'src/shared/util/random';
import onHistoryStateUpdated from './events/onHistoryStateUpdated';
import onInstall from './events/onInstall';
import onNewChromeSession from './events/onNewChromeSession';
import onServiceWorkerAlive from './events/onServiceWorkerAlive';
import onUpdate from './events/onUpdate';
import { sessionStore } from '../shared/storage/sessionStore';
import browserActionHandler from './handler/browserActionHandler';
import hotReloadingHandler from './handler/hotReloadingHandler';
import tabManagementHandler from './handler/tabManagementHandler';
onServiceWorkerAlive();
/**
* will be triggered on either install or update
* (will also be triggered on a user's sync'd browsers (on other devices)))
*/
chrome.runtime.onInstalled.addListener(details => {
switch (details.reason) {
case 'install':
onInstall();
break;
case 'update':
onUpdate();
break;
default:
break;
}
});
// This event is fired when any tab's url changes.
chrome.webNavigation.onHistoryStateUpdated.addListener(onHistoryStateUpdated);
// initialize the message listener that will listen for messages from the content script
const messageListener = new MessageListener<BACKGROUND_MESSAGES>({
...browserActionHandler,
...hotReloadingHandler,
...tabManagementHandler,
});
messageListener.listen();
sessionStore.getChromeSessionId().then(async chromeSessionId => {
if (!chromeSessionId) {
await sessionStore.setChromeSessionId(generateRandomId(10));
onNewChromeSession();
}
});

View File

@@ -0,0 +1,11 @@
/**
* This event is fired when any tab's url changes.
* This is useful for content scripts to know when SPA navigations occur.
* @param details
*/
export default function onHistoryStateUpdated(
details: chrome.webNavigation.WebNavigationTransitionCallbackDetails
): void {
const { tabId, url } = details;
// TODO: send a message to tab with tabId to reanalyze the page
}

View File

@@ -0,0 +1,26 @@
import { SECOND } from 'src/shared/util/time';
/**
* Called when the extension is first installed or synced onto a new machine
*/
export default async function onInstall() {
// set the uninstall url
chrome.runtime.setUninstallURL('https://www.google.com');
logOnInstallEvent();
}
/**
* making sure we are not sending duplicate install event for users that have synced browsers
* sync storage get's cleared on browser uninstall, so re-installing the browser will trigger the event
*/
function logOnInstallEvent() {
setTimeout(async () => {
const manifest = chrome.runtime.getManifest();
const INSTALL_KEY = `${manifest.short_name}-installed`;
const storage = await chrome.storage.sync.get(INSTALL_KEY);
if (!storage[INSTALL_KEY]) {
// TODO: send install event
await chrome.storage.sync.set({ [INSTALL_KEY]: true });
}
}, 5 * SECOND);
}

View File

@@ -0,0 +1,4 @@
/**
* This function is called when the user's browser opens for the first time
*/
export default function onNewChromeSession() {}

View File

@@ -0,0 +1,9 @@
import { openDebugTab } from '../util/openDebugTab';
/**
* Called whenever the background service worker comes alive
* (usually around 30 seconds to 5 minutes after it was last alive)
*/
export default function onServiceWorkerAlive() {
openDebugTab();
}

View File

@@ -0,0 +1,10 @@
import { hotReloadTab } from 'src/background/util/hotReloadTab';
/**
* Called when the extension is updated (or when the extension is reloaded in development mode)
*/
export default function onUpdate() {
if (process.env.NODE_ENV === 'development') {
hotReloadTab();
}
}

View File

@@ -0,0 +1,20 @@
import { MessageHandler } from 'chrome-extension-toolkit';
import BrowserActionMessages from 'src/shared/messages/BrowserActionMessages';
const browserActionHandler: MessageHandler<BrowserActionMessages> = {
disableBrowserAction({ sender, sendResponse }) {
// by setting the popup to an empty string, clicking the browser action will not open the popup.html.
// we can then add an onClickListener to it from the content script
chrome.action.setPopup({ tabId: sender.tab?.id, popup: '' }).then(sendResponse);
},
enableBrowserAction({ sender, sendResponse }) {
chrome.action
.setPopup({
tabId: sender.tab?.id,
popup: 'popup.html',
})
.then(sendResponse);
},
};
export default browserActionHandler;

View File

@@ -0,0 +1,21 @@
import HotReloadingMessages from 'src/shared/messages/HotReloadingMessages';
import { MessageHandler } from 'chrome-extension-toolkit';
import { devStore } from 'src/shared/storage/devStore';
const hotReloadingHandler: MessageHandler<HotReloadingMessages> = {
async reloadExtension({ sendResponse }) {
const isExtensionReloading = await devStore.getIsExtensionReloading();
if (!isExtensionReloading) return sendResponse();
const isTabReloading = await devStore.getIsExtensionReloading();
if (isTabReloading) {
const tabs = await chrome.tabs.query({ active: true, currentWindow: true });
const tabToReload = tabs[0];
await devStore.setReloadTabId(tabToReload?.id);
}
chrome.runtime.reload();
},
};
export default hotReloadingHandler;

View File

@@ -0,0 +1,18 @@
import { MessageHandler } from 'chrome-extension-toolkit';
import TabManagementMessages from 'src/shared/messages/TabManagementMessages';
const tabManagementHandler: MessageHandler<TabManagementMessages> = {
getTabId({ sendResponse, sender }) {
sendResponse(sender.tab?.id ?? -1);
},
openNewTab({ data, sendResponse }) {
const { url } = data;
chrome.tabs.create({ url }).then(sendResponse);
},
removeTab({ data, sendResponse }) {
const { tabId } = data;
chrome.tabs.remove(tabId).then(sendResponse);
},
};
export default tabManagementHandler;

View File

@@ -0,0 +1,39 @@
import { devStore } from 'src/shared/storage/devStore';
/**
* A list of websites that we don't want to reload when the extension reloads (becuase it'd be hella annoying lmao)
*/
const HOT_RELOADING_WHITELIST = [
'youtube.com',
'twitch.tv',
'github.dev',
'figma.com',
'netflix.com',
'disneyplus.com',
'hbomax.com',
'spotify.com',
'localhost:6006',
'docs.google.com',
'reddit.com',
'gmail.com',
'photopea.com',
];
/**
* Reloads the tab that was open when the extension was reloaded
* @returns a promise that resolves when the tab is reloaded
*/
export async function hotReloadTab(): Promise<void> {
const { getIsTabReloading, getReloadTabId } = devStore;
const [isTabReloading, reloadTabId] = await Promise.all([getIsTabReloading(), getReloadTabId()]);
if (!isTabReloading || !reloadTabId) return;
chrome.tabs.get(reloadTabId, tab => {
if (!tab?.id) return;
if (!HOT_RELOADING_WHITELIST.find(url => tab.url?.includes(url))) {
chrome.tabs.reload(tab.id);
}
});
}

View File

@@ -0,0 +1,24 @@
import { devStore } from 'src/shared/storage/devStore';
/**
* Open the debug tab as the first tab
*/
export async function openDebugTab() {
if (process.env.NODE_ENV === 'development') {
const debugTabId = await devStore.getDebugTabId();
const isAlreadyOpen = await (await chrome.tabs.query({})).some(tab => tab.id === debugTabId);
if (isAlreadyOpen) return;
const wasVisible = await devStore.getWasDebugTabVisible();
const tab = await chrome.tabs.create({
url: chrome.runtime.getURL('debug.html'),
active: wasVisible,
pinned: true,
index: 0,
});
await devStore.setDebugTabId(tab.id);
}
}

33
src/debug/hotReload.ts Normal file
View File

@@ -0,0 +1,33 @@
import io from 'socket.io-client';
import { bMessenger } from 'src/shared/messages';
const socket = io('http://localhost:9090');
let reBuilding = false;
socket.on('disconnect', async reason => {
reBuilding = reason.includes('transport') && !reason.includes('client');
});
socket.onAny(args => {
console.log(args);
});
socket.on('connect', async () => {
if (!reBuilding) {
console.log('%c[hot-reloading] listening for changes...', 'color:white; background-color: orange;');
} else {
console.log(
'%c[hot-reloading] changes detected, rebuilding and refreshing...',
'color:white; background-color: orange;'
);
}
});
socket.on('reload', async () => {
console.log('%c[hot-reloading] reloading...', 'color:white; background-color: orange;');
chrome.tabs.query({ active: true, currentWindow: true }, tabs => {
if (tabs?.[0]?.id) {
bMessenger.reloadExtension();
}
});
});

129
src/debug/index.tsx Normal file
View File

@@ -0,0 +1,129 @@
import './hotReload';
import React, { useEffect } from 'react';
import { render } from 'react-dom';
import { devStore } from 'src/shared/storage/devStore';
const manifest = chrome.runtime.getManifest();
interface JSONEditorProps {
data: any;
onChange: (updates: any) => void;
}
function JSONEditor(props: JSONEditorProps) {
const { data, onChange } = props;
const [isEditing, setIsEditing] = React.useState(false);
const [json, setJson] = React.useState(JSON.stringify(data, null, 2));
useEffect(() => {
setJson(JSON.stringify(data, null, 2));
}, [data]);
const handleChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
setJson(e.target.value);
};
const handleSave = () => {
try {
const updates = JSON.parse(json);
onChange(updates);
setIsEditing(false);
} catch (e) {
console.error(e);
alert('Invalid JSON');
}
};
return (
<div>
{isEditing ? (
<div>
<div style={{ flex: 1, marginBottom: 10, gap: 10, display: 'flex' }}>
<button style={{ color: 'green' }} onClick={handleSave}>
Save
</button>
<button style={{ color: 'red' }} onClick={() => setIsEditing(false)}>
Cancel
</button>
</div>
<textarea style={{ width: '100%', height: '300px' }} value={json} onChange={handleChange} />
</div>
) : (
<div>
<pre onClick={() => setIsEditing(true)}>{json}</pre>
</div>
)}
</div>
);
}
// const PrettyPrintJson = React.memo(({ data }: any) => (
// <div>
// <pre>{JSON.stringify(data, null, 2)}</pre>
// </div>
// ));
function DevDashboard() {
const [localStorage, setLocalStorage] = React.useState<any>({});
const [syncStorage, setSyncStorage] = React.useState<any>({});
const [sessionStorage, setSessionStorage] = React.useState<any>({});
useEffect(() => {
const onVisibilityChange = () => {
if (document.visibilityState === 'visible') {
devStore.setWasDebugTabVisible(true);
} else {
devStore.setWasDebugTabVisible(false);
}
};
document.addEventListener('visibilitychange', onVisibilityChange);
return () => {
document.removeEventListener('visibilitychange', onVisibilityChange);
};
}, []);
useEffect(() => {
chrome.storage.local.get(null, result => {
setLocalStorage(result);
});
chrome.storage.sync.get(null, result => {
setSyncStorage(result);
});
chrome.storage.session.get(null, result => {
setSessionStorage(result);
});
chrome.storage.onChanged.addListener((changes, areaName) => {
if (areaName === 'local') {
setLocalStorage({ ...localStorage, ...changes });
} else if (areaName === 'sync') {
setSyncStorage({ ...syncStorage, ...changes });
} else if (areaName === 'session') {
setSessionStorage({ ...sessionStorage, ...changes });
}
});
}, []);
const handleEditStorage = (areaName: string) => (changes: Record<string, any>) => {
chrome.storage[areaName].set(changes);
};
return (
<div>
<h1>
{manifest.name} {manifest.version} - {process.env.NODE_ENV}
</h1>
<p>This tab is used for hot reloading and debugging. We will update this tab further in the future.</p>
<h2>Local Storage</h2>
<JSONEditor data={localStorage} onChange={handleEditStorage('local')} />
<h2>Sync Storage</h2>
<JSONEditor data={syncStorage} onChange={handleEditStorage('sync')} />
<h2>Session Storage</h2>
<JSONEditor data={sessionStorage} onChange={handleEditStorage('session')} />
<br />
</div>
);
}
render(<DevDashboard />, document.getElementById('root'));

View File

@@ -0,0 +1,6 @@
export default interface BrowserActionMessages {
/** make it so that clicking the browser action will open the popup.html */
enableBrowserAction: () => void;
/** make it so that clicking the browser action will respond to interactions from the content script */
disableBrowserAction: () => void;
}

View File

@@ -0,0 +1,3 @@
export default interface HotReloadingMessages {
reloadExtension: () => void;
}

View File

@@ -0,0 +1,21 @@
/**
* Messages for managing the user's open tabs list
*/
export default interface TabManagementMessages {
/**
* Opens a new tab with the given URL
* @param data The URL to open
*/
openNewTab: (data: { url: string }) => chrome.tabs.Tab;
/**
* Gets the ID of the current tab (the tab that sent the message)
* @returns The ID of the current tab
*/
getTabId: () => number;
/**
* Removes the tab with the given ID
* @param data The ID of the tab to remove
* @returns The ID of the tab that was removed
*/
removeTab: (data: { tabId: number }) => void;
}

View File

@@ -0,0 +1,6 @@
/**
* This is a type with all the message definitions that can be sent TO specific tabs
*/
export default interface TAB_MESSAGES {
reAnalyzePage: (data: { url: string }) => void;
}

View File

@@ -0,0 +1,21 @@
import { createMessenger } from 'chrome-extension-toolkit';
import TAB_MESSAGES from './TabMessages';
import BrowserActionMessages from './BrowserActionMessages';
import HotReloadingMessages from './HotReloadingMessages';
import TabManagementMessages from './TabManagementMessages';
/**
* This is a type with all the message definitions that can be sent TO the background script
*/
export type BACKGROUND_MESSAGES = BrowserActionMessages & TabManagementMessages & HotReloadingMessages;
/**
* A utility object that can be used to send type-safe messages to the background script
*/
export const bMessenger = createMessenger<BACKGROUND_MESSAGES>('background');
/**
* A utility object that can be used to send type-safe messages to specific tabs
*/
export const tabMessenger = createMessenger<TAB_MESSAGES>('tab');

View File

@@ -0,0 +1,25 @@
import { createStore } from 'chrome-extension-toolkit';
/**
* A store that is used to store data that is only relevant during development
*/
interface IDevStore {
/** the tabId for the debug tab */
debugTabId?: number;
/** whether the debug tab is visible */
wasDebugTabVisible?: boolean;
/** whether we should enable extension reloading */
isExtensionReloading?: boolean;
/** whether we should enable tab reloading */
isTabReloading?: boolean;
/** The id of the tab that we want to reload (after the extension reloads itself ) */
reloadTabId?: number;
}
export const devStore = createStore<IDevStore>('DEV_STORE', {
debugTabId: undefined,
isTabReloading: true,
wasDebugTabVisible: false,
isExtensionReloading: true,
reloadTabId: undefined,
});

View File

@@ -0,0 +1,15 @@
import { createStore, Store } from 'chrome-extension-toolkit';
interface ISessionStore {
chromeSessionId?: string;
}
export const sessionStore = createStore<ISessionStore>(
'SESSION_STORE',
{
chromeSessionId: undefined,
},
{
area: 'session',
}
);

25
src/shared/util/random.ts Normal file
View File

@@ -0,0 +1,25 @@
/**
* Generate a random ID
*
* @returns string of size 10 made up of random numbers and letters
* @param length the length of the ID to generate
* @example "cdtl9l88pj"
*/
export function generateRandomId(length: number = 10): string {
const chars = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789';
let result = '';
for (let i = 0; i < length; i += 1) {
result += chars.charAt(Math.floor(Math.random() * chars.length));
}
return result;
}
/**
* Generate a random number between min and max
* @param min the minimum number
* @param max the maximum number
* @returns a random number between min and max
*/
export function rangeRandom(min: number, max: number): number {
return Math.floor(Math.random() * (max - min + 1)) + min;
}

34
src/shared/util/string.ts Normal file
View File

@@ -0,0 +1,34 @@
/**
* Given a string, returns a string with the first letter capitalized.
* @input The string to capitalize.
*/
export function capitalize(input: string): string {
try {
return input.charAt(0).toUpperCase() + input.substring(1).toLowerCase();
} catch (err) {
return input;
}
}
/**
* Given a string, returns a string with the first letter capitalized.
* @param input capitalize the first letter of this string
* @returns the string with the first letter capitalized
*/
export function capitalizeFirstLetter(input: string): string {
return input.charAt(0).toUpperCase() + input.slice(1);
}
/**
* Cuts the
* @param input The string to ellipsify.
* @param length The length of the string to return.
* @returns The ellipsified string.
*/
export const ellipsify = (input: string, chars: number): string => {
let ellipisifed = input;
if (input && input.length > chars) {
ellipisifed = `${input.substring(0, chars)}...`;
}
return ellipisifed;
};

19
src/shared/util/time.ts Normal file
View File

@@ -0,0 +1,19 @@
export const MILLISECOND = 1;
export const SECOND = 1000 * MILLISECOND;
export const MINUTE = 60 * SECOND;
export const HOUR = 60 * MINUTE;
export const DAY = 24 * HOUR;
/**
*
*/
export const sleep = (milliseconds: number): Promise<void> => new Promise(resolve => setTimeout(resolve, milliseconds));
/**
* Checks to see if expired by the time first stored and the time frame that it is stored for
*
* @param time time it was stored
* @param threshold time frame it can be stored for
* @return true if expired, false if the time frame is still in range
*/
export const didExpire = (time: number, threshold: number): boolean => time + threshold <= Date.now();

17
src/tsconfig.json Normal file
View File

@@ -0,0 +1,17 @@
{
"extends": "../tsconfig.json",
"compilerOptions": {
"composite": true,
"lib": [
"DOM",
"es2021"
],
"types": [
"chrome",
"node"
],
},
"exclude": [
"../webpack"
]
}

View File

@@ -0,0 +1,19 @@
@import 'src/views/styles/base.module.scss';
.button {
background-color: #000;
color: #fff;
padding: 10px;
border-radius: 5px;
border: none;
cursor: pointer;
font-size: 16px;
font-weight: 600;
transition: all 0.3s ease;
font-family: 'Inter';
&:hover {
background-color: #fff;
color: #000;
}
}

View File

@@ -0,0 +1,15 @@
import React from 'react';
import { bMessenger } from 'src/shared/messages';
import styles from './Button.module.scss';
export function Button(): JSX.Element {
const handleOpenUrl = (url: string) => () => {
bMessenger.openNewTab({ url });
};
return (
<button className={styles.button} onClick={handleOpenUrl('https://www.google.com')}>
Click me
</button>
);
}

View File

View File

@@ -0,0 +1,26 @@
import React from 'react';
import { render } from 'react-dom';
import { bMessenger } from 'src/shared/messages';
import { ContextInvalidated, createShadowDOM, onContextInvalidated } from 'chrome-extension-toolkit';
import { Button } from './components/Button/Button';
bMessenger.getTabId().then(tabId => {
console.log('tabId', tabId);
});
injectReact();
async function injectReact() {
const shadowDom = createShadowDOM('extension-dom-container');
render(<Button />, shadowDom.shadowRoot);
await shadowDom.addStyle('static/css/content.css');
}
if (process.env.NODE_ENV === 'development') {
onContextInvalidated(() => {
const div = document.createElement('div');
div.id = 'context-invalidated-container';
document.body.appendChild(div);
render(<ContextInvalidated color='black' backgroundColor='orange' />, div);
});
}

View File

@@ -0,0 +1,4 @@
import { createUseMessage } from 'chrome-extension-toolkit';
import TAB_MESSAGES from 'src/shared/messages/TabMessages';
export const useTabMessage = createUseMessage<TAB_MESSAGES>();

17
src/views/popup/popup.tsx Normal file
View File

@@ -0,0 +1,17 @@
import React from 'react';
import { render } from 'react-dom';
console.log('test');
console.log('test2');
// Path: src/views/popup/popup.tsx
console.log('test3');
render(
<div>
<h1>Test</h1>
</div>,
document.getElementById('root')
);

View File

@@ -0,0 +1,24 @@
// this is a custom wrapper around react-devtools
// that changes it so that we only send messages to the devtools when the current tab is active;
import { connectToDevTools } from 'react-devtools-core';
// connect to the devtools server
let ws = new WebSocket('ws://localhost:8097');
connectToDevTools({
websocket: ws,
});
// when the tab's visibile state changes, we connect or disconnect from the devtools
const onVisibilityChange = () => {
if (document.visibilityState === 'visible') {
ws = new WebSocket('ws://localhost:8097');
connectToDevTools({
websocket: ws,
});
} else {
ws.close();
}
};
document.addEventListener('visibilitychange', onVisibilityChange);

View File

@@ -0,0 +1,2 @@
@import './colors.module.scss';
@import './fonts.module.scss';

View File

View File

@@ -0,0 +1,9 @@
@each $weights in '100' '200' '300' '400' '500' '600' '700' '800' '900' {
@font-face {
font-family: 'Inter';
src: url('chrome-extension://__MSG_@@extension_id__/fonts/inter-#{$weights}.woff2') format('woff2');
font-display: auto;
font-style: normal;
font-weight: #{$weights};
}
}

55
tsconfig.json Normal file
View File

@@ -0,0 +1,55 @@
{
"compilerOptions": {
"target": "es2021",
"outDir": "./",
"noEmit": true,
"typeRoots": [
"./node_modules/@types",
"./@types/"
],
"rootDir": "./",
"allowSyntheticDefaultImports": true,
"esModuleInterop": true,
"module": "ES2022",
"moduleResolution": "node",
"allowJs": true,
"sourceMap": true,
"resolveJsonModule": true,
"incremental": true,
"lib": [
"DOM",
"es2021"
],
"jsx": "react",
"skipLibCheck": true,
"strictBindCallApply": true,
"pretty": true,
"noImplicitReturns": false,
"baseUrl": "./",
"paths": {
"src/*": [
"./src/*"
],
"webpack/*": [
"./webpack/*"
],
},
"noImplicitThis": true,
"noImplicitAny": false,
"strictNullChecks": true,
"forceConsistentCasingInFileNames": true,
},
"include": [
"src/**/*",
"webpack/**/*",
"@types/**/*",
"./package.json",
"./release.config.js",
"webpack/plugins/custom/.ts"
],
"exclude": [
"node_modules",
"**/.*/",
"build",
],
}

41
webpack/development.ts Normal file
View File

@@ -0,0 +1,41 @@
import webpack from 'webpack';
import WebpackDevServer from 'webpack-dev-server';
import path from 'path';
import { Server } from 'socket.io';
import config from './webpack.config';
import { version } from '../package.json';
import { getManifest } from './manifest.config';
import { initializeHotReloading } from './plugins/custom/hotReloadServer';
const HOT_RELOAD_PORT = 9090;
const MODE: Environment = 'development';
const manifest = getManifest(MODE, version);
const compiler = webpack(config(MODE, manifest));
initializeHotReloading(HOT_RELOAD_PORT, compiler);
const server = new WebpackDevServer(
{
https: false,
hot: false,
client: false,
host: 'localhost',
static: {
directory: path.resolve('build'),
},
devMiddleware: {
writeToDisk: true,
},
headers: {
'Access-Control-Allow-Origin': '*',
},
allowedHosts: 'all',
watchFiles: {
paths: ['src/**/*.{ts,tsx,js,jsx,html,css,scss,json,md,png,jpg,jpeg,gif,svg}', 'public/**/*'],
},
},
compiler
);
await server.start();

52
webpack/loaders/index.ts Normal file
View File

@@ -0,0 +1,52 @@
import { RuleSetRule } from 'webpack';
import * as styleLoaders from './styleLoaders';
/** using esbuild-loader for ⚡ fast builds */
const typescriptLoader: RuleSetRule = {
test: /\.tsx?$/,
loader: 'esbuild-loader',
options: {
loader: 'tsx',
target: 'es2021',
},
};
/** convert svgs to react components automatically */
const svgLoader: RuleSetRule = {
test: /\.svg$/,
issuer: /\.tsx?$/,
loader: '@svgr/webpack',
};
/** these are files that we want to be able to be loaded into the extension folder instead of imported */
const urlLoader: RuleSetRule = {
test: [/\.bmp$/, /\.gif$/, /\.jpe?g$/, /\.png$/, /\.mp3$/],
loader: 'url-loader',
options: {
limit: '10000',
name: 'static/media/[name].[ext]',
},
};
/** these loaders will allow us to use raw css imports, css modules, raw sass imports, and sass modules */
const { cssLoader, cssModuleLoader, sassLoader, sassModuleLoader } = styleLoaders;
// this is the default file loader, it will be used for any file that doesn't match the other loaders
const fileLoader: RuleSetRule = {
loader: 'file-loader',
exclude: [/\.(js|mjs|jsx|ts|tsx)$/, /\.html$/, /\.json$/, /\.mp3$/],
options: {
name: 'static/media/[name].[ext]',
},
};
/** the assembled list of loaders in the order that we want webpack to attempt to use them on modules */
const loaders: RuleSetRule[] = [
typescriptLoader,
{
// IMPORTANT: if you are adding a new loader, it must come before the file loader
oneOf: [svgLoader, urlLoader, cssLoader, cssModuleLoader, sassLoader, sassModuleLoader, fileLoader],
},
];
export default loaders;

View File

@@ -0,0 +1,75 @@
import { RuleSetRule, RuleSetUseItem } from 'webpack';
import MiniCssExtractPlugin from 'mini-css-extract-plugin';
import getCSSModuleLocalIdent from 'react-dev-utils/getCSSModuleLocalIdent';
const cssRegex = /\.css$/;
const cssModuleRegex = /\.module\.css$/;
const sassRegex = /\.(scss|sass)$/;
const sassModuleRegex = /\.module\.(scss|sass)$/;
function buildStyleLoaders(cssLoaderOptions: Record<string, any>): RuleSetUseItem[] {
const loaders = [
{
loader: MiniCssExtractPlugin.loader,
},
{
loader: 'css-loader',
options: { ...cssLoaderOptions, sourceMap: false },
},
];
return loaders;
}
export const cssLoader: RuleSetRule = {
test: cssRegex,
exclude: cssModuleRegex,
sideEffects: true,
use: [
...buildStyleLoaders({
importLoaders: 1,
esModule: false,
}),
],
};
export const cssModuleLoader: RuleSetRule = {
test: cssModuleRegex,
use: [
...buildStyleLoaders({
importLoaders: 1,
modules: {
getLocalIdent: getCSSModuleLocalIdent,
},
}),
],
};
export const sassLoader: RuleSetRule = {
test: sassRegex,
exclude: sassModuleRegex,
sideEffects: true,
use: [
...buildStyleLoaders({
importLoaders: 2,
}),
{
loader: 'sass-loader',
},
],
};
export const sassModuleLoader: RuleSetRule = {
test: sassModuleRegex,
use: [
...buildStyleLoaders({
importLoaders: 2,
modules: {
getLocalIdent: getCSSModuleLocalIdent,
},
}),
{
loader: 'sass-loader',
},
],
};

View File

@@ -0,0 +1,57 @@
const NAME = 'UT Registration Plus';
const SHORT_NAME = 'ut-registration-plus';
const DESCRIPTION = 'Improves the course registration process at the University of Texas at Austin!';
const HOST_PERMISSIONS: string[] = [
'*://*.utdirect.utexas.edu/apps/registrar/course_schedule/*',
'*://*.utexas.collegescheduler.com/*',
'*://*.catalog.utexas.edu/ribbit/',
'*://*.registrar.utexas.edu/schedules/*',
'*://*.login.utexas.edu/login/*',
];
/**
* Creates a chrome extension manifest from the given version, mode, and
* @param mode the build mode (development or production)
* @param version a chrome extension version (not a semantic version)
* @returns a chrome extension manifest
*/
export function getManifest(mode: Environment, version: string): chrome.runtime.ManifestV3 {
let name = mode === 'development' ? `${NAME} (dev)` : NAME;
const manifest = {
name,
short_name: SHORT_NAME,
description: DESCRIPTION,
version,
manifest_version: 3,
// hardcode the key for development builds
key: process.env.MANIFEST_KEY,
host_permissions: HOST_PERMISSIONS,
permissions: ['storage', 'unlimitedStorage', 'background'],
background: {
service_worker: 'static/js/background.js',
},
content_scripts: [
{
matches: HOST_PERMISSIONS,
css: ['/static/css/content.css'],
js: ['/static/js/content.js'],
},
],
web_accessible_resources: [
{
resources: ['static/media/*', '*'],
matches: HOST_PERMISSIONS,
},
],
icons: {
16: `icons/icon_${mode}_16.png`,
48: `icons/icon_${mode}_48.png`,
128: `icons/icon_${mode}_128.png`,
},
action: {
default_popup: 'popup.html',
},
} satisfies chrome.runtime.ManifestV3;
return manifest;
}

View File

@@ -0,0 +1,116 @@
import path from 'path';
import dotenv from 'dotenv';
import webpack, { WebpackPluginInstance } from 'webpack';
import { EntryId } from 'webpack/webpack.config';
import CreateFileWebpack from 'create-file-webpack';
import CopyWebpackPlugin from 'copy-webpack-plugin';
import ForkTsCheckerWebpackPlugin from 'fork-ts-checker-webpack-plugin';
import WebpackBuildNotifierPlugin from 'webpack-build-notifier';
import CaseSensitivePathsPlugin from 'case-sensitive-paths-webpack-plugin';
import HTMLWebpackPlugin from 'html-webpack-plugin';
import MiniCssExtractPlugin from 'mini-css-extract-plugin';
import TypeErrorNotifierPlugin from './custom/TypeErrorNotifierPlugin';
/**
* Gets the plugins that are used in the build process
* @param mode the environment that the build is running in
* @param htmlEntries the entry points that need an html file
* @param manifest the manifest.json file
* @returns an array of webpack plugins
*/
export function getBuildPlugins(mode: Environment, htmlEntries: EntryId[], manifest: chrome.runtime.ManifestV3) {
let plugins: WebpackPluginInstance[] = [];
// show the progress of the build
plugins.push(new webpack.ProgressPlugin());
// make sure that the paths are case sensitive
plugins.push(new CaseSensitivePathsPlugin());
// specify how the outputed css files should be named
plugins.push(
new MiniCssExtractPlugin({
filename: 'static/css/[name].css',
chunkFilename: 'static/css/[name].chunk.css',
})
);
// create an html file for each entry point that needs one
for (const entryId of htmlEntries) {
// if (!entries[entryId]) return;
plugins.push(
new HTMLWebpackPlugin({
hash: false,
filename: `${entryId}.html`,
chunks: [entryId],
title: `${manifest.short_name} ${entryId} `,
template: path.resolve('webpack', 'plugins', 'template.html'),
})
);
}
// write the manifest.json file to the build directory
plugins.push(
new CreateFileWebpack({
path: path.resolve('build'),
fileName: 'manifest.json',
content: JSON.stringify(manifest, null, 2),
})
);
// copy the public directory to the build directory, but only copy the icons for the current mode
plugins.push(
new CopyWebpackPlugin({
patterns: [
{
from: path.resolve('public'),
filter: path => (path.includes('icons') ? path.includes(mode) : true),
},
],
})
);
// run the typescript checker in a separate process
plugins.push(
new ForkTsCheckerWebpackPlugin({
async: false,
})
);
// notify the developer of build events when in development mode
if (mode === 'development') {
plugins.push(
new WebpackBuildNotifierPlugin({
title: `${manifest.short_name} v${manifest.version} ${mode}`,
logo: path.resolve('public', 'icons', 'icon_production_128.png'),
failureSound: 'Ping',
showDuration: true,
suppressWarning: true,
})
);
}
// notify the developer of type errors
plugins.push(new TypeErrorNotifierPlugin());
// define the environment variables that are available within the extension code
plugins.push(
new webpack.DefinePlugin({
'process.env': JSON.stringify({
SEMANTIC_VERSION: process.env.SEMANTIC_VERSION,
NODE_ENV: mode,
...dotenv.config({ path: `.env.${mode}` }).parsed,
} satisfies typeof process.env),
})
);
// provide some global nodejs variables so that nodejs libraries can be used
plugins.push(
new webpack.ProvidePlugin({
Buffer: ['buffer', 'Buffer'],
process: 'process/browser',
})
);
return plugins;
}

View File

@@ -0,0 +1,84 @@
import { Compiler } from 'webpack';
import path from 'path';
import ForkTsCheckerWebpackPlugin from 'fork-ts-checker-webpack-plugin';
import WebpackBuildNotifierPlugin from 'webpack-build-notifier';
import { Issue, IssueLocation } from 'fork-ts-checker-webpack-plugin/lib/issue';
interface Resource {
path: string;
location: IssueLocation;
}
/**
* This plugin hooks into the fork-ts-checker-webpack-plugin and
* notifies the developer of type errors using the webpack-build-notifier plugin.
*/
export default class TypeErrorNotifierPlugin {
apply(compiler: Compiler) {
// hook into the fork-ts-checker-webpack-plugin
const hooks = ForkTsCheckerWebpackPlugin.getCompilerHooks(compiler);
hooks.issues.tap('MyPlugin', issues => {
const errors = issues.filter(issue => issue.severity === 'error');
if (!errors?.[0]?.message) {
return errors;
}
let error = errors[0];
let resource = getErrorResource(error);
try {
notifyTypeError(resource, error.message, errors);
} catch (e) {
console.error(e);
}
return errors;
});
}
}
function notifyTypeError(resource: Resource, message: string, errors: Issue[]) {
const { line, column } = resource.location.start;
const buildNotifier = new WebpackBuildNotifierPlugin({
logo: path.resolve('public', 'icons', 'icon_production_128.png'),
compilationSound: 'Pop',
failureSound: 'Sosumi',
title: `TS: ${errors.length} errors`,
notifyOptions: {
open: `vscode://file/${resource.path}:${line}:${column}`,
},
});
const fakeInput = {
hasErrors: () => true,
compilation: {
children: null,
errors: [
{
message,
module: {
resource: resource.path,
},
},
],
},
};
// @ts-ignore - private method
buildNotifier.onCompilationDone(fakeInput);
}
function getErrorResource(error: Issue): Resource {
return {
path: error.file ?? '',
location: error.location ?? {
end: {
column: 0,
line: 0,
},
start: {
column: 0,
line: 0,
},
},
};
}

View File

@@ -0,0 +1,36 @@
import { Server } from 'socket.io';
import { Compiler } from 'webpack';
import ForkTsCheckerWebpackPlugin from 'fork-ts-checker-webpack-plugin';
// Name of the plugin
const PLUGIN_NAME = 'HotReloadServer';
// How long to wait before reloading the browser after a successful build
const RELOAD_LOCKOUT = 2000;
// we want to cache all the "reload requests" here so we don't have to keep re-compiling the app while typing
const reloads: NodeJS.Timeout[] = [];
let io: Server;
export function initializeHotReloading(port: number, compiler: Compiler) {
io = new Server(port);
const hooks = ForkTsCheckerWebpackPlugin.getCompilerHooks(compiler);
hooks.issues.tap(PLUGIN_NAME, (issues, compilation) => {
const typeErrors = issues.filter(issue => issue.severity === 'error');
const buildErrors = compilation?.errors ?? [];
// if no errors (thus successful build), lets queue up a reload for the browser
if (typeErrors.length === 0 && buildErrors.length === 0) {
reloads.push(setTimeout(() => io.emit('reload'), RELOAD_LOCKOUT));
}
return typeErrors;
});
// if a recompile is triggered, we want to clear out all the queue'd reloads
// (so we don't spam-reload the extension while we are still typing
compiler.hooks.compile.tap(PLUGIN_NAME, () => {
reloads.forEach(reload => clearTimeout(reload));
reloads.length = 0;
});
}

View File

@@ -0,0 +1,12 @@
import TsconfigPathsPlugin from 'tsconfig-paths-webpack-plugin';
import path from 'path';
import ModuleScopePlugin from 'react-dev-utils/ModuleScopePlugin';
export const moduleResolutionPlugins = [
// this will make sure that webpack uses the tsconfig path aliases
new TsconfigPathsPlugin({
configFile: path.resolve('src', 'tsconfig.json'),
}),
// this will make sure that we don't import anything outside of the src directory from the src directory
new ModuleScopePlugin(path.resolve('src'), path.resolve('package.json')),
];

View File

@@ -0,0 +1,32 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="theme-color" content="#000000" />
<!--
Notice the use of %PUBLIC_URL% in the tags above.
It will be replaced with the URL of the `public` folder during the build.
Only files inside the `public` folder can be referenced from the HTML.
Unlike "/favicon.ico" or "favicon.ico", "%PUBLIC_URL%/favicon.ico" will
work correctly both with client-side routing and a non-root public URL.
Learn how to configure a non-root public URL by running `npm run build`.
-->
<title><%= htmlWebpackPlugin.options.title %></title>
</head>
<body>
<noscript>You need to enable JavaScript to run this app.</noscript>
<div id="root"></div>
<!--
This HTML file is a template.
If you open it directly in the browser, you will see an empty page.
You can add webfonts, meta tags, or analytics to this file.
The build step will place the bundled scripts into the <body> tag.
To begin the development, run `npm start` or `yarn start`.
To create a production bundle, use `npm run build` or `yarn build`.
--></body>
</html>

46
webpack/production.ts Normal file
View File

@@ -0,0 +1,46 @@
import webpack from 'webpack';
import formatWebpackMessages from 'react-dev-utils/formatWebpackMessages';
import config from './webpack.config';
import { info, success } from './utils/chalk';
import { getManifest } from './manifest.config';
import { version } from '../package.json';
import { convertSemver } from './utils/convertSemver';
import printError from './utils/printError';
import { zipProductionBuild } from './utils/zipProductionBuild';
const MODE: Environment = 'production';
// generate the manifest.json file
const semanticVersion = process.env.SEMANTIC_VERSION || version;
const manifestVersion = convertSemver(semanticVersion);
const manifest = getManifest(MODE, manifestVersion);
console.log(info(`${manifest.short_name} v${manifest.version} ${MODE} build starting...`));
// kick off the webpack build
webpack(config(MODE, manifest), async error => {
if (!error) {
await onBuildSuccess();
process.exit(0);
}
await onBuildFailure(error);
process.exit(1);
});
async function onBuildSuccess(): Promise<void> {
// zip the output directory and put it in the artifacts directory
const fileName = `${manifest.short_name} v${manifestVersion}`;
await zipProductionBuild(fileName);
console.log(success(`${fileName} built and zipped into build/artifacts/${fileName}.zip!`));
}
function onBuildFailure(error: Error): void {
if (!error.message) {
return printError(error);
}
const messages = formatWebpackMessages({ errors: [error.message], warnings: [] });
if (messages.errors.length > 1) {
messages.errors.length = 1;
}
return printError(new Error(messages.errors.join('\n\n')));
}

52
webpack/release.ts Normal file
View File

@@ -0,0 +1,52 @@
import { simpleGit } from 'simple-git';
import prompts from 'prompts';
import { error, info } from './utils/chalk';
import { getSourceRef } from './utils/git/getSourceRef';
const git = simpleGit();
const status = await git.status();
if (status.files.length) {
console.log(error('Working directory is not clean, please commit or stash changes before releasing.'));
process.exit(1);
}
const { destinationBranch } = await prompts({
type: 'select',
name: 'destinationBranch',
message: 'What kind of release do you want to create?',
choices: ['preview', 'production'].map(releaseType => ({
title: releaseType,
value: releaseType,
})),
});
const sourceRef = await getSourceRef(destinationBranch);
const { confirm } = await prompts({
type: 'confirm',
name: 'confirm',
message: `Are you sure you want to create a ${destinationBranch} release from ${sourceRef}?`,
});
if (!confirm) {
console.log(error('Aborting release.'));
process.exit(1);
}
// we fetch the latest changes from the remote
await git.fetch();
// we checkout the source ref, pull the latest changes and then checkout the destination branch
console.info(`Checking out and updating ${sourceRef}...`);
await git.checkout(sourceRef);
await git.pull('origin', sourceRef);
console.info(`Checking out and updating ${destinationBranch}...`);
await git.checkout(destinationBranch);
await git.pull('origin', destinationBranch);
// we trigger the release github action by merging the source ref into the destination branch
console.info(`Merging ${sourceRef} into ${destinationBranch}...`);
await git.merge([sourceRef, '--ff-only']);
await git.push('origin', destinationBranch);
console.info(info(`Release to ${destinationBranch} created! Check github for status`));

15
webpack/tsconfig.json Normal file
View File

@@ -0,0 +1,15 @@
{
"extends": "../tsconfig.json",
"compilerOptions": {
"module": "esnext",
"esModuleInterop": true,
"composite": true,
"lib": [
"es2021"
],
"types": [
"chrome",
"node"
],
},
}

7
webpack/utils/chalk.ts Normal file
View File

@@ -0,0 +1,7 @@
import chalk from 'chalk';
export const error = chalk.bold.red;
export const { bold } = chalk;
export const info = chalk.bgHex('#673AB7').rgb(255, 255, 255);
export const warning = chalk.bgHex('#FF9800').rgb(255, 255, 255);
export const success = chalk.bgHex('#4CAF50').rgb(255, 255, 255);

View File

@@ -0,0 +1,22 @@
import { parse } from 'semver';
/**
* Converts npm semver-style strings (including pre-releases) to a release version compatible
* with the extension stores.
*
* @example
* semverVersionTo('1.0.0-beta.1`) returns 1.0.0.100
*/
export function convertSemver(version: string): string {
const semver = parse(version);
if (!semver) {
throw new Error(`Couldn't parse ${version}!`);
}
const { major, minor, patch, prerelease } = semver;
let manifestVersion = `${major}.${minor}.${patch}`;
if (prerelease.length) {
manifestVersion += `.${prerelease[1]}00`;
}
return manifestVersion;
}

View File

@@ -0,0 +1,28 @@
import prompts from 'prompts';
import { simpleGit } from 'simple-git';
import { error } from '../chalk';
const git = simpleGit();
export async function getSourceRef(destinationBranch: 'preview' | 'production'): Promise<string> {
if (destinationBranch === 'preview') {
return 'main';
}
const tags = await git.tags(['--sort=-committerdate']);
const alphaTags = tags.all.filter((tag: string) => tag.includes('alpha'));
if (!alphaTags.length) {
console.log(error('No preview builds found, please create one before releasing a production build.'));
process.exit(1);
}
const { sourceTag } = await prompts({
message: 'Which preview tag do you want to create a production build from?',
type: 'select',
name: 'sourceTag',
choices: alphaTags.map(tag => ({ title: tag, value: tag })),
});
return sourceTag;
}

View File

@@ -0,0 +1,16 @@
import printBuildError from 'react-dev-utils/printBuildError';
import { error } from './chalk';
/**
* Print Errors that we got back from webpack
* @param e the error provided by webpacxk
*/
export default function printError(e: Error) {
console.log('printBuildError -> e', e);
if (process.env.TSC_COMPILE_ON_ERROR === 'true') {
printBuildError(e);
} else {
// console.log(error('Failed to compile.\n'));
printBuildError(e);
process.exit(1);
}
}

View File

@@ -0,0 +1,35 @@
import fs, { mkdirSync } from 'fs';
import archiver from 'archiver';
import chalk from 'chalk';
import path from 'path';
/**
* Creates a zip file from the given source directory
* @param fileName the name of the zip file to create
* @param outDir the directory to zip up
* @param globOptions the glob options to use when finding files to zip
* @returns
*/
export async function zipProductionBuild(fileName: string) {
const outDirectory = path.resolve('build');
const artifactsDir = path.join(outDirectory, 'artifacts');
mkdirSync(artifactsDir, { recursive: true });
const output = fs.createWriteStream(`${artifactsDir}/${fileName}.zip`);
const archive = archiver('zip', {
zlib: { level: 9 },
});
archive.pipe(output);
const promise = new Promise((resolve, reject) => {
output.on('close', resolve);
archive.on('warning', warn => console.log(chalk.red(warn)));
archive.on('error', err => reject(err));
});
archive.glob('**/*', { cwd: outDirectory, ignore: ['*.zip', 'artifacts'] });
// eslint-disable-next-line no-void
void archive.finalize(); // The promise returned is what's `await-ed`, not the call to `finalize()`
return promise;
}

119
webpack/webpack.config.ts Normal file
View File

@@ -0,0 +1,119 @@
import { Configuration, EntryObject } from 'webpack';
import path from 'path';
import TerserPlugin from 'terser-webpack-plugin';
import { moduleResolutionPlugins } from './plugins/moduleResolutionPlugins';
import loaders from './loaders';
import { getBuildPlugins } from './plugins/buildProcessPlugins';
export interface Entries {
content: string[];
background: string[];
popup: string[];
// only used in development
debug?: string[];
}
export type EntryId = keyof Entries;
/**
* This function will generate the webpack configuration for the extension
* @param mode the mode that webpack is running in (development or production)
* * @param manifest the manifest.json object for the extension
* @returns the webpack configuration
*/
export default function config(mode: Environment, manifest: chrome.runtime.ManifestV3): Configuration {
const outDirectory = path.resolve('build');
// the entry points for the extension (the files that webpack will start bundling from)
const entry: Entries = {
content: [path.resolve('src', 'views', 'content', 'content')],
background: [path.resolve('src', 'background', 'background')],
popup: [path.resolve('src', 'views', 'popup', 'popup')],
};
// the entries that need an html file to be generated
const htmlEntries: EntryId[] = mode === 'development' ? ['popup', 'debug'] : ['popup'];
if (mode === 'development') {
// TODO: add hot reloading script to the debug entry
entry.debug = [path.resolve('src', 'debug')];
// we need to import react-devtools before the react code in development
entry.content = [path.resolve('src', 'views', 'reactDevtools'), ...entry.content];
entry.popup = [path.resolve('src', 'views', 'reactDevtools'), ...entry.popup];
}
/** @see https://webpack.js.org/configuration for documentation */
const config: Configuration = {
mode,
devtool: mode === 'development' ? 'cheap-module-source-map' : undefined,
bail: true,
cache: true,
// entry and resolve is what webpack uses for figuring out where to start bundling and how to resolve modules
entry: entry as unknown as EntryObject,
resolve: {
modules: ['node_modules'],
extensions: ['.js', '.jsx', '.ts', '.tsx'],
plugins: moduleResolutionPlugins,
// this is to polyfill some node-only modules
fallback: {
crypto: 'crypto-browserify',
stream: 'stream-browserify',
buffer: 'buffer',
},
},
// this is where we define the loaders for different file types
module: {
strictExportPresence: true,
rules: loaders,
},
output: {
clean: true,
path: outDirectory,
pathinfo: mode === 'development',
filename: 'static/js/[name].js',
publicPath: '/',
// this is for windows support (which uses backslashes in paths)
devtoolModuleFilenameTemplate: info => path.resolve(info.absoluteResourcePath).replace(/\\/g, '/'),
// this is to make sure that the global chunk loading function name is unique
chunkLoadingGlobal: `webpackJsonp${manifest.short_name}`,
globalObject: 'this',
},
stats: {
errorDetails: true,
errorsCount: true,
},
// this is where we define the plugins that webpack will use
plugins: getBuildPlugins(mode, htmlEntries, manifest),
};
if (mode === 'production') {
config.optimization = {
minimize: true,
minimizer: [
new TerserPlugin({
extractComments: false,
parallel: false,
terserOptions: {
compress: {
ecma: 2020,
drop_console: true,
drop_debugger: true,
comparisons: false,
inline: 2,
},
keep_classnames: false,
keep_fnames: false,
output: {
ecma: 2020,
comments: false,
ascii_only: true,
},
},
}),
],
};
}
return config;
}