├── .env.local.example ├── .eslintignore ├── .eslintrc ├── .github ├── FUNDING.yml └── workflows │ ├── ci.yml │ ├── release.yml │ ├── semgrep.yml │ └── update-deps.yml ├── .gitignore ├── .husky ├── commit-msg └── pre-commit ├── .vscode ├── extensions.json ├── launch.json ├── settings.json └── tasks.json ├── CHANGELOG.md ├── README.md ├── __mocks__ └── next │ └── router.ts ├── boilerplateLICENSE ├── commitlint.config.js ├── cypress.config.js ├── cypress ├── e2e │ └── SeoMetadata.cy.ts ├── fixtures │ └── example.json ├── support │ └── e2e.ts └── tsconfig.json ├── jest.config.js ├── jest.setup.js ├── lint-staged.config.js ├── netlify.toml ├── next-env.d.ts ├── next-sitemap.config.js ├── next.config.js ├── package-lock.json ├── package.json ├── postcss.config.js ├── public └── favicon │ ├── android-chrome-192x192.png │ ├── android-chrome-512x512.png │ ├── apple-touch-icon.png │ ├── favicon-16x16.png │ ├── favicon-32x32.png │ └── favicon.ico ├── src ├── components.test │ ├── CookieBanner.test.tsx │ └── Layout.test.tsx ├── components │ ├── CookieBanner.tsx │ ├── ExternalLink.tsx │ ├── Footer.tsx │ ├── Header.tsx │ ├── Layout.tsx │ ├── Link.tsx │ ├── Meta.tsx │ ├── PostBody.tsx │ └── Seo.tsx ├── data │ └── posts.ts ├── lib │ ├── apollo.ts │ ├── cookies.ts │ ├── datetime.ts │ └── posts.ts ├── pages.test │ ├── about.test.tsx │ ├── contact.test.tsx │ └── index.test.tsx ├── pages │ ├── _app.tsx │ ├── _document.tsx │ ├── about.tsx │ ├── api │ │ └── contact.ts │ ├── contact.tsx │ ├── index.tsx │ └── posts │ │ └── [slug].tsx ├── styles │ ├── PostBody.module.css │ ├── global.css │ └── wordpress.css └── utils │ └── AppConfig.ts ├── tailwind.config.js └── tsconfig.json /.env.local.example: -------------------------------------------------------------------------------- 1 | WORDPRESS_API_URL=https://example.eu/graphql 2 | 3 | # Only required if you want to enable preview mode 4 | # WORDPRESS_AUTH_REFRESH_TOKEN= 5 | # WORDPRESS_PREVIEW_SECRET= -------------------------------------------------------------------------------- /.eslintignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | out 3 | -------------------------------------------------------------------------------- /.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | // Configuration for JavaScript files 3 | "extends": [ 4 | "airbnb-base", 5 | "next/core-web-vitals", // Needed to avoid warning in next.js build: 'The Next.js plugin was not detected in your ESLint configuration' 6 | "plugin:prettier/recommended" 7 | ], 8 | "rules": { 9 | "prettier/prettier": [ 10 | "error", 11 | { 12 | "singleQuote": true, 13 | "endOfLine": "auto" 14 | } 15 | ] 16 | }, 17 | "overrides": [ 18 | // Configuration for TypeScript files 19 | { 20 | "files": ["**/*.ts", "**/*.tsx"], 21 | "plugins": [ 22 | "@typescript-eslint", 23 | "unused-imports", 24 | "tailwindcss", 25 | "simple-import-sort" 26 | ], 27 | "extends": [ 28 | "plugin:tailwindcss/recommended", 29 | "airbnb-typescript", 30 | "next/core-web-vitals", 31 | "plugin:prettier/recommended" 32 | ], 33 | "parserOptions": { 34 | "project": "./tsconfig.json" 35 | }, 36 | "rules": { 37 | "prettier/prettier": [ 38 | "error", 39 | { 40 | "singleQuote": true, 41 | "endOfLine": "auto" 42 | } 43 | ], 44 | "react/destructuring-assignment": "off", // Vscode doesn't support automatically destructuring, it's a pain to add a new variable 45 | "react/require-default-props": "off", // Allow non-defined react props as undefined 46 | "react/jsx-props-no-spreading": "off", // _app.tsx uses spread operator and also, react-hook-form 47 | "react-hooks/exhaustive-deps": "off", // Incorrectly report needed dependency with Next.js router 48 | "@next/next/no-img-element": "off", // We currently not using next/image because it isn't supported with SSG mode 49 | "@typescript-eslint/comma-dangle": "off", // Avoid conflict rule between Eslint and Prettier 50 | "@typescript-eslint/consistent-type-imports": "error", // Ensure `import type` is used when it's necessary 51 | "import/prefer-default-export": "off", // Named export is easier to refactor automatically 52 | "simple-import-sort/imports": "error", // Import configuration for `eslint-plugin-simple-import-sort` 53 | "simple-import-sort/exports": "error", // Export configuration for `eslint-plugin-simple-import-sort` 54 | "@typescript-eslint/no-unused-vars": "off", 55 | "unused-imports/no-unused-imports": "error", 56 | "unused-imports/no-unused-vars": [ 57 | "error", 58 | { "argsIgnorePattern": "^_" } 59 | ] 60 | } 61 | }, 62 | // Configuration for testing 63 | { 64 | "files": ["**/*.test.ts", "**/*.test.tsx"], 65 | "plugins": ["jest", "jest-formatting", "testing-library", "jest-dom"], 66 | "extends": [ 67 | "plugin:jest/recommended", 68 | "plugin:jest-formatting/recommended", 69 | "plugin:testing-library/react", 70 | "plugin:jest-dom/recommended" 71 | ] 72 | }, 73 | // Configuration for e2e testing (Cypress) 74 | { 75 | "files": ["cypress/**/*.ts"], 76 | "plugins": ["cypress"], 77 | "extends": ["plugin:cypress/recommended"], 78 | "parserOptions": { 79 | "project": "./cypress/tsconfig.json" 80 | } 81 | } 82 | ] 83 | } 84 | -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/deployn/nextjs-boilerplate-with-wordpress/0e5c9896608b74e672a068745a81bf92b5b13ff2/.github/FUNDING.yml -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: 4 | push: 5 | branches: [main] 6 | pull_request: 7 | branches: [main] 8 | 9 | jobs: 10 | build: 11 | strategy: 12 | matrix: 13 | node-version: [16.x, 18.x] 14 | # See supported Node.js release schedule at https://nodejs.org/en/about/releases/ 15 | 16 | name: Build with ${{ matrix.node-version }} 17 | runs-on: ubuntu-latest 18 | 19 | steps: 20 | - uses: actions/checkout@v3 21 | - name: Use Node.js ${{ matrix.node-version }} 22 | uses: actions/setup-node@v3 23 | with: 24 | node-version: ${{ matrix.node-version }} 25 | cache: "npm" 26 | - run: npm ci 27 | - run: npm run build-prod 28 | env: 29 | WORDPRESS_API_URL: ${{ secrets.WORDPRESS_API_URL }} 30 | 31 | test: 32 | strategy: 33 | matrix: 34 | node-version: [18.x] 35 | 36 | name: Run all tests 37 | runs-on: ubuntu-latest 38 | 39 | steps: 40 | - uses: actions/checkout@v3 41 | with: 42 | fetch-depth: 0 # Retrieve Git history, needed to verify commits 43 | - name: Use Node.js ${{ matrix.node-version }} 44 | uses: actions/setup-node@v3 45 | with: 46 | node-version: ${{ matrix.node-version }} 47 | cache: "npm" 48 | - run: npm ci 49 | 50 | - if: github.event_name == 'pull_request' 51 | name: Validate all commits from PR 52 | run: npx commitlint --from ${{ github.event.pull_request.base.sha }} --to ${{ github.event.pull_request.head.sha }} --verbose 53 | 54 | - name: Linter 55 | run: npm run lint 56 | 57 | - name: Type checking 58 | run: npm run check-types 59 | 60 | - name: Run unit tests 61 | run: npm run test 62 | 63 | - name: Run e2e tests 64 | run: npx percy exec -- npm run e2e:headless 65 | env: 66 | PERCY_TOKEN: ${{ secrets.PERCY_TOKEN }} 67 | WORDPRESS_API_URL: ${{ secrets.WORDPRESS_API_URL }} 68 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Release 2 | 3 | on: 4 | workflow_run: 5 | workflows: ["CI"] 6 | types: 7 | - completed 8 | branches: 9 | - main 10 | 11 | jobs: 12 | release: 13 | strategy: 14 | matrix: 15 | node-version: [18.x] 16 | 17 | name: Create a new release 18 | runs-on: ubuntu-latest 19 | 20 | steps: 21 | - uses: actions/checkout@v3 22 | with: 23 | fetch-depth: 0 24 | - name: Use Node.js ${{ matrix.node-version }} 25 | uses: actions/setup-node@v3 26 | with: 27 | node-version: ${{ matrix.node-version }} 28 | cache: "npm" 29 | - run: HUSKY=0 npm ci 30 | 31 | - name: Release 32 | env: 33 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 34 | NPM_TOKEN: ${{ secrets.NPM_TOKEN }} 35 | run: npx semantic-release 36 | -------------------------------------------------------------------------------- /.github/workflows/semgrep.yml: -------------------------------------------------------------------------------- 1 | name: Semgrep 2 | 3 | on: 4 | pull_request: {} 5 | push: 6 | branches: 7 | - main 8 | - master 9 | paths: 10 | - .github/workflows/semgrep.yml 11 | schedule: 12 | # random HH:MM to avoid a load spike on GitHub Actions at 00:00 13 | - cron: 33 19 * * * 14 | 15 | jobs: 16 | semgrep: 17 | name: Scan 18 | runs-on: ubuntu-20.04 19 | env: 20 | SEMGREP_APP_TOKEN: ${{ secrets.SEMGREP_APP_TOKEN }} 21 | container: 22 | image: returntocorp/semgrep 23 | steps: 24 | - uses: actions/checkout@v3 25 | - run: semgrep ci 26 | -------------------------------------------------------------------------------- /.github/workflows/update-deps.yml: -------------------------------------------------------------------------------- 1 | name: Update dependencies 2 | 3 | on: 4 | workflow_dispatch: 5 | schedule: 6 | - cron: "0 0 1 * *" 7 | 8 | jobs: 9 | update: 10 | strategy: 11 | matrix: 12 | node-version: [18.x] 13 | 14 | name: Update all dependencies 15 | runs-on: ubuntu-latest 16 | 17 | steps: 18 | - uses: actions/checkout@v3 19 | - name: Use Node.js ${{ matrix.node-version }} 20 | uses: actions/setup-node@v3 21 | with: 22 | node-version: ${{ matrix.node-version }} 23 | cache: "npm" 24 | - run: npm ci 25 | 26 | - run: npx npm-check-updates -u # Update dependencies 27 | - run: rm -Rf node_modules package-lock.json 28 | - run: npm install 29 | - name: Create Pull Request 30 | uses: peter-evans/create-pull-request@v4 31 | with: 32 | commit-message: "build: update dependencies to the latest version" 33 | title: Update dependencies to the latest version 34 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | /.pnp 6 | .pnp.js 7 | 8 | # testing 9 | /coverage 10 | 11 | # cypress 12 | cypress/screenshots 13 | cypress/videos 14 | 15 | # next.js 16 | /.next 17 | /out 18 | 19 | # next-sitemap 20 | public/robots.txt 21 | public/sitemap.xml 22 | public/sitemap-*.xml 23 | 24 | # cache 25 | .swc/ 26 | 27 | # production 28 | /build 29 | 30 | # misc 31 | .DS_Store 32 | *.pem 33 | Thumbs.db 34 | 35 | # debug 36 | npm-debug.log* 37 | pnpm-debug.log* 38 | yarn-debug.log* 39 | yarn-error.log* 40 | 41 | # dotenv local files 42 | .env*.local 43 | 44 | # local folder 45 | local 46 | 47 | # vercel 48 | .vercel 49 | -------------------------------------------------------------------------------- /.husky/commit-msg: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | . "$(dirname "$0")/_/husky.sh" 3 | 4 | npx --no -- commitlint --edit $1 5 | -------------------------------------------------------------------------------- /.husky/pre-commit: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | . "$(dirname "$0")/_/husky.sh" 3 | 4 | # Disable concurent to run `check-types` after ESLint in lint-staged 5 | npx lint-staged --concurrent false 6 | -------------------------------------------------------------------------------- /.vscode/extensions.json: -------------------------------------------------------------------------------- 1 | { 2 | "recommendations": [ 3 | "dbaeumer.vscode-eslint", 4 | "esbenp.prettier-vscode", 5 | "mikestead.dotenv", 6 | "csstools.postcss", 7 | "bradlc.vscode-tailwindcss", 8 | "Orta.vscode-jest" 9 | ] 10 | } 11 | -------------------------------------------------------------------------------- /.vscode/launch.json: -------------------------------------------------------------------------------- 1 | { 2 | // Use IntelliSense to learn about possible attributes. 3 | // Hover to view descriptions of existing attributes. 4 | // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 5 | "version": "0.2.0", 6 | "configurations": [ 7 | { 8 | "type": "chrome", 9 | "request": "launch", 10 | "name": "Next: Chrome", 11 | "url": "http://localhost:3000", 12 | "webRoot": "${workspaceFolder}" 13 | }, 14 | { 15 | "type": "node", 16 | "request": "launch", 17 | "name": "Next: Node", 18 | "program": "${workspaceFolder}/node_modules/.bin/next", 19 | "args": ["dev"], 20 | "autoAttachChildProcesses": true, 21 | "skipFiles": ["/**"], 22 | "console": "integratedTerminal" 23 | } 24 | ], 25 | "compounds": [ 26 | { 27 | "name": "Next: Full", 28 | "configurations": ["Next: Node", "Next: Chrome"] 29 | } 30 | ] 31 | } 32 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "editor.tabSize": 2, 3 | "editor.detectIndentation": false, 4 | "search.exclude": { 5 | "package-lock.json": true 6 | }, 7 | "editor.defaultFormatter": "dbaeumer.vscode-eslint", 8 | "editor.formatOnSave": false, 9 | "editor.codeActionsOnSave": [ 10 | "source.addMissingImports", 11 | "source.fixAll.eslint" 12 | ], 13 | "jest.autoRun": { 14 | "watch": false // Start the jest with the watch flag 15 | // "onStartup": ["all-tests"] // Run all tests upon project launch 16 | }, 17 | "jest.showCoverageOnLoad": true, // Show code coverage when the project is launched 18 | "jest.autoRevealOutput": "on-exec-error", // Don't automatically open test explorer terminal on launch 19 | // Multiple language settings for json and jsonc files 20 | "[json][jsonc][yaml]": { 21 | "editor.formatOnSave": true, 22 | "editor.defaultFormatter": "esbenp.prettier-vscode" 23 | }, 24 | "prettier.ignorePath": ".gitignore" // Don't run prettier for files listed in .gitignore 25 | } 26 | -------------------------------------------------------------------------------- /.vscode/tasks.json: -------------------------------------------------------------------------------- 1 | { 2 | // See https://go.microsoft.com/fwlink/?LinkId=733558 3 | // for the documentation about the tasks.json format 4 | "version": "2.0.0", 5 | "tasks": [ 6 | { 7 | "label": "Project wide type checking with TypeScript", 8 | "type": "npm", 9 | "script": "check-types", 10 | "problemMatcher": ["$tsc"], 11 | "group": { 12 | "kind": "build", 13 | "isDefault": true 14 | }, 15 | "presentation": { 16 | "clear": true, 17 | "reveal": "never" 18 | } 19 | } 20 | ] 21 | } 22 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # [2.0.0](https://github.com/deployn/nextjs-boilerplate-with-wordpress/compare/v1.16.0...v2.0.0) (2023-04-07) 2 | 3 | 4 | ### Continuous Integration 5 | 6 | * drop node 14 support and update deps and main test to node 18 ([a8aa340](https://github.com/deployn/nextjs-boilerplate-with-wordpress/commit/a8aa3406f6fc86b64a518608a27acbc18b57bb0f)) 7 | 8 | 9 | ### BREAKING CHANGES 10 | 11 | * no test for node 14 12 | 13 | # [1.16.0](https://github.com/deployn/nextjs-boilerplate-with-wordpress/compare/v1.15.4...v1.16.0) (2023-03-31) 14 | 15 | 16 | ### Features 17 | 18 | * add some wp content styles ([778fc57](https://github.com/deployn/nextjs-boilerplate-with-wordpress/commit/778fc57b1927e606eae5477071c6c19c2bd720e2)) 19 | 20 | ## [1.15.4](https://github.com/deployn/nextjs-boilerplate-with-wordpress/compare/v1.15.3...v1.15.4) (2023-03-09) 21 | 22 | 23 | ### Bug Fixes 24 | 25 | * remove protocol from plausible domain ([d6b4f51](https://github.com/deployn/nextjs-boilerplate-with-wordpress/commit/d6b4f51308f2d1b0b0596c2bca7d59ff7e070372)), closes [#24](https://github.com/deployn/nextjs-boilerplate-with-wordpress/issues/24) 26 | 27 | ## [1.15.3](https://github.com/deployn/nextjs-boilerplate-with-wordpress/compare/v1.15.2...v1.15.3) (2023-03-07) 28 | 29 | 30 | ### Bug Fixes 31 | 32 | * prevent hydration error on index page ([ae5f893](https://github.com/deployn/nextjs-boilerplate-with-wordpress/commit/ae5f893d72960dfdd0cecf145e62bec51a970b13)) 33 | 34 | ## [1.15.2](https://github.com/deployn/nextjs-boilerplate-with-wordpress/compare/v1.15.1...v1.15.2) (2023-03-03) 35 | 36 | 37 | ### Bug Fixes 38 | 39 | * update for build-in next/font ([c1f863b](https://github.com/deployn/nextjs-boilerplate-with-wordpress/commit/c1f863b3dd7843f43ee1ef2f6fb5b3cf67c168b7)) 40 | 41 | ## [1.15.1](https://github.com/deployn/nextjs-boilerplate-with-wordpress/compare/v1.15.0...v1.15.1) (2023-02-24) 42 | 43 | 44 | ### Bug Fixes 45 | 46 | * **tailwind config:** move plugins outside of theme ([e75a1b6](https://github.com/deployn/nextjs-boilerplate-with-wordpress/commit/e75a1b68901142e6bd3c4d2a81eabf5c72bfe295)) 47 | 48 | # [1.15.0](https://github.com/deployn/nextjs-boilerplate-with-wordpress/compare/v1.14.0...v1.15.0) (2023-02-22) 49 | 50 | 51 | ### Features 52 | 53 | * add some wordpress styles ([949d17a](https://github.com/deployn/nextjs-boilerplate-with-wordpress/commit/949d17a6c96ed077bb77b22e670ec0d9ac5551d4)) 54 | * sort by date ([64e8efb](https://github.com/deployn/nextjs-boilerplate-with-wordpress/commit/64e8efb4af2a051def373f9ec693e74f1cf12cc1)) 55 | * update wordpress post fetching ([97a47be](https://github.com/deployn/nextjs-boilerplate-with-wordpress/commit/97a47be7670c3df5f799367b39e2fae8a13d06ef)) 56 | 57 | 58 | ### Reverts 59 | 60 | * **Link:** don't handle wpdomain links as internal ([3eec669](https://github.com/deployn/nextjs-boilerplate-with-wordpress/commit/3eec6693563ebc0d07f996e3a04a47ca9d854d32)) 61 | 62 | # [1.14.0](https://github.com/deployn/nextjs-boilerplate-with-wordpress/compare/v1.13.0...v1.14.0) (2023-02-20) 63 | 64 | 65 | ### Features 66 | 67 | * add contact form ([bf3f9c6](https://github.com/deployn/nextjs-boilerplate-with-wordpress/commit/bf3f9c6b049bce20bb288162ea5f315a609afbaf)) 68 | 69 | # [1.13.0](https://github.com/deployn/nextjs-boilerplate-with-wordpress/compare/v1.12.0...v1.13.0) (2023-02-19) 70 | 71 | 72 | ### Features 73 | 74 | * load youtube video only after visitor gave consent ([0b95fa1](https://github.com/deployn/nextjs-boilerplate-with-wordpress/commit/0b95fa153c99d5ef03cfbefab78a543ba478d6ac)) 75 | 76 | # [1.12.0](https://github.com/deployn/nextjs-boilerplate-with-wordpress/compare/v1.11.0...v1.12.0) (2023-02-17) 77 | 78 | 79 | ### Features 80 | 81 | * add support for selfhosted plausible or umami ([6b311e0](https://github.com/deployn/nextjs-boilerplate-with-wordpress/commit/6b311e0e186db800dcfe61493a46ba3d18e77c0f)) 82 | 83 | 84 | ### Reverts 85 | 86 | * **deps:** revert fsevents ([ec44eec](https://github.com/deployn/nextjs-boilerplate-with-wordpress/commit/ec44eec42be36c2b5276bd25586e048f7f998241)) 87 | 88 | # [1.11.0](https://github.com/deployn/nextjs-boilerplate-with-wordpress/compare/v1.10.0...v1.11.0) (2023-02-04) 89 | 90 | 91 | ### Bug Fixes 92 | 93 | * **Link:** update link component to use internel link when starts with wpDomain ([b1282a1](https://github.com/deployn/nextjs-boilerplate-with-wordpress/commit/b1282a1c4de88820952b5a4c9bcfec592cd74129)) 94 | 95 | 96 | ### Features 97 | 98 | * **_app:** wrap into apolloclient ([6acc681](https://github.com/deployn/nextjs-boilerplate-with-wordpress/commit/6acc681f6bc7bc57634a2c8f77308fb301d5c10c)) 99 | * **apollo:** add page to test the postlist ([5d8a2d4](https://github.com/deployn/nextjs-boilerplate-with-wordpress/commit/5d8a2d4b1548acefb9f69150be43e6869620860a)) 100 | * **Apolloclient:** add apolloclient ([7371f81](https://github.com/deployn/nextjs-boilerplate-with-wordpress/commit/7371f8135bce14346b36cf42b49490ca846285a9)) 101 | * **Link:** treat link to wp domain as internal ([b16aff6](https://github.com/deployn/nextjs-boilerplate-with-wordpress/commit/b16aff618d71e77a2ca8cb7d580ea077fcc4d472)) 102 | * **next.config.js:** image optimisation for wordpress images ([d20e664](https://github.com/deployn/nextjs-boilerplate-with-wordpress/commit/d20e664b0a4acd1759ad76b77e9fbb54fc5206f1)) 103 | * **PostBody:** transform into ([3bf3f00](https://github.com/deployn/nextjs-boilerplate-with-wordpress/commit/3bf3f00aa3ce2a30156f74968c257bf43a4d25ed)) 104 | * **PostBody:** use htmlreactparser instead of dangerouslysetinnerhtml ([1deb264](https://github.com/deployn/nextjs-boilerplate-with-wordpress/commit/1deb264dd04e354a45f2ffb171def8f40e65443c)) 105 | * **PostList:** add a postlist component to fetch 10 posts with apollo ([dd5a1e3](https://github.com/deployn/nextjs-boilerplate-with-wordpress/commit/dd5a1e33240de8e318fd951f073c0005f4c6abb9)) 106 | * **PostList:** add html react parser ([ccda68a](https://github.com/deployn/nextjs-boilerplate-with-wordpress/commit/ccda68a48e98a0e91f8e4bc42a4cbefc06e3ded2)) 107 | * **wordpress.css:** add flex to column blocks ([26dea44](https://github.com/deployn/nextjs-boilerplate-with-wordpress/commit/26dea44b693a55b9565285856e689cd9e707315a)) 108 | 109 | # [1.10.0](https://github.com/deployn/nextjs-boilerplate-with-wordpress/compare/v1.9.0...v1.10.0) (2023-02-01) 110 | 111 | 112 | ### Features 113 | 114 | * **Link:** link components uses internal link when it's the domain in the appconfig ([229c7b0](https://github.com/deployn/nextjs-boilerplate-with-wordpress/commit/229c7b0d0332c8da0e1525abeeb1976bd1daca7d)) 115 | 116 | # [1.9.0](https://github.com/deployn/nextjs-boilerplate-with-wordpress/compare/v1.8.0...v1.9.0) (2023-01-31) 117 | 118 | 119 | ### Features 120 | 121 | * add impulse.dev for live editing of tailwind classes ([e49d3c9](https://github.com/deployn/nextjs-boilerplate-with-wordpress/commit/e49d3c90dcfbac6ef3bc95f111df79f3ec1cc3b5)) 122 | 123 | # [1.8.0](https://github.com/deployn/nextjs-boilerplate-with-wordpress/compare/v1.7.0...v1.8.0) (2023-01-28) 124 | 125 | 126 | ### Features 127 | 128 | * add local google fonts ([4cc2cf2](https://github.com/deployn/nextjs-boilerplate-with-wordpress/commit/4cc2cf2fa23f0c3e0c9e22f8225b670100cd46c5)) 129 | * **index.tsx:** add some fonts for testing purpose ([13c109f](https://github.com/deployn/nextjs-boilerplate-with-wordpress/commit/13c109f8b706a1b0e7f1f0cb6f8c485d84c15ce2)) 130 | 131 | # [1.7.0](https://github.com/deployn/nextjs-boilerplate-with-wordpress/compare/v1.6.0...v1.7.0) (2023-01-24) 132 | 133 | 134 | ### Bug Fixes 135 | 136 | * **Layout.tsx:** wrap content into a grid to stick footer to have the footer at the bottom ([9ca4584](https://github.com/deployn/nextjs-boilerplate-with-wordpress/commit/9ca4584ac1305885754b35eb3e1f8f3729c22d5a)) 137 | 138 | 139 | ### Features 140 | 141 | * **Footer.tsx:** add footer component ([feda0fa](https://github.com/deployn/nextjs-boilerplate-with-wordpress/commit/feda0fa9e79abc4342dc470479b3607317cae29d)) 142 | * **Footer.tsx:** make footer a little bit brighter for more contrast with the cookiebanner ([1ea425b](https://github.com/deployn/nextjs-boilerplate-with-wordpress/commit/1ea425bb77299cbc43c74bcd8aa67fef624c9572)) 143 | 144 | # [1.6.0](https://github.com/deployn/nextjs-boilerplate-with-wordpress/compare/v1.5.0...v1.6.0) (2023-01-24) 145 | 146 | 147 | ### Features 148 | 149 | * **index.tsx:** add some styles ([988b4a0](https://github.com/deployn/nextjs-boilerplate-with-wordpress/commit/988b4a0ded130866940a7437dc5e845c36e70a55)) 150 | 151 | # [1.5.0](https://github.com/deployn/nextjs-boilerplate-with-wordpress/compare/v1.4.0...v1.5.0) (2023-01-24) 152 | 153 | 154 | ### Features 155 | 156 | * **CookieBanner.tsx:** add cookiebanner component ([b8e9577](https://github.com/deployn/nextjs-boilerplate-with-wordpress/commit/b8e95774a9a4e88ef80ddbe8b27b4ad5ba5b66c5)) 157 | * **Index.tsx:** show on index page if cookies were accepted ([e50ed82](https://github.com/deployn/nextjs-boilerplate-with-wordpress/commit/e50ed829bfd667a40b89cd77cc3d1068e2f50372)) 158 | * **Layout.tsx:** add cookiebanner to layout ([bf1100b](https://github.com/deployn/nextjs-boilerplate-with-wordpress/commit/bf1100b46a9e5b3ca820a90bd660427ea243aa78)) 159 | 160 | # [1.4.0](https://github.com/deployn/nextjs-boilerplate-with-wordpress/compare/v1.3.0...v1.4.0) (2023-01-22) 161 | 162 | 163 | ### Features 164 | 165 | * **Header.tsx:** show active route ([2dabfe4](https://github.com/deployn/nextjs-boilerplate-with-wordpress/commit/2dabfe4c2e5d932effb8b27c541b3aecb4c955b8)) 166 | 167 | # [1.3.0](https://github.com/deployn/nextjs-boilerplate-with-wordpress/compare/v1.2.0...v1.3.0) (2023-01-22) 168 | 169 | 170 | ### Features 171 | 172 | * **pages:** bigger heading ([409ff5b](https://github.com/deployn/nextjs-boilerplate-with-wordpress/commit/409ff5bf45993a0511e8d879993bfcda6abf6903)) 173 | 174 | # [1.2.0](https://github.com/deployn/nextjs-boilerplate-with-wordpress/compare/v1.1.0...v1.2.0) (2023-01-22) 175 | 176 | 177 | ### Features 178 | 179 | * create about and contact page for navigation ([932fee7](https://github.com/deployn/nextjs-boilerplate-with-wordpress/commit/932fee7cabb9487e39c1418ada2475fd6d33fc59)) 180 | * **Header.tsx:** create header component ([ccd17d7](https://github.com/deployn/nextjs-boilerplate-with-wordpress/commit/ccd17d72a71ed7173a5915ceb9226402617bc701)) 181 | * **Layout.tsx:** add header to layout ([08b30e0](https://github.com/deployn/nextjs-boilerplate-with-wordpress/commit/08b30e0a94fc9117998ced28fabe033245c81db4)) 182 | * **package.json:** install headlessui and heroicons ([368c247](https://github.com/deployn/nextjs-boilerplate-with-wordpress/commit/368c24742a9ae95738b00f9bab6e482315565d24)) 183 | * **posts/[slug].tsx:** use layout instead of postlayout ([c3cc0b9](https://github.com/deployn/nextjs-boilerplate-with-wordpress/commit/c3cc0b9baf91bbec7ea0bc462d5b1425480f2984)) 184 | 185 | # [1.1.0](https://github.com/deployn/nextjs-boilerplate-with-wordpress/compare/v1.0.0...v1.1.0) (2023-01-17) 186 | 187 | 188 | ### Bug Fixes 189 | 190 | * add seoprops type ([4a7bbf6](https://github.com/deployn/nextjs-boilerplate-with-wordpress/commit/4a7bbf6816775daef00cb9117ed8c68a6fa31090)) 191 | * **index.tsx:** remove dublicate link import to use custom link component ([0b7d55a](https://github.com/deployn/nextjs-boilerplate-with-wordpress/commit/0b7d55ab1a237b8c7c8e45ce313d279f93a34ff2)) 192 | * **PostBody.tsx:** add typecheck ([a52bb7c](https://github.com/deployn/nextjs-boilerplate-with-wordpress/commit/a52bb7c40bfd5830d7075a16b1bd640bf0cd78fa)) 193 | * **posts/[slug]:** moved layout component, add postlayout component ([9dd59c9](https://github.com/deployn/nextjs-boilerplate-with-wordpress/commit/9dd59c9efcb3adc1a8f974ea1031436e6ec0516a)) 194 | * **posts/[slug]:** use custom link component ([1cd3121](https://github.com/deployn/nextjs-boilerplate-with-wordpress/commit/1cd312106e4136f85f1fc1d406ffb74a42ff4d60)) 195 | 196 | 197 | ### Features 198 | 199 | * add routing to posts and post page ([591047a](https://github.com/deployn/nextjs-boilerplate-with-wordpress/commit/591047ab94754a45e26ca98cecf2644443748793)) 200 | * **PostBody:** create postbody component with some styles ([98d1086](https://github.com/deployn/nextjs-boilerplate-with-wordpress/commit/98d10864e785490d25d429e699d922733fc30eef)) 201 | 202 | # 1.0.0 (2023-01-16) 203 | 204 | 205 | ### Features 206 | 207 | * **Link:** add custom link component ([b35e597](https://github.com/deployn/nextjs-boilerplate-with-wordpress/commit/b35e5979c9cdcf56554815d286ab3a30ea9fbdc9)) 208 | * **tailwind.config.js:** extend tailwind config with primary color ([69f06f8](https://github.com/deployn/nextjs-boilerplate-with-wordpress/commit/69f06f8858d766a909e25492b725c80b5dbf1262)) 209 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Boilerplate for Next JS 13+, Tailwind CSS 3.3, TypeScript and WordPress 2 | 3 | 🚀 Boilerplate and Starter for Next.js, Tailwind CSS, TypeScript and WordPress 4 | ⚡️ Made with developer experience first: Next.js, TypeScript, ESLint, Prettier, Husky, Lint-Staged, Jest, Testing Library, Commitlint, VSCode, Netlify, PostCSS, Tailwind CSS. 5 | 6 | ### Attribution 7 | 8 | Based on [this Nextjs-Boilerplate](https://github.com/ixartz/Next-js-Boilerplate) and [this Nextjs-with-Wordpress and Apollo example](https://github.com/vercel/next.js/tree/canary/examples/cms-wordpress) and [this Next-wordpress-starter](https://github.com/colbyfayock/next-wordpress-starter) 9 | 10 | Some elements from [Tailwind UI](https://tailwindui.com/) 11 | 12 | ### Features 13 | 14 | Developer experience first: 15 | 16 | - ⚡ [Next.js](https://nextjs.org) for Static Site Generator 17 | - 🔥 Type checking [TypeScript](https://www.typescriptlang.org) 18 | - 💎 Integrate with [Tailwind CSS](https://tailwindcss.com) 19 | - ✅ Strict Mode for TypeScript and React 18 20 | - 📏 Linter with [ESLint](https://eslint.org) (default NextJS, NextJS Core Web Vitals, Tailwind CSS and Airbnb configuration) 21 | - 💖 Code Formatter with [Prettier](https://prettier.io) 22 | - 🦊 Husky for Git Hooks 23 | - 🚫 Lint-staged for running linters on Git staged files 24 | - 🚓 Lint git commit with Commitlint 25 | - 📓 Write standard compliant commit messages with Commitizen 26 | - 🦺 Unit Testing with Jest and React Testing Library 27 | - 🧪 E2E Testing with Cypress 28 | - 👷 Run tests on pull request with GitHub Actions 29 | - 🎁 Automatic changelog generation with Semantic Release 30 | - 🔍 Visual testing with Percy (Optional) 31 | - 💡 Absolute Imports using `@` prefix 32 | - 🗂 VSCode configuration: Debug, Settings, Tasks and extension for PostCSS, ESLint, Prettier, TypeScript, Jest 33 | - 🤖 SEO metadata, JSON-LD and Open Graph tags with Next SEO 34 | - 🗺️ Sitemap.xml and robots.txt with next-sitemap 35 | - ⚙️ [Bundler Analyzer](https://www.npmjs.com/package/@next/bundle-analyzer) 36 | - 💯 Maximize lighthouse score 37 | - ✍️ Blogposts with headless Wordpress 38 | - ✅ Live editing of Tailwind CSS classes with [Impulse](https://impulse.dev) 39 | - 📊 Analytics with selfhosted [Plausible](https://plausible.io) or [Umami](https://umami.is) 40 | 41 | Built-in feature from Next.js: 42 | 43 | - ☕ Minify HTML & CSS 44 | - 💨 Live reload 45 | - ✅ Cache busting 46 | 47 | ### Philosophy 48 | 49 | - Minimal code 50 | - SEO-friendly 51 | - 🚀 Production-ready 52 | 53 | ### Requirements 54 | 55 | - Node.js 16+ and npm 56 | - WordPress CMS with GraphQL endpoint 57 | 58 | ### Commit Message Format 59 | 60 | The project enforces [Conventional Commits](https://www.conventionalcommits.org/) specification. This means that all your commit messages must be formatted according to the specification. To help you write commit messages, the project uses [Commitizen](https://github.com/commitizen/cz-cli), an interactive CLI that guides you through the commit process. To use it, run the following command: 61 | 62 | ```shell 63 | npm run commit 64 | ``` 65 | 66 | One of the benefits of using Conventional Commits is that it allows us to automatically generate a `CHANGELOG` file. It also allows us to automatically determine the next version number based on the types of commits that are included in a release. 67 | 68 | ### Deploy to production 69 | 70 | You can see the results locally in production mode with: 71 | 72 | ```shell 73 | $ npm run build 74 | $ npm run start 75 | ``` 76 | 77 | The generated HTML and CSS files are minified (built-in feature from Next js). It will also removed unused CSS from [Tailwind CSS](https://tailwindcss.com). 78 | 79 | You can create an optimized production build with: 80 | 81 | ```shell 82 | npm run build-prod 83 | ``` 84 | 85 | Now, your blog is ready to be deployed. All generated files are located at `out` folder, which you can deploy with any hosting service. 86 | 87 | ### Testing 88 | 89 | All tests are colocated with the source code inside the same directory. So, it makes it easier to find them. Unfortunately, it is not possible with the `pages` folder which is used by Next.js for routing. So, what is why we have a `pages.test` folder to write tests from files located in `pages` folder. 90 | 91 | ### VSCode information (optional) 92 | 93 | If you are VSCode users, you can have a better integration with VSCode by installing the suggested extension in `.vscode/extension.json`. The starter code comes up with Settings for a seamless integration with VSCode. The Debug configuration is also provided for frontend and backend debugging experience. 94 | 95 | With the plugins installed on your VSCode, ESLint and Prettier can automatically fix the code and show you the errors. Same goes for testing, you can install VSCode Jest extension to automatically run your tests and it also show the code coverage in context. 96 | 97 | Pro tips: if you need a project wide type checking with TypeScript, you can run a build with Cmd + Shift + B on Mac. 98 | 99 | ### Contributions 100 | 101 | Everyone is welcome to contribute to this project. Feel free to open an issue if you have question or found a bug. Totally open to any suggestions and improvements. 102 | 103 | --- 104 | -------------------------------------------------------------------------------- /__mocks__/next/router.ts: -------------------------------------------------------------------------------- 1 | // The easiest solution to mock `next/router`: https://github.com/vercel/next.js/issues/7479 2 | export const useRouter = () => { 3 | return { 4 | basePath: '.', 5 | }; 6 | }; 7 | -------------------------------------------------------------------------------- /boilerplateLICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020 Remi W. 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 | -------------------------------------------------------------------------------- /commitlint.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { extends: ['@commitlint/config-conventional'] }; 2 | -------------------------------------------------------------------------------- /cypress.config.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable import/no-extraneous-dependencies */ 2 | const { defineConfig } = require('cypress'); 3 | 4 | module.exports = defineConfig({ 5 | e2e: { 6 | baseUrl: 'http://localhost:3000', 7 | }, 8 | }); 9 | -------------------------------------------------------------------------------- /cypress/e2e/SeoMetadata.cy.ts: -------------------------------------------------------------------------------- 1 | describe('Seo metadata', () => { 2 | describe('Verify SEO Metadata', () => { 3 | it('should render SEO metadata on Index page', () => { 4 | cy.visit('/'); 5 | 6 | // The Index page should have a page title 7 | cy.title().should('not.be.empty'); 8 | 9 | // The Index page should also contain a meta description for SEO 10 | cy.get('head meta[name="description"]') 11 | .invoke('attr', 'content') 12 | .should('not.be.empty'); 13 | }); 14 | }); 15 | }); 16 | -------------------------------------------------------------------------------- /cypress/fixtures/example.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "Using fixtures to represent data", 3 | "email": "hello@cypress.io", 4 | "body": "Fixtures are a great way to mock data for responses to routes" 5 | } 6 | -------------------------------------------------------------------------------- /cypress/support/e2e.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable import/no-extraneous-dependencies */ 2 | // *********************************************************** 3 | // This example support/e2e.ts is processed and 4 | // loaded automatically before your test files. 5 | // 6 | // This is a great place to put global configuration and 7 | // behavior that modifies Cypress. 8 | // 9 | // You can change the location of this file or turn off 10 | // automatically serving support files with the 11 | // 'supportFile' configuration option. 12 | // 13 | // You can read more here: 14 | // https://on.cypress.io/configuration 15 | // *********************************************************** 16 | 17 | // Import commands.js using ES2015 syntax: 18 | import '@testing-library/cypress/add-commands'; 19 | import '@percy/cypress'; 20 | 21 | // Alternatively you can use CommonJS syntax: 22 | // require('./commands') 23 | -------------------------------------------------------------------------------- /cypress/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../tsconfig.json", 3 | "compilerOptions": { 4 | "target": "es5", 5 | "lib": ["es5", "dom"], 6 | "types": ["node", "cypress", "@percy/cypress", "@testing-library/cypress"], 7 | 8 | "isolatedModules": false 9 | }, 10 | "include": ["**/*.ts"], 11 | "exclude": [] 12 | } 13 | -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | const nextJest = require('next/jest'); 2 | 3 | const createJestConfig = nextJest({ 4 | // Provide the path to your Next.js app to load next.config.js and .env files in your test environment 5 | dir: './', 6 | }); 7 | 8 | const customJestConfig = { 9 | moduleNameMapper: { 10 | // Handle module aliases (this will be automatically configured for you soon) 11 | '^@/(.*)$': '/src/$1', 12 | 13 | '^@/public/(.*)$': '/public/$1', 14 | }, 15 | setupFilesAfterEnv: ['./jest.setup.js'], 16 | clearMocks: true, 17 | collectCoverage: true, 18 | collectCoverageFrom: [ 19 | './src/**/*.{js,jsx,ts,tsx}', 20 | '!./src/**/_*.{js,jsx,ts,tsx}', 21 | '!**/*.d.ts', 22 | '!**/node_modules/**', 23 | ], 24 | coverageThreshold: { 25 | global: { 26 | branches: 25, 27 | functions: 25, 28 | lines: 25, 29 | statements: 25, 30 | }, 31 | }, 32 | testEnvironment: 'jest-environment-jsdom', 33 | }; 34 | 35 | module.exports = createJestConfig(customJestConfig); 36 | -------------------------------------------------------------------------------- /jest.setup.js: -------------------------------------------------------------------------------- 1 | // Optional: configure or set up a testing framework before each test. 2 | // If you delete this file, remove `setupFilesAfterEnv` from `jest.config.js` 3 | import '@testing-library/jest-dom/extend-expect'; 4 | -------------------------------------------------------------------------------- /lint-staged.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | '*.{js,jsx,ts,tsx}': ['eslint --fix', 'eslint'], 3 | '**/*.ts?(x)': () => 'npm run check-types', 4 | '*.{json,yaml}': ['prettier --write'], 5 | }; 6 | -------------------------------------------------------------------------------- /netlify.toml: -------------------------------------------------------------------------------- 1 | [build] 2 | publish = "out" 3 | command = "npm run build-prod" 4 | 5 | [build.environment] 6 | NETLIFY_NEXT_PLUGIN_SKIP = "true" 7 | -------------------------------------------------------------------------------- /next-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | /// 3 | 4 | // NOTE: This file should not be edited 5 | // see https://nextjs.org/docs/basic-features/typescript for more information. 6 | -------------------------------------------------------------------------------- /next-sitemap.config.js: -------------------------------------------------------------------------------- 1 | /** @type {import('next-sitemap').IConfig} */ 2 | module.exports = { 3 | siteUrl: 'https://example.com', // FIXME: Change to the production URL 4 | generateRobotsTxt: true, 5 | }; 6 | -------------------------------------------------------------------------------- /next.config.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable import/no-extraneous-dependencies */ 2 | const withBundleAnalyzer = require('@next/bundle-analyzer')({ 3 | enabled: process.env.ANALYZE === 'true', 4 | }); 5 | 6 | module.exports = withBundleAnalyzer({ 7 | eslint: { 8 | dirs: ['.'], 9 | }, 10 | poweredByHeader: false, 11 | trailingSlash: true, 12 | basePath: '', 13 | // The starter code load resources from `public` folder with `router.basePath` in React components. 14 | // So, the source code is "basePath-ready". 15 | // You can remove `basePath` if you don't need it. 16 | reactStrictMode: true, 17 | images: { 18 | domains: [ 19 | process.env.WORDPRESS_API_URL && 20 | process.env.WORDPRESS_API_URL.match(/(?!(w+)\.)\w*(?:\w+\.)+\w+/) 21 | ? process.env.WORDPRESS_API_URL.match( 22 | /(?!(w+)\.)\w*(?:\w+\.)+\w+/ 23 | )[0].replace('/graphql', '') 24 | : '', 25 | '0.gravatar.com', 26 | '1.gravatar.com', 27 | '2.gravatar.com', 28 | 'secure.gravatar.com', 29 | ], 30 | }, 31 | }); 32 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "next-js-boilerplate-with-wordpress", 3 | "version": "2.0.0", 4 | "scripts": { 5 | "dev": "next dev", 6 | "build": "next build", 7 | "start": "next start", 8 | "build-stats": "cross-env ANALYZE=true npm run build", 9 | "export": "next export", 10 | "build-prod": "run-s clean build export", 11 | "clean": "rimraf .next out", 12 | "lint": "next lint", 13 | "format": "next lint --fix && prettier '**/*.{json,yaml}' --write --ignore-path .gitignore", 14 | "check-types": "tsc --noEmit --pretty && tsc --project cypress --noEmit --pretty", 15 | "test": "jest", 16 | "commit": "cz", 17 | "cypress": "cypress open", 18 | "cypress:headless": "cypress run", 19 | "e2e": "start-server-and-test dev http://localhost:3000 cypress", 20 | "e2e:headless": "start-server-and-test dev http://localhost:3000 cypress:headless", 21 | "prepare": "husky install", 22 | "postbuild": "next-sitemap" 23 | }, 24 | "dependencies": { 25 | "@apollo/client": "^3.7.10", 26 | "@headlessui/react": "^1.7.13", 27 | "@heroicons/react": "^2.0.16", 28 | "date-fns": "^2.29.3", 29 | "deepmerge": "^4.3.1", 30 | "dompurify": "^3.0.1", 31 | "graphql": "^16.6.0", 32 | "html-react-parser": "^3.0.15", 33 | "isomorphic-dompurify": "^1.2.0", 34 | "lodash": "^4.17.21", 35 | "next": "^13.2.1", 36 | "next-plausible": "^3.7.2", 37 | "next-seo": "^5.15.0", 38 | "next-sitemap": "^3.1.52", 39 | "react": "^18.2.0", 40 | "react-dom": "^18.2.0", 41 | "react-hook-form": "^7.43.8" 42 | }, 43 | "devDependencies": { 44 | "@commitlint/cli": "^17.4.4", 45 | "@commitlint/config-conventional": "^17.4.4", 46 | "@commitlint/cz-commitlint": "^17.4.4", 47 | "@impulse.dev/runtime": "^0.4.5", 48 | "@next/bundle-analyzer": "^13.2.1", 49 | "@percy/cli": "^1.20.0", 50 | "@percy/cypress": "^3.1.2", 51 | "@semantic-release/changelog": "^6.0.2", 52 | "@semantic-release/git": "^10.0.1", 53 | "@testing-library/cypress": "^9.0.0", 54 | "@testing-library/jest-dom": "^5.16.5", 55 | "@testing-library/react": "^14.0.0", 56 | "@types/date-fns": "^2.6.0", 57 | "@types/dompurify": "^3.0.0", 58 | "@types/jest": "^29.4.0", 59 | "@types/lodash": "^4.14.192", 60 | "@types/node": "^18.14.1", 61 | "@types/react": "^18.0.28", 62 | "@typescript-eslint/eslint-plugin": "^5.53.0", 63 | "@typescript-eslint/parser": "^5.53.0", 64 | "autoprefixer": "^10.4.13", 65 | "commitizen": "^4.3.0", 66 | "cross-env": "^7.0.3", 67 | "cssnano": "^5.1.15", 68 | "cypress": "^12.6.0", 69 | "eslint": "^8.34.0", 70 | "eslint-config-airbnb-base": "^15.0.0", 71 | "eslint-config-airbnb-typescript": "^17.0.0", 72 | "eslint-config-next": "^13.2.1", 73 | "eslint-config-prettier": "^8.6.0", 74 | "eslint-plugin-cypress": "^2.12.1", 75 | "eslint-plugin-import": "^2.27.5", 76 | "eslint-plugin-jest": "^27.2.1", 77 | "eslint-plugin-jest-dom": "^4.0.3", 78 | "eslint-plugin-jest-formatting": "^3.1.0", 79 | "eslint-plugin-jsx-a11y": "^6.7.1", 80 | "eslint-plugin-prettier": "^4.2.1", 81 | "eslint-plugin-react": "^7.32.2", 82 | "eslint-plugin-react-hooks": "^4.6.0", 83 | "eslint-plugin-simple-import-sort": "^10.0.0", 84 | "eslint-plugin-tailwindcss": "^3.9.0", 85 | "eslint-plugin-testing-library": "^5.10.2", 86 | "eslint-plugin-unused-imports": "^2.0.0", 87 | "husky": "^8.0.3", 88 | "jest": "^29.4.3", 89 | "jest-environment-jsdom": "^29.4.3", 90 | "lint-staged": "^13.1.2", 91 | "npm-run-all": "^4.1.5", 92 | "postcss": "^8.4.21", 93 | "prettier": "^2.8.4", 94 | "rimraf": "^4.1.2", 95 | "semantic-release": "^19.0.5", 96 | "start-server-and-test": "^1.15.4", 97 | "tailwindcss": "^3.3.1", 98 | "typescript": "^4.9.5" 99 | }, 100 | "config": { 101 | "commitizen": { 102 | "path": "@commitlint/cz-commitlint" 103 | } 104 | }, 105 | "release": { 106 | "branches": [ 107 | "main" 108 | ], 109 | "plugins": [ 110 | "@semantic-release/commit-analyzer", 111 | "@semantic-release/release-notes-generator", 112 | "@semantic-release/changelog", 113 | [ 114 | "@semantic-release/npm", 115 | { 116 | "npmPublish": false 117 | } 118 | ], 119 | "@semantic-release/git", 120 | "@semantic-release/github" 121 | ] 122 | }, 123 | "author": "Deployn (https://github.com/deployn)" 124 | } 125 | -------------------------------------------------------------------------------- /postcss.config.js: -------------------------------------------------------------------------------- 1 | // Please do not use the array form (like ['tailwindcss', 'postcss-preset-env']) 2 | // it will create an unexpected error: Invalid PostCSS Plugin found: [0] 3 | 4 | module.exports = { 5 | plugins: { 6 | tailwindcss: {}, 7 | autoprefixer: {}, 8 | ...(process.env.NODE_ENV === 'production' ? { cssnano: {} } : {}), 9 | }, 10 | }; 11 | -------------------------------------------------------------------------------- /public/favicon/android-chrome-192x192.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/deployn/nextjs-boilerplate-with-wordpress/0e5c9896608b74e672a068745a81bf92b5b13ff2/public/favicon/android-chrome-192x192.png -------------------------------------------------------------------------------- /public/favicon/android-chrome-512x512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/deployn/nextjs-boilerplate-with-wordpress/0e5c9896608b74e672a068745a81bf92b5b13ff2/public/favicon/android-chrome-512x512.png -------------------------------------------------------------------------------- /public/favicon/apple-touch-icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/deployn/nextjs-boilerplate-with-wordpress/0e5c9896608b74e672a068745a81bf92b5b13ff2/public/favicon/apple-touch-icon.png -------------------------------------------------------------------------------- /public/favicon/favicon-16x16.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/deployn/nextjs-boilerplate-with-wordpress/0e5c9896608b74e672a068745a81bf92b5b13ff2/public/favicon/favicon-16x16.png -------------------------------------------------------------------------------- /public/favicon/favicon-32x32.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/deployn/nextjs-boilerplate-with-wordpress/0e5c9896608b74e672a068745a81bf92b5b13ff2/public/favicon/favicon-32x32.png -------------------------------------------------------------------------------- /public/favicon/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/deployn/nextjs-boilerplate-with-wordpress/0e5c9896608b74e672a068745a81bf92b5b13ff2/public/favicon/favicon.ico -------------------------------------------------------------------------------- /src/components.test/CookieBanner.test.tsx: -------------------------------------------------------------------------------- 1 | import { render, screen } from '@testing-library/react'; 2 | 3 | import CookieBanner from '@/components/CookieBanner'; 4 | 5 | describe('CookieBanner component', () => { 6 | describe('Render method', () => { 7 | it('should have at least one button', () => { 8 | render(); 9 | 10 | const button = screen.getAllByRole('button')[0]; 11 | 12 | expect(button).toBeInTheDocument(); 13 | }); 14 | }); 15 | }); 16 | -------------------------------------------------------------------------------- /src/components.test/Layout.test.tsx: -------------------------------------------------------------------------------- 1 | import { render, waitFor } from '@testing-library/react'; 2 | import type { ReactNode } from 'react'; 3 | 4 | import Layout from '@/components/Layout'; 5 | 6 | // Mock `next/head`: https://bradgarropy.com/blog/mocking-nextjs 7 | jest.mock( 8 | 'next/head', 9 | () => 10 | function Head(props: { children: ReactNode }) { 11 | // eslint-disable-next-line testing-library/no-node-access 12 | return <>{props.children}; 13 | } 14 | ); 15 | 16 | describe('Layout component', () => { 17 | describe('Render method', () => { 18 | it('should a page title', async () => { 19 | const title = 'Random title'; 20 | 21 | render( 22 | 23 | Test 24 | 25 | ); 26 | 27 | await waitFor(() => { 28 | expect(document.title).toEqual(title); 29 | }); 30 | }); 31 | }); 32 | }); 33 | -------------------------------------------------------------------------------- /src/components/CookieBanner.tsx: -------------------------------------------------------------------------------- 1 | import { useEffect, useState } from 'react'; 2 | 3 | import { getCookie, setCookie } from '@/lib/cookies'; 4 | 5 | const CookieBanner = () => { 6 | const [showBanner, setShowBanner] = useState(false); 7 | 8 | useEffect(() => { 9 | const cookie = getCookie('acceptedCookies'); 10 | if (!cookie) { 11 | setShowBanner(true); 12 | } 13 | }, []); 14 | 15 | const handleAccept = () => { 16 | setCookie('acceptedCookies', true, 30); 17 | setShowBanner(false); 18 | }; 19 | 20 | const handleDecline = () => { 21 | setCookie('acceptedCookies', false, 30); 22 | setShowBanner(false); 23 | }; 24 | 25 | return ( 26 |
31 |
32 |
33 |
34 |

35 | Our website uses cookies. Do you accept all cookies? 36 |

37 |
38 |
39 | 47 | 55 |
56 |
57 |
58 |
59 | ); 60 | }; 61 | 62 | export default CookieBanner; 63 | -------------------------------------------------------------------------------- /src/components/ExternalLink.tsx: -------------------------------------------------------------------------------- 1 | const style = { 2 | base: 'text-blue-500 hover:text-blue-700 underline hover:no-underline transition duration-150 ease-in-out cursor-pointer focus:underline focus:text-blue-700 focus:shadow-outline focus:shadow-outline-blue after:content-["_↗"]', 3 | }; 4 | 5 | type ExternalLinkProps = { 6 | href: string; 7 | children: React.ReactNode; 8 | className?: string; 9 | }; 10 | 11 | const ExternalLink = ({ 12 | href, 13 | children, 14 | className, 15 | ...props 16 | }: ExternalLinkProps) => { 17 | const classes = [style.base, className].join(' '); 18 | return ( 19 | // eslint-disable-next-line react/jsx-no-target-blank 20 |
27 | {children} 28 | 29 | ); 30 | }; 31 | 32 | export default ExternalLink; 33 | -------------------------------------------------------------------------------- /src/components/Footer.tsx: -------------------------------------------------------------------------------- 1 | import Link from '@/components/Link'; 2 | 3 | const Footer = () => { 4 | return ( 5 | 39 | ); 40 | }; 41 | 42 | export default Footer; 43 | -------------------------------------------------------------------------------- /src/components/Header.tsx: -------------------------------------------------------------------------------- 1 | import { Popover, Transition } from '@headlessui/react'; 2 | import { Bars3Icon, XMarkIcon } from '@heroicons/react/24/outline'; 3 | import { useRouter } from 'next/router'; 4 | import { Fragment } from 'react'; 5 | 6 | import Link from '@/components/Link'; 7 | 8 | export default function Header() { 9 | const router = useRouter(); 10 | const { pathname } = router; 11 | 12 | return ( 13 | 14 |
15 |
16 |
17 | 18 | Home 19 | 32 | 37 | 38 | 39 |
40 |
41 | 42 | Open menu 43 | 45 |
46 | 72 |
73 |
74 | 75 | 84 | 88 |
89 |
90 |
91 |
92 | 93 | 106 | 111 | 112 | 113 |
114 |
115 | 116 | Close menu 117 | 119 |
120 |
121 |
122 |
123 |
124 | 125 | 133 | About 134 | 135 | 136 | 137 | 138 | 146 | Contact 147 | 148 | 149 |
150 |
151 |
152 |
153 |
154 |
155 | ); 156 | } 157 | -------------------------------------------------------------------------------- /src/components/Layout.tsx: -------------------------------------------------------------------------------- 1 | import { Assistant, Playfair_Display } from 'next/font/google'; 2 | 3 | import CookieBanner from '@/components/CookieBanner'; 4 | import Footer from '@/components/Footer'; 5 | import Header from '@/components/Header'; 6 | import Meta from '@/components/Meta'; 7 | 8 | const primaryFont = Assistant({ 9 | subsets: ['latin'], 10 | variable: '--font-primary', 11 | }); 12 | 13 | const headingFont = Playfair_Display({ 14 | subsets: ['latin'], 15 | variable: '--font-heading', 16 | }); 17 | 18 | type LayoutProps = { 19 | title?: string; 20 | description?: string; 21 | image?: string; 22 | children: React.ReactNode; 23 | }; 24 | 25 | type PostLayoutProps = { 26 | title: string; 27 | description?: string; 28 | image?: string; 29 | children: React.ReactNode; 30 | }; 31 | 32 | const Layout = ({ children, ...props }: LayoutProps) => { 33 | return ( 34 |
35 | 36 |
37 |
38 |
39 |
{children}
40 |
41 |
42 |
43 | 44 |
45 | ); 46 | }; 47 | 48 | export default Layout; 49 | 50 | const PostLayout = ({ children, ...props }: PostLayoutProps) => { 51 | return ( 52 | <> 53 | 54 |
{children}
55 | 56 | ); 57 | }; 58 | 59 | export { PostLayout }; 60 | -------------------------------------------------------------------------------- /src/components/Link.tsx: -------------------------------------------------------------------------------- 1 | import NextLink from 'next/link'; 2 | 3 | import ExternalLink from '@/components/ExternalLink'; 4 | import { AppConfig } from '@/utils/AppConfig'; 5 | 6 | const domain = AppConfig.siteUrl; 7 | 8 | const style = { 9 | base: 'text-primary-500 hover:text-primary-700 transition duration-150 ease-in-out cursor-pointer focus:underline focus:text-primary-700 focus:shadow-outline focus:shadow-outline-blue', 10 | }; 11 | 12 | type LinkProps = { 13 | href: string; 14 | children: React.ReactNode | React.ReactNode[] | string; 15 | className?: string; 16 | }; 17 | 18 | const Link = ({ href, children, className, ...props }: LinkProps) => { 19 | const classes = [style.base, className].join(' '); 20 | 21 | if ( 22 | href.toString().startsWith('/') || 23 | href.toString().startsWith(domain) || 24 | href.toString().startsWith('http://localhost') 25 | ) { 26 | return ( 27 | 28 | {children} 29 | 30 | ); 31 | } 32 | return ( 33 | 34 | {children} 35 | 36 | ); 37 | }; 38 | 39 | export default Link; 40 | -------------------------------------------------------------------------------- /src/components/Meta.tsx: -------------------------------------------------------------------------------- 1 | import Head from 'next/head'; 2 | import { useRouter } from 'next/router'; 3 | 4 | import Seo from '@/components/Seo'; 5 | 6 | type SeoProps = { 7 | title?: string; 8 | description?: string; 9 | image?: string; 10 | }; 11 | 12 | const Meta = ({ ...props }: SeoProps) => { 13 | const router = useRouter(); 14 | return ( 15 | <> 16 | 17 | 18 | 23 | 28 | 35 | 42 | 47 | 54 | 61 | 62 | 63 | 64 | ); 65 | }; 66 | 67 | export default Meta; 68 | -------------------------------------------------------------------------------- /src/components/PostBody.tsx: -------------------------------------------------------------------------------- 1 | /* eslint-disable consistent-return */ 2 | /* eslint-disable no-param-reassign */ 3 | import type { DOMNode, Element } from 'html-react-parser'; 4 | import parse, { domToReact } from 'html-react-parser'; 5 | import Dompurify from 'isomorphic-dompurify'; 6 | 7 | import styles from '@/styles/PostBody.module.css'; 8 | 9 | import Link from './Link'; 10 | 11 | type PostBodyProps = { 12 | content: string; 13 | }; 14 | 15 | function domNodeIsElement(domNode: DOMNode): domNode is Element { 16 | return domNode.type === 'tag'; 17 | } 18 | 19 | const parser = (input: string) => 20 | parse(input, { 21 | replace: (domNode) => { 22 | if (!domNodeIsElement(domNode)) return; 23 | if (domNode.name === 'a' && domNode.attribs.href) { 24 | return ( 25 | 26 | {domToReact(domNode.children)} 27 | 28 | ); 29 | } 30 | return domNode; 31 | }, 32 | }); 33 | 34 | export default function PostBody({ content }: PostBodyProps) { 35 | return ( 36 |
37 |
38 | {parser(Dompurify.sanitize(content))} 39 |
40 |
41 | ); 42 | } 43 | -------------------------------------------------------------------------------- /src/components/Seo.tsx: -------------------------------------------------------------------------------- 1 | import { NextSeo } from 'next-seo'; 2 | 3 | import { AppConfig } from '@/utils/AppConfig'; 4 | 5 | type SeoProps = { 6 | title?: string; 7 | description?: string; 8 | canonical?: string; 9 | url?: string; 10 | creator?: string; 11 | }; 12 | 13 | const Seo = ({ ...props }: SeoProps) => { 14 | return ( 15 | 32 | ); 33 | }; 34 | 35 | export default Seo; 36 | -------------------------------------------------------------------------------- /src/data/posts.ts: -------------------------------------------------------------------------------- 1 | import { gql } from '@apollo/client'; 2 | 3 | export const POST_FIELDS = gql` 4 | fragment PostFields on Post { 5 | id 6 | categories { 7 | edges { 8 | node { 9 | databaseId 10 | id 11 | name 12 | slug 13 | } 14 | } 15 | } 16 | databaseId 17 | date 18 | isSticky 19 | postId 20 | slug 21 | title 22 | } 23 | `; 24 | 25 | export const QUERY_ALL_POSTS_INDEX = gql` 26 | ${POST_FIELDS} 27 | query AllPostsIndex { 28 | posts(first: 10000, where: { hasPassword: false }) { 29 | edges { 30 | node { 31 | ...PostFields 32 | } 33 | } 34 | } 35 | } 36 | `; 37 | 38 | export const QUERY_ALL_POSTS_ARCHIVE = gql` 39 | ${POST_FIELDS} 40 | query AllPostsArchive { 41 | posts(first: 10000, where: { hasPassword: false }) { 42 | edges { 43 | node { 44 | ...PostFields 45 | author { 46 | node { 47 | avatar { 48 | height 49 | url 50 | width 51 | } 52 | id 53 | name 54 | slug 55 | } 56 | } 57 | excerpt 58 | } 59 | } 60 | } 61 | } 62 | `; 63 | 64 | export const QUERY_ALL_POSTS = gql` 65 | ${POST_FIELDS} 66 | query AllPosts { 67 | posts(first: 10000, where: { hasPassword: false }) { 68 | edges { 69 | node { 70 | ...PostFields 71 | author { 72 | node { 73 | avatar { 74 | height 75 | url 76 | width 77 | } 78 | id 79 | name 80 | slug 81 | } 82 | } 83 | content 84 | excerpt 85 | featuredImage { 86 | node { 87 | altText 88 | caption 89 | sourceUrl 90 | srcSet 91 | sizes 92 | id 93 | } 94 | } 95 | modified 96 | } 97 | } 98 | } 99 | } 100 | `; 101 | 102 | export const QUERY_POST_BY_SLUG = gql` 103 | query PostBySlug($slug: ID!) { 104 | post(id: $slug, idType: SLUG) { 105 | author { 106 | node { 107 | avatar { 108 | height 109 | url 110 | width 111 | } 112 | id 113 | name 114 | slug 115 | } 116 | } 117 | id 118 | categories { 119 | edges { 120 | node { 121 | databaseId 122 | id 123 | name 124 | slug 125 | } 126 | } 127 | } 128 | content 129 | date 130 | excerpt 131 | featuredImage { 132 | node { 133 | altText 134 | caption 135 | sourceUrl 136 | srcSet 137 | sizes 138 | id 139 | } 140 | } 141 | modified 142 | databaseId 143 | title 144 | slug 145 | isSticky 146 | } 147 | } 148 | `; 149 | 150 | export const QUERY_POSTS_BY_CATEGORY_ID_INDEX = gql` 151 | ${POST_FIELDS} 152 | query PostsByCategoryId($categoryId: Int!) { 153 | posts(where: { categoryId: $categoryId, hasPassword: false }) { 154 | edges { 155 | node { 156 | ...PostFields 157 | } 158 | } 159 | } 160 | } 161 | `; 162 | 163 | export const QUERY_POSTS_BY_CATEGORY_ID_ARCHIVE = gql` 164 | ${POST_FIELDS} 165 | query PostsByCategoryId($categoryId: Int!) { 166 | posts(where: { categoryId: $categoryId, hasPassword: false }) { 167 | edges { 168 | node { 169 | ...PostFields 170 | author { 171 | node { 172 | avatar { 173 | height 174 | url 175 | width 176 | } 177 | id 178 | name 179 | slug 180 | } 181 | } 182 | excerpt 183 | } 184 | } 185 | } 186 | } 187 | `; 188 | 189 | export const QUERY_POSTS_BY_CATEGORY_ID = gql` 190 | ${POST_FIELDS} 191 | query PostsByCategoryId($categoryId: Int!) { 192 | posts(where: { categoryId: $categoryId, hasPassword: false }) { 193 | edges { 194 | node { 195 | ...PostFields 196 | author { 197 | node { 198 | avatar { 199 | height 200 | url 201 | width 202 | } 203 | id 204 | name 205 | slug 206 | } 207 | } 208 | content 209 | excerpt 210 | featuredImage { 211 | node { 212 | altText 213 | caption 214 | id 215 | sizes 216 | sourceUrl 217 | srcSet 218 | } 219 | } 220 | modified 221 | } 222 | } 223 | } 224 | } 225 | `; 226 | 227 | export const QUERY_POSTS_BY_AUTHOR_SLUG_INDEX = gql` 228 | ${POST_FIELDS} 229 | query PostByAuthorSlugIndex($slug: String!) { 230 | posts(where: { authorName: $slug, hasPassword: false }) { 231 | edges { 232 | node { 233 | ...PostFields 234 | } 235 | } 236 | } 237 | } 238 | `; 239 | 240 | export const QUERY_POSTS_BY_AUTHOR_SLUG_ARCHIVE = gql` 241 | ${POST_FIELDS} 242 | query PostByAuthorSlugArchive($slug: String!) { 243 | posts(where: { authorName: $slug, hasPassword: false }) { 244 | edges { 245 | node { 246 | ...PostFields 247 | excerpt 248 | } 249 | } 250 | } 251 | } 252 | `; 253 | 254 | export const QUERY_POSTS_BY_AUTHOR_SLUG = gql` 255 | ${POST_FIELDS} 256 | query PostByAuthorSlug($slug: String!) { 257 | posts(where: { authorName: $slug, hasPassword: false }) { 258 | edges { 259 | node { 260 | ...PostFields 261 | excerpt 262 | featuredImage { 263 | node { 264 | altText 265 | caption 266 | id 267 | sizes 268 | sourceUrl 269 | srcSet 270 | } 271 | } 272 | modified 273 | } 274 | } 275 | } 276 | } 277 | `; 278 | 279 | export const QUERY_POST_SEO_BY_SLUG = gql` 280 | query PostSEOBySlug($slug: ID!) { 281 | post(id: $slug, idType: SLUG) { 282 | id 283 | seo { 284 | canonical 285 | metaDesc 286 | metaRobotsNofollow 287 | metaRobotsNoindex 288 | opengraphAuthor 289 | opengraphDescription 290 | opengraphModifiedTime 291 | opengraphPublishedTime 292 | opengraphPublisher 293 | opengraphTitle 294 | opengraphType 295 | readingTime 296 | title 297 | twitterDescription 298 | twitterTitle 299 | twitterImage { 300 | altText 301 | sourceUrl 302 | mediaDetails { 303 | width 304 | height 305 | } 306 | } 307 | opengraphImage { 308 | altText 309 | sourceUrl 310 | mediaDetails { 311 | height 312 | width 313 | } 314 | } 315 | } 316 | } 317 | } 318 | `; 319 | 320 | export const QUERY_POST_PER_PAGE = gql` 321 | query PostPerPage { 322 | allSettings { 323 | readingSettingsPostsPerPage 324 | } 325 | } 326 | `; 327 | -------------------------------------------------------------------------------- /src/lib/apollo.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable no-param-reassign */ 2 | /* eslint-disable @typescript-eslint/naming-convention */ 3 | /* eslint-disable no-underscore-dangle */ 4 | import { ApolloClient, from, HttpLink, InMemoryCache } from '@apollo/client'; 5 | import { concatPagination } from '@apollo/client/utilities'; 6 | import merge from 'deepmerge'; 7 | import isEqual from 'lodash/isEqual'; 8 | import { useMemo } from 'react'; 9 | 10 | export const APOLLO_STATE_PROP_NAME = '__APOLLO_STATE__'; 11 | 12 | let apolloClient: ApolloClient; 13 | 14 | const httpLink = new HttpLink({ 15 | uri: process.env.WORDPRESS_API_URL, // Server URL (must be absolute) 16 | credentials: 'same-origin', // Additional fetch() options like `credentials` or `headers` 17 | }); 18 | 19 | function createApolloClient() { 20 | return new ApolloClient({ 21 | ssrMode: typeof window === 'undefined', 22 | link: from([httpLink]), 23 | cache: new InMemoryCache({ 24 | typePolicies: { 25 | Query: { 26 | fields: { 27 | allPosts: concatPagination(), 28 | }, 29 | }, 30 | }, 31 | }), 32 | }); 33 | } 34 | 35 | export function initializeApollo(initialState = null) { 36 | const _apolloClient = apolloClient ?? createApolloClient(); 37 | 38 | // If your page has Next.js data fetching methods that use Apollo Client, the initial state 39 | // gets hydrated here 40 | if (initialState) { 41 | // Get existing cache, loaded during client side data fetching 42 | const existingCache = _apolloClient.extract(); 43 | 44 | // Merge the initialState from getStaticProps/getServerSideProps in the existing cache 45 | const data = merge( 46 | existingCache as Record, 47 | initialState as Record, 48 | { 49 | // combine arrays using object equality (like in sets) 50 | arrayMerge: (destinationArray, sourceArray) => [ 51 | ...sourceArray, 52 | ...destinationArray.filter((d) => 53 | sourceArray.every((s) => !isEqual(d, s)) 54 | ), 55 | ], 56 | } 57 | ); 58 | 59 | // Restore the cache with the merged data 60 | _apolloClient.cache.restore(data); 61 | } 62 | // For SSG and SSR always create a new Apollo Client 63 | if (typeof window === 'undefined') return _apolloClient; 64 | // Create the Apollo Client once in the client 65 | if (!apolloClient) apolloClient = _apolloClient; 66 | 67 | return _apolloClient; 68 | } 69 | 70 | export function addApolloState(client: any, pageProps: any) { 71 | const extractedState = client.cache.extract(); 72 | if (pageProps?.props) { 73 | pageProps.props[APOLLO_STATE_PROP_NAME] = extractedState; 74 | } 75 | 76 | return pageProps; 77 | } 78 | 79 | export function useApollo(pageProps: any) { 80 | const state = pageProps[APOLLO_STATE_PROP_NAME]; 81 | const store = useMemo(() => initializeApollo(state), [state]); 82 | return store; 83 | } 84 | -------------------------------------------------------------------------------- /src/lib/cookies.ts: -------------------------------------------------------------------------------- 1 | export const getCookie = (name: any) => { 2 | const value = `; ${document.cookie}`; 3 | const parts = value.split(`; ${name}=`); 4 | if (parts.length === 2) { 5 | const cookie = parts.pop(); 6 | if (cookie) { 7 | return cookie.split(';').shift(); 8 | } 9 | return null; 10 | } 11 | return null; 12 | }; 13 | 14 | export const setCookie = (name: string, value: any, days: number) => { 15 | const date = new Date(); 16 | date.setTime(date.getTime() + days * 24 * 60 * 60 * 1000); 17 | const expires = `expires=${date.toUTCString()}`; 18 | document.cookie = `${name}=${value};${expires};path=/`; 19 | }; 20 | 21 | export const deleteCookie = (name: any) => { 22 | document.cookie = `${name}=; Max-Age=-99999999;`; 23 | }; 24 | -------------------------------------------------------------------------------- /src/lib/datetime.ts: -------------------------------------------------------------------------------- 1 | import { format } from 'date-fns'; 2 | 3 | export function formatDate(date: any, pattern = 'PPP') { 4 | return format(new Date(date), pattern); 5 | } 6 | 7 | export function sortObjectsByDate(array: any, { key = 'date' } = {}) { 8 | return array.sort((a: any, b: any) => { 9 | const dateA = new Date(a[key]).valueOf(); 10 | const dateB = new Date(b[key]).valueOf(); 11 | 12 | return dateB - dateA; 13 | }); 14 | } 15 | -------------------------------------------------------------------------------- /src/lib/posts.ts: -------------------------------------------------------------------------------- 1 | import { 2 | QUERY_ALL_POSTS, 3 | QUERY_ALL_POSTS_ARCHIVE, 4 | QUERY_ALL_POSTS_INDEX, 5 | QUERY_POST_BY_SLUG, 6 | } from '@/data/posts'; 7 | import { initializeApollo } from '@/lib/apollo'; 8 | 9 | /** 10 | * postPathBySlug 11 | */ 12 | 13 | export function postPathBySlug(slug: string): string { 14 | return `/posts/${slug}`; 15 | } 16 | 17 | /** 18 | * mapPostData 19 | */ 20 | 21 | type Post = { 22 | author?: { 23 | node?: 24 | | { 25 | avatar?: 26 | | { 27 | height?: number | undefined; 28 | url?: string | undefined; 29 | width?: number | undefined; 30 | } 31 | | undefined; 32 | id?: string | undefined; 33 | name?: string | undefined; 34 | slug?: string | undefined; 35 | } 36 | | undefined; 37 | avatar?: { 38 | height?: number; 39 | url?: string; 40 | width?: number; 41 | }; 42 | id?: string; 43 | name?: string; 44 | slug?: string; 45 | }; 46 | categories?: { 47 | edges?: { 48 | node?: { 49 | id?: string; 50 | name?: string; 51 | slug?: string; 52 | }; 53 | }[]; 54 | }; 55 | content?: string; 56 | date?: string; 57 | excerpt?: string; 58 | id?: string; 59 | slug?: string; 60 | title?: string; 61 | featuredImage?: { 62 | node?: { 63 | altText?: string; 64 | caption?: string; 65 | sourceUrl?: string; 66 | srcSet?: string; 67 | sizes?: string; 68 | id?: string; 69 | }; 70 | }; 71 | modified?: string; 72 | databaseId?: number; 73 | isSticky?: boolean; 74 | }; 75 | 76 | export function mapPostData(post: Post = {}): Post { 77 | const data = { ...post }; 78 | 79 | // Clean up the author object to avoid someone having to look an extra 80 | // level deeper into the node 81 | 82 | if (data.author) { 83 | data.author = { 84 | ...data.author.node, 85 | }; 86 | } 87 | 88 | return data; 89 | } 90 | 91 | /** 92 | * getPostBySlug 93 | */ 94 | 95 | export async function getPostBySlug(slug: string) { 96 | const apolloClient = initializeApollo(); 97 | const wpDomain = process.env.WORDPRESS_API_URL; 98 | if (!wpDomain) { 99 | throw new Error( 100 | 'WORDPRESS_API_URL is not defined in environment variables' 101 | ); 102 | } 103 | 104 | let postData; 105 | 106 | try { 107 | postData = await apolloClient.query({ 108 | query: QUERY_POST_BY_SLUG, 109 | variables: { 110 | slug, 111 | }, 112 | }); 113 | } catch (error) { 114 | return { post: undefined }; 115 | } 116 | 117 | if (!postData?.data.post) return { post: undefined }; 118 | 119 | const post = [postData?.data.post].map(mapPostData)[0]; 120 | 121 | return { 122 | post, 123 | }; 124 | } 125 | 126 | /** 127 | * getAllPosts 128 | */ 129 | 130 | const allPostsIncludesTypes = { 131 | all: QUERY_ALL_POSTS, 132 | archive: QUERY_ALL_POSTS_ARCHIVE, 133 | index: QUERY_ALL_POSTS_INDEX, 134 | }; 135 | 136 | type Options = { 137 | queryIncludes?: 'all' | 'archive' | 'index'; 138 | }; 139 | 140 | export async function getAllPosts(options: Options = {}) { 141 | const { queryIncludes = 'index' } = options; 142 | 143 | const apolloClient = initializeApollo(); 144 | 145 | const data = await apolloClient.query({ 146 | query: allPostsIncludesTypes[queryIncludes], 147 | }); 148 | 149 | const posts = data?.data.posts.edges.map(({ node = {} }) => node); 150 | 151 | return { 152 | posts: Array.isArray(posts) && posts.map(mapPostData), 153 | }; 154 | } 155 | -------------------------------------------------------------------------------- /src/pages.test/about.test.tsx: -------------------------------------------------------------------------------- 1 | import { render, screen } from '@testing-library/react'; 2 | 3 | import About from '@/pages/about'; 4 | 5 | // The easiest solution to mock `next/router`: https://github.com/vercel/next.js/issues/7479 6 | // The mock has been moved to `__mocks__` folder to avoid duplication 7 | 8 | describe('Contact page', () => { 9 | describe('Render method', () => { 10 | it('should have h1 tag', () => { 11 | render(); 12 | 13 | const heading = screen.getByRole('heading', { level: 1 }); 14 | 15 | expect(heading).toBeInTheDocument(); 16 | }); 17 | }); 18 | }); 19 | -------------------------------------------------------------------------------- /src/pages.test/contact.test.tsx: -------------------------------------------------------------------------------- 1 | import { render, screen } from '@testing-library/react'; 2 | 3 | import Contact from '@/pages/contact'; 4 | 5 | // The easiest solution to mock `next/router`: https://github.com/vercel/next.js/issues/7479 6 | // The mock has been moved to `__mocks__` folder to avoid duplication 7 | 8 | describe('Contact page', () => { 9 | describe('Render method', () => { 10 | it('should have h1 tag', () => { 11 | render(); 12 | 13 | const heading = screen.getByRole('heading', { level: 1 }); 14 | 15 | expect(heading).toBeInTheDocument(); 16 | }); 17 | }); 18 | 19 | describe('Form', () => { 20 | it('should have input for name', () => { 21 | render(); 22 | 23 | const input = screen.getByLabelText(/name/i); 24 | 25 | expect(input).toBeInTheDocument(); 26 | }); 27 | 28 | it('should have input for email', () => { 29 | render(); 30 | 31 | const input = screen.getByLabelText(/email/i); 32 | 33 | expect(input).toBeInTheDocument(); 34 | }); 35 | 36 | it('should have input for message', () => { 37 | render(); 38 | 39 | const input = screen.getByLabelText(/message/i); 40 | 41 | expect(input).toBeInTheDocument(); 42 | }); 43 | }); 44 | 45 | describe('Form validation', () => { 46 | it('should have error message if empty', async () => { 47 | render(); 48 | 49 | const submitButton = screen.getByRole('button', { name: /submit/i }); 50 | 51 | submitButton.click(); 52 | 53 | const errorMessages = await screen.findAllByText( 54 | /This field is required/i 55 | ); 56 | 57 | const errorMessage = errorMessages[0]; 58 | 59 | expect(errorMessage).toBeInTheDocument(); 60 | }); 61 | }); 62 | }); 63 | -------------------------------------------------------------------------------- /src/pages.test/index.test.tsx: -------------------------------------------------------------------------------- 1 | import { render, screen } from '@testing-library/react'; 2 | 3 | import Index from '@/pages/index'; 4 | 5 | jest.mock('@/lib/posts', () => ({ 6 | getAllPosts: jest.fn(() => Promise.resolve({ posts: [] })), 7 | })); 8 | 9 | jest.mock('@/lib/apollo', () => ({ 10 | initializeApollo: jest.fn(() => ({ 11 | query: jest.fn(() => Promise.resolve({ data: {} })), 12 | })), 13 | })); 14 | 15 | describe('Index', () => { 16 | it('renders without crashing', async () => { 17 | render(); 18 | expect(screen.getByText('Index')).toBeInTheDocument(); 19 | }); 20 | 21 | it('renders more posts', async () => { 22 | const morePosts = [ 23 | { 24 | title: 'More Post 1', 25 | slug: 'more-post-1', 26 | }, 27 | { 28 | title: 'More Post 2', 29 | slug: 'more-post-2', 30 | }, 31 | ]; 32 | render(); 33 | morePosts.forEach((post) => { 34 | expect(screen.getByText(post.title)).toBeInTheDocument(); 35 | }); 36 | }); 37 | 38 | it('renders cookies', async () => { 39 | render(); 40 | expect(screen.getByText('Cookies: not accepted')).toBeInTheDocument(); 41 | }); 42 | 43 | it('renders cookies accepted', async () => { 44 | Object.defineProperty(window.document, 'cookie', { 45 | writable: true, 46 | value: 'acceptedCookies=true', 47 | }); 48 | render(); 49 | expect(screen.getByText('Cookies: accepted')).toBeInTheDocument(); 50 | }); 51 | 52 | it('renders cookies not accepted', async () => { 53 | Object.defineProperty(window.document, 'cookie', { 54 | writable: true, 55 | value: 'acceptedCookies=false', 56 | }); 57 | render(); 58 | expect(screen.getByText('Cookies: not accepted')).toBeInTheDocument(); 59 | }); 60 | }); 61 | -------------------------------------------------------------------------------- /src/pages/_app.tsx: -------------------------------------------------------------------------------- 1 | /* eslint-disable global-require */ 2 | /* eslint-disable import/no-extraneous-dependencies */ 3 | 4 | import '../styles/global.css'; 5 | import '../styles/wordpress.css'; 6 | 7 | import { ApolloProvider } from '@apollo/client'; 8 | import type { AppProps } from 'next/app'; 9 | import Script from 'next/script'; 10 | import PlausibleProvider from 'next-plausible'; 11 | 12 | import { useApollo } from '@/lib/apollo'; 13 | import { AppConfig } from '@/utils/AppConfig'; 14 | 15 | if (process.env.NODE_ENV === 'development') { 16 | import('@impulse.dev/runtime').then((impulse) => 17 | impulse.run({ 18 | tailwindConfig: require('../../tailwind.config'), 19 | }) 20 | ); 21 | } 22 | 23 | const MyApp = ({ Component, pageProps }: AppProps) => { 24 | const apolloClient = useApollo(pageProps); 25 | 26 | if (AppConfig.plausibleUrl !== '') { 27 | const domain = AppConfig.plausibleUrl.replace(/(^\w+:|^)\/\//, ''); 28 | 29 | return ( 30 | 35 | 36 | 37 | 38 | 39 | ); 40 | } 41 | 42 | return ( 43 | 44 | {AppConfig.umamiScriptUrl !== '' && AppConfig.umamiWebsiteId !== '' && ( 45 |