├── styles
├── PageContentWrapper.module.css
├── ContentWrapper.module.css
├── RecentPostList.module.css
├── PublishedDate.module.css
├── PreviewBanner.module.css
├── Footer.module.css
├── Tags.module.css
├── Button.module.css
├── SocialLinks.module.css
├── ContentList.module.css
├── ExternalUrl.module.css
├── Author.module.css
├── RichTextPageContent.module.css
├── Pagination.module.css
├── HeroBanner.module.css
├── Header.module.css
└── Typography.module.css
├── screenshot.png
├── next.config.js
├── public
├── favicon.ico
├── favicon-16x16.png
├── favicon-32x32.png
├── apple-touch-icon.png
├── mstile-150x150.png
├── android-chrome-192x192.png
├── android-chrome-512x512.png
├── browserconfig.xml
└── site.webmanifest
├── screenshot_space_id.png
├── screenshot_access_token.png
├── screenshot_content_model.png
├── screenshot_create_build_hook.png
├── screenshot_import_terminal.png
├── .prettierrc
├── screenshot_configure_build_hook.png
├── netlify.toml
├── components
├── RichTextPageContent
│ ├── CodeBlock
│ │ ├── CodeBlock.module.css
│ │ └── index.js
│ ├── VideoEmbed
│ │ ├── VideoEmbed.module.css
│ │ └── index.js
│ ├── svg
│ │ └── LinkIcon.js
│ └── index.js
├── ContentWrapper
│ └── index.js
├── PageContentWrapper
│ └── index.js
├── PreviewBanner
│ └── index.js
├── Post
│ ├── Tags
│ │ └── index.js
│ ├── PublishedDate
│ │ └── index.js
│ ├── ExternalUrl
│ │ ├── InfoSvg.js
│ │ └── index.js
│ ├── index.js
│ └── Author
│ │ └── index.js
├── Footer
│ └── index.js
├── PostList
│ ├── Pagination
│ │ ├── svg
│ │ │ ├── ChevronLeft.js
│ │ │ └── ChevronRight.js
│ │ └── index.js
│ └── index.js
├── SocialLinks
│ ├── svgs
│ │ ├── feed.js
│ │ └── twitter.js
│ └── index.js
├── RecentPostList
│ └── index.js
├── HeroBanner
│ └── index.js
├── Header
│ ├── index.js
│ └── svg
│ │ └── Logo.js
└── PageMeta
│ └── index.js
├── pages
├── api
│ ├── endpreview.js
│ └── preview.js
├── _document.js
├── server-sitemap.xml
│ └── index.js
├── blog
│ ├── [slug].js
│ ├── index.js
│ └── page
│ │ └── [page].js
├── index.js
└── buildrss.js
├── .env.local.example
├── jsconfig.json
├── next-sitemap.js
├── layouts
├── main.js
└── main.styles.js
├── package.json
├── .gitignore
├── utils
├── ReactMarkdownRenderers.js
├── Date.js
├── Config.js
├── OpenGraph.js
└── ContentfulApi.js
├── LICENSE
└── README.md
/styles/PageContentWrapper.module.css:
--------------------------------------------------------------------------------
1 | .container {
2 | margin-bottom: 4rem;
3 | }
4 |
--------------------------------------------------------------------------------
/screenshot.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/whitep4nth3r/nextjs-contentful-blog-starter/HEAD/screenshot.png
--------------------------------------------------------------------------------
/next.config.js:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | images: {
3 | domains: ["images.ctfassets.net"],
4 | },
5 | };
6 |
--------------------------------------------------------------------------------
/public/favicon.ico:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/whitep4nth3r/nextjs-contentful-blog-starter/HEAD/public/favicon.ico
--------------------------------------------------------------------------------
/public/favicon-16x16.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/whitep4nth3r/nextjs-contentful-blog-starter/HEAD/public/favicon-16x16.png
--------------------------------------------------------------------------------
/public/favicon-32x32.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/whitep4nth3r/nextjs-contentful-blog-starter/HEAD/public/favicon-32x32.png
--------------------------------------------------------------------------------
/screenshot_space_id.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/whitep4nth3r/nextjs-contentful-blog-starter/HEAD/screenshot_space_id.png
--------------------------------------------------------------------------------
/public/apple-touch-icon.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/whitep4nth3r/nextjs-contentful-blog-starter/HEAD/public/apple-touch-icon.png
--------------------------------------------------------------------------------
/public/mstile-150x150.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/whitep4nth3r/nextjs-contentful-blog-starter/HEAD/public/mstile-150x150.png
--------------------------------------------------------------------------------
/screenshot_access_token.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/whitep4nth3r/nextjs-contentful-blog-starter/HEAD/screenshot_access_token.png
--------------------------------------------------------------------------------
/screenshot_content_model.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/whitep4nth3r/nextjs-contentful-blog-starter/HEAD/screenshot_content_model.png
--------------------------------------------------------------------------------
/screenshot_create_build_hook.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/whitep4nth3r/nextjs-contentful-blog-starter/HEAD/screenshot_create_build_hook.png
--------------------------------------------------------------------------------
/screenshot_import_terminal.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/whitep4nth3r/nextjs-contentful-blog-starter/HEAD/screenshot_import_terminal.png
--------------------------------------------------------------------------------
/public/android-chrome-192x192.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/whitep4nth3r/nextjs-contentful-blog-starter/HEAD/public/android-chrome-192x192.png
--------------------------------------------------------------------------------
/public/android-chrome-512x512.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/whitep4nth3r/nextjs-contentful-blog-starter/HEAD/public/android-chrome-512x512.png
--------------------------------------------------------------------------------
/.prettierrc:
--------------------------------------------------------------------------------
1 | {
2 | "trailingComma": "all",
3 | "singleQuote": false,
4 | "printWidth": 80,
5 | "semi": true,
6 | "endOfLine": "auto"
7 | }
8 |
--------------------------------------------------------------------------------
/screenshot_configure_build_hook.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/whitep4nth3r/nextjs-contentful-blog-starter/HEAD/screenshot_configure_build_hook.png
--------------------------------------------------------------------------------
/styles/ContentWrapper.module.css:
--------------------------------------------------------------------------------
1 | .container {
2 | margin-left: auto;
3 | margin-right: auto;
4 | max-width: var(--wrapper-max-width);
5 | padding: 1rem;
6 | }
7 |
--------------------------------------------------------------------------------
/netlify.toml:
--------------------------------------------------------------------------------
1 | [template.environment]
2 | CONTENTFUL_SPACE_ID = "Set this to your Contentful space ID"
3 | CONTENTFUL_ACCESS_TOKEN = "Set this to your Contentful access token"
4 |
--------------------------------------------------------------------------------
/styles/RecentPostList.module.css:
--------------------------------------------------------------------------------
1 | .recentPostList__header {
2 | font-size: 2rem;
3 | color: var(--color-foreground);
4 | font-family: var(--font-family-main);
5 | margin-bottom: 1rem;
6 | }
7 |
--------------------------------------------------------------------------------
/components/RichTextPageContent/CodeBlock/CodeBlock.module.css:
--------------------------------------------------------------------------------
1 | pre[class*="language-"].codeBlock {
2 | margin: 2rem 0;
3 | }
4 |
5 | code[class*="language-"].codeBlock__inner {
6 | overflow-x: auto;
7 | white-space: pre-wrap;
8 | }
9 |
--------------------------------------------------------------------------------
/pages/api/endpreview.js:
--------------------------------------------------------------------------------
1 | /*
2 | * Clears the Next.js preview mode cookies.
3 | * This function accepts no arguments.
4 | */
5 |
6 | export default function handler(req, res) {
7 | res.clearPreviewData();
8 | res.redirect("/");
9 | }
10 |
--------------------------------------------------------------------------------
/components/ContentWrapper/index.js:
--------------------------------------------------------------------------------
1 | import ContentWrapperStyles from "@styles/ContentWrapper.module.css";
2 |
3 | export default function ContentWrapper({ children }) {
4 | return
{children}
;
5 | }
6 |
--------------------------------------------------------------------------------
/.env.local.example:
--------------------------------------------------------------------------------
1 | CONTENTFUL_SPACE_ID=84zl5qdw0ore
2 | CONTENTFUL_ACCESS_TOKEN=_9I7fuuLbV9FUV1p596lpDGkfLs9icTP2DZA5KUbFjA
3 | CONTENTFUL_PREVIEW_ACCESS_TOKEN=g8AyZ0_oXgEPGAKudse8KLFWQmSJa_mTukur8zpXZiM
4 | CONTENTFUL_PREVIEW_SECRET=YD57VT4aE79PTgAfeXbPKs9EpnPmZkd7
--------------------------------------------------------------------------------
/components/PageContentWrapper/index.js:
--------------------------------------------------------------------------------
1 | import PageContentWrapperStyles from "@styles/PageContentWrapper.module.css";
2 |
3 | export default function PageContentWrapper({ children }) {
4 | return {children}
;
5 | }
6 |
--------------------------------------------------------------------------------
/jsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "baseUrl": "./",
4 | "paths": {
5 | "@components/*": ["components/*"],
6 | "@layouts/*": ["layouts/*"],
7 | "@styles/*": ["styles/*"],
8 | "@utils/*": ["utils/*"]
9 | }
10 | }
11 | }
12 |
--------------------------------------------------------------------------------
/styles/PublishedDate.module.css:
--------------------------------------------------------------------------------
1 | .publishedDate {
2 | font-size: 1rem;
3 | line-height: 1.4;
4 | margin-bottom: 2rem;
5 | font-weight: var(--font-weight-normal);
6 | font-family: var(--font-family-main);
7 | color: var(--color-foreground);
8 | display: flex;
9 | }
10 |
--------------------------------------------------------------------------------
/components/RichTextPageContent/VideoEmbed/VideoEmbed.module.css:
--------------------------------------------------------------------------------
1 | .videoEmbed {
2 | position: relative;
3 | padding-bottom: 56.25%;
4 | margin-bottom: 2rem;
5 | }
6 |
7 | .videoEmbed__iframe {
8 | position: absolute;
9 | top: 0;
10 | left: 0;
11 | width: 100%;
12 | height: 100%;
13 | }
14 |
--------------------------------------------------------------------------------
/public/browserconfig.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 | #b91d47
7 |
8 |
9 |
10 |
--------------------------------------------------------------------------------
/components/PreviewBanner/index.js:
--------------------------------------------------------------------------------
1 | import PreviewBannerStyles from "@styles/PreviewBanner.module.css";
2 |
3 | export default function PreviewBanner() {
4 | return (
5 |
8 | );
9 | }
10 |
--------------------------------------------------------------------------------
/components/Post/Tags/index.js:
--------------------------------------------------------------------------------
1 | import TagsStyles from "@styles/Tags.module.css";
2 |
3 | export default function Tags(props) {
4 | const { tags } = props;
5 |
6 | return (
7 |
8 | {tags.map((tag) => (
9 | -
10 | {tag}
11 |
12 | ))}
13 |
14 | );
15 | }
16 |
--------------------------------------------------------------------------------
/next-sitemap.js:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | siteUrl: "https://nextjs-contentful-blog-starter.vercel.app/",
3 | generateRobotsTxt: true, // (optional)
4 | robotsTxtOptions: {
5 | policies: [{ userAgent: "*", disallow: "/api" }],
6 | additionalSitemaps: [
7 | "https://nextjs-contentful-blog-starter.vercel.app/server-sitemap.xml",
8 | ],
9 | },
10 | exclude: ["/api/*", "/server-sitemap.xml"],
11 | };
12 |
--------------------------------------------------------------------------------
/styles/PreviewBanner.module.css:
--------------------------------------------------------------------------------
1 | .preview {
2 | width: 100%;
3 | padding: 1rem;
4 | text-align: center;
5 | display: block;
6 | background-color: var(--color-tertiary);
7 | }
8 |
9 | .preview__text {
10 | color: var(--color-background);
11 | font-size: 2rem;
12 | line-height: 1;
13 | text-transform: uppercase;
14 | font-family: var(--font-family-heading);
15 | font-weight: var(--font-weight-bold);
16 | }
17 |
--------------------------------------------------------------------------------
/public/site.webmanifest:
--------------------------------------------------------------------------------
1 | {
2 | "name": "example.com",
3 | "short_name": "example.com",
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": "#f11012",
17 | "background_color": "#f11012",
18 | "display": "standalone"
19 | }
20 |
--------------------------------------------------------------------------------
/components/Post/PublishedDate/index.js:
--------------------------------------------------------------------------------
1 | import PublishedDateStyles from "@styles/PublishedDate.module.css";
2 | import {
3 | formatPublishedDateForDateTime,
4 | formatPublishedDateForDisplay,
5 | } from "@utils/Date";
6 |
7 | export default function PublishedDate(props) {
8 | const { date } = props;
9 |
10 | return (
11 |
17 | );
18 | }
19 |
--------------------------------------------------------------------------------
/components/RichTextPageContent/CodeBlock/index.js:
--------------------------------------------------------------------------------
1 | import CodeBlockStyles from "./CodeBlock.module.css";
2 | import Prism from "prismjs";
3 | import { useEffect } from "react";
4 |
5 | export default function CodeBlock(props) {
6 | useEffect(() => {
7 | Prism.highlightAll();
8 | }, []);
9 |
10 | const { language, code } = props;
11 |
12 | return (
13 |
14 | {code}
15 |
16 | );
17 | }
18 |
--------------------------------------------------------------------------------
/pages/_document.js:
--------------------------------------------------------------------------------
1 | import Document, { Html, Head, Main, NextScript } from "next/document";
2 |
3 | class MyDocument extends Document {
4 | static async getInitialProps(ctx) {
5 | const initialProps = await Document.getInitialProps(ctx);
6 | return { ...initialProps };
7 | }
8 |
9 | render() {
10 | return (
11 |
12 |
14 |
15 |
16 |
17 |
18 | );
19 | }
20 | }
21 |
22 | export default MyDocument;
23 |
--------------------------------------------------------------------------------
/layouts/main.js:
--------------------------------------------------------------------------------
1 | import GlobalStyles from "./main.styles.js";
2 | import Header from "@components/Header";
3 | import Footer from "@components/Footer";
4 | import PreviewBanner from "@components/PreviewBanner";
5 |
6 | export default function MainLayout(props) {
7 | const { preview } = props;
8 | return (
9 | <>
10 | {preview && }
11 |
12 | {props.children}
13 |
14 |
15 |
18 | >
19 | );
20 | }
21 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "learn-starter",
3 | "version": "0.1.0",
4 | "private": true,
5 | "scripts": {
6 | "dev": "next dev",
7 | "build": "next build",
8 | "postbuild": "next-sitemap",
9 | "start": "next start"
10 | },
11 | "dependencies": {
12 | "@contentful/rich-text-react-renderer": "^14.1.2",
13 | "next": "^12.0.4",
14 | "next-sitemap": "^1.6.9",
15 | "prismjs": "^1.23.0",
16 | "react": "^17.0.2",
17 | "react-dom": "^17.0.2",
18 | "react-markdown": "^5.0.3"
19 | },
20 | "license": "MIT"
21 | }
22 |
--------------------------------------------------------------------------------
/components/RichTextPageContent/VideoEmbed/index.js:
--------------------------------------------------------------------------------
1 | import VideoEmbedStyles from "./VideoEmbed.module.css";
2 |
3 | export default function VideoEmbed(props) {
4 | const { embedUrl, title } = props;
5 |
6 | return (
7 |
8 |
18 |
19 | );
20 | }
21 |
--------------------------------------------------------------------------------
/components/Footer/index.js:
--------------------------------------------------------------------------------
1 | import SocialLinks from "@components/SocialLinks";
2 | import FooterStyles from "@styles/Footer.module.css";
3 | import ButtonStyles from "@styles/Button.module.css";
4 | import { Config } from "@utils/Config";
5 |
6 | export default function Footer() {
7 | const date = new Date();
8 |
9 | return (
10 |
16 | );
17 | }
18 |
--------------------------------------------------------------------------------
/.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 |
21 | # debug
22 | npm-debug.log*
23 | yarn-debug.log*
24 | yarn-error.log*
25 |
26 | # local env files
27 | .env.local
28 | .env.development.local
29 | .env.test.local
30 | .env.production.local
31 |
32 | # Generated XML feed at build time
33 | public/feed.xml
34 | public/sitemap.xml
35 | public/robots.txt
36 |
--------------------------------------------------------------------------------
/styles/Footer.module.css:
--------------------------------------------------------------------------------
1 | .footer {
2 | background-color: var(--color-foreground);
3 | color: var(--color-background);
4 | padding: 2rem 2rem 4rem;
5 | margin-top: 2rem;
6 | border-top: 0.5rem solid var(--color-tertiary);
7 | display: flex;
8 | flex-direction: column;
9 | }
10 |
11 | .footer__copyright {
12 | font-size: 1.2rem;
13 | line-height: 1.6;
14 | font-weight: var(--font-weight-bold);
15 | font-family: var(--font-family-main);
16 | color: var(--footer-copyright-color);
17 | display: flex;
18 | justify-content: center;
19 | align-content: center;
20 | margin-bottom: 2rem;
21 | text-align: center;
22 | }
23 |
--------------------------------------------------------------------------------
/components/PostList/Pagination/svg/ChevronLeft.js:
--------------------------------------------------------------------------------
1 | export default function ChevronLeft() {
2 | return (
3 |
17 | );
18 | }
19 |
--------------------------------------------------------------------------------
/components/PostList/Pagination/svg/ChevronRight.js:
--------------------------------------------------------------------------------
1 | export default function ChevronRight() {
2 | return (
3 |
17 | );
18 | }
19 |
--------------------------------------------------------------------------------
/styles/Tags.module.css:
--------------------------------------------------------------------------------
1 | .tags {
2 | list-style: none;
3 | padding: 0;
4 | display: flex;
5 | flex-direction: row;
6 | justify-content: flex-start;
7 | align-content: center;
8 | margin-top: 1rem;
9 | margin-bottom: 1rem;
10 | flex-wrap: wrap;
11 | }
12 |
13 | .tags__tag {
14 | background-color: var(--color-foreground);
15 | color: var(--color-background);
16 | text-transform: uppercase;
17 | font-size: 1rem;
18 | border: 0.25rem solid var(--color-secondary);
19 | border-radius: 1rem;
20 | padding: 0.25rem 0.5rem;
21 | font-weight: var(--font-weight-normal);
22 | font-family: var(--font-family-main);
23 | font-size: 0.875rem;
24 | text-decoration: none;
25 | margin-right: 0.5rem;
26 | margin-bottom: 0.75rem;
27 | }
28 |
--------------------------------------------------------------------------------
/components/Post/ExternalUrl/InfoSvg.js:
--------------------------------------------------------------------------------
1 | export default function InfoSvg() {
2 | return (
3 |
17 | );
18 | }
19 |
--------------------------------------------------------------------------------
/styles/Button.module.css:
--------------------------------------------------------------------------------
1 | .button {
2 | border-radius: 0;
3 | border: 0.125rem solid var(--color-foreground);
4 | text-transform: uppercase;
5 | font-family: var(--font-family-main);
6 | font-size: 1.5rem;
7 | display: inline-block;
8 | background-color: var(--color-foreground);
9 | padding: 0.5rem 1rem;
10 | color: var(--color-background);
11 | text-decoration: none;
12 | cursor: pointer;
13 | transition: all 0.2s ease-in-out;
14 | font-weight: var(--font-weight-normal);
15 | border: 0.125rem solid var(--color-background);
16 | margin-bottom: 2rem;
17 | }
18 |
19 | .button:hover {
20 | border-color: var(--color-primary);
21 | color: var(--color-primary);
22 | box-shadow: none;
23 | }
24 |
25 | .button:focus {
26 | outline-width: 0;
27 | box-shadow: var(--color-primary) 0 0 0 0.25rem;
28 | transition: box-shadow 0.2s ease 0s;
29 | }
30 |
--------------------------------------------------------------------------------
/components/SocialLinks/svgs/feed.js:
--------------------------------------------------------------------------------
1 | export default function Feed() {
2 | return (
3 |
14 | );
15 | }
16 |
--------------------------------------------------------------------------------
/components/Post/ExternalUrl/index.js:
--------------------------------------------------------------------------------
1 | import ExternalUrlStyles from "@styles/ExternalUrl.module.css";
2 | import Link from "next/link";
3 | import InfoSvg from "./InfoSvg";
4 |
5 | export default function ExternalUrl(props) {
6 | const { url } = props;
7 |
8 | function formatUrlForDisplay(url) {
9 | return new URL(url).hostname;
10 | }
11 |
12 | return (
13 |
30 | );
31 | }
32 |
--------------------------------------------------------------------------------
/components/Post/index.js:
--------------------------------------------------------------------------------
1 | import RichTextPageContentStyles from "@styles/RichTextPageContent.module.css";
2 | import TypographyStyles from "@styles/Typography.module.css";
3 | import Tags from "@components/Post/Tags";
4 | import PublishedDate from "@components/Post/PublishedDate";
5 | import Author from "@components/Post/Author";
6 | import ExternalUrl from "@components/Post/ExternalUrl";
7 | import RichTextPageContent from "@components/RichTextPageContent";
8 |
9 | export default function Post(props) {
10 | const { post } = props;
11 |
12 | return (
13 |
14 | {post.externalUrl && }
15 |
16 | {post.tags !== null && }
17 | {post.title}
18 |
19 | {post.author !== null && }
20 |
21 | );
22 | }
23 |
--------------------------------------------------------------------------------
/styles/SocialLinks.module.css:
--------------------------------------------------------------------------------
1 | .socialLinks {
2 | width: 100%;
3 | max-width: 20rem;
4 | margin-left: auto;
5 | margin-right: auto;
6 | }
7 |
8 | .socialLinks__list {
9 | display: flex;
10 | flex-direction: row;
11 | justify-content: space-evenly;
12 | margin: 2rem 1rem;
13 | list-style: none;
14 | padding: 0;
15 | }
16 |
17 | .socialLinks__listItem {
18 | max-width: 2.75rem;
19 | display: inline-flex;
20 | }
21 |
22 | .socialLinks__listItemLink {
23 | display: block;
24 | cursor: pointer;
25 | color: var(--color-foreground);
26 | }
27 |
28 | .socialLinks__listItemLink:focus {
29 | outline-width: 0;
30 | box-shadow: var(--color-primary) 0 0 0 0.25rem;
31 | transition: box-shadow var(--global-transition-time) ease 0s;
32 | }
33 |
34 | .socialLinks__listItemLink:focus:active {
35 | outline-width: 0;
36 | box-shadow: unset;
37 | }
38 |
39 | .socialLinks__listItemLink svg path {
40 | transition: fill var(--global-transition-time) ease-in-out;
41 | }
42 |
43 | .socialLinks__listItemLink svg:hover path {
44 | fill: var(--color-primary);
45 | }
46 |
--------------------------------------------------------------------------------
/utils/ReactMarkdownRenderers.js:
--------------------------------------------------------------------------------
1 | import TypographyStyles from "@styles/Typography.module.css";
2 |
3 | /*
4 | * The react-markdown package is used to render the blog post excerpt markdown field.
5 | * https://www.npmjs.com/package/react-markdown
6 | *
7 | * This function is used to render markdown fields consistently across
8 | * the application, applying appropriate typography styles and markup.
9 | *
10 | */
11 |
12 | export default function ReactMarkdownRenderers(markdown) {
13 | return {
14 | heading: ({ children }) => (
15 | {children}
16 | ),
17 | strong: ({ children }) => (
18 | {children}
19 | ),
20 | paragraph: ({ children }) => (
21 | {children}
22 | ),
23 | link: ({ children, href }) => (
24 |
30 | {children}
31 |
32 | ),
33 | };
34 | }
35 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | MIT License Copyright (c) 2021 Salma Alam-Naylor
2 |
3 | Permission is hereby granted,
4 | free of charge, to any person obtaining a copy of this software and associated
5 | documentation files (the "Software"), to deal in the Software without
6 | restriction, including without limitation the rights to use, copy, modify, merge,
7 | publish, distribute, sublicense, and/or sell copies of the Software, and to
8 | permit persons to whom the Software is furnished to do so, subject to the
9 | following conditions:
10 |
11 | The above copyright notice and this permission notice
12 | (including the next paragraph) shall be included in all copies or substantial
13 | portions of the Software.
14 |
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF
16 | ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
17 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO
18 | EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR
19 | OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
20 | FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
21 | THE SOFTWARE.
--------------------------------------------------------------------------------
/components/SocialLinks/svgs/twitter.js:
--------------------------------------------------------------------------------
1 | export default function Twitter() {
2 | return (
3 |
25 | );
26 | }
27 |
--------------------------------------------------------------------------------
/styles/ContentList.module.css:
--------------------------------------------------------------------------------
1 | .contentList {
2 | list-style: none;
3 | padding-inline-start: 0;
4 | margin-top: 2rem;
5 | margin-bottom: 2rem;
6 | }
7 |
8 | .contentList__post {
9 | margin-bottom: 4rem;
10 | }
11 |
12 | .contentList__titleLink {
13 | cursor: pointer;
14 | text-decoration: none;
15 | margin-bottom: 2rem;
16 | display: block;
17 | }
18 |
19 | .contentList__titleLink:hover {
20 | color: var(--color-primary);
21 | }
22 |
23 | .contentList__titleLink:focus {
24 | outline-width: 0;
25 | box-shadow: var(--color-primary) 0 0 0 0.25rem;
26 | transition: box-shadow var(--global-transition-time) ease 0s;
27 | }
28 |
29 | .contentList__titleLink:focus:active {
30 | outline-width: 0;
31 | box-shadow: unset;
32 | }
33 |
34 | .contentList__title {
35 | font-weight: var(--font-weight-normal);
36 | font-family: var(--font-family-main);
37 | color: var(--color-foreground);
38 | font-size: 1.6rem;
39 | line-height: 1.6;
40 | }
41 |
42 | .contentList__excerpt p {
43 | font-size: 1.2rem;
44 | line-height: 1.8rem;
45 | margin-bottom: 2rem;
46 | font-weight: var(--font-weight-light);
47 | font-family: var(--font-family-main);
48 | color: var(--color-foreground);
49 | }
50 |
--------------------------------------------------------------------------------
/styles/ExternalUrl.module.css:
--------------------------------------------------------------------------------
1 | .externalUrl {
2 | margin-bottom: 2rem;
3 | background-color: var(--external-url-background-color);
4 | padding: 1rem;
5 | border-radius: 0.5rem;
6 | display: flex;
7 | flex-direction: row;
8 | justify-content: flex-start;
9 | align-items: center;
10 | }
11 |
12 | .externalUrl__svgContainer {
13 | height: 2rem;
14 | margin-right: 1rem;
15 | color: var(--color-foreground);
16 | }
17 |
18 | .externalUrl__text {
19 | font-size: 1.2rem;
20 | font-family: var(--font-family-main);
21 | color: var(--color-foreground);
22 | }
23 |
24 | .externalUrl__link {
25 | color: var(--color-foreground);
26 | font-weight: var(--font-weight-bold);
27 | text-decoration: none;
28 | }
29 |
30 | .externalUrl__link:visited {
31 | color: var(--color-foreground);
32 | }
33 |
34 | .externalUrl__link:hover {
35 | color: var(--color-foreground);
36 | text-decoration: underline;
37 | text-underline-offset: 0.125rem;
38 | text-decoration-thickness: 0.125rem;
39 | }
40 |
41 | .externalUrl__link:focus {
42 | outline-width: 0;
43 | box-shadow: var(--color-foreground) 0 0 0 0.25rem;
44 | transition: box-shadow var(--global-transition-time) ease 0s;
45 | }
46 |
47 | .externalUrl__link:focus:active {
48 | outline-width: 0;
49 | box-shadow: unset;
50 | }
51 |
--------------------------------------------------------------------------------
/pages/server-sitemap.xml/index.js:
--------------------------------------------------------------------------------
1 | import { getServerSideSitemap } from "next-sitemap";
2 | import ContentfulApi from "@utils/ContentfulApi";
3 | import { Config } from "@utils/Config";
4 |
5 | export const getServerSideProps = async (ctx) => {
6 | // Source urls from Contentful
7 | const blogPostSlugs = await ContentfulApi.getAllPostSlugs();
8 |
9 | const blogPostFields = blogPostSlugs.map((slug) => {
10 | return {
11 | loc: `https://nextjs-contentful-blog-starter.vercel.app/blog/${slug}`,
12 | lastmod: new Date().toISOString(),
13 | };
14 | });
15 |
16 | const totalPosts = await ContentfulApi.getTotalPostsNumber();
17 | const totalPages = Math.ceil(totalPosts / Config.pagination.pageSize);
18 |
19 | const blogIndexPageFields = [];
20 |
21 | /**
22 | * Start from page 2, so we don't replicate /blog
23 | * which is page 1
24 | */
25 | for (let page = 2; page <= totalPages; page++) {
26 | blogIndexPageFields.push({
27 | loc: `https://nextjs-contentful-blog-starter.vercel.app/blog/page/${page}`,
28 | lastmod: new Date().toISOString(),
29 | });
30 | }
31 |
32 | const fields = blogPostFields.concat(blogIndexPageFields);
33 | return getServerSideSitemap(ctx, fields);
34 | };
35 |
36 | // Default export to prevent next.js errors
37 | export default () => {};
38 |
--------------------------------------------------------------------------------
/utils/Date.js:
--------------------------------------------------------------------------------
1 | /*
2 | * Get a three letter month string from an integer.
3 | */
4 |
5 | function getMonthStringFromInt(int) {
6 | const months = [
7 | "Jan",
8 | "Feb",
9 | "Mar",
10 | "Apr",
11 | "May",
12 | "Jun",
13 | "Jul",
14 | "Aug",
15 | "Sep",
16 | "Oct",
17 | "Nov",
18 | "Dec",
19 | ];
20 |
21 | return months[int];
22 | }
23 |
24 | /*
25 | * Add a leading zero to a single integer.
26 | */
27 | function addLeadingZero(num) {
28 | num = num.toString();
29 | while (num.length < 2) num = "0" + num;
30 | return num;
31 | }
32 |
33 | /*
34 | * Format a blog post published date for a datetime
35 | * HTML element.
36 | */
37 | export function formatPublishedDateForDateTime(dateString) {
38 | const timestamp = Date.parse(dateString);
39 | const date = new Date(timestamp);
40 | return `${date.getFullYear()}-${addLeadingZero(
41 | date.getMonth() + 1,
42 | )}-${date.getDate()}`;
43 | }
44 |
45 | /*
46 | * Format a blog post published date for a human to read.
47 | * Output is e.g. 16 Feb 2020
48 | */
49 | export function formatPublishedDateForDisplay(dateString) {
50 | const timestamp = Date.parse(dateString);
51 | const date = new Date(timestamp);
52 | return `${date.getDate()} ${getMonthStringFromInt(
53 | date.getMonth(),
54 | )} ${date.getFullYear()}`;
55 | }
56 |
--------------------------------------------------------------------------------
/styles/Author.module.css:
--------------------------------------------------------------------------------
1 | .author {
2 | display: flex;
3 | flex-direction: row;
4 | border-top: 0.5rem solid var(--color-primary);
5 | padding-top: 2rem;
6 | }
7 |
8 | .author__imgContainer {
9 | flex-basis: 30%;
10 | }
11 |
12 | .author__img {
13 | border-radius: 50%;
14 | }
15 |
16 | .author__detailsContainer {
17 | flex-basis: 65%;
18 | margin-left: 5%;
19 | }
20 |
21 | .author__links {
22 | display: flex;
23 | flex-direction: column;
24 | justify-content: space-between;
25 | margin-bottom: 2rem;
26 | margin-top: 2rem;
27 | }
28 |
29 | @media screen and (min-width: 520px) {
30 | .author__links {
31 | flex-direction: row;
32 | }
33 | }
34 |
35 | .author__linkText {
36 | text-transform: uppercase;
37 | color: var(--color-foreground);
38 | font-weight: var(--font-weight-bold);
39 | margin-bottom: 1rem;
40 | }
41 |
42 | .author__name {
43 | text-transform: uppercase;
44 | font-size: 1.2rem;
45 | line-height: 1.8;
46 | margin-bottom: 1rem;
47 | font-weight: var(--font-weight-bold);
48 | font-family: var(--font-family-main);
49 | color: var(--color-foreground);
50 | }
51 |
52 | .author__description {
53 | font-size: 1rem;
54 | line-height: 1.6;
55 | margin-bottom: 2rem;
56 | font-weight: var(--font-weight-light);
57 | font-family: var(--font-family-main);
58 | color: var(--color-foreground);
59 | }
60 |
--------------------------------------------------------------------------------
/components/SocialLinks/index.js:
--------------------------------------------------------------------------------
1 | import SocialLinksStyles from "@styles/SocialLinks.module.css";
2 | import Twitter from "./svgs/twitter";
3 | import Feed from "./svgs/feed";
4 | import { Config } from "@utils/Config";
5 |
6 | const socialLinksList = [
7 | {
8 | name: "Twitter",
9 | url: `https://twitter.com/${Config.pageMeta.openGraph.twitterUser}`,
10 | ariaLabel: "Follow me on Twitter",
11 | svg: ,
12 | },
13 | {
14 | name: "RSS Feed",
15 | url: "feed.xml",
16 | ariaLabel: `View the RSS feed of ${Config.site.domain}`,
17 | svg: ,
18 | },
19 | ];
20 |
21 | export default function SocialLinks(props) {
22 | const { fillColor } = props;
23 |
24 | return (
25 |
46 | );
47 | }
48 |
--------------------------------------------------------------------------------
/styles/RichTextPageContent.module.css:
--------------------------------------------------------------------------------
1 | .page {
2 | margin-left: auto;
3 | margin-right: auto;
4 | }
5 |
6 | .page__content {
7 | margin-bottom: 4rem;
8 | }
9 |
10 | .page__linkedHeaderContainer {
11 | display: flex;
12 | justify-content: flex-start;
13 | align-items: center;
14 | margin-top: 4rem;
15 | margin-bottom: 2rem;
16 | }
17 |
18 | .page__linkedHeaderContainer h2 {
19 | margin-bottom: 0;
20 | }
21 |
22 | .page__headerLink {
23 | color: var(--color-primary);
24 | margin-left: 0.5rem;
25 | }
26 |
27 | .page__hr {
28 | margin-bottom: 2rem;
29 | margin-top: 2rem;
30 | border-color: transparent;
31 | border-bottom: 0.25rem solid var(--color-primary);
32 | }
33 |
34 | .page__ul {
35 | display: block;
36 | list-style-type: disc;
37 | margin-block-start: 3rem;
38 | margin-block-end: 3rem;
39 | margin-inline-start: 0;
40 | margin-inline-end: 0;
41 | padding-inline-start: 4rem;
42 | }
43 |
44 | .page__ol {
45 | display: block;
46 | list-style-type: decimal;
47 | margin-block-start: 3rem;
48 | margin-block-end: 3rem;
49 | margin-inline-start: 0;
50 | margin-inline-end: 0;
51 | padding-inline-start: 4rem;
52 | }
53 |
54 | .page__li {
55 | font-size: 1.2rem;
56 | line-height: 1.6rem;
57 | margin-bottom: 1rem;
58 | font-weight: var(--font-weight-light);
59 | font-family: var(--font-family-main);
60 | color: var(--color-foreground);
61 | }
62 |
63 | .page__imgContainer {
64 | width: 100%;
65 | height: auto;
66 | margin: 1rem 0 2rem 0;
67 | }
68 |
--------------------------------------------------------------------------------
/components/RichTextPageContent/svg/LinkIcon.js:
--------------------------------------------------------------------------------
1 | export default function LinkIcon() {
2 | return (
3 |
14 | );
15 | }
16 |
--------------------------------------------------------------------------------
/utils/Config.js:
--------------------------------------------------------------------------------
1 | /*
2 | * This Config object is used throughout the application to
3 | * personalise your code and preferences for how you would
4 | * like things to work.
5 | *
6 | * For example, use the Config object to configure your menu links
7 | * without editing HTML, or change the page size on /blog without
8 | * touching any of the functional code.
9 | *
10 | */
11 |
12 | const SITE_URL = "https://nextjs-contentful-blog-starter.vercel.app";
13 |
14 | export const Config = {
15 | site: {
16 | owner: "A. Blogger",
17 | title: "My new Next.js + Contentful blog site",
18 | domain: "nextjs-contentful-blog-starter.vercel.app",
19 | email: "example@example.com",
20 | feedDescription: "RSS Feed for example.com",
21 | },
22 | pageMeta: {
23 | openGraph: {
24 | twitterUser: "contentful",
25 | },
26 | home: {
27 | url: SITE_URL,
28 | slug: "/",
29 | },
30 | blogIndex: {
31 | url: `${SITE_URL}/blog`,
32 | slug: "/blog",
33 | },
34 | blogIndexPage: {
35 | slug: "/blog/page/[page]",
36 | },
37 | post: {
38 | slug: "/blog/[slug]",
39 | },
40 | buildRss: {
41 | url: `${SITE_URL}/buildrss`,
42 | slug: "/buildrss",
43 | },
44 | notFound: {
45 | url: SITE_URL,
46 | slug: "/404",
47 | },
48 | },
49 | pagination: {
50 | pageSize: 2,
51 | recentPostsSize: 3,
52 | },
53 | menuLinks: [
54 | {
55 | displayName: "Home",
56 | path: "/",
57 | },
58 | {
59 | displayName: "Blog",
60 | path: "/blog",
61 | },
62 | ],
63 | };
64 |
--------------------------------------------------------------------------------
/styles/Pagination.module.css:
--------------------------------------------------------------------------------
1 | .pagination {
2 | margin: 2rem auto;
3 | }
4 |
5 | .pagination__list {
6 | list-style: none;
7 | display: flex;
8 | flex-direction: row;
9 | justify-content: space-between;
10 | align-items: center;
11 | padding: 0;
12 | }
13 |
14 | .pagination__listItem {
15 | font-family: var(--font-family-main);
16 | font-size: 1.2rem;
17 | text-decoration: none;
18 | }
19 |
20 | .pagination__listItem a {
21 | color: inherit;
22 | text-decoration: none;
23 | display: flex;
24 | flex-direction: row;
25 | align-items: center;
26 | }
27 |
28 | .pagination__listItem a:hover {
29 | color: var(--color-tertiary);
30 | transition: color var(--global-transition-time) ease-in-out;
31 | }
32 |
33 | .pagination__listItem a:focus {
34 | outline-width: 0;
35 | box-shadow: var(--color-primary) 0 0 0 0.25rem;
36 | transition: box-shadow var(--global-transition-time) ease 0s;
37 | }
38 |
39 | .pagination__listItem a:focus:active {
40 | outline-width: 0;
41 | box-shadow: unset;
42 | }
43 |
44 | .pagination__listItem__disabled {
45 | color: var(--color-muted);
46 | display: flex;
47 | flex-direction: row;
48 | align-items: center;
49 | }
50 |
51 | .pagination__chevronContainer__left {
52 | margin-right: 0.5rem;
53 | display: flex;
54 | }
55 |
56 | .pagination__chevronContainer__right {
57 | margin-left: 0.5rem;
58 | display: flex;
59 | }
60 |
61 | .pagination__listItem__pageDescriptor {
62 | text-decoration: underline;
63 | text-decoration-color: var(--color-secondary);
64 | text-underline-offset: 0.5rem;
65 | text-decoration-thickness: 0.125rem;
66 | }
67 |
--------------------------------------------------------------------------------
/pages/blog/[slug].js:
--------------------------------------------------------------------------------
1 | import ContentfulApi from "@utils/ContentfulApi";
2 | import Post from "@components/Post";
3 | import { Config } from "@utils/Config";
4 | import PageMeta from "@components/PageMeta";
5 | import MainLayout from "@layouts/main";
6 | import ContentWrapper from "@components/ContentWrapper";
7 |
8 | export default function PostWrapper(props) {
9 | const { post, preview } = props;
10 |
11 | return (
12 |
13 |
19 |
20 |
21 |
22 |
23 | );
24 | }
25 |
26 | export async function getStaticPaths() {
27 | const blogPostSlugs = await ContentfulApi.getAllPostSlugs();
28 |
29 | const paths = blogPostSlugs.map((slug) => {
30 | return { params: { slug } };
31 | });
32 |
33 | // Using fallback: "blocking" here enables preview mode for unpublished blog slugs
34 | // on production
35 | return {
36 | paths,
37 | fallback: "blocking",
38 | };
39 | }
40 |
41 | export async function getStaticProps({ params, preview = false }) {
42 | const post = await ContentfulApi.getPostBySlug(params.slug, {
43 | preview: preview,
44 | });
45 |
46 | // Add this with fallback: "blocking"
47 | // So that if we do not have a post on production,
48 | // the 404 is served
49 | if (!post) {
50 | return {
51 | notFound: true,
52 | };
53 | }
54 |
55 | return {
56 | props: {
57 | preview,
58 | post,
59 | },
60 | };
61 | }
62 |
--------------------------------------------------------------------------------
/layouts/main.styles.js:
--------------------------------------------------------------------------------
1 | import css from "styled-jsx/css";
2 | import "prismjs/themes/prism-okaidia.css";
3 |
4 | export default css.global`
5 | :root {
6 | --color-primary: #84b9f5;
7 | --color-secondary: #16875d;
8 | --color-tertiary: #84b9f5;
9 | --color-foreground: #283848;
10 | --color-background: #ffffff;
11 | --color-muted: #666666;
12 |
13 | --grid-unit: 0.5rem;
14 |
15 | --font-weight-light: 300;
16 | --font-weight-normal: 400;
17 | --font-weight-bold: 700;
18 |
19 | --font-family-heading: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto,
20 | Helvetica, Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji",
21 | "Segoe UI Symbol";
22 | --font-family-body: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto,
23 | Helvetica, Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji",
24 | "Segoe UI Symbol";
25 | --font-family-code: Consolas, Monaco, "Andale Mono", "Ubuntu Mono",
26 | monospace;
27 |
28 | --global-transition-time: 0.2s;
29 |
30 | --wrapper-max-width: 48rem;
31 |
32 | --header-nav-item-active-color: #b7ded0;
33 | --footer-copyright-color: #b7ded0;
34 | --external-url-background-color: #b7ded0;
35 | }
36 |
37 | html {
38 | font-size: 100%;
39 | background-color: var(--color-background);
40 | }
41 |
42 | body {
43 | font-size: 1rem;
44 | font-weight: var(--font-weight-light);
45 | font-family: var(--font-family-body);
46 | color: var(--color-foreground);
47 | margin: 0;
48 | }
49 |
50 | * {
51 | margin: 0;
52 | box-sizing: border-box;
53 | }
54 |
55 | /* accessibility fixes for prismjs */
56 | .token.comment {
57 | color: #adb8c2 !important;
58 | }
59 |
60 | .token.tag, .token.constant {
61 | color: #fc92b6 !important;
62 | }
63 | `;
64 |
--------------------------------------------------------------------------------
/components/RecentPostList/index.js:
--------------------------------------------------------------------------------
1 | import Link from "next/link";
2 | import ReactMarkdown from "react-markdown";
3 | import RecentPostListStyles from "@styles/RecentPostList.module.css";
4 | import ButtonStyles from "@styles/Button.module.css";
5 | import PublishedDate from "@components/Post/PublishedDate";
6 | import Tags from "@components/Post/Tags";
7 | import ContentListStyles from "@styles/ContentList.module.css";
8 | import { Config } from "@utils/Config";
9 | import ReactMarkdownRenderers from "@utils/ReactMarkdownRenderers";
10 |
11 | export default function RecentPostList(props) {
12 | const { posts } = props;
13 | return (
14 | <>
15 |
16 | Recent articles
17 |
18 |
19 | {posts.map((post) => (
20 | -
21 |
22 |
23 |
24 |
25 |
26 | {post.title}
27 |
28 |
29 |
30 | {post.tags !== null && }
31 |
32 |
36 |
37 |
38 |
39 | ))}
40 |
41 |
42 | See more articles
43 |
44 | >
45 | );
46 | }
47 |
--------------------------------------------------------------------------------
/components/PostList/index.js:
--------------------------------------------------------------------------------
1 | import ReactMarkdown from "react-markdown";
2 | import Link from "next/link";
3 | import PublishedDate from "@components/Post/PublishedDate";
4 | import Tags from "@components/Post/Tags";
5 | import Pagination from "@components/PostList/Pagination";
6 | import ContentListStyles from "@styles/ContentList.module.css";
7 | import ReactMarkdownRenderers from "@utils/ReactMarkdownRenderers";
8 | import { Config } from "@utils/Config";
9 |
10 | export default function PostList(props) {
11 | const { posts, currentPage, totalPages } = props;
12 | const nextDisabled = parseInt(currentPage, 10) === parseInt(totalPages, 10);
13 | const prevDisabled = parseInt(currentPage, 10) === 1;
14 |
15 | return (
16 | <>
17 |
18 | {posts.map((post) => (
19 | -
20 |
21 |
22 |
23 |
24 |
25 | {post.title}
26 |
27 |
28 |
29 | {post.tags !== null && }
30 |
31 |
35 |
36 |
37 |
38 | ))}
39 |
40 |
41 |
47 | >
48 | );
49 | }
50 |
--------------------------------------------------------------------------------
/styles/HeroBanner.module.css:
--------------------------------------------------------------------------------
1 | .heroBanner {
2 | width: 100%;
3 | overflow: hidden;
4 | position: relative;
5 | background-position: center;
6 | background-size: cover;
7 | min-height: 28rem;
8 | display: flex;
9 | flex-direction: column;
10 | margin-bottom: 2rem;
11 | border-top: 0.5rem solid var(--color-primary);
12 | border-bottom: 0.5rem solid var(--color-primary);
13 | background-color: var(--color-foreground);
14 | }
15 |
16 | .heroBanner__overlay {
17 | background-color: var(--color-foreground);
18 | opacity: 0.5;
19 | height: 100%;
20 | width: 100%;
21 | position: absolute;
22 | z-index: 0;
23 | }
24 |
25 | .heroBanner__bgImg {
26 | object-position: center;
27 | object-fit: cover;
28 | pointer-events: none;
29 | }
30 |
31 | .heroBanner__inner {
32 | width: 100%;
33 | max-width: var(--wrapper-max-width);
34 | margin-left: auto;
35 | margin-right: auto;
36 | padding: 2rem 1rem;
37 | display: flex;
38 | flex-direction: column;
39 | justify-items: center;
40 | margin: auto;
41 | z-index: 1;
42 | }
43 |
44 | .heroBanner__textContainer {
45 | display: flex;
46 | flex-direction: column;
47 | padding: 1rem;
48 | }
49 |
50 | .heroBanner__headline {
51 | font-size: clamp(2rem, 3vw, 3rem);
52 | line-height: 1.6;
53 | margin-bottom: 3rem;
54 | font-weight: var(--font-weight-normal);
55 | font-family: var(--font-family-main);
56 | color: var(--color-background);
57 | background-color: var(--color-foreground);
58 | padding: 0.5rem 1rem;
59 | }
60 |
61 | .heroBanner__subheading {
62 | font-size: clamp(1.2rem, 1.8vw, 1.8rem);
63 | line-height: 1.6;
64 | margin-bottom: 2rem;
65 | font-weight: var(--font-weight-normal);
66 | font-family: var(--font-family-main);
67 | color: var(--color-background);
68 | background-color: var(--color-foreground);
69 | padding: 0.5rem 1rem;
70 | }
71 |
72 | .heroBanner__ctaContainer {
73 | display: flex;
74 | align-self: flex-start;
75 | padding: 1rem 1rem 0 1rem;
76 | }
77 |
--------------------------------------------------------------------------------
/components/HeroBanner/index.js:
--------------------------------------------------------------------------------
1 | import Image from "next/image";
2 | import Link from "next/link";
3 | import HeroBannerStyles from "@styles/HeroBanner.module.css";
4 | import ButtonStyles from "@styles/Button.module.css";
5 |
6 | export default function HeroBanner(props) {
7 | const {
8 | headline,
9 | subHeading,
10 | ctaText,
11 | internalLink,
12 | externalLink,
13 | image,
14 | } = props.data;
15 |
16 | return (
17 |
18 |
25 |
26 |
27 |
28 | {headline && (
29 |
30 | {headline}
31 |
32 | )}
33 | {subHeading && (
34 |
35 | {subHeading}
36 |
37 | )}
38 |
39 | {internalLink && ctaText && (
40 |
45 | )}
46 | {externalLink && ctaText && (
47 |
57 | )}
58 |
59 |
60 | );
61 | }
62 |
--------------------------------------------------------------------------------
/pages/index.js:
--------------------------------------------------------------------------------
1 | import { Config } from "@utils/Config";
2 | import PageMeta from "@components/PageMeta";
3 | import ContentfulApi from "@utils/ContentfulApi";
4 | import RichTextPageContent from "@components/RichTextPageContent";
5 | import MainLayout from "@layouts/main";
6 | import RecentPostList from "@components/RecentPostList";
7 | import HeroBanner from "@components/HeroBanner";
8 | import ContentWrapper from "@components/ContentWrapper";
9 | import PageContentWrapper from "@components/PageContentWrapper";
10 |
11 | export default function Home(props) {
12 | const { pageContent, recentPosts, preview } = props;
13 |
14 | const pageTitle = pageContent ? pageContent.title : "Home";
15 |
16 | const pageDescription = pageContent
17 | ? pageContent.description
18 | : "Welcome to the Next.js Contentful blog starter";
19 |
20 | return (
21 | <>
22 |
23 |
28 |
29 | {pageContent && pageContent.heroBanner !== null && (
30 |
31 | )}
32 |
33 |
34 | {pageContent && pageContent.body && (
35 |
36 |
37 |
38 | )}
39 |
40 |
41 |
42 | >
43 | );
44 | }
45 |
46 | export async function getStaticProps({ preview = false }) {
47 | const pageContent = await ContentfulApi.getPageContentBySlug(
48 | Config.pageMeta.home.slug,
49 | {
50 | preview: preview,
51 | },
52 | );
53 |
54 | const recentPosts = await ContentfulApi.getRecentPostList();
55 |
56 | return {
57 | props: {
58 | preview,
59 | pageContent: pageContent || null,
60 | recentPosts,
61 | },
62 | };
63 | }
64 |
--------------------------------------------------------------------------------
/styles/Header.module.css:
--------------------------------------------------------------------------------
1 | .header {
2 | display: flex;
3 | flex-direction: column;
4 | }
5 |
6 | .header__logoContainer {
7 | display: flex;
8 | justify-content: center;
9 | background-color: var(--color-foreground);
10 | padding: 1rem;
11 | }
12 |
13 | .header__logoContainerLink {
14 | text-decoration: none;
15 | display: inline-flex;
16 | width: 100%;
17 | justify-content: center;
18 | }
19 |
20 | .header__logoContainerLink:focus {
21 | outline-width: 0;
22 | box-shadow: var(--color-primary) 0 0 0 0.25rem;
23 | transition: box-shadow var(--global-transition-time) ease 0s;
24 | }
25 |
26 | .header__logoContainerLink:focus:active {
27 | outline-width: 0;
28 | box-shadow: unset;
29 | }
30 |
31 | .header__logo {
32 | height: 4rem;
33 | margin: 0 1rem;
34 | }
35 |
36 | .header__nav {
37 | display: flex;
38 | justify-content: center;
39 | background-color: var(--color-foreground);
40 | padding: 0.5rem 1rem;
41 | border-bottom: 0.5rem solid var(--color-tertiary);
42 | }
43 |
44 | .header__navList {
45 | list-style: none;
46 | padding: 0;
47 | }
48 |
49 | .header__navListItem {
50 | padding: 0.8rem 1rem;
51 | text-transform: uppercase;
52 | color: var(--color-background);
53 | font-size: 1.4rem;
54 | line-height: 1;
55 | transition: color var(--global-transition-time) ease-in-out;
56 | display: inline-block;
57 | text-decoration: none;
58 | font-weight: var(--font-weight-bold);
59 | }
60 |
61 | .header__navListItem.header__navListItem__active {
62 | color: var(--header-nav-item-active-color);
63 | }
64 |
65 | .header__navListItemLink {
66 | color: inherit;
67 | text-decoration: none;
68 | }
69 |
70 | .header__navListItemLink:visited {
71 | color: inherit;
72 | }
73 |
74 | .header__navListItemLink:hover {
75 | color: inherit;
76 | }
77 |
78 | .header__navListItemLink:focus {
79 | outline-width: 0;
80 | box-shadow: var(--color-primary) 0 0 0 0.25rem;
81 | transition: box-shadow var(--global-transition-time) ease 0s;
82 | }
83 |
84 | .header__navListItemLink:focus:active {
85 | outline-width: 0;
86 | box-shadow: unset;
87 | }
88 |
--------------------------------------------------------------------------------
/components/Header/index.js:
--------------------------------------------------------------------------------
1 | import HeaderStyles from "@styles/Header.module.css";
2 | import Link from "next/link";
3 | import SocialLinks from "@components/SocialLinks";
4 | import { useRouter } from "next/router";
5 | import { Config } from "@utils/Config";
6 | import Logo from "./svg/Logo";
7 |
8 | export default function Header() {
9 | const router = useRouter();
10 |
11 | return (
12 |
13 |
23 |
55 |
56 |
57 | );
58 | }
59 |
--------------------------------------------------------------------------------
/utils/OpenGraph.js:
--------------------------------------------------------------------------------
1 | /*
2 | * Open Graph meta tags are used in the of an HTML page to expose
3 | * information about your web page to social media platforms and other applications
4 | * that unfurl URL meta data.
5 | *
6 | * This is an Open Graph meta tag that provides a url to an image that is used to represent the web page.
7 | *
8 | *
9 | * You can find all Open Graph meta tags in @components/PageMeta.
10 | *
11 | * The example code uses a serverless service that generates dynamic Open Graph
12 | * images that you can embed in your tags.
13 | *
14 | * View the code here: https://github.com/vercel/og-image
15 | *
16 | * Explore the application in the UI here: https://og-image.vercel.app/
17 | */
18 |
19 | export default class OpenGraph {
20 | static generateImageUrl(title) {
21 | return `https://og-image.vercel.app/${encodeURI(
22 | title,
23 | )}.png?theme=light&md=0fontSize=100px&images=https%3A%2F%2Fassets.vercel.com%2Fimage%2Fupload%2Ffront%2Fassets%2Fdesign%2Fnextjs-black-logo.svg&images=data%3Aimage%2Fsvg%2Bxml%3Bbase64%2CPHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIyOSIgaGVpZ2h0PSIzMiI%2BCiAgPHBhdGggZmlsbD0iI0ZGRDg1RiIgZD0iTTkuNyAyMi4zQzggMjAuNyA3IDE4LjUgNyAxNnMxLTQuNyAyLjYtNi4zYzEuNC0xLjQgMS40LTMuNiAwLTVzLTMuNi0xLjQtNSAwQzEuOCA3LjYgMCAxMS42IDAgMTZzMS44IDguNCA0LjcgMTEuM2MxLjQgMS40IDMuNiAxLjQgNSAwIDEuMy0xLjQgMS4zLTMuNiAwLTV6Ij48L3BhdGg%2BCiAgPHBhdGggZmlsbD0iIzNCQjRFNyIgZD0iTTkuNyA5LjdDMTEuMyA4IDEzLjUgNyAxNiA3czQuNyAxIDYuMyAyLjZjMS40IDEuNCAzLjYgMS40IDUgMHMxLjQtMy42IDAtNUMyNC40IDEuOCAyMC40IDAgMTYgMFM3LjYgMS44IDQuNyA0LjdjLTEuNCAxLjQtMS40IDMuNiAwIDUgMS40IDEuMyAzLjYgMS4zIDUgMHoiPjwvcGF0aD4KICA8cGF0aCBmaWxsPSIjRUQ1QzY4IiBkPSJNMjIuMyAyMi4zQzIwLjcgMjQgMTguNSAyNSAxNiAyNXMtNC43LTEtNi4zLTIuNmMtMS40LTEuNC0zLjYtMS40LTUgMHMtMS40IDMuNiAwIDVDNy42IDMwLjIgMTEuNiAzMiAxNiAzMnM4LjQtMS44IDExLjMtNC43YzEuNC0xLjQgMS40LTMuNiAwLTUtMS40LTEuMy0zLjYtMS4zLTUgMHoiPjwvcGF0aD4KICA8Y2lyY2xlIGN4PSI3LjIiIGN5PSI3LjIiIHI9IjMuNSIgZmlsbD0iIzMwOEJDNSI%2BPC9jaXJjbGU%2BCiAgPGNpcmNsZSBjeD0iNy4yIiBjeT0iMjQuOCIgcj0iMy41IiBmaWxsPSIjRDU0NjVGIj48L2NpcmNsZT4KPC9zdmc%2B`;
24 | }
25 | }
26 |
--------------------------------------------------------------------------------
/pages/blog/index.js:
--------------------------------------------------------------------------------
1 | import ContentfulApi from "@utils/ContentfulApi";
2 | import { Config } from "@utils/Config";
3 | import PageMeta from "@components/PageMeta";
4 | import PostList from "@components/PostList";
5 | import RichTextPageContent from "@components/RichTextPageContent";
6 | import MainLayout from "@layouts/main";
7 | import ContentWrapper from "@components/ContentWrapper";
8 | import PageContentWrapper from "@components/PageContentWrapper";
9 | import HeroBanner from "@components/HeroBanner";
10 |
11 | export default function BlogIndex(props) {
12 | const {
13 | postSummaries,
14 | currentPage,
15 | totalPages,
16 | pageContent,
17 | preview,
18 | } = props;
19 |
20 | /**
21 | * This provides some fallback values to PageMeta so that a pageContent
22 | * entry is not required for /blog
23 | */
24 | const pageTitle = pageContent ? pageContent.title : "Blog";
25 | const pageDescription = pageContent
26 | ? pageContent.description
27 | : "Articles | Next.js Contentful blog starter";
28 |
29 | return (
30 |
31 |
36 |
37 | {pageContent.heroBanner !== null && (
38 |
39 | )}
40 |
41 |
42 | {pageContent.body && (
43 |
44 |
45 |
46 | )}
47 |
52 |
53 |
54 | );
55 | }
56 |
57 | export async function getStaticProps({ preview = false }) {
58 | const postSummaries = await ContentfulApi.getPaginatedPostSummaries(1);
59 | const pageContent = await ContentfulApi.getPageContentBySlug(
60 | Config.pageMeta.blogIndex.slug,
61 | {
62 | preview: preview,
63 | },
64 | );
65 |
66 | const totalPages = Math.ceil(
67 | postSummaries.total / Config.pagination.pageSize,
68 | );
69 |
70 | return {
71 | props: {
72 | preview,
73 | postSummaries: postSummaries.items,
74 | totalPages,
75 | currentPage: "1",
76 | pageContent: pageContent || null,
77 | },
78 | };
79 | }
80 |
--------------------------------------------------------------------------------
/components/PageMeta/index.js:
--------------------------------------------------------------------------------
1 | import Head from "next/head";
2 | import OpenGraph from "@utils/OpenGraph";
3 | import { Config } from "@utils/Config";
4 |
5 | export default function PageMeta(props) {
6 | const { title, description, url, canonical } = props;
7 | const siteTitle = `${title} | ${Config.site.title}`;
8 |
9 | return (
10 |
11 | {siteTitle}
12 |
13 | {canonical && }
14 |
15 |
21 |
22 |
23 |
24 |
25 |
26 |
27 |
28 |
29 |
30 |
31 |
32 |
33 |
34 |
38 |
39 |
40 |
44 |
48 |
49 |
50 |
51 |
56 |
62 |
68 |
69 |
70 |
71 |
72 |
73 | );
74 | }
75 |
--------------------------------------------------------------------------------
/pages/buildrss.js:
--------------------------------------------------------------------------------
1 | import ReactDOMServer from "react-dom/server";
2 | import ContentfulApi from "@utils/ContentfulApi";
3 | import fs from "fs";
4 | import { documentToReactComponents } from "@contentful/rich-text-react-renderer";
5 | import { getRichTextRenderOptions } from "@components/RichTextPageContent";
6 | import { Config } from "@utils/Config";
7 |
8 | export default function buildRss(props) {
9 | return null;
10 | }
11 |
12 | function buildTags(tags) {
13 | if (!tags) {
14 | return;
15 | }
16 | return tags
17 | .map((tag) => {
18 | return `${tag}`;
19 | })
20 | .join("");
21 | }
22 |
23 | function buildContent(postBody) {
24 | return `
25 | `;
33 | }
34 |
35 | function buildRssItems(posts) {
36 | return posts
37 | .map((post) => {
38 | return `
39 | -
40 | ${post.title}
41 | ${post.excerpt}
42 | ${Config.site.email} (${Config.site.owner})
43 | https://${Config.site.domain}/blog/${post.slug}
44 | https://${Config.site.domain}/blog/${post.slug}
45 | ${post.date}
46 | ${buildTags(post.tags)}
47 | ${buildContent(post.body)}
48 |
49 | `;
50 | })
51 | .join("");
52 | }
53 |
54 | export async function getStaticProps() {
55 | const posts = await ContentfulApi.getAllBlogPosts();
56 |
57 | const feedString = `
58 |
61 |
62 | ${Config.site.title}
63 |
66 | https://${Config.site.domain}
67 | ${Config.site.feedDescription}
68 | ${buildRssItems(posts)}
69 |
70 | `;
71 |
72 | fs.writeFile("./public/feed.xml", feedString, function (err) {
73 | if (err) {
74 | console.log("Could not write to feed.xml");
75 | }
76 | console.log("feed.xml written to ./public!");
77 | });
78 |
79 | return {
80 | props: {
81 | feedString,
82 | },
83 | };
84 | }
85 |
--------------------------------------------------------------------------------
/pages/api/preview.js:
--------------------------------------------------------------------------------
1 | /*
2 | * https://nextjs.org/docs/advanced-features/preview-mode
3 | */
4 |
5 | import ContentfulApi from "@utils/ContentfulApi";
6 |
7 | export default async function preview(req, res) {
8 | /*
9 | * Check for the secret and query parameters.
10 | * This secret should only be known to this API route and the CMS.
11 | *
12 | * Set your content preview URLS in Contentful > Settings > Content Preview
13 | *
14 | * The preview URL for the blogPost content type is
15 | * http://localhost:3000/api/preview?secret={SECRET}&slug={entry.fields.slug}&contentType=blogPost
16 | *
17 | * The preview URL for the pageContent content type is
18 | * http://localhost:3000/api/preview?secret={SECRET}&slug={entry.fields.slug}&contentType=pageContent
19 | *
20 | */
21 | if (
22 | req.query.secret !== process.env.CONTENTFUL_PREVIEW_SECRET ||
23 | !req.query.slug ||
24 | !req.query.contentType
25 | ) {
26 | return res.status(401).json({ message: "Invalid options" });
27 | }
28 |
29 | // Fetch the page or blog content by slug using the Contentful Preview API.
30 | let preview = null;
31 | let redirectPrefix = "";
32 |
33 | switch (req.query.contentType) {
34 | case "blogPost":
35 | redirectPrefix = "/blog/";
36 | preview = await ContentfulApi.getPostBySlug(req.query.slug, {
37 | preview: true,
38 | });
39 | break;
40 | case "pageContent":
41 | preview = await ContentfulApi.getPageContentBySlug(req.query.slug, {
42 | preview: true,
43 | });
44 | break;
45 | default:
46 | preview = null;
47 | }
48 |
49 | // Prevent Next.js preview mode from being enabled if the content doesn't exist.
50 | if (!preview) {
51 | return res.status(401).json({ message: "Invalid slug" });
52 | }
53 |
54 | /**
55 | * res.setPreviewData({}) sets some cookies on the browser
56 | * which turns on the preview mode. Any requests to Next.js
57 | * containing these cookies will be considered as the preview
58 | * mode, and the behavior for statically generated pages
59 | * will change.
60 | *
61 | * To end Next.js preview mode, navigate to /api/endpreview.
62 | */
63 |
64 | res.setPreviewData({});
65 |
66 | /*
67 | * Redirect to the path from the fetched post.
68 | * We don't redirect to req.query.slug as that might lead to open redirect vulnerabilities.
69 | */
70 |
71 | const url = `${redirectPrefix}${preview.slug}`;
72 |
73 | res.write(
74 | `
75 |
76 | `,
77 | );
78 | res.end();
79 | }
80 |
--------------------------------------------------------------------------------
/components/PostList/Pagination/index.js:
--------------------------------------------------------------------------------
1 | import PaginationStyles from "@styles/Pagination.module.css";
2 | import Link from "next/link";
3 | import ChevronLeft from "./svg/ChevronLeft";
4 | import ChevronRight from "./svg/ChevronRight";
5 |
6 | export default function Pagination(props) {
7 | const { totalPages, currentPage, prevDisabled, nextDisabled } = props;
8 |
9 | const prevPageUrl =
10 | currentPage === "2"
11 | ? "/blog"
12 | : `/blog/page/${parseInt(currentPage, 10) - 1}`;
13 | const nextPageUrl = `/blog/page/${parseInt(currentPage, 10) + 1}`;
14 |
15 | return (
16 |
17 |
18 | -
19 | {prevDisabled && (
20 |
21 |
24 |
25 |
26 | Previous page
27 |
28 | )}
29 | {!prevDisabled && (
30 |
31 |
32 |
37 |
38 |
39 | Previous page
40 |
41 |
42 | )}
43 |
44 | -
47 | Page {currentPage} of {totalPages}
48 |
49 | -
50 | {nextDisabled && (
51 |
52 | Next page
53 |
56 |
57 |
58 |
59 | )}
60 | {!nextDisabled && (
61 |
62 |
63 | Next page
64 |
69 |
70 |
71 |
72 |
73 | )}
74 |
75 |
76 |
77 | );
78 | }
79 |
--------------------------------------------------------------------------------
/components/Post/Author/index.js:
--------------------------------------------------------------------------------
1 | import Image from "next/image";
2 | import AuthorStyles from "@styles/Author.module.css";
3 | import TypographyStyles from "@styles/Typography.module.css";
4 |
5 | function renderTwitter(username) {
6 | return (
7 |
13 | Twitter
14 |
15 | );
16 | }
17 |
18 | function renderTwitch(username) {
19 | return (
20 |
26 | Twitch
27 |
28 | );
29 | }
30 |
31 | function renderGitHub(username) {
32 | return (
33 |
39 | GitHub
40 |
41 | );
42 | }
43 |
44 | function renderWebsite(url) {
45 | return (
46 |
52 | Website
53 |
54 | );
55 | }
56 |
57 | export default function Author(props) {
58 | const { author } = props;
59 | const hasLinks =
60 | author.twitterUsername ||
61 | author.twitchUsername ||
62 | author.gitHubUsername ||
63 | author.websiteUrl;
64 | return (
65 | <>
66 |
67 |
68 |
75 |
76 |
77 |
{author.name}
78 |
79 | {author.description}
80 |
81 | {hasLinks && (
82 |
83 | {author.twitterUsername && renderTwitter(author.twitterUsername)}
84 | {author.twitchUsername && renderTwitch(author.twitchUsername)}
85 | {author.gitHubUsername && renderGitHub(author.gitHubUsername)}
86 | {author.websiteUrl && renderWebsite(author.websiteUrl)}
87 |
88 | )}
89 |
90 |
91 | >
92 | );
93 | }
94 |
--------------------------------------------------------------------------------
/pages/blog/page/[page].js:
--------------------------------------------------------------------------------
1 | import ContentfulApi from "@utils/ContentfulApi";
2 | import { Config } from "@utils/Config";
3 | import PageMeta from "@components/PageMeta";
4 | import PostList from "@components/PostList";
5 | import RichTextPageContent from "@components/RichTextPageContent";
6 | import MainLayout from "@layouts/main";
7 | import ContentWrapper from "@components/ContentWrapper";
8 | import PageContentWrapper from "@components/PageContentWrapper";
9 | import HeroBanner from "@components/HeroBanner";
10 |
11 | export default function BlogIndexPage(props) {
12 | const {
13 | postSummaries,
14 | totalPages,
15 | currentPage,
16 | pageContent,
17 | preview,
18 | } = props;
19 |
20 | /**
21 | * This provides some fallback values to PageMeta so that a pageContent
22 | * entry is not required for /blog
23 | */
24 | const pageTitle = pageContent ? pageContent.title : "Blog";
25 | const pageDescription = pageContent
26 | ? pageContent.description
27 | : "Articles | Next.js Contentful blog starter";
28 |
29 | return (
30 |
31 |
36 |
37 | {pageContent.heroBanner !== null && (
38 |
39 | )}
40 |
41 |
42 | {pageContent.body && (
43 |
44 |
45 |
46 | )}
47 |
52 |
53 |
54 | );
55 | }
56 |
57 | export async function getStaticPaths() {
58 | const totalPosts = await ContentfulApi.getTotalPostsNumber();
59 | const totalPages = Math.ceil(totalPosts / Config.pagination.pageSize);
60 |
61 | const paths = [];
62 |
63 | /**
64 | * Start from page 2, so we don't replicate /blog
65 | * which is page 1
66 | */
67 | for (let page = 2; page <= totalPages; page++) {
68 | paths.push({ params: { page: page.toString() } });
69 | }
70 |
71 | return {
72 | paths,
73 | fallback: false,
74 | };
75 | }
76 |
77 | export async function getStaticProps({ params, preview = false }) {
78 | const postSummaries = await ContentfulApi.getPaginatedPostSummaries(
79 | params.page,
80 | );
81 | const totalPages = Math.ceil(
82 | postSummaries.total / Config.pagination.pageSize,
83 | );
84 | const pageContent = await ContentfulApi.getPageContentBySlug(
85 | Config.pageMeta.blogIndex.slug,
86 | {
87 | preview: preview,
88 | },
89 | );
90 |
91 | return {
92 | props: {
93 | preview,
94 | postSummaries: postSummaries.items,
95 | totalPages,
96 | currentPage: params.page,
97 | pageContent: pageContent || null,
98 | },
99 | };
100 | }
101 |
--------------------------------------------------------------------------------
/styles/Typography.module.css:
--------------------------------------------------------------------------------
1 | .heading__h1 {
2 | font-size: clamp(2.2rem, 3vw, 3rem);
3 | line-height: 1.5;
4 | margin-bottom: 3rem;
5 | font-weight: var(--font-weight-normal);
6 | font-family: var(--font-family-main);
7 | color: var(--color-foreground);
8 | }
9 |
10 | .heading__h2 {
11 | font-size: clamp(2rem, 2.8vw, 1.8rem);
12 | line-height: 1.4;
13 | margin-bottom: 2rem;
14 | font-weight: var(--font-weight-normal);
15 | font-family: var(--font-family-main);
16 | color: var(--color-foreground);
17 | word-break: break-word;
18 | hyphens: auto;
19 | }
20 |
21 | .heading__h3 {
22 | font-size: 1.4rem;
23 | line-height: 1.4;
24 | margin-bottom: 2rem;
25 | font-weight: var(--font-weight-normal);
26 | font-family: var(--font-family-main);
27 | color: var(--color-foreground);
28 | }
29 |
30 | .heading__h4 {
31 | font-size: 1.2rem;
32 | line-height: 1.4;
33 | margin-bottom: 2rem;
34 | font-weight: var(--font-weight-normal);
35 | font-family: var(--font-family-main);
36 | color: var(--color-foreground);
37 | }
38 |
39 | .heading__h5 {
40 | font-size: 1.2rem;
41 | line-height: 1.4;
42 | margin-bottom: 2rem;
43 | font-weight: var(--font-weight-normal);
44 | font-family: var(--font-family-main);
45 | color: var(--color-foreground);
46 | }
47 |
48 | .heading__h6 {
49 | font-size: 1.2rem;
50 | line-height: 1.4;
51 | margin-bottom: 2rem;
52 | font-weight: var(--font-weight-normal);
53 | font-family: var(--font-family-main);
54 | font-weight: var(--font-weight-bold);
55 | color: var(--color-foreground);
56 | }
57 |
58 | .bodyCopy {
59 | font-size: 1.2rem;
60 | line-height: 1.8;
61 | margin-bottom: 2rem;
62 | font-weight: var(--font-weight-light);
63 | font-family: var(--font-family-main);
64 | color: var(--color-foreground);
65 | word-break: break-word;
66 | }
67 |
68 | .bodyCopy__bold {
69 | font-weight: var(--font-weight-bold);
70 | }
71 |
72 | .blockquote {
73 | display: block;
74 | margin-block-start: 4rem;
75 | margin-block-end: 4rem;
76 | margin-inline-start: 2rem;
77 | margin-inline-end: 2rem;
78 | }
79 |
80 | .blockquote p {
81 | /* todo - clamp! */
82 | font-style: italic;
83 | font-size: 1.2rem;
84 | line-height: 1.6;
85 | }
86 |
87 | @media screen and (min-width: 600px) {
88 | .blockquote {
89 | margin-inline-start: 4rem;
90 | margin-inline-end: 4rem;
91 | }
92 | }
93 |
94 | .blockquote:before {
95 | content: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 512 512' height='44px' width='44px'%3E%3Cpath fill='%2382af3a' d='M464 256h-80v-64c0-35.3 28.7-64 64-64h8c13.3 0 24-10.7 24-24V56c0-13.3-10.7-24-24-24h-8c-88.4 0-160 71.6-160 160v240c0 26.5 21.5 48 48 48h128c26.5 0 48-21.5 48-48V304c0-26.5-21.5-48-48-48zm-288 0H96v-64c0-35.3 28.7-64 64-64h8c13.3 0 24-10.7 24-24V56c0-13.3-10.7-24-24-24h-8C71.6 32 0 103.6 0 192v240c0 26.5 21.5 48 48 48h128c26.5 0 48-21.5 48-48V304c0-26.5-21.5-48-48-48z' /%3E%3C/svg%3E%0A");
96 | }
97 |
98 | .inlineLink {
99 | color: inherit;
100 | transition: color var(--global-transition-time) ease-in-out;
101 | text-decoration: underline;
102 | text-underline-offset: 0.125rem;
103 | text-decoration-thickness: 0.125rem;
104 | }
105 |
106 | .inlineLink:visited {
107 | color: var(--color-foreground);
108 | }
109 |
110 | .inlineLink:hover {
111 | color: var(--color-foreground);
112 | }
113 |
114 | .inlineLink:focus {
115 | outline-width: 0;
116 | box-shadow: var(--color-primary) 0 0 0 0.25rem;
117 | transition: box-shadow var(--global-transition-time) ease 0s;
118 | }
119 |
120 | .inlineLink:focus:active {
121 | outline-width: 0;
122 | box-shadow: unset;
123 | }
124 |
125 | .inlineCodeContainer {
126 | display: inline;
127 | }
128 |
129 | .inlineCode {
130 | color: var(--color-background);
131 | background-color: var(--color-foreground);
132 | text-shadow: 0 1px rgb(0 0 0 / 30%);
133 | font-family: var(--font-family-code);
134 | font-size: 1rem;
135 | }
136 |
--------------------------------------------------------------------------------
/components/Header/svg/Logo.js:
--------------------------------------------------------------------------------
1 | import HeaderStyles from "@styles/Header.module.css";
2 |
3 | export default function Logo() {
4 | return (
5 | <>
6 |
35 |
50 | >
51 | );
52 | }
53 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # Next.js + Contentful Blog Starter
2 |
3 | 
4 |
5 | This is an example repository for you to use to create a new blog site using Next.js and Contentful, using Contentful's GraphQL API.
6 |
7 | [Read more about the GraphQL API](https://graphql.contentful.com).
8 |
9 | ## View the demo site
10 |
11 | [Click here to explore the demo site that uses this repository as its source code.](https://nextjs-contentful-blog-starter.vercel.app/)
12 |
13 | ## Getting set up
14 |
15 | Fork the repository to your GitHub account and clone it to your local machine.
16 |
17 | ```bash
18 | #using git
19 | git clone git@github.com:whitep4nth3r/nextjs-contentful-blog-starter.git
20 |
21 | #using the GitHub CLI
22 | gh repo clone whitep4nth3r/nextjs-contentful-blog-starter
23 | ```
24 |
25 | ## Configuring your development environment
26 |
27 | ### Install dependencies
28 |
29 | In a terminal window, navigate to the project directory and install dependencies with npm.
30 |
31 | ```bash
32 | cd nextjs-contentful-blog-starter
33 | npm install
34 | ```
35 |
36 | ### Set your environment variables
37 |
38 | At the root of the project, create a new `.env.local` file. Add the following environment variable names to the file:
39 |
40 | ```text
41 | CONTENTFUL_SPACE_ID=
42 | CONTENTFUL_ACCESS_TOKEN=
43 | ```
44 |
45 | ### Using example content from Contentful
46 |
47 | **You can choose to use your own Contentful account, or connect to the example space that we've provided.**
48 |
49 | If you'd like to view some example content in your development environment to get a feel for how it works, you can use the provided credentials in `env.local.example` which will connect your code to the example space provided by Contentful.
50 |
51 | ### Using your own Contentful account
52 |
53 | To get started with your own Contentful space, [sign up for free](https://www.contentful.com/sign-up/).
54 |
55 | Create a new space inside your Contentful account. Go to Settings > General Settings, and make a note of your space ID.
56 |
57 | 
58 |
59 | Generate a Content Delivery API access token for your Contentful space.
60 |
61 | 
62 |
63 | Add your space ID and access token to your `.env.local` file.
64 |
65 | ## Importing the starter content model and example content into your own Contentful space
66 |
67 | To get started quickly on your own version of the application, you can use the Contentful CLI to import the content model and the example content from the starter into your own Contentful space — without touching the Contentful UI!
68 |
69 | ### Install the Contentful CLI
70 |
71 | ```bash
72 | #using homebrew
73 | brew install contentful-cli
74 |
75 | #using npm
76 | npm install -g contentful-cli
77 |
78 | #using yarn
79 | yarn global add contentful-cli
80 | ```
81 |
82 | ### Authenticate with the CLI
83 |
84 | Open a terminal and run:
85 |
86 | ```bash
87 | contentful login
88 | ```
89 |
90 | A browser window will open. Follow the instructions to log in to Contentful via the CLI.
91 |
92 | ### Import the content model and example content
93 |
94 | The following command in your terminal, ensuring you switch out SPACE_ID for your new space ID.
95 |
96 | ```bash
97 | cd nextjs-contentful-blog-starter/setup
98 |
99 | contentful space import --space-id SPACE_ID --content-file content-export.json
100 | ```
101 |
102 | You should see this output in the terminal. The import will take around 1 minute to complete.
103 |
104 | 
105 |
106 | Refresh Contentful in your browser, navigate to the content model tab, and you'll find the content types have been imported into your space. You'll find the example content by clicking on the content tab.
107 |
108 | 
109 |
110 | ## Running the application in development
111 |
112 | Navigate to the project directory in a terminal window and run:
113 |
114 | ```bash
115 | npm run dev
116 | ```
117 |
118 | ## Deploy this site to Netlify
119 |
120 | [](https://app.netlify.com/start/deploy?repository=https://github.com/whitep4nth3r/nextjs-contentful-blog-starter)
121 |
122 | During the deploy process, add the following environment variables to Netlify. Use the same credentials as you set up in your local development environment.
123 |
124 | ```text
125 | CONTENTFUL_SPACE_ID
126 | CONTENTFUL_ACCESS_TOKEN
127 | ```
128 |
129 | ## Publish via webhooks
130 |
131 | After you deploy the site to Netlify you can configure it to build whenever new a new entry is published in Contentful. To configure this navigate to your site settings on Netlify and go to the Build & Deploy tab. Find the Build hooks section and add a new build hook. Name the build hook something like Contentful and select your production branch.
132 |
133 | 
134 |
135 | Copy the generated URL and navigate to Settings > Webhooks in your Contentful space. Under Webhook Templates click Add next to the Netlify template. Add the URL you just copied and click Create webhook.
136 |
137 | 
138 |
139 | Now when you publish an entry in your Contentful space it will trigger a build of your production branch on Netlify.
140 |
141 | ## Configure Next.js preview mode
142 |
143 | In your Contentful space, go to Settings > Content preview and add a new content preview. Under content preview URLs check Blog Post and add this URL
144 |
145 | ```text
146 | https://$NETLIFY_URL/api/preview?secret=$SECRET&slug={entry.fields.slug}&contentType=blogPost
147 | ```
148 |
149 | Replacing `$NETLIFY` with the URL of your site deployed on Netlify and `$SECRET` which a secret value that you generate. Store this value as you will add to your Netlify environment variables in a moment.
150 |
151 | Check Page Content and add this URL
152 |
153 | ```text
154 | https://$NETLIFY_URL/api/preview?secret=$SECRET&slug={entry.fields.slug}&contentType=pageContent
155 | ```
156 |
157 | Replacing the variables with the same values you used above. Navigate to your site on Netlify and go to Site settings > Build & Deploy > Environment and add the following environment variables
158 |
159 | ```text
160 | CONTENTFUL_PREVIEW_SECRET
161 | CONTENTFUL_PREVIEW_ACCESS_TOKEN
162 | ```
163 |
164 | Set `CONTENTFUL_PREVIEW_SECRET` to the value you generated above and used for `$SECRET` in the preview URLs. Set `CONTENTFUL_PREVIEW_ACCESS_TOKEN` to your Contentful Content Preview API access token which can be found under Settings > API keys.
165 |
166 | Trigger a new deploy of your site on Netlify so the new variables are applied and you should now be able to enter Preview mode by clicking the preview button on relevant content entries.
167 |
--------------------------------------------------------------------------------
/components/RichTextPageContent/index.js:
--------------------------------------------------------------------------------
1 | import Image from "next/image";
2 | import Link from "next/link";
3 | import dynamic from "next/dynamic";
4 | import RichTextPageContentStyles from "@styles/RichTextPageContent.module.css";
5 | import TypographyStyles from "@styles/Typography.module.css";
6 | import LinkIcon from "./svg/LinkIcon";
7 | import { BLOCKS, MARKS, INLINES } from "@contentful/rich-text-types";
8 | import { documentToReactComponents } from "@contentful/rich-text-react-renderer";
9 |
10 | function slugifyString(string) {
11 | return string
12 | .replace(/\s+/g, "-")
13 | .replace(/[^\w\-]+/g, "")
14 | .replace(/\-\-+/g, "-")
15 | .replace(/^-+/, "")
16 | .replace(/-+$/, "")
17 | .toLowerCase();
18 | }
19 |
20 | const DynamicCodeBlock = dynamic(() => import("./CodeBlock"));
21 |
22 | const DynamicVideoEmbed = dynamic(() => import("./VideoEmbed"));
23 |
24 | export function getRichTextRenderOptions(links, options) {
25 | const { renderH2Links, renderNativeImg } = options;
26 |
27 | const assetBlockMap = new Map(
28 | links?.assets?.block?.map((asset) => [asset.sys.id, asset]),
29 | );
30 |
31 | const entryMap = new Map();
32 | // loop through the block linked entries and add them to the map
33 | if (links.entries.block) {
34 | for (const entry of links.entries.block) {
35 | entryMap.set(entry.sys.id, entry);
36 | }
37 | }
38 |
39 | // loop through the inline linked entries and add them to the map
40 | if (links.entries.inline) {
41 | for (const entry of links.entries.inline) {
42 | entryMap.set(entry.sys.id, entry);
43 | }
44 | }
45 |
46 | return {
47 | renderMark: {
48 | [MARKS.BOLD]: (text) => (
49 |
52 | {text}
53 |
54 | ),
55 | [MARKS.CODE]: (text) => (
56 | {text}
57 | ),
58 | },
59 |
60 | renderNode: {
61 | [INLINES.HYPERLINK]: (node, children) => (
62 |
68 | {children}
69 |
70 | ),
71 | [BLOCKS.HR]: (text) => (
72 |
73 | ),
74 | [BLOCKS.HEADING_1]: (node, children) => (
75 | {children}
76 | ),
77 | [BLOCKS.HEADING_2]: (node, children) => {
78 | if (renderH2Links) {
79 | return (
80 |
83 |
87 | {children}
88 |
89 |
94 |
95 |
96 |
97 | );
98 | } else {
99 | return {children}
;
100 | }
101 | },
102 | [BLOCKS.HEADING_3]: (node, children) => (
103 | {children}
104 | ),
105 | [BLOCKS.HEADING_4]: (node, children) => (
106 | {children}
107 | ),
108 | [BLOCKS.HEADING_5]: (node, children) => (
109 | {children}
110 | ),
111 | [BLOCKS.HEADING_6]: (node, children) => (
112 | {children}
113 | ),
114 | [BLOCKS.PARAGRAPH]: (node, children) => (
115 | {children}
116 | ),
117 | [BLOCKS.QUOTE]: (node, children) => (
118 |
119 | {children}
120 |
121 | ),
122 | [BLOCKS.UL_LIST]: (node, children) => (
123 |
124 | ),
125 | [BLOCKS.OL_LIST]: (node, children) => (
126 | {children}
127 | ),
128 | [BLOCKS.LIST_ITEM]: (node, children) => (
129 |
132 | {children}
133 |
134 | ),
135 | [INLINES.EMBEDDED_ENTRY]: (node, children) => {
136 | const entry = entryMap.get(node.data.target.sys.id);
137 | const { __typename } = entry;
138 |
139 | switch (__typename) {
140 | case "BlogPost":
141 | return (
142 |
143 | {entry.title}
144 |
145 | );
146 | default:
147 | return null;
148 | }
149 | },
150 | [BLOCKS.EMBEDDED_ENTRY]: (node, children) => {
151 | const entry = entryMap.get(node.data.target.sys.id);
152 | const { __typename } = entry;
153 |
154 | switch (__typename) {
155 | case "VideoEmbed":
156 | const { embedUrl, title } = entry;
157 | return ;
158 | case "CodeBlock":
159 | const { language, code } = entry;
160 |
161 | return ;
162 | default:
163 | return null;
164 | }
165 | },
166 | [BLOCKS.EMBEDDED_ASSET]: (node, next) => {
167 | const { title, url, height, width, description } = assetBlockMap.get(
168 | node.data.target.sys.id,
169 | );
170 |
171 | if (renderNativeImg) {
172 | return (
173 |
174 |

175 |
176 | );
177 | } else {
178 | return (
179 |
180 |
187 |
188 | );
189 | }
190 | },
191 | },
192 | };
193 | }
194 |
195 | export default function RichTextPageContent(props) {
196 | const { richTextBodyField, renderH2Links } = props;
197 |
198 | return (
199 |
200 | {documentToReactComponents(
201 | richTextBodyField.json,
202 | getRichTextRenderOptions(richTextBodyField.links, { renderH2Links }),
203 | )}
204 |
205 | );
206 | }
207 |
--------------------------------------------------------------------------------
/utils/ContentfulApi.js:
--------------------------------------------------------------------------------
1 | import { Config } from "./Config";
2 |
3 | /**
4 | * This class constructs GraphQL queries for blog posts, page content and other data
5 | * and calls out to the Contentful GraphQL API.
6 | *
7 | * Contentful GraphQL API docs:
8 | * https://www.contentful.com/developers/docs/references/graphql/
9 | *
10 | * Explore the GraphQL API in depth in the GraphiQL Playground:
11 | * https://graphql.contentful.com/content/v1/spaces/{SPACE_ID}/explore?access_token={ACCESS_TOKEN}
12 | *
13 | */
14 |
15 | const defaultOptions = {
16 | preview: false,
17 | };
18 |
19 | export default class ContentfulApi {
20 | /**
21 | * Fetch the content for a single page by slug.
22 | *
23 | * The content type uses the powerful Rich Text field type for the
24 | * body of the post.
25 | *
26 | * This query fetches linked assets (i.e. images) and entries
27 | * (i.e. video embed and code block entries) that are embedded
28 | * in the Rich Text field. This is rendered to the page using
29 | * @components/RichTextPageContent.
30 | *
31 | * For more information on Rich Text fields in Contentful, view the
32 | * documentation here: https://www.contentful.com/developers/docs/concepts/rich-text/
33 | *
34 | * Linked assets and entries are parsed and rendered using the npm package
35 | * @contentful/rich-text-react-renderer
36 | *
37 | * https://www.npmjs.com/package/@contentful/rich-text-react-renderer
38 | *
39 | * param: slug (string)
40 | *
41 | */
42 | static async getPageContentBySlug(slug, options = defaultOptions) {
43 | const variables = { slug, preview: options.preview };
44 | const query = `
45 | query GetPageContentBySlug($slug: String!, $preview: Boolean!) {
46 | pageContentCollection(limit: 1, where: {slug: $slug}, preview: $preview) {
47 | items {
48 | sys {
49 | id
50 | }
51 | heroBanner {
52 | headline
53 | subHeading
54 | internalLink
55 | externalLink
56 | ctaText
57 | image {
58 | url
59 | title
60 | description
61 | width
62 | height
63 | }
64 | }
65 | title
66 | description
67 | slug
68 | body {
69 | json
70 | links {
71 | entries {
72 | block {
73 | sys {
74 | id
75 | }
76 | __typename
77 | ... on VideoEmbed {
78 | title
79 | embedUrl
80 | }
81 | ... on CodeBlock {
82 | description
83 | language
84 | code
85 | }
86 | }
87 | }
88 | assets {
89 | block {
90 | sys {
91 | id
92 | }
93 | url
94 | title
95 | width
96 | height
97 | description
98 | }
99 | }
100 | }
101 | }
102 | }
103 | }
104 | }`;
105 |
106 | const response = await this.callContentful(query, variables, options);
107 |
108 | const pageContent = response.data.pageContentCollection.items
109 | ? response.data.pageContentCollection.items
110 | : [];
111 |
112 | return pageContent.pop();
113 | }
114 |
115 | /**
116 | * Fetch a batch of blog post slugs (by given page number).
117 | *
118 | * This method queries the GraphQL API for a single batch of blog post slugs.
119 | *
120 | * The query limit of 100 is the maximum number of slugs
121 | * we can fetch with this query due to GraphQL complexity costs.
122 | *
123 | * For more information about GraphQL query complexity, visit:
124 | * https://www.contentful.com/developers/videos/learn-graphql/#graphql-fragments-and-query-complexity
125 | *
126 | * param: page (number)
127 | *
128 | */
129 | static async getPaginatedSlugs(page) {
130 | const queryLimit = 100;
131 | const skipMultiplier = page === 1 ? 0 : page - 1;
132 | const skip = skipMultiplier > 0 ? queryLimit * skipMultiplier : 0;
133 |
134 | const variables = { limit: queryLimit, skip };
135 |
136 | const query = `query GetPaginatedSlugs($limit: Int!, $skip: Int!) {
137 | blogPostCollection(limit: $limit, skip: $skip, order: date_DESC) {
138 | total
139 | items {
140 | slug
141 | }
142 | }
143 | }`;
144 |
145 | const response = await this.callContentful(query, variables);
146 |
147 | const { total } = response.data.blogPostCollection;
148 | const slugs = response.data.blogPostCollection.items
149 | ? response.data.blogPostCollection.items.map((item) => item.slug)
150 | : [];
151 |
152 | return { slugs, total };
153 | }
154 |
155 | /**
156 | * Fetch all blog post slugs.
157 | *
158 | * This method queries the GraphQL API for blog post slugs
159 | * in batches that accounts for the query complexity cost,
160 | * and returns them in one array.
161 | *
162 | * This method is used on pages/blog/[slug] inside getStaticPaths() to
163 | * generate all dynamic blog post routes.
164 | *
165 | * For more information about GraphQL query complexity, visit:
166 | * https://www.contentful.com/developers/videos/learn-graphql/#graphql-fragments-and-query-complexity
167 | *
168 | */
169 | static async getAllPostSlugs() {
170 | let page = 1;
171 | let shouldQueryMoreSlugs = true;
172 | const returnSlugs = [];
173 |
174 | while (shouldQueryMoreSlugs) {
175 | const response = await this.getPaginatedSlugs(page);
176 |
177 | if (response.slugs.length > 0) {
178 | returnSlugs.push(...response.slugs);
179 | }
180 |
181 | shouldQueryMoreSlugs = returnSlugs.length < response.total;
182 | page++;
183 | }
184 |
185 | return returnSlugs;
186 | }
187 |
188 | /**
189 | * Fetch a batch of blog posts (by given page number).
190 | *
191 | * This method queries the GraphQL API for a single batch of blog posts.
192 | *
193 | * The query limit of 10 is the maximum number of posts
194 | * we can fetch with this query due to GraphQL complexity costs.
195 | *
196 | * For more information about GraphQL query complexity, visit:
197 | * https://www.contentful.com/developers/videos/learn-graphql/#graphql-fragments-and-query-complexity
198 | *
199 | * param: page (number)
200 | *
201 | */
202 | static async getPaginatedBlogPosts(page) {
203 | const queryLimit = 10;
204 | const skipMultiplier = page === 1 ? 0 : page - 1;
205 | const skip = skipMultiplier > 0 ? queryLimit * skipMultiplier : 0;
206 |
207 | const variables = { limit: queryLimit, skip };
208 |
209 | const query = `query GetPaginatedBlogPosts($limit: Int!, $skip: Int!) {
210 | blogPostCollection(limit: $limit, skip: $skip, order: date_DESC) {
211 | total
212 | items {
213 | sys {
214 | id
215 | }
216 | date
217 | title
218 | slug
219 | excerpt
220 | tags
221 | externalUrl
222 | author {
223 | name
224 | description
225 | twitchUsername
226 | twitterUsername
227 | gitHubUsername
228 | websiteUrl
229 | image {
230 | url
231 | title
232 | width
233 | height
234 | description
235 | }
236 | }
237 | body {
238 | json
239 | links {
240 | entries {
241 | inline {
242 | sys {
243 | id
244 | }
245 | __typename
246 | ... on BlogPost {
247 | title
248 | slug
249 | }
250 | }
251 | block {
252 | sys {
253 | id
254 | }
255 | __typename
256 | ... on VideoEmbed {
257 | title
258 | embedUrl
259 | }
260 | ... on CodeBlock {
261 | description
262 | language
263 | code
264 | }
265 | }
266 | }
267 | assets {
268 | block {
269 | sys {
270 | id
271 | }
272 | url
273 | title
274 | width
275 | height
276 | description
277 | }
278 | }
279 | }
280 | }
281 | }
282 | }
283 | }`;
284 |
285 | const response = await this.callContentful(query, variables);
286 |
287 | const { total } = response.data.blogPostCollection;
288 | const posts = response.data.blogPostCollection.items
289 | ? response.data.blogPostCollection.items
290 | : [];
291 |
292 | return { posts, total };
293 | }
294 |
295 | /**
296 | * Fetch all blog posts.
297 | *
298 | * This method queries the GraphQL API for blog posts
299 | * in batches that accounts for the query complexity cost,
300 | * and returns them in one array.
301 | *
302 | * This method is used to build the RSS feed on pages/buildrss.
303 | *
304 | * For more information about GraphQL query complexity, visit:
305 | * https://www.contentful.com/developers/videos/learn-graphql/#graphql-fragments-and-query-complexity
306 | *
307 | */
308 | static async getAllBlogPosts() {
309 | let page = 1;
310 | let shouldQueryMorePosts = true;
311 | const returnPosts = [];
312 |
313 | while (shouldQueryMorePosts) {
314 | const response = await this.getPaginatedBlogPosts(page);
315 |
316 | if (response.posts.length > 0) {
317 | returnPosts.push(...response.posts);
318 | }
319 |
320 | shouldQueryMorePosts = returnPosts.length < response.total;
321 | page++;
322 | }
323 |
324 | return returnPosts;
325 | }
326 |
327 | /**
328 | * Fetch a single blog post by slug.
329 | *
330 | * This method is used on pages/blog/[slug] to fetch the data for
331 | * individual blog posts at build time, which are prerendered as
332 | * static HTML.
333 | *
334 | * The content type uses the powerful Rich Text field type for the
335 | * body of the post.
336 | *
337 | * This query fetches linked assets (i.e. images) and entries
338 | * (i.e. video embed and code block entries) that are embedded
339 | * in the Rich Text field. This is rendered to the page using
340 | * @components/RichTextPageContent.
341 | *
342 | * For more information on Rich Text fields in Contentful, view the
343 | * documentation here: https://www.contentful.com/developers/docs/concepts/rich-text/
344 | *
345 | * Linked assets and entries are parsed and rendered using the npm package
346 | * @contentful/rich-text-react-renderer
347 | *
348 | * https://www.npmjs.com/package/@contentful/rich-text-react-renderer
349 | *
350 | * param: slug (string)
351 | *
352 | */
353 | static async getPostBySlug(slug, options = defaultOptions) {
354 | const variables = { slug, preview: options.preview };
355 | const query = `query GetPostBySlug($slug: String!, $preview: Boolean!) {
356 | blogPostCollection(limit: 1, where: {slug: $slug}, preview: $preview) {
357 | total
358 | items {
359 | sys {
360 | id
361 | }
362 | date
363 | title
364 | slug
365 | excerpt
366 | tags
367 | externalUrl
368 | author {
369 | name
370 | description
371 | twitchUsername
372 | twitterUsername
373 | gitHubUsername
374 | websiteUrl
375 | image {
376 | url
377 | title
378 | width
379 | height
380 | description
381 | }
382 | }
383 | body {
384 | json
385 | links {
386 | entries {
387 | inline {
388 | sys {
389 | id
390 | }
391 | __typename
392 | ... on BlogPost {
393 | title
394 | slug
395 | }
396 | }
397 | block {
398 | sys {
399 | id
400 | }
401 | __typename
402 | ... on VideoEmbed {
403 | title
404 | embedUrl
405 | }
406 | ... on CodeBlock {
407 | description
408 | language
409 | code
410 | }
411 | }
412 | }
413 | assets {
414 | block {
415 | sys {
416 | id
417 | }
418 | url
419 | title
420 | width
421 | height
422 | description
423 | }
424 | }
425 | }
426 | }
427 | }
428 | }
429 | }`;
430 |
431 | const response = await this.callContentful(query, variables, options);
432 | const post = response.data.blogPostCollection.items
433 | ? response.data.blogPostCollection.items
434 | : [];
435 |
436 | return post.pop();
437 | }
438 |
439 | /**
440 | * Fetch n post summaries that are displayed on pages/blog.js.
441 | *
442 | * This method accepts a parameter of a page number that calculates
443 | * how many blog posts to skip in the GraphQL query.
444 | *
445 | * Set your desired page size in @utils/Config:
446 | * Config.pagination.pageSize
447 | *
448 | * The page size is currently set to 2 so you can view how the pagination
449 | * works on a fresh clone of the repository.
450 | *
451 | * param: page (number)
452 | *
453 | */
454 | static async getPaginatedPostSummaries(page) {
455 | /**
456 | * Calculate the skip parameter for the query based on the incoming page number.
457 | * For example, if page === 2, and your page length === 3,
458 | * the skip parameter would be calculated as 3 (the length of a page)
459 | * therefore skipping the results of page 1.
460 | */
461 |
462 | const skipMultiplier = page === 1 ? 0 : page - 1;
463 | const skip =
464 | skipMultiplier > 0 ? Config.pagination.pageSize * skipMultiplier : 0;
465 |
466 | const variables = { limit: Config.pagination.pageSize, skip };
467 |
468 | const query = `query GetPaginatedPostSummaries($limit: Int!, $skip: Int!) {
469 | blogPostCollection(limit: $limit, skip: $skip, order: date_DESC) {
470 | total
471 | items {
472 | sys {
473 | id
474 | }
475 | date
476 | title
477 | slug
478 | excerpt
479 | tags
480 | }
481 | }
482 | }`;
483 |
484 | const response = await this.callContentful(query, variables);
485 |
486 | const paginatedPostSummaries = response.data.blogPostCollection
487 | ? response.data.blogPostCollection
488 | : { total: 0, items: [] };
489 |
490 | return paginatedPostSummaries;
491 | }
492 |
493 | /**
494 | * Fetch n recent post summaries that are displayed on pages/index.js.
495 | *
496 | * This query is purposefully not paginated as it serves as a single
497 | * responsibility function to display a fixed size group of posts.
498 | *
499 | * Set your desired recent post list size in @utils/Config:
500 | * Config.pagination.recentPostsSize
501 | *
502 | */
503 | static async getRecentPostList() {
504 | const variables = { limit: Config.pagination.recentPostsSize };
505 | const query = `query GetRecentPostList($limit: Int!) {
506 | blogPostCollection(limit: $limit, order: date_DESC) {
507 | items {
508 | sys {
509 | id
510 | }
511 | date
512 | title
513 | slug
514 | excerpt
515 | tags
516 | }
517 | }
518 | }`;
519 |
520 | const response = await this.callContentful(query, variables);
521 |
522 | const recentPosts = response.data.blogPostCollection.items
523 | ? response.data.blogPostCollection.items
524 | : [];
525 |
526 | return recentPosts;
527 | }
528 |
529 | /**
530 | * Fetch the total number of blog posts.
531 | */
532 | static async getTotalPostsNumber() {
533 | const query = `
534 | {
535 | blogPostCollection {
536 | total
537 | }
538 | }
539 | `;
540 |
541 | const response = await this.callContentful(query);
542 | const totalPosts = response.data.blogPostCollection.total
543 | ? response.data.blogPostCollection.total
544 | : 0;
545 |
546 | return totalPosts;
547 | }
548 |
549 | /**
550 | * Call the Contentful GraphQL API using fetch.
551 | *
552 | * param: query (string)
553 | */
554 | static async callContentful(query, variables = {}, options = defaultOptions) {
555 | const fetchUrl = `https://graphql.contentful.com/content/v1/spaces/${process.env.CONTENTFUL_SPACE_ID}`;
556 |
557 | const accessToken = options.preview
558 | ? process.env.CONTENTFUL_PREVIEW_ACCESS_TOKEN
559 | : process.env.CONTENTFUL_ACCESS_TOKEN;
560 |
561 | const fetchOptions = {
562 | method: "POST",
563 | headers: {
564 | Authorization: "Bearer " + accessToken,
565 | "Content-Type": "application/json",
566 | },
567 | body: JSON.stringify({ query, variables }),
568 | };
569 |
570 | try {
571 | const data = await fetch(fetchUrl, fetchOptions).then((response) =>
572 | response.json(),
573 | );
574 | return data;
575 | } catch (error) {
576 | throw new Error("Could not fetch data from Contentful!");
577 | }
578 | }
579 | }
580 |
--------------------------------------------------------------------------------