├── .browserslistrc ├── .gitignore ├── .husky └── pre-commit ├── .ncurc.cjs ├── LICENSE ├── README.md ├── babel.config.cjs ├── cspell.json ├── eslint.config.mjs ├── jest.config.mjs ├── lint-staged.config.mjs ├── package-lock.json ├── package.json ├── postcss.config.cjs ├── prettier.config.mjs ├── src ├── components │ ├── App │ │ ├── App.test.tsx │ │ ├── App.tsx │ │ ├── index.ts │ │ └── react.svg │ └── RandomNumber │ │ ├── RandomNumber.test.tsx │ │ ├── RandomNumber.tsx │ │ └── index.ts ├── config │ └── constants.ts ├── index.html ├── index.tsx ├── static │ ├── android-chrome-192x192.png │ ├── android-chrome-512x512.png │ ├── apple-touch-icon.png │ ├── browserconfig.xml │ ├── favicon-16x16.png │ ├── favicon-32x32.png │ ├── favicon.ico │ ├── mstile-150x150.png │ ├── safari-pinned-tab.svg │ └── site.webmanifest ├── styles │ └── index.scss ├── types │ └── staticAssets.d.ts └── utils │ ├── random │ ├── index.ts │ ├── random.test.ts │ └── random.ts │ └── seededRandom │ ├── index.ts │ ├── seededRandom.test.ts │ └── seededRandom.ts ├── test ├── helpers │ └── userEventSetup.ts ├── mocks │ └── mockRandom.ts ├── setup.ts ├── teardown.ts └── transformers │ └── importPathTransformer.cjs ├── tsconfig.json ├── webpack.common.mjs ├── webpack.config.mjs ├── webpack.dev.mjs └── webpack.prod.mjs /.browserslistrc: -------------------------------------------------------------------------------- 1 | last 3 Chrome major versions 2 | last 3 Firefox major versions 3 | last 3 Edge major versions 4 | last 2 Safari major versions 5 | last 2 iOS major versions 6 | last 3 ChromeAndroid major versions 7 | Firefox ESR 8 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /node_modules 2 | /coverage 3 | /dist 4 | -------------------------------------------------------------------------------- /.husky/pre-commit: -------------------------------------------------------------------------------- 1 | npx --no lint-staged 2 | -------------------------------------------------------------------------------- /.ncurc.cjs: -------------------------------------------------------------------------------- 1 | const blockMajorVersion = [ 2 | // Must match the version of Node this package uses 3 | "@types/node", 4 | ]; 5 | 6 | module.exports = { 7 | target: (packageName) => { 8 | if (blockMajorVersion.includes(packageName)) { 9 | return "minor"; 10 | } 11 | return "latest"; 12 | }, 13 | groupFunction: (packageName, defaultGroup) => { 14 | // TypeScript doesn't use SemVer. Both minor and major version changes are major. 15 | if (packageName === "typescript" && defaultGroup === "minor") { 16 | return "major"; 17 | } 18 | // Create custom group for eslint packages since you'll want to review new and changed rules to add. 19 | if (packageName.includes("eslint-plugin") || packageName === "eslint" || packageName.endsWith("/eslint")) { 20 | return "ESLint Packages"; 21 | } 22 | return defaultGroup; 23 | }, 24 | }; 25 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2023 Jason O'Neill 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # React Template 2 | 3 | This repository exists as a starting point for a new React 18 application (with hooks). The build system, testing, linting, formatting, compiling, spellchecking and more are all pre-configured. 4 | 5 | This repository should be generic enough that most people can use it out of the box. It comes with an existing "hello world" application which you can build and run right away. 6 | 7 | It also includes all of the nice-to-haves to ensure that you code is high quality and follows best practices. This is very helpful for a beginner who needs nudges in the right direction but also helps an expert focus on the higher level problems and not worry about missing smaller errors. 8 | 9 | ## Setup 10 | 11 | - Be sure you have [the current LTS version of Node.js installed](https://nodejs.org/) 12 | - If you are on Windows, you probably want to be using either [GitBash which comes with Git](https://git-scm.com/download/win) or [WSL](https://docs.microsoft.com/en-us/windows/wsl/install). 13 | - Run `npm ci` to install dependencies 14 | - Run `npm run start` to start the dev server and visit the link provided in the terminal to view it in your browser 15 | 16 | ## Core Dependencies Included 17 | 18 | - [React](https://react.dev/learn) (JavaScript UI framework) 19 | - [Webpack](https://webpack.js.org/) (Asset bundling) 20 | - [TypeScript](https://www.typescriptlang.org/docs/handbook/intro.html) (JavaScript with Types) 21 | - [Babel](https://babeljs.io/docs/en/) (Transpiling JavaScript for older browsers) 22 | - [ESLint](https://eslint.org/) (Identifying and reporting errors in code) 23 | - [Prettier](https://prettier.io/docs/en/index.html) (Code formatter) 24 | - [CSpell](https://github.com/streetsidesoftware/cspell) (Code Spellchecker) 25 | - [SCSS](https://sass-lang.com/guide) (Enhanced CSS) 26 | - [Jest](https://jestjs.io/docs/en/getting-started) (Unit test framework) 27 | - [React Testing Library](https://testing-library.com/docs/react-testing-library/intro) (React unit test utilities) 28 | - [Husky](https://typicode.github.io/husky) (Git hooks - run commands on commit) 29 | 30 | ## NPM scripts 31 | 32 | - `npm clean-install` - install all dependencies. _Do this first before anything else_ 33 | - `npm run start` - starts a local server which can be accessed at http://localhost:7579. As long as this server is running it'll auto refresh whenever you save changes. 34 | - `npm run release` - creates a release build of your application. All output files will be located in the dist folder. This also runs all of the checks to ensure the code works, is formatted, etc. 35 | - `npm run verify` - checks the application without building 36 | - `npm run bundle-analysis` - opens a bundle analysis UI showing the file size of each dependency in your output JavaScript bundle. 37 | - `npm run lint` - runs ESLint enforcing good coding style and habits and erroring if any are broken. 38 | - `npm run lint:fix` - fixes any auto-fixable ESLint errors 39 | - `npm run format` - runs Prettier to reformat all files 40 | - `npm run autofix` - fix all autofixable issues 41 | - `npm run ts-check` - runs the TypeScript compiler to see TypeScript errors 42 | - `npm run spellcheck` - runs CSpell to see any typos. If the word is misidentified, add it to `cspell.json`. 43 | - `npm run test` - runs Jest and all unit tests 44 | - `npm run clean` - removes all auto-generated files and dependencies 45 | - `npm run list-outdated-dependencies` - lists the dependencies that have newer versions available with links to their repository and changelog 46 | - `npm run update-dependencies` - update and install all outdated dependencies 47 | 48 | ## Why use this instead of create-react-app? 49 | 50 | Tools like create-react-app bring everything and the kitchen sink when setting up a new project. They are great to start quickly, but as soon as you want to customize or understand how it all works you'll have trouble. My goal is to expose all of the tools and show how easy it can be to configure from scratch. This makes it easier to debug and tweak settings to fit your needs. 51 | -------------------------------------------------------------------------------- /babel.config.cjs: -------------------------------------------------------------------------------- 1 | const packageJson = require("./package.json"); 2 | const allPackages = { 3 | ...packageJson.dependencies, 4 | ...packageJson.devDependencies, 5 | }; 6 | const coreJSVersion = allPackages["core-js"].match(/\d\.\d+/)[0]; 7 | 8 | module.exports = (api) => { 9 | const isTest = api.env("test"); 10 | const isDev = api.env("development"); 11 | return { 12 | plugins: [...(isDev ? ["react-refresh/babel"] : [])], 13 | presets: [ 14 | [ 15 | "@babel/env", 16 | { 17 | bugfixes: true, 18 | useBuiltIns: "usage", 19 | corejs: { 20 | version: coreJSVersion, 21 | proposals: true, 22 | }, 23 | shippedProposals: true, 24 | ...(isTest 25 | ? { 26 | targets: { 27 | node: "current", 28 | }, 29 | } 30 | : {}), 31 | }, 32 | ], 33 | [ 34 | "@babel/react", 35 | { 36 | useBuiltIns: true, 37 | runtime: "automatic", 38 | }, 39 | ], 40 | [ 41 | "@babel/typescript", 42 | { 43 | allowDeclareFields: true, 44 | onlyRemoveTypeImports: true, 45 | }, 46 | ], 47 | ], 48 | retainLines: isTest, 49 | assumptions: { 50 | ignoreFunctionLength: true, 51 | constantReexports: true, 52 | noClassCalls: true, 53 | noDocumentAll: true, 54 | }, 55 | }; 56 | }; 57 | -------------------------------------------------------------------------------- /cspell.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "0.2", 3 | "words": ["autofix", "autofixable", "browserconfig", "corejs", "lcov", "msapplication", "mstile"], 4 | "ignorePaths": ["package.json", "package-lock.json"], 5 | "useGitignore": true, 6 | "validateDirectives": true, 7 | "cache": { 8 | "useCache": true, 9 | "cacheLocation": "./node_modules/.cache/cspell/.cspell" 10 | }, 11 | "language": "en", 12 | "dictionaries": ["html", "typescript", "css", "en-gb", "fonts", "npm"] 13 | } 14 | -------------------------------------------------------------------------------- /eslint.config.mjs: -------------------------------------------------------------------------------- 1 | // cspell:words setstate, eqeqeq, backreference, isnan, nonoctal, textnodes, nonconstructor, typedefs 2 | 3 | import typescriptPlugin from "@typescript-eslint/eslint-plugin"; 4 | import typescriptEsLintParser from "@typescript-eslint/parser"; 5 | import importPlugin from "eslint-plugin-import"; 6 | import jestPlugin from "eslint-plugin-jest"; 7 | import jsxA11yPlugin from "eslint-plugin-jsx-a11y"; 8 | import reactPlugin from "eslint-plugin-react"; 9 | import reactHooksPlugin from "eslint-plugin-react-hooks"; 10 | import testingLibraryPlugin from "eslint-plugin-testing-library"; 11 | import globals from "globals"; 12 | 13 | /* 14 | Rules in this file are in the same order as they appear in the docs sites to make it easy to find. (Usually this is alphabetical but sometimes there's subgroups.) 15 | ESLint Rule Documentation Sites: 16 | https://eslint.org/docs/latest/rules/ 17 | https://github.com/jsx-eslint/eslint-plugin-react 18 | https://github.com/import-js/eslint-plugin-import 19 | https://github.com/testing-library/eslint-plugin-testing-library 20 | https://github.com/jest-community/eslint-plugin-jest 21 | https://typescript-eslint.io/rules/ 22 | https://github.com/jsx-eslint/eslint-plugin-jsx-a11y 23 | */ 24 | 25 | const baseRestrictedImports = { 26 | patterns: [ 27 | { 28 | group: ["../*"], 29 | message: "Usage of relative parent imports is not allowed.", 30 | }, 31 | ], 32 | paths: [ 33 | { 34 | name: ".", 35 | message: "Usage of local index imports is not allowed.", 36 | }, 37 | { 38 | name: "./index", 39 | message: "Import from the source file instead.", 40 | }, 41 | ], 42 | }; 43 | 44 | export default [ 45 | { 46 | linterOptions: { 47 | reportUnusedDisableDirectives: "warn", 48 | reportUnusedInlineConfigs: "warn", 49 | }, 50 | languageOptions: { 51 | parser: typescriptEsLintParser, 52 | sourceType: "module", 53 | globals: { 54 | ...globals.es2025, 55 | ...globals.browser, 56 | }, 57 | }, 58 | }, 59 | { 60 | ignores: ["**/dist/**", "**/coverage/**"], 61 | }, 62 | { 63 | files: ["**/*.js", "**/*.cjs", "**/*.mjs", "**/*.jsx", "**/*.ts", "**/*.cts", "**/*.mts", "**/*.tsx"], 64 | plugins: { 65 | import: importPlugin, 66 | react: reactPlugin, 67 | "react-hooks": reactHooksPlugin, 68 | }, 69 | rules: { 70 | // Possible Problems - https://eslint.org/docs/latest/rules/#possible-problems 71 | "array-callback-return": [ 72 | "error", 73 | { 74 | checkForEach: true, 75 | }, 76 | ], 77 | "constructor-super": "error", 78 | "for-direction": "error", 79 | "getter-return": "error", 80 | "no-async-promise-executor": "error", 81 | "no-class-assign": "error", 82 | "no-compare-neg-zero": "error", 83 | "no-cond-assign": ["error", "always"], 84 | "no-const-assign": "error", 85 | "no-constant-binary-expression": "warn", 86 | "no-constant-condition": "error", 87 | "no-constructor-return": "error", 88 | "no-control-regex": "error", 89 | "no-debugger": "warn", 90 | "no-dupe-args": "error", 91 | "no-dupe-class-members": "error", 92 | "no-dupe-else-if": "error", 93 | "no-dupe-keys": "error", 94 | "no-duplicate-case": "error", 95 | "no-empty-character-class": "error", 96 | "no-empty-pattern": "error", 97 | "no-ex-assign": "error", 98 | "no-fallthrough": "error", 99 | "no-func-assign": "error", 100 | "no-import-assign": "error", 101 | "no-inner-declarations": ["error", "both"], 102 | "no-invalid-regexp": "error", 103 | "no-irregular-whitespace": [ 104 | "error", 105 | { 106 | skipStrings: false, 107 | skipTemplates: false, 108 | skipJSXText: false, 109 | }, 110 | ], 111 | "no-loss-of-precision": "error", 112 | "no-misleading-character-class": "error", 113 | "no-new-native-nonconstructor": "error", 114 | "no-obj-calls": "error", 115 | "no-prototype-builtins": "error", 116 | "no-self-assign": "warn", 117 | "no-self-compare": "warn", 118 | "no-setter-return": "error", 119 | "no-sparse-arrays": "error", 120 | "no-template-curly-in-string": "error", 121 | "no-this-before-super": "error", 122 | "no-undef": "error", 123 | "no-unexpected-multiline": "error", 124 | "no-unmodified-loop-condition": "error", 125 | "no-unreachable": "warn", 126 | "no-unsafe-finally": "error", 127 | "no-unsafe-negation": [ 128 | "error", 129 | { 130 | enforceForOrderingRelations: true, 131 | }, 132 | ], 133 | "no-unsafe-optional-chaining": [ 134 | "error", 135 | { 136 | disallowArithmeticOperators: true, 137 | }, 138 | ], 139 | "no-unused-private-class-members": "warn", 140 | "no-unused-vars": [ 141 | "warn", 142 | { 143 | varsIgnorePattern: "^_", 144 | argsIgnorePattern: "^_", 145 | reportUsedIgnorePattern: true, 146 | }, 147 | ], 148 | "no-use-before-define": [ 149 | "warn", 150 | { 151 | functions: false, 152 | classes: false, 153 | variables: true, 154 | allowNamedExports: false, 155 | }, 156 | ], 157 | "no-useless-backreference": "error", 158 | "use-isnan": "error", 159 | "valid-typeof": "error", 160 | // Suggestions - https://eslint.org/docs/latest/rules/#suggestions 161 | "consistent-return": "error", 162 | curly: "warn", 163 | "default-param-last": "error", 164 | eqeqeq: "error", 165 | "func-names": ["warn", "never"], 166 | "func-style": ["warn", "declaration"], 167 | "no-array-constructor": "error", 168 | "no-bitwise": "error", 169 | "no-case-declarations": "error", 170 | "no-delete-var": "error", 171 | "no-else-return": "warn", 172 | "no-empty": "warn", 173 | "no-empty-function": "warn", 174 | "no-empty-static-block": "warn", 175 | "no-eval": "error", 176 | "no-extend-native": "error", 177 | "no-extra-bind": "error", 178 | "no-extra-boolean-cast": [ 179 | "warn", 180 | { 181 | enforceForLogicalOperands: true, 182 | }, 183 | ], 184 | "no-global-assign": "error", 185 | "no-implicit-coercion": "error", 186 | "no-implicit-globals": "error", 187 | "no-implied-eval": "error", 188 | "no-invalid-this": [ 189 | "error", 190 | { 191 | capIsConstructor: false, 192 | }, 193 | ], 194 | "no-labels": "error", 195 | "no-lone-blocks": "error", 196 | "no-multi-assign": "warn", 197 | "no-new": "error", 198 | "no-new-func": "error", 199 | "no-new-wrappers": "error", 200 | "no-nonoctal-decimal-escape": "error", 201 | "no-object-constructor": "error", 202 | "no-octal": "error", 203 | "no-octal-escape": "error", 204 | "no-proto": "error", 205 | "no-redeclare": "error", 206 | "no-regex-spaces": "warn", 207 | "no-restricted-imports": ["warn", baseRestrictedImports], 208 | "no-restricted-syntax": [ 209 | "warn", 210 | { 211 | selector: "CallExpression[callee.name='Number']", 212 | message: "Don't use the Number function. Use parseInt or parseFloat instead.", 213 | }, 214 | { 215 | selector: "CallExpression[callee.name='Boolean']", 216 | message: "Don't use the Boolean function. Use a strict comparison instead.", 217 | }, 218 | { 219 | selector: "TSEnumDeclaration", 220 | message: "Use a type with a union of strings instead.", 221 | }, 222 | { 223 | selector: "TSTypeReference Identifier[name='React']", 224 | message: "Import the type explicitly instead of using the React global.", 225 | }, 226 | { 227 | selector: "TSTypeReference Identifier[name='PropsWithChildren']", 228 | message: "Explicitly declare children in your props type.", 229 | }, 230 | ], 231 | "no-return-assign": ["warn", "always"], 232 | "no-script-url": "error", 233 | "no-sequences": [ 234 | "warn", 235 | { 236 | allowInParentheses: false, 237 | }, 238 | ], 239 | "no-shadow": [ 240 | "error", 241 | { 242 | ignoreOnInitialization: true, 243 | }, 244 | ], 245 | "no-shadow-restricted-names": "error", 246 | "no-throw-literal": "error", 247 | "no-unused-expressions": [ 248 | "warn", 249 | { 250 | enforceForJSX: true, 251 | }, 252 | ], 253 | "no-useless-call": "error", 254 | "no-useless-catch": "warn", 255 | "no-useless-computed-key": [ 256 | "warn", 257 | { 258 | enforceForClassMembers: true, 259 | }, 260 | ], 261 | "no-useless-concat": "error", 262 | "no-useless-escape": "warn", 263 | "no-useless-rename": "warn", 264 | "no-useless-return": "warn", 265 | "no-var": "error", 266 | "no-with": "error", 267 | "one-var": ["warn", "never"], 268 | "operator-assignment": "warn", 269 | "prefer-arrow-callback": "warn", 270 | "prefer-const": "warn", 271 | "prefer-numeric-literals": "warn", 272 | "prefer-object-spread": "warn", 273 | "prefer-promise-reject-errors": "error", 274 | "prefer-rest-params": "warn", 275 | "prefer-spread": "warn", 276 | "prefer-template": "warn", 277 | radix: "error", 278 | "require-await": "error", 279 | "require-yield": "error", 280 | // Layout & Formatting - https://eslint.org/docs/latest/rules/#layout--formatting 281 | // ---- Nothing in this category. Defer to Prettier. ---- 282 | // React Hooks - https://github.com/facebook/react/tree/main/packages/eslint-plugin-react-hooks 283 | "react-hooks/rules-of-hooks": "error", 284 | "react-hooks/exhaustive-deps": "error", 285 | // React - https://github.com/jsx-eslint/eslint-plugin-react 286 | "react/jsx-filename-extension": [ 287 | "error", 288 | { 289 | extensions: [".jsx", ".tsx"], 290 | allow: "as-needed", 291 | }, 292 | ], 293 | // Import - https://github.com/import-js/eslint-plugin-import 294 | "import/no-duplicates": ["warn", { "prefer-inline": true }], 295 | "import/no-namespace": "warn", 296 | "import/order": [ 297 | "warn", 298 | { 299 | groups: ["builtin", "external", "parent", ["sibling", "internal", "index"]], 300 | pathGroups: [ 301 | { 302 | pattern: "~/**", 303 | group: "parent", 304 | }, 305 | { 306 | pattern: "test/**", 307 | group: "parent", 308 | }, 309 | ], 310 | alphabetize: { 311 | order: "asc", 312 | orderImportKind: "desc", 313 | caseInsensitive: true, 314 | }, 315 | "newlines-between": "never", 316 | }, 317 | ], 318 | }, 319 | }, 320 | { 321 | // Run the JS rules above on these file types in node environment 322 | files: ["**/*.cjs", "**/*.mjs"], 323 | languageOptions: { 324 | globals: { 325 | ...globals.node, 326 | }, 327 | }, 328 | }, 329 | { 330 | languageOptions: { 331 | parserOptions: { 332 | project: "./tsconfig.json", 333 | tsconfigRootDir: import.meta.dirname, 334 | }, 335 | }, 336 | files: ["**/*.ts", "**/*.cts", "**/*.mts", "**/*.tsx"], 337 | plugins: { 338 | "@typescript-eslint": typescriptPlugin, 339 | import: importPlugin, 340 | }, 341 | rules: { 342 | // TypeScript ESLint Core Disables - https://typescript-eslint.io/docs/linting/configs#eslint-recommended 343 | "constructor-super": "off", 344 | "getter-return": "off", 345 | "no-class-assign": "off", 346 | "no-const-assign": "off", 347 | "no-dupe-args": "off", 348 | "no-dupe-keys": "off", 349 | "no-func-assign": "off", 350 | "no-import-assign": "off", 351 | "no-new-native-nonconstructor": "off", 352 | "no-obj-calls": "off", 353 | "no-setter-return": "off", 354 | "no-this-before-super": "off", 355 | "no-undef": "off", 356 | "no-unreachable": "off", 357 | "no-unsafe-negation": "off", 358 | "valid-typeof": "off", 359 | // TypeScript - https://typescript-eslint.io/rules/ 360 | "@typescript-eslint/adjacent-overload-signatures": "error", 361 | "@typescript-eslint/array-type": "warn", 362 | "@typescript-eslint/await-thenable": "error", 363 | "@typescript-eslint/ban-ts-comment": "error", 364 | "@typescript-eslint/consistent-generic-constructors": ["warn", "constructor"], 365 | "@typescript-eslint/consistent-type-assertions": [ 366 | "warn", 367 | { 368 | assertionStyle: "as", 369 | objectLiteralTypeAssertions: "allow-as-parameter", 370 | }, 371 | ], 372 | "@typescript-eslint/consistent-type-definitions": ["warn", "type"], 373 | "@typescript-eslint/consistent-type-exports": "error", 374 | "@typescript-eslint/consistent-type-imports": [ 375 | "warn", 376 | { 377 | fixStyle: "inline-type-imports", 378 | }, 379 | ], 380 | "@typescript-eslint/explicit-function-return-type": [ 381 | "error", 382 | { 383 | allowTypedFunctionExpressions: true, 384 | }, 385 | ], 386 | "@typescript-eslint/explicit-member-accessibility": "warn", 387 | "@typescript-eslint/method-signature-style": "warn", 388 | "@typescript-eslint/naming-convention": [ 389 | "warn", 390 | { 391 | selector: [ 392 | "classProperty", 393 | "objectLiteralProperty", 394 | "typeProperty", 395 | "classMethod", 396 | "objectLiteralMethod", 397 | "typeMethod", 398 | "accessor", 399 | "enumMember", 400 | ], 401 | format: null, 402 | modifiers: ["requiresQuotes"], 403 | }, 404 | { 405 | selector: "default", 406 | format: ["camelCase"], 407 | }, 408 | { 409 | selector: "import", 410 | format: ["camelCase", "PascalCase"], 411 | }, 412 | { 413 | selector: ["function", "method", "enumMember", "property"], 414 | format: ["camelCase", "PascalCase"], 415 | }, 416 | { 417 | selector: "parameter", 418 | format: ["camelCase"], 419 | leadingUnderscore: "allow", 420 | }, 421 | { 422 | selector: "variable", 423 | modifiers: ["const"], 424 | format: ["camelCase", "PascalCase", "UPPER_CASE"], 425 | leadingUnderscore: "allow", 426 | }, 427 | { 428 | selector: "typeLike", 429 | format: ["PascalCase"], 430 | }, 431 | { 432 | selector: "typeProperty", 433 | format: ["camelCase", "PascalCase", "UPPER_CASE"], 434 | }, 435 | ], 436 | "@typescript-eslint/no-array-delete": "error", 437 | "@typescript-eslint/no-base-to-string": "error", 438 | "@typescript-eslint/no-confusing-non-null-assertion": "error", 439 | "@typescript-eslint/no-confusing-void-expression": [ 440 | "error", 441 | { 442 | ignoreVoidReturningFunctions: true, 443 | }, 444 | ], 445 | "@typescript-eslint/no-deprecated": "warn", 446 | "@typescript-eslint/no-duplicate-type-constituents": "warn", 447 | "@typescript-eslint/no-empty-interface": "warn", 448 | "@typescript-eslint/no-empty-object-type": "warn", 449 | "@typescript-eslint/no-explicit-any": "warn", 450 | "@typescript-eslint/no-extra-non-null-assertion": "error", 451 | "@typescript-eslint/no-extraneous-class": "error", 452 | "@typescript-eslint/no-floating-promises": "error", 453 | "@typescript-eslint/no-for-in-array": "error", 454 | "@typescript-eslint/no-inferrable-types": "warn", 455 | "@typescript-eslint/no-invalid-void-type": "error", 456 | "@typescript-eslint/no-meaningless-void-operator": "warn", 457 | "@typescript-eslint/no-misused-new": "error", 458 | "@typescript-eslint/no-misused-promises": "error", 459 | "@typescript-eslint/no-misused-spread": "error", 460 | "@typescript-eslint/no-namespace": "warn", 461 | "@typescript-eslint/no-non-null-asserted-nullish-coalescing": "warn", 462 | "@typescript-eslint/no-non-null-asserted-optional-chain": "error", 463 | "@typescript-eslint/no-redundant-type-constituents": "warn", 464 | "@typescript-eslint/no-require-imports": "error", 465 | "@typescript-eslint/no-this-alias": "warn", 466 | "@typescript-eslint/no-unnecessary-boolean-literal-compare": "warn", 467 | "@typescript-eslint/no-unnecessary-condition": [ 468 | "warn", 469 | { 470 | checkTypePredicates: true, 471 | }, 472 | ], 473 | "@typescript-eslint/no-unnecessary-qualifier": "warn", 474 | "@typescript-eslint/no-unnecessary-template-expression": "warn", 475 | "@typescript-eslint/no-unnecessary-type-arguments": "warn", 476 | "@typescript-eslint/no-unnecessary-type-assertion": "warn", 477 | "@typescript-eslint/no-unnecessary-type-constraint": "warn", 478 | "@typescript-eslint/no-unsafe-argument": "error", 479 | "@typescript-eslint/no-unsafe-assignment": "error", 480 | "@typescript-eslint/no-unsafe-call": "error", 481 | "@typescript-eslint/no-unsafe-enum-comparison": "warn", 482 | "@typescript-eslint/no-unsafe-function-type": "error", 483 | "@typescript-eslint/no-unsafe-declaration-merging": "error", 484 | "@typescript-eslint/no-unsafe-member-access": "error", 485 | "@typescript-eslint/no-unsafe-return": "error", 486 | "@typescript-eslint/no-unsafe-unary-minus": "error", 487 | "@typescript-eslint/no-useless-empty-export": "warn", 488 | "@typescript-eslint/no-var-requires": "error", 489 | "@typescript-eslint/no-wrapper-object-types": "error", 490 | "@typescript-eslint/non-nullable-type-assertion-style": "warn", 491 | "@typescript-eslint/parameter-properties": "error", 492 | "@typescript-eslint/prefer-as-const": "warn", 493 | "@typescript-eslint/prefer-find": "warn", 494 | "@typescript-eslint/prefer-for-of": "warn", 495 | "@typescript-eslint/prefer-includes": "warn", 496 | "@typescript-eslint/prefer-namespace-keyword": "warn", 497 | "@typescript-eslint/prefer-nullish-coalescing": [ 498 | "warn", 499 | { 500 | ignoreTernaryTests: false, 501 | }, 502 | ], 503 | "@typescript-eslint/prefer-optional-chain": "warn", 504 | "@typescript-eslint/prefer-readonly": "warn", 505 | "@typescript-eslint/prefer-reduce-type-parameter": "warn", 506 | "@typescript-eslint/prefer-return-this-type": "error", 507 | "@typescript-eslint/prefer-string-starts-ends-with": "warn", 508 | "@typescript-eslint/prefer-ts-expect-error": "warn", 509 | "@typescript-eslint/related-getter-setter-pairs": "error", 510 | "@typescript-eslint/require-array-sort-compare": "error", 511 | "@typescript-eslint/restrict-plus-operands": "error", 512 | "@typescript-eslint/restrict-template-expressions": "error", 513 | "@typescript-eslint/strict-boolean-expressions": [ 514 | "error", 515 | { 516 | allowString: false, 517 | allowNumber: false, 518 | allowNullableObject: false, 519 | allowNullableEnum: false, 520 | }, 521 | ], 522 | "@typescript-eslint/switch-exhaustiveness-check": "error", 523 | "@typescript-eslint/triple-slash-reference": "warn", 524 | "@typescript-eslint/unbound-method": "error", 525 | "@typescript-eslint/unified-signatures": "warn", 526 | "@typescript-eslint/use-unknown-in-catch-callback-variable": "warn", 527 | // TypeScript Extension Rules - https://typescript-eslint.io/rules/#extension-rules 528 | "consistent-return": "off", 529 | "@typescript-eslint/consistent-return": "error", 530 | "default-param-last": "off", 531 | "@typescript-eslint/default-param-last": "error", 532 | "prefer-promise-reject-errors": "off", 533 | "@typescript-eslint/prefer-promise-reject-errors": "error", 534 | "no-array-constructor": "off", 535 | "@typescript-eslint/no-array-constructor": "error", 536 | "no-dupe-class-members": "off", 537 | "no-empty-function": "off", 538 | "@typescript-eslint/no-empty-function": "warn", 539 | "no-implied-eval": "off", 540 | "@typescript-eslint/no-implied-eval": "error", 541 | "no-invalid-this": "off", 542 | "no-redeclare": "off", 543 | "@typescript-eslint/no-redeclare": [ 544 | "error", 545 | { 546 | ignoreDeclarationMerge: false, 547 | }, 548 | ], 549 | "no-shadow": "off", 550 | "@typescript-eslint/no-shadow": [ 551 | "error", 552 | { 553 | ignoreOnInitialization: true, 554 | }, 555 | ], 556 | "no-throw-literal": "off", 557 | "@typescript-eslint/only-throw-error": [ 558 | "error", 559 | { 560 | allowThrowingAny: false, 561 | allowThrowingUnknown: false, 562 | }, 563 | ], 564 | "no-unused-expressions": "off", 565 | "@typescript-eslint/no-unused-expressions": [ 566 | "warn", 567 | { 568 | enforceForJSX: true, 569 | }, 570 | ], 571 | "no-unused-vars": "off", 572 | "@typescript-eslint/no-unused-vars": [ 573 | "warn", 574 | { 575 | varsIgnorePattern: "^_", 576 | argsIgnorePattern: "^_", 577 | reportUsedIgnorePattern: true, 578 | }, 579 | ], 580 | "no-use-before-define": "off", 581 | "@typescript-eslint/no-use-before-define": [ 582 | "warn", 583 | { 584 | functions: false, 585 | classes: false, 586 | variables: true, 587 | allowNamedExports: false, 588 | // TS extension options 589 | enums: true, 590 | typedefs: true, 591 | ignoreTypeReferences: true, 592 | }, 593 | ], 594 | "require-await": "off", 595 | "@typescript-eslint/require-await": "error", 596 | }, 597 | }, 598 | { 599 | files: ["**/*.jsx", "**/*.tsx"], 600 | ignores: ["**/*.test.jsx", "**/*.test.tsx"], 601 | plugins: { "jsx-a11y": jsxA11yPlugin }, 602 | rules: { 603 | // JSX A11y - This plugin is being extended because there's an extensive amount of custom options automatically configured. - https://github.com/jsx-eslint/eslint-plugin-jsx-a11y 604 | ...jsxA11yPlugin.flatConfigs.recommended.rules, 605 | }, 606 | }, 607 | { 608 | settings: { 609 | react: { 610 | version: "detect", 611 | }, 612 | }, 613 | files: ["**/*.jsx", "**/*.js", "**/*.tsx", "**/*.ts"], 614 | plugins: { 615 | react: reactPlugin, 616 | "jsx-a11y": jsxA11yPlugin, 617 | }, 618 | rules: { 619 | // React - https://github.com/jsx-eslint/eslint-plugin-react#list-of-supported-rules 620 | "react/checked-requires-onchange-or-readonly": "error", 621 | "react/function-component-definition": [ 622 | "warn", 623 | { 624 | unnamedComponents: "arrow-function", 625 | }, 626 | ], 627 | "react/hook-use-state": "warn", 628 | "react/iframe-missing-sandbox": "warn", 629 | "react/no-access-state-in-setstate": "error", 630 | "react/no-array-index-key": "error", 631 | "react/no-children-prop": "error", 632 | "react/no-danger": "error", 633 | "react/no-danger-with-children": "error", 634 | "react/no-deprecated": "error", 635 | "react/no-did-mount-set-state": "error", 636 | "react/no-did-update-set-state": "error", 637 | "react/no-direct-mutation-state": "error", 638 | "react/no-find-dom-node": "error", 639 | "react/no-invalid-html-attribute": "warn", 640 | "react/no-is-mounted": "error", 641 | "react/no-object-type-as-default-prop": "error", 642 | "react/no-redundant-should-component-update": "error", 643 | "react/no-render-return-value": "error", 644 | "react/no-string-refs": "error", 645 | "react/no-this-in-sfc": "error", 646 | "react/no-typos": "error", 647 | "react/no-unknown-property": "error", 648 | "react/no-unused-state": "warn", 649 | "react/require-render-return": "error", 650 | "react/self-closing-comp": "warn", 651 | "react/void-dom-elements-no-children": "error", 652 | // JSX-specific rules - https://github.com/jsx-eslint/eslint-plugin-react#jsx-specific-rules 653 | "react/jsx-boolean-value": ["warn", "always"], 654 | "react/jsx-curly-brace-presence": ["warn", "never"], 655 | "react/jsx-fragments": "warn", 656 | "react/jsx-key": [ 657 | "error", 658 | { 659 | checkFragmentShorthand: true, 660 | checkKeyMustBeforeSpread: true, 661 | warnOnDuplicates: true, 662 | }, 663 | ], 664 | "react/jsx-no-comment-textnodes": "error", 665 | "react/jsx-no-duplicate-props": "error", 666 | "react/jsx-no-script-url": "error", 667 | "react/jsx-no-target-blank": "warn", 668 | "react/jsx-no-undef": "error", 669 | "react/jsx-no-useless-fragment": [ 670 | "warn", 671 | { 672 | allowExpressions: true, 673 | }, 674 | ], 675 | "react/jsx-pascal-case": "warn", 676 | "react/jsx-props-no-spread-multi": "warn", 677 | "react/jsx-props-no-spreading": "warn", 678 | "react/jsx-uses-react": "error", 679 | "react/jsx-uses-vars": "error", 680 | // Other 681 | "no-restricted-imports": [ 682 | "warn", 683 | { 684 | ...baseRestrictedImports, 685 | paths: [ 686 | { 687 | name: "react", 688 | importNames: ["default"], 689 | message: 690 | "'import React' is not needed due to the new JSX transform: https://reactjs.org/blog/2020/09/22/introducing-the-new-jsx-transform.html\n\nIf you need a named export, use: 'import { Something } from \"react\"'", 691 | }, 692 | ], 693 | }, 694 | ], 695 | }, 696 | }, 697 | { 698 | files: ["**/*.test.js", "**/*.test.ts", "**/*.test.jsx", "**/*.test.tsx", "**/test/**"], 699 | plugins: { 700 | jest: jestPlugin, 701 | }, 702 | languageOptions: { 703 | globals: { 704 | ...jestPlugin.environments.globals.globals, 705 | ...globals["shared-node-browser"], 706 | }, 707 | }, 708 | rules: { 709 | // Jest - https://github.com/jest-community/eslint-plugin-jest#rules 710 | "jest/consistent-test-it": [ 711 | "warn", 712 | { 713 | withinDescribe: "test", 714 | }, 715 | ], 716 | "jest/expect-expect": "warn", 717 | "jest/no-alias-methods": "warn", 718 | "jest/no-commented-out-tests": "warn", 719 | "jest/no-conditional-expect": "error", 720 | "jest/no-conditional-in-test": "error", 721 | "jest/no-confusing-set-timeout": "error", 722 | "jest/no-deprecated-functions": "error", 723 | "jest/no-disabled-tests": "warn", 724 | "jest/no-done-callback": "error", 725 | "jest/no-export": "error", 726 | "jest/no-focused-tests": "warn", 727 | "jest/no-identical-title": "error", 728 | "jest/no-interpolation-in-snapshots": "error", 729 | "jest/no-jasmine-globals": "error", 730 | "jest/no-mocks-import": "error", 731 | "jest/no-standalone-expect": "error", 732 | "jest/no-test-prefixes": "warn", 733 | "jest/no-test-return-statement": "error", 734 | "jest/prefer-comparison-matcher": "warn", 735 | "jest/prefer-each": "warn", 736 | "jest/prefer-equality-matcher": "warn", 737 | "jest/prefer-importing-jest-globals": "error", 738 | "jest/prefer-jest-mocked": "warn", 739 | "jest/prefer-lowercase-title": [ 740 | "warn", 741 | { 742 | ignoreTopLevelDescribe: true, 743 | }, 744 | ], 745 | "jest/prefer-mock-promise-shorthand": "warn", 746 | "jest/prefer-spy-on": "warn", 747 | "jest/prefer-strict-equal": "error", 748 | "jest/prefer-to-be": "warn", 749 | "jest/prefer-to-contain": "warn", 750 | "jest/prefer-to-have-length": "warn", 751 | "jest/valid-describe-callback": "error", 752 | "jest/valid-expect": "error", 753 | "jest/valid-expect-in-promise": "error", 754 | "jest/valid-title": "warn", 755 | }, 756 | }, 757 | { 758 | files: ["**/*.test.ts", "**/*.test.tsx", "**/test/**/*.ts"], 759 | plugins: { 760 | "@typescript-eslint": typescriptPlugin, 761 | jest: jestPlugin, 762 | }, 763 | rules: { 764 | // TypeScript-specific test overrides 765 | "@typescript-eslint/naming-convention": "off", 766 | "@typescript-eslint/unbound-method": "off", 767 | "jest/unbound-method": "error", 768 | }, 769 | }, 770 | { 771 | files: ["**/*.test.jsx", "**/*.test.tsx"], 772 | plugins: { 773 | "testing-library": testingLibraryPlugin, 774 | jest: jestPlugin, 775 | }, 776 | rules: { 777 | // React Testing Library - https://github.com/testing-library/eslint-plugin-testing-library 778 | "testing-library/await-async-queries": "error", 779 | "testing-library/await-async-utils": "error", 780 | "testing-library/no-await-sync-queries": "error", 781 | "testing-library/no-container": "error", 782 | "testing-library/no-debugging-utils": "warn", 783 | "testing-library/no-dom-import": ["error", "react"], 784 | "testing-library/no-global-regexp-flag-in-query": "warn", 785 | "testing-library/no-node-access": "warn", 786 | "testing-library/no-unnecessary-act": "warn", 787 | "testing-library/no-wait-for-multiple-assertions": "error", 788 | "testing-library/no-wait-for-side-effects": "error", 789 | "testing-library/no-wait-for-snapshot": "error", 790 | "testing-library/prefer-find-by": "warn", 791 | "testing-library/prefer-implicit-assert": "warn", 792 | "testing-library/prefer-presence-queries": "error", 793 | "testing-library/prefer-query-by-disappearance": "error", 794 | "testing-library/prefer-screen-queries": "warn", 795 | "testing-library/prefer-user-event": "error", 796 | "testing-library/render-result-naming-convention": "error", 797 | // Jest - https://github.com/jest-community/eslint-plugin-jest 798 | "jest/expect-expect": [ 799 | "warn", 800 | { 801 | assertFunctionNames: ["expect", "*.getBy*", "*.getAllBy*", "*.findBy*", "*.findAllBy*"], 802 | }, 803 | ], 804 | }, 805 | }, 806 | ]; 807 | -------------------------------------------------------------------------------- /jest.config.mjs: -------------------------------------------------------------------------------- 1 | import inspector from "node:inspector"; 2 | const isDebuggerAttached = inspector.url() !== undefined; 3 | 4 | export default { 5 | restoreMocks: true, 6 | collectCoverage: true, 7 | collectCoverageFrom: ["src/**/*.{js,jsx,ts,tsx}"], 8 | coverageDirectory: "coverage", 9 | coverageReporters: ["text-summary", "lcov"], 10 | coverageThreshold: { 11 | global: { 12 | branches: 100, 13 | functions: 100, 14 | lines: 100, 15 | statements: 100, 16 | }, 17 | }, 18 | errorOnDeprecated: true, 19 | moduleNameMapper: { 20 | "\\.(css|less|scss|sass)$": "identity-obj-proxy", 21 | "test/(.*)$": "/test/$1", 22 | "~/(.*)$": "/src/$1", 23 | }, 24 | transform: { 25 | "\\.[jt]sx?$": "babel-jest", 26 | "\\.(jpg|jpeg|png|gif|webp|svg|bmp|woff|woff2|ttf)$": "/test/transformers/importPathTransformer.cjs", 27 | }, 28 | setupFilesAfterEnv: ["/test/setup.ts"], 29 | globalTeardown: "/test/teardown.ts", 30 | fakeTimers: { 31 | enableGlobally: true, 32 | }, 33 | verbose: true, 34 | testEnvironment: "jsdom", 35 | testTimeout: isDebuggerAttached ? 10000000 : 5000, 36 | randomize: true, 37 | }; 38 | -------------------------------------------------------------------------------- /lint-staged.config.mjs: -------------------------------------------------------------------------------- 1 | import path from "node:path"; 2 | 3 | export default { 4 | "*.{js,ts,jsx,tsx,cjs,mjs,json,html,xml,svg,css,scss,sass,md}": "cspell --no-progress --no-must-find-files", 5 | "*.{js,ts,jsx,tsx,cjs,mjs}": "eslint --no-warn-ignored --max-warnings 0 --fix", 6 | "*": "prettier --write --ignore-unknown --log-level warn", 7 | "src/**/*.{js,ts,jsx,tsx}": (paths) => { 8 | const relativePaths = paths.map((filePath) => 9 | path 10 | .relative(import.meta.dirname, filePath) 11 | .split(path.sep) 12 | .join(path.posix.sep) 13 | ); 14 | // Running unit tests is the largest contributor to the speed of a commit. 15 | // If you are running all of your validations in PR before merging then remove this and depend on PR checks. 16 | // This is only useful if you are directly pushing code and not creating PRs. 17 | return `jest --runInBand --bail --collectCoverageFrom '(${relativePaths.join( 18 | "|" 19 | )})' --coverage --findRelatedTests ${paths.join(" ")}`; 20 | }, 21 | }; 22 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "react-template", 3 | "version": "1.0.0", 4 | "description": "", 5 | "author": "", 6 | "scripts": { 7 | "start": "webpack serve", 8 | "release": "npm run verify && npm run build", 9 | "verify": "npm run lint && npm run format:check && npm run ts-check && npm run spellcheck && npm run test", 10 | "build": "npm run build:production", 11 | "build:production": "npm run clean:dist && webpack --node-env production", 12 | "build:development": "npm run clean:dist && webpack --node-env development", 13 | "bundle-analysis": "webpack --node-env production --env showBundleAnalysis", 14 | "autofix": "npm run lint:fix && npm run format", 15 | "lint": "eslint --max-warnings 0", 16 | "lint:fix": "npm run lint -- --fix", 17 | "test": "jest --runInBand", 18 | "ts-check": "tsc", 19 | "format": "prettier . --write --cache --log-level warn", 20 | "format:check": "prettier . --check --cache --log-level warn", 21 | "spellcheck": "cspell --no-progress --dot \"**/*.{js,ts,jsx,tsx,cjs,mjs,json,html,xml,svg,css,scss,sass,md}\"", 22 | "list-outdated-dependencies": "npm-check-updates --format repo,group --peer", 23 | "update-dependencies": "npm run list-outdated-dependencies -- -u && npm install && npm update && npm dedupe && npm run autofix && npm run release", 24 | "clean": "rimraf node_modules coverage dist", 25 | "clean:dist": "rimraf dist", 26 | "prepare": "husky" 27 | }, 28 | "dependencies": { 29 | "react": "^19.0.0", 30 | "react-dom": "^19.0.0" 31 | }, 32 | "devDependencies": { 33 | "@babel/core": "^7.26.9", 34 | "@babel/preset-env": "^7.26.9", 35 | "@babel/preset-react": "^7.26.3", 36 | "@babel/preset-typescript": "^7.26.0", 37 | "@jest/globals": "^29.7.0", 38 | "@pmmmwh/react-refresh-webpack-plugin": "^0.5.15", 39 | "@testing-library/dom": "^10.4.0", 40 | "@testing-library/jest-dom": "^6.6.3", 41 | "@testing-library/react": "^16.2.0", 42 | "@testing-library/user-event": "^14.6.1", 43 | "@types/node": "^22.13.5", 44 | "@types/react": "^19.0.10", 45 | "@types/react-dom": "^19.0.4", 46 | "@typescript-eslint/eslint-plugin": "^8.25.0", 47 | "@typescript-eslint/parser": "^8.25.0", 48 | "babel-loader": "^9.2.1", 49 | "copy-webpack-plugin": "^12.0.2", 50 | "core-js": "^3.40.0", 51 | "cspell": "^8.17.5", 52 | "css-loader": "^7.1.2", 53 | "css-minimizer-webpack-plugin": "^7.0.0", 54 | "eslint": "^9.21.0", 55 | "eslint-plugin-import": "^2.31.0", 56 | "eslint-plugin-jest": "^28.11.0", 57 | "eslint-plugin-jsx-a11y": "^6.10.2", 58 | "eslint-plugin-react": "^7.37.4", 59 | "eslint-plugin-react-hooks": "^5.1.0", 60 | "eslint-plugin-testing-library": "^7.1.1", 61 | "fork-ts-checker-webpack-plugin": "^9.0.2", 62 | "globals": "^16.0.0", 63 | "html-minimizer-webpack-plugin": "^5.0.0", 64 | "html-webpack-plugin": "^5.6.3", 65 | "husky": "^9.1.7", 66 | "identity-obj-proxy": "^3.0.0", 67 | "jest": "^29.7.0", 68 | "jest-environment-jsdom": "^29.7.0", 69 | "license-webpack-plugin": "^4.0.2", 70 | "lint-staged": "^15.4.3", 71 | "mini-css-extract-plugin": "^2.9.2", 72 | "mini-svg-data-uri": "^1.4.4", 73 | "npm-check-updates": "^17.1.15", 74 | "postcss": "^8.5.3", 75 | "postcss-loader": "^8.1.1", 76 | "postcss-preset-env": "^10.1.5", 77 | "prettier": "^3.5.2", 78 | "react-refresh": "^0.16.0", 79 | "rimraf": "^6.0.1", 80 | "sass": "^1.85.1", 81 | "sass-loader": "^16.0.5", 82 | "style-loader": "^4.0.0", 83 | "typescript": "^5.7.3", 84 | "webpack": "^5.98.0", 85 | "webpack-bundle-analyzer": "^4.10.2", 86 | "webpack-cli": "^6.0.1", 87 | "webpack-dev-server": "^5.2.0" 88 | }, 89 | "engines": { 90 | "node": ">=22.12.0" 91 | } 92 | } 93 | -------------------------------------------------------------------------------- /postcss.config.cjs: -------------------------------------------------------------------------------- 1 | const postcssPresetEnv = require("postcss-preset-env"); 2 | 3 | module.exports = { 4 | plugins: [postcssPresetEnv()], 5 | }; 6 | -------------------------------------------------------------------------------- /prettier.config.mjs: -------------------------------------------------------------------------------- 1 | export default { 2 | printWidth: 120, 3 | tabWidth: 4, 4 | singleQuote: false, 5 | bracketSameLine: false, 6 | trailingComma: "es5", 7 | }; 8 | -------------------------------------------------------------------------------- /src/components/App/App.test.tsx: -------------------------------------------------------------------------------- 1 | import { describe, expect, test } from "@jest/globals"; 2 | import { render, screen } from "@testing-library/react"; 3 | import { App } from "./App"; 4 | 5 | describe("App", () => { 6 | test("is rendered", () => { 7 | render(); 8 | screen.getByText("Hello World", { 9 | exact: false, 10 | }); 11 | expect(screen.getByRole("img")).toHaveAttribute("src", "react.svg"); 12 | }); 13 | }); 14 | -------------------------------------------------------------------------------- /src/components/App/App.tsx: -------------------------------------------------------------------------------- 1 | import type { ReactElement } from "react"; 2 | import { RandomNumber } from "~/components/RandomNumber"; 3 | import { randomDefaults } from "~/config/constants"; 4 | import reactLogo from "./react.svg"; 5 | 6 | export function App(): ReactElement { 7 | return ( 8 |
9 | React Logo 10 |
11 | Hello World 12 |
13 | ); 14 | } 15 | -------------------------------------------------------------------------------- /src/components/App/index.ts: -------------------------------------------------------------------------------- 1 | export { App } from "./App"; 2 | -------------------------------------------------------------------------------- /src/components/App/react.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/components/RandomNumber/RandomNumber.test.tsx: -------------------------------------------------------------------------------- 1 | import { describe, test } from "@jest/globals"; 2 | import { render, screen } from "@testing-library/react"; 3 | import { userEventSetup } from "test/helpers/userEventSetup"; 4 | import { RandomNumber } from "./RandomNumber"; 5 | 6 | describe("RandomNumber", () => { 7 | test("is generated", () => { 8 | render(); 9 | 10 | screen.getByRole("button", { 11 | name: "13", 12 | }); 13 | }); 14 | 15 | test("is regenerated on mouse click", async () => { 16 | render(); 17 | const user = userEventSetup(); 18 | 19 | await user.click( 20 | screen.getByRole("button", { 21 | name: "2", 22 | }) 23 | ); 24 | screen.getByRole("button", { 25 | name: "10", 26 | }); 27 | }); 28 | }); 29 | -------------------------------------------------------------------------------- /src/components/RandomNumber/RandomNumber.tsx: -------------------------------------------------------------------------------- 1 | import { useState, useCallback, type ReactElement } from "react"; 2 | import { random } from "~/utils/random"; 3 | 4 | type RandomNumberProps = { 5 | min: number; 6 | max: number; 7 | }; 8 | 9 | export function RandomNumber(props: RandomNumberProps): ReactElement { 10 | const [number, setNumber] = useState(() => random.randInt(props.min, props.max)); 11 | 12 | const onClick = useCallback(() => { 13 | setNumber(random.randInt(props.min, props.max)); 14 | }, [props.min, props.max]); 15 | 16 | return ; 17 | } 18 | -------------------------------------------------------------------------------- /src/components/RandomNumber/index.ts: -------------------------------------------------------------------------------- 1 | export { RandomNumber } from "./RandomNumber"; 2 | -------------------------------------------------------------------------------- /src/config/constants.ts: -------------------------------------------------------------------------------- 1 | export const randomDefaults = { 2 | MIN: 0, 3 | MAX: 100, 4 | }; 5 | -------------------------------------------------------------------------------- /src/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | ReactTemplate 17 | 18 | 19 | 20 | -------------------------------------------------------------------------------- /src/index.tsx: -------------------------------------------------------------------------------- 1 | /* istanbul ignore file */ 2 | import "~/styles/index.scss"; 3 | import { StrictMode } from "react"; 4 | import reactDOMClient from "react-dom/client"; 5 | import { App } from "~/components/App"; 6 | 7 | const rootContainer = document.createElement("div"); 8 | document.body.appendChild(rootContainer); 9 | const root = reactDOMClient.createRoot(rootContainer); 10 | root.render( 11 | 12 | 13 | 14 | ); 15 | -------------------------------------------------------------------------------- /src/static/android-chrome-192x192.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/CreativeTechGuy/ReactTemplate/bf06542d4ab47fb773e57494d486f9c8bfcd9f01/src/static/android-chrome-192x192.png -------------------------------------------------------------------------------- /src/static/android-chrome-512x512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/CreativeTechGuy/ReactTemplate/bf06542d4ab47fb773e57494d486f9c8bfcd9f01/src/static/android-chrome-512x512.png -------------------------------------------------------------------------------- /src/static/apple-touch-icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/CreativeTechGuy/ReactTemplate/bf06542d4ab47fb773e57494d486f9c8bfcd9f01/src/static/apple-touch-icon.png -------------------------------------------------------------------------------- /src/static/browserconfig.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | #2d89ef 7 | 8 | 9 | 10 | -------------------------------------------------------------------------------- /src/static/favicon-16x16.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/CreativeTechGuy/ReactTemplate/bf06542d4ab47fb773e57494d486f9c8bfcd9f01/src/static/favicon-16x16.png -------------------------------------------------------------------------------- /src/static/favicon-32x32.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/CreativeTechGuy/ReactTemplate/bf06542d4ab47fb773e57494d486f9c8bfcd9f01/src/static/favicon-32x32.png -------------------------------------------------------------------------------- /src/static/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/CreativeTechGuy/ReactTemplate/bf06542d4ab47fb773e57494d486f9c8bfcd9f01/src/static/favicon.ico -------------------------------------------------------------------------------- /src/static/mstile-150x150.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/CreativeTechGuy/ReactTemplate/bf06542d4ab47fb773e57494d486f9c8bfcd9f01/src/static/mstile-150x150.png -------------------------------------------------------------------------------- /src/static/safari-pinned-tab.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/static/site.webmanifest: -------------------------------------------------------------------------------- 1 | { 2 | "name": "", 3 | "short_name": "", 4 | "icons": [ 5 | { 6 | "src": "/android-chrome-192x192.png?v=1", 7 | "sizes": "192x192", 8 | "type": "image/png" 9 | }, 10 | { 11 | "src": "/android-chrome-512x512.png?v=1", 12 | "sizes": "512x512", 13 | "type": "image/png" 14 | } 15 | ], 16 | "theme_color": "#ffffff", 17 | "background_color": "#ffffff", 18 | "display": "standalone" 19 | } 20 | -------------------------------------------------------------------------------- /src/styles/index.scss: -------------------------------------------------------------------------------- 1 | .center { 2 | text-align: center; 3 | } 4 | -------------------------------------------------------------------------------- /src/types/staticAssets.d.ts: -------------------------------------------------------------------------------- 1 | // Stylesheet formats 2 | 3 | declare module "*.module.scss" { 4 | const classes: Record; 5 | export default classes; 6 | } 7 | 8 | declare module "*.module.css" { 9 | const classes: Record; 10 | export default classes; 11 | } 12 | 13 | declare module "*.scss" { 14 | const content: string; 15 | export default content; 16 | } 17 | 18 | declare module "*.css" { 19 | const content: string; 20 | export default content; 21 | } 22 | 23 | // Image formats 24 | 25 | declare module "*.jpg" { 26 | const content: string; 27 | export default content; 28 | } 29 | 30 | declare module "*.jpeg" { 31 | const content: string; 32 | export default content; 33 | } 34 | 35 | declare module "*.png" { 36 | const content: string; 37 | export default content; 38 | } 39 | 40 | declare module "*.gif" { 41 | const content: string; 42 | export default content; 43 | } 44 | 45 | declare module "*.bmp" { 46 | const content: string; 47 | export default content; 48 | } 49 | 50 | declare module "*.webp" { 51 | const content: string; 52 | export default content; 53 | } 54 | 55 | declare module "*.svg" { 56 | const content: string; 57 | export default content; 58 | } 59 | 60 | // Font formats 61 | 62 | declare module "*.woff" { 63 | const content: string; 64 | export default content; 65 | } 66 | 67 | declare module "*.woff2" { 68 | const content: string; 69 | export default content; 70 | } 71 | 72 | declare module "*.ttf" { 73 | const content: string; 74 | export default content; 75 | } 76 | -------------------------------------------------------------------------------- /src/utils/random/index.ts: -------------------------------------------------------------------------------- 1 | export { Random, random } from "./random"; 2 | -------------------------------------------------------------------------------- /src/utils/random/random.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, test, expect } from "@jest/globals"; 2 | import { random } from "./random"; 3 | 4 | describe("random", () => { 5 | test("randInt generates random integer in range", () => { 6 | expect(random.randInt(10, 20)).toBe(12); 7 | }); 8 | 9 | test("randFloat generates random float in range", () => { 10 | expect(random.randFloat(10, 20)).toBeCloseTo(12.577907438389957); 11 | }); 12 | 13 | test("pick random item from array", () => { 14 | expect(random.pick([1, 2, 3, 4, 5, 6])).toBe(2); 15 | }); 16 | }); 17 | -------------------------------------------------------------------------------- /src/utils/random/random.ts: -------------------------------------------------------------------------------- 1 | export class Random { 2 | private readonly rng: () => number; 3 | public constructor(rng: () => number) { 4 | this.rng = rng; 5 | } 6 | /** 7 | * Generates a random integer [min, max] 8 | */ 9 | public randInt(min: number, max: number): number { 10 | return Math.floor(this.rng() * (max - min + 1) + min); 11 | } 12 | /** 13 | * Generates a random float [min, max] 14 | */ 15 | public randFloat(min: number, max: number): number { 16 | return this.rng() * (max - min) + min; 17 | } 18 | /** 19 | * Picks a random item from an array 20 | */ 21 | public pick(arr: Item[]): Item { 22 | return arr[this.randInt(0, arr.length - 1)]; 23 | } 24 | /** 25 | * Generates a random float [0, 1) 26 | */ 27 | public random(): number { 28 | return this.rng(); 29 | } 30 | } 31 | 32 | export const random = new Random(() => Math.random()); 33 | -------------------------------------------------------------------------------- /src/utils/seededRandom/index.ts: -------------------------------------------------------------------------------- 1 | export { SeededRandom } from "./seededRandom"; 2 | -------------------------------------------------------------------------------- /src/utils/seededRandom/seededRandom.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, test, expect } from "@jest/globals"; 2 | import { Random } from "~/utils/random"; 3 | import { SeededRandom } from "./seededRandom"; 4 | 5 | describe("seededRandom", () => { 6 | test("seed creates a deterministic series", () => { 7 | const random = new SeededRandom(100000); 8 | expect(random.random()).toBeCloseTo(0.45954178320243955); 9 | }); 10 | 11 | test("can update seed", () => { 12 | const random = new SeededRandom(100000); 13 | random.setSeed(9999999999); 14 | expect(random.random()).toBeCloseTo(0.8015434315893799); 15 | }); 16 | 17 | test("seeded random updates seed", () => { 18 | const random = new SeededRandom(100000); 19 | expect(random.random()).toBeCloseTo(0.45954178320243955); 20 | expect(random.random()).toBeCloseTo(0.7448947750963271); 21 | }); 22 | 23 | test("is instance of Random", () => { 24 | const random = new SeededRandom(100000); 25 | expect(random).toBeInstanceOf(Random); 26 | }); 27 | }); 28 | -------------------------------------------------------------------------------- /src/utils/seededRandom/seededRandom.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable no-bitwise */ 2 | // mulberry32 3 | 4 | import { Random } from "~/utils/random"; 5 | 6 | export class SeededRandom extends Random { 7 | private seed: number; 8 | /** 9 | * Set the seed of the random number generator 10 | */ 11 | public constructor(seed: number) { 12 | super((): number => { 13 | return this.next(); 14 | }); 15 | this.seed = seed; 16 | } 17 | /** 18 | * Reset the seed of the random number generator 19 | */ 20 | public setSeed(seed: number): void { 21 | this.seed = seed; 22 | } 23 | /** 24 | * Get the next number in the series - equivalent to Math.random() 25 | */ 26 | private next(): number { 27 | this.seed |= 0; 28 | this.seed = (this.seed + 0x6d2b79f5) | 0; 29 | let t = Math.imul(this.seed ^ (this.seed >>> 15), 1 | this.seed); 30 | // eslint-disable-next-line operator-assignment 31 | t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t; 32 | return ((t ^ (t >>> 14)) >>> 0) / 4294967296; 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /test/helpers/userEventSetup.ts: -------------------------------------------------------------------------------- 1 | import { jest } from "@jest/globals"; 2 | import { userEvent, type Options, type UserEvent } from "@testing-library/user-event"; 3 | 4 | /** 5 | * Setup user-event instance with custom defaults 6 | */ 7 | export function userEventSetup(options: Options = {}): UserEvent { 8 | return userEvent.setup({ 9 | advanceTimers: jest.advanceTimersByTime.bind(jest), 10 | ...options, 11 | }); 12 | } 13 | -------------------------------------------------------------------------------- /test/mocks/mockRandom.ts: -------------------------------------------------------------------------------- 1 | import { beforeEach, jest } from "@jest/globals"; 2 | import { SeededRandom } from "~/utils/seededRandom"; 3 | 4 | const random = new SeededRandom(0); 5 | 6 | beforeEach(() => { 7 | random.setSeed(123456789); 8 | jest.spyOn(Math, "random").mockImplementation(() => { 9 | return random.random(); 10 | }); 11 | }); 12 | -------------------------------------------------------------------------------- /test/setup.ts: -------------------------------------------------------------------------------- 1 | import { configure } from "@testing-library/react"; 2 | import "@testing-library/jest-dom/jest-globals"; 3 | import "./mocks/mockRandom"; 4 | 5 | configure({ 6 | throwSuggestions: true, 7 | }); 8 | -------------------------------------------------------------------------------- /test/teardown.ts: -------------------------------------------------------------------------------- 1 | import path from "node:path"; 2 | 3 | export default function teardown(): void { 4 | console.log(` 5 | ================================================================================ 6 | View Coverage Report (Open in Browser): ${path.relative( 7 | process.cwd(), 8 | path.join(__dirname, "../coverage/lcov-report/index.html") 9 | )} 10 | ================================================================================ 11 | `); 12 | } 13 | -------------------------------------------------------------------------------- /test/transformers/importPathTransformer.cjs: -------------------------------------------------------------------------------- 1 | const path = require("node:path"); 2 | 3 | module.exports = { 4 | process(sourceText, sourcePath) { 5 | return { 6 | code: `module.exports = ${JSON.stringify(path.basename(sourcePath))};`, 7 | }; 8 | }, 9 | }; 10 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "noEmit": true, 4 | "strict": true, 5 | "target": "ESNext", 6 | "lib": ["ES2024", "DOM", "DOM.Iterable"], 7 | "allowJs": true, 8 | "jsx": "preserve", 9 | "esModuleInterop": true, 10 | "module": "ESNext", 11 | "moduleResolution": "bundler", 12 | "forceConsistentCasingInFileNames": true, 13 | "useUnknownInCatchVariables": true, 14 | "noImplicitOverride": true, 15 | "verbatimModuleSyntax": true, 16 | "paths": { 17 | "test/*": ["test/*"], 18 | "~/*": ["src/*"] 19 | }, 20 | "typeRoots": ["src/types", "node_modules/@types"], 21 | "baseUrl": "." 22 | }, 23 | "include": ["src", "test"] 24 | } 25 | -------------------------------------------------------------------------------- /webpack.common.mjs: -------------------------------------------------------------------------------- 1 | import path from "node:path"; 2 | import CopyWebpackPlugin from "copy-webpack-plugin"; 3 | import ForkTsCheckerWebpackPlugin from "fork-ts-checker-webpack-plugin"; 4 | import HtmlWebpackPlugin from "html-webpack-plugin"; 5 | import svgToMiniDataURI from "mini-svg-data-uri"; 6 | 7 | export default function () { 8 | return { 9 | context: path.resolve("./src"), 10 | entry: ["./index"], 11 | output: { 12 | path: path.resolve("./dist"), 13 | filename: "[name].[contenthash].js", 14 | assetModuleFilename: "[name].[contenthash][ext]", 15 | publicPath: "/", 16 | hashDigestLength: 10, 17 | }, 18 | plugins: [ 19 | new ForkTsCheckerWebpackPlugin({ 20 | typescript: { 21 | configFile: path.resolve("./tsconfig.json"), 22 | configOverwrite: { 23 | compilerOptions: { 24 | skipLibCheck: true, 25 | sourceMap: false, 26 | inlineSourceMap: false, 27 | declarationMap: false, 28 | }, 29 | exclude: ["**/*.test.js", "**/*.test.jsx", "**/*.test.ts", "**/*.test.tsx", "test/**"], 30 | }, 31 | }, 32 | }), 33 | new HtmlWebpackPlugin({ 34 | template: "./index.html", 35 | }), 36 | new CopyWebpackPlugin({ 37 | patterns: [{ from: "static", noErrorOnMissing: true }], 38 | }), 39 | ], 40 | resolve: { 41 | alias: { 42 | "~": path.resolve("./src"), 43 | }, 44 | extensionAlias: { 45 | ".mjs": [".mts", ".mjs"], 46 | }, 47 | extensions: [".js", ".jsx", ".ts", ".tsx", ".json"], 48 | }, 49 | node: false, 50 | module: { 51 | rules: [ 52 | { 53 | test: /\.(j|t)s(x?)$/, 54 | exclude: /node_modules/, 55 | use: ["babel-loader"], 56 | }, 57 | { 58 | test: /\.(woff2|woff|ttf|png|jpg|jpeg|gif|bmp|webp)$/, 59 | type: "asset", 60 | }, 61 | { 62 | test: /\.svg$/, 63 | type: "asset", 64 | generator: { 65 | dataUrl: (content) => svgToMiniDataURI(content.toString()), 66 | }, 67 | }, 68 | ], 69 | }, 70 | stats: "errors-warnings", 71 | }; 72 | } 73 | -------------------------------------------------------------------------------- /webpack.config.mjs: -------------------------------------------------------------------------------- 1 | import process from "node:process"; 2 | import { BundleAnalyzerPlugin } from "webpack-bundle-analyzer"; 3 | import { merge as webpackMerge } from "webpack-merge"; 4 | import webpackCommon from "./webpack.common.mjs"; 5 | import webpackDev from "./webpack.dev.mjs"; 6 | import webpackProd from "./webpack.prod.mjs"; 7 | 8 | export default function (env = {}) { 9 | const isProduction = process.env.NODE_ENV === "production"; 10 | 11 | const commonWebpackConfig = webpackCommon(); 12 | 13 | if (env.showBundleAnalysis) { 14 | return webpackMerge(commonWebpackConfig, webpackProd(), { 15 | plugins: [ 16 | new BundleAnalyzerPlugin({ 17 | defaultSizes: "parsed", 18 | openAnalyzer: true, 19 | }), 20 | ], 21 | }); 22 | } 23 | if (isProduction) { 24 | return webpackMerge(commonWebpackConfig, webpackProd()); 25 | } 26 | return webpackMerge(commonWebpackConfig, webpackDev()); 27 | } 28 | -------------------------------------------------------------------------------- /webpack.dev.mjs: -------------------------------------------------------------------------------- 1 | // cspell:words pmmmwh 2 | import ReactRefreshWebpackPlugin from "@pmmmwh/react-refresh-webpack-plugin"; 3 | 4 | export default function () { 5 | return { 6 | mode: "development", 7 | plugins: [ 8 | new ReactRefreshWebpackPlugin({ 9 | overlay: false, 10 | }), 11 | ], 12 | module: { 13 | rules: [ 14 | { 15 | test: /\.(s?)css$/, 16 | use: [ 17 | "style-loader", 18 | { 19 | loader: "css-loader", 20 | options: { 21 | modules: { 22 | auto: true, 23 | localIdentName: "[local]_[hash:base64:5]", 24 | }, 25 | }, 26 | }, 27 | "postcss-loader", 28 | "sass-loader", 29 | ], 30 | sideEffects: true, 31 | }, 32 | ], 33 | }, 34 | devServer: { 35 | hot: true, 36 | port: 7579, 37 | static: false, 38 | compress: true, 39 | host: "localhost", 40 | allowedHosts: "all", 41 | client: { 42 | logging: "warn", 43 | overlay: false, 44 | }, 45 | historyApiFallback: true, 46 | }, 47 | watchOptions: { 48 | ignored: /node_modules/, 49 | }, 50 | devtool: "source-map", 51 | }; 52 | } 53 | -------------------------------------------------------------------------------- /webpack.prod.mjs: -------------------------------------------------------------------------------- 1 | import CssMinimizerPlugin from "css-minimizer-webpack-plugin"; 2 | import HTMLMinimizerPlugin from "html-minimizer-webpack-plugin"; 3 | import { LicenseWebpackPlugin } from "license-webpack-plugin"; 4 | import MiniCssExtractPlugin from "mini-css-extract-plugin"; 5 | import TerserPlugin from "terser-webpack-plugin"; 6 | import packageJson from "./package.json" with { type: "json" }; 7 | 8 | const ACCEPTABLE_LICENSES = ["MIT", "0BSD", "BSD-2-Clause", "BSD-3-Clause", "APACHE-2.0", "ISC", "Unlicense"]; 9 | 10 | export default function () { 11 | return { 12 | mode: "production", 13 | plugins: [ 14 | new MiniCssExtractPlugin({ 15 | filename: "[name].[contenthash].css", 16 | }), 17 | new LicenseWebpackPlugin({ 18 | outputFilename: "third-party-licenses.txt", 19 | unacceptableLicenseTest: (licenseIdentifier) => { 20 | const licenses = licenseIdentifier.replace(/[()]/g, "").split(" OR "); 21 | return licenses.every((licenseName) => { 22 | return !ACCEPTABLE_LICENSES.map((name) => name.toLowerCase()).includes( 23 | licenseName.toLowerCase() 24 | ); 25 | }); 26 | }, 27 | perChunkOutput: false, 28 | skipChildCompilers: true, 29 | excludedPackageTest: (packageName) => { 30 | return packageName === packageJson.name; 31 | }, 32 | }), 33 | ], 34 | module: { 35 | rules: [ 36 | { 37 | test: /\.(s?)css$/, 38 | use: [MiniCssExtractPlugin.loader, "css-loader", "postcss-loader", "sass-loader"], 39 | sideEffects: true, 40 | }, 41 | ], 42 | }, 43 | optimization: { 44 | minimizer: [ 45 | new TerserPlugin({ 46 | extractComments: false, 47 | terserOptions: { 48 | format: { 49 | comments: false, 50 | preamble: "/* See third-party-licenses.txt for licenses of any bundled software. */", 51 | }, 52 | }, 53 | }), 54 | new CssMinimizerPlugin(), 55 | new HTMLMinimizerPlugin(), 56 | ], 57 | splitChunks: { 58 | chunks: "all", 59 | }, 60 | }, 61 | devtool: false, 62 | }; 63 | } 64 | --------------------------------------------------------------------------------