├── .github └── workflows │ └── codeql-analysis.yml ├── .gitignore ├── LICENSE ├── README.md ├── api ├── ghost_data.js └── posts.js ├── components ├── footer.js ├── githubbanner.js ├── header.js ├── layout.js └── postpreviewcard.js ├── package-lock.json ├── package.json ├── pages ├── _app.js ├── blogpages │ └── [slug].js ├── index.js ├── posts │ └── [slug].js └── tags │ ├── [slug].js │ └── tagoverview.js ├── postcss.config.js ├── public ├── favicon.ico ├── github_socialpreview_nextjs-ghost-tutorial_1.png ├── instagram.png ├── profilepic.jpg ├── robots.txt └── twitter.png ├── scripts └── generate-sitemap-postbuild.js ├── styles ├── Home.module.css ├── blogInnerHtml.css ├── globalstyles.css └── tailwind.css └── tailwind.config.js /.github/workflows/codeql-analysis.yml: -------------------------------------------------------------------------------- 1 | # For most projects, this workflow file will not need changing; you simply need 2 | # to commit it to your repository. 3 | # 4 | # You may wish to alter this file to override the set of languages analyzed, 5 | # or to provide custom queries or build logic. 6 | # 7 | # ******** NOTE ******** 8 | # We have attempted to detect the languages in your repository. Please check 9 | # the `language` matrix defined below to confirm you have the correct set of 10 | # supported CodeQL languages. 11 | # 12 | name: "CodeQL" 13 | 14 | on: 15 | push: 16 | branches: [ main ] 17 | pull_request: 18 | # The branches below must be a subset of the branches above 19 | branches: [ main ] 20 | schedule: 21 | - cron: '30 20 * * 5' 22 | 23 | jobs: 24 | analyze: 25 | name: Analyze 26 | runs-on: ubuntu-latest 27 | 28 | strategy: 29 | fail-fast: false 30 | matrix: 31 | language: [ 'javascript' ] 32 | # CodeQL supports [ 'cpp', 'csharp', 'go', 'java', 'javascript', 'python' ] 33 | # Learn more: 34 | # https://docs.github.com/en/free-pro-team@latest/github/finding-security-vulnerabilities-and-errors-in-your-code/configuring-code-scanning#changing-the-languages-that-are-analyzed 35 | 36 | steps: 37 | - name: Checkout repository 38 | uses: actions/checkout@v2 39 | 40 | # Initializes the CodeQL tools for scanning. 41 | - name: Initialize CodeQL 42 | uses: github/codeql-action/init@v1 43 | with: 44 | languages: ${{ matrix.language }} 45 | # If you wish to specify custom queries, you can do so here or in a config file. 46 | # By default, queries listed here will override any specified in a config file. 47 | # Prefix the list here with "+" to use these queries and those in the config file. 48 | # queries: ./path/to/local/query, your-org/your-repo/queries@main 49 | 50 | # Autobuild attempts to build any compiled languages (C/C++, C#, or Java). 51 | # If this step fails, then you should remove it and run the build manually (see below) 52 | - name: Autobuild 53 | uses: github/codeql-action/autobuild@v1 54 | 55 | # ℹ️ Command-line programs to run using the OS shell. 56 | # 📚 https://git.io/JvXDl 57 | 58 | # ✏️ If the Autobuild fails above, remove it and uncomment the following three lines 59 | # and modify them (or add more) to build your code if your project 60 | # uses a compiled language 61 | 62 | #- run: | 63 | # make bootstrap 64 | # make release 65 | 66 | - name: Perform CodeQL Analysis 67 | uses: github/codeql-action/analyze@v1 68 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | /.pnp 6 | .pnp.js 7 | 8 | # testing 9 | /coverage 10 | 11 | # next.js 12 | /.next/ 13 | /out/ 14 | 15 | # production 16 | /build 17 | 18 | # misc 19 | .DS_Store 20 | *.pem 21 | 22 | # debug 23 | npm-debug.log* 24 | yarn-debug.log* 25 | yarn-error.log* 26 | 27 | # local env files 28 | .env.local 29 | .env.development.local 30 | .env.test.local 31 | .env.production.local 32 | 33 | # vercel 34 | .vercel 35 | 36 | # ghost api key 37 | /api/ghost_data_private.js 38 | 39 | /.vscode 40 | 41 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021 Filipe Matos 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # nextjs-ghost-tutorial 2 | 3 | Codebase for a tutorial on how to create a [Next.js](https://nextjs.org/) frontend for [ghost](https://ghost.org/), a headless Content Management System (CMS).
4 | 5 | ![](./public/github_socialpreview_nextjs-ghost-tutorial_1.png) 6 | 7 | 8 | ## Usage 9 | 10 | ```bash 11 | mkdir somedirectory 12 | cd ./somedirectory 13 | git clone https://github.com/GitHubFilipe/nextjs-ghost-tutorial.git 14 | cd ./nextjs-ghost-tutorial 15 | npm run install 16 | npm run dev 17 | ``` 18 | 19 | Browse [http://localhost:3000](http://localhost:3000) 20 |

21 | 22 | ## Manual Sections 23 | 24 | - [Episode 1 - Intro](https://filipe-matos.netlify.app/posts/nextjs-ghost-1-intro) 25 | - [Episode 2 - Setting up the development environment](https://filipe-matos.netlify.app/posts/nextjs-ghost-2-setup-dev-env) 26 | - [Episode 3 - Creating Next.js project and code repository](https://filipe-matos.netlify.app/posts/nextjs-ghost-3-create-nextjs-project) 27 | - [Episode 4 - Styling with Vanilla CSS and Tailwind](https://filipe-matos.netlify.app/posts/nextjs-ghost-4-style-with-css-and-tailwind) 28 | - [Episode 5 - Creating pages and layout components](https://filipe-matos.netlify.app/posts/nextjs-ghost-5-routing-and-layout-components) 29 | - [Episode 6 - Speaking with Ghost(s)](https://filipe-matos.netlify.app/posts/nextjs-ghost-6-speaking-with-ghost) 30 | - [Episode 7 - Render blog posts](https://filipe-matos.netlify.app/posts/nextjs-ghost-7-render-blog-posts) 31 | - [Episode 8 - Rendering blog pages and tags](https://filipe-matos.netlify.app/posts/nextjs-ghost-8-render-blog-pages-and-tags) 32 | - [Episode 9 - Publish your blog](https://filipe-matos.netlify.app/posts/nextjs-ghost-9-publish-your-blog) 33 | - [Episode 10 - Search Engine Optimization (SEO)](https://filipe-matos.netlify.app/posts/nextjs-ghost-10-seo) 34 |

35 | 36 | ## Feedback 37 | 38 | Go to [GitHub Discussions](https://github.com/GitHubFilipe/nextjs-ghost-tutorial/discussions) for feedback. 39 | 40 |
41 | 42 | ## License 43 | 44 | nextjs-ghost-tutorial is licensed under the [MIT License](LICENSE) 45 | -------------------------------------------------------------------------------- /api/ghost_data.js: -------------------------------------------------------------------------------- 1 | import GhostContentAPI from '@tryghost/content-api' 2 | 3 | // Create API instance with site credentials 4 | // (replace and with your own credentials 5 | const api = new GhostContentAPI({ 6 | url: '', 7 | key: '', 8 | version: 'v3', 9 | }) 10 | 11 | export async function getPosts() { 12 | return await api.posts 13 | .browse({ 14 | include: 'tags,authors', 15 | limit: 'all', 16 | }) 17 | .catch((err) => { 18 | console.error(err) 19 | }) 20 | } 21 | 22 | export async function getSinglePost(postSlug) { 23 | return await api.posts 24 | .read({ 25 | slug: postSlug, 26 | }) 27 | .catch((err) => { 28 | console.error(err) 29 | }) 30 | } 31 | 32 | export async function getPages() { 33 | return await api.pages 34 | .browse({ 35 | limit: 'all', 36 | }) 37 | .catch((err) => { 38 | console.error(err) 39 | }) 40 | } 41 | 42 | export async function getSinglePage(pageSlug) { 43 | return await api.pages 44 | .read({ 45 | slug: pageSlug, 46 | }) 47 | .catch((err) => { 48 | console.error(err) 49 | }) 50 | } 51 | 52 | export async function getTags() { 53 | return await api.tags 54 | .browse({ 55 | limit: 'all', 56 | }) 57 | .catch((err) => { 58 | console.error(err) 59 | }) 60 | } 61 | 62 | export async function getSingleTag(tagSlug) { 63 | return await api.tags 64 | .read({ 65 | slug: tagSlug, 66 | }) 67 | .catch((err) => { 68 | console.error(err) 69 | }) 70 | } 71 | 72 | export async function getPostsByTag(tag) { 73 | const posts = await api.posts 74 | .browse({ 75 | filter: `tag:${tag}`, 76 | }) 77 | .catch((err) => { 78 | console.error(err) 79 | }) 80 | return posts 81 | } 82 | -------------------------------------------------------------------------------- /api/posts.js: -------------------------------------------------------------------------------- 1 | import GhostContentAPI from '@tryghost/content-api' 2 | -------------------------------------------------------------------------------- /components/footer.js: -------------------------------------------------------------------------------- 1 | import Link from 'next/link' 2 | 3 | export default function Footer() { 4 | return ( 5 | 38 | ) 39 | } 40 | -------------------------------------------------------------------------------- /components/githubbanner.js: -------------------------------------------------------------------------------- 1 | export default function GithubBanner() { 2 | return ( 3 |
4 | The source code for this page is 5 | 9 | {' '} 10 | available on Github 11 | 12 |
13 | ) 14 | } 15 | -------------------------------------------------------------------------------- /components/header.js: -------------------------------------------------------------------------------- 1 | import Link from 'next/link' 2 | 3 | export default function Header({ home }) { 4 | return ( 5 | 52 | ) 53 | } 54 | -------------------------------------------------------------------------------- /components/layout.js: -------------------------------------------------------------------------------- 1 | import Header from './header' 2 | import Footer from './footer' 3 | import Head from 'next/head' 4 | import GithubBanner from './githubbanner' 5 | 6 | export default function Layout({ home, _metaData, children }) { 7 | return ( 8 |
9 | // Delete this line to remove banner at the top 10 |
11 | 12 | {_metaData.n_title} 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 25 | 29 | 33 | 37 | 38 | 39 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 |
54 |
55 |
59 | {children} 60 |
61 |
62 |
63 |
64 |
65 | ) 66 | } 67 | -------------------------------------------------------------------------------- /components/postpreviewcard.js: -------------------------------------------------------------------------------- 1 | import Link from 'next/link' 2 | 3 | export default function BlogPreviewCard({ blogpost }) { 4 | return ( 5 |
6 |
7 |
8 | 9 | 10 | 11 |
12 |
13 |
14 |

15 | {blogpost.tags.map((tag) => ( 16 | 17 | 18 | {tag.name} 19 | 20 | 21 | ))} 22 |

23 |

{blogpost.dateFormatted}

24 |
25 | 26 | 27 |

28 | {blogpost.title} 29 |

30 |
31 | 32 |
36 | 37 | ...read more 38 | 39 |
40 |
41 |
42 |
43 | ) 44 | } 45 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "nextjs-ghost-tutorial", 3 | "version": "1.0.0", 4 | "description": "Tutorial on how to create a next.js frontend to Ghost Headless cms", 5 | "main": "index.js", 6 | "scripts": { 7 | "dev": "NODE_OPTIONS='--inspect' next dev", 8 | "build": "next build", 9 | "start": "next start", 10 | "export": "next export", 11 | "postexport": "node scripts/generate-sitemap-postbuild.js" 12 | }, 13 | "author": "", 14 | "license": "MIT", 15 | "dependencies": { 16 | "@tryghost/content-api": "^1.4.10", 17 | "autoprefixer": "^10.0.4", 18 | "next": "^10.0.2", 19 | "postcss": "^8.1.10", 20 | "react": "^17.0.1", 21 | "react-dom": "^17.0.1", 22 | "tailwindcss": "^2.0.1" 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /pages/_app.js: -------------------------------------------------------------------------------- 1 | import '../styles/tailwind.css' 2 | import '../styles/globalstyles.css' 3 | import '../styles/blogInnerHtml.css' 4 | 5 | function MyApp({ Component, pageProps }) { 6 | return 7 | } 8 | 9 | export default MyApp 10 | -------------------------------------------------------------------------------- /pages/blogpages/[slug].js: -------------------------------------------------------------------------------- 1 | import { getPages, getSinglePage } from '../../api/ghost_data' 2 | import Link from 'next/link' 3 | import Layout from '../../components/layout' 4 | 5 | // PostPage page component 6 | export default function PostPage({ page }) { 7 | // Render post title and content in the page from props 8 | let _title = page.meta_title + ' - YOUR BLOG TILE' 9 | let facebook_handle = 'YOUR FACEBOOK HANDLE' 10 | let twitter_handle = '@YOUR TWITTER HANDLE' 11 | let metaObject = { 12 | n_title: _title, 13 | n_description: page.meta_description, 14 | n_HandheldFriendly: 'True', 15 | n_canonical_url: page.canonical_url, 16 | p_og_site_name: 'YOUR BLOG TITLE', 17 | p_og_type: 'website', 18 | p_og_description: page.meta_description, 19 | p_og_image: page.feature_image, 20 | p_article_published_time: page.published_at, // TODO: format date! 21 | p_article_modified_time: page.updated_at, // TODO: format date! 22 | p_article_tag: '', // TODO: object with tags 23 | p_article_publisher: 'https://www.facebook.com/' + facebook_handle, 24 | n_twitter_card: 'summary_large_image', 25 | n_twitter_title: _title, 26 | n_twitter_description: page.meta_description, 27 | n_twitter_image: page.feature_image, 28 | n_twitter_label1: 'Written by', 29 | n_twitter_data1: 'YOUR NAME', 30 | n_twitter_label2: 'Filed under', 31 | n_twitter_data2: '', // TODO: object with tags 32 | n_twitter_site: twitter_handle, 33 | n_twitter_creator: twitter_handle, 34 | n_generator: 'Filipe Matos next.js + Ghost CMS', 35 | } 36 | return ( 37 | 38 |
39 |

{page.title}

40 |
41 |
42 | 43 | ) 44 | } 45 | 46 | export async function getStaticPaths() { 47 | const pages = await getPages() 48 | const paths = pages.map((page) => ({ 49 | params: { slug: page.slug }, 50 | })) 51 | return { paths, fallback: false } 52 | } 53 | 54 | export async function getStaticProps({ params }) { 55 | const page = await getSinglePage(params.slug) 56 | return { props: { page: page } } 57 | } 58 | -------------------------------------------------------------------------------- /pages/index.js: -------------------------------------------------------------------------------- 1 | import PostPreviewCard from '../components/postpreviewcard' 2 | import styles from '../styles/Home.module.css' 3 | import { getPosts } from '../api/ghost_data' 4 | import Layout from '../components/layout' 5 | 6 | export default function Home({ posts }) { 7 | let facebook_handle = 'YOUR FACEBOOK HANDLE' 8 | let twitter_handle = '@YOUR TWITTER HANDLE' 9 | let title = 'YOUR BLOG TITLE' 10 | let description = 'A NICE DESCRIPTION FOR YOUR BLOG' 11 | let metaObject = { 12 | n_title: title, 13 | n_description: description, 14 | n_HandheldFriendly: 'True', 15 | n_canonical_url: 'THE URL OF YOUR HOMEPAGE', 16 | p_og_site_name: 'YOUR BLOG TITLE', 17 | p_og_type: 'website', 18 | p_og_description: description, 19 | p_og_image: '/images/profilepic.jpg', // TODO: Website picture 20 | p_article_published_time: '', // TODO: Today + format date! 21 | p_article_modified_time: '', // TODO: Today + format date! 22 | p_article_tag: 'Personal Blog', 23 | p_article_publisher: 'YOUR NAME', 24 | n_twitter_card: 'summary_large_image', 25 | n_twitter_title: title, 26 | n_twitter_description: description, 27 | n_twitter_image: '/images/profilepic.jpg', // TODO: Website picture 28 | n_twitter_label1: 'Written by', 29 | n_twitter_data1: 'YOUR NAME', 30 | n_twitter_label2: 'Filed under', 31 | n_twitter_data2: 'SOME LABELS OF YOUR CHOICE', 32 | n_twitter_site: twitter_handle, 33 | n_twitter_creator: twitter_handle, 34 | n_generator: 'Filipe Matos next.js + Ghost CMS', 35 | } 36 | return ( 37 | 38 |
    39 | {posts.map((post) => ( 40 |
  • 41 | 42 |
  • 43 | ))} 44 |
45 |
46 | ) 47 | } 48 | 49 | export async function getStaticProps() { 50 | const posts = await getPosts() 51 | 52 | posts.map((post) => { 53 | const options = { 54 | year: 'numeric', 55 | month: 'short', 56 | day: 'numeric', 57 | } 58 | 59 | post.dateFormatted = new Intl.DateTimeFormat('default', options).format( 60 | new Date(post.published_at), 61 | ) 62 | }) 63 | return { 64 | props: { 65 | posts, 66 | }, 67 | } 68 | } 69 | -------------------------------------------------------------------------------- /pages/posts/[slug].js: -------------------------------------------------------------------------------- 1 | import { getPosts, getSinglePost } from '../../api/ghost_data' 2 | import Link from 'next/link' 3 | import Layout from '../../components/layout' 4 | 5 | // PostPage page component 6 | export default function PostPage({ post }) { 7 | // Render post title and content in the page from props 8 | let _title = post.meta_title + ' - YOUR BLOG TILE' 9 | let facebook_handle = 'YOUR FACEBOOK HANDLE' 10 | let twitter_handle = '@YOUR TWITTER HANDLE' 11 | let metaObject = { 12 | n_title: _title, 13 | n_description: post.meta_description, 14 | n_HandheldFriendly: 'True', 15 | n_canonical_url: post.canonical_url, 16 | p_og_site_name: 'YOUR BLOG TITLE', 17 | p_og_type: 'article', 18 | p_og_description: post.meta_description, 19 | p_og_image: post.feature_image, 20 | p_article_published_time: post.published_at, // TODO: format date! 21 | p_article_modified_time: post.updated_at, // TODO: format date! 22 | p_article_tag: '', // TODO: object with tags 23 | p_article_publisher: 'https://www.facebook.com/' + facebook_handle, 24 | n_twitter_card: 'summary_large_image', 25 | n_twitter_title: _title, 26 | n_twitter_description: post.meta_description, 27 | n_twitter_image: post.feature_image, 28 | n_twitter_label1: 'Written by', 29 | n_twitter_data1: 'YOUR NAME', 30 | n_twitter_label2: 'Filed under', 31 | n_twitter_data2: '', // TODO: object with tags 32 | n_twitter_site: twitter_handle, 33 | n_twitter_creator: twitter_handle, 34 | n_generator: 'Filipe Matos next.js + Ghost CMS', 35 | } 36 | return ( 37 | 38 |
39 |

{post.title}

40 |
41 |
42 | {/* 43 | -- go to homepage -- 44 | */} 45 | 46 | ) 47 | } 48 | 49 | export async function getStaticPaths() { 50 | const posts = await getPosts() 51 | const paths = posts.map((post) => ({ 52 | params: { slug: post.slug }, 53 | })) 54 | return { paths, fallback: false } 55 | } 56 | 57 | // Pass the page slug over to the "getSinglePost" function 58 | // In turn passing it to the posts.read() to query the Ghost Content API 59 | 60 | export async function getStaticProps({ params }) { 61 | const post = await getSinglePost(params.slug) 62 | return { props: { post: post } } 63 | } 64 | -------------------------------------------------------------------------------- /pages/tags/[slug].js: -------------------------------------------------------------------------------- 1 | import { getTags, getSingleTag, getPostsByTag } from '../../api/ghost_data' 2 | import Link from 'next/link' 3 | import Layout from '../../components/layout' 4 | 5 | export default function TagPage(tagData) { 6 | let _title = tagData.meta_title + ' - YOUR BLOG TILE' 7 | let facebook_handle = 'YOUR FACEBOOK HANDLE' 8 | let twitter_handle = '@YOUR TWITTER HANDLE' 9 | let metaObject = { 10 | n_title: _title, 11 | n_description: tagData.tag.meta_description, 12 | n_HandheldFriendly: 'True', 13 | n_canonical_url: '', 14 | p_og_site_name: 'YOUR BLOG TITLE', 15 | p_og_type: 'website', 16 | p_og_description: tagData.tag.meta_description, 17 | p_og_image: tagData.tag.feature_image, 18 | p_article_published_time: '', 19 | p_article_modified_time: '', 20 | p_article_tag: '', 21 | p_article_publisher: 'https://www.facebook.com/' + facebook_handle, 22 | n_twitter_card: 'summary_large_image', 23 | n_twitter_title: _title, 24 | n_twitter_description: tagData.tag.meta_description, 25 | n_twitter_image: tagData.tag.feature_image, 26 | n_twitter_label1: 'Written by', 27 | n_twitter_data1: 'YOUR NAME', 28 | n_twitter_label2: 'Filed under', 29 | n_twitter_data2: '', 30 | n_twitter_site: twitter_handle, 31 | n_twitter_creator: twitter_handle, 32 | n_generator: 'Filipe Matos next.js + Ghost CMS', 33 | } 34 | return ( 35 | 36 |
37 |

38 | Posts tagged with{' '} 39 | {tagData.tag.name} 40 |

41 |
    42 | {tagData.posts.map((post) => ( 43 |
  • 44 |
    45 | 46 |
    47 |

    48 | {post.title} 49 |

    50 | 51 | {/* TODO refactor START */} 52 |

    53 | {new Intl.DateTimeFormat('default', { 54 | year: 'numeric', 55 | month: 'short', 56 | day: 'numeric', 57 | }).format(new Date(post.published_at))} 58 |

    59 | {/* TODO refactor END */} 60 |

    61 | (reading time: {post.reading_time} min.) 62 |

    63 |
    64 | 65 |
    66 |
  • 67 | ))} 68 |
69 |
70 |
71 | ) 72 | } 73 | 74 | export async function getStaticPaths() { 75 | const tags = await getTags() 76 | const paths = tags.map((tag) => ({ 77 | params: { slug: tag.slug }, 78 | })) 79 | return { paths, fallback: false } 80 | } 81 | 82 | // Pass the tag slug over to the "getSingleTag" function 83 | // and retrieve all associated posts 84 | 85 | export async function getStaticProps({ params }) { 86 | const _tag = await getSingleTag(params.slug) 87 | let _posts = (await getPostsByTag(params.slug)).sort((a, b) => { 88 | return a.published_at > b.published_at ? -1 : 1 89 | }) 90 | return { props: { tag: _tag, posts: _posts } } 91 | } 92 | -------------------------------------------------------------------------------- /pages/tags/tagoverview.js: -------------------------------------------------------------------------------- 1 | import { getTags, getSingleTag, getPostsByTag } from '../../api/ghost_data' 2 | import Layout from '../../components/layout' 3 | import Link from 'next/link' 4 | 5 | export default function TagOverview({ tagObjects }) { 6 | let _title = tagObjects.meta_title + ' - YOUR BLOG TILE' 7 | let description = 'Overview of all tags used on my blog articles' 8 | let facebook_handle = 'YOUR FACEBOOK HANDLE' 9 | let twitter_handle = '@YOUR TWITTER HANDLE' 10 | let metaObject = { 11 | n_title: _title, 12 | n_description: description, 13 | n_HandheldFriendly: 'True', 14 | n_canonical_url: 'https://YOURBLOGURL/tags/tagoverview', 15 | p_site_name: 'YOUR BLOG TITLE', 16 | p_og_type: 'website', 17 | p_og_description: description, 18 | p_og_image: '', // TODO: create image for Tag overview 19 | p_article_published_time: '', 20 | p_article_modified_time: '', // 21 | p_article_tag: '', // 22 | p_article_publisher: 'https://www.facebook.com/' + facebook_handle, 23 | n_twitter_card: 'summary_large_image', 24 | n_twitter_title: _title, 25 | n_twitter_description: description, 26 | n_twitter_image: '', // TODO: create image for Tag overview 27 | n_twitter_label1: 'Written by', 28 | n_twitter_data1: 'YOUR NAME', 29 | n_twitter_label2: 'Filed under', 30 | n_twitter_data2: '', 31 | n_twitter_site: twitter_handle, 32 | n_twitter_creator: twitter_handle, 33 | n_generator: 'Filipe Matos next.js + Ghost CMS', 34 | } 35 | return ( 36 | 37 |
38 |

39 | Tag Overview 40 |

41 | {tagObjects.map((tag) => ( 42 |
43 |

44 | {tag.posts.length} post{tag.posts.length > 1 ? 's' : ''} tagged 45 | with {tag.tag.name} 46 |

47 |
    48 | {tag.posts.map((post) => ( 49 |
  • 50 | 51 |
    52 |

    53 | {post.title} 54 |

    55 | {/* TODO refactor START */} 56 |

    57 | {new Intl.DateTimeFormat('default', { 58 | year: 'numeric', 59 | month: 'short', 60 | day: 'numeric', 61 | }).format(new Date(post.published_at))} 62 |

    63 | {/* TODO refactor END */} 64 |
    65 | 66 |
  • 67 | ))} 68 |
69 |
70 | ))} 71 |
72 |
73 | ) 74 | } 75 | 76 | export async function getStaticProps() { 77 | let tagObjects = [] 78 | 79 | for (const _tag of await getTags()) { 80 | let _posts = (await getPostsByTag(_tag.slug)).sort((a, b) => { 81 | return a.published_at > b.published_at ? -1 : 1 82 | return 0 83 | }) 84 | 85 | tagObjects.push({ 86 | tag: _tag, 87 | posts: _posts, 88 | }) 89 | } 90 | 91 | return { 92 | props: { tagObjects }, 93 | } 94 | } 95 | -------------------------------------------------------------------------------- /postcss.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | plugins: { 3 | tailwindcss: {}, 4 | autoprefixer: {}, 5 | }, 6 | } 7 | -------------------------------------------------------------------------------- /public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/GitHubFilipe/nextjs-ghost-tutorial/0c6e48a6f1e3532cb8a4ef00afeb9ea6e77cdeca/public/favicon.ico -------------------------------------------------------------------------------- /public/github_socialpreview_nextjs-ghost-tutorial_1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/GitHubFilipe/nextjs-ghost-tutorial/0c6e48a6f1e3532cb8a4ef00afeb9ea6e77cdeca/public/github_socialpreview_nextjs-ghost-tutorial_1.png -------------------------------------------------------------------------------- /public/instagram.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/GitHubFilipe/nextjs-ghost-tutorial/0c6e48a6f1e3532cb8a4ef00afeb9ea6e77cdeca/public/instagram.png -------------------------------------------------------------------------------- /public/profilepic.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/GitHubFilipe/nextjs-ghost-tutorial/0c6e48a6f1e3532cb8a4ef00afeb9ea6e77cdeca/public/profilepic.jpg -------------------------------------------------------------------------------- /public/robots.txt: -------------------------------------------------------------------------------- 1 | +User-agent: * 2 | +Disallow: 3 | +Sitemap: https://yoururlhere/sitemap.xml -------------------------------------------------------------------------------- /public/twitter.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/GitHubFilipe/nextjs-ghost-tutorial/0c6e48a6f1e3532cb8a4ef00afeb9ea6e77cdeca/public/twitter.png -------------------------------------------------------------------------------- /scripts/generate-sitemap-postbuild.js: -------------------------------------------------------------------------------- 1 | // File: /scripts/generate-sitemap-postbuild.js 2 | 3 | const fs = require('fs') 4 | const path = require('path') 5 | 6 | function isPageFile(filename) { 7 | return path.extname(filename) === '.html' && !filename.endsWith('404.html') 8 | } 9 | 10 | function getPageFiles(folders, files = []) { 11 | folders.map((folder) => { 12 | const entries = fs.readdirSync(folder, { withFileTypes: true }) 13 | entries.forEach((entry) => { 14 | const absolutePath = path.resolve(folder, entry.name) 15 | if (entry.isDirectory()) { 16 | getPageFiles(absolutePath, files) 17 | } else if (isPageFile(absolutePath)) { 18 | files.push(absolutePath) 19 | } 20 | }) 21 | }) 22 | return files 23 | } 24 | 25 | function buildSiteMap(websiteUrl, outDirectory, pageFiles) { 26 | const urls = pageFiles.map((file) => { 27 | let f = file.split('/') 28 | let folder = file.split('/')[f.length - 2] 29 | return websiteUrl + '/' + folder + '/' + path.parse(file).name 30 | }) // Hack: add index.html manually (adding it in the "folders" array isn't working) 31 | urls.push(websiteUrl + '/') 32 | const sitemap = ` 33 | 34 | 35 | ${urls 36 | .map( 37 | (url) => ` 38 |      39 |       ${url} 40 |      41 |     `, 42 | ) 43 | .join('')} 44 | ` 45 | 46 | // write to the output static folder 47 | fs.writeFileSync(path.join(outDirectory, 'sitemap.xml'), sitemap) 48 | } 49 | 50 | function main() { 51 | const websiteUrl = 'https://YOUR_URL_HERE ' 52 | const baseDirectory = './.next/server/pages' 53 | const outDirectory = './out/' 54 | const folders = [ 55 | baseDirectory + '/blogpages', 56 | baseDirectory + '/posts', 57 | baseDirectory + '/tags', 58 | ] 59 | 60 | const pageFiles = getPageFiles(folders) 61 | buildSiteMap(websiteUrl, outDirectory, pageFiles) 62 | } 63 | 64 | main() 65 | -------------------------------------------------------------------------------- /styles/Home.module.css: -------------------------------------------------------------------------------- 1 | /* file: /styles/Home.module.css */ 2 | 3 | h2.caption { 4 | color: darkviolet; 5 | margin-left: 20px; 6 | text-decoration: underline; 7 | } 8 | -------------------------------------------------------------------------------- /styles/blogInnerHtml.css: -------------------------------------------------------------------------------- 1 | .blogInnerHTML { 2 | @apply w-11/12 py-8; 3 | } 4 | 5 | .blogInnerHTML h1 { 6 | @apply text-3xl text-gray-600 font-bold py-4; 7 | } 8 | 9 | .blogInnerHTML h2 { 10 | @apply text-2xl text-gray-600 font-bold py-4; 11 | } 12 | 13 | .blogInnerHTML h3 { 14 | @apply text-xl text-gray-600 font-bold py-4; 15 | } 16 | 17 | .blogInnerHTML p { 18 | @apply text-lg text-gray-700 py-px; 19 | } 20 | 21 | .blogInnerHTML ul, 22 | ol { 23 | @apply my-4; 24 | } 25 | 26 | .blogInnerHTML ol li { 27 | @apply list-decimal ml-10; 28 | } 29 | 30 | .blogInnerHTML ul li { 31 | @apply ml-10; 32 | list-style-type: square; 33 | } 34 | 35 | pre { 36 | white-space: pre-wrap; /* Since CSS 2.1 */ 37 | white-space: -moz-pre-wrap; /* Mozilla, since 1999 */ 38 | white-space: -pre-wrap; /* Opera 4-6 */ 39 | white-space: -o-pre-wrap; /* Opera 7 */ 40 | word-wrap: break-word; /* Internet Explorer 5.5+ */ 41 | } 42 | 43 | .blogInnerHTML pre { 44 | @apply bg-gray-700 p-4 my-8 rounded-lg; 45 | } 46 | 47 | .blogInnerHTML code.language-javascript { 48 | @apply text-yellow-200 text-sm; 49 | } 50 | 51 | .blogInnerHTML a { 52 | @apply text-blue-700 font-light italic; 53 | } 54 | 55 | .blogInnerHTML img { 56 | @apply my-8; 57 | } 58 | 59 | .blogInnerHTML blockquote p { 60 | @apply bg-blue-100 text-blue-800 font-serif italic p-2 my-8 rounded-md; 61 | } 62 | 63 | .blogInnerHTML blockquote p::before { 64 | @apply text-2xl text-blue-600 font-extrabold font-serif mx-2; 65 | content: '\0022'; 66 | } 67 | 68 | .blogInnerHTML blockquote p::after { 69 | @apply text-2xl text-blue-600 font-extrabold font-serif mx-2; 70 | content: '\0022'; 71 | } 72 | -------------------------------------------------------------------------------- /styles/globalstyles.css: -------------------------------------------------------------------------------- 1 | h1 { 2 | color: red; 3 | } 4 | -------------------------------------------------------------------------------- /styles/tailwind.css: -------------------------------------------------------------------------------- 1 | /* /styles/tailwind.css */ 2 | 3 | @tailwind base; 4 | @tailwind components; 5 | @tailwind utilities; 6 | -------------------------------------------------------------------------------- /tailwind.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | purge: ['./pages/**/*.js', './components/**/*.js'], 3 | darkMode: false, // or 'media' or 'class' 4 | theme: { 5 | extend: {}, 6 | }, 7 | variants: { 8 | extend: {}, 9 | }, 10 | plugins: [], 11 | } 12 | --------------------------------------------------------------------------------