├── .babelrc ├── .browserslistrc ├── .eslintrc ├── .gitignore ├── .husky └── pre-commit ├── .lintstagedrc ├── .nvmrc ├── .stylelintrc ├── MIT-LICENSE ├── README.md ├── __tests__ └── js │ ├── e2e │ ├── mathOperationsTest.js │ └── page1Test.js │ └── models │ └── Article.test.js ├── bin └── clear.sh ├── dev ├── router.js ├── server.js └── server_responses │ └── articles.json ├── jest.config.js ├── package-lock.json ├── package.json ├── postcss.config.js ├── public ├── index.html └── page1.html ├── src ├── css │ ├── page1.css │ └── shared │ │ ├── fonts.css │ │ ├── index.css │ │ └── tailwind.css ├── fonts │ └── Roboto-Regular.ttf ├── images │ ├── background.png │ ├── loco.svg │ └── logo.png ├── index.js ├── js │ ├── actions │ │ └── page1 │ │ │ └── articles.js │ ├── components │ │ ├── index │ │ │ ├── Content.js │ │ │ ├── Cube.js │ │ │ ├── Root.js │ │ │ └── Square.js │ │ ├── page1 │ │ │ ├── Article.js │ │ │ ├── ArticleList.js │ │ │ ├── Assets.js │ │ │ ├── Content.js │ │ │ ├── Filter.js │ │ │ ├── Link.js │ │ │ └── Root.js │ │ └── shared │ │ │ ├── ErrorPage.js │ │ │ ├── Layout.js │ │ │ └── NavLink.js │ ├── config │ │ ├── index.js │ │ └── inflections.js │ ├── containers │ │ └── page1 │ │ │ ├── ArticleList.js │ │ │ └── Root.js │ ├── helpers │ │ └── common.js │ ├── models │ │ └── Article.js │ ├── reducers │ │ ├── common.js │ │ └── page1 │ │ │ ├── articles.js │ │ │ ├── index.js │ │ │ └── visibilityFilter.js │ ├── selectors │ │ └── articles.js │ ├── services │ │ └── math.js │ └── stores │ │ └── page1 │ │ └── store.js └── page1.js ├── tailwind.config.js ├── webpack.common.js ├── webpack.dev.js ├── webpack.hmr.js └── webpack.prod.js /.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "plugins": [ 3 | "@babel/plugin-proposal-class-properties" 4 | ], 5 | "presets": [ 6 | ["@babel/preset-env",{ 7 | "useBuiltIns": "usage", 8 | "corejs": 3 9 | }], 10 | "@babel/preset-react" 11 | ] 12 | } 13 | -------------------------------------------------------------------------------- /.browserslistrc: -------------------------------------------------------------------------------- 1 | > 1% 2 | last 1 version 3 | not dead -------------------------------------------------------------------------------- /.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "env": { 3 | "browser": true, 4 | "jest/globals": true, 5 | "node": true 6 | }, 7 | 8 | "extends": [ 9 | "eslint:recommended", 10 | "plugin:react/recommended", 11 | "plugin:testcafe/recommended", 12 | "prettier" 13 | ], 14 | 15 | "parser": "@babel/eslint-parser", 16 | 17 | "parserOptions": { 18 | "ecmaFeatures": { 19 | "jsx": true 20 | } 21 | }, 22 | 23 | "plugins": [ 24 | "prettier", 25 | "react", 26 | "jest", 27 | "testcafe" 28 | ], 29 | 30 | "rules": { 31 | "prettier/prettier": "error" 32 | }, 33 | 34 | "settings": { 35 | "import/resolver": { 36 | "webpack": { 37 | "config": { 38 | "resolve": { 39 | "modules": [ 40 | "src/js", 41 | "src/css", 42 | "src/images", 43 | "node_modules" 44 | ] 45 | } 46 | } 47 | } 48 | }, 49 | "react": { 50 | "version": "detect" 51 | } 52 | } 53 | } -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | 3 | public/assets -------------------------------------------------------------------------------- /.husky/pre-commit: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | . "$(dirname "$0")/_/husky.sh" 3 | 4 | npm run lint-staged 5 | -------------------------------------------------------------------------------- /.lintstagedrc: -------------------------------------------------------------------------------- 1 | { 2 | "./*.js": [ 3 | "prettier --write", 4 | "eslint" 5 | ], 6 | "src/**/*.js": [ 7 | "prettier --write", 8 | "eslint" 9 | ], 10 | "__tests__/**/*.js": [ 11 | "prettier --write", 12 | "eslint" 13 | ], 14 | "src/css/**/*.css": [ 15 | "prettier --write", 16 | "stylelint --fix" 17 | ] 18 | } -------------------------------------------------------------------------------- /.nvmrc: -------------------------------------------------------------------------------- 1 | 18.15 2 | -------------------------------------------------------------------------------- /.stylelintrc: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "stylelint-config-standard", 3 | "rules": { 4 | "at-rule-no-unknown": [ 5 | true, 6 | { 7 | "ignoreAtRules": ["apply", "layer"] 8 | } 9 | ] 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /MIT-LICENSE: -------------------------------------------------------------------------------- 1 | Copyright 2016 Zbigniew Humeniuk 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining 4 | a copy of this software and associated documentation files (the 5 | "Software"), to deal in the Software without restriction, including 6 | without limitation the rights to use, copy, modify, merge, publish, 7 | distribute, sublicense, and/or sell copies of the Software, and to 8 | permit persons to whom the Software is furnished to do so, subject to 9 | the following conditions: 10 | 11 | The above copyright notice and this permission notice shall be 12 | included in all copies or substantial portions of the Software. 13 | 14 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 15 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 16 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND 17 | NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE 18 | LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION 19 | OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION 20 | WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 21 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # 🧐 What's inside? 2 | 3 | ## 1️⃣ Boilerplate code 4 | 5 | This repository contains boilerplate code for everyone who wants to create a multi-page website powered by all the modern tools such as: 6 | 7 | * [React](https://reactjs.org) - a JavaScript library for building user interfaces 8 | * [Redux](https://redux.js.org) - a predictable state container for JavaScript apps 9 | * [React Router](https://reacttraining.com/react-router) - a collection of navigational components 10 | * [**Loco-JS-Model**](https://github.com/locoframework/loco-js-model) - a missing model layer for JavaScript. Give it a try - it's a neat solution for handling RESTful resources 11 | * [Babel](https://babeljs.io) - a JavaScript compiler 12 | * [Webpack](https://webpack.js.org) with plugins - a module bundler 13 | * [Tailwind CSS](https://tailwindcss.com) - a utility-first CSS framework packed with classes 14 | * [Jest](https://facebook.github.io/jest) - delightful JavaScript testing framework 15 | * [TestCafe](https://testcafe.io) - end-to-end testing, simplified 16 | * Linters + [Prettier](https://prettier.io) 17 | 18 | For a complete list of dependencies see [package.json](https://github.com/artofcodelabs/front-end-boilerplate/blob/master/package.json) 19 | 20 | 👍 It may be a good choice for everyone who thinks that [Create React App](https://github.com/facebook/create-react-app) is too complicated and looking for a more straightforward, preconfigured solution to experiment with all the mentioned above tools. 21 | 22 | ## 2️⃣ Example app 23 | 24 | This repository also contains an exemplary app showcasing how to use all the libraries and correctly structure your code. 25 | 26 | # 🎮 Usage 27 | 28 | Delete the example app to have a fresh start: 29 | 30 | ```bash 31 | npm run clear 32 | ``` 33 | 34 | Start developing your own website: 35 | 36 | ```bash 37 | npm run start 38 | ``` 39 | 40 | Run tests: 41 | 42 | ```bash 43 | npm run test 44 | ``` 45 | 46 | Cut a production build: 47 | 48 | ```bash 49 | npm run build 50 | ``` 51 | 52 | # 📜 License 53 | 54 | [MIT License](https://opensource.org/licenses/MIT) 55 | 56 | # 👨‍🏭 Author 57 | 58 | Zbigniew Humeniuk from [Art of Code](http://artofcode.co) -------------------------------------------------------------------------------- /__tests__/js/e2e/mathOperationsTest.js: -------------------------------------------------------------------------------- 1 | import { Selector } from "testcafe"; 2 | 3 | fixture`Math Operations`.page`http://localhost:3000/`; 4 | 5 | test("changing number and displaying both calcs", async (t) => { 6 | await t 7 | .typeText("input#number", "5", { replace: true }) 8 | .expect(Selector("p").withText("5 squared is 25").exists) 9 | .ok() 10 | .expect(Selector("p").withText("5 cubed is 125").exists) 11 | .ok(); 12 | }); 13 | -------------------------------------------------------------------------------- /__tests__/js/e2e/page1Test.js: -------------------------------------------------------------------------------- 1 | import { Selector } from "testcafe"; 2 | 3 | fixture`Page 1`.page`http://localhost:3000/page1/articles`; 4 | 5 | test("loading articles", async (t) => { 6 | await t 7 | .click(Selector("a").withText("Load Articles")) 8 | .expect(Selector("article").count) 9 | .eql(3); 10 | }); 11 | -------------------------------------------------------------------------------- /__tests__/js/models/Article.test.js: -------------------------------------------------------------------------------- 1 | import Article from "models/Article"; 2 | 3 | test("vulgarityLevel", () => { 4 | const article = new Article({ title: "fuck this!" }); 5 | article.isValid(); 6 | expect(article.errors.base[0]).toEqual("Article contains strong language."); 7 | }); 8 | -------------------------------------------------------------------------------- /bin/clear.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | rm MIT-LICENSE 4 | rm README.md 5 | 6 | rm __tests__/js/e2e/* 7 | rm __tests__/js/models/* 8 | rm -r dev/server_responses 9 | 10 | rm -r src/js/actions/page1 11 | rm src/js/components/index/* 12 | rm -r src/js/components/page1 13 | rm -r src/js/components/shared 14 | rm -r src/js/containers/page1 15 | rm src/js/helpers/* 16 | rm src/js/models/* 17 | rm -r src/js/reducers/page1 18 | rm src/js/selectors/* 19 | rm src/js/services/* 20 | rm -r src/js/stores/page1 21 | 22 | if [ -d ".git" ]; then 23 | rm -rf .git 24 | fi 25 | 26 | echo Cleared! -------------------------------------------------------------------------------- /dev/router.js: -------------------------------------------------------------------------------- 1 | const path = require('path'); 2 | const fs = require('fs'); 3 | 4 | const rootPath = '..'; 5 | 6 | const setRoutes = (app) => { 7 | app.get('/', function(_, res) { 8 | res.sendFile(path.join(__dirname, `${rootPath}/public/`)); 9 | }); 10 | 11 | app.get(/^\/(squaring|cubing)/, function(_, res) { 12 | res.sendFile(path.join(__dirname, `${rootPath}/public/index.html`)); 13 | }); 14 | 15 | app.get(/^\/page1/, function(_, res) { 16 | res.sendFile(path.join(__dirname, `${rootPath}/public/page1.html`)); 17 | }); 18 | 19 | app.get(/^\/articles(\.json)?/, function(_, res) { 20 | const filePath = path.join(__dirname, './server_responses/articles.json'); 21 | const body = JSON.parse(fs.readFileSync(filePath, 'utf8')); 22 | res.json(body); 23 | }); 24 | }; 25 | 26 | module.exports = setRoutes; -------------------------------------------------------------------------------- /dev/server.js: -------------------------------------------------------------------------------- 1 | const express = require('express'); 2 | const setRoutes = require('./router'); 3 | 4 | const opts = process.argv.slice(2) 5 | .map(s => s.split('=')) 6 | .filter(arr => arr[0].startsWith('--')) 7 | .map(arr => [arr[0].slice(2), arr[1]]) 8 | .reduce((acc, arr) => { 9 | acc[arr[0]] = arr[1]; 10 | return acc; 11 | }, {}); 12 | 13 | const app = express(); 14 | 15 | switch (opts.env) { 16 | case 'development': { 17 | const config = require('../webpack.hmr.js'); 18 | const compiler = require('webpack')(config); 19 | 20 | app.use(require('webpack-dev-middleware')(compiler, { 21 | publicPath: config.output.publicPath 22 | })); 23 | app.use(require('webpack-hot-middleware')(compiler)); 24 | 25 | console.log('Server runs in dev mode.'); 26 | break; 27 | } 28 | } 29 | 30 | setRoutes(app); 31 | app.use(express.static('public')); 32 | 33 | app.listen(3000, () => { 34 | console.log('Example app listening on port 3000!\n'); 35 | }); 36 | -------------------------------------------------------------------------------- /dev/server_responses/articles.json: -------------------------------------------------------------------------------- 1 | {"resources":[{"id":3,"title":"Step 1: Define a plan","text":"Plans are objects representing a set cost, currency, and billing cycle. You may need to define just one plan or several hundred, depending upon the...","published_at":"2017-07-05T15:19:35.000Z","author":"zbig","comments_count":11},{"id":2,"title":"Subscriptions Quickstart","text":"Learn how to set up basic recurring billing for your customers. If you need help after reading this, search our documentation or check out answers...","published_at":"2017-07-05T15:14:51.000Z","author":"zbig","comments_count":0},{"id":1,"title":"Retrieve a card","text":"You can always see the 10 most recent cards directly on a customer or recipient; this method lets you retrieve details about a specific card stored...","published_at":"2017-07-05T15:11:11.000Z","author":"zbig","comments_count":4}],"count":3} -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | modulePaths: ["src/js"], 3 | testEnvironment: "jsdom", 4 | testPathIgnorePatterns: ["/__tests__/js/e2e"], 5 | }; 6 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "private": true, 3 | "name": "front-end-boilerplate", 4 | "version": "0.0.4", 5 | "description": "Boilerplate code for a multi-static-page website powered by modern and fancy tools", 6 | "scripts": { 7 | "build": "webpack --mode=production --node-env=production -c webpack.prod.js --progress", 8 | "build-dev": "webpack -c webpack.dev.js --progress", 9 | "clear": "./bin/clear.sh", 10 | "eslint": "eslint src __tests__ ./*.js --fix", 11 | "lint-staged": "lint-staged", 12 | "start": "node dev/server.js --env=development", 13 | "serve": "node dev/server.js", 14 | "stylelint": "stylelint src/css --fix", 15 | "test": "npm run test-unit && npm run test-e2e", 16 | "test-unit": "jest", 17 | "pretest-e2e": "npm run build", 18 | "test-e2e": "testcafe firefox __tests__/js/e2e/*.js --app 'node dev/server.js --env=test'", 19 | "prepare": "husky install" 20 | }, 21 | "author": "Zbigniew Humeniuk ", 22 | "license": "MIT", 23 | "dependencies": { 24 | "@headlessui/react": "^1.7.13", 25 | "@heroicons/react": "^2.0.16", 26 | "core-js": "^3.29.0", 27 | "immer": "^9.0.19", 28 | "loco-js-model": "^2.0.0", 29 | "prop-types": "^15.8.1", 30 | "react": "^18.2.0", 31 | "react-dom": "^18.2.0", 32 | "react-redux": "^8.0.5", 33 | "react-router": "^6.8.2", 34 | "react-router-dom": "^6.8.2", 35 | "redux": "^4.2.1" 36 | }, 37 | "devDependencies": { 38 | "@babel/core": "^7.21.0", 39 | "@babel/eslint-parser": "^7.19.1", 40 | "@babel/plugin-proposal-class-properties": "^7.18.6", 41 | "@babel/preset-env": "^7.20.2", 42 | "@babel/preset-react": "^7.18.6", 43 | "@tailwindcss/forms": "^0.5.3", 44 | "autoprefixer": "^10.4.13", 45 | "babel-jest": "^29.5.0", 46 | "babel-loader": "^9.1.2", 47 | "clean-webpack-plugin": "^4.0.0", 48 | "css-loader": "^6.7.3", 49 | "css-minimizer-webpack-plugin": "^4.2.2", 50 | "eslint": "^8.35.0", 51 | "eslint-config-prettier": "^8.7.0", 52 | "eslint-import-resolver-webpack": "^0.13.2", 53 | "eslint-plugin-import": "^2.27.5", 54 | "eslint-plugin-jest": "^27.2.1", 55 | "eslint-plugin-prettier": "^4.2.1", 56 | "eslint-plugin-react": "^7.32.2", 57 | "eslint-plugin-testcafe": "^0.2.1", 58 | "express": "^4.21.1", 59 | "husky": "^8.0.3", 60 | "image-minimizer-webpack-plugin": "^3.8.1", 61 | "jest": "^29.5.0", 62 | "jest-environment-jsdom": "^29.5.0", 63 | "lint-staged": "^13.1.2", 64 | "mini-css-extract-plugin": "^2.7.3", 65 | "postcss": "^8.4.31", 66 | "postcss-import": "^15.1.0", 67 | "postcss-loader": "^7.0.2", 68 | "prettier": "^2.8.4", 69 | "sharp": "^0.32.6", 70 | "stylelint": "^15.2.0", 71 | "stylelint-config-standard": "^30.0.1", 72 | "svgo": "^3.0.2", 73 | "tailwindcss": "^3.2.7", 74 | "testcafe": "^3.1.0", 75 | "webpack": "^5.94.0", 76 | "webpack-cli": "^5.0.1", 77 | "webpack-dev-middleware": "^6.1.2", 78 | "webpack-hot-middleware": "^2.25.3" 79 | } 80 | } 81 | -------------------------------------------------------------------------------- /postcss.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | ident: "postcss", 3 | sourceMap: true, 4 | plugins: [ 5 | require("postcss-import"), 6 | require("tailwindcss/nesting"), 7 | require("tailwindcss"), 8 | require("autoprefixer"), 9 | ], 10 | }; 11 | -------------------------------------------------------------------------------- /public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | Boilerplate 7 | 8 | 9 | 10 |
11 | 12 | 13 | 14 | 15 | 16 | -------------------------------------------------------------------------------- /public/page1.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | Boilerplate 7 | 8 | 9 | 10 | 11 |
12 | 13 | 14 | 15 | 16 | 17 | -------------------------------------------------------------------------------- /src/css/page1.css: -------------------------------------------------------------------------------- 1 | :root { 2 | --border-radius: 10px; 3 | } 4 | 5 | p { 6 | &.with-border { 7 | border: 1px solid #ccc; 8 | border-radius: var(--border-radius); 9 | filter: grayscale(50%); 10 | } 11 | 12 | &.with-background { 13 | background: url("../images/background.png"); 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /src/css/shared/fonts.css: -------------------------------------------------------------------------------- 1 | @font-face { 2 | font-family: Roboto; 3 | src: url("../../fonts/Roboto-Regular.ttf"); 4 | font-weight: 400; 5 | font-style: normal; 6 | } 7 | -------------------------------------------------------------------------------- /src/css/shared/index.css: -------------------------------------------------------------------------------- 1 | @import url("./fonts.css"); 2 | @import url("./tailwind.css"); 3 | -------------------------------------------------------------------------------- /src/css/shared/tailwind.css: -------------------------------------------------------------------------------- 1 | @import url("tailwindcss/base"); 2 | @import url("tailwindcss/components"); 3 | @import url("tailwindcss/utilities"); 4 | 5 | @layer components { 6 | a.link { 7 | @apply text-indigo-600 hover:text-indigo-700 hover:underline; 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /src/fonts/Roboto-Regular.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/artofcodelabs/front-end-boilerplate/25efae310eb08fcc82ca24bedaf06686eb9b14fe/src/fonts/Roboto-Regular.ttf -------------------------------------------------------------------------------- /src/images/background.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/artofcodelabs/front-end-boilerplate/25efae310eb08fcc82ca24bedaf06686eb9b14fe/src/images/background.png -------------------------------------------------------------------------------- /src/images/loco.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | loco 29 | 30 | 31 | 32 | 33 | loco 34 | 35 | 36 | 37 | 38 | 39 | -------------------------------------------------------------------------------- /src/images/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/artofcodelabs/front-end-boilerplate/25efae310eb08fcc82ca24bedaf06686eb9b14fe/src/images/logo.png -------------------------------------------------------------------------------- /src/index.js: -------------------------------------------------------------------------------- 1 | import "core-js/stable"; 2 | import React from "react"; 3 | import { createRoot } from "react-dom/client"; 4 | import { 5 | createRoutesFromElements, 6 | createBrowserRouter, 7 | RouterProvider, 8 | Route, 9 | } from "react-router-dom"; 10 | 11 | import Cube from "components/index/Cube"; 12 | import ErrorPage from "components/shared/ErrorPage"; 13 | import Root from "components/index/Root"; 14 | import Square from "components/index/Square"; 15 | 16 | import "shared/index.css"; 17 | 18 | const fetchNumber = ({ request }) => { 19 | const url = new URL(request.url); 20 | const number = url.searchParams.get("number"); 21 | return Number(number) || 4; 22 | }; 23 | 24 | const router = createBrowserRouter( 25 | createRoutesFromElements( 26 | } 29 | errorElement={} 30 | loader={fetchNumber} 31 | > 32 | }> 33 | 37 | 38 | 39 | 40 | } 41 | loader={fetchNumber} 42 | /> 43 | } loader={fetchNumber} /> 44 | } loader={fetchNumber} /> 45 | 46 | 47 | ) 48 | ); 49 | 50 | const container = document.getElementById("root"); 51 | const root = createRoot(container); 52 | root.render( 53 | 54 | 55 | 56 | ); 57 | 58 | if (module.hot) { 59 | module.hot.accept(); 60 | } 61 | -------------------------------------------------------------------------------- /src/js/actions/page1/articles.js: -------------------------------------------------------------------------------- 1 | import store from "stores/page1/store"; 2 | import Article from "models/Article"; 3 | 4 | const show = (filter = "SHOW_ALL") => { 5 | store.dispatch({ 6 | type: "SET_VISIBILITY_FILTER", 7 | filter, 8 | }); 9 | }; 10 | 11 | const loadArticles = async () => { 12 | if (store.getState().articles.resources.length > 0) return; 13 | 14 | try { 15 | const resp = await Article.all({ resource: "main" }); 16 | store.dispatch({ 17 | type: "ARTICLES.ADD", 18 | articles: resp.resources, 19 | }); 20 | } catch (err) { 21 | store.dispatch({ 22 | type: "ARTICLES.FETCH_FAILURE", 23 | msg: "Something went wrong", 24 | }); 25 | } 26 | }; 27 | 28 | const showAll = () => { 29 | show(); 30 | loadArticles(); 31 | return null; 32 | }; 33 | 34 | const showRead = () => { 35 | show("SHOW_READ"); 36 | return null; 37 | }; 38 | 39 | const showUnread = () => { 40 | show("SHOW_UNREAD"); 41 | loadArticles(); 42 | return null; 43 | }; 44 | 45 | export { showAll, showRead, showUnread }; 46 | -------------------------------------------------------------------------------- /src/js/components/index/Content.js: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import PropTypes from "prop-types"; 3 | import { useLocation, useSubmit, Form } from "react-router-dom"; 4 | 5 | import NavLink from "../shared/NavLink"; 6 | 7 | const Content = ({ number }) => { 8 | const submit = useSubmit(); 9 | const location = useLocation(); 10 | 11 | return ( 12 |
13 |
14 |
15 | 21 |
22 | { 30 | submit(event.currentTarget.form, { 31 | action: `${location.pathname}?${location.search}`, 32 | replace: true, 33 | }); 34 | }} 35 | className="block w-full rounded-md border-gray-300 focus:border-indigo-500 focus:ring-indigo-500 sm:text-sm" 36 | /> 37 |
38 |
39 |
40 | All operations 41 | Squaring 42 | Cubing 43 |
44 | ); 45 | }; 46 | 47 | Content.propTypes = { 48 | number: PropTypes.number.isRequired, 49 | }; 50 | 51 | export default Content; 52 | -------------------------------------------------------------------------------- /src/js/components/index/Cube.js: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { useLoaderData } from "react-router-dom"; 3 | 4 | import { cube } from "services/math"; 5 | 6 | const Cube = () => { 7 | const number = useLoaderData(); 8 | 9 | return ( 10 |

11 | {number} cubed is {cube(number)} 12 |

13 | ); 14 | }; 15 | 16 | export default Cube; 17 | -------------------------------------------------------------------------------- /src/js/components/index/Root.js: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { useLoaderData, Outlet } from "react-router-dom"; 3 | 4 | import Content from "./Content"; 5 | import Layout from "../shared/Layout"; 6 | 7 | const Root = () => { 8 | const number = useLoaderData(); 9 | 10 | return ( 11 | 12 | 13 | 14 | 15 | ); 16 | }; 17 | 18 | export default Root; 19 | -------------------------------------------------------------------------------- /src/js/components/index/Square.js: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { useLoaderData } from "react-router-dom"; 3 | 4 | import { square } from "services/math"; 5 | 6 | const Square = () => { 7 | const number = useLoaderData(); 8 | 9 | return ( 10 |

11 | {number} squared is {square(number)} 12 |

13 | ); 14 | }; 15 | 16 | export default Square; 17 | -------------------------------------------------------------------------------- /src/js/components/page1/Article.js: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import PropTypes from "prop-types"; 3 | 4 | import Link from "./Link"; 5 | 6 | const Article = ({ 7 | id, 8 | title, 9 | author, 10 | publishedAt, 11 | content, 12 | read, 13 | onMarkAsReadClick, 14 | }) => ( 15 |
16 |

{title}

17 | 18 |

19 | {author} wrote this on {publishedAt.toLocaleDateString("pl")} 20 |

21 | 22 |

{content}

23 | 24 | {!read ? ( 25 |

26 | 27 | Mark as read 28 | 29 |

30 | ) : ( 31 | "" 32 | )} 33 |
34 | ); 35 | 36 | Article.propTypes = { 37 | id: PropTypes.number.isRequired, 38 | title: PropTypes.string.isRequired, 39 | author: PropTypes.string.isRequired, 40 | publishedAt: PropTypes.instanceOf(Date).isRequired, 41 | content: PropTypes.string.isRequired, 42 | read: PropTypes.bool.isRequired, 43 | onMarkAsReadClick: PropTypes.func.isRequired, 44 | }; 45 | 46 | export default Article; 47 | -------------------------------------------------------------------------------- /src/js/components/page1/ArticleList.js: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import PropTypes from "prop-types"; 3 | import { NavLink } from "react-router-dom"; 4 | 5 | import ArticleModel from "models/Article"; 6 | import Article from "./Article"; 7 | import Filter from "./Filter"; 8 | 9 | const ArticleList = ({ articles, showLink, onMarkAsReadClick, errorMsg }) => ( 10 |
11 | {!showLink ? : ""} 12 | 13 | {errorMsg && !articles.length ? ( 14 |

{errorMsg}

15 | ) : ( 16 | "" 17 | )} 18 | 19 | {showLink ? ( 20 | 21 | Load Articles 22 | 23 | ) : ( 24 | "" 25 | )} 26 | 27 | {articles.map((article) => ( 28 |
33 | ))} 34 |
35 | ); 36 | 37 | ArticleList.propTypes = { 38 | articles: PropTypes.arrayOf(PropTypes.instanceOf(ArticleModel)).isRequired, 39 | showLink: PropTypes.bool.isRequired, 40 | onMarkAsReadClick: PropTypes.func.isRequired, 41 | errorMsg: PropTypes.string, 42 | }; 43 | 44 | export default ArticleList; 45 | -------------------------------------------------------------------------------- /src/js/components/page1/Assets.js: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | 3 | import { square } from "services/math"; 4 | import Logo from "logo.png"; 5 | import Loco from "loco.svg"; 6 | 7 | const Assets = () => ( 8 |
9 |

10 | 4 squared is {square(4)} 11 |

12 | 13 |

14 | AOC Logo 15 |

16 | 17 |

18 | Loco Logo 19 |

20 |
21 | ); 22 | 23 | export default Assets; 24 | -------------------------------------------------------------------------------- /src/js/components/page1/Content.js: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import PropTypes from "prop-types"; 3 | import { useLocation } from "react-router-dom"; 4 | 5 | import NavLink from "components/shared/NavLink"; 6 | 7 | const Content = ({ anyArticles }) => { 8 | const location = useLocation(); 9 | const showArticles = location.pathname.match(/^\/page1\/articles/) !== null; 10 | 11 | return ( 12 |

13 | 17 | Articles 18 | 19 | Assets 20 |

21 | ); 22 | }; 23 | 24 | Content.propTypes = { 25 | anyArticles: PropTypes.bool.isRequired, 26 | }; 27 | 28 | export default Content; 29 | -------------------------------------------------------------------------------- /src/js/components/page1/Filter.js: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { NavLink } from "react-router-dom"; 3 | 4 | const Filter = () => ( 5 |

6 | Show:{" "} 7 | 8 | All 9 | 10 | {", "} 11 | 12 | Read 13 | 14 | {", "} 15 | 16 | Unread 17 | 18 |

19 | ); 20 | 21 | export default Filter; 22 | -------------------------------------------------------------------------------- /src/js/components/page1/Link.js: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import PropTypes from "prop-types"; 3 | 4 | const Link = ({ active, children, onClick }) => { 5 | if (active) return {children}; 6 | 7 | return ( 8 | { 11 | e.preventDefault(); 12 | onClick(); 13 | }} 14 | className="link" 15 | > 16 | {children} 17 | 18 | ); 19 | }; 20 | 21 | Link.propTypes = { 22 | active: PropTypes.bool.isRequired, 23 | children: PropTypes.node.isRequired, 24 | onClick: PropTypes.func.isRequired, 25 | }; 26 | 27 | export default Link; 28 | -------------------------------------------------------------------------------- /src/js/components/page1/Root.js: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import PropTypes from "prop-types"; 3 | import { Outlet } from "react-router-dom"; 4 | 5 | import Content from "./Content"; 6 | import Layout from "../shared/Layout"; 7 | 8 | const Root = ({ anyArticles }) => ( 9 | 10 | 11 | 12 | 13 | ); 14 | 15 | Root.propTypes = { 16 | anyArticles: PropTypes.bool.isRequired, 17 | }; 18 | 19 | export default Root; 20 | -------------------------------------------------------------------------------- /src/js/components/shared/ErrorPage.js: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { useRouteError } from "react-router-dom"; 3 | 4 | const ErrorPage = () => { 5 | const error = useRouteError(); 6 | 7 | return ( 8 |
9 |

Oops!

10 |

Sorry, an unexpected error has occurred.

11 |

12 | {error.statusText || error.message} 13 |

14 |
15 | ); 16 | }; 17 | 18 | export default ErrorPage; 19 | -------------------------------------------------------------------------------- /src/js/components/shared/Layout.js: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import PropTypes from "prop-types"; 3 | import { Disclosure } from "@headlessui/react"; 4 | import { Bars3Icon, XMarkIcon } from "@heroicons/react/24/outline"; 5 | 6 | const classNames = (...classes) => { 7 | return classes.filter(Boolean).join(" "); 8 | }; 9 | 10 | const navigation = [ 11 | { id: "math", name: "Math Operations", href: "/", current: false }, 12 | { 13 | id: "page1", 14 | name: "Articles & Assets", 15 | href: "/page1/articles", 16 | current: false, 17 | }, 18 | ]; 19 | 20 | const Layout = ({ current, children }) => { 21 | const currentPage = navigation.find((i) => i.id === current); 22 | currentPage.current = true; 23 | 24 | return ( 25 |
26 | 27 | {({ open }) => ( 28 | <> 29 |
30 |
31 |
32 |
33 | Your Company 38 |
39 |
40 |
41 | {navigation.map((item) => ( 42 | 53 | {item.name} 54 | 55 | ))} 56 |
57 |
58 |
59 |
60 | {/* Mobile menu button */} 61 | 62 | Open main menu 63 | {open ? ( 64 | 69 |
70 |
71 |
72 | 73 | 74 |
75 | {navigation.map((item) => ( 76 | 88 | {item.name} 89 | 90 | ))} 91 |
92 |
93 | 94 | )} 95 |
96 | 97 |
98 |
99 |

100 | {currentPage.name} 101 |

102 |
103 |
104 |
105 |
{children}
106 |
107 |
108 | ); 109 | }; 110 | 111 | Layout.propTypes = { 112 | current: PropTypes.string.isRequired, 113 | children: PropTypes.node.isRequired, 114 | }; 115 | 116 | export default Layout; 117 | -------------------------------------------------------------------------------- /src/js/components/shared/NavLink.js: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import PropTypes from "prop-types"; 3 | import { NavLink as RouterNavLink } from "react-router-dom"; 4 | 5 | const spanClass = 6 | "inline-block mr-2 rounded-lg bg-indigo-600 px-4 py-1.5 text-base font-semibold leading-7 text-white shadow-sm ring-1 ring-indigo-600 hover:bg-indigo-700 hover:ring-indigo-700 active:bg-indigo-800"; 7 | 8 | const NavLink = ({ to, active, children }) => { 9 | return ( 10 | 11 | {({ isActive }) => ( 12 | 17 | {children} 18 | 19 | )} 20 | 21 | ); 22 | }; 23 | 24 | NavLink.propTypes = { 25 | to: PropTypes.string.isRequired, 26 | active: PropTypes.bool, 27 | children: PropTypes.node.isRequired, 28 | }; 29 | 30 | export default NavLink; 31 | -------------------------------------------------------------------------------- /src/js/config/index.js: -------------------------------------------------------------------------------- 1 | import { Config } from "loco-js-model"; 2 | 3 | Config.protocolWithHost = "http://localhost:3000"; 4 | -------------------------------------------------------------------------------- /src/js/config/inflections.js: -------------------------------------------------------------------------------- 1 | const irregularPluralForms = {}; 2 | 3 | export default (word) => irregularPluralForms[word] || `${word}s`; 4 | -------------------------------------------------------------------------------- /src/js/containers/page1/ArticleList.js: -------------------------------------------------------------------------------- 1 | import { connect } from "react-redux"; 2 | 3 | import { 4 | anyArticles, 5 | getErrorMsg, 6 | getVisibleArticles, 7 | } from "selectors/articles"; 8 | import ArticleListComponent from "components/page1/ArticleList"; 9 | 10 | const mapStateToProps = (state) => ({ 11 | articles: getVisibleArticles(state), 12 | showLink: !anyArticles(state), 13 | errorMsg: getErrorMsg(state), 14 | }); 15 | 16 | const mapDispatchToProps = (dispatch) => ({ 17 | onMarkAsReadClick(id) { 18 | dispatch({ 19 | type: "ARTICLE.UPDATE", 20 | id, 21 | changes: { read: [false, true] }, 22 | }); 23 | }, 24 | }); 25 | 26 | const ArticleList = connect( 27 | mapStateToProps, 28 | mapDispatchToProps 29 | )(ArticleListComponent); 30 | 31 | export default ArticleList; 32 | -------------------------------------------------------------------------------- /src/js/containers/page1/Root.js: -------------------------------------------------------------------------------- 1 | import { connect } from "react-redux"; 2 | 3 | import { anyArticles } from "selectors/articles"; 4 | import RootComponent from "components/page1/Root"; 5 | 6 | const mapStateToProps = (state) => ({ 7 | anyArticles: anyArticles(state), 8 | }); 9 | 10 | const App = connect(mapStateToProps, null)(RootComponent); 11 | 12 | export default App; 13 | -------------------------------------------------------------------------------- /src/js/helpers/common.js: -------------------------------------------------------------------------------- 1 | export function isProd() { 2 | return process.env.NODE_ENV === "production"; 3 | } 4 | -------------------------------------------------------------------------------- /src/js/models/Article.js: -------------------------------------------------------------------------------- 1 | import { Models } from "loco-js-model"; 2 | 3 | class Article extends Models.Base { 4 | static identity = "Article"; 5 | 6 | static resources = { 7 | url: "/user/articles", 8 | paginate: { per: 5 }, 9 | main: { 10 | url: "/articles", 11 | paginate: { per: 3 }, 12 | }, 13 | admin: { 14 | url: "/admin/articles", 15 | paginate: { per: 4 }, 16 | }, 17 | }; 18 | 19 | static attributes = { 20 | title: { 21 | validations: { 22 | presence: true, 23 | length: { within: [3, 255] }, 24 | }, 25 | }, 26 | content: { 27 | validations: { 28 | presence: true, 29 | length: { minimum: 100 }, 30 | }, 31 | remoteName: "text", 32 | }, 33 | createdAt: { 34 | type: "Date", 35 | remoteName: "created_at", 36 | }, 37 | updatedAt: { 38 | type: "Date", 39 | remoteName: "updated_at", 40 | }, 41 | commentsCount: { 42 | type: "Int", 43 | remoteName: "comments_count", 44 | }, 45 | publishedAt: { 46 | type: "Date", 47 | remoteName: "published_at", 48 | }, 49 | published: {}, 50 | adminReview: { 51 | remoteName: "admin_review", 52 | }, 53 | adminRate: { 54 | type: "Int", 55 | remoteName: "admin_rate", 56 | }, 57 | categoryId: { 58 | type: "Int", 59 | remoteName: "category_id", 60 | }, 61 | adminReviewStartedAt: { 62 | remoteName: "admin_review_started_at", 63 | }, 64 | }; 65 | 66 | static validate = ["vulgarityLevel"]; 67 | 68 | constructor(data) { 69 | super(data); 70 | this.published = this.publishedAt !== null; 71 | this.read = this.read !== undefined; 72 | } 73 | 74 | vulgarityLevel() { 75 | if ( 76 | (this.title && /fuck/i.exec(this.title)) || 77 | (this.content && /fuck/i.exec(this.content)) 78 | ) { 79 | this.addErrorMessage("Article contains strong language.", { 80 | for: "base", 81 | }); 82 | } 83 | } 84 | 85 | setDefaultValuesForAdminReview() { 86 | this.adminRate = this.adminRate == null ? 3 : this.adminRate; 87 | this.categoryId = this.categoryId == null ? 6 : this.categoryId; 88 | this.adminReviewStartedAt = Date.now(); 89 | } 90 | } 91 | 92 | export default Article; 93 | -------------------------------------------------------------------------------- /src/js/reducers/common.js: -------------------------------------------------------------------------------- 1 | import produce from "immer"; 2 | import plural from "config/inflections"; 3 | 4 | export default (model) => { 5 | const identity = model.identity; 6 | const pluralForm = plural(identity.toLowerCase()); 7 | 8 | return produce((draft = [], action) => { 9 | switch (action.type) { 10 | case `${pluralForm.toUpperCase()}.ADD`: 11 | return draft.concat(action[pluralForm]); 12 | case `${identity.toUpperCase()}.DELETE`: 13 | return draft.filter((obj) => obj.id !== action.id); 14 | case `${identity.toUpperCase()}.REPLACE`: { 15 | const current = draft.find( 16 | (obj) => obj.id === action[identity.toLowerCase()].id 17 | ); 18 | draft[draft.indexOf(current)] = action[identity.toLowerCase()]; 19 | break; 20 | } 21 | case `${identity.toUpperCase()}.UPDATE`: { 22 | const current = draft.find((t) => t.id === action.id); 23 | if (current === undefined) return draft; 24 | const newObj = new model({ ...current }); 25 | for (const attr of Object.keys(action.changes)) { 26 | newObj[attr] = action.changes[attr][1]; 27 | } 28 | draft[draft.indexOf(current)] = newObj; 29 | break; 30 | } 31 | default: 32 | return draft; 33 | } 34 | }); 35 | }; 36 | -------------------------------------------------------------------------------- /src/js/reducers/page1/articles.js: -------------------------------------------------------------------------------- 1 | import { combineReducers } from "redux"; 2 | 3 | import common from "../common"; 4 | 5 | import Article from "models/Article"; 6 | 7 | const errorMsg = (state = null, action) => { 8 | switch (action.type) { 9 | case "ARTICLES.FETCH_FAILURE": 10 | return action.msg; 11 | default: 12 | return state; 13 | } 14 | }; 15 | 16 | const articles = combineReducers({ 17 | resources: common(Article), 18 | errorMsg, 19 | }); 20 | 21 | export default articles; 22 | -------------------------------------------------------------------------------- /src/js/reducers/page1/index.js: -------------------------------------------------------------------------------- 1 | import { combineReducers } from "redux"; 2 | 3 | import articles from "./articles"; 4 | import visibilityFilter from "./visibilityFilter"; 5 | 6 | const rootReducer = combineReducers({ 7 | articles, 8 | visibilityFilter, 9 | }); 10 | 11 | export default rootReducer; 12 | -------------------------------------------------------------------------------- /src/js/reducers/page1/visibilityFilter.js: -------------------------------------------------------------------------------- 1 | const visibilityFilter = (state = "SHOW_ALL", action) => { 2 | switch (action.type) { 3 | case "SET_VISIBILITY_FILTER": 4 | return action.filter; 5 | default: 6 | return state; 7 | } 8 | }; 9 | 10 | export default visibilityFilter; 11 | -------------------------------------------------------------------------------- /src/js/selectors/articles.js: -------------------------------------------------------------------------------- 1 | export const getVisibleArticles = (state) => { 2 | const articles = state.articles.resources; 3 | const filter = state.visibilityFilter; 4 | 5 | switch (filter) { 6 | case "SHOW_READ": 7 | return articles.filter((a) => a.read); 8 | case "SHOW_UNREAD": 9 | return articles.filter((a) => !a.read); 10 | case "SHOW_ALL": 11 | default: 12 | return articles; 13 | } 14 | }; 15 | 16 | export const anyArticles = (state) => state.articles.resources.length > 0; 17 | 18 | export const getErrorMsg = (state) => state.articles.errorMsg; 19 | -------------------------------------------------------------------------------- /src/js/services/math.js: -------------------------------------------------------------------------------- 1 | export function square(x) { 2 | return x * x; 3 | } 4 | 5 | export function cube(x) { 6 | return x * x * x; 7 | } 8 | -------------------------------------------------------------------------------- /src/js/stores/page1/store.js: -------------------------------------------------------------------------------- 1 | import { createStore, compose } from "redux"; 2 | 3 | import { isProd } from "helpers/common"; 4 | import rootReducer from "reducers/page1"; 5 | 6 | /* eslint-disable no-underscore-dangle */ 7 | const enhancers = compose( 8 | !isProd() && window.__REDUX_DEVTOOLS_EXTENSION__ 9 | ? window.__REDUX_DEVTOOLS_EXTENSION__() 10 | : (f) => f 11 | ); 12 | /* eslint-enable no-underscore-dangle */ 13 | 14 | const store = createStore(rootReducer, undefined, enhancers); 15 | 16 | export default store; 17 | -------------------------------------------------------------------------------- /src/page1.js: -------------------------------------------------------------------------------- 1 | import "core-js/stable"; 2 | import React from "react"; 3 | import { createRoot } from "react-dom/client"; 4 | import { Provider } from "react-redux"; 5 | import { 6 | createRoutesFromElements, 7 | createBrowserRouter, 8 | RouterProvider, 9 | Route, 10 | } from "react-router-dom"; 11 | 12 | import "config"; 13 | import store from "stores/page1/store"; 14 | import Root from "containers/page1/Root"; 15 | import ArticleList from "containers/page1/ArticleList"; 16 | import Assets from "components/page1/Assets"; 17 | import ErrorPage from "components/shared/ErrorPage"; 18 | 19 | import { showAll, showRead, showUnread } from "actions/page1/articles"; 20 | 21 | import "shared/index.css"; 22 | import "page1.css"; 23 | 24 | const router = createBrowserRouter( 25 | createRoutesFromElements( 26 | } errorElement={}> 27 | }> 28 | } /> 29 | } /> 30 | } loader={showAll} /> 31 | } 34 | loader={showRead} 35 | /> 36 | } 39 | loader={showUnread} 40 | /> 41 | 42 | 43 | ) 44 | ); 45 | 46 | const container = document.getElementById("root"); 47 | const root = createRoot(container); 48 | root.render( 49 | 50 | 51 | 52 | 53 | 54 | ); 55 | 56 | if (module.hot) { 57 | module.hot.accept(); 58 | } 59 | -------------------------------------------------------------------------------- /tailwind.config.js: -------------------------------------------------------------------------------- 1 | /** @type {import('tailwindcss').Config} */ 2 | 3 | const defaultTheme = require("tailwindcss/defaultTheme"); 4 | 5 | module.exports = { 6 | content: ["./public/**/*.html", "./src/**/*.js"], 7 | theme: { 8 | extend: { 9 | fontFamily: { 10 | sans: ["Roboto", ...defaultTheme.fontFamily.sans], 11 | }, 12 | }, 13 | }, 14 | plugins: [require("@tailwindcss/forms")], 15 | }; 16 | -------------------------------------------------------------------------------- /webpack.common.js: -------------------------------------------------------------------------------- 1 | const path = require("path"); 2 | const { CleanWebpackPlugin } = require("clean-webpack-plugin"); 3 | const MiniCssExtractPlugin = require("mini-css-extract-plugin"); 4 | 5 | const postcssOptions = require("./postcss.config.js"); 6 | 7 | module.exports = { 8 | resolve: { 9 | modules: [ 10 | path.join(__dirname, "src", "js"), 11 | path.join(__dirname, "src", "css"), 12 | path.join(__dirname, "src", "images"), 13 | "node_modules", 14 | ], 15 | }, 16 | entry: { 17 | index: "./src/index", 18 | page1: "./src/page1", 19 | }, 20 | module: { 21 | rules: [ 22 | { 23 | test: /\.jsx?$/, 24 | exclude: /node_modules/, 25 | use: { 26 | loader: "babel-loader", 27 | }, 28 | }, 29 | { 30 | test: /\.css$/, 31 | use: [ 32 | MiniCssExtractPlugin.loader, 33 | "css-loader", 34 | { 35 | loader: "postcss-loader", 36 | options: { postcssOptions: postcssOptions }, 37 | }, 38 | ], 39 | }, 40 | { 41 | test: /\.(png|svg|jpe?g|gif)$/i, 42 | type: "asset", 43 | }, 44 | { 45 | test: /\.(woff|woff2|eot|ttf|otf)$/, 46 | type: "asset", 47 | }, 48 | ], 49 | }, 50 | plugins: [ 51 | new CleanWebpackPlugin(), 52 | new MiniCssExtractPlugin({ 53 | filename: "[name].css", 54 | }), 55 | ], 56 | optimization: { 57 | splitChunks: { 58 | cacheGroups: { 59 | commons: { 60 | chunks: "initial", 61 | name: "commons", 62 | minChunks: 2, 63 | minSize: 5000, // The default is too small to create commons chunks 64 | }, 65 | vendor: { 66 | test: /node_modules/, 67 | chunks: "all", 68 | name: "vendor", 69 | }, 70 | }, 71 | }, 72 | }, 73 | output: { 74 | path: path.resolve(__dirname, "public/assets"), 75 | publicPath: "/assets/", 76 | }, 77 | }; 78 | -------------------------------------------------------------------------------- /webpack.dev.js: -------------------------------------------------------------------------------- 1 | const { merge } = require("webpack-merge"); 2 | 3 | const common = require("./webpack.common.js"); 4 | 5 | module.exports = merge(common, { 6 | mode: "development", 7 | devtool: "inline-source-map", 8 | }); 9 | -------------------------------------------------------------------------------- /webpack.hmr.js: -------------------------------------------------------------------------------- 1 | const webpack = require("webpack"); 2 | const { merge } = require("webpack-merge"); 3 | 4 | const common = require("./webpack.dev.js"); 5 | 6 | module.exports = merge(common, { 7 | entry: { 8 | index: ["./src/index", "webpack-hot-middleware/client"], 9 | page1: ["./src/page1", "webpack-hot-middleware/client"], 10 | }, 11 | plugins: [new webpack.HotModuleReplacementPlugin()], 12 | }); 13 | -------------------------------------------------------------------------------- /webpack.prod.js: -------------------------------------------------------------------------------- 1 | const { merge } = require("webpack-merge"); 2 | const CssMinimizerPlugin = require("css-minimizer-webpack-plugin"); 3 | const ImageMinimizerPlugin = require("image-minimizer-webpack-plugin"); 4 | 5 | const common = require("./webpack.common.js"); 6 | 7 | module.exports = merge(common, { 8 | mode: "production", 9 | devtool: "source-map", 10 | optimization: { 11 | minimize: true, 12 | minimizer: [ 13 | `...`, 14 | new CssMinimizerPlugin(), 15 | new ImageMinimizerPlugin({ 16 | minimizer: { 17 | implementation: ImageMinimizerPlugin.sharpMinify, 18 | options: { 19 | encodeOptions: { 20 | jpeg: { 21 | // https://sharp.pixelplumbing.com/api-output#jpeg 22 | quality: 100, 23 | }, 24 | webp: { 25 | // https://sharp.pixelplumbing.com/api-output#webp 26 | lossless: true, 27 | }, 28 | avif: { 29 | // https://sharp.pixelplumbing.com/api-output#avif 30 | lossless: true, 31 | }, 32 | 33 | // png by default sets the quality to 100%, which is same as lossless 34 | // https://sharp.pixelplumbing.com/api-output#png 35 | png: {}, 36 | 37 | // gif does not support lossless compression at all 38 | // https://sharp.pixelplumbing.com/api-output#gif 39 | gif: {}, 40 | }, 41 | }, 42 | }, 43 | }), 44 | new ImageMinimizerPlugin({ 45 | minimizer: { 46 | implementation: ImageMinimizerPlugin.svgoMinify, 47 | options: { 48 | encodeOptions: { 49 | // Pass over SVGs multiple times to ensure all optimizations are applied. False by default 50 | multipass: true, 51 | plugins: [ 52 | // set of built-in plugins enabled by default 53 | // see: https://github.com/svg/svgo#default-preset 54 | "preset-default", 55 | ], 56 | }, 57 | }, 58 | }, 59 | }), 60 | ], 61 | }, 62 | }); 63 | --------------------------------------------------------------------------------