├── .eslintrc.json
├── .gitignore
├── .prettierrc
├── README.md
├── _authors
└── makerkit.json
├── _collections
└── lorem-ipsum.json
├── _posts
├── dextera-sibi-orbes.mdx
└── lorem-reddita.mdx
├── components
├── Author.tsx
├── ClientOnly.tsx
├── CollectionLink.tsx
├── DarkModeToggle.tsx
├── DateFormatter.tsx
├── Footer.tsx
├── Header.tsx
├── LayoutContainer.tsx
├── Logo.tsx
├── MDXComponents.tsx
├── MDXRenderer.tsx
├── Meta.tsx
├── Post.tsx
├── PostBody.module.css
├── PostBody.tsx
├── PostHeader.tsx
├── PostImage.tsx
├── PostMetadata.tsx
├── PostPreview.tsx
├── PostTitle.tsx
├── PostsList.tsx
└── ThemeProvider.tsx
├── configuration.ts
├── global.d.ts
├── lib
├── blog
│ ├── api.ts
│ ├── author.ts
│ ├── blog-post.ts
│ ├── collection.ts
│ ├── compile-mdx.ts
│ └── rss-feed.ts
├── is-browser.ts
└── theme.ts
├── next-env.d.ts
├── next-sitemap.config.js
├── next.config.js
├── package-lock.json
├── package.json
├── pages
├── [collection]
│ └── [slug].tsx
├── _app.tsx
└── index.tsx
├── postcss.config.js
├── public
└── favicon.ico
├── styles
├── Home.module.css
├── github-dark.css
└── globals.css
├── tailwind.config.js
└── tsconfig.json
/.eslintrc.json:
--------------------------------------------------------------------------------
1 | {
2 | "extends": "next/core-web-vitals"
3 | }
4 |
--------------------------------------------------------------------------------
/.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 | .pnpm-debug.log*
27 |
28 | # local env files
29 | .env*.local
30 |
31 | # vercel
32 | .vercel
33 |
34 | # typescript
35 | *.tsbuildinfo
36 | .idea
37 |
38 | # generated
39 | public/rss.xml
40 | public/atom.xml
41 | public/rss.json
42 | public/robots.txt
43 | public/sitemap*.xml
44 |
--------------------------------------------------------------------------------
/.prettierrc:
--------------------------------------------------------------------------------
1 | {
2 | "tabWidth": 2,
3 | "useTabs": false,
4 | "semi": true,
5 | "arrowParens": "always",
6 | "parser": "typescript",
7 | "printWidth": 80,
8 | "singleQuote": true
9 | }
10 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | A free and open-source starter by MakerKit.
2 |
3 | # Blog Starter Template with Next.js, MDX and Tailwind CSS
4 |
5 | This is a [Next.js](https://nextjs.org/) project bootstrapped with [`create-next-app`](https://github.com/vercel/next.js/tree/canary/packages/create-next-app).
6 |
7 | This blog starter is the perfect foundation for writing your next blog, portfolio or online publication using Next.js and Tailwind CSS.
8 |
9 | ## Features
10 |
11 | - ✅ **Fully responsive Blog/Portfolio Site**
12 | - 📄 **Write your articles with all the power of MDX**
13 | - ⚡ **Core Web Vitals = 100**
14 | - 🚀 **Search Engine Optimized (SEO) out-of-the-box**
15 | - 📂 **Sitemap and RSS generated automatically**
16 | - ✨ **Written with strict Typescript, validated with EsLint, formatted with Prettier**
17 |
18 | ## Getting Started
19 |
20 | Clone the repository:
21 |
22 | ```
23 | git clone https://github.com/makerkit/next-blog-kit
24 | ```
25 |
26 | Rename your project and jump into the folder. Then, run the development server:
27 |
28 | ```bash
29 | npm run dev
30 | # or
31 | yarn dev
32 | ```
33 |
34 | Open [http://localhost:3000](http://localhost:3000) with your browser to see the result.
35 |
36 | ### Setting the upstream folder
37 |
38 | If you want, reinitialize the git repository and set this repository as your upstream, so you can continue getting updates:
39 |
40 | ```
41 | rm -rf .git
42 | git init
43 | git remote add upstream https://github.com/makerkit/next-blog-kit
44 | ```
45 |
46 | To keep your repository up-to-date with this, use `git pull`:
47 |
48 | ```
49 | git pull upstream main
50 | ```
51 |
52 | Solve the eventual conflicts and merge 😃
53 |
54 | ### Configuration
55 |
56 | Open the configuration file at `./configuration.ts`. It will have the following content:
57 |
58 | ```tsx
59 | const configuration = {
60 | site: {
61 | name: '',
62 | description: '',
63 | themeColor: '',
64 | siteUrl: '',
65 | siteName: '',
66 | twitterHandle: '',
67 | githubHandle: '',
68 | language: 'en',
69 | },
70 | blog: {
71 | maxReadMorePosts: 6,
72 | },
73 | production: process.env.NODE_ENV === 'production',
74 | };
75 | ```
76 |
77 | Update it with your own data, or leave as is to begin with.
78 |
79 | ### Add Articles, Collections and Authors
80 |
81 | Before creating a blog post, we define which collection it belongs to and the author of the post.
82 |
83 | To define a collection, create a JSON file at `_collctions/`:
84 |
85 | ```json
86 | {
87 | "name": "Tutorials",
88 | "emoji": "🖥️"
89 | }
90 | ```
91 |
92 | Alternatively, you can choose to assign a picture to each collection (or neither):
93 |
94 | ```json
95 | {
96 | "name": "Tutorials",
97 | "picture": "/assets/images/tutorials.png"
98 | }
99 | ```
100 |
101 | Next, we need to add the author of the article. Add a JSON file at `_authors`:
102 |
103 | ```json
104 | {
105 | "name": "MakerKit",
106 | "picture": "/assets/images/makerkit.png",
107 | "url": "https://twitter.com/makerkit_dev"
108 | }
109 | ```
110 |
111 | We can now create a blog post. Add an MDX file at `_posts`:
112 |
113 | ```yaml
114 | ---
115 | title: 'Dextera Sibi Orbes'
116 | collection: '_collections/lorem-ipsum.json'
117 | author: '_authors/makerkit.json'
118 | date: 2022-03-30
119 | live: true
120 | image: ''
121 | description: "Lorem markdownum ictu; leti quae, paenituisse venere. Liquet praemia omne di
122 | amarunt dicta."
123 | ---
124 | ```
125 |
126 | As you can see, the properties `collection` and `author` are references to the path of each.s
127 |
128 | ## Building this from scratch
129 |
130 | For mor information about building this codebase from scratch, take a look at the article [Create an MDX-powered Blog with Next.js](https://makerkit.dev/blog/tutorials/create-a-blog-mdx-nextjs).
131 |
132 | ## Deploy on Vercel
133 |
134 | The easiest way to deploy this app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js.
135 |
136 | Check out our [Next.js deployment documentation](https://nextjs.org/docs/deployment) for more details.
137 |
--------------------------------------------------------------------------------
/_authors/makerkit.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "MakerKit",
3 | "picture": "",
4 | "url": "https://twitter.com/makerkit_dev"
5 | }
6 |
--------------------------------------------------------------------------------
/_collections/lorem-ipsum.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "Lorem Ipsum",
3 | "emoji": "🖥️"
4 | }
5 |
--------------------------------------------------------------------------------
/_posts/dextera-sibi-orbes.mdx:
--------------------------------------------------------------------------------
1 | ---
2 | title: 'Dextera Sibi Orbes'
3 | collection: '_collections/lorem-ipsum.json'
4 | author: '_authors/makerkit.json'
5 | date: 2022-03-30
6 | live: true
7 | image: 'https://images.unsplash.com/photo-1604931668626-ab49cb27d952?ixlib=rb-1.2.1&ixid=MnwxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8&auto=format&fit=crop&w=2370&q=80'
8 | description: "Lorem markdownum ictu; leti quae, paenituisse venere. Liquet praemia omne di
9 | amarunt dicta."
10 | ---
11 |
12 | ## Digna Plexippi argento dixerunt
13 |
14 | Lorem markdownum ictu; leti quae, paenituisse venere. Liquet praemia omne di
15 | amarunt dicta.
16 |
17 | Tarda tantorum magnum, **mea**. Dilecta requirenti florebat, nitenti potuit
18 | victa; sunt que corpusque, ausis, et esse *dubia*. Sedemque terque accede,
19 | caelum! Nec ferro quid donec et praebetque nympha, **ut adfata** si pressum
20 | precatur. Nymphae anxia relatus cumque, patrio, *cum* et, fraudem praemiaque
21 | ratis nam *duris praesagia addere* dies.
22 |
23 | ```
24 | pcbOf = 530359;
25 | im_mbr(2);
26 | if (lagBacklink - 37 != 4) {
27 | processor = sdslCloneOn + hard_nui;
28 | }
29 | ```
30 |
31 | Sulcis urbem inulta in **minata** en litus; solebat **depulerat at** quaedam
32 | fluctus aequoris. Flamma dilatant equos cum placuit nec, si capitis recusat de
33 | erat edere: Italiam frondere. Cervice maxima adporrectumque huc datis Euboicus:
34 | iactura pudibundaque danti? Fera ducebat; perpetuas veteris. Amat sanguine,
35 | luctus dextra.
36 |
37 | ## Solutum scelerique ignarus vincere Troasque mihi
38 |
39 | Poste [secutum pietas](http://prohibetedecipienda.org/parstrepidaeque) ne
40 | supplex nefas memorantur proceres duce exemplum animoque esset. Toro decuit,
41 | filia annos tura salicta, cognoscit dotem deterritus miraturus animi comitemque
42 | auxiliaribus.
43 |
44 | > Quodque freta, generis plurimus merito destrinxit, redunco sollicitive cruento
45 | > potentia, aera dum nectare **requirit si potes**. Tegendas **et violaverat
46 | > tempus**?
47 |
48 | Pete in captantur vulnere trabibus excipit Terram te cara arserunt pugnabo.
49 | Matura in Baccho fulvis tollere. Viaeque sua ultoresque saepe successisse
50 | nubibus orbe bello, aliis domestice omnia negare. Et pone posito defensore
51 | fuisset quos veneni illa? Moto parte forti patriosque postquam facit pennisque
52 | ventos, functi, mihi classem honores?
53 |
54 | Carne quae terra Sunt nec ex naides votisque finemque turpe, Hersilien neque
55 | volentem mentes trabes temptat oro sublimis. Quae hoc ait nos concretaque: illos
56 | Iphis pariterque coniugis flenda. Raptam adversaque, nec petit quo [speciem
57 | coepit et](http://www.ignes-in.org/munere) angulus [Caune fons
58 | adesse](http://www.animoque-hoc.net/orbae-aegre.html) spes, et dicitur de?
59 |
60 | Humus vertice parentis corrigit [ruricolae
61 | unus](http://auroraefonti.net/habet-nebulas)? Dum tellure hac: Iovique statione
62 | repressit gemmae inficit ductum. Dederunt mirabile non probat portus nullo, est
63 | ulta tenet, quoque currus, ponentem ora nomen lacrimis nostris. Gravis pecoris
64 | velut: haec sive *angulus* illi bella iuventus.
65 |
--------------------------------------------------------------------------------
/_posts/lorem-reddita.mdx:
--------------------------------------------------------------------------------
1 | ---
2 | title: 'Lorem Reddita'
3 | collection: '_collections/lorem-ipsum.json'
4 | author: '_authors/makerkit.json'
5 | date: 2022-03-30
6 | live: true
7 | image: 'https://images.unsplash.com/photo-1519879110616-349b57f8cd11?ixlib=rb-1.2.1&ixid=MnwxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8&auto=format&fit=crop&w=2370&q=80'
8 | description: "Emi non revolvor opem exitus vires, ad ante regno, unus. Iter
9 | meo venae finierat, nec neu ense lassaque Phoeboque probabit Iapygis"
10 | ---
11 |
12 | ## Tectis nectare tamen veni Pandrose
13 |
14 | Lorem [markdownum urbem iste](http://et.com/) reddita. Videt Iuppiter
15 | [inpavidus](http://www.adunco.net/omniauncis) temone versus. Aliquid **rupit**
16 | ita noctem Cipi: emi non revolvor opem exitus vires, ad ante regno, unus. Iter
17 | meo venae finierat, nec neu ense lassaque Phoeboque probabit Iapygis et humo
18 | penetrant. Venerem sistitur ingreditur te laesi vacet confer, prodit Ixion
19 | socios moriensque cornua, [et edidit eadem](http://www.caelovirga.org/).
20 |
21 | Rupes mora voce erant brevissimus aras, sed vocat de umero patriam quod. Non
22 | pecudes terraque in nostris fistula supposita neve inluxisse rapta, igitur,
23 | Iphis dei pendent pallebant tigres. Cum iter fallere lanient ferumque percepto
24 | restabat armis illa abesse tecta cum coniunx aut. Arma facundus, et eam volat et
25 | e canis montis sed suae caput isto Cecropidis senatum.
26 |
27 | ## Non o
28 |
29 | Crinem caduca externis extrema urbis illum nomen, non voce superare sacri
30 | *monstris Phoebus* vocesque. Sustinet talibus memorabile stat; **Phaethon ea
31 | rursus** proculcat tepere culpae. [Ait superis](http://divulsere.org/) nec illi
32 | cum demittere flumine petis subtexere mirantem manu luce facunde nec ille
33 | finita. Perque viri non [ipsa](http://inguinibus.org/iuris-bis), Iovis, positu
34 | pendebat *crimine*; victima culmine.
35 |
36 | ## Pia Lucifer nulla vota conferat quid parentis
37 |
38 | Morphea Boeotia annua prole Romana opibus senilis hastae Iunone Pittheia orantem
39 | ipsa certumque taurorum. Patrios subponunt defrenato Apis **fugis et** coniugis,
40 | contemptor quamque ait; sub pastores; male nostros. Quam dedit laborum, in domo
41 | sancta quo, artus virgineis aequoris Ceres. **Eadem haec**, toto aquae, erat
42 | Mycenae, vela capi sonitusque umbras; in. Vos erant corpore iam dixit stamina
43 | miremur virgamque coniugis festa nomine: fecerat.
44 |
45 | 1. Sic lapides io factoque Peneidas ignis pharetras
46 | 2. Et esse quaerit vinctum montibus qui vulnera
47 | 3. Fetus spumaque referam fuisset virgo palluit quotiens
48 | 4. Tam fui quem quoque
49 | 5. Et cremat tauri sit ferro bacchae quem
50 | 6. Diffudere plurima non haberet rediit praecipites nulla
51 |
52 | ## Quod mecum vestis commissas os ipsos pavido
53 |
54 | Laceraret se munera vestibus Achivi. Duros dicta, montis vidit. Graves dixit,
55 | sua dixit Desine Ilion catulus inerti: hac. Est triennia collum, preces terga.
56 | Impia [sine sic](http://www.me.io/ne-adligat.html) aeterna rigidi.
57 |
58 | Cum quo ubi requiemque vastat, altrice dexterior sum o. Athenas adnuit: media
59 | cum; Nixosque est vagantes profanam.
60 |
--------------------------------------------------------------------------------
/components/Author.tsx:
--------------------------------------------------------------------------------
1 | import Image from 'next/image';
2 | import AuthorType from '~/lib/blog/author';
3 |
4 | const Author: React.FCC<{ author: AuthorType }> = ({ author }) => {
5 | const alt = `${author.name}'s picture`;
6 | const imageSize = `45px`;
7 |
8 | return (
9 |
23 | );
24 | };
25 |
26 | export default Author;
27 |
--------------------------------------------------------------------------------
/components/ClientOnly.tsx:
--------------------------------------------------------------------------------
1 | import { isBrowser } from '~/lib/is-browser';
2 |
3 | const ClientOnly: React.FCC = ({ children }) => {
4 | return isBrowser() ? <>{children}> : null;
5 | };
6 |
7 | export default ClientOnly;
8 |
--------------------------------------------------------------------------------
/components/CollectionLink.tsx:
--------------------------------------------------------------------------------
1 | import Link from 'next/link';
2 | import Collection from '~/lib/blog/collection';
3 |
4 | const CollectionLink: React.FCC<{
5 | collection: Collection;
6 | }> = ({ collection }) => {
7 | const href = `/${collection.slug}`;
8 |
9 | return (
10 |
15 | );
16 | };
17 |
18 | export default CollectionLink;
19 |
--------------------------------------------------------------------------------
/components/DarkModeToggle.tsx:
--------------------------------------------------------------------------------
1 | import { useCallback, useRef } from 'react';
2 |
3 | import {
4 | loadThemeFromLocalStorage,
5 | setTheme,
6 | DARK_THEME_CLASSNAME,
7 | } from '~/lib/theme';
8 |
9 | const DarkModeToggle = () => {
10 | const theme = useRef(loadThemeFromLocalStorage());
11 |
12 | const toggleMode = useCallback(() => {
13 | const themeClass = theme.current ? null : DARK_THEME_CLASSNAME;
14 |
15 | theme.current = themeClass;
16 | setTheme(themeClass);
17 | }, []);
18 |
19 | return (
20 |
23 | );
24 | };
25 |
26 | export default DarkModeToggle;
27 |
28 | function ThemeIcon() {
29 | return (
30 |
41 | );
42 | }
43 |
--------------------------------------------------------------------------------
/components/DateFormatter.tsx:
--------------------------------------------------------------------------------
1 | import { parseISO, format } from 'date-fns';
2 |
3 | const DateFormatter: React.FCC<{
4 | dateString: string;
5 | }> = ({ dateString }) => {
6 | const date = parseISO(dateString);
7 |
8 | return ;
9 | };
10 |
11 | export default DateFormatter;
12 |
--------------------------------------------------------------------------------
/components/Footer.tsx:
--------------------------------------------------------------------------------
1 | const Footer: React.FCC = () => {
2 | return Makerkit - {new Date().getFullYear()}
;
3 | };
4 |
5 | export default Footer;
6 |
--------------------------------------------------------------------------------
/components/Header.tsx:
--------------------------------------------------------------------------------
1 | import dynamic from 'next/dynamic';
2 | import Logo from '~/components/Logo';
3 |
4 | const DarkModeToggle = dynamic(() => import('~/components/DarkModeToggle'), {
5 | ssr: false,
6 | });
7 |
8 | const Header: React.FCC = () => {
9 | return (
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 | );
18 | };
19 |
20 | export default Header;
21 |
--------------------------------------------------------------------------------
/components/LayoutContainer.tsx:
--------------------------------------------------------------------------------
1 | const LayoutContainer: React.FCC = ({ children }) => {
2 | return {children}
;
3 | };
4 |
5 | export default LayoutContainer;
6 |
--------------------------------------------------------------------------------
/components/Logo.tsx:
--------------------------------------------------------------------------------
1 | import Link from 'next/link';
2 |
3 | const Logo: React.FCC = () => {
4 | return (
5 |
10 | );
11 | };
12 |
13 | export default Logo;
14 |
--------------------------------------------------------------------------------
/components/MDXComponents.tsx:
--------------------------------------------------------------------------------
1 | import Image from 'next/image';
2 |
3 | import configuration from '~/configuration';
4 | import PostsList from './PostsList';
5 | import ClientOnly from '~/components/ClientOnly';
6 |
7 | type ImageLayout = 'fixed' | 'fill' | 'intrinsic' | 'responsive' | undefined;
8 | type StringObject = Record;
9 |
10 | const NextImage: React.FCC = (props: StringObject) => {
11 | const width = props.width ?? '4';
12 | const height = props.height ?? '3';
13 |
14 | return (
15 |
24 | );
25 | };
26 |
27 | const ExternalLink: React.FCC<{ href: string }> = ({ href, children }) => {
28 | const siteUrl = configuration.site.siteUrl ?? '';
29 | const isRoot = href[0] === '/';
30 | const isInternalLink = href.startsWith(siteUrl) || isRoot;
31 |
32 | if (isInternalLink) {
33 | return {children};
34 | }
35 |
36 | return (
37 |
38 | {children}
39 |
40 | );
41 | };
42 |
43 | const Video: React.FCC<{
44 | src: string;
45 | width?: string;
46 | type?: string;
47 | }> = ({ src, type, width }) => {
48 | const useType = type ?? 'video/mp4';
49 |
50 | return (
51 |
52 |
63 |
64 | );
65 | };
66 |
67 | const MDXComponents = {
68 | img: NextImage,
69 | a: ExternalLink,
70 | PostsList,
71 | Video,
72 | Image: NextImage,
73 | };
74 |
75 | export default MDXComponents;
76 |
--------------------------------------------------------------------------------
/components/MDXRenderer.tsx:
--------------------------------------------------------------------------------
1 | import * as runtime from 'react/jsx-runtime';
2 | import { runSync } from '@mdx-js/mdx';
3 | import MDXComponents from '~/components/MDXComponents';
4 |
5 | type MdxComponent = React.ExoticComponent<{
6 | components: Record;
7 | }>;
8 |
9 | function MDXRenderer({ code }: { code: string }) {
10 | const { default: MdxModuleComponent } = runSync(code, runtime) as {
11 | default: MdxComponent;
12 | };
13 |
14 | return ;
15 | }
16 |
17 | export default MDXRenderer;
18 |
--------------------------------------------------------------------------------
/components/Meta.tsx:
--------------------------------------------------------------------------------
1 | import Head from 'next/head';
2 | import configuration from '~/configuration';
3 |
4 | const Meta = () => {
5 | const siteUrl = configuration.site.siteUrl;
6 |
7 | if (!siteUrl) {
8 | throw new Error(`Please add the property siteUrl in the configuration`);
9 | }
10 |
11 | const structuredData = {
12 | name: configuration.site.name,
13 | url: configuration.site.siteUrl,
14 | logo: `${configuration.site.siteUrl}/assets/images/favicon/favicon-150x150.png`,
15 | '@context': 'https://schema.org',
16 | '@type': 'Organization', // change to person for Personal websites
17 | };
18 |
19 | return (
20 |
21 |
26 |
27 |
33 |
34 |
40 |
41 |
47 |
48 |
49 |
50 |
55 |
56 |
57 |
58 |
63 |
64 |
65 |
66 |
67 |
68 |
69 |
70 |
75 |
76 | {configuration.site.name}
77 |
78 |
83 |
84 |
85 |
86 |
91 |
92 |
97 |
98 |
99 |
100 |
105 |
106 |
110 |
111 |
116 |
117 | );
118 | };
119 |
120 | export default Meta;
121 |
--------------------------------------------------------------------------------
/components/Post.tsx:
--------------------------------------------------------------------------------
1 | import BlogPost from '~/lib/blog/blog-post';
2 | import PostHeader from '~/components/PostHeader';
3 | import PostBody from '~/components/PostBody';
4 |
5 | const Post: React.FCC<{
6 | post: BlogPost;
7 | content: string;
8 | }> = ({ post, content }) => {
9 | return (
10 |
11 |
12 |
13 |
14 |
17 |
18 |
19 | );
20 | };
21 |
22 | export default Post;
23 |
--------------------------------------------------------------------------------
/components/PostBody.module.css:
--------------------------------------------------------------------------------
1 | .PostBody h1 {
2 | @apply font-extrabold text-4xl mt-10;
3 | }
4 |
5 | .PostBody h2 {
6 | @apply font-extrabold text-3xl mt-8;
7 | }
8 |
9 | .PostBody h3 {
10 | @apply font-bold text-2xl mt-6;
11 | }
12 |
13 | .PostBody h4 {
14 | @apply font-bold text-xl mt-4;
15 | }
16 |
17 | .PostBody h5 {
18 | @apply font-medium text-lg mt-2;
19 | }
20 |
21 | .PostBody h6 {
22 | @apply font-medium text-base;
23 | }
24 | /**
25 | Tailwind "dark" variants do not work with CSS Modules
26 | We work it around using :global(.dark)
27 | For more info: https://github.com/tailwindlabs/tailwindcss/issues/3258#issuecomment-770215347
28 | */
29 | :global(.dark) .PostBody h1,
30 | :global(.dark) .PostBody h2,
31 | :global(.dark) .PostBody h3,
32 | :global(.dark) .PostBody h4,
33 | :global(.dark) .PostBody h5,
34 | :global(.dark) .PostBody h6 {
35 | @apply text-white;
36 | }
37 |
38 | .PostBody > p {
39 | @apply text-lg mt-4 mb-6;
40 | }
41 |
42 | .PostBody li {
43 | @apply text-lg my-2;
44 | }
45 |
46 | :global(.dark) .PostBody > p,
47 | :global(.dark) .PostBody li {
48 | @apply text-gray-200;
49 | }
50 |
51 | .PostBody b,
52 | .PostBody strong {
53 | @apply font-bold;
54 | }
55 |
56 | .PostBody pre {
57 | @apply mb-8 text-sm text-current;
58 | }
59 |
60 | :global(.dark) .PostBody pre {
61 | @apply bg-black-300 shadow-none;
62 | }
63 |
64 | .PostBody img,
65 | .PostBody video {
66 | @apply rounded-lg;
67 | }
68 |
69 | .PostBody ul {
70 | list-style: circle;
71 |
72 | @apply pl-6 pb-2 mb-2;
73 | }
74 |
75 | .PostBody ol {
76 | list-style: decimal;
77 |
78 | @apply pl-6 pb-2 mb-2;
79 | }
80 |
81 | .PostBody blockquote {
82 | @apply my-12 p-8 shadow-xl
83 | font-medium font-sans dark:text-gray-300
84 | border-l-[8px] border-primary-500;
85 | }
86 |
87 | .PostBody pre {
88 | @apply my-2 rounded-3xl;
89 | }
90 |
91 | .PostBody code {
92 | @apply rounded-lg;
93 |
94 | word-break: break-word;
95 | }
96 |
97 | .PostBody pre > code {
98 | @apply p-3 md:p-5 text-xs md:text-sm font-medium font-monospace block;
99 |
100 | white-space: pre-wrap;
101 | }
102 |
103 | :global(.dark) .PostBody code {
104 | @apply bg-black-400;
105 | }
106 |
107 | .PostBody p > code,
108 | .PostBody li > code {
109 | @apply px-1 py-1 text-sm rounded-md;
110 | }
111 |
112 | :global(.dark) .PostBody p > code,
113 | :global(.dark) .PostBody li > code {
114 | @apply bg-black-300;
115 | }
116 |
117 | .PostBody hr {
118 | @apply mt-8 mb-6;
119 | }
120 |
121 | .PostBody p > a {
122 | @apply font-medium underline;
123 | }
124 |
125 | :global(.dark) .PostBody p > a {
126 | @apply text-primary-500;
127 | }
128 |
--------------------------------------------------------------------------------
/components/PostBody.tsx:
--------------------------------------------------------------------------------
1 | import styles from './PostBody.module.css';
2 | import MDXRenderer from '~/components/MDXRenderer';
3 |
4 | const PostBody: React.FCC<{ content: string }> = ({ content }) => {
5 | return (
6 |
7 |
8 |
9 | );
10 | };
11 |
12 | export default PostBody;
13 |
--------------------------------------------------------------------------------
/components/PostHeader.tsx:
--------------------------------------------------------------------------------
1 | import BlogPost from '~/lib/blog/blog-post';
2 | import PostTitle from '~/components/PostTitle';
3 | import PostImage from '~/components/PostImage';
4 | import PostMetadata from '~/components/PostMetadata';
5 | import CollectionLink from '~/components/CollectionLink';
6 |
7 | const PostHeader: React.FCC<{
8 | post: BlogPost;
9 | }> = ({ post }) => {
10 | return (
11 | <>
12 |
13 |
14 |
15 |
16 | {post.title}
17 |
18 |
19 | {post.description}
20 |
21 |
22 |
25 |
26 |
34 | >
35 | );
36 | };
37 |
38 | export default PostHeader;
39 |
--------------------------------------------------------------------------------
/components/PostImage.tsx:
--------------------------------------------------------------------------------
1 | import Image from 'next/image';
2 |
3 | type Props = {
4 | title: string;
5 | src: string;
6 | preloadImage?: boolean;
7 | width?: number;
8 | height?: number;
9 | className?: string;
10 | };
11 |
12 | const PostImage = ({
13 | title,
14 | src,
15 | width,
16 | height,
17 | className,
18 | preloadImage,
19 | }: Props) => {
20 | return (
21 |
30 | );
31 | };
32 |
33 | export default PostImage;
34 |
--------------------------------------------------------------------------------
/components/PostMetadata.tsx:
--------------------------------------------------------------------------------
1 | import Author from '~/components/Author';
2 | import DateFormatter from '~/components/DateFormatter';
3 | import BlogPost from '~/lib/blog/blog-post';
4 |
5 | const PostMetadata: React.FCC<{
6 | post: BlogPost;
7 | }> = ({ post }) => {
8 | const { date, readingTime } = post;
9 |
10 | return (
11 |
12 |
13 |
14 |
·
15 |
16 |
17 |
18 |
19 |
20 |
·
21 |
{readingTime} min. read
22 |
23 | );
24 | };
25 |
26 | export default PostMetadata;
27 |
--------------------------------------------------------------------------------
/components/PostPreview.tsx:
--------------------------------------------------------------------------------
1 | import Link from 'next/link';
2 | import BlogPost from '~/lib/blog/blog-post';
3 | import PostImage from '~/components/PostImage';
4 | import PostMetadata from '~/components/PostMetadata';
5 |
6 | const PostPreview: React.FCC<{
7 | post: BlogPost;
8 | }> = ({ post }) => {
9 | const hrefAs = `/${post.collection.slug}/${post.slug}`;
10 | const href = `/[collection]/[slug]`;
11 |
12 | return (
13 |
16 |
23 |
24 |
27 |
28 |
29 | {post.title}
30 |
31 |
32 |
33 |
34 |
35 |
42 |
43 |
44 | );
45 | };
46 |
47 | export default PostPreview;
48 |
--------------------------------------------------------------------------------
/components/PostTitle.tsx:
--------------------------------------------------------------------------------
1 | const PostTitle: React.FCC = ({ children }) => {
2 | return (
3 |
8 | {children}
9 |
10 | );
11 | };
12 |
13 | export default PostTitle;
14 |
--------------------------------------------------------------------------------
/components/PostsList.tsx:
--------------------------------------------------------------------------------
1 | import BlogPost from '~/lib/blog/blog-post';
2 | import PostPreview from '~/components/PostPreview';
3 |
4 | const PostsList: React.FCC<{
5 | posts: BlogPost[];
6 | }> = ({ posts }) => {
7 | return (
8 |
9 | {posts.map((post) => {
10 | return
;
11 | })}
12 |
13 | );
14 | };
15 |
16 | export default PostsList;
17 |
--------------------------------------------------------------------------------
/components/ThemeProvider.tsx:
--------------------------------------------------------------------------------
1 | import { useLayoutEffect } from 'react';
2 | import { loadSelectedTheme } from '~/lib/theme';
3 | import { isBrowser } from '~/lib/is-browser';
4 |
5 | const shouldSetTheme = isBrowser();
6 |
7 | const ThemeProvider: React.FCC = ({ children }) => {
8 | if (shouldSetTheme) {
9 | return {children};
10 | }
11 |
12 | return <>{children}>;
13 | };
14 |
15 | function BrowserThemeSetter({ children }: React.PropsWithChildren) {
16 | useLayoutEffect(loadSelectedTheme, []);
17 |
18 | return <>{children}>;
19 | }
20 |
21 | export default ThemeProvider;
22 |
--------------------------------------------------------------------------------
/configuration.ts:
--------------------------------------------------------------------------------
1 | const configuration = {
2 | site: {
3 | name: 'MakerKit - Next.js Blog Starter',
4 | description:
5 | 'MakerKit is the SaaS starter built with Next.js, Firebase and' +
6 | ' Tailwind that lets you launch your next idea in minutes.',
7 | themeColor: '#efee00',
8 | siteUrl: 'https://makerkit.dev',
9 | siteName: 'MakerKit Next.js Blog Starter',
10 | twitterHandle: 'makerkit_dev',
11 | githubHandle: 'makerkit',
12 | language: 'en',
13 | },
14 | blog: {
15 | maxReadMorePosts: 6,
16 | },
17 | production: process.env.NODE_ENV === 'production',
18 | };
19 |
20 | export default configuration;
21 |
--------------------------------------------------------------------------------
/global.d.ts:
--------------------------------------------------------------------------------
1 | declare module '@mdx-js/react';
2 | declare module 'remark-mdx';
3 |
4 | declare global {
5 | declare module 'react' {
6 | type FCC> = React.FC<
7 | React.PropsWithChildren
8 | >;
9 | }
10 | }
11 |
--------------------------------------------------------------------------------
/lib/blog/api.ts:
--------------------------------------------------------------------------------
1 | import { join } from 'path';
2 | import { existsSync, readdirSync, readFileSync } from 'fs';
3 | import matter from 'gray-matter';
4 |
5 | import BlogPost from './blog-post';
6 |
7 | const POSTS_DIRECTORY_NAME = '_posts';
8 | const COLLECTIONS_DIRECTORY_NAME = `_collections`;
9 | const AUTHORS_DIRECTORY_NAME = `_authors`;
10 |
11 | const MDX_EXTENSION = '.mdx';
12 | const JSON_EXTENSION = '.json';
13 |
14 | const postsDirectory = join(process.cwd(), POSTS_DIRECTORY_NAME);
15 | const collections = readJson(COLLECTIONS_DIRECTORY_NAME);
16 | const authors = readJson(AUTHORS_DIRECTORY_NAME);
17 |
18 | const posts = readdirSync(postsDirectory).map((post) => {
19 | return removeExtensionFromSlug(post);
20 | });
21 |
22 | function readJson(directoryName: string) {
23 | return readdirSync(directoryName)
24 | .map((slug) => {
25 | const path = join(process.cwd(), directoryName, slug);
26 |
27 | if (!existsSync(path)) {
28 | return;
29 | }
30 |
31 | const json = readFileSync(path, 'utf-8');
32 |
33 | try {
34 | const data = JSON.parse(json);
35 | const realSlug = removeExtensionFromSlug(slug, JSON_EXTENSION);
36 |
37 | return {
38 | data,
39 | slug,
40 | realSlug,
41 | };
42 | } catch (e) {
43 | console.warn(`Error while reading JSON file`, e);
44 | return;
45 | }
46 | })
47 | .filter(Boolean);
48 | }
49 |
50 | export function readFrontMatter(fullPath: string) {
51 | try {
52 | const fileContents = readFileSync(fullPath, 'utf-8');
53 |
54 | return matter(fileContents);
55 | } catch (e) {
56 | console.warn(`Error while reading Front matter at ${fullPath}`, e);
57 | }
58 | }
59 |
60 | export function getPostBySlug(slug: string) {
61 | const postPath = join(postsDirectory, `${slug}${MDX_EXTENSION}`);
62 | const file = readFrontMatter(postPath);
63 |
64 | if (!file) {
65 | return;
66 | }
67 |
68 | const content = file.content;
69 | const data = file.data;
70 | const empty = Object.keys(data).length === 0;
71 |
72 | if (empty) {
73 | return;
74 | }
75 |
76 | const readingTime = getReadingTimeInMinutes(content);
77 |
78 | const post: Partial = {
79 | live: data.live,
80 | readingTime,
81 | slug: removeExtensionFromSlug(slug),
82 | content,
83 | };
84 |
85 | for (const field in data) {
86 | if (field === 'collection') {
87 | post[field] = getCollection(data[field]);
88 | continue;
89 | }
90 |
91 | if (field === 'author') {
92 | post[field] = getAuthor(data[field]);
93 | continue;
94 | }
95 |
96 | if (field === 'date' && data.date) {
97 | try {
98 | post[field] = new Date(data.date).toISOString();
99 | continue;
100 | } catch (e) {
101 | console.error(`Error processing blog post date ${data.date}`);
102 | }
103 | }
104 |
105 | // if the field exists, assign as is
106 | if (data[field]) {
107 | Object.assign(post, {
108 | [field]: data[field],
109 | });
110 | }
111 | }
112 |
113 | return post as BlogPost;
114 | }
115 |
116 | export function getCollection(slug: string) {
117 | const collectionSlug = slug.replace(COLLECTIONS_DIRECTORY_NAME + '/', '');
118 |
119 | const collection = collections.find((item) => {
120 | return [item?.slug, item?.realSlug].includes(collectionSlug);
121 | });
122 |
123 | if (!collection) {
124 | throw new Error(
125 | `Collection with slug "${collectionSlug}" was not found. Please add a collection file at ${slug}`
126 | );
127 | }
128 |
129 | return {
130 | ...collection.data,
131 | slug: collection.realSlug,
132 | };
133 | }
134 |
135 | export function getAuthor(slug: string) {
136 | const authorFileName = slug.replace(AUTHORS_DIRECTORY_NAME + '/', '');
137 |
138 | const author = authors.find((item) => {
139 | return [item?.slug, item?.realSlug].includes(authorFileName);
140 | });
141 |
142 | if (!author) {
143 | throw new Error(
144 | `Author with slug "${authorFileName}" was not found. Please add an author file at ${slug}`
145 | );
146 | }
147 |
148 | return {
149 | ...author.data,
150 | slug: author.realSlug,
151 | };
152 | }
153 |
154 | function getReadingTimeInMinutes(content: string, wordsPerMinute = 225) {
155 | const words = content.trim().split(/\s+/).length;
156 |
157 | return Math.ceil(words / wordsPerMinute);
158 | }
159 |
160 | export function getAllPosts(
161 | filterFn: (post: Partial) => boolean = () => true
162 | ) {
163 | const foundPosts = posts.map(getPostBySlug).filter(Boolean) as BlogPost[];
164 |
165 | return foundPosts
166 | .filter(filterByPublishedPostsOnly)
167 | .filter(filterFn)
168 | .sort(sortBlogPostByDate);
169 | }
170 |
171 | function filterByPublishedPostsOnly(post: BlogPost) {
172 | // we want to exclude blog posts
173 | // if it's the prod env AND if not live
174 | if (!process.env.production || !('live' in post)) {
175 | return true;
176 | }
177 |
178 | return post.live;
179 | }
180 |
181 | export function getPostsByCollection(collectionSlug: string) {
182 | const collection = getCollection(collectionSlug);
183 |
184 | return getAllPosts(
185 | (item) =>
186 | item.collection?.name.toLowerCase() === collection.name.toLowerCase()
187 | );
188 | }
189 |
190 | function sortBlogPostByDate(item: BlogPost, nextItem: BlogPost) {
191 | if (!item.date || !nextItem.date) {
192 | return 1;
193 | }
194 |
195 | return item.date > nextItem.date ? -1 : 1;
196 | }
197 |
198 | function removeExtensionFromSlug(slug: string, extension = MDX_EXTENSION) {
199 | return slug.replace(extension, '');
200 | }
201 |
--------------------------------------------------------------------------------
/lib/blog/author.ts:
--------------------------------------------------------------------------------
1 | type Author = {
2 | name: string;
3 | picture: string;
4 | url: string;
5 | };
6 |
7 | export default Author;
8 |
--------------------------------------------------------------------------------
/lib/blog/blog-post.ts:
--------------------------------------------------------------------------------
1 | import Author from './author';
2 | import Collection from './collection';
3 |
4 | type BlogPost = {
5 | author: Author;
6 | collection: Collection;
7 | image: string;
8 | description: string;
9 | slug: string;
10 | title: string;
11 | date: string;
12 | live: boolean;
13 | tags: string[];
14 | content: string;
15 | readingTime: number;
16 | canonical?: string;
17 | };
18 |
19 | export default BlogPost;
20 |
--------------------------------------------------------------------------------
/lib/blog/collection.ts:
--------------------------------------------------------------------------------
1 | interface WithEmoji {
2 | emoji?: string;
3 | }
4 |
5 | interface WithLogo {
6 | logo?: string;
7 | }
8 |
9 | interface Collection extends WithEmoji, WithLogo {
10 | name: string;
11 | slug: string;
12 | emoji: string;
13 | }
14 |
15 | export default Collection;
16 |
--------------------------------------------------------------------------------
/lib/blog/compile-mdx.ts:
--------------------------------------------------------------------------------
1 | import rehypeHighlight from 'rehype-highlight';
2 | import rehypeSlug from 'rehype-slug';
3 | import rehypeAutoLinkHeadings from 'rehype-autolink-headings';
4 |
5 | export async function compileMdx(markdown: string) {
6 | const { compile } = await import('@mdx-js/mdx');
7 |
8 | const code = await compile(markdown, {
9 | outputFormat: 'function-body',
10 | rehypePlugins: [rehypeHighlight, rehypeSlug, rehypeAutoLinkHeadings],
11 | });
12 |
13 | return String(code);
14 | }
15 |
--------------------------------------------------------------------------------
/lib/blog/rss-feed.ts:
--------------------------------------------------------------------------------
1 | /*
2 | - This file gets processed by ts-node as a post-build script
3 | - Please leave the file imports as relative
4 | */
5 |
6 | import { Feed } from 'feed';
7 | import { writeFileSync } from 'fs';
8 |
9 | import { getAllPosts } from './api';
10 | import configuration from '../../configuration';
11 | import BlogPost from './blog-post';
12 |
13 | const DEFAULT_RSS_PATH = 'public/rss.xml';
14 | const DEFAULT_JSON_PATH = 'public/rss.json';
15 | const DEFAULT_ATOM_PATH = 'public/atom.xml';
16 |
17 | function generateRSSFeed(posts: BlogPost[]) {
18 | const baseUrl = configuration.site.siteUrl;
19 | const description = configuration.site.description;
20 | const title = `${configuration.site.name} - Blog`;
21 |
22 | const author = {
23 | email: ``,
24 | link: configuration.site.twitterHandle,
25 | };
26 |
27 | const feed = new Feed({
28 | title,
29 | description,
30 | id: baseUrl,
31 | link: baseUrl,
32 | favicon: `${baseUrl}/assets/favicon/favicon.ico`,
33 | language: configuration.site.language ?? `en`,
34 | feedLinks: {
35 | rss2: `${baseUrl}/rss.xml`,
36 | json: `${baseUrl}/rss.json`,
37 | atom: `${baseUrl}/atom.xml`,
38 | },
39 | author,
40 | copyright: '',
41 | });
42 |
43 | posts.forEach((post) => {
44 | const { date, slug, title, content, description, collection, live, image } =
45 | post;
46 |
47 | if (!live) {
48 | return;
49 | }
50 |
51 | const url = `${baseUrl}/blog/${collection.slug}/${slug}`;
52 |
53 | feed.addItem({
54 | title,
55 | id: url,
56 | link: url,
57 | description,
58 | content,
59 | author: [author],
60 | date: new Date(date),
61 | image: `${baseUrl}/${image}`,
62 | });
63 | });
64 |
65 | writeFileSync(DEFAULT_RSS_PATH, feed.rss2());
66 | writeFileSync(DEFAULT_ATOM_PATH, feed.atom1());
67 | writeFileSync(DEFAULT_JSON_PATH, feed.json1());
68 | }
69 |
70 | function main() {
71 | console.log(`Generating RSS Feed...`);
72 |
73 | try {
74 | generateRSSFeed(getAllPosts());
75 |
76 | console.log(`RSS Feed generated successfully...`);
77 | process.exit(0);
78 | } catch (e) {
79 | console.error(`RSS Feed not generated: ${JSON.stringify(e)}`);
80 | process.exit(1);
81 | }
82 | }
83 |
84 | main();
85 |
--------------------------------------------------------------------------------
/lib/is-browser.ts:
--------------------------------------------------------------------------------
1 | export function isBrowser() {
2 | return typeof window !== 'undefined';
3 | }
4 |
--------------------------------------------------------------------------------
/lib/theme.ts:
--------------------------------------------------------------------------------
1 | const THEME_LOCAL_STORAGE_KEY = `theme`;
2 |
3 | export const DARK_THEME_CLASSNAME = `dark`;
4 |
5 | export function loadThemeFromLocalStorage() {
6 | return localStorage.getItem(THEME_LOCAL_STORAGE_KEY);
7 | }
8 |
9 | export function setTheme(theme: string | null) {
10 | const root = getHtml();
11 |
12 | if (theme) {
13 | localStorage.setItem(THEME_LOCAL_STORAGE_KEY, theme);
14 | root.classList.add(theme);
15 | } else {
16 | localStorage.removeItem(THEME_LOCAL_STORAGE_KEY);
17 | root.classList.remove(DARK_THEME_CLASSNAME);
18 | }
19 | }
20 |
21 | export function loadSelectedTheme() {
22 | setTheme(loadThemeFromLocalStorage());
23 | }
24 |
25 | function getHtml() {
26 | return document.firstElementChild as HTMLHtmlElement;
27 | }
28 |
--------------------------------------------------------------------------------
/next-env.d.ts:
--------------------------------------------------------------------------------
1 | ///
2 | ///
3 |
4 | // NOTE: This file should not be edited
5 | // see https://nextjs.org/docs/basic-features/typescript for more information.
6 |
--------------------------------------------------------------------------------
/next-sitemap.config.js:
--------------------------------------------------------------------------------
1 | const siteUrl = process.env.NEXT_PUBLIC_SITE_URL;
2 |
3 | // add your excluded routes here
4 | const exclude = ['/tags/*'];
5 |
6 | module.exports = {
7 | siteUrl,
8 | generateRobotsTxt: true,
9 | exclude,
10 | };
11 |
--------------------------------------------------------------------------------
/next.config.js:
--------------------------------------------------------------------------------
1 | const MS_PER_SECOND = 1000;
2 | const SECONDS_PER_DAY = 86400;
3 |
4 | /**
5 | * @type {import("next").NextConfig}
6 | */
7 | module.exports = {
8 | pageExtensions: ['ts', 'tsx', 'md', 'mdx'],
9 | swcMinify: false,
10 | // please disable if too verbose while developing. No judgment
11 | reactStrictMode: true,
12 | images: {
13 | domains: ['images.unsplash.com'],
14 | },
15 | onDemandEntries: {
16 | // period (in ms) where the server will keep pages in the buffer
17 | maxInactiveAge: SECONDS_PER_DAY * MS_PER_SECOND,
18 | // number of pages that should be kept simultaneously without being disposed
19 | pagesBufferLength: 100,
20 | },
21 | webpack: (config, { isServer }) => {
22 | if (!isServer) {
23 | config.resolve.fallback.fs = false;
24 | }
25 |
26 | return config;
27 | },
28 | };
29 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "mk-next-blog-kit",
3 | "version": "0.1.1",
4 | "private": true,
5 | "scripts": {
6 | "dev": "next dev",
7 | "build": "next build",
8 | "start": "next start",
9 | "lint": "next lint",
10 | "typecheck": "tsc",
11 | "healthcheck": "npm run lint && npm run typecheck",
12 | "postbuild": "npm run sitemap && npm run rss",
13 | "sitemap": "next-sitemap",
14 | "rss": "npx tsx './lib/blog/rss-feed.ts'"
15 | },
16 | "author": {
17 | "email": "info@makerkit.dev",
18 | "name": "Giancarlo Buomprisco"
19 | },
20 | "dependencies": {
21 | "@mdx-js/mdx": "^2.1.3",
22 | "date-fns": "^2.29.2",
23 | "feed": "^4.2.2",
24 | "gray-matter": "^4.0.3",
25 | "next": "12.2.5",
26 | "next-sitemap": "^3.1.21",
27 | "react": "18.2.0",
28 | "react-dom": "18.2.0",
29 | "rehype-autolink-headings": "^6.1.1",
30 | "rehype-highlight": "^5.0.2",
31 | "rehype-slug": "^5.0.1",
32 | "tailwindcss": "^3.1.8"
33 | },
34 | "devDependencies": {
35 | "@types/node": "18.7.14",
36 | "@types/react": "18.0.18",
37 | "@types/react-dom": "18.0.6",
38 | "autoprefixer": "^10.4.8",
39 | "eslint": "8.23.0",
40 | "eslint-config-next": "12.2.5",
41 | "prettier": "^2.7.1",
42 | "prettier-plugin-tailwindcss": "^0.1.13",
43 | "typescript": "4.8.2"
44 | }
45 | }
46 |
--------------------------------------------------------------------------------
/pages/[collection]/[slug].tsx:
--------------------------------------------------------------------------------
1 | import Head from 'next/head';
2 |
3 | import {
4 | getAllPosts,
5 | getPostBySlug,
6 | getPostsByCollection,
7 | } from '~/lib/blog/api';
8 |
9 | import configuration from '~/configuration';
10 |
11 | import BlogPost from '~/lib/blog/blog-post';
12 | import Post from '~/components/Post';
13 | import Header from '~/components/Header';
14 | import PostsList from '~/components/PostsList';
15 | import LayoutContainer from '~/components/LayoutContainer';
16 | import Meta from '~/components/Meta';
17 | import { compileMdx } from '~/lib/blog/compile-mdx';
18 |
19 | type Props = {
20 | post: BlogPost;
21 | morePosts: BlogPost[];
22 | content: string;
23 | };
24 |
25 | type Params = {
26 | params: {
27 | slug: string;
28 | collection: string;
29 | };
30 | };
31 |
32 | const PostPage = ({ post, morePosts, content }: Props) => {
33 | return (
34 | <>
35 |
36 |
37 |
38 |
39 |
40 |
41 |
42 |
43 |
44 |
45 |
46 | Learn more about {post.collection.name}
47 |
48 |
49 |
50 |
53 |
54 |
55 | >
56 | );
57 | };
58 |
59 | export default PostPage;
60 |
61 | function PostHead({ post }: React.PropsWithChildren<{ post: BlogPost }>) {
62 | const title = post.title;
63 | const structuredDataJson = getStructuredData(post);
64 | const fullImagePath = getFullImagePath(post.image);
65 |
66 | return (
67 |
68 | {title}
69 |
70 |
71 |
72 |
73 |
74 |
75 |
76 |
81 |
82 | {post.description && (
83 | <>
84 |
89 |
90 |
95 |
96 |
101 | >
102 | )}
103 |
104 | {post.canonical && (
105 |
106 | )}
107 |
108 | {fullImagePath && (
109 |
110 | )}
111 |
112 |
119 |
120 | );
121 | }
122 |
123 | export async function getStaticProps({ params }: Params) {
124 | const { slug, collection } = params;
125 | const post = getPostBySlug(slug);
126 |
127 | if (!post) {
128 | return {
129 | notFound: true,
130 | };
131 | }
132 |
133 | const morePosts = getPostsByCollection(collection)
134 | .filter((item) => item.slug !== slug)
135 | .slice(0, configuration.blog.maxReadMorePosts);
136 |
137 | const content = await compileMdx(post.content ?? '');
138 |
139 | return {
140 | props: {
141 | post,
142 | content,
143 | morePosts,
144 | },
145 | };
146 | }
147 |
148 | export function getStaticPaths() {
149 | const posts = getAllPosts();
150 |
151 | const paths = posts.map((post) => {
152 | const slug = post.slug;
153 | const collection = post.collection.slug;
154 |
155 | return {
156 | params: {
157 | collection,
158 | slug,
159 | },
160 | };
161 | });
162 |
163 | return {
164 | paths,
165 | fallback: false,
166 | };
167 | }
168 |
169 | function getStructuredData(post: BlogPost) {
170 | const fullImagePath = getFullImagePath(post.image);
171 |
172 | return {
173 | '@context': 'https://schema.org/',
174 | '@type': 'Article',
175 | mainEntityOfPage: {
176 | '@type': 'WebPage',
177 | '@id': 'https://google.com/article',
178 | },
179 | image: [fullImagePath],
180 | headline: post.title,
181 | description: post.description,
182 | author: {
183 | '@type': 'Person',
184 | name: post.author.name,
185 | url: post.author.url,
186 | },
187 | datePublished: post.date,
188 | };
189 | }
190 |
191 | function getFullImagePath(path: string) {
192 | return `${configuration.site.siteUrl}${path}`;
193 | }
194 |
--------------------------------------------------------------------------------
/pages/_app.tsx:
--------------------------------------------------------------------------------
1 | import '../styles/globals.css';
2 | import type { AppProps } from 'next/app';
3 | import ThemeProvider from '~/components/ThemeProvider';
4 |
5 | function MyApp({ Component, pageProps }: AppProps) {
6 | return (
7 |
8 |
9 |
10 | );
11 | }
12 |
13 | export default MyApp;
14 |
--------------------------------------------------------------------------------
/pages/index.tsx:
--------------------------------------------------------------------------------
1 | import type { NextPage } from 'next';
2 |
3 | import { getAllPosts } from '~/lib/blog/api';
4 | import BlogPost from '~/lib/blog/blog-post';
5 | import configuration from '~/configuration';
6 |
7 | import LayoutContainer from '~/components/LayoutContainer';
8 | import PostsList from '~/components/PostsList';
9 | import Header from '~/components/Header';
10 | import Meta from '~/components/Meta';
11 |
12 | const Home: NextPage<{
13 | posts: BlogPost[];
14 | }> = ({ posts }) => {
15 | return (
16 | <>
17 |
18 |
19 |
20 |
21 |
24 |
25 |
26 | {configuration.site.siteName}
27 |
28 |
29 |
30 |
31 |
32 |
33 | >
34 | );
35 | };
36 |
37 | export function getStaticProps() {
38 | const posts = getAllPosts().slice(0, 6);
39 |
40 | return {
41 | props: {
42 | posts,
43 | },
44 | };
45 | }
46 |
47 | export default Home;
48 |
--------------------------------------------------------------------------------
/postcss.config.js:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | plugins: {
3 | tailwindcss: {},
4 | autoprefixer: {},
5 | },
6 | };
7 |
--------------------------------------------------------------------------------
/public/favicon.ico:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/makerkit/next-blog-kit/9ef861bf33372dcbb1770c73a705f2e120d53f50/public/favicon.ico
--------------------------------------------------------------------------------
/styles/Home.module.css:
--------------------------------------------------------------------------------
1 | .container {
2 | padding: 0 2rem;
3 | }
4 |
5 | .main {
6 | min-height: 100vh;
7 | padding: 4rem 0;
8 | flex: 1;
9 | display: flex;
10 | flex-direction: column;
11 | justify-content: center;
12 | align-items: center;
13 | }
14 |
15 | .footer {
16 | display: flex;
17 | flex: 1;
18 | padding: 2rem 0;
19 | border-top: 1px solid #eaeaea;
20 | justify-content: center;
21 | align-items: center;
22 | }
23 |
24 | .footer a {
25 | display: flex;
26 | justify-content: center;
27 | align-items: center;
28 | flex-grow: 1;
29 | }
30 |
31 | .title a {
32 | color: #0070f3;
33 | text-decoration: none;
34 | }
35 |
36 | .title a:hover,
37 | .title a:focus,
38 | .title a:active {
39 | text-decoration: underline;
40 | }
41 |
42 | .title {
43 | margin: 0;
44 | line-height: 1.15;
45 | font-size: 4rem;
46 | }
47 |
48 | .title,
49 | .description {
50 | text-align: center;
51 | }
52 |
53 | .description {
54 | margin: 4rem 0;
55 | line-height: 1.5;
56 | font-size: 1.5rem;
57 | }
58 |
59 | .code {
60 | background: #fafafa;
61 | border-radius: 5px;
62 | padding: 0.75rem;
63 | font-size: 1.1rem;
64 | font-family: Menlo, Monaco, Lucida Console, Liberation Mono, DejaVu Sans Mono,
65 | Bitstream Vera Sans Mono, Courier New, monospace;
66 | }
67 |
68 | .grid {
69 | display: flex;
70 | align-items: center;
71 | justify-content: center;
72 | flex-wrap: wrap;
73 | max-width: 800px;
74 | }
75 |
76 | .card {
77 | margin: 1rem;
78 | padding: 1.5rem;
79 | text-align: left;
80 | color: inherit;
81 | text-decoration: none;
82 | border: 1px solid #eaeaea;
83 | border-radius: 10px;
84 | transition: color 0.15s ease, border-color 0.15s ease;
85 | max-width: 300px;
86 | }
87 |
88 | .card:hover,
89 | .card:focus,
90 | .card:active {
91 | color: #0070f3;
92 | border-color: #0070f3;
93 | }
94 |
95 | .card h2 {
96 | margin: 0 0 1rem 0;
97 | font-size: 1.5rem;
98 | }
99 |
100 | .card p {
101 | margin: 0;
102 | font-size: 1.25rem;
103 | line-height: 1.5;
104 | }
105 |
106 | .logo {
107 | height: 1em;
108 | margin-left: 0.5rem;
109 | }
110 |
111 | @media (max-width: 600px) {
112 | .grid {
113 | width: 100%;
114 | flex-direction: column;
115 | }
116 | }
117 |
--------------------------------------------------------------------------------
/styles/github-dark.css:
--------------------------------------------------------------------------------
1 | /*!
2 | Theme: GitHub Dark
3 | Description: Dark theme as seen on github.com
4 | Author: github.com
5 | Maintainer: @Hirse
6 | Updated: 2021-05-15
7 | Outdated base version: https://github.com/primer/github-syntax-dark
8 | Current colors taken from GitHub's CSS
9 | */
10 |
11 | .hljs {
12 | color: #c9d1d9;
13 | background: #0d1117;
14 | }
15 |
16 | .hljs-doctag,
17 | .hljs-keyword,
18 | .hljs-meta .hljs-keyword,
19 | .hljs-template-tag,
20 | .hljs-template-variable,
21 | .hljs-type,
22 | .hljs-variable.language_ {
23 | /* prettylights-syntax-keyword */
24 | color: #ff7b72;
25 | }
26 |
27 | .hljs-title,
28 | .hljs-title.class_,
29 | .hljs-title.class_.inherited__,
30 | .hljs-title.function_ {
31 | /* prettylights-syntax-entity */
32 | color: #d2a8ff;
33 | }
34 |
35 | .hljs-attr,
36 | .hljs-attribute,
37 | .hljs-literal,
38 | .hljs-meta,
39 | .hljs-number,
40 | .hljs-operator,
41 | .hljs-variable,
42 | .hljs-selector-attr,
43 | .hljs-selector-class,
44 | .hljs-selector-id {
45 | /* prettylights-syntax-constant */
46 | color: #79c0ff;
47 | }
48 |
49 | .hljs-regexp,
50 | .hljs-string,
51 | .hljs-meta .hljs-string {
52 | /* prettylights-syntax-string */
53 | color: #a5d6ff;
54 | }
55 |
56 | .hljs-built_in,
57 | .hljs-symbol {
58 | /* prettylights-syntax-variable */
59 | color: #ffa657;
60 | }
61 |
62 | .hljs-comment,
63 | .hljs-code,
64 | .hljs-formula {
65 | /* prettylights-syntax-comment */
66 | color: #8b949e;
67 | }
68 |
69 | .hljs-name,
70 | .hljs-quote,
71 | .hljs-selector-tag,
72 | .hljs-selector-pseudo {
73 | /* prettylights-syntax-entity-tag */
74 | color: #7ee787;
75 | }
76 |
77 | .hljs-subst {
78 | /* prettylights-syntax-storage-modifier-import */
79 | color: #c9d1d9;
80 | }
81 |
82 | .hljs-section {
83 | /* prettylights-syntax-markup-heading */
84 | color: #1f6feb;
85 | font-weight: bold;
86 | }
87 |
88 | .hljs-bullet {
89 | /* prettylights-syntax-markup-list */
90 | color: #f2cc60;
91 | }
92 |
93 | .hljs-emphasis {
94 | /* prettylights-syntax-markup-italic */
95 | color: #c9d1d9;
96 | font-style: italic;
97 | }
98 |
99 | .hljs-strong {
100 | /* prettylights-syntax-markup-bold */
101 | color: #c9d1d9;
102 | font-weight: bold;
103 | }
104 |
105 | .hljs-addition {
106 | /* prettylights-syntax-markup-inserted */
107 | color: #aff5b4;
108 | background-color: #033a16;
109 | }
110 |
111 | .hljs-deletion {
112 | /* prettylights-syntax-markup-deleted */
113 | color: #ffdcd7;
114 | background-color: #67060c;
115 | }
116 |
117 | .hljs-char.escape_,
118 | .hljs-link,
119 | .hljs-params,
120 | .hljs-property,
121 | .hljs-punctuation,
122 | .hljs-tag {
123 | /* purposely ignored */
124 | }
125 |
--------------------------------------------------------------------------------
/styles/globals.css:
--------------------------------------------------------------------------------
1 | @tailwind base;
2 |
3 | /* Start purging... */
4 | @tailwind components;
5 | @tailwind utilities;
6 | /* Stop purging. */
7 |
8 | html,
9 | body {
10 | text-rendering: geometricPrecision;
11 | -webkit-font-smoothing: subpixel-antialiased;
12 | font-feature-settings: 'rlig' 1, 'calt' 1, 'ss01' 1;
13 |
14 | @apply bg-white text-gray-800 dark:bg-black-500 dark:text-gray-300 p-0 m-0;
15 | }
16 |
17 | html.dark {
18 | @apply bg-black-500;
19 | }
20 |
21 | a {
22 | color: inherit;
23 | text-decoration: none;
24 | }
25 |
26 | * {
27 | box-sizing: border-box;
28 | }
29 |
--------------------------------------------------------------------------------
/tailwind.config.js:
--------------------------------------------------------------------------------
1 | const plugin = require('tailwindcss/plugin');
2 |
3 | module.exports = {
4 | content: ['./**/*.tsx'],
5 | darkMode: 'class',
6 | corePlugins: {
7 | container: false,
8 | },
9 | theme: {
10 | extend: {
11 | colors: {
12 | primary: {
13 | 50: '#fffff9',
14 | 100: '#ffffad',
15 | 200: '#fffe75',
16 | 300: '#fffe2d',
17 | 400: '#fffe0f',
18 | 500: '#efee00',
19 | 600: '#d0d000',
20 | 700: '#b2b100',
21 | 800: '#939300',
22 | 900: '#757400',
23 | },
24 | black: {
25 | 50: '#707070',
26 | 100: '#424242',
27 | 200: '#323232',
28 | 300: '#242424',
29 | 400: '#181818',
30 | 500: '#0a0a0a',
31 | 600: '#040404',
32 | 700: '#000',
33 | },
34 | },
35 | },
36 | container: {
37 | center: true,
38 | padding: {
39 | DEFAULT: '1rem',
40 | sm: '2rem',
41 | },
42 | },
43 | fontFamily: {
44 | serif: ['Bitter', 'serif'],
45 | sans: [
46 | 'SF Pro Text',
47 | 'Inter',
48 | 'system-ui',
49 | 'BlinkMacSystemFont',
50 | 'Segoe UI',
51 | 'Roboto',
52 | 'Ubuntu',
53 | ],
54 | monospace: [`SF Mono`, `ui-monospace`, `Monaco`, 'Monospace'],
55 | },
56 | },
57 | plugins: [customContainerPlugin, plugin(ellipisfyPlugin)],
58 | };
59 |
60 | function ellipisfyPlugin({ addUtilities }) {
61 | const styles = {
62 | '.ellipsify': {
63 | overflow: 'hidden',
64 | 'text-overflow': 'ellipsis',
65 | 'white-space': 'pre',
66 | },
67 | };
68 |
69 | addUtilities(styles);
70 | }
71 |
72 | function customContainerPlugin({ addComponents }) {
73 | addComponents({
74 | '.container': {
75 | '@screen lg': {
76 | maxWidth: '1024px',
77 | },
78 | '@screen xl': {
79 | maxWidth: '1166px',
80 | },
81 | },
82 | });
83 | }
84 |
--------------------------------------------------------------------------------
/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "target": "es5",
4 | "lib": ["dom", "dom.iterable", "esnext"],
5 | "allowJs": true,
6 | "skipLibCheck": true,
7 | "strict": true,
8 | "forceConsistentCasingInFileNames": true,
9 | "noEmit": true,
10 | "esModuleInterop": true,
11 | "module": "esnext",
12 | "moduleResolution": "node",
13 | "resolveJsonModule": true,
14 | "isolatedModules": true,
15 | "jsx": "preserve",
16 | "incremental": true,
17 | "baseUrl": ".",
18 | "paths": {
19 | "~/configuration": ["./configuration"],
20 | "~/components/*": ["./components/*"],
21 | "~/lib/*": ["./lib/*"]
22 | }
23 | },
24 | "include": ["next-env.d.ts", "global.d.ts", "**/*.ts", "**/*.tsx"],
25 | "exclude": ["node_modules"]
26 | }
27 |
--------------------------------------------------------------------------------