├── .all-contributorsrc ├── .eslintignore ├── .eslintrc.js ├── .github ├── ISSUE_TEMPLATE │ ├── bug_report.md │ └── feature_request.md ├── pull_request_template.md └── workflows │ └── checks.yml ├── .gitignore ├── .husky └── pre-commit ├── .nvmrc ├── .prettierignore ├── .prettierrc.js ├── CODE_OF_CONDUCT.md ├── CONTRIBUTING.md ├── LICENSE ├── README.md ├── jsconfig.json ├── netlify.toml ├── next.config.js ├── package.json ├── plugins ├── feed.js ├── plugin-compiler.js ├── search-index.js ├── sitemap.js ├── socialImages.js ├── socialImages.template.html └── util.js ├── pnpm-lock.yaml ├── public ├── android-chrome-192x192.png ├── android-chrome-512x512.png ├── apple-touch-icon.png ├── favicon-1024x1024.png ├── favicon-16x16.png ├── favicon-32x32.png ├── favicon.ico ├── site.webmanifest └── sitemap.jpg └── src ├── components ├── Breadcrumbs │ ├── Breadcrumbs.js │ ├── Breadcrumbs.module.scss │ └── index.js ├── Button │ ├── Button.js │ ├── Button.module.scss │ └── index.js ├── Container │ ├── Container.js │ ├── Container.module.scss │ └── index.js ├── Content │ ├── Content.js │ ├── Content.module.scss │ └── index.js ├── FeaturedImage │ ├── FeaturedImage.js │ ├── FeaturedImage.module.scss │ └── index.js ├── Footer │ ├── Footer.js │ ├── Footer.module.scss │ └── index.js ├── Header │ ├── Header.js │ ├── Header.module.scss │ └── index.js ├── Image │ ├── Image.js │ ├── Image.module.scss │ └── index.js ├── Layout │ ├── Layout.js │ ├── Layout.module.scss │ └── index.js ├── Main │ ├── Main.js │ ├── Main.module.scss │ └── index.js ├── Metadata │ ├── Metadata.js │ ├── Metadata.module.scss │ └── index.js ├── Nav │ ├── Nav.js │ ├── Nav.module.scss │ └── index.js ├── NavListItem │ ├── NavListItem.js │ ├── NavListItem.module.scss │ └── index.js ├── Pagination │ ├── Pagination.js │ ├── Pagination.module.scss │ └── index.js ├── PostCard │ ├── PostCard.js │ ├── PostCard.module.scss │ └── index.js ├── Section │ ├── Section.js │ ├── Section.module.scss │ └── index.js ├── SectionTitle │ ├── SectionTitle.js │ ├── SectionTitle.module.scss │ └── index.js └── Title │ ├── Title.js │ ├── Title.module.scss │ └── index.js ├── data ├── categories.js ├── menus.js ├── pages.js ├── posts.js ├── site.js └── users.js ├── hooks ├── use-page-metadata.js ├── use-search.js └── use-site.js ├── lib ├── apollo-client.js ├── categories.js ├── datetime.js ├── json-ld.js ├── menus.js ├── pages.js ├── posts.js ├── search.js ├── site.js ├── users.js └── util.js ├── models └── classname.js ├── pages ├── 404.js ├── 500.js ├── [slugParent] │ └── [[...slugChild]].js ├── _app.js ├── _document.js ├── authors │ └── [slug].js ├── categories.js ├── categories │ └── [slug].js ├── index.js ├── posts.js ├── posts │ ├── [slug].js │ └── page │ │ └── [page].js └── search.js ├── styles ├── _variables.module.scss ├── components │ ├── _code.scss │ └── _container.scss ├── globals.scss ├── pages │ ├── Categories.module.scss │ ├── Error.module.scss │ ├── Home.module.scss │ ├── Page.module.scss │ └── Post.module.scss ├── settings │ ├── __settings.scss │ ├── _colors.scss │ ├── _display.scss │ └── _typography.scss ├── templates │ └── Archive.module.scss └── wordpress.scss └── templates └── archive.js /.all-contributorsrc: -------------------------------------------------------------------------------- 1 | { 2 | "files": [ 3 | "README.md" 4 | ], 5 | "imageSize": 100, 6 | "commit": false, 7 | "contributors": [ 8 | { 9 | "login": "colbyfayock", 10 | "name": "Colby Fayock", 11 | "avatar_url": "https://avatars2.githubusercontent.com/u/1045274?v=4", 12 | "profile": "https://colbyfayock.com/newsletter", 13 | "contributions": [ 14 | "code", 15 | "doc" 16 | ] 17 | }, 18 | { 19 | "login": "doingandlearning", 20 | "name": "Kevin Cunningham", 21 | "avatar_url": "https://avatars3.githubusercontent.com/u/8320213?v=4", 22 | "profile": "http://www.kevincunningham.co.uk", 23 | "contributions": [ 24 | "code" 25 | ] 26 | }, 27 | { 28 | "login": "GuilleAngulo", 29 | "name": "Guillermo Angulo", 30 | "avatar_url": "https://avatars0.githubusercontent.com/u/50624358?v=4", 31 | "profile": "http://guilleangulo.me", 32 | "contributions": [ 33 | "code" 34 | ] 35 | }, 36 | { 37 | "login": "HeinSnyman", 38 | "name": "Hein Snyman", 39 | "avatar_url": "https://avatars0.githubusercontent.com/u/22816814?v=4", 40 | "profile": "http://www.heinsnyman.co.za", 41 | "contributions": [ 42 | "code" 43 | ] 44 | }, 45 | { 46 | "login": "grische", 47 | "name": "Grische", 48 | "avatar_url": "https://avatars0.githubusercontent.com/u/2787581?v=4", 49 | "profile": "https://github.com/grische", 50 | "contributions": [ 51 | "tool" 52 | ] 53 | }, 54 | { 55 | "login": "jatin-rathee", 56 | "name": "Jatin Rathee", 57 | "avatar_url": "https://avatars0.githubusercontent.com/u/44899844?v=4", 58 | "profile": "https://github.com/jatin-rathee", 59 | "contributions": [ 60 | "code" 61 | ] 62 | }, 63 | { 64 | "login": "thedavedavies", 65 | "name": "Dave", 66 | "avatar_url": "https://avatars.githubusercontent.com/u/2972436?v=4", 67 | "profile": "https://highaltitude.io/", 68 | "contributions": [ 69 | "code" 70 | ] 71 | }, 72 | { 73 | "login": "bradgarropy", 74 | "name": "Brad Garropy", 75 | "avatar_url": "https://avatars.githubusercontent.com/u/11336745?v=4", 76 | "profile": "https://bradgarropy.com", 77 | "contributions": [ 78 | "code" 79 | ] 80 | }, 81 | { 82 | "login": "ffabiosales", 83 | "name": "Fábio Sales", 84 | "avatar_url": "https://avatars.githubusercontent.com/u/1392528?v=4", 85 | "profile": "http://ffabiosales.github.io", 86 | "contributions": [ 87 | "code" 88 | ] 89 | }, 90 | { 91 | "login": "leoloso", 92 | "name": "Leonardo Losoviz", 93 | "avatar_url": "https://avatars.githubusercontent.com/u/1981996?v=4", 94 | "profile": "https://leoloso.com", 95 | "contributions": [ 96 | "code" 97 | ] 98 | }, 99 | { 100 | "login": "avneesh0612", 101 | "name": "Avneesh Agarwal", 102 | "avatar_url": "https://avatars.githubusercontent.com/u/76690419?v=4", 103 | "profile": "https://www.avneesh.tech/", 104 | "contributions": [ 105 | "code" 106 | ] 107 | }, 108 | { 109 | "login": "PhattOZ", 110 | "name": "Phattarapol L.", 111 | "avatar_url": "https://avatars.githubusercontent.com/u/63938605?v=4", 112 | "profile": "https://github.com/PhattOZ", 113 | "contributions": [ 114 | "code" 115 | ] 116 | }, 117 | { 118 | "login": "petercr", 119 | "name": "Peter Cruckshank", 120 | "avatar_url": "https://avatars.githubusercontent.com/u/26460352?v=4", 121 | "profile": "https://capecod.world", 122 | "contributions": [ 123 | "code" 124 | ] 125 | }, 126 | { 127 | "login": "shaneog", 128 | "name": "Shane O'Grady", 129 | "avatar_url": "https://avatars.githubusercontent.com/u/130415?v=4", 130 | "profile": "https://ogrady.ie", 131 | "contributions": [ 132 | "code" 133 | ] 134 | }, 135 | { 136 | "login": "gaswirth", 137 | "name": "Nick Gaswirth", 138 | "avatar_url": "https://avatars.githubusercontent.com/u/665784?v=4", 139 | "profile": "https://roundhouse-designs.com", 140 | "contributions": [ 141 | "code" 142 | ] 143 | }, 144 | { 145 | "login": "alexandruvisan19", 146 | "name": "alexandruvisan19", 147 | "avatar_url": "https://avatars.githubusercontent.com/u/79447321?v=4", 148 | "profile": "https://github.com/alexandruvisan19", 149 | "contributions": [ 150 | "code" 151 | ] 152 | }, 153 | { 154 | "login": "theritikchoure", 155 | "name": "Ritik Chourasiya", 156 | "avatar_url": "https://avatars.githubusercontent.com/u/56495602?v=4", 157 | "profile": "https://linktr.ee/theritikchoure", 158 | "contributions": [ 159 | "tool" 160 | ] 161 | }, 162 | { 163 | "login": "rickknowlton", 164 | "name": "Rick Knowlton", 165 | "avatar_url": "https://avatars.githubusercontent.com/u/10679138?v=4", 166 | "profile": "https://rickknowlton.io", 167 | "contributions": [ 168 | "code" 169 | ] 170 | }, 171 | { 172 | "login": "amjedidiah", 173 | "name": "Jedidiah Amaraegbu", 174 | "avatar_url": "https://avatars.githubusercontent.com/u/17021436?v=4", 175 | "profile": "https://github.com/amjedidiah", 176 | "contributions": [ 177 | "doc" 178 | ] 179 | } 180 | ], 181 | "contributorsPerLine": 7, 182 | "projectName": "next-wordpress-starter", 183 | "projectOwner": "colbyfayock", 184 | "repoType": "github", 185 | "repoHost": "https://github.com", 186 | "skipCi": true, 187 | "commitConvention": "angular", 188 | "commitType": "docs" 189 | } 190 | -------------------------------------------------------------------------------- /.eslintignore: -------------------------------------------------------------------------------- 1 | CODE_OF_CONDUCT.md 2 | CONTRIBUTING.md 3 | LICENSE 4 | README.md 5 | 6 | # dependencies 7 | /node_modules 8 | /.pnp 9 | .pnp.js 10 | 11 | # testing 12 | /coverage 13 | 14 | # next.js 15 | /.next/ 16 | /out/ 17 | 18 | # production 19 | /build 20 | 21 | # misc 22 | .DS_Store 23 | *.pem 24 | 25 | # debug 26 | npm-debug.log* 27 | yarn-debug.log* 28 | yarn-error.log* 29 | 30 | # local env files 31 | .env.local 32 | .env.development.local 33 | .env.test.local 34 | .env.production.local 35 | 36 | # vercel 37 | .vercel 38 | 39 | yarn.lock 40 | pnpm-lock.yaml 41 | package-lock.json 42 | .prettierignore 43 | .gitignore 44 | .eslintignore 45 | 46 | 47 | public 48 | 49 | .all-contributorsrc -------------------------------------------------------------------------------- /.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | env: { 3 | browser: true, 4 | es2021: true, 5 | node: true, 6 | }, 7 | settings: { 8 | react: { 9 | version: 'detect', 10 | }, 11 | }, 12 | extends: [ 13 | 'eslint:recommended', 14 | 'plugin:react/recommended', 15 | 'plugin:prettier/recommended', 16 | 'plugin:@next/next/recommended', 17 | 'next/core-web-vitals', 18 | ], 19 | parserOptions: { 20 | ecmaFeatures: { 21 | jsx: true, 22 | }, 23 | ecmaVersion: 12, 24 | sourceType: 'module', 25 | }, 26 | plugins: ['react'], 27 | rules: { 28 | 'react-hooks/rules-of-hooks': 'error', 29 | 'react-hooks/exhaustive-deps': 'warn', 30 | 'react/prop-types': 'off', 31 | 'react/react-in-jsx-scope': 'off', 32 | '@next/next/no-img-element': 'off', 33 | }, 34 | }; 35 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Create a report to help us improve 4 | title: '[Bug] ' 5 | labels: 'Type: Bug' 6 | --- 7 | 8 | # **Bug Report** 9 | 10 | ## **Describe the bug** 11 | 12 | 13 | 14 | ## **Is this a regression?** 15 | 16 | 17 | 18 | 19 | ## **Steps To Reproduce the error** 20 | 21 | 22 | 23 | 1. 24 | 2. 25 | 3. 26 | 4. 27 | 28 | ## **Expected behaviour** 29 | 30 | 31 | 32 | ## **CodeSandbox or Live Example of Bug** 33 | 34 | 35 | 36 | ## **Screenshot or Video Recording** 37 | 38 | 39 | 40 | 41 | ### **Your environment** 42 | 43 | 45 | 46 | - OS: 47 | - Node version: 48 | - Npm version: 49 | - Browser name and version: 50 | 51 | 52 | ## **Additional context** 53 | 54 | 55 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: "Feature Request" 3 | about: "Suggest an idea or possible new feature for this project." 4 | title: "[Feature] " 5 | labels: "Type: Feature" 6 | --- 7 | 8 | # **Feature Request** 9 | 10 | ## **Is your feature request related to a problem? Please describe.** 11 | 12 | 13 | 14 | ## **Describe the solution you'd like** 15 | 16 | 17 | 18 | 19 | ## **Describe alternatives you've considered** 20 | 21 | 22 | 23 | ## **Additional context** 24 | 25 | 26 | -------------------------------------------------------------------------------- /.github/pull_request_template.md: -------------------------------------------------------------------------------- 1 | # Description 2 | 3 | 4 | 5 | ## Issue Ticket Number 6 | 7 | 8 | 9 | 10 | Fixes 11 | 12 | ## Type of change 13 | 14 | 15 | 16 | - [ ] Bug fix (non-breaking change which fixes an issue) 17 | - [ ] New feature (non-breaking change which adds functionality) 18 | - [ ] Breaking change (fix or feature that would cause existing functionality to not work as expected) 19 | - [ ] Fix or improve the documentation 20 | - [ ] This change requires a documentation update 21 | 22 | 23 | # Checklist 24 | 25 | 26 | 27 | - [ ] I have followed the contributing guidelines of this project as mentioned in [CONTRIBUTING.md](/CONTRIBUTING.md) 28 | - [ ] I have created an [issue](https://github.com/colbyfayock/next-wordpress-starter/issues) ticket for this PR 29 | - [ ] I have checked to ensure there aren't other open [Pull Requests](https://github.com/colbyfayock/next-wordpress-starter/pulls) for the same update/change? 30 | - [ ] I have performed a self-review of my own code 31 | - [ ] I have run tests locally to ensure they all pass 32 | - [ ] I have commented my code, particularly in hard-to-understand areas 33 | - [ ] I have made corresponding changes needed to the documentation 34 | -------------------------------------------------------------------------------- /.github/workflows/checks.yml: -------------------------------------------------------------------------------- 1 | name: Checks 2 | 3 | on: [push, pull_request] 4 | 5 | jobs: 6 | linting: 7 | name: Linting 8 | runs-on: ubuntu-latest 9 | strategy: 10 | matrix: 11 | node-version: [18.x] 12 | steps: 13 | - uses: actions/checkout@v2 14 | 15 | - uses: pnpm/action-setup@v2 16 | with: 17 | version: 7 18 | 19 | - uses: actions/setup-node@v2 20 | with: 21 | node-version: ${{ matrix.node-version }} 22 | cache: 'pnpm' 23 | 24 | - run: pnpm install 25 | - run: pnpm lint 26 | 27 | static-compat: 28 | name: Static Compatability 29 | runs-on: ubuntu-latest 30 | strategy: 31 | matrix: 32 | node-version: [16.x, 18.x] 33 | steps: 34 | - uses: actions/checkout@v2 35 | 36 | - uses: pnpm/action-setup@v2 37 | with: 38 | version: 7 39 | 40 | - uses: actions/setup-node@v2 41 | with: 42 | node-version: ${{ matrix.node-version }} 43 | cache: 'pnpm' 44 | 45 | - run: pnpm install --frozen-lockfile 46 | 47 | - run: pnpm build && pnpm export 48 | env: 49 | WORDPRESS_GRAPHQL_ENDPOINT: ${{ secrets.WORDPRESS_GRAPHQL_ENDPOINT }} 50 | -------------------------------------------------------------------------------- /.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 | # next.js 12 | /.next/ 13 | /out/ 14 | 15 | # production 16 | /build 17 | 18 | # misc 19 | .DS_Store 20 | *.pem 21 | 22 | # debug 23 | npm-debug.log* 24 | yarn-debug.log* 25 | yarn-error.log* 26 | 27 | # local env files 28 | .env.local 29 | .env.development.local 30 | .env.test.local 31 | .env.production.local 32 | 33 | # vercel 34 | .vercel 35 | 36 | public/wp-search.json 37 | public/feed.xml 38 | public/sitemap.xml 39 | public/robots.txt 40 | public/images/og 41 | 42 | # VSCode settings 43 | .vscode/ 44 | # Local Netlify folder 45 | .netlify 46 | -------------------------------------------------------------------------------- /.husky/pre-commit: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | . "$(dirname "$0")/_/husky.sh" 3 | 4 | yarn run lint-staged 5 | -------------------------------------------------------------------------------- /.nvmrc: -------------------------------------------------------------------------------- 1 | 18 -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | CODE_OF_CONDUCT.md 2 | CONTRIBUTING.md 3 | LICENSE 4 | README.md 5 | 6 | # dependencies 7 | /node_modules 8 | /.pnp 9 | .pnp.js 10 | 11 | # testing 12 | /coverage 13 | 14 | # next.js 15 | /.next/ 16 | /out/ 17 | 18 | # production 19 | /build 20 | 21 | # misc 22 | .DS_Store 23 | *.pem 24 | 25 | # debug 26 | npm-debug.log* 27 | yarn-debug.log* 28 | yarn-error.log* 29 | 30 | # local env files 31 | .env.local 32 | .env.development.local 33 | .env.test.local 34 | .env.production.local 35 | 36 | # vercel 37 | .vercel 38 | 39 | yarn.lock 40 | .prettierignore 41 | .gitignore 42 | 43 | public 44 | 45 | .all-contributorsrc -------------------------------------------------------------------------------- /.prettierrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | singleQuote: true, 3 | printWidth: 120, 4 | overrides: [ 5 | { 6 | files: '*.scss', 7 | options: { 8 | singleQuote: false, 9 | }, 10 | }, 11 | ], 12 | }; 13 | -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Contributor Covenant Code of Conduct 2 | 3 | ## Our Pledge 4 | 5 | In the interest of fostering an open and welcoming environment, we as 6 | contributors and maintainers pledge to making participation in our project and 7 | our community a harassment-free experience for everyone, regardless of age, body 8 | size, disability, ethnicity, sex characteristics, gender identity and expression, 9 | level of experience, education, socio-economic status, nationality, personal 10 | appearance, race, religion, or sexual identity and orientation. 11 | 12 | ## Our Standards 13 | 14 | Examples of behavior that contributes to creating a positive environment 15 | include: 16 | 17 | * Using welcoming and inclusive language 18 | * Being respectful of differing viewpoints and experiences 19 | * Gracefully accepting constructive criticism 20 | * Focusing on what is best for the community 21 | * Showing empathy towards other community members 22 | 23 | Examples of unacceptable behavior by participants include: 24 | 25 | * The use of sexualized language or imagery and unwelcome sexual attention or 26 | advances 27 | * Trolling, insulting/derogatory comments, and personal or political attacks 28 | * Public or private harassment 29 | * Publishing others' private information, such as a physical or electronic 30 | address, without explicit permission 31 | * Other conduct which could reasonably be considered inappropriate in a 32 | professional setting 33 | 34 | ## Our Responsibilities 35 | 36 | Project maintainers are responsible for clarifying the standards of acceptable 37 | behavior and are expected to take appropriate and fair corrective action in 38 | response to any instances of unacceptable behavior. 39 | 40 | Project maintainers have the right and responsibility to remove, edit, or 41 | reject comments, commits, code, wiki edits, issues, and other contributions 42 | that are not aligned to this Code of Conduct, or to ban temporarily or 43 | permanently any contributor for other behaviors that they deem inappropriate, 44 | threatening, offensive, or harmful. 45 | 46 | ## Scope 47 | 48 | This Code of Conduct applies both within project spaces and in public spaces 49 | when an individual is representing the project or its community. Examples of 50 | representing a project or community include using an official project e-mail 51 | address, posting via an official social media account, or acting as an appointed 52 | representative at an online or offline event. Representation of a project may be 53 | further defined and clarified by project maintainers. 54 | 55 | ## Enforcement 56 | 57 | Instances of abusive, harassing, or otherwise unacceptable behavior may be 58 | reported by contacting the project team at conduct@colbyfayock.com. All 59 | complaints will be reviewed and investigated and will result in a response that 60 | is deemed necessary and appropriate to the circumstances. The project team is 61 | obligated to maintain confidentiality with regard to the reporter of an incident. 62 | Further details of specific enforcement policies may be posted separately. 63 | 64 | Project maintainers who do not follow or enforce the Code of Conduct in good 65 | faith may face temporary or permanent repercussions as determined by other 66 | members of the project's leadership. 67 | 68 | ## Attribution 69 | 70 | This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 1.4, 71 | available at https://www.contributor-covenant.org/version/1/4/code-of-conduct.html 72 | 73 | [homepage]: https://www.contributor-covenant.org 74 | 75 | For answers to common questions about this code of conduct, see 76 | https://www.contributor-covenant.org/faq 77 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing 2 | This project follows the [all-contributors](https://github.com/all-contributors/all-contributors) specification. Contributions of any kind welcome! 3 | 4 | ## Issues 5 | 6 | ### Creating an Issue 7 | If you find a bug, problem, or maybe the documentation just doesn't make sense, please create an Issue to document the concern. 8 | 9 | ### Description 10 | Please be descriptive in your Issue, the more info you provide, the more likely someone will be able to help. 11 | 12 | ### Code Examples 13 | If you're experiencing an issue with the code, the most helpful thing you can do is create an example where you can reproduce the problem. This can be an open source Github repo, a private repo you can share with the maintainers, a [CodeSandbox](https://codesandbox.io/), or really anything to show the issue live with code along side of it. 14 | 15 | ## Pull Requests 16 | 17 | ### Creating a Pull Request 18 | If you're able to fix an active Issue, feel free to create a new Pull Request addressing the problem. There are no gaurantees that the code will be merged in "as is", but chances are, if you're willing to work with the maintainers, everyone will be able to come up with a solution everyone can be happy with. 19 | 20 | ### Description 21 | Please be descriptive in your Pull Request. Whether big or small, it's important to be able to see the context of a change throughout the history of a project. 22 | 23 | ### Linking Fixed Issues 24 | If the Pull Request is addressing an Issue, please link that issue by specifying the `Fixes [Issue #]` syntax within the Pull Request. 25 | 26 | ### Getting Added to All Contributors in the README.md 27 | Once your Pull Request is successfully merged, feel free to tag yourself using the [All Conributors syntax](https://allcontributors.org/docs/en/bot/usage), which will create a new Pull Request requesting to add you in. 28 | 29 | ``` 30 | @all-contributors please add for 31 | ``` 32 | 33 | If your Pull Request is merged in and you're not added, please let someone know if you don't want to tag yourself, as we want to recognize everyone for their help. 34 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020 Colby Fayock 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Next.js WordPress Starter 2 | 3 | 4 | [![All Contributors](https://img.shields.io/badge/all_contributors-19-orange.svg?style=flat-square)](#contributors-) 5 | 6 | 7 | Scaling WordPress with the power of [Next.js](https://nextjs.org/) and the static web! 8 | 9 | - [⚡️ Quick Start](#%EF%B8%8F-quick-start) 10 | - [🚀 Getting Started](#-getting-started) 11 | - [🛠 Configuring Your Project](#-configuring-your-project) 12 | - [🔌 Plugins](#-plugins) 13 | - [💝 Sponsors](#-sponsors) 14 | - [✨ Contributors](#-contributors) 15 | 16 | ## ⚡️ Quick Start 17 | 18 | [![Deploy to Netlify](https://www.netlify.com/img/deploy/button.svg)](https://app.netlify.com/start/deploy?repository=https://github.com/colbyfayock/next-wordpress-starter) [![Deploy with Vercel](https://vercel.com/button)](https://vercel.com/new/clone?repository-url=https%3A%2F%2Fgithub.com%2Fcolbyfayock%2Fnext-wordpress-starter) 19 | 20 | ### Requirements 21 | * [WordPress](https://wordpress.org/) 22 | * [WPGraphQL](https://www.wpgraphql.com/) 23 | * Environment variables (see below) 24 | 25 | ```bash 26 | yarn create next-app -e https://github.com/colbyfayock/next-wordpress-starter 27 | # or 28 | npx create-next-app -e https://github.com/colbyfayock/next-wordpress-starter 29 | ``` 30 | 31 | Add an `.env.local` file to the root with the following: 32 | ``` 33 | WORDPRESS_GRAPHQL_ENDPOINT="http://wordpressite.com/graphql" 34 | ``` 35 | 36 | In some cases, the above may not work. 37 | Change it as follows: 38 | ``` 39 | WORDPRESS_GRAPHQL_ENDPOINT="http://yourhost.com/index.php?graphql 40 | ``` 41 | 42 | ## 🚀 Getting Started 43 | 44 | ### What is this and what does it include? 45 | 46 | The goal of this project is to take WordPress as a headless CMS and use Next.js to create a static experience without any 3rd party services that can be deployed anywhere. 47 | 48 | The hope is to build out as many features as we can to support what's typically expected from an out of the box theme on WordPress. Currently, those features include: 49 | * Blog (https://next-wordpress-starter.spacejelly.dev) 50 | * Pages (https://next-wordpress-starter.spacejelly.dev/about/) 51 | * Posts (https://next-wordpress-starter.spacejelly.dev/posts/how-to-create-a-headless-wordpress-blog-with-next-js-wordpress-starter/) 52 | * Categories (https://next-wordpress-starter.spacejelly.dev/categories/tutorial/) 53 | * Authors (https://next-wordpress-starter.spacejelly.dev/authors/colby-fayock/) 54 | * Search (Client side global navigation and https://next-wordpress-starter.spacejelly.dev/search/?q=wordpress) 55 | * RSS (https://next-wordpress-starter.spacejelly.dev/feed.xml) 56 | * Sitemap (https://next-wordpress-starter.spacejelly.dev/sitemap.xml) 57 | 58 | Additionally, the theme is expected to be SEO friendly and performant out of the box, including: 59 | * Unique page titles 60 | * Unique descriptions 61 | * Open Graph tags 62 | 63 | You can also optionally enable Yoast SEO plugin support to supercharge your SEO! (See below) 64 | 65 | Check out the [Issues](https://github.com/colbyfayock/next-wordpress-starter/issues) for what's on deck! 66 | 67 | *Want something a little more **basic**? Check out my other starter with an MVP setup to get up and running with WPGraphQL in WordPress: https://github.com/colbyfayock/next-wpgraphql-basic-starter* 68 | 69 | ### Requirements 70 | * [WordPress](https://wordpress.org/) 71 | * [WPGraphQL](https://www.wpgraphql.com/) 72 | * Environment variables (see below) 73 | 74 | ### Environment 75 | 76 | This project makes use of WPGraphQL to query WordPress with GraphQL. In order to make that request to the appropriate endpoint, we need to set a environment variable to let Next.js know where to request the site information from. 77 | 78 | Create a new file locally called `.env.local` and add the following: 79 | 80 | ```bash 81 | WORDPRESS_GRAPHQL_ENDPOINT="[WPGraphQL Endpoint]" 82 | ``` 83 | 84 | Replace the contents of the variable with your WPGraphQL endpoint. By default, it should resemble `[Your Host]/graphql`. 85 | 86 | *Note: environment variables can optionally be statically configured in next.config.js* 87 | 88 | #### All Environment Variables 89 | 90 | | Name | Required | Default | Description | 91 | | ---------------------------------- | -------- | - | ------------------------------------------------- | 92 | | WORDPRESS_GRAPHQL_ENDPOINT | Yes | - | WordPress WPGraphQL endpoint (ex: host.com/graphl)| 93 | | WORDPRESS_MENU_LOCATION_NAVIGATION | No | PRIMARY | Configures header navigation Menu Location | 94 | | WORDPRESS_PLUGIN_SEO | No | false | Enables SEO plugin support (true, false) | 95 | 96 | Please note some themes do not have PRIMARY menu location. 97 | 98 | ### Development 99 | 100 | To start the project locally, run: 101 | 102 | ```bash 103 | yarn dev 104 | # or 105 | npm run dev 106 | ``` 107 | 108 | The project should now be available at [http://localhost:3000](http://localhost:3000)! 109 | 110 | #### ESLint extension for Visual Studio Code 111 | 112 | It is possible to take advantage of this extension to improve the development experience. 113 | To set up the [ESLint extension](https://marketplace.visualstudio.com/items?itemName=dbaeumer.vscode-eslint) in Visual Studio Code add a new folder to the root `.vscode`. Inside add a file `settings.json` with the following content: 114 | 115 | ```json 116 | { 117 | "editor.formatOnSave": false, 118 | "editor.codeActionsOnSave": { 119 | "source.fixAll.eslint": true 120 | } 121 | } 122 | ``` 123 | 124 | With this file ESLint will automatically fix and validate syntax errors and format the code on save (based on Prettier configuration). 125 | 126 | ### Deployment 127 | 128 | #### Netlify 129 | 130 | There are two options as to how you can deploy this project to Netlify: 131 | * [Essential Next.js Plugin](https://github.com/netlify/netlify-plugin-nextjs) (recommended) 132 | * [Exporting the project](https://nextjs.org/docs/advanced-features/static-html-export) via `next export` 133 | 134 | **Essential Next.js Plugin** should be provided as an option when you're first importing a project based on this starter. If it's not, you can install this plugin using the Netlify Plugins directory. This will allow the project to take full advantage of all native Next.js features that Netlify supports with this plugin. 135 | 136 | **Exporting the project** lets Next.js compile the project into static assets including HTML files. This allows you to deploy the project as a static site directly to Netlify just like any other site. You can do this by adding `next export` to the end of the `build` command inside `package.json` (ex: `next build && next export`). 137 | 138 | Regardless of which option you choose, you can configure your [environment variables](#environment) either when creating your new site or by navigating to Site Settings > Build & Deploy > Environment and triggering a new deploy once added. 139 | 140 | [![Deploy to Netlify](https://www.netlify.com/img/deploy/button.svg)](https://app.netlify.com/start/deploy?repository=https://github.com/colbyfayock/next-wordpress-starter) 141 | 142 | #### Vercel 143 | 144 | Given Next.js is a Vercel-supported project, you can simply import the project as a new site and configure your [environment variables](#environment) by either adding them during import or by navigating to Settings > Environment Variables and triggering a new build once added. 145 | 146 | [![Deploy with Vercel](https://vercel.com/button)](https://vercel.com/new/clone?repository-url=https%3A%2F%2Fgithub.com%2Fcolbyfayock%2Fnext-wordpress-starter) 147 | 148 | ## 🛠 Configuring Your Project 149 | 150 | ### package.json 151 | 152 | In order to avoid an additional configuration file, we take advantage of some built-in properties of `package.json` to configure parts of the website. 153 | 154 | | Name | Required | Description | 155 | | -------------------------- | -------- | ------------------------------------------------------------------ | 156 | | homepage | Yes | Homepage or hostname used to construct full URLs (ex Open Graph) | 157 | 158 | - homepage: Setting the `homepage` property will update instances where the full URL is required such as Open Graph tags 159 | 160 | ### WordPress 161 | 162 | This project aims to take advantage of as many built-in WordPress features by default like a typical WordPress theme. Those include: 163 | 164 | | Name | Usage | 165 | | -------------------------- | --------------------------------------- | 166 | | Site Language | `lang` attribute on the `` tag | 167 | | Site Title | Homepage header, page metadata | 168 | | Tagline | Homepage subtitle | 169 | 170 | There is some specific WordPress configuration required to allow for the best use of this starter. 171 | 172 | ### Images 173 | 174 | This Starter doesn't currently provide any mechanisms for dealing with image content from WordPress. The images are linked to "as is", meaning if the image is uploaded via the WordPress interface, the image will be served from WordPress. 175 | 176 | To serve the images statically, you have a few options. 177 | 178 | #### Jetpack 179 | 180 | By enabling the Image Accelerator from Jetpack, your images will automatically be served statically and cached via the wp.com CDN. This feature comes free with the basic installation of Jetpack, requiring only that you connect the WordPress site to the Jetpack service. 181 | 182 | [Jetpack CDN](https://jetpack.com/features/design/content-delivery-network/) 183 | 184 | ## 🔌 Plugins 185 | 186 | ### Yoast SEO 187 | 188 | The Yoast SEO plugin is partially supported including most major features like metadata and open graph customization. 189 | 190 | #### Requirements 191 | * Yoast SEO plugin 192 | * Add WPGraphQL SEO plugin 193 | 194 | To enable the plugin, configure `WORDPRESS_PLUGIN_SEO` to be `true` either as an environment variable or within next.config.js. 195 | 196 | ## 🥾 Bootstrapped with Next.js WordPress Starter 197 | 198 | Examples of websites that started off with Next.js WordPress Starter 199 | 200 | * [colbyfayock.com](https://colbyfayock.com/) 201 | * [spacejelly.dev](https://spacejelly.dev/) 202 | 203 | ## 💝 Sponsors 204 | 205 | WordPress hosting for the public-facing project provided by [WP Engine](https://wpengine.com/). 206 | 207 | WP Engine Logo 208 | 209 | ## ✨ Contributors 210 | 211 | Thanks goes to these wonderful people ([emoji key](https://allcontributors.org/docs/en/emoji-key)): 212 | 213 | 214 | 215 | 216 | 217 | 218 | 219 | 220 | 221 | 222 | 223 | 224 | 225 | 226 | 227 | 228 | 229 | 230 | 231 | 232 | 233 | 234 | 235 | 236 | 237 | 238 | 239 | 240 | 241 | 242 | 243 | 244 |
Colby Fayock
Colby Fayock

💻 📖
Kevin Cunningham
Kevin Cunningham

💻
Guillermo Angulo
Guillermo Angulo

💻
Hein Snyman
Hein Snyman

💻
Grische
Grische

🔧
Jatin Rathee
Jatin Rathee

💻
Dave
Dave

💻
Brad Garropy
Brad Garropy

💻
Fábio Sales
Fábio Sales

💻
Leonardo Losoviz
Leonardo Losoviz

💻
Avneesh Agarwal
Avneesh Agarwal

💻
Phattarapol L.
Phattarapol L.

💻
Peter Cruckshank
Peter Cruckshank

💻
Shane O'Grady
Shane O'Grady

💻
Nick Gaswirth
Nick Gaswirth

💻
alexandruvisan19
alexandruvisan19

💻
Ritik Chourasiya
Ritik Chourasiya

🔧
Rick Knowlton
Rick Knowlton

💻
Jedidiah Amaraegbu
Jedidiah Amaraegbu

📖
245 | 246 | 247 | 248 | 249 | 250 | 251 | This project follows the [all-contributors](https://github.com/all-contributors/all-contributors) specification. Contributions of any kind welcome! 252 | -------------------------------------------------------------------------------- /jsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "baseUrl": "./src", 4 | "paths": { 5 | "public/*": ["../public/*"] 6 | } 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /netlify.toml: -------------------------------------------------------------------------------- 1 | [build] 2 | command = "yarn build" 3 | publish = ".next" 4 | 5 | [[plugins]] 6 | package = "@netlify/plugin-nextjs" 7 | -------------------------------------------------------------------------------- /next.config.js: -------------------------------------------------------------------------------- 1 | const indexSearch = require('./plugins/search-index'); 2 | const feed = require('./plugins/feed'); 3 | const sitemap = require('./plugins/sitemap'); 4 | // const socialImages = require('./plugins/socialImages'); TODO: failing to run on Netlify 5 | 6 | /** @type {import('next').NextConfig} */ 7 | const nextConfig = { 8 | reactStrictMode: true, 9 | swcMinify: true, 10 | 11 | // By default, Next.js removes the trailing slash. One reason this would be good 12 | // to include is by default, the `path` property of the router for the homepage 13 | // is `/` and by using that, would instantly create a redirect 14 | 15 | trailingSlash: true, 16 | 17 | // By enabling verbose logging, it will provide additional output details for 18 | // diagnostic purposes. By default is set to false. 19 | // verbose: true, 20 | 21 | env: { 22 | // The image directory for open graph images will be saved at the location above 23 | // with `public` prepended. By default, images will be saved at /public/images/og 24 | // and available at /images/og. If changing, make sure to update the .gitignore 25 | 26 | OG_IMAGE_DIRECTORY: '/images/og', 27 | 28 | // By default, only render this number of post pages ahead of time, otherwise 29 | // the rest will be rendered on-demand 30 | POSTS_PRERENDER_COUNT: 5, 31 | 32 | WORDPRESS_GRAPHQL_ENDPOINT: process.env.WORDPRESS_GRAPHQL_ENDPOINT, 33 | WORDPRESS_MENU_LOCATION_NAVIGATION: process.env.WORDPRESS_MENU_LOCATION_NAVIGATION || 'PRIMARY', 34 | WORDPRESS_PLUGIN_SEO: parseEnvValue(process.env.WORDPRESS_PLUGIN_SEO, false), 35 | }, 36 | }; 37 | 38 | module.exports = () => { 39 | const plugins = [indexSearch, feed, sitemap]; 40 | return plugins.reduce((acc, plugin) => plugin(acc), nextConfig); 41 | }; 42 | 43 | /** 44 | * parseEnv 45 | * @description Helper function to check if a variable is defined and parse booelans 46 | */ 47 | 48 | function parseEnvValue(value, defaultValue) { 49 | if (typeof value === 'undefined') return defaultValue; 50 | if (value === true || value === 'true') return true; 51 | if (value === false || value === 'false') return false; 52 | return value; 53 | } 54 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "next-wordpress-starter", 3 | "homepage": "https://next-wordpress-starter.spacejelly.dev", 4 | "version": "0.1.0", 5 | "license": "MIT", 6 | "author": "Colby Fayock ", 7 | "scripts": { 8 | "build": "next build", 9 | "dev": "next dev", 10 | "export": "next export", 11 | "format": "pnpm lint --fix", 12 | "format:no-path": "pnpm lint:no-path --fix", 13 | "lint": "eslint .", 14 | "lint:no-path": "eslint", 15 | "start": "next start -p ${PORT:=3000}", 16 | "prepare": "husky install" 17 | }, 18 | "lint-staged": { 19 | "**/*.js": "pnpm format:no-path", 20 | "**/*.scss": "pnpm prettier --write" 21 | }, 22 | "dependencies": { 23 | "@apollo/client": "^3.7.14", 24 | "date-fns": "^2.30.0", 25 | "dotenv": "^16.0.3", 26 | "fuse.js": "^6.6.2", 27 | "graphql": "^16.6.0", 28 | "he": "^1.2.0", 29 | "loader-utils": "^3.2.1", 30 | "next": "13.4.0", 31 | "nextjs-progressbar": "^0.0.16", 32 | "parameterize": "^1.0.0", 33 | "path": "^0.12.7", 34 | "react": "18.2.0", 35 | "react-dom": "18.2.0", 36 | "react-helmet": "^6.1.0", 37 | "react-icons": "^4.8.0", 38 | "rss": "^1.2.2", 39 | "sass": "^1.62.1", 40 | "style.css": "^1.0.3" 41 | }, 42 | "devDependencies": { 43 | "eslint": "8.39.0", 44 | "eslint-config-next": "^13.4.0", 45 | "eslint-config-prettier": "^8.8.0", 46 | "eslint-plugin-prettier": "^4.2.1", 47 | "eslint-plugin-react": "^7.32.2", 48 | "husky": "^8.0.3", 49 | "lint-staged": "^13.2.2", 50 | "playwright": "^1.33.0", 51 | "prettier": "2.8.8" 52 | }, 53 | "repository": { 54 | "type": "git", 55 | "url": "https://github.com/colbyfayock/next-wordpress-starter" 56 | }, 57 | "bugs": { 58 | "url": "https://github.com/colbyfayock/next-wordpress-starter/issues" 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /plugins/feed.js: -------------------------------------------------------------------------------- 1 | const path = require('path'); 2 | const { getFeedData, generateFeed } = require('./util'); 3 | 4 | const WebpackPluginCompiler = require('./plugin-compiler'); 5 | 6 | module.exports = function feed(nextConfig = {}) { 7 | const { env, outputDirectory, outputName, verbose = false } = nextConfig; 8 | 9 | const plugin = { 10 | name: 'Feed', 11 | outputDirectory: outputDirectory || './public', 12 | outputName: outputName || 'feed.xml', 13 | getData: getFeedData, 14 | generate: generateFeed, 15 | }; 16 | 17 | const { WORDPRESS_GRAPHQL_ENDPOINT } = env; 18 | 19 | return Object.assign({}, nextConfig, { 20 | webpack(config, options) { 21 | if (config.watchOptions) { 22 | config.watchOptions.ignored.push(path.join('**', plugin.outputDirectory, plugin.outputName)); 23 | } 24 | 25 | config.plugins.push( 26 | new WebpackPluginCompiler({ 27 | url: WORDPRESS_GRAPHQL_ENDPOINT, 28 | plugin, 29 | verbose, 30 | }) 31 | ); 32 | 33 | if (typeof nextConfig.webpack === 'function') { 34 | return nextConfig.webpack(config, options); 35 | } 36 | 37 | return config; 38 | }, 39 | }); 40 | }; 41 | -------------------------------------------------------------------------------- /plugins/plugin-compiler.js: -------------------------------------------------------------------------------- 1 | const path = require('path'); 2 | 3 | const { createApolloClient, createFile, terminalColor, removeLastTrailingSlash } = require('./util'); 4 | 5 | class WebpackPlugin { 6 | constructor(options = {}) { 7 | this.options = options; 8 | } 9 | 10 | async index(compilation, options) { 11 | const { url, plugin, verbose = false, nextConfig } = options; 12 | 13 | try { 14 | plugin.outputLocation = path.join(plugin.outputDirectory, plugin.outputName); 15 | 16 | verbose && console.log(`[${plugin.name}] Compiling file ${plugin.outputLocation}`); 17 | 18 | const hasUrl = typeof url === 'string'; 19 | 20 | if (!hasUrl) { 21 | throw new Error( 22 | `[${plugin.name}] Failed to compile: Please check that WORDPRESS_GRAPHQL_ENDPOINT is set and configured in your environment. WORDPRESS_HOST is no longer supported by default.` 23 | ); 24 | } 25 | 26 | const apolloClient = createApolloClient(removeLastTrailingSlash(url)); 27 | 28 | const data = await plugin.getData(apolloClient, plugin.name, verbose); 29 | 30 | const file = await plugin.generate(data, nextConfig); 31 | 32 | if (file !== false) { 33 | await createFile(file, plugin.name, plugin.outputDirectory, plugin.outputLocation, verbose); 34 | } 35 | 36 | //If there is an additional action to perform 37 | if (plugin.postcreate) { 38 | plugin.postcreate(plugin); 39 | } 40 | 41 | !verbose && console.log(`Successfully created: ${terminalColor(plugin.outputName, 'info')}`); 42 | } catch (e) { 43 | console.error(`${terminalColor(e.message, 'error')}`); 44 | } 45 | } 46 | 47 | apply(compiler) { 48 | // We want this plugin to be able to run both during development mode and production 49 | // mode, but we also only want it to compile once. We need a way to be able to detect 50 | // some kind of identifier that will only be available once, where here we're using 51 | // the `main` entry. When these are ran, we check if that's in the active compiler 52 | // options and only run if it is 53 | 54 | const { plugin } = this.options; 55 | 56 | // Value to ensure that the plugins only run once and not continuously on every page request 57 | let hasRun = false; 58 | 59 | compiler.hooks.run.tap(plugin.name, async (compiler) => { 60 | if (!compiler.options.entry.main || hasRun) return; 61 | 62 | await this.index(compiler, this.options); 63 | hasRun = true; 64 | }); 65 | 66 | compiler.hooks.watchRun.tap(plugin.name, async (compiler) => { 67 | const entries = await compiler.options.entry(); 68 | if (!entries || !entries.main || hasRun) return; 69 | await this.index(compiler, this.options); 70 | hasRun = true; 71 | }); 72 | } 73 | } 74 | 75 | module.exports = WebpackPlugin; 76 | -------------------------------------------------------------------------------- /plugins/search-index.js: -------------------------------------------------------------------------------- 1 | const path = require('path'); 2 | const { getAllPosts, generateIndexSearch } = require('./util'); 3 | 4 | const WebpackPluginCompiler = require('./plugin-compiler'); 5 | 6 | module.exports = function indexSearch(nextConfig = {}) { 7 | const { env, outputDirectory, outputName, verbose = false } = nextConfig; 8 | 9 | const plugin = { 10 | name: 'SearchIndex', 11 | outputDirectory: outputDirectory || './public', 12 | outputName: outputName || 'wp-search.json', 13 | getData: getAllPosts, 14 | generate: generateIndexSearch, 15 | }; 16 | 17 | const { WORDPRESS_GRAPHQL_ENDPOINT } = env; 18 | 19 | return Object.assign({}, nextConfig, { 20 | webpack(config, options) { 21 | if (config.watchOptions) { 22 | config.watchOptions.ignored.push(path.join('**', plugin.outputDirectory, plugin.outputName)); 23 | } 24 | 25 | config.plugins.push( 26 | new WebpackPluginCompiler({ 27 | url: WORDPRESS_GRAPHQL_ENDPOINT, 28 | plugin, 29 | verbose, 30 | }) 31 | ); 32 | 33 | if (typeof nextConfig.webpack === 'function') { 34 | return nextConfig.webpack(config, options); 35 | } 36 | 37 | return config; 38 | }, 39 | }); 40 | }; 41 | -------------------------------------------------------------------------------- /plugins/sitemap.js: -------------------------------------------------------------------------------- 1 | const path = require('path'); 2 | const { getSitemapData, generateSitemap, generateRobotsTxt } = require('./util'); 3 | 4 | const WebpackPluginCompiler = require('./plugin-compiler'); 5 | 6 | module.exports = function sitemap(nextConfig = {}) { 7 | const { env, outputDirectory, outputName, verbose = false } = nextConfig; 8 | 9 | const plugin = { 10 | name: 'Sitemap', 11 | outputDirectory: outputDirectory || './public', 12 | outputName: outputName || 'sitemap.xml', 13 | getData: getSitemapData, 14 | generate: generateSitemap, 15 | postcreate: generateRobotsTxt, 16 | }; 17 | 18 | const { WORDPRESS_GRAPHQL_ENDPOINT } = env; 19 | 20 | return Object.assign({}, nextConfig, { 21 | webpack(config, options) { 22 | if (config.watchOptions) { 23 | config.watchOptions.ignored.push(path.join('**', plugin.outputDirectory, plugin.outputName)); 24 | } 25 | 26 | config.plugins.push( 27 | new WebpackPluginCompiler({ 28 | url: WORDPRESS_GRAPHQL_ENDPOINT, 29 | plugin, 30 | verbose, 31 | nextConfig, 32 | }) 33 | ); 34 | 35 | if (typeof nextConfig.webpack === 'function') { 36 | return nextConfig.webpack(config, options); 37 | } 38 | 39 | return config; 40 | }, 41 | }); 42 | }; 43 | -------------------------------------------------------------------------------- /plugins/socialImages.js: -------------------------------------------------------------------------------- 1 | const fs = require('fs').promises; 2 | const path = require('path'); 3 | const { chromium } = require('playwright'); 4 | 5 | const { getAllPosts, mkdirp } = require('./util'); 6 | const WebpackPluginCompiler = require('./plugin-compiler'); 7 | 8 | const pkg = require('../package.json'); 9 | 10 | module.exports = function socialImages(nextConfig = {}) { 11 | const { 12 | env, 13 | outputDirectory = `./public${nextConfig.env.OG_IMAGE_DIRECTORY}`, 14 | outputName = '[slug].png', 15 | verbose = false, 16 | } = nextConfig; 17 | 18 | const width = 1012; 19 | const height = 506; 20 | 21 | const plugin = { 22 | name: 'SocialImages', 23 | outputDirectory, 24 | outputName, 25 | getData: getAllPosts, 26 | generate: async ({ posts = [] }) => { 27 | mkdirp(outputDirectory); 28 | 29 | const homepage = pkg.homepage && pkg.homepage.replace(/http(s)?:\/\//, ''); 30 | const template = await fs.readFile('./plugins/socialImages.template.html', 'utf8'); 31 | 32 | const browser = await chromium.launch(); 33 | 34 | await Promise.all( 35 | posts.map(async (post) => { 36 | const { title, slug } = post; 37 | let html = template; 38 | 39 | html = html.replace('{{ title }}', title); 40 | html = html.replace('{{ homepage }}', homepage); 41 | 42 | const page = await browser.newPage(); 43 | await page.setViewportSize({ width, height }); 44 | await page.setContent(html); 45 | await page.screenshot({ path: `${outputDirectory}/${slug}.png` }); 46 | await page.close(); 47 | }) 48 | ); 49 | 50 | await browser.close(); 51 | 52 | return false; 53 | }, 54 | }; 55 | 56 | const { WORDPRESS_GRAPHQL_ENDPOINT } = env; 57 | 58 | return Object.assign({}, nextConfig, { 59 | webpack(config, options) { 60 | if (config.watchOptions) { 61 | config.watchOptions.ignored.push(path.join('**', plugin.outputDirectory, plugin.outputName)); 62 | } 63 | 64 | config.plugins.push( 65 | new WebpackPluginCompiler({ 66 | url: WORDPRESS_GRAPHQL_ENDPOINT, 67 | plugin, 68 | verbose, 69 | }) 70 | ); 71 | 72 | if (typeof nextConfig.webpack === 'function') { 73 | return nextConfig.webpack(config, options); 74 | } 75 | 76 | return config; 77 | }, 78 | }); 79 | }; 80 | -------------------------------------------------------------------------------- /plugins/socialImages.template.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 53 | 54 | 55 | 56 |
57 |
58 |

{{ title }}

59 |
60 | 63 |
64 | 65 | 66 | -------------------------------------------------------------------------------- /plugins/util.js: -------------------------------------------------------------------------------- 1 | const fs = require('fs'); 2 | const he = require('he'); 3 | const { gql, ApolloClient, InMemoryCache } = require('@apollo/client'); 4 | const RSS = require('rss'); 5 | const prettier = require('prettier'); 6 | 7 | const config = require('../package.json'); 8 | 9 | /** 10 | * createFile 11 | */ 12 | 13 | async function createFile(file, process, directory, location, verbose = false) { 14 | try { 15 | mkdirp(directory); 16 | verbose && console.log(`[${process}] Created directory ${directory}`); 17 | await promiseToWriteFile(location, file); 18 | verbose && console.log(`[${process}] Successfully wrote file to ${location}`); 19 | } catch (e) { 20 | throw new Error(`[${process}] Failed to create file: ${e.message}`); 21 | } 22 | } 23 | 24 | /** 25 | * promiseToWriteFile 26 | */ 27 | 28 | function promiseToWriteFile(location, content) { 29 | return new Promise((resolve, reject) => { 30 | fs.writeFile(location, content, (err) => { 31 | if (err) { 32 | reject(err); 33 | return; 34 | } 35 | resolve(); 36 | }); 37 | }); 38 | } 39 | 40 | /** 41 | * mkdirp 42 | */ 43 | 44 | function mkdirp(directory) { 45 | const split = directory.split('/'); 46 | let temp = '.'; 47 | 48 | split.forEach((dir) => { 49 | temp = `${temp}/${dir}`; 50 | 51 | if (!fs.existsSync(temp)) { 52 | fs.mkdirSync(temp); 53 | } 54 | }); 55 | } 56 | 57 | /** 58 | * createApolloClient 59 | */ 60 | 61 | function createApolloClient(url) { 62 | return new ApolloClient({ 63 | uri: url, 64 | cache: new InMemoryCache(), 65 | }); 66 | } 67 | 68 | /** 69 | * getAllPosts 70 | */ 71 | 72 | async function getAllPosts(apolloClient, process, verbose = false) { 73 | const query = gql` 74 | { 75 | posts(first: 10000) { 76 | edges { 77 | node { 78 | title 79 | excerpt 80 | databaseId 81 | slug 82 | date 83 | modified 84 | author { 85 | node { 86 | name 87 | } 88 | } 89 | categories { 90 | edges { 91 | node { 92 | name 93 | } 94 | } 95 | } 96 | } 97 | } 98 | } 99 | } 100 | `; 101 | 102 | let posts = []; 103 | 104 | try { 105 | const data = await apolloClient.query({ query }); 106 | const nodes = [...data.data.posts.edges.map(({ node = {} }) => node)]; 107 | 108 | posts = nodes.map((post) => { 109 | const data = { ...post }; 110 | 111 | if (data.author) { 112 | data.author = data.author.node.name; 113 | } 114 | 115 | if (data.categories) { 116 | data.categories = data.categories.edges.map(({ node }) => node.name); 117 | } 118 | 119 | if (data.excerpt) { 120 | //Sanitize the excerpt by removing all HTML tags 121 | const regExHtmlTags = /(<([^>]+)>)/g; 122 | data.excerpt = data.excerpt.replace(regExHtmlTags, ''); 123 | } 124 | 125 | return data; 126 | }); 127 | 128 | verbose && console.log(`[${process}] Successfully fetched posts from ${apolloClient.link.options.uri}`); 129 | return { 130 | posts, 131 | }; 132 | } catch (e) { 133 | throw new Error(`[${process}] Failed to fetch posts from ${apolloClient.link.options.uri}: ${e.message}`); 134 | } 135 | } 136 | 137 | /** 138 | * getSiteMetadata 139 | */ 140 | 141 | async function getSiteMetadata(apolloClient, process, verbose = false) { 142 | const query = gql` 143 | { 144 | generalSettings { 145 | description 146 | language 147 | title 148 | } 149 | } 150 | `; 151 | 152 | let metadata = {}; 153 | 154 | try { 155 | const data = await apolloClient.query({ query }); 156 | metadata = { ...data.data.generalSettings }; 157 | 158 | if (!metadata.language || metadata.language === '') { 159 | metadata.language = 'en'; 160 | } else { 161 | metadata.language = metadata.language.split('_')[0]; 162 | } 163 | 164 | verbose && console.log(`[${process}] Successfully fetched metadata from ${apolloClient.link.options.uri}`); 165 | return { 166 | metadata, 167 | }; 168 | } catch (e) { 169 | throw new Error(`[${process}] Failed to fetch metadata from ${apolloClient.link.options.uri}: ${e.message}`); 170 | } 171 | } 172 | 173 | /** 174 | * getSitePages 175 | */ 176 | 177 | async function getPages(apolloClient, process, verbose = false) { 178 | const query = gql` 179 | { 180 | pages(first: 10000) { 181 | edges { 182 | node { 183 | slug 184 | modified 185 | } 186 | } 187 | } 188 | } 189 | `; 190 | 191 | let pages = []; 192 | 193 | try { 194 | const data = await apolloClient.query({ query }); 195 | pages = [ 196 | ...data.data.pages.edges.map(({ node = {} }) => { 197 | return { 198 | slug: node.slug, 199 | modified: node.modified, 200 | }; 201 | }), 202 | ]; 203 | 204 | verbose && console.log(`[${process}] Successfully fetched page slugs from ${apolloClient.link.options.uri}`); 205 | return { 206 | pages, 207 | }; 208 | } catch (e) { 209 | throw new Error(`[${process}] Failed to fetch page slugs from ${apolloClient.link.options.uri}: ${e.message}`); 210 | } 211 | } 212 | 213 | /** 214 | * getFeedData 215 | */ 216 | 217 | async function getFeedData(apolloClient, process, verbose = false) { 218 | const metadata = await getSiteMetadata(apolloClient, process, verbose); 219 | const posts = await getAllPosts(apolloClient, process, verbose); 220 | 221 | return { 222 | ...metadata, 223 | ...posts, 224 | }; 225 | } 226 | 227 | /** 228 | * getFeedData 229 | */ 230 | 231 | async function getSitemapData(apolloClient, process, verbose = false) { 232 | const posts = await getAllPosts(apolloClient, process, verbose); 233 | const pages = await getPages(apolloClient, process, verbose); 234 | 235 | return { 236 | ...posts, 237 | ...pages, 238 | }; 239 | } 240 | 241 | /** 242 | * generateFeed 243 | */ 244 | 245 | function generateFeed({ posts = [], metadata = {} }) { 246 | const { homepage = '' } = config; 247 | 248 | const feed = new RSS({ 249 | title: metadata.title || '', 250 | description: metadata.description, 251 | site_url: homepage, 252 | feed_url: `${homepage}/feed.xml`, 253 | copyright: `${new Date().getFullYear()} ${metadata.title}`, 254 | language: metadata.language, 255 | pubDate: new Date(), 256 | }); 257 | 258 | posts.map((post) => { 259 | feed.item({ 260 | title: post.title, 261 | guid: `${homepage}/posts/${post.slug}`, 262 | url: `${homepage}/posts/${post.slug}`, 263 | date: post.date, 264 | description: post.excerpt, 265 | author: post.author, 266 | categories: post.categories || [], 267 | }); 268 | }); 269 | 270 | return feed.xml({ indent: true }); 271 | } 272 | 273 | /** 274 | * generateIndexSearch 275 | */ 276 | 277 | function generateIndexSearch({ posts }) { 278 | const index = posts.map((post = {}) => { 279 | // We need to decode the title because we're using the 280 | // rendered version which assumes this value will be used 281 | // within the DOM 282 | 283 | const title = he.decode(post.title); 284 | 285 | return { 286 | title, 287 | slug: post.slug, 288 | date: post.date, 289 | }; 290 | }); 291 | 292 | const indexJson = JSON.stringify({ 293 | generated: Date.now(), 294 | posts: index, 295 | }); 296 | 297 | return indexJson; 298 | } 299 | 300 | /** 301 | * getSitemapData 302 | */ 303 | 304 | function generateSitemap({ posts = [], pages = [] }, nextConfig = {}) { 305 | const { homepage = '' } = config; 306 | const { trailingSlash } = nextConfig; 307 | 308 | const sitemap = ` 309 | 310 | 311 | ${homepage} 312 | ${new Date().toISOString()} 313 | 314 | ${pages 315 | .map((page) => { 316 | return ` 317 | ${homepage}/${page.slug}${trailingSlash ? '/' : ''} 318 | 0.3 319 | ${new Date(page.modified).toISOString()} 320 | 321 | `; 322 | }) 323 | .join('')} 324 | ${posts 325 | .map((post) => { 326 | return ` 327 | ${homepage}/posts/${post.slug}${trailingSlash ? '/' : ''} 328 | ${new Date(post.modified).toISOString()} 329 | 330 | `; 331 | }) 332 | .join('')} 333 | 334 | `; 335 | 336 | const sitemapFormatted = prettier.format(sitemap, { 337 | printWidth: 120, 338 | parser: 'html', 339 | }); 340 | 341 | return sitemapFormatted; 342 | } 343 | 344 | /** 345 | * generateRobotsTxt 346 | */ 347 | 348 | async function generateRobotsTxt({ outputDirectory, outputName }) { 349 | const { homepage = '' } = config; 350 | 351 | try { 352 | // Build sitemap URL at root directory 353 | let sitemapUrl = new URL(outputName, homepage); 354 | 355 | // Check if output directory is not root directory 356 | if (outputDirectory !== './public') { 357 | // Check if output directory is within './public' folder 358 | if (outputDirectory.startsWith('./public')) { 359 | // Update sitemap URL with new directory 360 | sitemapUrl.pathname = resolvePublicPathname(outputDirectory, outputName); 361 | } else { 362 | throw new Error('Sitemap should be within ./public folder.'); 363 | } 364 | } 365 | 366 | // Robots content using sitemap final URL 367 | const robots = `User-agent: *\nSitemap: ${sitemapUrl}`; 368 | 369 | // Create robots.txt always at root directory 370 | await createFile(robots, 'Robots.txt', './public', './public/robots.txt'); 371 | } catch (e) { 372 | throw new Error(`[Robots.txt] Failed to create robots.txt: ${e.message}`); 373 | } 374 | } 375 | 376 | /** 377 | * resolvePathname 378 | */ 379 | 380 | function resolvePublicPathname(outputDirectory, outputName) { 381 | const directory = outputDirectory.split('/'); 382 | const index = directory.indexOf('public'); 383 | const path = directory 384 | .map((path, i) => { 385 | // If actual folder is a 'public' direct subfolder and is not empty, add to pathname 386 | if (i > index && path) { 387 | return `/${path}`; 388 | } 389 | }) 390 | .join(''); 391 | 392 | return `${path}/${outputName}`; 393 | } 394 | 395 | /** 396 | * removeLastTrailingSlash 397 | */ 398 | 399 | function removeLastTrailingSlash(url) { 400 | if (typeof url !== 'string') return url; 401 | return url.replace(/\/$/, ''); 402 | } 403 | 404 | /** 405 | * terminalColor 406 | */ 407 | 408 | function terminalColor(text, level) { 409 | switch (level) { 410 | /** green */ 411 | case 'info': 412 | default: 413 | return `\x1b[32m${text}\x1b[0m`; 414 | /** yellow */ 415 | case 'warn': 416 | return `\x1b[33m${text}\x1b[0m`; 417 | /** red */ 418 | case 'error': 419 | return `\x1b[31m${text}\x1b[0m`; 420 | } 421 | } 422 | 423 | module.exports = { 424 | createFile, 425 | promiseToWriteFile, 426 | mkdirp, 427 | createApolloClient, 428 | getAllPosts, 429 | getSiteMetadata, 430 | getFeedData, 431 | generateFeed, 432 | generateIndexSearch, 433 | getPages, 434 | getSitemapData, 435 | generateSitemap, 436 | generateRobotsTxt, 437 | removeLastTrailingSlash, 438 | resolvePublicPathname, 439 | terminalColor, 440 | }; 441 | -------------------------------------------------------------------------------- /public/android-chrome-192x192.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/colbyfayock/next-wordpress-starter/493507a70958c78b56b0d5866a3e08e79b398d7c/public/android-chrome-192x192.png -------------------------------------------------------------------------------- /public/android-chrome-512x512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/colbyfayock/next-wordpress-starter/493507a70958c78b56b0d5866a3e08e79b398d7c/public/android-chrome-512x512.png -------------------------------------------------------------------------------- /public/apple-touch-icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/colbyfayock/next-wordpress-starter/493507a70958c78b56b0d5866a3e08e79b398d7c/public/apple-touch-icon.png -------------------------------------------------------------------------------- /public/favicon-1024x1024.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/colbyfayock/next-wordpress-starter/493507a70958c78b56b0d5866a3e08e79b398d7c/public/favicon-1024x1024.png -------------------------------------------------------------------------------- /public/favicon-16x16.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/colbyfayock/next-wordpress-starter/493507a70958c78b56b0d5866a3e08e79b398d7c/public/favicon-16x16.png -------------------------------------------------------------------------------- /public/favicon-32x32.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/colbyfayock/next-wordpress-starter/493507a70958c78b56b0d5866a3e08e79b398d7c/public/favicon-32x32.png -------------------------------------------------------------------------------- /public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/colbyfayock/next-wordpress-starter/493507a70958c78b56b0d5866a3e08e79b398d7c/public/favicon.ico -------------------------------------------------------------------------------- /public/site.webmanifest: -------------------------------------------------------------------------------- 1 | { 2 | "name": "Next.js WordPress Starter", 3 | "short_name": "Next.js WP Starter", 4 | "icons": [ 5 | { 6 | "src": "/android-chrome-192x192.png", 7 | "sizes": "192x192", 8 | "type": "image/png" 9 | }, 10 | { 11 | "src": "/android-chrome-512x512.png", 12 | "sizes": "512x512", 13 | "type": "image/png" 14 | } 15 | ], 16 | "theme_color": "#000000", 17 | "background_color": "#000000", 18 | "display": "standalone" 19 | } -------------------------------------------------------------------------------- /public/sitemap.jpg: -------------------------------------------------------------------------------- 1 | undefined -------------------------------------------------------------------------------- /src/components/Breadcrumbs/Breadcrumbs.js: -------------------------------------------------------------------------------- 1 | import Link from 'next/link'; 2 | 3 | import ClassName from 'models/classname'; 4 | 5 | import styles from './Breadcrumbs.module.scss'; 6 | 7 | const Breadcrumbs = ({ className, breadcrumbs }) => { 8 | const breadcrumbsClassName = new ClassName(styles.breadcrumbs); 9 | 10 | breadcrumbsClassName.addIf(className, className); 11 | 12 | return ( 13 |
    14 | {breadcrumbs.map(({ id, title, uri }) => { 15 | return ( 16 |
  • 17 | {!uri && title} 18 | {uri && {title}} 19 |
  • 20 | ); 21 | })} 22 |
23 | ); 24 | }; 25 | 26 | export default Breadcrumbs; 27 | -------------------------------------------------------------------------------- /src/components/Breadcrumbs/Breadcrumbs.module.scss: -------------------------------------------------------------------------------- 1 | @import "styles/settings/__settings"; 2 | 3 | .breadcrumbs { 4 | display: flex; 5 | list-style: none; 6 | padding-left: 0; 7 | margin: 0 0 2em; 8 | 9 | li { 10 | margin-right: 0.5em; 11 | 12 | &:after { 13 | content: "/"; 14 | margin-left: 0.5em; 15 | } 16 | 17 | &:last-child { 18 | &:after { 19 | content: none; 20 | } 21 | } 22 | } 23 | 24 | a { 25 | color: $color-gray-800; 26 | text-decoration: none; 27 | 28 | @media (hover: hover) { 29 | &:hover { 30 | color: $color-primary; 31 | text-decoration: underline; 32 | } 33 | } 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /src/components/Breadcrumbs/index.js: -------------------------------------------------------------------------------- 1 | export { default } from './Breadcrumbs'; 2 | -------------------------------------------------------------------------------- /src/components/Button/Button.js: -------------------------------------------------------------------------------- 1 | import styles from './Button.module.scss'; 2 | 3 | const Button = ({ children, className, ...rest }) => { 4 | let buttonClassName = styles.button; 5 | 6 | if (className) { 7 | buttonClassName = `${buttonClassName} ${className}`; 8 | } 9 | 10 | return ( 11 | 14 | ); 15 | }; 16 | 17 | export default Button; 18 | -------------------------------------------------------------------------------- /src/components/Button/Button.module.scss: -------------------------------------------------------------------------------- 1 | @import "styles/settings/__settings"; 2 | 3 | .button { 4 | color: white; 5 | font-family: inherit; 6 | font-size: 1.3em; 7 | font-weight: 700; 8 | background-color: $color-primary; 9 | padding: 0.8em 1.4em; 10 | border-radius: $border-radius-small; 11 | border: 0; 12 | box-shadow: 0 2px 6px rgba(0, 0, 0, 0.4); 13 | cursor: pointer; 14 | } 15 | -------------------------------------------------------------------------------- /src/components/Button/index.js: -------------------------------------------------------------------------------- 1 | export { default } from './Button'; 2 | -------------------------------------------------------------------------------- /src/components/Container/Container.js: -------------------------------------------------------------------------------- 1 | import ClassName from 'models/classname'; 2 | 3 | import styles from './Container.module.scss'; 4 | 5 | const Container = ({ children, className }) => { 6 | const containerClassName = new ClassName(styles.container); 7 | 8 | containerClassName.addIf(className, className); 9 | 10 | return
{children}
; 11 | }; 12 | 13 | export default Container; 14 | -------------------------------------------------------------------------------- /src/components/Container/Container.module.scss: -------------------------------------------------------------------------------- 1 | @import "styles/settings/__settings"; 2 | 3 | .container { 4 | max-width: 60rem; 5 | padding: 0 2rem; 6 | margin: 0 auto; 7 | } 8 | -------------------------------------------------------------------------------- /src/components/Container/index.js: -------------------------------------------------------------------------------- 1 | export { default } from './Container'; 2 | -------------------------------------------------------------------------------- /src/components/Content/Content.js: -------------------------------------------------------------------------------- 1 | import ClassName from 'models/classname'; 2 | 3 | import styles from './Content.module.scss'; 4 | 5 | const Content = ({ children, className }) => { 6 | const contentClassName = new ClassName(styles.content); 7 | 8 | contentClassName.addIf(className, className); 9 | 10 | return
{children}
; 11 | }; 12 | 13 | export default Content; 14 | -------------------------------------------------------------------------------- /src/components/Content/Content.module.scss: -------------------------------------------------------------------------------- 1 | @import "styles/settings/__settings"; 2 | 3 | .content { 4 | font-size: 1.5rem; 5 | 6 | h2, 7 | h3, 8 | h4, 9 | p, 10 | ul { 11 | &:first-child { 12 | margin-top: 0; 13 | } 14 | 15 | &:last-child { 16 | margin-bottom: 0; 17 | } 18 | } 19 | 20 | img { 21 | display: block; 22 | margin: 0 auto; 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /src/components/Content/index.js: -------------------------------------------------------------------------------- 1 | export { default } from './Content'; 2 | -------------------------------------------------------------------------------- /src/components/FeaturedImage/FeaturedImage.js: -------------------------------------------------------------------------------- 1 | import ClassName from 'models/classname'; 2 | 3 | import Image from 'components/Image'; 4 | 5 | import styles from './FeaturedImage.module.scss'; 6 | 7 | const FeaturedImage = ({ className, alt, ...rest }) => { 8 | const featuredImageClassName = new ClassName(styles.featuredImage); 9 | 10 | featuredImageClassName.addIf(className, className); 11 | 12 | return {alt}; 13 | }; 14 | 15 | export default FeaturedImage; 16 | -------------------------------------------------------------------------------- /src/components/FeaturedImage/FeaturedImage.module.scss: -------------------------------------------------------------------------------- 1 | @use "sass:math"; 2 | @import "styles/settings/__settings"; 3 | 4 | .featuredImage { 5 | margin: 0 0 2em; 6 | 7 | div { 8 | position: relative; 9 | width: 100%; 10 | height: 0; 11 | padding-top: percentage(math.div(400, 960)); 12 | } 13 | 14 | img { 15 | position: absolute; 16 | top: 0; 17 | right: 0; 18 | bottom: 0; 19 | left: 0; 20 | border: 0; 21 | padding: 0; 22 | margin: auto; 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /src/components/FeaturedImage/index.js: -------------------------------------------------------------------------------- 1 | export { default } from './FeaturedImage'; 2 | -------------------------------------------------------------------------------- /src/components/Footer/Footer.js: -------------------------------------------------------------------------------- 1 | import Link from 'next/link'; 2 | 3 | import useSite from 'hooks/use-site'; 4 | import { postPathBySlug } from 'lib/posts'; 5 | import { categoryPathBySlug } from 'lib/categories'; 6 | 7 | import Section from 'components/Section'; 8 | import Container from 'components/Container'; 9 | 10 | import styles from './Footer.module.scss'; 11 | 12 | const Footer = () => { 13 | const { metadata = {}, recentPosts = [], categories = [] } = useSite(); 14 | const { title } = metadata; 15 | 16 | const hasRecentPosts = Array.isArray(recentPosts) && recentPosts.length > 0; 17 | const hasRecentCategories = Array.isArray(categories) && categories.length > 0; 18 | const hasMenu = hasRecentPosts || hasRecentCategories; 19 | 20 | return ( 21 |
22 | {hasMenu && ( 23 |
24 | 25 |
    26 | {hasRecentPosts && ( 27 |
  • 28 | 29 | Recent Posts 30 | 31 |
      32 | {recentPosts.map((post) => { 33 | const { id, slug, title } = post; 34 | return ( 35 |
    • 36 | {title} 37 |
    • 38 | ); 39 | })} 40 |
    41 |
  • 42 | )} 43 | {hasRecentCategories && ( 44 |
  • 45 | 46 | Categories 47 | 48 |
      49 | {categories.map((category) => { 50 | const { id, slug, name } = category; 51 | return ( 52 |
    • 53 | {name} 54 |
    • 55 | ); 56 | })} 57 |
    58 |
  • 59 | )} 60 |
  • 61 |

    62 | More 63 |

    64 | 72 |
  • 73 |
74 |
75 |
76 | )} 77 | 78 |
79 | 80 |

81 | © {new Date().getFullYear()} {title} 82 |

83 |
84 |
85 |
86 | ); 87 | }; 88 | 89 | export default Footer; 90 | -------------------------------------------------------------------------------- /src/components/Footer/Footer.module.scss: -------------------------------------------------------------------------------- 1 | @import "styles/settings/__settings"; 2 | 3 | .footer { 4 | width: 100%; 5 | border-top: 1px solid $color-gray-100; 6 | } 7 | 8 | .footerMenu { 9 | margin: 2rem 0; 10 | 11 | ul { 12 | list-style: none; 13 | padding: 0; 14 | } 15 | } 16 | 17 | .footerMenuColumns { 18 | display: flex; 19 | justify-content: flex-start; 20 | flex-wrap: wrap; 21 | margin: 0 -2em; 22 | 23 | @media (min-width: 480px) { 24 | justify-content: center; 25 | } 26 | 27 | & > li { 28 | max-width: 15em; 29 | margin: 2em; 30 | } 31 | } 32 | 33 | .footerMenuTitle { 34 | display: inline-block; 35 | color: $color-gray-800; 36 | text-decoration: none; 37 | margin-bottom: 1.4em; 38 | margin-top: 0; 39 | } 40 | 41 | .footerMenuItems { 42 | li { 43 | margin-bottom: 1em; 44 | 45 | &:last-child { 46 | margin-bottom: 0; 47 | } 48 | } 49 | 50 | a { 51 | display: block; 52 | overflow: hidden; 53 | color: $color-gray-600; 54 | text-decoration: none; 55 | white-space: nowrap; 56 | text-overflow: ellipsis; 57 | 58 | @media (hover: hover) { 59 | &:hover { 60 | color: $color-primary; 61 | text-decoration: underline; 62 | } 63 | } 64 | } 65 | } 66 | 67 | .footerLegal { 68 | color: $color-gray-400; 69 | background-color: $color-gray-900; 70 | padding: 0.8rem 0; 71 | margin: 0; 72 | 73 | p { 74 | font-size: 0.9em; 75 | text-align: center; 76 | margin: 0; 77 | } 78 | } 79 | -------------------------------------------------------------------------------- /src/components/Footer/index.js: -------------------------------------------------------------------------------- 1 | export { default } from './Footer'; 2 | -------------------------------------------------------------------------------- /src/components/Header/Header.js: -------------------------------------------------------------------------------- 1 | import Container from 'components/Container'; 2 | 3 | import styles from './Header.module.scss'; 4 | 5 | const Header = ({ children }) => { 6 | return ( 7 |
8 | {children} 9 |
10 | ); 11 | }; 12 | 13 | export default Header; 14 | -------------------------------------------------------------------------------- /src/components/Header/Header.module.scss: -------------------------------------------------------------------------------- 1 | @import "styles/settings/__settings"; 2 | 3 | @import "styles/components/_container"; 4 | 5 | .header { 6 | margin: 5rem 0; 7 | 8 | h1 { 9 | margin: 0; 10 | line-height: 1.15; 11 | font-size: 3rem; 12 | text-align: center; 13 | 14 | @media (min-width: 480px) { 15 | font-size: 4rem; 16 | } 17 | 18 | a { 19 | color: $color-primary; 20 | text-decoration: none; 21 | 22 | &:hover, 23 | &:focus, 24 | &:active { 25 | text-decoration: underline; 26 | } 27 | } 28 | 29 | & + ul { 30 | margin-top: 1.5em; 31 | } 32 | } 33 | 34 | p { 35 | &:last-child { 36 | margin-bottom: 0; 37 | } 38 | } 39 | 40 | figure { 41 | margin-left: -2rem; 42 | margin-right: -2rem; 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /src/components/Header/index.js: -------------------------------------------------------------------------------- 1 | export { default } from './Header'; 2 | -------------------------------------------------------------------------------- /src/components/Image/Image.js: -------------------------------------------------------------------------------- 1 | import ClassName from 'models/classname'; 2 | 3 | import styles from './Image.module.scss'; 4 | 5 | const Image = ({ 6 | children, 7 | className, 8 | width = '100%', 9 | height = 'auto', 10 | src, 11 | alt, 12 | srcSet, 13 | sizes, 14 | dangerouslySetInnerHTML, 15 | }) => { 16 | const imageClassName = new ClassName(styles.image); 17 | 18 | imageClassName.addIf(className, className); 19 | 20 | return ( 21 |
22 |
23 | {alt 24 |
25 | {children &&
{children}
} 26 | {dangerouslySetInnerHTML && ( 27 |
32 | )} 33 |
34 | ); 35 | }; 36 | 37 | export default Image; 38 | -------------------------------------------------------------------------------- /src/components/Image/Image.module.scss: -------------------------------------------------------------------------------- 1 | @import "styles/settings/__settings"; 2 | 3 | .image { 4 | div { 5 | overflow: hidden; 6 | margin: 0; 7 | } 8 | 9 | p { 10 | &:first-child { 11 | margin-top: 0; 12 | } 13 | 14 | &:last-child { 15 | margin-top: 0; 16 | } 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /src/components/Image/index.js: -------------------------------------------------------------------------------- 1 | export { default } from './Image'; 2 | -------------------------------------------------------------------------------- /src/components/Layout/Layout.js: -------------------------------------------------------------------------------- 1 | import { useRouter } from 'next/router'; 2 | import { Helmet } from 'react-helmet'; 3 | import styles from './Layout.module.scss'; 4 | 5 | import useSite from 'hooks/use-site'; 6 | import { helmetSettingsFromMetadata } from 'lib/site'; 7 | 8 | import Nav from 'components/Nav'; 9 | import Main from 'components/Main'; 10 | import Footer from 'components/Footer'; 11 | 12 | const Layout = ({ children }) => { 13 | const router = useRouter(); 14 | const { asPath } = router; 15 | 16 | const { homepage, metadata = {} } = useSite(); 17 | 18 | if (!metadata.og) { 19 | metadata.og = {}; 20 | } 21 | 22 | metadata.og.url = `${homepage}${asPath}`; 23 | 24 | const helmetSettings = { 25 | defaultTitle: metadata.title, 26 | titleTemplate: process.env.WORDPRESS_PLUGIN_SEO === true ? '%s' : `%s - ${metadata.title}`, 27 | ...helmetSettingsFromMetadata(metadata, { 28 | setTitle: false, 29 | link: [ 30 | { 31 | rel: 'alternate', 32 | type: 'application/rss+xml', 33 | href: '/feed.xml', 34 | }, 35 | 36 | // Favicon sizes and manifest generated via https://favicon.io/ 37 | 38 | { 39 | rel: 'apple-touch-icon', 40 | sizes: '180x180', 41 | href: '/apple-touch-icon.png', 42 | }, 43 | { 44 | rel: 'icon', 45 | type: 'image/png', 46 | sizes: '16x16', 47 | href: '/favicon-16x16.png', 48 | }, 49 | { 50 | rel: 'icon', 51 | type: 'image/png', 52 | sizes: '32x32', 53 | href: '/favicon-32x32.png', 54 | }, 55 | { 56 | rel: 'manifest', 57 | href: '/site.webmanifest', 58 | }, 59 | ], 60 | }), 61 | }; 62 | 63 | return ( 64 |
65 | 66 | 67 |
73 | ); 74 | }; 75 | 76 | export default Layout; 77 | -------------------------------------------------------------------------------- /src/components/Layout/Layout.module.scss: -------------------------------------------------------------------------------- 1 | .layoutContainer { 2 | display: grid; 3 | grid-template-rows: auto 1fr auto; 4 | min-height: 100vh; 5 | } 6 | -------------------------------------------------------------------------------- /src/components/Layout/index.js: -------------------------------------------------------------------------------- 1 | export { default } from './Layout'; 2 | -------------------------------------------------------------------------------- /src/components/Main/Main.js: -------------------------------------------------------------------------------- 1 | import styles from './Main.module.scss'; 2 | 3 | const Main = ({ children }) => { 4 | return
{children}
; 5 | }; 6 | 7 | export default Main; 8 | -------------------------------------------------------------------------------- /src/components/Main/Main.module.scss: -------------------------------------------------------------------------------- 1 | .main { 2 | } 3 | -------------------------------------------------------------------------------- /src/components/Main/index.js: -------------------------------------------------------------------------------- 1 | export { default } from './Main'; 2 | -------------------------------------------------------------------------------- /src/components/Metadata/Metadata.js: -------------------------------------------------------------------------------- 1 | import Link from 'next/link'; 2 | 3 | import { categoryPathBySlug } from 'lib/categories'; 4 | import { authorPathByName } from 'lib/users'; 5 | import { formatDate } from 'lib/datetime'; 6 | import ClassName from 'models/classname'; 7 | 8 | import { FaMapPin } from 'react-icons/fa'; 9 | import styles from './Metadata.module.scss'; 10 | 11 | const DEFAULT_METADATA_OPTIONS = { 12 | compactCategories: true, 13 | }; 14 | 15 | const Metadata = ({ className, author, date, categories, options = DEFAULT_METADATA_OPTIONS, isSticky = false }) => { 16 | const metadataClassName = new ClassName(styles.metadata); 17 | 18 | metadataClassName.addIf(className, className); 19 | 20 | const { compactCategories } = options; 21 | 22 | return ( 23 |
    24 | {author && ( 25 |
  • 26 |
    27 | {author.avatar && ( 28 | Author Avatar 34 | )} 35 | By{' '} 36 | 37 | {author.name} 38 | 39 |
    40 |
  • 41 | )} 42 | {date && ( 43 |
  • 44 | 47 |
  • 48 | )} 49 | {Array.isArray(categories) && categories[0] && ( 50 |
  • 51 | {compactCategories && ( 52 |

    name).join(', ')}> 53 | {categories[0].name} 54 | {categories.length > 1 && ' and more'} 55 |

    56 | )} 57 | {!compactCategories && ( 58 |
      59 | {categories.map((category) => { 60 | return ( 61 |
    • 62 | {category.name} 63 |
    • 64 | ); 65 | })} 66 |
    67 | )} 68 |
  • 69 | )} 70 | {isSticky && ( 71 |
  • 72 | 73 |
  • 74 | )} 75 |
76 | ); 77 | }; 78 | 79 | export default Metadata; 80 | -------------------------------------------------------------------------------- /src/components/Metadata/Metadata.module.scss: -------------------------------------------------------------------------------- 1 | @import "styles/settings/__settings"; 2 | 3 | .metadata { 4 | display: flex; 5 | align-items: center; 6 | flex-wrap: wrap; 7 | color: $color-gray-600; 8 | list-style: none; 9 | padding: 0; 10 | margin: 0 -0.8em; 11 | 12 | ul { 13 | list-style: none; 14 | padding: 0; 15 | margin: 0; 16 | } 17 | 18 | & > li { 19 | margin: 0.8em; 20 | } 21 | 22 | p { 23 | margin: 0; 24 | } 25 | 26 | a { 27 | color: inherit; 28 | text-decoration: none; 29 | 30 | &:hover { 31 | color: $color-primary; 32 | text-decoration: underline; 33 | } 34 | } 35 | } 36 | 37 | .metadataAuthor { 38 | a { 39 | margin-left: 0.2em; 40 | } 41 | 42 | address { 43 | display: flex; 44 | align-items: center; 45 | font-style: normal; 46 | } 47 | 48 | img { 49 | width: 1.4em; 50 | height: auto; 51 | border-radius: 50%; 52 | margin-right: 0.5em; 53 | } 54 | } 55 | 56 | .metadataCategories { 57 | li { 58 | display: inline-block; 59 | 60 | &:after { 61 | content: ", "; 62 | margin-right: 0.4em; 63 | } 64 | 65 | &:last-child { 66 | &:after { 67 | content: none; 68 | } 69 | } 70 | } 71 | } 72 | 73 | .metadataSticky { 74 | font-size: 1.15em; 75 | color: $color-gray-300; 76 | } 77 | -------------------------------------------------------------------------------- /src/components/Metadata/index.js: -------------------------------------------------------------------------------- 1 | export { default } from './Metadata'; 2 | -------------------------------------------------------------------------------- /src/components/Nav/Nav.js: -------------------------------------------------------------------------------- 1 | import { useEffect, useRef, useState, useCallback } from 'react'; 2 | import Link from 'next/link'; 3 | import { FaSearch } from 'react-icons/fa'; 4 | 5 | import useSite from 'hooks/use-site'; 6 | import useSearch, { SEARCH_STATE_LOADED } from 'hooks/use-search'; 7 | import { postPathBySlug } from 'lib/posts'; 8 | import { findMenuByLocation, MENU_LOCATION_NAVIGATION_DEFAULT } from 'lib/menus'; 9 | 10 | import Section from 'components/Section'; 11 | 12 | import styles from './Nav.module.scss'; 13 | import NavListItem from 'components/NavListItem'; 14 | 15 | const SEARCH_VISIBLE = 'visible'; 16 | const SEARCH_HIDDEN = 'hidden'; 17 | 18 | const Nav = () => { 19 | const formRef = useRef(); 20 | 21 | const [searchVisibility, setSearchVisibility] = useState(SEARCH_HIDDEN); 22 | 23 | const { metadata = {}, menus } = useSite(); 24 | const { title } = metadata; 25 | 26 | const navigationLocation = process.env.WORDPRESS_MENU_LOCATION_NAVIGATION || MENU_LOCATION_NAVIGATION_DEFAULT; 27 | const navigation = findMenuByLocation(menus, navigationLocation); 28 | 29 | const { query, results, search, clearSearch, state } = useSearch({ 30 | maxResults: 5, 31 | }); 32 | 33 | const searchIsLoaded = state === SEARCH_STATE_LOADED; 34 | 35 | // When the search visibility changes, we want to add an event listener that allows us to 36 | // detect when someone clicks outside of the search box, allowing us to close the results 37 | // when focus is drawn away from search 38 | 39 | useEffect(() => { 40 | // If we don't have a query, don't need to bother adding an event listener 41 | // but run the cleanup in case the previous state instance exists 42 | 43 | if (searchVisibility === SEARCH_HIDDEN) { 44 | removeDocumentOnClick(); 45 | return; 46 | } 47 | 48 | addDocumentOnClick(); 49 | addResultsRoving(); 50 | 51 | // When the search box opens up, additionall find the search input and focus 52 | // on the element so someone can start typing right away 53 | 54 | const searchInput = Array.from(formRef.current.elements).find((input) => input.type === 'search'); 55 | 56 | searchInput.focus(); 57 | 58 | return () => { 59 | removeResultsRoving(); 60 | removeDocumentOnClick(); 61 | }; 62 | // eslint-disable-next-line react-hooks/exhaustive-deps 63 | }, [searchVisibility]); 64 | 65 | /** 66 | * addDocumentOnClick 67 | */ 68 | 69 | function addDocumentOnClick() { 70 | document.body.addEventListener('click', handleOnDocumentClick, true); 71 | } 72 | 73 | /** 74 | * removeDocumentOnClick 75 | */ 76 | 77 | function removeDocumentOnClick() { 78 | document.body.removeEventListener('click', handleOnDocumentClick, true); 79 | } 80 | 81 | /** 82 | * handleOnDocumentClick 83 | */ 84 | 85 | function handleOnDocumentClick(e) { 86 | if (!e.composedPath().includes(formRef.current)) { 87 | setSearchVisibility(SEARCH_HIDDEN); 88 | clearSearch(); 89 | } 90 | } 91 | 92 | /** 93 | * handleOnSearch 94 | */ 95 | 96 | function handleOnSearch({ currentTarget }) { 97 | search({ 98 | query: currentTarget.value, 99 | }); 100 | } 101 | 102 | /** 103 | * handleOnToggleSearch 104 | */ 105 | 106 | function handleOnToggleSearch() { 107 | setSearchVisibility(SEARCH_VISIBLE); 108 | } 109 | 110 | /** 111 | * addResultsRoving 112 | */ 113 | 114 | function addResultsRoving() { 115 | document.body.addEventListener('keydown', handleResultsRoving); 116 | } 117 | 118 | /** 119 | * removeResultsRoving 120 | */ 121 | 122 | function removeResultsRoving() { 123 | document.body.removeEventListener('keydown', handleResultsRoving); 124 | } 125 | 126 | /** 127 | * handleResultsRoving 128 | */ 129 | 130 | function handleResultsRoving(e) { 131 | const focusElement = document.activeElement; 132 | 133 | if (e.key === 'ArrowDown') { 134 | e.preventDefault(); 135 | if (focusElement.nodeName === 'INPUT' && focusElement.nextSibling.children[0].nodeName !== 'P') { 136 | focusElement.nextSibling.children[0].firstChild.firstChild.focus(); 137 | } else if (focusElement.parentElement.nextSibling) { 138 | focusElement.parentElement.nextSibling.firstChild.focus(); 139 | } else { 140 | focusElement.parentElement.parentElement.firstChild.firstChild.focus(); 141 | } 142 | } 143 | 144 | if (e.key === 'ArrowUp') { 145 | e.preventDefault(); 146 | if (focusElement.nodeName === 'A' && focusElement.parentElement.previousSibling) { 147 | focusElement.parentElement.previousSibling.firstChild.focus(); 148 | } else { 149 | focusElement.parentElement.parentElement.lastChild.firstChild.focus(); 150 | } 151 | } 152 | } 153 | 154 | /** 155 | * escFunction 156 | */ 157 | 158 | // pressing esc while search is focused will close it 159 | 160 | const escFunction = useCallback((event) => { 161 | if (event.keyCode === 27) { 162 | clearSearch(); 163 | setSearchVisibility(SEARCH_HIDDEN); 164 | } 165 | // eslint-disable-next-line react-hooks/exhaustive-deps 166 | }, []); 167 | 168 | useEffect(() => { 169 | document.addEventListener('keydown', escFunction, false); 170 | 171 | return () => { 172 | document.removeEventListener('keydown', escFunction, false); 173 | }; 174 | // eslint-disable-next-line react-hooks/exhaustive-deps 175 | }, []); 176 | 177 | return ( 178 | 231 | ); 232 | }; 233 | 234 | export default Nav; 235 | -------------------------------------------------------------------------------- /src/components/Nav/Nav.module.scss: -------------------------------------------------------------------------------- 1 | @import "styles/settings/__settings"; 2 | 3 | .nav { 4 | width: 100%; 5 | border-bottom: 1px solid $color-gray-100; 6 | padding: 0 1rem; 7 | } 8 | 9 | .navSection { 10 | display: flex; 11 | align-items: center; 12 | justify-content: center; 13 | flex-direction: column; 14 | height: 100%; 15 | padding-top: 0; 16 | padding-bottom: 0; 17 | margin: 0; 18 | 19 | @media (min-width: 480px) { 20 | justify-content: space-between; 21 | flex-direction: row; 22 | } 23 | } 24 | 25 | .navName { 26 | display: flex; 27 | justify-content: center; 28 | align-items: center; 29 | flex-shrink: 0; 30 | flex-grow: 1; 31 | margin: 0.8em 0 0; 32 | 33 | @media (min-width: 480px) { 34 | justify-content: flex-start; 35 | margin-top: 0; 36 | } 37 | 38 | a { 39 | color: $color-gray-500; 40 | font-size: 1.2rem; 41 | font-weight: bold; 42 | text-decoration: none; 43 | border-bottom: solid 2px transparent; 44 | 45 | @media (min-width: 480px) { 46 | padding: 0.5em; 47 | margin-left: -0.5em; 48 | } 49 | 50 | &:hover { 51 | color: $color-primary; 52 | } 53 | } 54 | } 55 | 56 | .navSearch { 57 | flex-grow: 0; 58 | margin-left: 1em; 59 | form { 60 | display: flex; 61 | align-items: center; 62 | justify-content: center; 63 | position: relative; 64 | width: 100%; 65 | height: 100%; 66 | padding: 1em; 67 | 68 | @media (min-width: 480px) { 69 | justify-content: flex-end; 70 | margin-right: -1rem; 71 | } 72 | } 73 | 74 | input { 75 | font-size: 0.845em; 76 | } 77 | 78 | button { 79 | font-size: 1.2em; 80 | background: none; 81 | padding: 1.045em; 82 | border: none; 83 | outline: none; 84 | cursor: pointer; 85 | 86 | &[disabled] { 87 | svg { 88 | fill: $color-gray-200; 89 | transition: fill 0.5s; 90 | } 91 | } 92 | 93 | svg { 94 | fill: $color-gray-400; 95 | transform: translateY(2px); 96 | } 97 | 98 | &:focus { 99 | svg { 100 | fill: $color-primary; 101 | } 102 | } 103 | } 104 | } 105 | 106 | .navSearchResults { 107 | display: none; 108 | position: absolute; 109 | top: 100%; 110 | right: 0; 111 | width: 100vw; 112 | background-color: white; 113 | padding: 1.5em; 114 | box-shadow: 0 3px 6px rgba(0, 0, 0, 0.3); 115 | border-top: solid 5px $color-primary; 116 | margin-right: -1rem; 117 | z-index: 999; 118 | 119 | @media (min-width: 480px) { 120 | width: 30em; 121 | margin-right: 0; 122 | } 123 | 124 | [data-search-is-active="true"] & { 125 | display: block; 126 | } 127 | 128 | p { 129 | line-height: 1.15; 130 | margin: 0; 131 | } 132 | 133 | ul { 134 | list-style: none; 135 | padding: 0; 136 | margin: -0.5em 0; 137 | } 138 | 139 | a { 140 | display: block; 141 | color: $color-gray-800; 142 | text-decoration: none; 143 | padding: 0.5em; 144 | margin: 0 -0.5em; 145 | &:focus { 146 | outline: 2px solid $color-blue-500; 147 | } 148 | 149 | @media (hover: hover) { 150 | &:hover { 151 | color: $color-primary; 152 | } 153 | } 154 | } 155 | } 156 | 157 | .navMenu { 158 | display: flex; 159 | align-items: center; 160 | flex-grow: 0; 161 | list-style: none; 162 | padding: 0; 163 | margin: 0; 164 | 165 | li { 166 | position: relative; 167 | z-index: 1; 168 | margin: 0 0.25em; 169 | 170 | &:first-child { 171 | margin-left: 0; 172 | } 173 | 174 | &:last-child { 175 | margin-right: 0; 176 | } 177 | 178 | &:hover { 179 | & > a { 180 | color: $color-primary; 181 | } 182 | 183 | > .navSubMenu { 184 | display: block; 185 | } 186 | } 187 | 188 | & > .navSubMenu { 189 | box-shadow: 0 2px 6px rgba(0, 0, 0, 0.3); 190 | padding: 0.5em 0.8em; 191 | } 192 | 193 | .navSubMenu { 194 | ul { 195 | top: 0; 196 | left: 100%; 197 | } 198 | } 199 | } 200 | 201 | a { 202 | display: block; 203 | text-decoration: none; 204 | color: $color-gray-600; 205 | font-size: 1.1em; 206 | padding: 0.5em; 207 | 208 | @media (hover: hover) { 209 | &:hover { 210 | color: $color-primary; 211 | } 212 | } 213 | } 214 | 215 | .navSubMenu { 216 | display: none; 217 | position: absolute; 218 | white-space: nowrap; 219 | list-style: none; 220 | background-color: #fff; 221 | padding: 0; 222 | 223 | li { 224 | background-color: white; 225 | margin: 0; 226 | 227 | a { 228 | font-size: 1rem; 229 | padding: 0.3em; 230 | } 231 | } 232 | } 233 | } 234 | -------------------------------------------------------------------------------- /src/components/Nav/index.js: -------------------------------------------------------------------------------- 1 | export { default } from './Nav'; 2 | -------------------------------------------------------------------------------- /src/components/NavListItem/NavListItem.js: -------------------------------------------------------------------------------- 1 | // import ClassName from 'models/classname'; 2 | // import styles from './NavListItem.module.scss'; 3 | import Link from 'next/link'; 4 | 5 | const NavListItem = ({ className, item }) => { 6 | const nestedItems = (item.children || []).map((item) => { 7 | return ; 8 | }); 9 | 10 | return ( 11 |
  • 12 | {/* 13 | Before rendering the Link component, we first check if `item.path` exists 14 | and if it does not include 'http'. This prevents a TypeError when `item.path` is null. 15 | */} 16 | {item.path && !item.path.includes('http') && !item.target && ( 17 | 18 | {item.label} 19 | 20 | )} 21 | {/* 22 | Before rendering the `a` tag, we first check if `item.path` exists 23 | and if it includes 'http'. This prevents a TypeError when `item.path` is null. 24 | */} 25 | {item.path && item.path.includes('http') && ( 26 | 27 | {item.label} 28 | 29 | )} 30 | 31 | {nestedItems.length > 0 &&
      {nestedItems}
    } 32 |
  • 33 | ); 34 | }; 35 | 36 | export default NavListItem; 37 | -------------------------------------------------------------------------------- /src/components/NavListItem/NavListItem.module.scss: -------------------------------------------------------------------------------- 1 | @import "styles/settings/__settings"; 2 | -------------------------------------------------------------------------------- /src/components/NavListItem/index.js: -------------------------------------------------------------------------------- 1 | export { default } from './NavListItem'; 2 | -------------------------------------------------------------------------------- /src/components/Pagination/Pagination.js: -------------------------------------------------------------------------------- 1 | import Link from 'next/link'; 2 | 3 | import config from '../../../package.json'; 4 | import { Helmet } from 'react-helmet'; 5 | 6 | import { GrPrevious as PreviousIcon, GrNext as NextIcon } from 'react-icons/gr'; 7 | import { HiOutlineDotsHorizontal as Dots } from 'react-icons/hi'; 8 | import styles from './Pagination.module.scss'; 9 | 10 | const MAX_NUM_PAGES = 9; 11 | 12 | const { homepage = '' } = config; 13 | 14 | const Pagination = ({ pagesCount, currentPage, basePath, addCanonical = true }) => { 15 | const path = `${basePath}/page/`; 16 | 17 | const hasPreviousPage = pagesCount > 1 && currentPage > 1; 18 | const hasNextPage = pagesCount > 1 && currentPage < pagesCount; 19 | 20 | let hasPrevDots = false; 21 | let hasNextDots = false; 22 | 23 | function getPages() { 24 | let pages = pagesCount; 25 | let start = 0; 26 | // If the number of pages exceeds the max 27 | if (pagesCount > MAX_NUM_PAGES) { 28 | // Set number of pages to the max 29 | pages = MAX_NUM_PAGES; 30 | const half = Math.ceil(MAX_NUM_PAGES / 2); 31 | const isHead = currentPage <= half; 32 | const isTail = currentPage > pagesCount - half; 33 | hasNextDots = !isTail; 34 | // If the current page is at the head, the start variable remains 0 35 | if (!isHead) { 36 | hasPrevDots = true; 37 | // If the current page is at the tail, the start variable is set to 38 | // the last chunk. Otherwise the start variable will place the current 39 | // page at the middle 40 | start = isTail ? pagesCount - MAX_NUM_PAGES : currentPage - half; 41 | } 42 | } 43 | return [...new Array(pages)].map((_, i) => i + 1 + start); 44 | } 45 | 46 | const pages = getPages(); 47 | 48 | return ( 49 | <> 50 | 51 | {addCanonical && !hasPreviousPage && } 52 | {hasPreviousPage && } 53 | {hasNextPage && } 54 | 55 | 56 | 98 | 99 | ); 100 | }; 101 | 102 | export default Pagination; 103 | -------------------------------------------------------------------------------- /src/components/Pagination/Pagination.module.scss: -------------------------------------------------------------------------------- 1 | @import "styles/settings/__settings"; 2 | 3 | .nav { 4 | position: relative; 5 | display: flex; 6 | justify-content: flex-end; 7 | margin-top: 6em; 8 | 9 | > * { 10 | display: flex; 11 | gap: 0.3em; 12 | align-items: center; 13 | justify-content: center; 14 | font-size: 1em; 15 | } 16 | 17 | a { 18 | color: $color-gray-800; 19 | text-decoration: none; 20 | cursor: pointer; 21 | border: 1px solid $color-gray-200; 22 | border-radius: 0.4em; 23 | padding: 0.2em 0.8em; 24 | &:hover { 25 | color: $color-primary; 26 | background-color: $color-gray-50; 27 | } 28 | svg { 29 | width: 0.8rem; 30 | } 31 | } 32 | 33 | ul { 34 | list-style-type: none; 35 | } 36 | } 37 | 38 | .active { 39 | font-weight: 600; 40 | padding: 0.2em 0.8em; 41 | } 42 | 43 | .pages { 44 | position: absolute; 45 | left: 50%; 46 | top: 50%; 47 | transform: translate(-50%, -50%); 48 | @media (max-width: 768px) { 49 | display: none; 50 | } 51 | } 52 | .prev { 53 | margin-right: auto; 54 | } 55 | 56 | .dots { 57 | display: flex; 58 | align-self: flex-end; 59 | margin: 0 0.2rem -0.2rem 0.2rem; 60 | } 61 | -------------------------------------------------------------------------------- /src/components/Pagination/index.js: -------------------------------------------------------------------------------- 1 | export { default } from './Pagination'; 2 | -------------------------------------------------------------------------------- /src/components/PostCard/PostCard.js: -------------------------------------------------------------------------------- 1 | import Link from 'next/link'; 2 | 3 | import { postPathBySlug, sanitizeExcerpt } from 'lib/posts'; 4 | 5 | import Metadata from 'components/Metadata'; 6 | 7 | import { FaMapPin } from 'react-icons/fa'; 8 | import styles from './PostCard.module.scss'; 9 | 10 | const PostCard = ({ post, options = {} }) => { 11 | const { title, excerpt, slug, date, author, categories, isSticky = false } = post; 12 | const { excludeMetadata = [] } = options; 13 | 14 | const metadata = {}; 15 | 16 | if (!excludeMetadata.includes('author')) { 17 | metadata.author = author; 18 | } 19 | 20 | if (!excludeMetadata.includes('date')) { 21 | metadata.date = date; 22 | } 23 | 24 | if (!excludeMetadata.includes('categories')) { 25 | metadata.categories = categories; 26 | } 27 | 28 | let postCardStyle = styles.postCard; 29 | 30 | if (isSticky) { 31 | postCardStyle = `${postCardStyle} ${styles.postCardSticky}`; 32 | } 33 | 34 | return ( 35 |
    36 | {isSticky && } 37 | 38 |

    44 | 45 | 46 | {excerpt && ( 47 |
    53 | )} 54 |
    55 | ); 56 | }; 57 | 58 | export default PostCard; 59 | -------------------------------------------------------------------------------- /src/components/PostCard/PostCard.module.scss: -------------------------------------------------------------------------------- 1 | @import "styles/settings/__settings"; 2 | 3 | .postCard { 4 | position: relative; 5 | padding: 1.2em; 6 | color: inherit; 7 | text-align: left; 8 | text-decoration: none; 9 | 10 | & > a { 11 | display: block; 12 | color: inherit; 13 | text-decoration: none; 14 | 15 | &:hover { 16 | h3 { 17 | color: $color-primary; 18 | text-decoration: underline; 19 | } 20 | } 21 | } 22 | } 23 | 24 | .postCardSticky { 25 | border: solid 0.02em $color-gray-100; 26 | border-radius: 1em; 27 | 28 | svg { 29 | position: absolute; 30 | top: 1.2em; 31 | right: 1em; 32 | font-size: 1.15em; 33 | color: $color-gray-300; 34 | } 35 | } 36 | 37 | .postCardTitle { 38 | margin: 0 0 1em 0; 39 | font-size: 1.5em; 40 | } 41 | 42 | .postCardContent { 43 | font-size: 1.25em; 44 | margin: 0; 45 | 46 | p { 47 | &:first-child { 48 | margin-top: 0; 49 | } 50 | 51 | &:last-child { 52 | margin-bottom: 0; 53 | } 54 | } 55 | } 56 | 57 | .postCardMetadata { 58 | margin: -0.8em -0.8em 0.4em; 59 | } 60 | -------------------------------------------------------------------------------- /src/components/PostCard/index.js: -------------------------------------------------------------------------------- 1 | export { default } from './PostCard'; 2 | -------------------------------------------------------------------------------- /src/components/Section/Section.js: -------------------------------------------------------------------------------- 1 | import ClassName from 'models/classname'; 2 | 3 | import styles from './Section.module.scss'; 4 | 5 | const Section = ({ children, className, ...rest }) => { 6 | const sectionClassName = new ClassName(styles.section); 7 | 8 | sectionClassName.addIf(className, className); 9 | 10 | return ( 11 |
    12 | {children} 13 |
    14 | ); 15 | }; 16 | 17 | export default Section; 18 | -------------------------------------------------------------------------------- /src/components/Section/Section.module.scss: -------------------------------------------------------------------------------- 1 | @import "styles/settings/__settings"; 2 | 3 | .section { 4 | width: 100%; 5 | padding: 2rem 0; 6 | margin: 3rem 0; 7 | } 8 | -------------------------------------------------------------------------------- /src/components/Section/index.js: -------------------------------------------------------------------------------- 1 | export { default } from './Section'; 2 | -------------------------------------------------------------------------------- /src/components/SectionTitle/SectionTitle.js: -------------------------------------------------------------------------------- 1 | import styles from './SectionTitle.module.scss'; 2 | 3 | const SectionTitle = ({ children }) => { 4 | return

    {children}

    ; 5 | }; 6 | 7 | export default SectionTitle; 8 | -------------------------------------------------------------------------------- /src/components/SectionTitle/SectionTitle.module.scss: -------------------------------------------------------------------------------- 1 | @import "styles/settings/__settings"; 2 | 3 | .sectionTitle { 4 | color: $color-gray-600; 5 | font-weight: normal; 6 | padding-bottom: 1em; 7 | border-bottom: solid 1px $color-gray-100; 8 | margin-top: 0; 9 | margin-bottom: 3.5em; 10 | } 11 | -------------------------------------------------------------------------------- /src/components/SectionTitle/index.js: -------------------------------------------------------------------------------- 1 | export { default } from './SectionTitle'; 2 | -------------------------------------------------------------------------------- /src/components/Title/Title.js: -------------------------------------------------------------------------------- 1 | import ClassName from 'models/classname'; 2 | 3 | import styles from './Title.module.scss'; 4 | 5 | const Title = ({ className, title, thumbnail }) => { 6 | const titleClassName = new ClassName(styles.title); 7 | 8 | titleClassName.addIf(className, className); 9 | 10 | return ( 11 |
    12 | {thumbnail && } 13 | {title} 14 |
    15 | ); 16 | }; 17 | 18 | export default Title; 19 | -------------------------------------------------------------------------------- /src/components/Title/Title.module.scss: -------------------------------------------------------------------------------- 1 | @import "styles/settings/__settings"; 2 | 3 | .title { 4 | display: flex; 5 | justify-content: center; 6 | align-items: center; 7 | flex-direction: column; 8 | width: 100%; 9 | 10 | img { 11 | width: 1.4em; 12 | height: auto; 13 | border-radius: 50%; 14 | margin-bottom: 0.3em; 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /src/components/Title/index.js: -------------------------------------------------------------------------------- 1 | export { default } from './Title'; 2 | -------------------------------------------------------------------------------- /src/data/categories.js: -------------------------------------------------------------------------------- 1 | import { gql } from '@apollo/client'; 2 | 3 | export const QUERY_ALL_CATEGORIES = gql` 4 | query AllCategories { 5 | categories(first: 10000) { 6 | edges { 7 | node { 8 | databaseId 9 | description 10 | id 11 | name 12 | slug 13 | } 14 | } 15 | } 16 | } 17 | `; 18 | 19 | export const QUERY_CATEGORY_BY_SLUG = gql` 20 | query CategoryBySlug($slug: ID!) { 21 | category(id: $slug, idType: SLUG) { 22 | databaseId 23 | description 24 | id 25 | name 26 | slug 27 | } 28 | } 29 | `; 30 | 31 | export const QUERY_CATEGORY_SEO_BY_SLUG = gql` 32 | query CategorySEOBySlug($slug: ID!) { 33 | category(id: $slug, idType: SLUG) { 34 | id 35 | seo { 36 | canonical 37 | metaDesc 38 | metaRobotsNofollow 39 | metaRobotsNoindex 40 | opengraphAuthor 41 | opengraphDescription 42 | opengraphModifiedTime 43 | opengraphPublishedTime 44 | opengraphPublisher 45 | opengraphTitle 46 | opengraphType 47 | title 48 | twitterDescription 49 | twitterTitle 50 | twitterImage { 51 | altText 52 | sourceUrl 53 | mediaDetails { 54 | width 55 | height 56 | } 57 | } 58 | opengraphImage { 59 | altText 60 | sourceUrl 61 | mediaDetails { 62 | height 63 | width 64 | } 65 | } 66 | } 67 | } 68 | } 69 | `; 70 | -------------------------------------------------------------------------------- /src/data/menus.js: -------------------------------------------------------------------------------- 1 | import { gql } from '@apollo/client'; 2 | 3 | export const QUERY_ALL_MENUS = gql` 4 | query AllMenus { 5 | menus { 6 | edges { 7 | node { 8 | id 9 | menuItems { 10 | edges { 11 | node { 12 | cssClasses 13 | id 14 | parentId 15 | label 16 | title 17 | target 18 | path 19 | } 20 | } 21 | } 22 | name 23 | slug 24 | locations 25 | } 26 | } 27 | } 28 | } 29 | `; 30 | -------------------------------------------------------------------------------- /src/data/pages.js: -------------------------------------------------------------------------------- 1 | import { gql } from '@apollo/client'; 2 | 3 | export const PAGE_FIELDS = gql` 4 | fragment PageFields on Page { 5 | children { 6 | edges { 7 | node { 8 | id 9 | slug 10 | uri 11 | ... on Page { 12 | id 13 | title 14 | } 15 | } 16 | } 17 | } 18 | id 19 | menuOrder 20 | parent { 21 | node { 22 | id 23 | slug 24 | uri 25 | ... on Page { 26 | title 27 | } 28 | } 29 | } 30 | slug 31 | title 32 | uri 33 | } 34 | `; 35 | 36 | export const QUERY_ALL_PAGES_INDEX = gql` 37 | ${PAGE_FIELDS} 38 | query AllPagesIndex { 39 | pages(first: 10000, where: { hasPassword: false }) { 40 | edges { 41 | node { 42 | ...PageFields 43 | } 44 | } 45 | } 46 | } 47 | `; 48 | 49 | export const QUERY_ALL_PAGES_ARCHIVE = gql` 50 | ${PAGE_FIELDS} 51 | query AllPagesIndex { 52 | pages(first: 10000, where: { hasPassword: false }) { 53 | edges { 54 | node { 55 | ...PageFields 56 | } 57 | } 58 | } 59 | } 60 | `; 61 | 62 | export const QUERY_ALL_PAGES = gql` 63 | ${PAGE_FIELDS} 64 | query AllPagesIndex { 65 | pages(first: 10000, where: { hasPassword: false }) { 66 | edges { 67 | node { 68 | ...PageFields 69 | content 70 | featuredImage { 71 | node { 72 | altText 73 | caption 74 | id 75 | sizes 76 | sourceUrl 77 | srcSet 78 | } 79 | } 80 | } 81 | } 82 | } 83 | } 84 | `; 85 | 86 | export const QUERY_PAGE_BY_URI = gql` 87 | query PageByUri($uri: ID!) { 88 | page(id: $uri, idType: URI) { 89 | children { 90 | edges { 91 | node { 92 | id 93 | slug 94 | uri 95 | ... on Page { 96 | id 97 | title 98 | } 99 | } 100 | } 101 | } 102 | content 103 | featuredImage { 104 | node { 105 | altText 106 | caption 107 | id 108 | sizes 109 | sourceUrl 110 | srcSet 111 | } 112 | } 113 | id 114 | menuOrder 115 | parent { 116 | node { 117 | id 118 | slug 119 | uri 120 | ... on Page { 121 | title 122 | } 123 | } 124 | } 125 | slug 126 | title 127 | uri 128 | } 129 | } 130 | `; 131 | 132 | export const QUERY_PAGE_SEO_BY_URI = gql` 133 | query PageSEOByUri($uri: ID!) { 134 | page(id: $uri, idType: URI) { 135 | id 136 | seo { 137 | canonical 138 | metaDesc 139 | metaRobotsNofollow 140 | metaRobotsNoindex 141 | opengraphAuthor 142 | opengraphDescription 143 | opengraphModifiedTime 144 | opengraphPublishedTime 145 | opengraphPublisher 146 | opengraphTitle 147 | opengraphType 148 | readingTime 149 | title 150 | twitterDescription 151 | twitterTitle 152 | twitterImage { 153 | altText 154 | sourceUrl 155 | mediaDetails { 156 | width 157 | height 158 | } 159 | } 160 | opengraphImage { 161 | altText 162 | sourceUrl 163 | mediaDetails { 164 | height 165 | width 166 | } 167 | } 168 | } 169 | } 170 | } 171 | `; 172 | -------------------------------------------------------------------------------- /src/data/posts.js: -------------------------------------------------------------------------------- 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/data/site.js: -------------------------------------------------------------------------------- 1 | import { gql } from '@apollo/client'; 2 | 3 | export const QUERY_SITE_DATA = gql` 4 | query SiteData { 5 | generalSettings { 6 | description 7 | language 8 | title 9 | } 10 | } 11 | `; 12 | 13 | export const QUERY_SEO_DATA = gql` 14 | query SeoData { 15 | seo { 16 | webmaster { 17 | yandexVerify 18 | msVerify 19 | googleVerify 20 | baiduVerify 21 | } 22 | social { 23 | youTube { 24 | url 25 | } 26 | wikipedia { 27 | url 28 | } 29 | twitter { 30 | username 31 | cardType 32 | } 33 | pinterest { 34 | metaTag 35 | url 36 | } 37 | mySpace { 38 | url 39 | } 40 | linkedIn { 41 | url 42 | } 43 | instagram { 44 | url 45 | } 46 | facebook { 47 | url 48 | defaultImage { 49 | altText 50 | sourceUrl 51 | mediaDetails { 52 | height 53 | width 54 | } 55 | } 56 | } 57 | } 58 | } 59 | } 60 | `; 61 | -------------------------------------------------------------------------------- /src/data/users.js: -------------------------------------------------------------------------------- 1 | import { gql } from '@apollo/client'; 2 | 3 | export const QUERY_ALL_USERS = gql` 4 | query AllUsers { 5 | users(first: 10000) { 6 | edges { 7 | node { 8 | avatar { 9 | height 10 | width 11 | url 12 | } 13 | description 14 | id 15 | name 16 | roles { 17 | nodes { 18 | name 19 | } 20 | } 21 | slug 22 | } 23 | } 24 | } 25 | } 26 | `; 27 | 28 | export const QUERY_ALL_USERS_SEO = gql` 29 | query AllUsersSeo { 30 | users(first: 10000) { 31 | edges { 32 | node { 33 | id 34 | seo { 35 | metaDesc 36 | metaRobotsNofollow 37 | metaRobotsNoindex 38 | title 39 | social { 40 | youTube 41 | wikipedia 42 | twitter 43 | soundCloud 44 | pinterest 45 | mySpace 46 | linkedIn 47 | instagram 48 | facebook 49 | } 50 | } 51 | } 52 | } 53 | } 54 | } 55 | `; 56 | -------------------------------------------------------------------------------- /src/hooks/use-page-metadata.js: -------------------------------------------------------------------------------- 1 | import { useContext } from 'react'; 2 | import { useRouter } from 'next/router'; 3 | 4 | import { SiteContext } from 'hooks/use-site'; 5 | 6 | import { constructPageMetadata } from 'lib/site'; 7 | 8 | export default function usePageMetadata({ metadata: pageMetadata }) { 9 | const { homepage, metadata: defaultMetadata } = useContext(SiteContext); 10 | 11 | const router = useRouter(); 12 | 13 | const metadata = constructPageMetadata(defaultMetadata, pageMetadata, { 14 | homepage, 15 | router, 16 | }); 17 | 18 | return { 19 | metadata, 20 | }; 21 | } 22 | -------------------------------------------------------------------------------- /src/hooks/use-search.js: -------------------------------------------------------------------------------- 1 | import { useState, createContext, useContext, useEffect } from 'react'; 2 | import Fuse from 'fuse.js'; 3 | 4 | import { getSearchData } from 'lib/search'; 5 | 6 | const SEARCH_KEYS = ['slug', 'title']; 7 | 8 | export const SEARCH_STATE_LOADING = 'LOADING'; 9 | export const SEARCH_STATE_READY = 'READY'; 10 | export const SEARCH_STATE_ERROR = 'ERROR'; 11 | export const SEARCH_STATE_LOADED = 'LOADED'; 12 | 13 | export const SearchContext = createContext(); 14 | 15 | export const SearchProvider = (props) => { 16 | const search = useSearchState(); 17 | return ; 18 | }; 19 | 20 | export function useSearchState() { 21 | const [state, setState] = useState(SEARCH_STATE_READY); 22 | const [data, setData] = useState(null); 23 | 24 | let client; 25 | 26 | if (data) { 27 | client = new Fuse(data.posts, { 28 | keys: SEARCH_KEYS, 29 | isCaseSensitive: false, 30 | }); 31 | } 32 | 33 | // On load, we want to immediately pull in the search index, which we're 34 | // storing clientside and gets built at compile time 35 | 36 | useEffect(() => { 37 | (async function getData() { 38 | setState(SEARCH_STATE_LOADING); 39 | 40 | let searchData; 41 | 42 | try { 43 | searchData = await getSearchData(); 44 | } catch (e) { 45 | setState(SEARCH_STATE_ERROR); 46 | return; 47 | } 48 | 49 | setData(searchData); 50 | setState(SEARCH_STATE_LOADED); 51 | })(); 52 | }, []); 53 | 54 | return { 55 | state, 56 | data, 57 | client, 58 | }; 59 | } 60 | 61 | export default function useSearch({ defaultQuery = null, maxResults } = {}) { 62 | const search = useContext(SearchContext); 63 | const { client } = search; 64 | 65 | const [query, setQuery] = useState(defaultQuery); 66 | 67 | let results = []; 68 | 69 | // If we have a query, make a search. Otherwise, don't modify the 70 | // results to avoid passing back empty results 71 | 72 | if (client && query) { 73 | results = client.search(query).map(({ item }) => item); 74 | } 75 | 76 | if (maxResults && results.length > maxResults) { 77 | results = results.slice(0, maxResults); 78 | } 79 | 80 | // If the defaultQuery argument changes, the hook should reflect 81 | // that update and set that as the new state 82 | 83 | useEffect(() => setQuery(defaultQuery), [defaultQuery]); 84 | 85 | /** 86 | * handleSearch 87 | */ 88 | 89 | function handleSearch({ query }) { 90 | setQuery(query); 91 | } 92 | 93 | /** 94 | * handleClearSearch 95 | */ 96 | 97 | function handleClearSearch() { 98 | setQuery(null); 99 | } 100 | 101 | return { 102 | ...search, 103 | query, 104 | results, 105 | search: handleSearch, 106 | clearSearch: handleClearSearch, 107 | }; 108 | } 109 | -------------------------------------------------------------------------------- /src/hooks/use-site.js: -------------------------------------------------------------------------------- 1 | import { useContext, createContext } from 'react'; 2 | 3 | import config from '../../package.json'; 4 | 5 | import { removeLastTrailingSlash } from 'lib/util'; 6 | 7 | export const SiteContext = createContext(); 8 | 9 | /** 10 | * useSiteContext 11 | */ 12 | 13 | export function useSiteContext(data) { 14 | let { homepage = '' } = config; 15 | 16 | // Trim the trailing slash from the end of homepage to avoid 17 | // double // issues throughout the metadata 18 | 19 | homepage = removeLastTrailingSlash(homepage); 20 | 21 | return { 22 | ...data, 23 | homepage, 24 | }; 25 | } 26 | 27 | /** 28 | * useSite 29 | */ 30 | 31 | export default function useSite() { 32 | const site = useContext(SiteContext); 33 | return site; 34 | } 35 | -------------------------------------------------------------------------------- /src/lib/apollo-client.js: -------------------------------------------------------------------------------- 1 | import { ApolloClient, HttpLink, InMemoryCache } from '@apollo/client'; 2 | 3 | import { removeLastTrailingSlash } from 'lib/util'; 4 | let client; 5 | 6 | /** 7 | * getApolloClient 8 | */ 9 | 10 | export function getApolloClient() { 11 | if (!client) { 12 | client = _createApolloClient(); 13 | } 14 | return client; 15 | } 16 | 17 | /** 18 | * createApolloClient 19 | */ 20 | 21 | export function _createApolloClient() { 22 | return new ApolloClient({ 23 | link: new HttpLink({ 24 | uri: removeLastTrailingSlash(process.env.WORDPRESS_GRAPHQL_ENDPOINT), 25 | }), 26 | cache: new InMemoryCache({ 27 | typePolicies: { 28 | RootQuery: { 29 | queryType: true, 30 | }, 31 | RootMutation: { 32 | mutationType: true, 33 | }, 34 | }, 35 | }), 36 | }); 37 | } 38 | -------------------------------------------------------------------------------- /src/lib/categories.js: -------------------------------------------------------------------------------- 1 | import { getApolloClient } from 'lib/apollo-client'; 2 | 3 | import { QUERY_ALL_CATEGORIES, QUERY_CATEGORY_BY_SLUG, QUERY_CATEGORY_SEO_BY_SLUG } from 'data/categories'; 4 | 5 | /** 6 | * categoryPathBySlug 7 | */ 8 | 9 | export function categoryPathBySlug(slug) { 10 | return `/categories/${slug}`; 11 | } 12 | 13 | /** 14 | * getAllCategories 15 | */ 16 | 17 | export async function getAllCategories() { 18 | const apolloClient = getApolloClient(); 19 | 20 | const data = await apolloClient.query({ 21 | query: QUERY_ALL_CATEGORIES, 22 | }); 23 | 24 | const categories = data?.data.categories.edges.map(({ node = {} }) => node); 25 | 26 | return { 27 | categories, 28 | }; 29 | } 30 | 31 | /** 32 | * getCategoryBySlug 33 | */ 34 | 35 | export async function getCategoryBySlug(slug) { 36 | const apolloClient = getApolloClient(); 37 | const apiHost = new URL(process.env.WORDPRESS_GRAPHQL_ENDPOINT).host; 38 | 39 | let categoryData; 40 | let seoData; 41 | 42 | try { 43 | categoryData = await apolloClient.query({ 44 | query: QUERY_CATEGORY_BY_SLUG, 45 | variables: { 46 | slug, 47 | }, 48 | }); 49 | } catch (e) { 50 | console.log(`[categories][getCategoryBySlug] Failed to query category data: ${e.message}`); 51 | throw e; 52 | } 53 | 54 | if (!categoryData?.data.category) return { category: undefined }; 55 | 56 | const category = mapCategoryData(categoryData?.data.category); 57 | 58 | // If the SEO plugin is enabled, look up the data 59 | // and apply it to the default settings 60 | 61 | if (process.env.WORDPRESS_PLUGIN_SEO === true) { 62 | try { 63 | seoData = await apolloClient.query({ 64 | query: QUERY_CATEGORY_SEO_BY_SLUG, 65 | variables: { 66 | slug, 67 | }, 68 | }); 69 | } catch (e) { 70 | console.log(`[categories][getCategoryBySlug] Failed to query SEO plugin: ${e.message}`); 71 | console.log('Is the SEO Plugin installed? If not, disable WORDPRESS_PLUGIN_SEO in next.config.js.'); 72 | throw e; 73 | } 74 | 75 | const { seo = {} } = seoData?.data?.category || {}; 76 | 77 | category.title = seo.title; 78 | category.description = seo.metaDesc; 79 | 80 | // The SEO plugin by default includes a canonical link, but we don't want to use that 81 | // because it includes the WordPress host, not the site host. We manage the canonical 82 | // link along with the other metadata, but explicitly check if there's a custom one 83 | // in here by looking for the API's host in the provided canonical link 84 | 85 | if (seo.canonical && !seo.canonical.includes(apiHost)) { 86 | category.canonical = seo.canonical; 87 | } 88 | 89 | category.og = { 90 | author: seo.opengraphAuthor, 91 | description: seo.opengraphDescription, 92 | image: seo.opengraphImage, 93 | modifiedTime: seo.opengraphModifiedTime, 94 | publishedTime: seo.opengraphPublishedTime, 95 | publisher: seo.opengraphPublisher, 96 | title: seo.opengraphTitle, 97 | type: seo.opengraphType, 98 | }; 99 | 100 | category.article = { 101 | author: category.og.author, 102 | modifiedTime: category.og.modifiedTime, 103 | publishedTime: category.og.publishedTime, 104 | publisher: category.og.publisher, 105 | }; 106 | 107 | category.robots = { 108 | nofollow: seo.metaRobotsNofollow, 109 | noindex: seo.metaRobotsNoindex, 110 | }; 111 | 112 | category.twitter = { 113 | description: seo.twitterDescription, 114 | image: seo.twitterImage, 115 | title: seo.twitterTitle, 116 | }; 117 | } 118 | 119 | return { 120 | category, 121 | }; 122 | } 123 | 124 | /** 125 | * getCategories 126 | */ 127 | 128 | export async function getCategories({ count } = {}) { 129 | const { categories } = await getAllCategories(); 130 | return { 131 | categories: categories.slice(0, count), 132 | }; 133 | } 134 | 135 | /** 136 | * mapCategoryData 137 | */ 138 | 139 | export function mapCategoryData(category = {}) { 140 | const data = { ...category }; 141 | return data; 142 | } 143 | -------------------------------------------------------------------------------- /src/lib/datetime.js: -------------------------------------------------------------------------------- 1 | import { format } from 'date-fns'; 2 | 3 | /** 4 | * formatDate 5 | */ 6 | 7 | export function formatDate(date, pattern = 'PPP') { 8 | return format(new Date(date), pattern); 9 | } 10 | 11 | /** 12 | * sortObjectsByDate 13 | */ 14 | 15 | export function sortObjectsByDate(array, { key = 'date' } = {}) { 16 | return array.sort((a, b) => new Date(b[key]) - new Date(a[key])); 17 | } 18 | -------------------------------------------------------------------------------- /src/lib/json-ld.js: -------------------------------------------------------------------------------- 1 | import { Helmet } from 'react-helmet'; 2 | 3 | import { authorPathByName } from 'lib/users'; 4 | import { postPathBySlug } from 'lib/posts'; 5 | import { pagePathBySlug } from 'lib/pages'; 6 | 7 | import config from '../../package.json'; 8 | 9 | export function ArticleJsonLd({ post = {}, siteTitle = '' }) { 10 | const { homepage = '', faviconPath = '/favicon.ico' } = config; 11 | const { title, slug, excerpt, date, author, categories, modified, featuredImage } = post; 12 | const path = postPathBySlug(slug); 13 | const datePublished = !!date && new Date(date); 14 | const dateModified = !!modified && new Date(modified); 15 | 16 | /** TODO - As image is a recommended field would be interesting to have a 17 | * default image in case there is no featuredImage comming from WP, 18 | * like the open graph social image 19 | * */ 20 | 21 | const jsonLd = { 22 | '@context': 'https://schema.org', 23 | '@type': 'Article', 24 | mainEntityOfPage: { 25 | '@type': 'WebPage', 26 | '@id': `${homepage}${path}`, 27 | }, 28 | headline: title, 29 | image: [featuredImage?.sourceUrl], 30 | datePublished: datePublished ? datePublished.toISOString() : '', 31 | dateModified: dateModified ? dateModified.toISOString() : datePublished.toISOString(), 32 | description: excerpt, 33 | keywords: [categories.map(({ name }) => `${name}`).join(', ')], 34 | copyrightYear: datePublished ? datePublished.getFullYear() : '', 35 | author: { 36 | '@type': 'Person', 37 | name: author?.name, 38 | }, 39 | publisher: { 40 | '@type': 'Organization', 41 | name: siteTitle, 42 | logo: { 43 | '@type': 'ImageObject', 44 | url: `${homepage}${faviconPath}`, 45 | }, 46 | }, 47 | }; 48 | 49 | return ( 50 | 51 | 52 | 53 | ); 54 | } 55 | 56 | export function WebsiteJsonLd({ siteTitle = '' }) { 57 | const { homepage = '' } = config; 58 | 59 | const jsonLd = { 60 | '@context': 'https://schema.org', 61 | '@type': 'WebSite', 62 | name: siteTitle, 63 | url: homepage, 64 | copyrightYear: new Date().getFullYear(), 65 | potentialAction: { 66 | '@type': 'SearchAction', 67 | target: `${homepage}/search/?q={search_term_string}`, 68 | 'query-input': 'required name=search_term_string', 69 | }, 70 | }; 71 | 72 | return ( 73 | 74 | 75 | 76 | ); 77 | } 78 | 79 | export function WebpageJsonLd({ title = '', description = '', siteTitle = '', slug = '' }) { 80 | const { homepage = '' } = config; 81 | const path = pagePathBySlug(slug); 82 | 83 | const jsonLd = { 84 | '@context': 'http://schema.org', 85 | '@type': 'WebPage', 86 | name: title, 87 | description: description, 88 | url: `${homepage}${path}`, 89 | publisher: { 90 | '@type': 'ProfilePage', 91 | name: siteTitle, 92 | }, 93 | }; 94 | 95 | return ( 96 | 97 | 98 | 99 | ); 100 | } 101 | 102 | export function AuthorJsonLd({ author = {} }) { 103 | const { homepage = '' } = config; 104 | const { name, avatar, description } = author; 105 | const path = authorPathByName(name); 106 | 107 | const jsonLd = { 108 | '@context': 'https://schema.org', 109 | '@type': 'Person', 110 | name: name, 111 | image: avatar?.url, 112 | url: `${homepage}${path}`, 113 | description: description, 114 | }; 115 | 116 | return ( 117 | 118 | 119 | 120 | ); 121 | } 122 | 123 | export function LogoJsonLd() { 124 | const { homepage = '', faviconPath = '/favicon.ico' } = config; 125 | 126 | const jsonLd = { 127 | '@context': 'https://schema.org', 128 | '@type': 'Organization', 129 | url: `${homepage}`, 130 | logo: `${homepage}${faviconPath}`, 131 | }; 132 | 133 | return ( 134 | 135 | 136 | 137 | ); 138 | } 139 | -------------------------------------------------------------------------------- /src/lib/menus.js: -------------------------------------------------------------------------------- 1 | import { getApolloClient } from 'lib/apollo-client'; 2 | import { getTopLevelPages } from 'lib/pages'; 3 | import { QUERY_ALL_MENUS } from 'data/menus'; 4 | 5 | export const MENU_LOCATION_NAVIGATION_DEFAULT = 'DEFAULT_NAVIGATION'; 6 | 7 | /** 8 | * getAllMenus 9 | */ 10 | 11 | export async function getAllMenus() { 12 | const apolloClient = getApolloClient(); 13 | 14 | const data = await apolloClient.query({ 15 | query: QUERY_ALL_MENUS, 16 | }); 17 | 18 | const menus = data?.data.menus.edges.map(mapMenuData); 19 | 20 | const defaultNavigation = createMenuFromPages({ 21 | locations: [MENU_LOCATION_NAVIGATION_DEFAULT], 22 | pages: await getTopLevelPages({ 23 | queryIncludes: 'index', 24 | }), 25 | }); 26 | 27 | menus.push(defaultNavigation); 28 | 29 | return { 30 | menus, 31 | }; 32 | } 33 | 34 | /** 35 | * mapMenuData 36 | */ 37 | 38 | export function mapMenuData(menu = {}) { 39 | const { node } = menu; 40 | const data = { ...node }; 41 | 42 | data.menuItems = data.menuItems.edges.map(({ node }) => { 43 | return { ...node }; 44 | }); 45 | 46 | return data; 47 | } 48 | 49 | /** 50 | * mapPagesToMenuItems 51 | */ 52 | 53 | export function mapPagesToMenuItems(pages) { 54 | return pages.map(({ id, uri, title }) => { 55 | return { 56 | label: title, 57 | path: uri, 58 | id, 59 | }; 60 | }); 61 | } 62 | 63 | /** 64 | * createMenuFromPages 65 | */ 66 | 67 | export function createMenuFromPages({ locations, pages }) { 68 | return { 69 | menuItems: mapPagesToMenuItems(pages), 70 | locations, 71 | }; 72 | } 73 | 74 | /** 75 | * parseHierarchicalMenu 76 | */ 77 | export const parseHierarchicalMenu = ( 78 | data = [], 79 | { idKey = 'id', parentKey = 'parentId', childrenKey = 'children' } = {} 80 | ) => { 81 | const tree = []; 82 | const childrenOf = {}; 83 | 84 | data.forEach((item) => { 85 | const newItem = { ...item }; 86 | const { [idKey]: id, [parentKey]: parentId = 0 } = newItem; 87 | childrenOf[id] = childrenOf[id] || []; 88 | newItem[childrenKey] = childrenOf[id]; 89 | parentId ? (childrenOf[parentId] = childrenOf[parentId] || []).push(newItem) : tree.push(newItem); 90 | }); 91 | return tree; 92 | }; 93 | 94 | /** 95 | * findMenuByLocation 96 | */ 97 | 98 | export function findMenuByLocation(menus, location) { 99 | if (typeof location !== 'string') { 100 | throw new Error('Failed to find menu by location - location is not a string.'); 101 | } 102 | 103 | const menu = menus.find(({ locations }) => { 104 | return locations.map((loc) => loc.toUpperCase()).includes(location.toUpperCase()); 105 | }); 106 | 107 | return menu && parseHierarchicalMenu(menu.menuItems); 108 | } 109 | -------------------------------------------------------------------------------- /src/lib/pages.js: -------------------------------------------------------------------------------- 1 | import { getApolloClient } from 'lib/apollo-client'; 2 | 3 | import { 4 | QUERY_ALL_PAGES_INDEX, 5 | QUERY_ALL_PAGES_ARCHIVE, 6 | QUERY_ALL_PAGES, 7 | QUERY_PAGE_BY_URI, 8 | QUERY_PAGE_SEO_BY_URI, 9 | } from 'data/pages'; 10 | 11 | /** 12 | * pagePathBySlug 13 | */ 14 | 15 | export function pagePathBySlug(slug) { 16 | return `/${slug}`; 17 | } 18 | 19 | /** 20 | * getPageByUri 21 | */ 22 | 23 | export async function getPageByUri(uri) { 24 | const apolloClient = getApolloClient(); 25 | const apiHost = new URL(process.env.WORDPRESS_GRAPHQL_ENDPOINT).host; 26 | 27 | let pageData; 28 | let seoData; 29 | 30 | try { 31 | pageData = await apolloClient.query({ 32 | query: QUERY_PAGE_BY_URI, 33 | variables: { 34 | uri, 35 | }, 36 | }); 37 | } catch (e) { 38 | console.log(`[pages][getPageByUri] Failed to query page data: ${e.message}`); 39 | throw e; 40 | } 41 | 42 | if (!pageData?.data.page) return { page: undefined }; 43 | 44 | const page = [pageData?.data.page].map(mapPageData)[0]; 45 | 46 | // If the SEO plugin is enabled, look up the data 47 | // and apply it to the default settings 48 | 49 | if (process.env.WORDPRESS_PLUGIN_SEO === true) { 50 | try { 51 | seoData = await apolloClient.query({ 52 | query: QUERY_PAGE_SEO_BY_URI, 53 | variables: { 54 | uri, 55 | }, 56 | }); 57 | } catch (e) { 58 | console.log(`[pages][getPageByUri] Failed to query SEO plugin: ${e.message}`); 59 | console.log('Is the SEO Plugin installed? If not, disable WORDPRESS_PLUGIN_SEO in next.config.js.'); 60 | throw e; 61 | } 62 | 63 | const { seo = {} } = seoData?.data?.page || {}; 64 | 65 | page.metaTitle = seo.title; 66 | page.description = seo.metaDesc; 67 | page.readingTime = seo.readingTime; 68 | 69 | // The SEO plugin by default includes a canonical link, but we don't want to use that 70 | // because it includes the WordPress host, not the site host. We manage the canonical 71 | // link along with the other metadata, but explicitly check if there's a custom one 72 | // in here by looking for the API's host in the provided canonical link 73 | 74 | if (seo.canonical && !seo.canonical.includes(apiHost)) { 75 | page.canonical = seo.canonical; 76 | } 77 | 78 | page.og = { 79 | author: seo.opengraphAuthor, 80 | description: seo.opengraphDescription, 81 | image: seo.opengraphImage, 82 | modifiedTime: seo.opengraphModifiedTime, 83 | publishedTime: seo.opengraphPublishedTime, 84 | publisher: seo.opengraphPublisher, 85 | title: seo.opengraphTitle, 86 | type: seo.opengraphType, 87 | }; 88 | 89 | page.robots = { 90 | nofollow: seo.metaRobotsNofollow, 91 | noindex: seo.metaRobotsNoindex, 92 | }; 93 | 94 | page.twitter = { 95 | description: seo.twitterDescription, 96 | image: seo.twitterImage, 97 | title: seo.twitterTitle, 98 | }; 99 | } 100 | 101 | return { 102 | page, 103 | }; 104 | } 105 | 106 | /** 107 | * getAllPages 108 | */ 109 | 110 | const allPagesIncludesTypes = { 111 | all: QUERY_ALL_PAGES, 112 | archive: QUERY_ALL_PAGES_ARCHIVE, 113 | index: QUERY_ALL_PAGES_INDEX, 114 | }; 115 | 116 | export async function getAllPages(options = {}) { 117 | const { queryIncludes = 'index' } = options; 118 | 119 | const apolloClient = getApolloClient(); 120 | 121 | const data = await apolloClient.query({ 122 | query: allPagesIncludesTypes[queryIncludes], 123 | }); 124 | 125 | const pages = data?.data.pages.edges.map(({ node = {} }) => node).map(mapPageData); 126 | 127 | return { 128 | pages, 129 | }; 130 | } 131 | 132 | /** 133 | * getTopLevelPages 134 | */ 135 | 136 | export async function getTopLevelPages(options) { 137 | const { pages } = await getAllPages(options); 138 | 139 | const navPages = pages.filter(({ parent }) => parent === null); 140 | 141 | // Order pages by menuOrder 142 | navPages.sort((a, b) => parseFloat(a.menuOrder) - parseFloat(b.menuOrder)); 143 | 144 | return navPages; 145 | } 146 | 147 | /** 148 | * mapPageData 149 | */ 150 | 151 | export function mapPageData(page = {}) { 152 | const data = { ...page }; 153 | 154 | if (data.featuredImage) { 155 | data.featuredImage = data.featuredImage.node; 156 | } 157 | 158 | if (data.parent) { 159 | data.parent = data.parent.node; 160 | } 161 | 162 | if (data.children) { 163 | data.children = data.children.edges.map(({ node }) => node); 164 | } 165 | 166 | return data; 167 | } 168 | 169 | /** 170 | * getBreadcrumbsByUri 171 | */ 172 | 173 | export function getBreadcrumbsByUri(uri, pages) { 174 | const breadcrumbs = []; 175 | const uriSegments = uri.split('/').filter((segment) => segment !== ''); 176 | 177 | // We don't want to show the current page in the breadcrumbs, so pop off 178 | // the last chunk before we start 179 | 180 | uriSegments.pop(); 181 | 182 | // Work through each of the segments, popping off the last chunk and finding the related 183 | // page to gather the metadata for the breadcrumbs 184 | 185 | do { 186 | const breadcrumb = pages.find((page) => page.uri === `/${uriSegments.join('/')}/`); 187 | 188 | // If the breadcrumb is the active page, we want to pass udefined for the uri to 189 | // avoid the breadcrumbs being rendered as a link, given it's the current page 190 | 191 | if (breadcrumb) { 192 | breadcrumbs.push({ 193 | id: breadcrumb.id, 194 | title: breadcrumb.title, 195 | uri: breadcrumb.uri, 196 | }); 197 | } 198 | 199 | uriSegments.pop(); 200 | } while (uriSegments.length > 0); 201 | 202 | // When working through the segments, we're doing so from the lowest child to the parent 203 | // which means the parent will be at the end of the array. We need to reverse to show 204 | // the correct order for breadcrumbs 205 | 206 | breadcrumbs.reverse(); 207 | 208 | return breadcrumbs; 209 | } 210 | -------------------------------------------------------------------------------- /src/lib/posts.js: -------------------------------------------------------------------------------- 1 | import { getApolloClient } from 'lib/apollo-client'; 2 | 3 | import { updateUserAvatar } from 'lib/users'; 4 | import { sortObjectsByDate } from 'lib/datetime'; 5 | 6 | import { 7 | QUERY_ALL_POSTS_INDEX, 8 | QUERY_ALL_POSTS_ARCHIVE, 9 | QUERY_ALL_POSTS, 10 | QUERY_POST_BY_SLUG, 11 | QUERY_POSTS_BY_AUTHOR_SLUG_INDEX, 12 | QUERY_POSTS_BY_AUTHOR_SLUG_ARCHIVE, 13 | QUERY_POSTS_BY_AUTHOR_SLUG, 14 | QUERY_POSTS_BY_CATEGORY_ID_INDEX, 15 | QUERY_POSTS_BY_CATEGORY_ID_ARCHIVE, 16 | QUERY_POSTS_BY_CATEGORY_ID, 17 | QUERY_POST_SEO_BY_SLUG, 18 | QUERY_POST_PER_PAGE, 19 | } from 'data/posts'; 20 | 21 | /** 22 | * postPathBySlug 23 | */ 24 | 25 | export function postPathBySlug(slug) { 26 | return `/posts/${slug}`; 27 | } 28 | 29 | /** 30 | * getPostBySlug 31 | */ 32 | 33 | export async function getPostBySlug(slug) { 34 | const apolloClient = getApolloClient(); 35 | const apiHost = new URL(process.env.WORDPRESS_GRAPHQL_ENDPOINT).host; 36 | 37 | let postData; 38 | let seoData; 39 | 40 | try { 41 | postData = await apolloClient.query({ 42 | query: QUERY_POST_BY_SLUG, 43 | variables: { 44 | slug, 45 | }, 46 | }); 47 | } catch (e) { 48 | console.log(`[posts][getPostBySlug] Failed to query post data: ${e.message}`); 49 | throw e; 50 | } 51 | 52 | if (!postData?.data.post) return { post: undefined }; 53 | 54 | const post = [postData?.data.post].map(mapPostData)[0]; 55 | 56 | // If the SEO plugin is enabled, look up the data 57 | // and apply it to the default settings 58 | 59 | if (process.env.WORDPRESS_PLUGIN_SEO === true) { 60 | try { 61 | seoData = await apolloClient.query({ 62 | query: QUERY_POST_SEO_BY_SLUG, 63 | variables: { 64 | slug, 65 | }, 66 | }); 67 | } catch (e) { 68 | console.log(`[posts][getPostBySlug] Failed to query SEO plugin: ${e.message}`); 69 | console.log('Is the SEO Plugin installed? If not, disable WORDPRESS_PLUGIN_SEO in next.config.js.'); 70 | throw e; 71 | } 72 | 73 | const { seo = {} } = seoData?.data?.post || {}; 74 | 75 | post.metaTitle = seo.title; 76 | post.metaDescription = seo.metaDesc; 77 | post.readingTime = seo.readingTime; 78 | 79 | // The SEO plugin by default includes a canonical link, but we don't want to use that 80 | // because it includes the WordPress host, not the site host. We manage the canonical 81 | // link along with the other metadata, but explicitly check if there's a custom one 82 | // in here by looking for the API's host in the provided canonical link 83 | 84 | if (seo.canonical && !seo.canonical.includes(apiHost)) { 85 | post.canonical = seo.canonical; 86 | } 87 | 88 | post.og = { 89 | author: seo.opengraphAuthor, 90 | description: seo.opengraphDescription, 91 | image: seo.opengraphImage, 92 | modifiedTime: seo.opengraphModifiedTime, 93 | publishedTime: seo.opengraphPublishedTime, 94 | publisher: seo.opengraphPublisher, 95 | title: seo.opengraphTitle, 96 | type: seo.opengraphType, 97 | }; 98 | 99 | post.article = { 100 | author: post.og.author, 101 | modifiedTime: post.og.modifiedTime, 102 | publishedTime: post.og.publishedTime, 103 | publisher: post.og.publisher, 104 | }; 105 | 106 | post.robots = { 107 | nofollow: seo.metaRobotsNofollow, 108 | noindex: seo.metaRobotsNoindex, 109 | }; 110 | 111 | post.twitter = { 112 | description: seo.twitterDescription, 113 | image: seo.twitterImage, 114 | title: seo.twitterTitle, 115 | }; 116 | } 117 | 118 | return { 119 | post, 120 | }; 121 | } 122 | 123 | /** 124 | * getAllPosts 125 | */ 126 | 127 | const allPostsIncludesTypes = { 128 | all: QUERY_ALL_POSTS, 129 | archive: QUERY_ALL_POSTS_ARCHIVE, 130 | index: QUERY_ALL_POSTS_INDEX, 131 | }; 132 | 133 | export async function getAllPosts(options = {}) { 134 | const { queryIncludes = 'index' } = options; 135 | 136 | const apolloClient = getApolloClient(); 137 | 138 | const data = await apolloClient.query({ 139 | query: allPostsIncludesTypes[queryIncludes], 140 | }); 141 | 142 | const posts = data?.data.posts.edges.map(({ node = {} }) => node); 143 | 144 | return { 145 | posts: Array.isArray(posts) && posts.map(mapPostData), 146 | }; 147 | } 148 | 149 | /** 150 | * getPostsByAuthorSlug 151 | */ 152 | 153 | const postsByAuthorSlugIncludesTypes = { 154 | all: QUERY_POSTS_BY_AUTHOR_SLUG, 155 | archive: QUERY_POSTS_BY_AUTHOR_SLUG_ARCHIVE, 156 | index: QUERY_POSTS_BY_AUTHOR_SLUG_INDEX, 157 | }; 158 | 159 | export async function getPostsByAuthorSlug({ slug, ...options }) { 160 | const { queryIncludes = 'index' } = options; 161 | 162 | const apolloClient = getApolloClient(); 163 | 164 | let postData; 165 | 166 | try { 167 | postData = await apolloClient.query({ 168 | query: postsByAuthorSlugIncludesTypes[queryIncludes], 169 | variables: { 170 | slug, 171 | }, 172 | }); 173 | } catch (e) { 174 | console.log(`[posts][getPostsByAuthorSlug] Failed to query post data: ${e.message}`); 175 | throw e; 176 | } 177 | 178 | const posts = postData?.data.posts.edges.map(({ node = {} }) => node); 179 | 180 | return { 181 | posts: Array.isArray(posts) && posts.map(mapPostData), 182 | }; 183 | } 184 | 185 | /** 186 | * getPostsByCategoryId 187 | */ 188 | 189 | const postsByCategoryIdIncludesTypes = { 190 | all: QUERY_POSTS_BY_CATEGORY_ID, 191 | archive: QUERY_POSTS_BY_CATEGORY_ID_ARCHIVE, 192 | index: QUERY_POSTS_BY_CATEGORY_ID_INDEX, 193 | }; 194 | 195 | export async function getPostsByCategoryId({ categoryId, ...options }) { 196 | const { queryIncludes = 'index' } = options; 197 | 198 | const apolloClient = getApolloClient(); 199 | 200 | let postData; 201 | 202 | try { 203 | postData = await apolloClient.query({ 204 | query: postsByCategoryIdIncludesTypes[queryIncludes], 205 | variables: { 206 | categoryId, 207 | }, 208 | }); 209 | } catch (e) { 210 | console.log(`[posts][getPostsByCategoryId] Failed to query post data: ${e.message}`); 211 | throw e; 212 | } 213 | 214 | const posts = postData?.data.posts.edges.map(({ node = {} }) => node); 215 | 216 | return { 217 | posts: Array.isArray(posts) && posts.map(mapPostData), 218 | }; 219 | } 220 | 221 | /** 222 | * getRecentPosts 223 | */ 224 | 225 | export async function getRecentPosts({ count, ...options }) { 226 | const { posts } = await getAllPosts(options); 227 | const sorted = sortObjectsByDate(posts); 228 | return { 229 | posts: sorted.slice(0, count), 230 | }; 231 | } 232 | 233 | /** 234 | * sanitizeExcerpt 235 | */ 236 | 237 | export function sanitizeExcerpt(excerpt) { 238 | if (typeof excerpt !== 'string') { 239 | throw new Error(`Failed to sanitize excerpt: invalid type ${typeof excerpt}`); 240 | } 241 | 242 | let sanitized = excerpt; 243 | 244 | // If the theme includes [...] as the more indication, clean it up to just ... 245 | 246 | sanitized = sanitized.replace(/\s?\[…\]/, '…'); 247 | 248 | // If after the above replacement, the ellipsis includes 4 dots, it's 249 | // the end of a setence 250 | 251 | sanitized = sanitized.replace('....', '.'); 252 | sanitized = sanitized.replace('.…', '.'); 253 | 254 | // If the theme is including a "Continue..." link, remove it 255 | 256 | sanitized = sanitized.replace(/\w*/, ''); 257 | 258 | return sanitized; 259 | } 260 | 261 | /** 262 | * mapPostData 263 | */ 264 | 265 | export function mapPostData(post = {}) { 266 | const data = { ...post }; 267 | 268 | // Clean up the author object to avoid someone having to look an extra 269 | // level deeper into the node 270 | 271 | if (data.author) { 272 | data.author = { 273 | ...data.author.node, 274 | }; 275 | } 276 | 277 | // The URL by default that comes from Gravatar / WordPress is not a secure 278 | // URL. This ends up redirecting to https, but it gives mixed content warnings 279 | // as the HTML shows it as http. Replace the url to avoid those warnings 280 | // and provide a secure URL by default 281 | 282 | if (data.author?.avatar) { 283 | data.author.avatar = updateUserAvatar(data.author.avatar); 284 | } 285 | 286 | // Clean up the categories to make them more easy to access 287 | 288 | if (data.categories) { 289 | data.categories = data.categories.edges.map(({ node }) => { 290 | return { 291 | ...node, 292 | }; 293 | }); 294 | } 295 | 296 | // Clean up the featured image to make them more easy to access 297 | 298 | if (data.featuredImage) { 299 | data.featuredImage = data.featuredImage.node; 300 | } 301 | 302 | return data; 303 | } 304 | 305 | /** 306 | * getRelatedPosts 307 | */ 308 | 309 | export async function getRelatedPosts(categories, postId, count = 5) { 310 | if (!Array.isArray(categories) || categories.length === 0) return; 311 | 312 | let related = { 313 | category: categories && categories.shift(), 314 | }; 315 | 316 | if (related.category) { 317 | const { posts } = await getPostsByCategoryId({ 318 | categoryId: related.category.databaseId, 319 | queryIncludes: 'archive', 320 | }); 321 | 322 | const filtered = posts.filter(({ postId: id }) => id !== postId); 323 | const sorted = sortObjectsByDate(filtered); 324 | 325 | related.posts = sorted.map((post) => ({ title: post.title, slug: post.slug })); 326 | } 327 | 328 | if (!Array.isArray(related.posts) || related.posts.length === 0) { 329 | const relatedPosts = await getRelatedPosts(categories, postId, count); 330 | related = relatedPosts || related; 331 | } 332 | 333 | if (Array.isArray(related.posts) && related.posts.length > count) { 334 | return related.posts.slice(0, count); 335 | } 336 | 337 | return related; 338 | } 339 | 340 | /** 341 | * sortStickyPosts 342 | */ 343 | 344 | export function sortStickyPosts(posts) { 345 | return [...posts].sort((post) => (post.isSticky ? -1 : 1)); 346 | } 347 | 348 | /** 349 | * getPostsPerPage 350 | */ 351 | 352 | export async function getPostsPerPage() { 353 | //If POST_PER_PAGE is defined at next.config.js 354 | if (process.env.POSTS_PER_PAGE) { 355 | console.warn( 356 | 'You are using the deprecated POST_PER_PAGE variable. Use your WordPress instance instead to set this value ("Settings" > "Reading" > "Blog pages show at most").' 357 | ); 358 | return Number(process.env.POSTS_PER_PAGE); 359 | } 360 | 361 | try { 362 | const apolloClient = getApolloClient(); 363 | 364 | const { data } = await apolloClient.query({ 365 | query: QUERY_POST_PER_PAGE, 366 | }); 367 | 368 | return Number(data.allSettings.readingSettingsPostsPerPage); 369 | } catch (e) { 370 | console.log(`Failed to query post per page data: ${e.message}`); 371 | throw e; 372 | } 373 | } 374 | 375 | /** 376 | * getPageCount 377 | */ 378 | 379 | export async function getPagesCount(posts, postsPerPage) { 380 | const _postsPerPage = postsPerPage ?? (await getPostsPerPage()); 381 | return Math.ceil(posts.length / _postsPerPage); 382 | } 383 | 384 | /** 385 | * getPaginatedPosts 386 | */ 387 | 388 | export async function getPaginatedPosts({ currentPage = 1, ...options } = {}) { 389 | const { posts } = await getAllPosts(options); 390 | const postsPerPage = await getPostsPerPage(); 391 | const pagesCount = await getPagesCount(posts, postsPerPage); 392 | 393 | let page = Number(currentPage); 394 | 395 | if (typeof page === 'undefined' || isNaN(page)) { 396 | page = 1; 397 | } else if (page > pagesCount) { 398 | return { 399 | posts: [], 400 | pagination: { 401 | currentPage: undefined, 402 | pagesCount, 403 | }, 404 | }; 405 | } 406 | 407 | const offset = postsPerPage * (page - 1); 408 | const sortedPosts = sortStickyPosts(posts); 409 | return { 410 | posts: sortedPosts.slice(offset, offset + postsPerPage), 411 | pagination: { 412 | currentPage: page, 413 | pagesCount, 414 | }, 415 | }; 416 | } 417 | -------------------------------------------------------------------------------- /src/lib/search.js: -------------------------------------------------------------------------------- 1 | /** 2 | * getSearchData 3 | */ 4 | 5 | export async function getSearchData() { 6 | const response = await fetch('/wp-search.json'); 7 | const json = await response.json(); 8 | return json; 9 | } 10 | -------------------------------------------------------------------------------- /src/lib/site.js: -------------------------------------------------------------------------------- 1 | import { getApolloClient } from 'lib/apollo-client'; 2 | 3 | import { decodeHtmlEntities, removeExtraSpaces } from 'lib/util'; 4 | 5 | import { QUERY_SITE_DATA, QUERY_SEO_DATA } from 'data/site'; 6 | 7 | /** 8 | * getSiteMetadata 9 | */ 10 | 11 | export async function getSiteMetadata() { 12 | const apolloClient = getApolloClient(); 13 | 14 | let siteData; 15 | let seoData; 16 | 17 | try { 18 | siteData = await apolloClient.query({ 19 | query: QUERY_SITE_DATA, 20 | }); 21 | } catch (e) { 22 | console.log(`[site][getSiteMetadata] Failed to query site data: ${e.message}`); 23 | throw e; 24 | } 25 | 26 | const { generalSettings } = siteData?.data || {}; 27 | let { title, description, language } = generalSettings; 28 | 29 | const settings = { 30 | title, 31 | siteTitle: title, 32 | description, 33 | }; 34 | 35 | // It looks like the value of `language` when US English is set 36 | // in WordPress is empty or "", meaning, we have to infer that 37 | // if there's no value, it's English. On the other hand, if there 38 | // is a code, we need to grab the 2char version of it to use ofr 39 | // the HTML lang attribute 40 | 41 | if (!language || language === '') { 42 | settings.language = 'en'; 43 | } else { 44 | settings.language = language.split('_')[0]; 45 | } 46 | 47 | // If the SEO plugin is enabled, look up the data 48 | // and apply it to the default settings 49 | 50 | if (process.env.WORDPRESS_PLUGIN_SEO === true) { 51 | try { 52 | seoData = await apolloClient.query({ 53 | query: QUERY_SEO_DATA, 54 | }); 55 | } catch (e) { 56 | console.log(`[site][getSiteMetadata] Failed to query SEO plugin: ${e.message}`); 57 | console.log('Is the SEO Plugin installed? If not, disable WORDPRESS_PLUGIN_SEO in next.config.js.'); 58 | throw e; 59 | } 60 | 61 | const { webmaster, social } = seoData?.data?.seo || {}; 62 | 63 | if (social) { 64 | settings.social = {}; 65 | 66 | Object.keys(social).forEach((key) => { 67 | const { url } = social[key]; 68 | if (!url || key === '__typename') return; 69 | settings.social[key] = url; 70 | }); 71 | } 72 | 73 | if (webmaster) { 74 | settings.webmaster = {}; 75 | 76 | Object.keys(webmaster).forEach((key) => { 77 | if (!webmaster[key] || key === '__typename') return; 78 | settings.webmaster[key] = webmaster[key]; 79 | }); 80 | } 81 | 82 | if (social.twitter) { 83 | settings.twitter = { 84 | username: social.twitter.username, 85 | cardType: social.twitter.cardType, 86 | }; 87 | 88 | settings.social.twitter = { 89 | url: `https://twitter.com/${settings.twitter.username}`, 90 | }; 91 | } 92 | } 93 | 94 | settings.title = decodeHtmlEntities(settings.title); 95 | 96 | return settings; 97 | } 98 | 99 | /** 100 | * constructHelmetData 101 | */ 102 | 103 | export function constructPageMetadata(defaultMetadata = {}, pageMetadata = {}, options = {}) { 104 | const { router = {}, homepage = '' } = options; 105 | const { asPath } = router; 106 | 107 | const url = `${homepage}${asPath}`; 108 | const pathname = new URL(url).pathname; 109 | const canonical = pageMetadata.canonical || `${homepage}${pathname}`; 110 | 111 | const metadata = { 112 | canonical, 113 | og: { 114 | url, 115 | }, 116 | twitter: {}, 117 | }; 118 | 119 | // Static Properties 120 | // Loop through top level metadata properties that rely on a non-object value 121 | 122 | const staticProperties = ['description', 'language', 'title']; 123 | 124 | staticProperties.forEach((property) => { 125 | const value = typeof pageMetadata[property] !== 'undefined' ? pageMetadata[property] : defaultMetadata[property]; 126 | 127 | if (typeof value === 'undefined') return; 128 | 129 | metadata[property] = value; 130 | }); 131 | 132 | // Open Graph Properties 133 | // Loop through Open Graph properties that rely on a non-object value 134 | 135 | if (pageMetadata.og) { 136 | const ogProperties = ['description', 'imageUrl', 'imageHeight', 'imageSecureUrl', 'imageWidth', 'title', 'type']; 137 | 138 | ogProperties.forEach((property) => { 139 | const pageOg = pageMetadata.og?.[property]; 140 | const pageStatic = pageMetadata[property]; 141 | const defaultOg = defaultMetadata.og?.[property]; 142 | const defaultStatic = defaultMetadata[property]; 143 | const value = pageOg || pageStatic || defaultOg || defaultStatic; 144 | 145 | if (typeof value === 'undefined') return; 146 | 147 | metadata.og[property] = value; 148 | }); 149 | } 150 | 151 | // Twitter Properties 152 | // Loop through Twitter properties that rely on a non-object value 153 | 154 | if (pageMetadata.twitter) { 155 | const twitterProperties = ['cardType', 'description', 'imageUrl', 'title', 'username']; 156 | 157 | twitterProperties.forEach((property) => { 158 | const pageTwitter = pageMetadata.twitter?.[property]; 159 | const pageOg = metadata.og[property]; 160 | const value = pageTwitter || pageOg; 161 | 162 | if (typeof value === 'undefined') return; 163 | 164 | metadata.twitter[property] = value; 165 | }); 166 | } 167 | 168 | // Article Properties 169 | // Loop through article properties that rely on a non-object value 170 | 171 | if (metadata.og.type === 'article' && pageMetadata.article) { 172 | metadata.article = {}; 173 | 174 | const articleProperties = ['author', 'modifiedTime', 'publishedTime', 'publisher']; 175 | 176 | articleProperties.forEach((property) => { 177 | const value = pageMetadata.article[property]; 178 | 179 | if (typeof value === 'undefined') return; 180 | 181 | metadata.article[property] = value; 182 | }); 183 | } 184 | 185 | return metadata; 186 | } 187 | 188 | /** 189 | * helmetSettingsFromMetadata 190 | */ 191 | 192 | export function helmetSettingsFromMetadata(metadata = {}, options = {}) { 193 | const { link = [], meta = [], setTitle = true } = options; 194 | 195 | const sanitizedDescription = removeExtraSpaces(metadata.description); 196 | 197 | const settings = { 198 | htmlAttributes: { 199 | lang: metadata.language, 200 | }, 201 | }; 202 | 203 | if (setTitle) { 204 | settings.title = metadata.title; 205 | } 206 | 207 | settings.link = [ 208 | ...link, 209 | { 210 | rel: 'canonical', 211 | href: metadata.canonical, 212 | }, 213 | ].filter(({ href } = {}) => !!href); 214 | 215 | settings.meta = [ 216 | ...meta, 217 | { 218 | name: 'description', 219 | content: sanitizedDescription, 220 | }, 221 | { 222 | property: 'og:title', 223 | content: metadata.og?.title || metadata.title, 224 | }, 225 | { 226 | property: 'og:description', 227 | content: metadata.og?.description || sanitizedDescription, 228 | }, 229 | { 230 | property: 'og:url', 231 | content: metadata.og?.url, 232 | }, 233 | { 234 | property: 'og:image', 235 | content: metadata.og?.imageUrl, 236 | }, 237 | { 238 | property: 'og:image:secure_url', 239 | content: metadata.og?.imageSecureUrl, 240 | }, 241 | { 242 | property: 'og:image:width', 243 | content: metadata.og?.imageWidth, 244 | }, 245 | { 246 | property: 'og:image:height', 247 | content: metadata.og?.imageHeight, 248 | }, 249 | { 250 | property: 'og:type', 251 | content: metadata.og?.type || 'website', 252 | }, 253 | { 254 | property: 'og:site_name', 255 | content: metadata.siteTitle, 256 | }, 257 | { 258 | property: 'twitter:title', 259 | content: metadata.twitter?.title || metadata.og?.title || metadata.title, 260 | }, 261 | { 262 | property: 'twitter:description', 263 | content: metadata.twitter?.description || metadata.og?.description || sanitizedDescription, 264 | }, 265 | { 266 | property: 'twitter:image', 267 | content: metadata.twitter?.imageUrl || metadata.og?.imageUrl, 268 | }, 269 | { 270 | property: 'twitter:site', 271 | content: metadata.twitter?.username && `@${metadata.twitter.username}`, 272 | }, 273 | { 274 | property: 'twitter:card', 275 | content: metadata.twitter?.cardType, 276 | }, 277 | { 278 | property: 'article:modified_time', 279 | content: metadata.article?.modifiedTime, 280 | }, 281 | { 282 | property: 'article:published_time', 283 | content: metadata.article?.publishedTime, 284 | }, 285 | ].filter(({ content } = {}) => !!content); 286 | 287 | return settings; 288 | } 289 | -------------------------------------------------------------------------------- /src/lib/users.js: -------------------------------------------------------------------------------- 1 | import { getApolloClient } from 'lib/apollo-client'; 2 | 3 | import parameterize from 'parameterize'; 4 | 5 | import { QUERY_ALL_USERS, QUERY_ALL_USERS_SEO } from 'data/users'; 6 | 7 | // const ROLES_AUTHOR = ['author', 'administrator']; 8 | 9 | /** 10 | * postPathBySlug 11 | */ 12 | 13 | export function authorPathBySlug(slug) { 14 | return `/authors/${slug}`; 15 | } 16 | 17 | /** 18 | * getUserBySlug 19 | */ 20 | 21 | export async function getUserBySlug(slug) { 22 | const { users } = await getAllUsers(); 23 | 24 | const user = users.find((user) => user.slug === slug); 25 | 26 | return { 27 | user, 28 | }; 29 | } 30 | 31 | /** 32 | * authorPathByName 33 | */ 34 | 35 | export function authorPathByName(name) { 36 | return `/authors/${parameterize(name)}`; 37 | } 38 | 39 | /** 40 | * getUserByNameSlug 41 | */ 42 | 43 | export async function getUserByNameSlug(name) { 44 | const { users } = await getAllUsers(); 45 | 46 | const user = users.find((user) => parameterize(user.name) === name); 47 | 48 | return { 49 | user, 50 | }; 51 | } 52 | 53 | /** 54 | * userSlugByName 55 | */ 56 | 57 | export function userSlugByName(name) { 58 | return parameterize(name); 59 | } 60 | 61 | /** 62 | * getAllUsers 63 | */ 64 | 65 | export async function getAllUsers() { 66 | const apolloClient = getApolloClient(); 67 | 68 | let usersData; 69 | let seoData; 70 | 71 | try { 72 | usersData = await apolloClient.query({ 73 | query: QUERY_ALL_USERS, 74 | }); 75 | } catch (e) { 76 | console.log(`[users][getAllUsers] Failed to query users data: ${e.message}`); 77 | throw e; 78 | } 79 | 80 | let users = usersData?.data.users.edges.map(({ node = {} }) => node).map(mapUserData); 81 | 82 | // If the SEO plugin is enabled, look up the data 83 | // and apply it to the default settings 84 | 85 | if (process.env.WORDPRESS_PLUGIN_SEO === true) { 86 | try { 87 | seoData = await apolloClient.query({ 88 | query: QUERY_ALL_USERS_SEO, 89 | }); 90 | } catch (e) { 91 | console.log(`[users][getAllUsers] Failed to query SEO plugin: ${e.message}`); 92 | console.log('Is the SEO Plugin installed? If not, disable WORDPRESS_PLUGIN_SEO in next.config.js.'); 93 | throw e; 94 | } 95 | 96 | users = users.map((user) => { 97 | const data = { ...user }; 98 | const { id } = data; 99 | 100 | const seo = seoData?.data?.users.edges.map(({ node = {} }) => node).find((node) => node.id === id)?.seo; 101 | 102 | return { 103 | ...data, 104 | title: seo.title, 105 | description: seo.metaDesc, 106 | robots: { 107 | nofollow: seo.metaRobotsNofollow, 108 | noindex: seo.metaRobotsNoindex, 109 | }, 110 | social: seo.social, 111 | }; 112 | }); 113 | } 114 | 115 | return { 116 | users, 117 | }; 118 | } 119 | 120 | /** 121 | * getAllAuthors 122 | */ 123 | 124 | export async function getAllAuthors() { 125 | const { users } = await getAllUsers(); 126 | 127 | // TODO: Roles aren't showing in response - we should be filtering here 128 | 129 | // const authors = users.filter(({ roles }) => { 130 | // const userRoles = roles.map(({ name }) => name); 131 | // const authorRoles = userRoles.filter(role => ROLES_AUTHOR.includes(role)); 132 | // return authorRoles.length > 0; 133 | // }); 134 | 135 | return { 136 | authors: users, 137 | }; 138 | } 139 | 140 | /** 141 | * mapUserData 142 | */ 143 | 144 | export function mapUserData(user) { 145 | return { 146 | ...user, 147 | roles: [...user.roles.nodes], 148 | avatar: user.avatar && updateUserAvatar(user.avatar), 149 | }; 150 | } 151 | 152 | /** 153 | * updateUserAvatar 154 | */ 155 | 156 | export function updateUserAvatar(avatar) { 157 | // The URL by default that comes from Gravatar / WordPress is not a secure 158 | // URL. This ends up redirecting to https, but it gives mixed content warnings 159 | // as the HTML shows it as http. Replace the url to avoid those warnings 160 | // and provide a secure URL by default 161 | 162 | return { 163 | ...avatar, 164 | url: avatar.url?.replace('http://', 'https://'), 165 | }; 166 | } 167 | -------------------------------------------------------------------------------- /src/lib/util.js: -------------------------------------------------------------------------------- 1 | /** 2 | * decodeHtmlEntities 3 | */ 4 | 5 | export function decodeHtmlEntities(text) { 6 | if (typeof text !== 'string') { 7 | throw new Error(`Failed to decode HTML entity: invalid type ${typeof text}`); 8 | } 9 | 10 | let decoded = text; 11 | 12 | const entities = { 13 | '&': '\u0026', 14 | '"': '\u0022', 15 | ''': '\u0027', 16 | }; 17 | 18 | return decoded.replace(/&|"|'/g, (char) => entities[char]); 19 | } 20 | 21 | /** 22 | * removeLastTrailingSlash 23 | */ 24 | 25 | export function removeLastTrailingSlash(url) { 26 | if (typeof url !== 'string') return url; 27 | return url.replace(/\/$/, ''); 28 | } 29 | 30 | export function removeExtraSpaces(text) { 31 | if (typeof text !== 'string') return; 32 | return text.replace(/\s+/g, ' ').trim(); 33 | } 34 | -------------------------------------------------------------------------------- /src/models/classname.js: -------------------------------------------------------------------------------- 1 | class ClassName { 2 | constructor(className) { 3 | this.base = className; 4 | 5 | if (!Array.isArray(className)) { 6 | this.base = [this.base]; 7 | } 8 | } 9 | 10 | add(className) { 11 | if (!Array.isArray(className)) { 12 | className = [className]; 13 | } 14 | 15 | this.base = [...this.base, ...className]; 16 | 17 | return this; 18 | } 19 | 20 | addIf(className, condition) { 21 | if (condition) this.add(className); 22 | return this; 23 | } 24 | 25 | toString() { 26 | return this.base.join(' '); 27 | } 28 | } 29 | 30 | export default ClassName; 31 | -------------------------------------------------------------------------------- /src/pages/404.js: -------------------------------------------------------------------------------- 1 | import Link from 'next/link'; 2 | import { Helmet } from 'react-helmet'; 3 | 4 | import Layout from 'components/Layout'; 5 | import Section from 'components/Section'; 6 | import Container from 'components/Container'; 7 | 8 | import styles from 'styles/pages/Error.module.scss'; 9 | 10 | export default function Custom404() { 11 | return ( 12 | 13 | 14 | 404 - Page Not Found 15 | 16 | 17 |
    18 | 19 |

    Page Not Found

    20 |

    404

    21 |

    The page you were looking for could not be found.

    22 |

    23 | Go back home 24 |

    25 |
    26 |
    27 |
    28 | ); 29 | } 30 | 31 | // Next.js method to ensure a static page gets rendered 32 | export async function getStaticProps() { 33 | return { 34 | props: {}, 35 | }; 36 | } 37 | -------------------------------------------------------------------------------- /src/pages/500.js: -------------------------------------------------------------------------------- 1 | import Link from 'next/link'; 2 | import { Helmet } from 'react-helmet'; 3 | 4 | import Layout from 'components/Layout'; 5 | import Section from 'components/Section'; 6 | import Container from 'components/Container'; 7 | 8 | import styles from 'styles/pages/Error.module.scss'; 9 | 10 | export default function Custom500() { 11 | return ( 12 | 13 | 14 | 500 - Internal Error 15 | 16 | 17 |
    18 | 19 |

    Internal Error

    20 |

    500

    21 |

    22 | Uh oh, something went wrong. Please try refreshing the page or clearing site data. If the problem persists, 23 | reach out to let us know! 24 |

    25 |

    26 | Go back home 27 |

    28 |
    29 |
    30 |
    31 | ); 32 | } 33 | 34 | // Next.js method to ensure a static page gets rendered 35 | export async function getStaticProps() { 36 | return { 37 | props: {}, 38 | }; 39 | } 40 | -------------------------------------------------------------------------------- /src/pages/[slugParent]/[[...slugChild]].js: -------------------------------------------------------------------------------- 1 | import Link from 'next/link'; 2 | import { Helmet } from 'react-helmet'; 3 | 4 | import { getPageByUri, getAllPages, getBreadcrumbsByUri } from 'lib/pages'; 5 | import { WebpageJsonLd } from 'lib/json-ld'; 6 | import { helmetSettingsFromMetadata } from 'lib/site'; 7 | import useSite from 'hooks/use-site'; 8 | import usePageMetadata from 'hooks/use-page-metadata'; 9 | 10 | import Layout from 'components/Layout'; 11 | import Header from 'components/Header'; 12 | import Content from 'components/Content'; 13 | import Section from 'components/Section'; 14 | import Container from 'components/Container'; 15 | import FeaturedImage from 'components/FeaturedImage'; 16 | import Breadcrumbs from 'components/Breadcrumbs'; 17 | 18 | import styles from 'styles/pages/Page.module.scss'; 19 | 20 | export default function Page({ page, breadcrumbs }) { 21 | const { title, metaTitle, description, slug, content, featuredImage, children } = page; 22 | 23 | const { metadata: siteMetadata = {} } = useSite(); 24 | 25 | const { metadata } = usePageMetadata({ 26 | metadata: { 27 | ...page, 28 | title: metaTitle, 29 | description: description || page.og?.description || `Read more about ${title}`, 30 | }, 31 | }); 32 | 33 | if (process.env.WORDPRESS_PLUGIN_SEO !== true) { 34 | metadata.title = `${title} - ${siteMetadata.title}`; 35 | metadata.og.title = metadata.title; 36 | metadata.twitter.title = metadata.title; 37 | } 38 | 39 | const hasChildren = Array.isArray(children) && children.length > 0; 40 | const hasBreadcrumbs = Array.isArray(breadcrumbs) && breadcrumbs.length > 0; 41 | 42 | const helmetSettings = helmetSettingsFromMetadata(metadata); 43 | 44 | return ( 45 | 46 | 47 | 48 | 54 | 55 |
    56 | {hasBreadcrumbs && } 57 | {featuredImage && ( 58 | 63 | )} 64 |

    {title}

    65 |
    66 | 67 | 68 |
    69 | 70 |
    76 | 77 |
    78 | 79 | {hasChildren && ( 80 |
    81 | 82 | 96 | 97 |
    98 | )} 99 |
    100 |
    101 | ); 102 | } 103 | 104 | export async function getStaticProps({ params = {} } = {}) { 105 | const { slugParent, slugChild } = params; 106 | 107 | // We can use the URI to look up our page and subsequently its ID, so 108 | // we can first contruct our URI from the page params 109 | 110 | let pageUri = `/${slugParent}/`; 111 | 112 | // We only want to apply deeper paths to the URI if we actually have 113 | // existing children 114 | 115 | if (Array.isArray(slugChild) && slugChild.length > 0) { 116 | pageUri = `${pageUri}${slugChild.join('/')}/`; 117 | } 118 | 119 | const { page } = await getPageByUri(pageUri); 120 | 121 | if (!page) { 122 | return { 123 | props: {}, 124 | notFound: true, 125 | }; 126 | } 127 | 128 | // In order to show the proper breadcrumbs, we need to find the entire 129 | // tree of pages. Rather than querying every segment, the query should 130 | // be cached for all pages, so we can grab that and use it to create 131 | // our trail 132 | 133 | const { pages } = await getAllPages({ 134 | queryIncludes: 'index', 135 | }); 136 | 137 | const breadcrumbs = getBreadcrumbsByUri(pageUri, pages); 138 | 139 | return { 140 | props: { 141 | page, 142 | breadcrumbs, 143 | }, 144 | }; 145 | } 146 | 147 | export async function getStaticPaths() { 148 | const { pages } = await getAllPages({ 149 | queryIncludes: 'index', 150 | }); 151 | 152 | // Take all the pages and create path params. The slugParent will always be 153 | // the top level parent page, where the slugChild will be an array of the 154 | // remaining segments to make up the path or URI 155 | 156 | // We also filter out the `/` homepage as it will conflict with index.js if 157 | // as they have the same path, which will fail the build 158 | 159 | const paths = pages 160 | .filter(({ uri }) => typeof uri === 'string' && uri !== '/') 161 | .map(({ uri }) => { 162 | const segments = uri.split('/').filter((seg) => seg !== ''); 163 | 164 | return { 165 | params: { 166 | slugParent: segments.shift(), 167 | slugChild: segments, 168 | }, 169 | }; 170 | }); 171 | 172 | return { 173 | paths, 174 | fallback: 'blocking', 175 | }; 176 | } 177 | -------------------------------------------------------------------------------- /src/pages/_app.js: -------------------------------------------------------------------------------- 1 | import NextApp from 'next/app'; 2 | 3 | import { SiteContext, useSiteContext } from 'hooks/use-site'; 4 | import { SearchProvider } from 'hooks/use-search'; 5 | 6 | import { getSiteMetadata } from 'lib/site'; 7 | import { getRecentPosts } from 'lib/posts'; 8 | import { getCategories } from 'lib/categories'; 9 | import NextNProgress from 'nextjs-progressbar'; 10 | import { getAllMenus } from 'lib/menus'; 11 | 12 | import 'styles/globals.scss'; 13 | import 'styles/wordpress.scss'; 14 | import variables from 'styles/_variables.module.scss'; 15 | 16 | function App({ Component, pageProps = {}, metadata, recentPosts, categories, menus }) { 17 | const site = useSiteContext({ 18 | metadata, 19 | recentPosts, 20 | categories, 21 | menus, 22 | }); 23 | 24 | return ( 25 | 26 | 27 | 28 | 29 | 30 | 31 | ); 32 | } 33 | 34 | App.getInitialProps = async function (appContext) { 35 | const appProps = await NextApp.getInitialProps(appContext); 36 | 37 | const { posts: recentPosts } = await getRecentPosts({ 38 | count: 5, 39 | queryIncludes: 'index', 40 | }); 41 | 42 | const { categories } = await getCategories({ 43 | count: 5, 44 | }); 45 | 46 | const { menus = [] } = await getAllMenus(); 47 | 48 | return { 49 | ...appProps, 50 | metadata: await getSiteMetadata(), 51 | recentPosts, 52 | categories, 53 | menus, 54 | }; 55 | }; 56 | 57 | export default App; 58 | -------------------------------------------------------------------------------- /src/pages/_document.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable @next/next/no-document-import-in-page */ 2 | import Document, { Html, Head, Main, NextScript } from 'next/document'; 3 | import { Helmet } from 'react-helmet'; 4 | 5 | // Via https://github.com/vercel/next.js/blob/canary/examples/with-react-helmet/pages/_document.js 6 | 7 | export default class MyDocument extends Document { 8 | static async getInitialProps(...args) { 9 | const documentProps = await super.getInitialProps(...args); 10 | // see https://github.com/nfl/react-helmet#server-usage for more information 11 | // 'head' was occupied by 'renderPage().head', we cannot use it 12 | return { ...documentProps, helmet: Helmet.renderStatic() }; 13 | } 14 | 15 | // should render on 16 | get helmetHtmlAttrComponents() { 17 | return this.props.helmet.htmlAttributes.toComponent(); 18 | } 19 | 20 | // should render on 21 | get helmetBodyAttrComponents() { 22 | return this.props.helmet.bodyAttributes.toComponent(); 23 | } 24 | 25 | // should render on 26 | get helmetHeadComponents() { 27 | return Object.keys(this.props.helmet) 28 | .filter((el) => el !== 'htmlAttributes' && el !== 'bodyAttributes') 29 | .map((el) => this.props.helmet[el].toComponent()); 30 | } 31 | 32 | render() { 33 | return ( 34 | 35 | {this.helmetHeadComponents} 36 | 37 |
    38 | 39 | 40 | 41 | ); 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /src/pages/authors/[slug].js: -------------------------------------------------------------------------------- 1 | import { getUserByNameSlug } from 'lib/users'; 2 | import { getPostsByAuthorSlug } from 'lib/posts'; 3 | import { AuthorJsonLd } from 'lib/json-ld'; 4 | import usePageMetadata from 'hooks/use-page-metadata'; 5 | 6 | import TemplateArchive from 'templates/archive'; 7 | import Title from 'components/Title'; 8 | 9 | export default function Author({ user, posts }) { 10 | const { title, name, avatar, description, slug } = user; 11 | 12 | const { metadata } = usePageMetadata({ 13 | metadata: { 14 | ...user, 15 | title, 16 | description: description || user.og?.description || `Read ${posts.length} posts from ${name}`, 17 | }, 18 | }); 19 | 20 | const postOptions = { 21 | excludeMetadata: ['author'], 22 | }; 23 | 24 | return ( 25 | <> 26 | 27 | } 30 | posts={posts} 31 | postOptions={postOptions} 32 | slug={slug} 33 | metadata={metadata} 34 | /> 35 | 36 | ); 37 | } 38 | 39 | export async function getStaticProps({ params = {} } = {}) { 40 | const { user } = await getUserByNameSlug(params?.slug); 41 | 42 | if (!user) { 43 | return { 44 | props: {}, 45 | notFound: true, 46 | }; 47 | } 48 | 49 | const { posts } = await getPostsByAuthorSlug({ 50 | slug: user?.slug, 51 | queryIncludes: 'archive', 52 | }); 53 | 54 | return { 55 | props: { 56 | user, 57 | posts, 58 | }, 59 | }; 60 | } 61 | 62 | export async function getStaticPaths() { 63 | // By default, we don't render any Author pages as they're 64 | // we're considering them non-critical pages 65 | 66 | // To enable pre-rendering of Author pages: 67 | 68 | // 1. Add import to the top of the file 69 | // 70 | // import { getAllAuthors, userSlugByName } from 'lib/users'; 71 | 72 | // 2. Uncomment the below 73 | // 74 | // const { authors } = await getAllAuthors(); 75 | 76 | // const paths = authors.map((author) => { 77 | // const { name } = author; 78 | // return { 79 | // params: { 80 | // slug: userSlugByName(name), 81 | // }, 82 | // }; 83 | // }); 84 | 85 | // 3. Update `paths` in the return statement below to reference the `paths` constant above 86 | 87 | return { 88 | paths: [], 89 | fallback: 'blocking', 90 | }; 91 | } 92 | -------------------------------------------------------------------------------- /src/pages/categories.js: -------------------------------------------------------------------------------- 1 | import Link from 'next/link'; 2 | import { Helmet } from 'react-helmet'; 3 | 4 | import useSite from 'hooks/use-site'; 5 | import { getAllCategories, categoryPathBySlug } from 'lib/categories'; 6 | import { WebpageJsonLd } from 'lib/json-ld'; 7 | 8 | import Layout from 'components/Layout'; 9 | import Header from 'components/Header'; 10 | import Section from 'components/Section'; 11 | import Container from 'components/Container'; 12 | import SectionTitle from 'components/SectionTitle'; 13 | 14 | import styles from 'styles/pages/Categories.module.scss'; 15 | 16 | export default function Categories({ categories }) { 17 | const { metadata = {} } = useSite(); 18 | const { title: siteTitle } = metadata; 19 | const title = 'Categories'; 20 | const slug = 'categories'; 21 | let metaDescription = `Read ${categories.length} categories at ${siteTitle}.`; 22 | 23 | return ( 24 | 25 | 26 | Categories 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 |
    35 | 36 |

    Categories

    37 |
    38 |
    39 | 40 |
    41 | 42 | All Categories 43 |
      44 | {categories.map((category) => { 45 | return ( 46 |
    • 47 | {category.name} 48 |
    • 49 | ); 50 | })} 51 |
    52 |
    53 |
    54 |
    55 | ); 56 | } 57 | 58 | export async function getStaticProps() { 59 | const { categories } = await getAllCategories(); 60 | 61 | return { 62 | props: { 63 | categories, 64 | }, 65 | }; 66 | } 67 | -------------------------------------------------------------------------------- /src/pages/categories/[slug].js: -------------------------------------------------------------------------------- 1 | import { getCategoryBySlug } from 'lib/categories'; 2 | import { getPostsByCategoryId } from 'lib/posts'; 3 | import usePageMetadata from 'hooks/use-page-metadata'; 4 | 5 | import TemplateArchive from 'templates/archive'; 6 | import Title from 'components/Title'; 7 | 8 | export default function Category({ category, posts }) { 9 | const { name, description, slug } = category; 10 | 11 | const { metadata } = usePageMetadata({ 12 | metadata: { 13 | ...category, 14 | description: description || category.og?.description || `Read ${posts.length} posts from ${name}`, 15 | }, 16 | }); 17 | 18 | return } posts={posts} slug={slug} metadata={metadata} />; 19 | } 20 | 21 | export async function getStaticProps({ params = {} } = {}) { 22 | const { category } = await getCategoryBySlug(params?.slug); 23 | 24 | if (!category) { 25 | return { 26 | props: {}, 27 | notFound: true, 28 | }; 29 | } 30 | 31 | const { posts } = await getPostsByCategoryId({ 32 | categoryId: category.databaseId, 33 | queryIncludes: 'archive', 34 | }); 35 | 36 | return { 37 | props: { 38 | category, 39 | posts, 40 | }, 41 | }; 42 | } 43 | 44 | export async function getStaticPaths() { 45 | // By default, we don't render any Category pages as 46 | // we're considering them non-critical pages 47 | 48 | // To enable pre-rendering of Category pages: 49 | 50 | // 1. Add import to the top of the file 51 | // 52 | // import { getAllCategories, getCategoryBySlug } from 'lib/categories'; 53 | 54 | // 2. Uncomment the below 55 | // 56 | // const { categories } = await getAllCategories(); 57 | 58 | // const paths = categories.map((category) => { 59 | // const { slug } = category; 60 | // return { 61 | // params: { 62 | // slug, 63 | // }, 64 | // }; 65 | // }); 66 | 67 | // 3. Update `paths` in the return statement below to reference the `paths` constant above 68 | 69 | return { 70 | paths: [], 71 | fallback: 'blocking', 72 | }; 73 | } 74 | -------------------------------------------------------------------------------- /src/pages/index.js: -------------------------------------------------------------------------------- 1 | import useSite from 'hooks/use-site'; 2 | import { getPaginatedPosts } from 'lib/posts'; 3 | import { WebsiteJsonLd } from 'lib/json-ld'; 4 | 5 | import Layout from 'components/Layout'; 6 | import Header from 'components/Header'; 7 | import Section from 'components/Section'; 8 | import Container from 'components/Container'; 9 | import PostCard from 'components/PostCard'; 10 | import Pagination from 'components/Pagination'; 11 | 12 | import styles from 'styles/pages/Home.module.scss'; 13 | 14 | export default function Home({ posts, pagination }) { 15 | const { metadata = {} } = useSite(); 16 | const { title, description } = metadata; 17 | 18 | return ( 19 | 20 | 21 |
    22 |

    27 | 28 |

    34 |

    35 | 36 |
    37 | 38 |

    Posts

    39 |
      40 | {posts.map((post) => { 41 | return ( 42 |
    • 43 | 44 |
    • 45 | ); 46 | })} 47 |
    48 | {pagination && ( 49 | 55 | )} 56 |
    57 |
    58 |
    59 | ); 60 | } 61 | 62 | export async function getStaticProps() { 63 | const { posts, pagination } = await getPaginatedPosts({ 64 | queryIncludes: 'archive', 65 | }); 66 | return { 67 | props: { 68 | posts, 69 | pagination: { 70 | ...pagination, 71 | basePath: '/posts', 72 | }, 73 | }, 74 | }; 75 | } 76 | -------------------------------------------------------------------------------- /src/pages/posts.js: -------------------------------------------------------------------------------- 1 | import usePageMetadata from 'hooks/use-page-metadata'; 2 | 3 | import { getPaginatedPosts } from 'lib/posts'; 4 | 5 | import TemplateArchive from 'templates/archive'; 6 | 7 | export default function Posts({ posts, pagination }) { 8 | const title = 'All Posts'; 9 | const slug = 'posts'; 10 | 11 | const { metadata } = usePageMetadata({ 12 | metadata: { 13 | title, 14 | description: false, 15 | }, 16 | }); 17 | 18 | return ; 19 | } 20 | 21 | export async function getStaticProps() { 22 | const { posts, pagination } = await getPaginatedPosts({ 23 | queryIncludes: 'archive', 24 | }); 25 | return { 26 | props: { 27 | posts, 28 | pagination: { 29 | ...pagination, 30 | basePath: '/posts', 31 | }, 32 | }, 33 | }; 34 | } 35 | -------------------------------------------------------------------------------- /src/pages/posts/[slug].js: -------------------------------------------------------------------------------- 1 | import Link from 'next/link'; 2 | import { Helmet } from 'react-helmet'; 3 | 4 | import { getPostBySlug, getRecentPosts, getRelatedPosts, postPathBySlug } from 'lib/posts'; 5 | import { categoryPathBySlug } from 'lib/categories'; 6 | import { formatDate } from 'lib/datetime'; 7 | import { ArticleJsonLd } from 'lib/json-ld'; 8 | import { helmetSettingsFromMetadata } from 'lib/site'; 9 | import useSite from 'hooks/use-site'; 10 | import usePageMetadata from 'hooks/use-page-metadata'; 11 | 12 | import Layout from 'components/Layout'; 13 | import Header from 'components/Header'; 14 | import Section from 'components/Section'; 15 | import Container from 'components/Container'; 16 | import Content from 'components/Content'; 17 | import Metadata from 'components/Metadata'; 18 | import FeaturedImage from 'components/FeaturedImage'; 19 | 20 | import styles from 'styles/pages/Post.module.scss'; 21 | 22 | export default function Post({ post, socialImage, related }) { 23 | const { 24 | title, 25 | metaTitle, 26 | description, 27 | content, 28 | date, 29 | author, 30 | categories, 31 | modified, 32 | featuredImage, 33 | isSticky = false, 34 | } = post; 35 | 36 | const { metadata: siteMetadata = {}, homepage } = useSite(); 37 | 38 | if (!post.og) { 39 | post.og = {}; 40 | } 41 | 42 | post.og.imageUrl = `${homepage}${socialImage}`; 43 | post.og.imageSecureUrl = post.og.imageUrl; 44 | post.og.imageWidth = 2000; 45 | post.og.imageHeight = 1000; 46 | 47 | const { metadata } = usePageMetadata({ 48 | metadata: { 49 | ...post, 50 | title: metaTitle, 51 | description: description || post.og?.description || `Read more about ${title}`, 52 | }, 53 | }); 54 | 55 | if (process.env.WORDPRESS_PLUGIN_SEO !== true) { 56 | metadata.title = `${title} - ${siteMetadata.title}`; 57 | metadata.og.title = metadata.title; 58 | metadata.twitter.title = metadata.title; 59 | } 60 | 61 | const metadataOptions = { 62 | compactCategories: false, 63 | }; 64 | 65 | const { posts: relatedPostsList, title: relatedPostsTitle } = related || {}; 66 | 67 | const helmetSettings = helmetSettingsFromMetadata(metadata); 68 | 69 | return ( 70 | 71 | 72 | 73 | 74 | 75 |
    76 | {featuredImage && ( 77 | 82 | )} 83 |

    89 | 97 |

    98 | 99 | 100 |
    101 | 102 |
    108 | 109 |
    110 |
    111 | 112 |
    113 | 114 |

    Last updated on {formatDate(modified)}.

    115 | {Array.isArray(relatedPostsList) && relatedPostsList.length > 0 && ( 116 |
    117 | {relatedPostsTitle.name ? ( 118 | 119 | More from {relatedPostsTitle.name} 120 | 121 | ) : ( 122 | More Posts 123 | )} 124 |
      125 | {relatedPostsList.map((post) => ( 126 |
    • 127 | {post.title} 128 |
    • 129 | ))} 130 |
    131 |
    132 | )} 133 |
    134 |
    135 |
    136 | ); 137 | } 138 | 139 | export async function getStaticProps({ params = {} } = {}) { 140 | const { post } = await getPostBySlug(params?.slug); 141 | 142 | if (!post) { 143 | return { 144 | props: {}, 145 | notFound: true, 146 | }; 147 | } 148 | 149 | const { categories, databaseId: postId } = post; 150 | 151 | const props = { 152 | post, 153 | socialImage: `${process.env.OG_IMAGE_DIRECTORY}/${params?.slug}.png`, 154 | }; 155 | 156 | const { category: relatedCategory, posts: relatedPosts } = (await getRelatedPosts(categories, postId)) || {}; 157 | const hasRelated = relatedCategory && Array.isArray(relatedPosts) && relatedPosts.length; 158 | 159 | if (hasRelated) { 160 | props.related = { 161 | posts: relatedPosts, 162 | title: { 163 | name: relatedCategory.name || null, 164 | link: categoryPathBySlug(relatedCategory.slug), 165 | }, 166 | }; 167 | } 168 | 169 | return { 170 | props, 171 | }; 172 | } 173 | 174 | export async function getStaticPaths() { 175 | // Only render the most recent posts to avoid spending unecessary time 176 | // querying every single post from WordPress 177 | 178 | // Tip: this can be customized to use data or analytitcs to determine the 179 | // most popular posts and render those instead 180 | 181 | const { posts } = await getRecentPosts({ 182 | count: process.env.POSTS_PRERENDER_COUNT, // Update this value in next.config.js! 183 | queryIncludes: 'index', 184 | }); 185 | 186 | const paths = posts 187 | .filter(({ slug }) => typeof slug === 'string') 188 | .map(({ slug }) => ({ 189 | params: { 190 | slug, 191 | }, 192 | })); 193 | 194 | return { 195 | paths, 196 | fallback: 'blocking', 197 | }; 198 | } 199 | -------------------------------------------------------------------------------- /src/pages/posts/page/[page].js: -------------------------------------------------------------------------------- 1 | import { getPaginatedPosts } from 'lib/posts'; 2 | import usePageMetadata from 'hooks/use-page-metadata'; 3 | 4 | import TemplateArchive from 'templates/archive'; 5 | 6 | export default function Posts({ posts, pagination }) { 7 | const title = `All Posts`; 8 | const slug = 'posts'; 9 | 10 | const { metadata } = usePageMetadata({ 11 | metadata: { 12 | title, 13 | description: `Page ${pagination.currentPage}`, 14 | }, 15 | }); 16 | 17 | return ; 18 | } 19 | 20 | export async function getStaticProps({ params = {} } = {}) { 21 | const { posts, pagination } = await getPaginatedPosts({ 22 | currentPage: params?.page, 23 | queryIncludes: 'archive', 24 | }); 25 | 26 | if (!pagination.currentPage) { 27 | return { 28 | props: {}, 29 | notFound: true, 30 | }; 31 | } 32 | 33 | return { 34 | props: { 35 | posts, 36 | pagination: { 37 | ...pagination, 38 | basePath: '/posts', 39 | }, 40 | }, 41 | }; 42 | } 43 | 44 | export async function getStaticPaths() { 45 | // By default, we don't render any Pagination pages as 46 | // we're considering them non-critical pages 47 | 48 | // To enable pre-rendering of Category pages: 49 | 50 | // 1. Add import to the top of the file 51 | // 52 | // import { getAllPosts, getPagesCount } from 'lib/posts'; 53 | 54 | // 2. Uncomment the below 55 | // 56 | // const { posts } = await getAllPosts({ 57 | // queryIncludes: 'index', 58 | // }); 59 | // const pagesCount = await getPagesCount(posts); 60 | 61 | // const paths = [...new Array(pagesCount)].map((_, i) => { 62 | // return { params: { page: String(i + 1) } }; 63 | // }); 64 | 65 | // 3. Update `paths` in the return statement below to reference the `paths` constant above 66 | 67 | return { 68 | paths: [], 69 | fallback: 'blocking', 70 | }; 71 | } 72 | -------------------------------------------------------------------------------- /src/pages/search.js: -------------------------------------------------------------------------------- 1 | import { useEffect } from 'react'; 2 | import { Helmet } from 'react-helmet'; 3 | import usePageMetadata from 'hooks/use-page-metadata'; 4 | 5 | import useSearch from 'hooks/use-search'; 6 | 7 | import TemplateArchive from 'templates/archive'; 8 | 9 | export default function Search() { 10 | const { query, results, search } = useSearch(); 11 | const title = 'Search'; 12 | const slug = 'search'; 13 | 14 | useEffect(() => { 15 | const params = new URLSearchParams(window.location.search); 16 | search({ 17 | query: params.get('q'), 18 | }); 19 | // eslint-disable-next-line react-hooks/exhaustive-deps 20 | }, []); 21 | 22 | const { metadata } = usePageMetadata({ 23 | metadata: { 24 | title, 25 | description: `Search results for ${query}`, 26 | }, 27 | }); 28 | 29 | return ( 30 | <> 31 | 32 | 33 | 34 | 35 | 36 | ); 37 | } 38 | 39 | // Next.js method to ensure a static page gets rendered 40 | export async function getStaticProps() { 41 | return { 42 | props: {}, 43 | }; 44 | } 45 | -------------------------------------------------------------------------------- /src/styles/_variables.module.scss: -------------------------------------------------------------------------------- 1 | $progressbar_color: #0070f3; 2 | 3 | :export { 4 | progressbarColor: $progressbar_color; 5 | } 6 | -------------------------------------------------------------------------------- /src/styles/components/_code.scss: -------------------------------------------------------------------------------- 1 | @import "styles/settings/__settings"; 2 | 3 | .code { 4 | font-size: 1.1rem; 5 | font-family: $font-family-default; 6 | background: $color-gray-50; 7 | padding: 0.75rem; 8 | border-radius: 5px; 9 | } 10 | -------------------------------------------------------------------------------- /src/styles/components/_container.scss: -------------------------------------------------------------------------------- 1 | .container { 2 | max-width: 60rem; 3 | margin: 0 auto; 4 | } 5 | -------------------------------------------------------------------------------- /src/styles/globals.scss: -------------------------------------------------------------------------------- 1 | @import "styles/settings/__settings"; 2 | @import "style.css"; 3 | 4 | * { 5 | box-sizing: border-box; 6 | } 7 | 8 | html, 9 | body { 10 | padding: 0; 11 | margin: 0; 12 | font-family: -apple-system, BlinkMacSystemFont, Segoe UI, Helvetica Neue, sans-serif; 13 | } 14 | 15 | p { 16 | line-height: 1.6; 17 | margin: 1.2em 0; 18 | } 19 | 20 | ul { 21 | padding-left: 1.2em; 22 | } 23 | 24 | a { 25 | color: $color-primary; 26 | } 27 | 28 | img { 29 | max-width: 100%; 30 | height: auto; 31 | } 32 | 33 | input { 34 | &[type="text"], 35 | &[type="search"], 36 | &[type="email"] { 37 | outline-offset: 0; 38 | padding: 0.5em 0.8em; 39 | } 40 | } 41 | 42 | figcaption { 43 | color: $color-gray-500; 44 | font-size: 0.9em; 45 | text-align: center; 46 | font-style: italic; 47 | margin-top: 0.6em; 48 | } 49 | 50 | .sr-only { 51 | display: block; 52 | overflow: hidden; 53 | position: absolute; 54 | top: -9999px; 55 | left: -9999px; 56 | width: 0; 57 | height: 0; 58 | } 59 | -------------------------------------------------------------------------------- /src/styles/pages/Categories.module.scss: -------------------------------------------------------------------------------- 1 | @import "styles/settings/__settings"; 2 | 3 | .categories { 4 | font-size: 1.2em; 5 | list-style: none; 6 | padding-left: 0; 7 | margin: -1rem 0; 8 | 9 | & > li { 10 | margin: 1.5em 0; 11 | 12 | &:first-child { 13 | margin-top: 0; 14 | } 15 | 16 | &:last-child { 17 | margin-bottom: 0; 18 | } 19 | } 20 | 21 | a { 22 | color: inherit; 23 | text-decoration: none; 24 | 25 | @media (hover: hover) { 26 | &:hover { 27 | color: $color-primary; 28 | text-decoration: underline; 29 | } 30 | } 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /src/styles/pages/Error.module.scss: -------------------------------------------------------------------------------- 1 | @import "styles/settings/__settings"; 2 | 3 | .center { 4 | display: flex; 5 | flex-direction: column; 6 | align-items: center; 7 | text-align: center; 8 | } 9 | 10 | .errorCode { 11 | color: $color-gray-500; 12 | font-size: 1.5em; 13 | margin: -0.2em 0 0.8em; 14 | } 15 | 16 | .errorMessage { 17 | max-width: 40em; 18 | } 19 | -------------------------------------------------------------------------------- /src/styles/pages/Home.module.scss: -------------------------------------------------------------------------------- 1 | @import "styles/settings/__settings"; 2 | 3 | .description { 4 | text-align: center; 5 | line-height: 1.5; 6 | font-size: 1.5rem; 7 | } 8 | 9 | .posts { 10 | list-style: none; 11 | padding-left: 0; 12 | margin: -1rem 0; 13 | 14 | & > li { 15 | margin: 4em 0; 16 | 17 | &:first-child { 18 | margin-top: 0; 19 | } 20 | 21 | &:last-child { 22 | margin-bottom: 0; 23 | } 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /src/styles/pages/Page.module.scss: -------------------------------------------------------------------------------- 1 | @import "styles/settings/__settings"; 2 | 3 | .sectionChildren { 4 | aside { 5 | ul { 6 | font-size: 0.8em; 7 | list-style: none; 8 | padding-left: 0; 9 | } 10 | 11 | a { 12 | color: $color-gray-800; 13 | text-decoration: none; 14 | 15 | @media (hover: hover) { 16 | &:hover { 17 | color: $color-primary; 18 | text-decoration: underline; 19 | } 20 | } 21 | } 22 | } 23 | } 24 | 25 | .childrenHeader { 26 | font-size: 0.7em; 27 | padding-bottom: 0.4em; 28 | border-bottom: solid 1px $color-gray-100; 29 | margin-top: 0; 30 | } 31 | -------------------------------------------------------------------------------- /src/styles/pages/Post.module.scss: -------------------------------------------------------------------------------- 1 | @import "styles/settings/__settings"; 2 | 3 | .postMetadata { 4 | text-align: center; 5 | justify-content: center; 6 | } 7 | 8 | .postFooter { 9 | text-align: center; 10 | } 11 | 12 | .postModified { 13 | color: $color-gray-500; 14 | font-style: italic; 15 | margin-bottom: 3rem; 16 | } 17 | 18 | .relatedPosts { 19 | display: flex; 20 | flex-direction: column; 21 | span { 22 | font-size: 1.2em; 23 | margin-bottom: 0.4em; 24 | } 25 | ul { 26 | list-style: none; 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /src/styles/settings/__settings.scss: -------------------------------------------------------------------------------- 1 | @import "colors"; 2 | @import "display"; 3 | @import "typography"; 4 | -------------------------------------------------------------------------------- /src/styles/settings/_colors.scss: -------------------------------------------------------------------------------- 1 | $color-gray-50: #eef0f3; 2 | $color-gray-100: #cfd7de; 3 | $color-gray-200: #b1bdca; 4 | $color-gray-300: #93a3b5; 5 | $color-gray-400: #7589a0; 6 | $color-gray-500: #5c7086; 7 | $color-gray-600: #475768; 8 | $color-gray-700: #333e4a; 9 | $color-gray-800: #1e242b; 10 | $color-gray-900: #090b0d; 11 | 12 | $color-blue-50: #f3f8ff; 13 | $color-blue-100: #c0ddff; 14 | $color-blue-200: #8dc1ff; 15 | $color-blue-300: #5aa6ff; 16 | $color-blue-400: #278bff; 17 | $color-blue-500: #0070f3; 18 | $color-blue-600: #0059c0; 19 | $color-blue-700: #00418d; 20 | $color-blue-800: #002a5a; 21 | $color-blue-900: #001227; 22 | 23 | $color-primary: $color-blue-500; 24 | -------------------------------------------------------------------------------- /src/styles/settings/_display.scss: -------------------------------------------------------------------------------- 1 | $border-radius-small: 0.2em; 2 | -------------------------------------------------------------------------------- /src/styles/settings/_typography.scss: -------------------------------------------------------------------------------- 1 | $font-family-default: Menlo, Monaco, Lucida Console, Liberation Mono, DejaVu Sans Mono, Bitstream Vera Sans Mono, 2 | Courier New, monospace; 3 | -------------------------------------------------------------------------------- /src/styles/templates/Archive.module.scss: -------------------------------------------------------------------------------- 1 | @import "styles/settings/__settings"; 2 | 3 | .archiveDescription { 4 | text-align: center; 5 | font-size: 1.1em; 6 | color: $color-gray-600; 7 | max-width: 36em; 8 | margin-left: auto; 9 | margin-right: auto; 10 | } 11 | 12 | .posts { 13 | list-style: none; 14 | padding-left: 0; 15 | margin: -1rem 0; 16 | 17 | & > li { 18 | margin: 4em 0; 19 | 20 | &:first-child { 21 | margin-top: 0; 22 | } 23 | 24 | &:last-child { 25 | margin-bottom: 0; 26 | } 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /src/styles/wordpress.scss: -------------------------------------------------------------------------------- 1 | .aligncenter { 2 | text-align: center; 3 | } 4 | -------------------------------------------------------------------------------- /src/templates/archive.js: -------------------------------------------------------------------------------- 1 | import { Helmet } from 'react-helmet'; 2 | 3 | import { WebpageJsonLd } from 'lib/json-ld'; 4 | import { helmetSettingsFromMetadata } from 'lib/site'; 5 | import useSite from 'hooks/use-site'; 6 | 7 | import Layout from 'components/Layout'; 8 | import Header from 'components/Header'; 9 | import Section from 'components/Section'; 10 | import Container from 'components/Container'; 11 | import SectionTitle from 'components/SectionTitle'; 12 | import PostCard from 'components/PostCard'; 13 | import Pagination from 'components/Pagination/Pagination'; 14 | 15 | import styles from 'styles/templates/Archive.module.scss'; 16 | 17 | const DEFAULT_POST_OPTIONS = {}; 18 | 19 | export default function TemplateArchive({ 20 | title = 'Archive', 21 | Title, 22 | posts, 23 | postOptions = DEFAULT_POST_OPTIONS, 24 | slug, 25 | metadata, 26 | pagination, 27 | }) { 28 | const { metadata: siteMetadata = {} } = useSite(); 29 | 30 | if (process.env.WORDPRESS_PLUGIN_SEO !== true) { 31 | metadata.title = `${title} - ${siteMetadata.title}`; 32 | metadata.og.title = metadata.title; 33 | metadata.twitter.title = metadata.title; 34 | } 35 | 36 | const helmetSettings = helmetSettingsFromMetadata(metadata); 37 | 38 | return ( 39 | 40 | 41 | 42 | 43 | 44 |
    45 | 46 |

    {Title || title}

    47 | {metadata.description && ( 48 |

    54 | )} 55 | 56 |

    57 | 58 |
    59 | 60 | Posts 61 | {Array.isArray(posts) && ( 62 | <> 63 |
      64 | {posts.map((post) => { 65 | return ( 66 |
    • 67 | 68 |
    • 69 | ); 70 | })} 71 |
    72 | {pagination && ( 73 | 78 | )} 79 | 80 | )} 81 |
    82 |
    83 |
    84 | ); 85 | } 86 | --------------------------------------------------------------------------------