├── .eslintrc.json
├── .github
├── renovate.json
└── workflows
│ └── ci.yml
├── .gitignore
├── .prettierignore
├── LICENSE
├── README.md
├── app
├── api
│ └── og
│ │ └── route.tsx
├── blog
│ ├── [slug]
│ │ └── page.tsx
│ ├── layout.tsx
│ ├── page.tsx
│ ├── page
│ │ └── [[...count]]
│ │ │ └── page.tsx
│ └── tags
│ │ └── [[...tag]]
│ │ └── page.tsx
├── layout.tsx
├── not-found.tsx
├── page.tsx
├── robots.ts
└── sitemap.ts
├── components
├── blog-card.tsx
├── brand-icon.tsx
├── callout.tsx
├── dropdown-menu.tsx
├── footer.tsx
├── header.tsx
├── image.tsx
├── layout
│ └── blog-page-layout.tsx
├── link.tsx
├── mdx
│ ├── mdx-code-titles.tsx
│ ├── mdx-components.tsx
│ ├── mdx-content.tsx
│ ├── mdx-copy-code-btn.tsx
│ ├── mdx-image.tsx
│ ├── mdx-link.tsx
│ ├── mdx-pre-code.tsx
│ └── mdx-table.tsx
├── post-paginator.tsx
├── post-tag.tsx
├── prose-layout.tsx
├── render-posts.tsx
├── scroll-button.tsx
├── theme-toggle.tsx
└── toc.tsx
├── content
└── blog
│ └── lorem-ipsum.mdx
├── contentlayer.config.ts
├── contexts
└── theme-provider.tsx
├── global.d.ts
├── lib
├── contentlayer.ts
├── fonts.ts
├── icons.tsx
├── mdx
│ ├── contentlayer-extract-headings.ts
│ └── remark-normalize-headings.ts
├── siteConfig.ts
└── utils.ts
├── next.config.js
├── package.json
├── pnpm-lock.yaml
├── postcss.config.cjs
├── public
├── favicon.ico
├── fonts
│ ├── Biotif-Bold.woff
│ ├── Biotif-Bold.woff2
│ ├── Biotif-BoldItalic.woff2
│ ├── Biotif-Medium.woff2
│ ├── Biotif-MediumItalic.woff2
│ ├── Biotif-Regular.woff2
│ ├── Biotif-RegularItalic.woff2
│ └── NeuzeitGrotesk-Bold.woff2
└── images
│ ├── announcement-banner.png
│ ├── arrow-tr.svg
│ └── react.jpeg
├── schema
└── contentlayer
│ └── blog-post.ts
├── styles
├── config.css
├── global.css
├── markdown.css
└── tailwind.css
├── tailwind.config.js
└── tsconfig.json
/.eslintrc.json:
--------------------------------------------------------------------------------
1 | {
2 | "extends": ["next/core-web-vitals", "prettier"]
3 | }
4 |
--------------------------------------------------------------------------------
/.github/renovate.json:
--------------------------------------------------------------------------------
1 | {
2 | "$schema": "https://docs.renovatebot.com/renovate-schema.json",
3 | "extends": [
4 | "config:recommended",
5 | "npm:unpublishSafe",
6 | ":preserveSemverRanges"
7 | ],
8 | "labels": ["dependencies"]
9 | }
10 |
--------------------------------------------------------------------------------
/.github/workflows/ci.yml:
--------------------------------------------------------------------------------
1 | name: CI
2 | on:
3 | push:
4 | branches:
5 | - "main"
6 | - "demo"
7 | pull_request:
8 |
9 | jobs:
10 | lint:
11 | name: Lint
12 | runs-on: ubuntu-latest
13 | steps:
14 | - name: Checkout
15 | uses: actions/checkout@v4
16 |
17 | - name: Setup pnpm
18 | uses: pnpm/action-setup@v2
19 | with:
20 | version: 8
21 |
22 | - name: Setup Node
23 | uses: actions/setup-node@v4
24 | with:
25 | node-version: 18
26 | cache: "pnpm"
27 |
28 | - name: Install deps
29 | run: pnpm install --frozen-lockfile
30 |
31 | - name: Run Lint
32 | run: pnpm lint
33 |
34 | typecheck:
35 | name: Typecheck
36 | runs-on: ubuntu-latest
37 | steps:
38 | - name: Checkout
39 | uses: actions/checkout@v4
40 |
41 | - name: Setup pnpm
42 | uses: pnpm/action-setup@v2
43 | with:
44 | version: 8
45 |
46 | - name: Setup Node
47 | uses: actions/setup-node@v4
48 | with:
49 | node-version: 18
50 | cache: "pnpm"
51 |
52 | - name: Install deps
53 | run: pnpm install --frozen-lockfile
54 |
55 | # needs contentlayer to generate types before we typecheck
56 | - name: Run Contentlayer build
57 | run: pnpm build:contentlayer
58 |
59 | - name: Run typecheck
60 | run: pnpm typecheck
61 |
62 | build:
63 | name: Build
64 | needs: [lint, typecheck]
65 | runs-on: ubuntu-latest
66 | steps:
67 | - name: Checkout
68 | uses: actions/checkout@v4
69 |
70 | - name: Setup pnpm
71 | uses: pnpm/action-setup@v2
72 | with:
73 | version: 8
74 |
75 | - name: Setup Node
76 | uses: actions/setup-node@v4
77 | with:
78 | node-version: 18
79 | cache: "pnpm"
80 |
81 | - name: Install deps
82 | run: pnpm install --frozen-lockfile
83 |
84 | - name: Run Build
85 | run: pnpm build
86 |
--------------------------------------------------------------------------------
/.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 | /.contentlayer/
22 |
23 | # debug
24 | npm-debug.log*
25 | yarn-debug.log*
26 | yarn-error.log*
27 |
28 | # local env files
29 | .env*.local
30 |
31 | # vercel
32 | .vercel
33 |
34 | # typescript
35 | *.tsbuildinfo
36 | next-env.d.ts
37 |
--------------------------------------------------------------------------------
/.prettierignore:
--------------------------------------------------------------------------------
1 | /.next
2 | /*-lock.*
3 | /.contentlayer/
4 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) 2023 Saurabh Charde
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 |
2 |
3 |
Next.js Tailwind Contentlayer Blog
4 |
5 |
6 |
7 |
8 | A simple and minimalistic blog template built using the latest Next.js app router and Contentlayer.
9 |
10 |
11 | ## Motive
12 |
13 | I created this blog template as a learning experience (and to get my hands dirty) with [Next.js' new shiny app router](https://nextjs.org/docs/app). I also wanted few specific reusable functionalities for my personal portfolio blog that I can also easily copy-paste to my other projects. Developing a generic template seemed like a better way of achieving that.
14 |
15 | ## Features
16 |
17 | - **Mobile-First Approach**: Built with a mobile-first mindset, the template ensures your blog looks fantastic on any device.
18 |
19 | - **Highly Customizable**: Flexibility is key! The template offers extensive customizability, allowing you to tailor it to your unique style and preferences.
20 |
21 | - **CSS Variables for Easier Customizations**: Making changes to your blog's appearance becomes a breeze with the help of CSS variables.
22 |
23 | - **Keyboard Accessibility**: Ensuring an inclusive experience, the template is keyboard accessible.
24 |
25 | - **Light and Dark Mode Support**: Embrace the latest design trends by offering both light and dark mode options.
26 |
27 | - **Full Type-Safety**: Rest assured that your codebase will be free of type-related headaches.
28 |
29 | - **JSX in Markdown Support**: Unleash the power of JSX right within your Markdown content.
30 |
31 | - **Near Perfect Lighthouse Scores**: Your blog will be optimized for blazing-fast performance and excellent user experience.
32 |
33 | - **VS Code-like Code Highlighting**: Code snippets are displayed beautifully with line numbers, line highlighting, words highlighting, and inline code highlighting, powered by [Rehype Pretty Code](https://rehype-pretty-code.netlify.app/).
34 |
35 | - **Github-Flavored Markdown Support**: Write your content in familiar Markdown, including GitHub-flavored goodness.
36 |
37 | - **Separate Pages for Tags**: Easily categorize your posts with separate tag pages.
38 |
39 | - **Out-of-the-Box SEO Support**: Ensure your blog ranks well on search engines with built-in SEO features.
40 |
41 | - **Dynamic Open-Graph (OG) Image Generation**: Each post will have a sleek OG image automatically generated.
42 |
43 | - **Automatic Image Optimizations**: The template takes care of image optimizations using `next/image` for optimal performance.
44 |
45 | - **Bleeding Edge Technologies and Best Practices**: Stay ahead of the curve with the latest technologies and industry best practices (I hope so).
46 |
47 | ## Features Not Implemented (yet)
48 |
49 | Although packed with awesome features, there are some ideas on the horizon that I'm eager to implement in future updates:
50 |
51 | - **Views Counter / Analytics**: Keep track of your blog's popularity and engagement.
52 |
53 | - **Cloudinary Support**: Easily manage and optimize your images with Cloudinary integration.
54 |
55 | - **Comments**: Foster discussions and interactions by adding comment functionality.
56 |
57 | - **Search Blog Posts / Tags**: Help users find specific content with a powerful search feature.
58 |
59 | - **Mermaid Support**: Visualize data and concepts with the help of Mermaid charts.
60 |
61 | - **Author Support**: Properly attribute authors to each post.
62 |
63 | - **Custom Layout**: Create a unique layout for individual posts.
64 |
65 | ## Theme
66 |
67 | The template comes with a default theme based on **Stone** color palette from Tailwind CSS. But don't worry, you have the freedom to create your own theme by customising and tweaking `config.css`.
68 |
69 | ## Getting Started
70 |
71 | 1. Clone the starter template:
72 |
73 | ```bash
74 | git clone https://github.com/schardev/nextjs-contentlayer-blog
75 |
76 | # or using `gh`
77 | gh repo clone schardev/nextjs-contentlayer-blog
78 | ```
79 |
80 | 2. Add site information and relevant data to `lib/siteConfig.ts`.
81 | 3. Add your blog posts to `content/blog` directory with proper front-matter (see available fields [here](https://github.com/schardev/nextjs-contentlayer-blog/blob/main/schema/contentlayer/blog-post.ts))
82 | 4. Build your blog with:
83 |
84 | ```bash
85 | pnpm build
86 | ```
87 |
88 | 5. Deploy to your hosting provider 🎉
89 |
90 | Starting a development server isn't different either, just run:
91 |
92 | ```bash
93 | pnpm dev
94 | ```
95 |
96 | Now open `http://localhost:3000` to view changes to your blog as it happens.
97 |
98 | ## Directory and File Structure
99 |
100 | | Directory/File | Notes |
101 | | ------------------------ | --------------------------------------------------------------------------------------------------------------- |
102 | | `app/` | Defines your site/blog's routes |
103 | | `components/` | All react component code lives here |
104 | | `content/` | Directory where your MDX or Markdown file lives |
105 | | `public/` | Static assets goes here (e.g., put all your images inside `public/images` and fonts inside `public/fonts` etc) |
106 | | `styles/` | Find all styling files here |
107 | | `schema/` | Contains schema-related files |
108 | | `contentlayer.config.ts` | [Contentlayer](https://www.contentlayer.dev/) configuration file (you can change your `content` directory here) |
109 | | `lib/siteConfig.ts` | Holds config related to the site itself |
110 |
111 | ## Additional notes
112 |
113 | - If you have worked with [`next/image`](https://nextjs.org/docs/app/api-reference/components/image) before you know that working with external images is a pain in the ass, as you have to manually inspect and pass width and height properties to the ` ` component. Sure you can use `fill` property but then you have to have a fixed aspect ratio and/or deal with z-indexes. However, working with external images is now a breeze. Use the custom ` ` component from the `components/` directory that takes care of all of it. No more manual width and height hassles, as image optimizations are automatically handled.
114 |
115 | - Additionally, code blocks support everything [`rehype-pretty-code`](https://rehype-pretty-code.netlify.app/) supports. You even get the bonus of file type icons! You can also extend or customize the default file type icons from [here](https://github.com/schardev/nextjs-contentlayer-blog/blob/main/components/brand-icon.tsx).
116 |
117 | - As mentioned already, styling is mostly controlled via CSS variables so it's pretty easy to tweak and configure. See [`config.css`](https://github.com/schardev/nextjs-contentlayer-blog/blob/main/styles/config.css).
118 |
119 | > **Note**:
120 | >
121 | > Image optimisations using custom ` ` component only works if your blog is statically generated. **Do not use it as a client or server component.**
122 |
123 | If you encounter any issues or have any feature requests, feel free to report them in the [main repository](https://github.com/schardev/nextjs-contentlayer-blog/tree/main).
124 |
125 | ## Inspirations
126 |
127 | - [Tailwind Nextjs Starter Blog](https://github.com/timlrx/tailwind-nextjs-starter-blog)
128 | - [Stackoverflow Blog](https://stackoverflow.blog/)
129 | - [Material UI Blog](https://mui.com/blog/)
130 | - [Web Bulletin](https://web-bulletin.vercel.app/)
131 |
132 | ## Licence
133 |
134 | MIT © [Saurabh Charde](https://schar.dev)
135 |
136 | Feel free to use this in whatever projects you like. Make sure to star the [repo](https://github.com/schardev/nextjs-contentlayer-blog) if you like it.
137 |
--------------------------------------------------------------------------------
/app/api/og/route.tsx:
--------------------------------------------------------------------------------
1 | import config from "@/lib/siteConfig";
2 | import { ImageResponse } from "next/og";
3 |
4 | export const runtime = "edge";
5 |
6 | const fontBold = fetch(
7 | new URL("../../../public/fonts/Biotif-Bold.woff", import.meta.url),
8 | ).then((res) => res.arrayBuffer());
9 |
10 | export async function GET(request: Request) {
11 | const fontData = await fontBold;
12 | const url = new URL(request.url);
13 | const title = url.searchParams.get("title");
14 | const date = url.searchParams.get("date");
15 |
16 | return new ImageResponse(
17 | (
18 |
30 |
39 |
53 | {config.title}
54 |
55 |
62 | {date}
63 |
64 |
74 | {title}
75 |
76 |
77 | ),
78 | {
79 | width: 1200,
80 | height: 600,
81 | fonts: [
82 | {
83 | name: "Biotif",
84 | data: fontData,
85 | style: "normal",
86 | },
87 | ],
88 | },
89 | );
90 | }
91 |
--------------------------------------------------------------------------------
/app/blog/[slug]/page.tsx:
--------------------------------------------------------------------------------
1 | import Image from "@/components/image";
2 | import Link from "@/components/link";
3 | import MDXContent from "@/components/mdx/mdx-content";
4 | import PostTag from "@/components/post-tag";
5 | import ProseLayout from "@/components/prose-layout";
6 | import ScrollButton from "@/components/scroll-button";
7 | import TocDesktop, { TocMobile } from "@/components/toc";
8 | import { allSortedBlogs } from "@/lib/contentlayer";
9 | import {
10 | cn,
11 | formatDate,
12 | generateCommonMeta,
13 | isArrayNotEmpty,
14 | slugify,
15 | } from "@/lib/utils";
16 | import { Calendar, NavArrowLeft } from "iconoir-react";
17 | import { Metadata } from "next";
18 | import { notFound } from "next/navigation";
19 |
20 | export const generateStaticParams = () => {
21 | return allSortedBlogs.map((post) => {
22 | return {
23 | slug: post.slug,
24 | };
25 | });
26 | };
27 |
28 | export const generateMetadata = ({
29 | params,
30 | }: {
31 | params: { slug: string };
32 | }): Metadata => {
33 | const post = allSortedBlogs.find((post) => post.slug === params.slug);
34 | if (!post) notFound();
35 |
36 | const { title, description, date } = post;
37 | const imgParams = new URLSearchParams({ title, date: formatDate(date) });
38 | const image = post.image ?? `/api/og?${imgParams.toString()}`;
39 | return generateCommonMeta({ title, description, image });
40 | };
41 |
42 | const Page = ({ params }: { params: { slug: string } }) => {
43 | const post = allSortedBlogs.find((post) => post.slug === params.slug);
44 | if (!post) notFound();
45 |
46 | const moreThanOneHeading = post.headings && post.headings.length > 1;
47 |
48 | return (
49 |
53 |
61 |
62 | Back to blog
63 |
64 | *]:flex-1",
69 | )}>
70 |
71 | *]:flex [&>*]:gap-2",
76 | )}>
77 |
78 |
79 | {formatDate(post.date, "full")}
80 |
81 |
82 | {post.title}
83 | {post.description}
84 | {isArrayNotEmpty(post.tags) && (
85 |
86 | {post.tags.map((tag) => (
87 |
88 |
{tag}
89 |
90 | ))}
91 |
92 | )}
93 |
94 | {post.image && (
95 |
96 |
103 |
104 | )}
105 |
106 |
111 | {moreThanOneHeading && (
112 |
118 | )}
119 |
124 | {moreThanOneHeading && (
125 |
126 | )}
127 |
128 |
129 |
130 |
131 |
132 | );
133 | };
134 |
135 | export default Page;
136 |
--------------------------------------------------------------------------------
/app/blog/layout.tsx:
--------------------------------------------------------------------------------
1 | const BlogLayout = ({ children }: { children: React.ReactNode }) => {
2 | return (
3 |
4 | {children}
5 |
6 | );
7 | };
8 |
9 | export default BlogLayout;
10 |
--------------------------------------------------------------------------------
/app/blog/page.tsx:
--------------------------------------------------------------------------------
1 | import BlogCard from "@/components/blog-card";
2 | import BlogPageLayout from "@/components/layout/blog-page-layout";
3 | import Link from "@/components/link";
4 | import PostPaginator from "@/components/post-paginator";
5 | import RenderPosts from "@/components/render-posts";
6 | import { allSortedBlogs } from "@/lib/contentlayer";
7 | import config from "@/lib/siteConfig";
8 | import { cn, generateCommonMeta } from "@/lib/utils";
9 | import { Metadata } from "next";
10 |
11 | export const metadata: Metadata = generateCommonMeta({
12 | title: "Blog",
13 | description: "Latest blog posts",
14 | image: "/api/og",
15 | });
16 |
17 | const Page = () => {
18 | if (!allSortedBlogs.length)
19 | return (
20 |
21 | No posts found!
22 |
23 | See how to get started{" "}
24 |
28 | here
29 |
30 |
31 |
32 | );
33 |
34 | const blogs = [...allSortedBlogs];
35 | const recentBlogs = blogs.splice(0, 4);
36 | const latestPost = recentBlogs.shift();
37 | const allPostsCount = config.blog.postPerPage - 4;
38 |
39 | return (
40 |
41 |
42 | {latestPost && (
43 | = 3 && [
55 | "lg:grid gap-8 grid-cols-2 lg:col-span-full",
56 | "lg:[&>img]:mb-0 lg:text-lg lg:[&_h3]:text-2xl lg:[&_h3+p]:mt-[1em]",
57 | ],
58 | )}
59 | priority
60 | />
61 | )}
62 |
63 |
64 |
65 |
66 |
67 | {blogs.length > 0 && (
68 |
69 |
70 |
71 | )}
72 |
73 | );
74 | };
75 |
76 | export default Page;
77 |
--------------------------------------------------------------------------------
/app/blog/page/[[...count]]/page.tsx:
--------------------------------------------------------------------------------
1 | import BlogPageLayout from "@/components/layout/blog-page-layout";
2 | import PostPaginator from "@/components/post-paginator";
3 | import config from "@/lib/siteConfig";
4 | import { allSortedBlogs } from "@/lib/contentlayer";
5 | import { notFound } from "next/navigation";
6 |
7 | const totalPages = Math.ceil(allSortedBlogs.length / config.blog.postPerPage);
8 |
9 | export const generateStaticParams = () => {
10 | const params = Array.from({ length: totalPages }).map((_, idx) => ({
11 | count: [`${idx + 1}`],
12 | }));
13 | return params;
14 | };
15 |
16 | const Page = ({ params }: { params: { count?: string[] } }) => {
17 | const count = params.count ? +params.count : 1;
18 | if (!Number.isInteger(count) || count > totalPages) return notFound();
19 |
20 | return (
21 |
22 |
23 |
24 | );
25 | };
26 |
27 | export default Page;
28 |
--------------------------------------------------------------------------------
/app/blog/tags/[[...tag]]/page.tsx:
--------------------------------------------------------------------------------
1 | import BlogPageLayout from "@/components/layout/blog-page-layout";
2 | import PostPaginator from "@/components/post-paginator";
3 | import config from "@/lib/siteConfig";
4 | import { allSortedBlogs } from "@/lib/contentlayer";
5 | import { isArrayNotEmpty, nonNullable, slugify } from "@/lib/utils";
6 | import { notFound } from "next/navigation";
7 |
8 | export const generateStaticParams = () => {
9 | const allTags = allSortedBlogs
10 | .flatMap((post) => post.tags)
11 | .filter(nonNullable)
12 | .map((t) => slugify(t));
13 | return allTags.map((t) => ({ tag: [t] }));
14 | };
15 |
16 | const Page = ({ params }: { params: { tag?: string[] } }) => {
17 | if (!isArrayNotEmpty(params.tag)) notFound();
18 |
19 | const tag = params.tag[0];
20 | const posts = allSortedBlogs.filter(
21 | (post) => post.tags && post.tags.map((tag) => slugify(tag)).includes(tag),
22 | );
23 |
24 | return (
25 |
28 | Showing posts with tag:{" "}
29 |
30 | {tag}
31 |
32 | >
33 | }>
34 |
40 |
41 | );
42 | };
43 |
44 | export default Page;
45 |
--------------------------------------------------------------------------------
/app/layout.tsx:
--------------------------------------------------------------------------------
1 | import Footer from "@/components/footer";
2 | import Header from "@/components/header";
3 | import { fontSans } from "@/lib/fonts";
4 | import config from "@/lib/siteConfig";
5 | import { cn } from "@/lib/utils";
6 | import "@/styles/global.css";
7 | import { Metadata } from "next";
8 |
9 | export const metadata: Metadata = {
10 | title: {
11 | default: config.title,
12 | template: `%s | ${config.title}`,
13 | },
14 | description: config.description,
15 | authors: { name: config.author, url: config.socials.site },
16 | icons: {
17 | icon: [
18 | { url: "/favicon.ico", sizes: "any" },
19 | // { url: "/icon.svg", type: "image/svg+xml" },
20 | ],
21 | // apple: "/apple-touch-icon.png",
22 | },
23 | metadataBase: new URL(config.url),
24 | openGraph: {
25 | type: "website",
26 | title: {
27 | default: config.title,
28 | template: `%s | ${config.title}`,
29 | },
30 | description: config.description,
31 | siteName: config.title,
32 | url: config.url,
33 | images: [config.siteImage],
34 | },
35 | robots: {
36 | index: true,
37 | follow: true,
38 | googleBot: {
39 | index: true,
40 | follow: true,
41 | "max-image-preview": "large",
42 | "max-snippet": -1,
43 | },
44 | },
45 | twitter: {
46 | card: "summary_large_image",
47 | creator: `${config.socials.twitter.replace("https://twitter.com/", "@")}`,
48 | },
49 | };
50 |
51 | export default function RootLayout({
52 | children,
53 | }: {
54 | children: React.ReactNode;
55 | }) {
56 | return (
57 |
58 |
64 |
65 | {children}
66 |
67 |
68 |
69 | );
70 | }
71 |
--------------------------------------------------------------------------------
/app/not-found.tsx:
--------------------------------------------------------------------------------
1 | import Link from "@/components/link";
2 | import { cn } from "@/lib/utils";
3 | import { ArrowRight } from "iconoir-react";
4 |
5 | const NotFound = () => {
6 | return (
7 |
8 | 404
9 | Page not found.
10 |
17 | Go to Home
18 |
19 |
20 |
21 | );
22 | };
23 |
24 | export default NotFound;
25 |
--------------------------------------------------------------------------------
/app/page.tsx:
--------------------------------------------------------------------------------
1 | import { WarningTriangle } from "iconoir-react";
2 |
3 | const Page = () => {
4 | return (
5 |
6 |
7 |
Under construction!
8 |
9 | );
10 | };
11 |
12 | export default Page;
13 |
--------------------------------------------------------------------------------
/app/robots.ts:
--------------------------------------------------------------------------------
1 | import siteConfig from "@/lib/siteConfig";
2 | import { MetadataRoute } from "next";
3 |
4 | export default function robots(): MetadataRoute.Robots {
5 | return {
6 | rules: {
7 | userAgent: "*",
8 | allow: "/",
9 | },
10 | sitemap: `${siteConfig.url}/sitemap.xml`,
11 | };
12 | }
13 |
--------------------------------------------------------------------------------
/app/sitemap.ts:
--------------------------------------------------------------------------------
1 | import siteConfig from "@/lib/siteConfig";
2 | import { allBlogPosts } from "contentlayer/generated";
3 | import { MetadataRoute } from "next";
4 |
5 | export default function sitemap(): MetadataRoute.Sitemap {
6 | const blogs = allBlogPosts.map((post) => ({
7 | url: `${siteConfig.url}/blog/${post.slug}`,
8 | lastModified: post.lastmod ?? post.date,
9 | }));
10 |
11 | const routes = ["", "/about", "/blog"].map((route) => ({
12 | url: `${siteConfig.url}${route}`,
13 | lastModified: new Date(),
14 | }));
15 |
16 | return [...routes, ...blogs];
17 | }
18 |
--------------------------------------------------------------------------------
/components/blog-card.tsx:
--------------------------------------------------------------------------------
1 | import { cn, formatDate, isArrayNotEmpty, slugify } from "@/lib/utils";
2 | import PostTag from "./post-tag";
3 | import Link from "@/components/link";
4 | import Image from "@/components/image";
5 |
6 | type BlogCardProps = {
7 | img?: string;
8 | date: Date | string;
9 | title: string;
10 | desc: string;
11 | tags?: string[];
12 | href: string;
13 | className?: string;
14 | priority?: boolean;
15 | };
16 |
17 | const BlogCard = ({
18 | title,
19 | desc,
20 | tags,
21 | date,
22 | img,
23 | href,
24 | className,
25 | priority = false,
26 | }: BlogCardProps) => {
27 | return (
28 |
29 | {img && (
30 |
37 | )}
38 |
39 |
40 | {formatDate(date)}
41 |
42 |
46 | {title}
47 |
48 |
{desc}
49 | {isArrayNotEmpty(tags) && (
50 |
51 | {tags.map((tag) => (
52 |
53 |
{tag}
54 |
55 | ))}
56 |
57 | )}
58 |
59 |
60 | );
61 | };
62 |
63 | export default BlogCard;
64 |
--------------------------------------------------------------------------------
/components/brand-icon.tsx:
--------------------------------------------------------------------------------
1 | /* Preview icons here: https://simpleicons.org/ */
2 | import {
3 | SiCss3,
4 | SiGnubash,
5 | SiHtml5,
6 | SiJavascript,
7 | SiReact,
8 | SiTypescript,
9 | } from "@icons-pack/react-simple-icons";
10 | import { CodeBrackets } from "iconoir-react";
11 |
12 | const BrandIcon = ({
13 | brand,
14 | ...restProps
15 | }: React.ComponentPropsWithoutRef<"svg"> & { brand: string }) => {
16 | let Icon;
17 |
18 | switch (brand) {
19 | case "js":
20 | case "javascript":
21 | Icon = SiJavascript;
22 | break;
23 | case "ts":
24 | case "typescript":
25 | Icon = SiTypescript;
26 | break;
27 | case "jsx":
28 | case "tsx":
29 | Icon = SiReact;
30 | break;
31 | case "css":
32 | Icon = SiCss3;
33 | break;
34 | case "html":
35 | Icon = SiHtml5;
36 | break;
37 | case "json":
38 | case "jsonc":
39 | Icon = CodeBrackets;
40 | break;
41 | case "sh":
42 | case "bash":
43 | Icon = SiGnubash;
44 | break;
45 | default:
46 | return null;
47 | }
48 |
49 | return ;
50 | };
51 |
52 | export default BrandIcon;
53 |
--------------------------------------------------------------------------------
/components/callout.tsx:
--------------------------------------------------------------------------------
1 | import { cn } from "@/lib/utils";
2 | import { Xmark, InfoCircle, WarningTriangle } from "iconoir-react";
3 |
4 | const calloutVariants = {
5 | note: {
6 | icon: InfoCircle,
7 | title: "Note",
8 | styles: "bg-teal-100 text-teal-950 dark:bg-teal-950 dark:text-teal-50",
9 | },
10 | danger: {
11 | icon: Xmark,
12 | title: "Danger",
13 | styles: "bg-red-100 text-red-950 dark:bg-red-950 dark:text-red-50",
14 | },
15 | warning: {
16 | icon: WarningTriangle,
17 | title: "Warning",
18 | styles:
19 | "bg-yellow-100 text-yellow-950 dark:bg-yellow-950 dark:text-yellow-50",
20 | },
21 | };
22 |
23 | type CalloutProps = {
24 | children: React.ReactNode;
25 | className?: string;
26 | variant?: keyof typeof calloutVariants;
27 | };
28 |
29 | const Callout = ({ children, className, variant = "note" }: CalloutProps) => {
30 | const { icon: Icon, styles, title } = calloutVariants[variant];
31 |
32 | return (
33 |
39 |
40 |
41 | {title}
42 |
43 |
{children}
44 |
45 | );
46 | };
47 |
48 | export default Callout;
49 |
--------------------------------------------------------------------------------
/components/dropdown-menu.tsx:
--------------------------------------------------------------------------------
1 | import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu";
2 | import { forwardRef } from "react";
3 |
4 | export const DropdownMenu = DropdownMenuPrimitive.Root;
5 | export const DropdownMenuTrigger = DropdownMenuPrimitive.Trigger;
6 | export const DropdownMenuItem = DropdownMenuPrimitive.Item;
7 | export const DropdownMenuRadioGroup = DropdownMenuPrimitive.RadioGroup;
8 | export const DropdownMenuRadioItem = DropdownMenuPrimitive.RadioItem;
9 |
10 | export const DropdownMenuContent = forwardRef<
11 | React.ElementRef,
12 | React.ComponentPropsWithoutRef
13 | >(({ children, sideOffset = 5, ...props }, forwardedRef) => {
14 | return (
15 |
16 |
20 | {children}
21 |
22 |
23 | );
24 | });
25 | DropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName;
26 |
--------------------------------------------------------------------------------
/components/footer.tsx:
--------------------------------------------------------------------------------
1 | import config from "@/lib/siteConfig";
2 | import { cn } from "@/lib/utils";
3 | import { Github, Linkedin, Mail, Telegram, Twitter } from "iconoir-react";
4 | import Link from "./link";
5 |
6 | const socialLinks = [
7 | { icon: Github, href: config.socials.github, title: "Github" },
8 | { icon: Linkedin, href: config.socials.linkedin, title: "LinkedIn" },
9 | { icon: Twitter, href: config.socials.twitter, title: "Twitter" },
10 | { icon: Telegram, href: config.socials.telegram, title: "Telegram" },
11 | { icon: Mail, href: `mailto:${config.socials.email}`, title: "Email" },
12 | ];
13 |
14 | const Footer = () => {
15 | return (
16 |
21 |
22 | {socialLinks.map((link) => (
23 |
29 |
30 |
31 | ))}
32 |
33 |
34 |
Built using Next.js, Tailwind CSS and Contentlayer.
35 |
36 | © {new Date().getFullYear()}, Saurabh Charde. All rights reserved.
37 |
38 |
39 |
40 | );
41 | };
42 |
43 | export default Footer;
44 |
--------------------------------------------------------------------------------
/components/header.tsx:
--------------------------------------------------------------------------------
1 | import config from "@/lib/siteConfig";
2 | import Link from "@/components/link";
3 | import ThemeToggle from "@/components/theme-toggle";
4 | import { cn } from "@/lib/utils";
5 | import ThemeProvider from "@/contexts/theme-provider";
6 |
7 | const navLinks = [
8 | { text: "Home", href: "/" },
9 | { text: "Blog", href: "/blog" },
10 | ];
11 |
12 | const Header = () => {
13 | return (
14 |
15 |
20 |
21 |
22 | {config.title}
23 |
24 |
25 |
26 | {navLinks.map((link) => (
27 |
28 |
31 | {link.text}
32 |
33 |
34 | ))}
35 |
36 |
37 |
38 |
39 |
40 |
41 |
42 |
43 | );
44 | };
45 |
46 | export default Header;
47 |
--------------------------------------------------------------------------------
/components/image.tsx:
--------------------------------------------------------------------------------
1 | import sizeOf from "image-size";
2 | import { ISizeCalculationResult } from "image-size/dist/types/interface";
3 | import { ImageProps, default as NextImage } from "next/image";
4 | import { readFile } from "node:fs/promises";
5 | import { IncomingMessage } from "node:http";
6 | import https from "node:https";
7 | import path from "node:path";
8 | import "server-only";
9 |
10 | // https://github.com/image-size/image-size/issues/258
11 | // https://github.com/nickadamson/messonry/commit/1604311247f077718650435b4ca38ae87b41e55d
12 | const getStreamImageSize = async (stream: IncomingMessage) => {
13 | const chunks = [];
14 | for await (const chunk of stream) {
15 | chunks.push(chunk);
16 | try {
17 | // stop requesting data after dimensions are known
18 | return sizeOf(Buffer.concat(chunks));
19 | } catch (error) {
20 | // Not enough buffer to determine sizes yet
21 | }
22 | }
23 | };
24 |
25 | const fetchImageSizeFromUrl = async (imageUrl: string) => {
26 | // Not sure if this is the best way to do it, but it works so ...
27 | try {
28 | const imageSize = await new Promise(
29 | (resolve, reject) =>
30 | https
31 | .get(imageUrl, async (stream) => {
32 | const size = await getStreamImageSize(stream);
33 | if (size) {
34 | resolve(size);
35 | } else {
36 | reject({
37 | reason: `Error while resolving external image size with src: ${imageUrl}`,
38 | });
39 | }
40 | })
41 | .on("error", (e) => {
42 | reject({ reason: e });
43 | }),
44 | );
45 | return imageSize;
46 | } catch (error) {
47 | console.error(error);
48 | }
49 | };
50 |
51 | const fetchImageSizeFromFile = async (imagePath: string) => {
52 | try {
53 | const img = await readFile(imagePath);
54 | return sizeOf(img);
55 | } catch (error) {
56 | console.log(`Error while reading image with path: ${imagePath}`);
57 | console.error(error);
58 | }
59 | };
60 |
61 | const Image = async ({
62 | src,
63 | quality = 100,
64 | ...restProps
65 | }: Omit & { src: string }) => {
66 | if (!src) return null;
67 | const isExternalImage = src.startsWith("https");
68 | const isPublicImage = src.startsWith("/");
69 | const imgProps = { src, quality, ...restProps };
70 | let Img: typeof NextImage | string = "img";
71 |
72 | let size: ISizeCalculationResult | undefined;
73 |
74 | if (isPublicImage) {
75 | size = await fetchImageSizeFromFile(path.join("public", src));
76 | }
77 |
78 | if (isExternalImage) {
79 | size = await fetchImageSizeFromUrl(src);
80 | }
81 |
82 | if (size) {
83 | const { width, height } = size;
84 | imgProps.width = width;
85 | imgProps.height = height;
86 | Img = NextImage;
87 | }
88 |
89 | return ;
90 | };
91 |
92 | export default Image;
93 |
--------------------------------------------------------------------------------
/components/layout/blog-page-layout.tsx:
--------------------------------------------------------------------------------
1 | import { cn } from "@/lib/utils";
2 |
3 | const BlogPageLayout = ({
4 | children,
5 | title,
6 | className,
7 | gridClassName,
8 | as: Element = "section",
9 | }: {
10 | children: React.ReactNode;
11 | title: React.ReactNode;
12 | gridClassName?: string;
13 | className?: string;
14 | as?: keyof React.JSX.IntrinsicElements;
15 | }) => {
16 | return (
17 |
18 | {title}
19 |
24 | {children}
25 |
26 |
27 | );
28 | };
29 |
30 | export default BlogPageLayout;
31 |
--------------------------------------------------------------------------------
/components/link.tsx:
--------------------------------------------------------------------------------
1 | import { ArrowUpRight } from "iconoir-react";
2 | import { default as NextLink } from "next/link";
3 | import { forwardRef } from "react";
4 |
5 | type LinkProps = React.ComponentPropsWithoutRef<"a"> & {
6 | showIcon?: boolean;
7 | };
8 |
9 | const Link = forwardRef(function Link(
10 | { children, showIcon = false, href = "", ...restProps },
11 | forwardedRef,
12 | ) {
13 | // TODO: use regex instead of `startWith`
14 | const isRouterLink = href.startsWith("/");
15 | const isExternalLink = href.startsWith("https") || href.startsWith("http");
16 | // const isAnchorLink = href.startsWith("#");
17 | const Link = isRouterLink ? NextLink : "a";
18 |
19 | return (
20 |
29 | {children}
30 | {isExternalLink && showIcon && (
31 |
32 |
33 |
34 | )}
35 |
36 | );
37 | });
38 |
39 | export default Link;
40 |
--------------------------------------------------------------------------------
/components/mdx/mdx-code-titles.tsx:
--------------------------------------------------------------------------------
1 | import BrandIcon from "../brand-icon";
2 |
3 | type MarkdownCodeTitlesProps = React.ComponentPropsWithoutRef<"div"> & {
4 | [key: `data-${string}`]: string;
5 | };
6 |
7 | const MarkdownCodeTitles = ({
8 | children,
9 | ...restProps
10 | }: MarkdownCodeTitlesProps) => {
11 | if (!("data-rehype-pretty-code-title" in restProps))
12 | return {children}
;
13 |
14 | const language = restProps["data-language"];
15 |
16 | return (
17 |
18 |
24 | {children}
25 |
26 | );
27 | };
28 |
29 | export default MarkdownCodeTitles;
30 |
--------------------------------------------------------------------------------
/components/mdx/mdx-components.tsx:
--------------------------------------------------------------------------------
1 | import type { MDXComponents } from "mdx/types";
2 | import MarkdownImage from "./mdx-image";
3 | import MarkdownTable from "./mdx-table";
4 | import MarkdownLink from "./mdx-link";
5 | import MarkdownCodeTitles from "./mdx-code-titles";
6 | import MarkdownPreCode from "./mdx-pre-code";
7 | import Callout from "../callout";
8 |
9 | const mdxComponents: MDXComponents = {
10 | // @ts-expect-error https://github.com/DefinitelyTyped/DefinitelyTyped/pull/65003
11 | img: MarkdownImage,
12 | a: MarkdownLink,
13 | div: MarkdownCodeTitles as any,
14 | pre: MarkdownPreCode,
15 | table: MarkdownTable,
16 | Callout,
17 | };
18 |
19 | export default mdxComponents;
20 |
--------------------------------------------------------------------------------
/components/mdx/mdx-content.tsx:
--------------------------------------------------------------------------------
1 | import { useMDXComponent } from "next-contentlayer/hooks";
2 | import mdxComponents from "./mdx-components";
3 |
4 | type MDXContentProps = {
5 | code: string;
6 | };
7 |
8 | const MDXContent = ({ code }: MDXContentProps) => {
9 | const Content = useMDXComponent(code);
10 | return ;
11 | };
12 |
13 | export default MDXContent;
14 |
--------------------------------------------------------------------------------
/components/mdx/mdx-copy-code-btn.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import { copyToClipboard } from "@/lib/utils";
4 | import { Check, Copy } from "iconoir-react";
5 | import { MouseEventHandler, useState } from "react";
6 |
7 | const MarkdownCopyCodeButton = ({ className }: { className?: string }) => {
8 | const [isCopied, setIsCopied] = useState(false);
9 |
10 | const handleClick: MouseEventHandler = async (e) => {
11 | const parent = e.currentTarget.parentElement;
12 | if (!parent) return;
13 |
14 | const copied = await copyToClipboard(parent.innerText);
15 | setIsCopied(copied);
16 | setTimeout(() => {
17 | setIsCopied(false);
18 | }, 3500);
19 | };
20 |
21 | return (
22 |
23 | {isCopied ? : }
24 |
25 | );
26 | };
27 |
28 | export default MarkdownCopyCodeButton;
29 |
--------------------------------------------------------------------------------
/components/mdx/mdx-image.tsx:
--------------------------------------------------------------------------------
1 | import Image from "@/components/image";
2 | import { ImageProps } from "next/image";
3 |
4 | const MarkdownImage = async ({
5 | src,
6 | alt = "",
7 | ...restProps
8 | }: ImageProps & { src: string }) => {
9 | return (
10 |
11 |
17 | {alt && {alt} }
18 |
19 | );
20 | };
21 |
22 | export default MarkdownImage;
23 |
--------------------------------------------------------------------------------
/components/mdx/mdx-link.tsx:
--------------------------------------------------------------------------------
1 | import config from "@/lib/siteConfig";
2 | import Link from "@/components/link";
3 |
4 | const MarkdownLink = ({
5 | target,
6 | href = "",
7 | ...restProps
8 | }: React.ComponentPropsWithoutRef<"a">) => {
9 | const isExternalLink = href.startsWith("https");
10 | const linkTarget =
11 | isExternalLink && config.blog.openAllExternalLinksInNewTab
12 | ? "_blank"
13 | : target;
14 | return (
15 |
16 | );
17 | };
18 |
19 | export default MarkdownLink;
20 |
--------------------------------------------------------------------------------
/components/mdx/mdx-pre-code.tsx:
--------------------------------------------------------------------------------
1 | import MarkdownCopyCodeButton from "./mdx-copy-code-btn";
2 |
3 | const MarkdownPreCode = ({
4 | children,
5 | ...restProps
6 | }: React.ComponentPropsWithoutRef<"pre">) => {
7 | if (!("data-language" in restProps))
8 | return {children} ;
9 |
10 | return (
11 |
12 |
13 | {children}
14 |
15 | );
16 | };
17 |
18 | export default MarkdownPreCode;
19 |
--------------------------------------------------------------------------------
/components/mdx/mdx-table.tsx:
--------------------------------------------------------------------------------
1 | const MarkdownTable = (props: React.ComponentPropsWithoutRef<"table">) => {
2 | return (
3 |
6 | );
7 | };
8 |
9 | export default MarkdownTable;
10 |
--------------------------------------------------------------------------------
/components/post-paginator.tsx:
--------------------------------------------------------------------------------
1 | import { BlogPost } from "contentlayer/generated";
2 | import RenderPosts from "./render-posts";
3 | import { allSortedBlogs } from "@/lib/contentlayer";
4 | import { cn } from "@/lib/utils";
5 | import { ArrowLeft, ArrowRight } from "iconoir-react";
6 | import Link from "./link";
7 |
8 | const PaginatorButton = ({
9 | pageLink,
10 | disable = false,
11 | children,
12 | }: {
13 | children: React.ReactNode;
14 | pageLink: string;
15 | disable?: boolean;
16 | }) => {
17 | return (
18 |
21 | {children}
22 |
23 | );
24 | };
25 |
26 | const PostPaginatorNav = ({
27 | currentPage,
28 | totalPages,
29 | pageLink,
30 | }: {
31 | currentPage: number;
32 | totalPages: number;
33 | pageLink: string;
34 | }) => {
35 | return (
36 |
37 |
40 |
41 | Previous
42 |
43 |
44 | {Array.from({ length: totalPages }).map((_, idx) => {
45 | const pageNumber = idx + 1;
46 | return (
47 |
48 |
55 | {pageNumber}
56 |
57 |
58 | );
59 | })}
60 |
61 |
64 | Next
65 |
66 |
67 |
68 | );
69 | };
70 |
71 | const PostPaginator = ({
72 | page,
73 | postPerPage,
74 | posts,
75 | pageLink = "/blog/page",
76 | }: {
77 | page: number;
78 | postPerPage: number;
79 | posts?: BlogPost[];
80 | pageLink?: string;
81 | }) => {
82 | const currentPage = page <= 1 ? 1 : page;
83 | const blogs = Array.isArray(posts) ? posts : allSortedBlogs;
84 | const totalPages = Math.ceil(blogs.length / postPerPage);
85 | const toSlice = currentPage === 1 ? 0 : postPerPage * (currentPage - 1);
86 | const postsToShow = blogs.slice(toSlice, postPerPage * currentPage);
87 |
88 | return (
89 | <>
90 |
91 | {totalPages > 1 && (
92 |
97 | )}
98 | >
99 | );
100 | };
101 |
102 | export default PostPaginator;
103 |
--------------------------------------------------------------------------------
/components/post-tag.tsx:
--------------------------------------------------------------------------------
1 | import { cn } from "@/lib/utils";
2 |
3 | const tagColors = [
4 | "dark:text-purple-50 dark:bg-purple-950 text-purple-950 bg-purple-100",
5 | "dark:text-amber-50 dark:bg-amber-950 text-amber-950 bg-amber-100",
6 | "dark:text-teal-50 dark:bg-teal-950 text-teal-950 bg-teal-100",
7 | "dark:text-lime-50 dark:bg-lime-950 text-lime-950 bg-lime-100",
8 | "dark:text-cyan-50 dark:bg-cyan-950 text-cyan-950 bg-cyan-100",
9 | "dark:text-violet-50 dark:bg-violet-950 text-violet-950 bg-violet-100",
10 | "dark:text-yellow-50 dark:bg-yellow-950 text-yellow-950 bg-yellow-100",
11 | "dark:text-pink-50 dark:bg-pink-950 text-pink-950 bg-pink-100",
12 | "dark:text-blue-50 dark:bg-blue-950 text-blue-950 bg-blue-100",
13 | ];
14 | let currentIdx = 0;
15 |
16 | const PostTag = ({ children }: { children: string }) => {
17 | const tagColor = tagColors[currentIdx++];
18 | currentIdx = currentIdx >= tagColors.length ? 0 : currentIdx;
19 |
20 | return (
21 |
22 | {children}
23 |
24 | );
25 | };
26 |
27 | export default PostTag;
28 |
--------------------------------------------------------------------------------
/components/prose-layout.tsx:
--------------------------------------------------------------------------------
1 | import { cn } from "@/lib/utils";
2 |
3 | const ProseLayout = ({
4 | children,
5 | className,
6 | ...restProps
7 | }: React.ComponentProps<"div">) => {
8 | return (
9 |
12 | {children}
13 |
14 | );
15 | };
16 |
17 | export default ProseLayout;
18 |
--------------------------------------------------------------------------------
/components/render-posts.tsx:
--------------------------------------------------------------------------------
1 | import { BlogPost } from "@/.contentlayer/generated";
2 | import BlogCard from "./blog-card";
3 |
4 | const RenderPosts = ({ posts }: { posts: BlogPost[] }) => {
5 | return posts.map((post) => (
6 |
15 | ));
16 | };
17 |
18 | export default RenderPosts;
19 |
--------------------------------------------------------------------------------
/components/scroll-button.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import { cn } from "@/lib/utils";
4 | import { ArrowUp } from "iconoir-react";
5 | import { useEffect, useState } from "react";
6 |
7 | const ScrollButton = () => {
8 | const [scrolled, setScrolled] = useState(false);
9 | const handleScroll = () => {
10 | const userHasScrolled = window.scrollY >= window.innerHeight / 2;
11 | setScrolled(userHasScrolled);
12 | };
13 |
14 | useEffect(() => {
15 | window.addEventListener("scroll", handleScroll);
16 | return () => window.removeEventListener("scroll", handleScroll);
17 | });
18 |
19 | return (
20 | window.scrollTo({ top: 0, behavior: "smooth" })}>
32 |
33 |
34 | );
35 | };
36 |
37 | export default ScrollButton;
38 |
--------------------------------------------------------------------------------
/components/theme-toggle.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import { Moon } from "@/lib/icons";
4 | import { SunLight } from "iconoir-react";
5 | import { useTheme } from "next-themes";
6 | import { cn } from "@/lib/utils";
7 | import {
8 | DropdownMenu,
9 | DropdownMenuContent,
10 | DropdownMenuRadioGroup,
11 | DropdownMenuRadioItem,
12 | DropdownMenuTrigger,
13 | } from "@/components/dropdown-menu";
14 | import { useEffect, useState } from "react";
15 |
16 | const ThemeToggle = () => {
17 | const [mounted, setMounted] = useState(false);
18 | const { theme, themes, resolvedTheme, setTheme } = useTheme();
19 |
20 | useEffect(() => {
21 | setMounted(true);
22 | }, []);
23 |
24 | return (
25 |
26 |
32 | {mounted && resolvedTheme === "dark" ? : }
33 |
34 |
42 | *]:capitalize",
47 | "bg-background-secondary p-1 rounded-md border shadow-md border-borders",
48 | "min-w-[120px]",
49 | )}>
50 | {themes.map((theme) => (
51 |
58 | {theme}
59 |
60 | ))}
61 |
62 |
63 |
64 | );
65 | };
66 |
67 | export default ThemeToggle;
68 |
--------------------------------------------------------------------------------
/components/toc.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import Link from "@/components/link";
4 | import { cn, isArrayNotEmpty } from "@/lib/utils";
5 | import { NavArrowRight } from "iconoir-react";
6 | import { useEffect, useRef, useState } from "react";
7 | import {
8 | DropdownMenu,
9 | DropdownMenuContent,
10 | DropdownMenuItem,
11 | DropdownMenuTrigger,
12 | } from "@/components/dropdown-menu";
13 |
14 | type TocProps = {
15 | contents: Array<{ text: string; slug: string; depth: number }>;
16 | className?: string;
17 | };
18 |
19 | export const TocMobile = ({ contents, className }: TocProps) => {
20 | if (!isArrayNotEmpty(contents)) return null;
21 |
22 | return (
23 |
24 |
25 | svg]:data-[state=open]:rotate-90 [&>svg]:text-xs",
31 | )}>
32 | On this page
33 |
34 |
35 | e.preventDefault()}
39 | align="start"
40 | sideOffset={10}
41 | avoidCollisions={false}
42 | className={cn(
43 | "w-[calc(var(--radix-dropdown-menu-content-available-width)-var(--px-padding))]",
44 | "lg:w-fit xl:hidden",
45 | "data-[state=open]:animate-slide-up-fade",
46 | "data-[state=closed]:animate-slide-down-fade",
47 | )}>
48 |
55 |
56 | {contents.map((e) => {
57 | if (e.depth > 2) return;
58 | return (
59 |
60 |
61 |
64 | {e.text}
65 |
66 |
67 |
68 | );
69 | })}
70 |
71 |
72 |
73 |
74 |
75 | );
76 | };
77 |
78 | const TocDesktop = ({ contents, className }: TocProps) => {
79 | const [hash, setHash] = useState("");
80 | const allAnchorLinksRef = useRef>();
81 | const observerRef = useRef();
82 |
83 | useEffect(() => {
84 | allAnchorLinksRef.current = document.querySelectorAll("h2[id]");
85 | observerRef.current = new IntersectionObserver(
86 | (entries) => {
87 | entries.forEach((entry) => {
88 | if (entry.isIntersecting) {
89 | setHash(entry.target.id);
90 | }
91 | });
92 | },
93 | { rootMargin: "0px 0px -70% 0px" },
94 | );
95 | allAnchorLinksRef.current.forEach((e) => observerRef.current?.observe(e));
96 |
97 | return () => {
98 | observerRef.current?.disconnect();
99 | };
100 | }, []);
101 |
102 | if (!contents.length) return null;
103 | return (
104 |
105 |
106 | On this page
107 |
108 | {contents.map((e) => {
109 | if (e.depth > 2) return;
110 | return (
111 |
112 |
120 | {e.text}
121 |
122 |
123 | );
124 | })}
125 |
126 |
127 |
128 | );
129 | };
130 |
131 | export default TocDesktop;
132 |
--------------------------------------------------------------------------------
/content/blog/lorem-ipsum.mdx:
--------------------------------------------------------------------------------
1 | ---
2 | title: Lorem Ipsum
3 | description: Lorem ipsum dolor, sit amet consectetur adipisicing elit. Consequatur harum reprehenderit, nemo veniam sit facere voluptates tempore delectus nam? Esse et voluptatibus est adipisci odit commodi eum.
4 | date: "2003-01-12"
5 | tags:
6 | - Lorem
7 | - Ipsum
8 | - dolar
9 | - esse
10 | - et
11 | - sit
12 | - amet
13 | - amet
14 | ---
15 |
16 | ## Heading 2
17 |
18 | ### Heading 3
19 |
20 | #### Heading 4
21 |
22 | ##### Heading 5
23 |
24 | ###### Heading 6
25 |
26 | ### Always start your top-level headings with ``
27 |
28 | There should only be one `` tag in the whole page, which is reserved by the blog post title, so you should always start your top level headings with `` tag instead. If you still include an `` tag then the **entire post headings will be normalized by increasing their heading level by 1** (i.e, `` -> `` and `` -> ``).
29 |
30 | ## Links
31 |
32 | Markup:
33 |
34 | ```
35 | This is a paragraph that contains an [external link](https://web.dev/blog/), [an internal link](/about) and an [anchor link](#heading-2).
36 |
37 | Raw links can be auto-linked, just like Github. For example:
38 | www.example.com
39 | https://github.com
40 | ```
41 |
42 | Rendered:
43 |
44 | This is a paragraph that contains an [external link](https://web.dev/blog/), [an internal link](/about) and an [anchor link](#heading-2).
45 |
46 | Raw links can be auto-linked, just like Github. For example:
47 | www.example.com
48 | https://github.com
49 |
50 | ## Blockquotes
51 |
52 | Markup:
53 |
54 | ```
55 | > This is a normal blockquote.
56 |
57 | > Blockquotes can be nested as well of course.
58 | >
59 | > > see.
60 | ```
61 |
62 | Rendered:
63 |
64 | > This is a normal blockquote.
65 |
66 | > Blockquotes can be nested as well of course.
67 | >
68 | > > see.
69 |
70 | ## Callout cards
71 |
72 | Markup:
73 |
74 | ````
75 | This is the default callout card
76 |
77 | This is a warning callout
78 |
79 |
80 | This is a danger callout
81 |
82 | ```tsx
83 | "use strict";
84 |
85 | const obj = { a: "Hello", b: "World" };
86 | const test = ["This", "is", "cool", "isn't", "it?"]
87 | ```
88 |
89 | ```tsx
90 | const Page = () => {
91 | return Hellow World!
;
92 | };
93 | ```
94 |
95 | ````
96 |
97 | Rendered:
98 |
99 | This is the default callout card
100 |
101 | This is a warning callout
102 |
103 |
104 | This is a danger callout
105 |
106 | ```tsx
107 | "use strict";
108 |
109 | const obj = { a: "Hello", b: "World" };
110 | const test = ["This", "is", "cool", "isn't", "it?"]
111 | ```
112 |
113 | ```tsx
114 | const Page = () => {
115 | return Hellow World!
;
116 | };
117 | ```
118 |
119 |
120 |
121 | ## Lists
122 |
123 | Markup:
124 |
125 | ```
126 | - This
127 | - is
128 | - a
129 | - nested
130 | - list
131 | - cool
132 | - Similar to how in Github
133 | ```
134 |
135 | Rendered:
136 |
137 | - This
138 | - is
139 | - a
140 | - nested
141 | - list
142 | - cool
143 | - Similar to how in Github
144 |
145 | ## Tables
146 |
147 | Markup:
148 |
149 | ```
150 | | this | is | a | table |
151 | | ----- | ----- | ----- | ----- |
152 | | col 1 | col 2 | col 3 | col 4 |
153 | | col 1 | col 2 | col 3 | col 4 |
154 | | col 1 | col 2 | col 3 | col 4 |
155 | ```
156 |
157 | Rendered:
158 |
159 | | this | is | a | table |
160 | | ----- | ----- | ----- | ----- |
161 | | col 1 | col 2 | col 3 | col 4 |
162 | | col 1 | col 2 | col 3 | col 4 |
163 | | col 1 | col 2 | col 3 | col 4 |
164 |
165 | Markup:
166 |
167 | ```
168 | | Left aligned Header | Right aligned Header | Center aligned Header |
169 | | :------------------ | -------------------: | :-------------------: |
170 | | Content Cell | Content Cell | Content Cell |
171 | | Content Cell | Content Cell | Content Cell |
172 | ```
173 |
174 | Rendered:
175 |
176 | | Left aligned Header | Right aligned Header | Center aligned Header |
177 | | :------------------ | -------------------: | :-------------------: |
178 | | Content Cell | Content Cell | Content Cell |
179 | | Content Cell | Content Cell | Content Cell |
180 |
181 | ## Code Blocks
182 |
183 | Markup:
184 |
185 | ````
186 | ```js showLineNumbers
187 | // This is a javascript code block
188 | const ls = window.localStorage;
189 | ls.setItem("awesome", "blog-template");
190 | ```
191 | ````
192 |
193 | Rendered:
194 |
195 | ```js showLineNumbers
196 | // This is a javascript code block
197 | const ls = window.localStorage;
198 | ls.setItem("awesome", "blog-template");
199 | ```
200 |
201 | ### Code block with title and caption
202 |
203 | Markup:
204 |
205 | ````
206 | ```tsx title="app/components/mdx-content.tsx"
207 | import mdxComponents from "@/components/mdx-components";
208 | import { useMDXComponent } from "next-contentlayer/hooks";
209 |
210 | type MDXContentProps = {
211 | code: string;
212 | };
213 |
214 | const MDXContent = ({ code }: MDXContentProps) => {
215 | const Content = useMDXComponent(code);
216 | return ;
217 | };
218 |
219 | export default MDXContent;
220 | ```
221 | ````
222 |
223 | Rendered:
224 |
225 | ```tsx title="app/components/mdx-content.tsx"
226 | import mdxComponents from "@/components/mdx-components";
227 | import { useMDXComponent } from "next-contentlayer/hooks";
228 |
229 | type MDXContentProps = {
230 | code: string;
231 | };
232 |
233 | const MDXContent = ({ code }: MDXContentProps) => {
234 | const Content = useMDXComponent(code);
235 | return ;
236 | };
237 |
238 | export default MDXContent;
239 | ```
240 |
241 | The file icon in the code titles are based on the language provided to the code block.
242 |
243 | #### Code with caption below:
244 |
245 | Markup:
246 |
247 | ````
248 | ```js caption="Caption for above code block"
249 | // This is a javascript code block
250 | const ls = window.localStorage;
251 | ls.setItem("awesome", "blog-template");
252 | ```
253 | ````
254 |
255 | Rendered:
256 |
257 | ```js caption="Caption for above code block"
258 | // This is a javascript code block
259 | const ls = window.localStorage;
260 | ls.setItem("awesome", "blog-template");
261 | ```
262 |
263 | ### Highlighting line number in code blocks
264 |
265 | Markup;
266 |
267 | ````
268 | ```tsx {2}
269 | // This is a javascript code block
270 | const ls = window.localStorage;
271 | ls.setItem("awesome", "blog-template");
272 | ```
273 | ````
274 |
275 | Rendered:
276 |
277 | ```tsx {2}
278 | // This is a javascript code block
279 | const ls = window.localStorage;
280 | ls.setItem("awesome", "blog-template");
281 | ```
282 |
283 | Range highlighting:
284 |
285 | Markup:
286 |
287 | ````
288 | ```js title="page.tsx" {5-10}
289 | // This is a javascript code block
290 | const ls = window.localStorage;
291 | ls.setItem("awesome", "blog-template");
292 |
293 | try {
294 | const res = await fetch(process.env.API_URL);
295 | } catch (e) {
296 | // this is what peak error handling looks like
297 | console.log(e);
298 | }
299 | ```
300 | ````
301 |
302 | Rendered:
303 |
304 | ```js title="page.tsx" {5-10}
305 | // This is a javascript code block
306 | const ls = window.localStorage;
307 | ls.setItem("awesome", "blog-template");
308 |
309 | try {
310 | const res = await fetch(process.env.API_URL);
311 | } catch (e) {
312 | // this is what peak error handling looks like
313 | console.log(e);
314 | }
315 | ```
316 |
317 | ### Highlighting words in code blocks
318 |
319 | Markup;
320 |
321 | ````
322 | ```tsx /title/1-2,4
323 | export const generateCommonMeta = (meta: {
324 | title: string;
325 | description: string;
326 | image?: string;
327 | }): Metadata => {
328 | return {
329 | title: meta.title,
330 | description: meta.description,
331 | openGraph: {
332 | title: meta.title,
333 | description: meta.description,
334 | images: [meta.image || config.siteImage],
335 | },
336 | };
337 | };
338 | ```
339 | ````
340 |
341 | Rendered:
342 |
343 | ```tsx /title/1-2,4
344 | export const generateCommonMeta = (meta: {
345 | title: string;
346 | description: string;
347 | image?: string;
348 | }): Metadata => {
349 | return {
350 | title: meta.title,
351 | description: meta.description,
352 | openGraph: {
353 | title: meta.title,
354 | description: meta.description,
355 | images: [meta.image || config.siteImage],
356 | },
357 | };
358 | };
359 | ```
360 |
361 | ### Inline code
362 |
363 | Markup:
364 |
365 | ```
366 | This is an inline code with syntax highlighting: `code(){:js}`
367 |
368 | This is a normal inline code `code()`
369 | ```
370 |
371 | Rendered:
372 |
373 | This is an inline code with syntax highlighting: `code(){:js}`
374 |
375 | This is a normal inline code `code()`
376 |
377 | ## Images
378 |
379 | Markup:
380 |
381 | ```
382 | 
383 |
384 | 
385 | ```
386 |
387 | Rendered:
388 |
389 | 
390 |
391 | 
392 |
393 | ## Text styles
394 |
395 | Markup:
396 |
397 | ```
398 | **This is a bold text**
399 |
400 | _This is an italic text_
401 |
402 | **_This is a strong italic text_**
403 |
404 | ~~This is a strikethrough text~~
405 |
406 | Below is a seperator
407 |
408 | ---
409 | ```
410 |
411 | Rendered:
412 |
413 | **This is a bold text**
414 |
415 | _This is an italic text_
416 |
417 | **_This is a strong italic text_**
418 |
419 | ~~This is a strikethrough text~~
420 |
421 | Below is a seperator
422 |
423 | ---
424 |
425 | Markup:
426 |
427 | ```
428 | - [ ] An uncompleted task
429 | - [x] A completed task
430 | ```
431 |
432 | Rendered:
433 |
434 | - [ ] An uncompleted task
435 | - [x] A completed task
436 |
--------------------------------------------------------------------------------
/contentlayer.config.ts:
--------------------------------------------------------------------------------
1 | import { makeSource } from "contentlayer/source-files";
2 | import flattenImageParagraphs from "mdast-flatten-image-paragraphs";
3 | import rehypeAutolinkHeadings from "rehype-autolink-headings";
4 | import rehypePrettyCode from "rehype-pretty-code";
5 | import rehypeSlug from "rehype-slug";
6 | import remarkGfm from "remark-gfm";
7 | import remarkNormalizeHeadings from "./lib/mdx/remark-normalize-headings";
8 | import BlogPost from "./schema/contentlayer/blog-post";
9 |
10 | export default makeSource({
11 | contentDirPath: "content",
12 | documentTypes: [BlogPost],
13 | mdx: {
14 | remarkPlugins: [remarkGfm, flattenImageParagraphs, remarkNormalizeHeadings],
15 | rehypePlugins: [
16 | rehypeSlug,
17 | [
18 | rehypeAutolinkHeadings,
19 | {
20 | behavior: "append",
21 | properties: {
22 | className: ["anchor-link"],
23 | ariaHidden: true,
24 | },
25 | },
26 | ],
27 | [
28 | rehypePrettyCode,
29 | {
30 | theme: { dark: "github-dark", light: "github-light" },
31 | keepBackground: false,
32 | },
33 | ],
34 | ],
35 | },
36 | });
37 |
--------------------------------------------------------------------------------
/contexts/theme-provider.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import { ThemeProvider as NextThemeProvider } from "next-themes";
4 |
5 | const ThemeProvider = ({ children }: { children: React.ReactNode }) => {
6 | return {children} ;
7 | };
8 |
9 | export default ThemeProvider;
10 |
--------------------------------------------------------------------------------
/global.d.ts:
--------------------------------------------------------------------------------
1 | declare module "server-only";
2 | declare module "mdast-flatten-image-paragraphs" {
3 | export default function flattenImageParagraphs(): void;
4 | }
5 |
--------------------------------------------------------------------------------
/lib/contentlayer.ts:
--------------------------------------------------------------------------------
1 | import { allBlogPosts } from "contentlayer/generated";
2 |
3 | /** Returns all blog posts sorted by date (latest first) */
4 | export const allSortedBlogs = [
5 | ...allBlogPosts.sort(
6 | (a, b) => new Date(b.date).getTime() - new Date(a.date).getTime(),
7 | ),
8 | ];
9 |
--------------------------------------------------------------------------------
/lib/fonts.ts:
--------------------------------------------------------------------------------
1 | import localFont from "next/font/local";
2 |
3 | export const fontSans = localFont({
4 | variable: "--font-sans",
5 | src: [
6 | {
7 | path: "../public/fonts/Biotif-Regular.woff2",
8 | style: "normal",
9 | weight: "400",
10 | },
11 | {
12 | path: "../public/fonts/Biotif-RegularItalic.woff2",
13 | style: "italic",
14 | weight: "400",
15 | },
16 | {
17 | path: "../public/fonts/Biotif-Medium.woff2",
18 | style: "normal",
19 | weight: "500",
20 | },
21 | {
22 | path: "../public/fonts/Biotif-MediumItalic.woff2",
23 | style: "italic",
24 | weight: "500",
25 | },
26 | {
27 | path: "../public/fonts/Biotif-Bold.woff2",
28 | style: "normal",
29 | weight: "700",
30 | },
31 | {
32 | path: "../public/fonts/Biotif-BoldItalic.woff2",
33 | style: "italic",
34 | weight: "700",
35 | },
36 | ],
37 | });
38 |
--------------------------------------------------------------------------------
/lib/icons.tsx:
--------------------------------------------------------------------------------
1 | export const Trend = () => (
2 |
7 |
13 |
14 |
15 |
16 |
17 | );
18 |
19 | export const Moon = () => (
20 |
25 |
30 |
31 | );
32 |
--------------------------------------------------------------------------------
/lib/mdx/contentlayer-extract-headings.ts:
--------------------------------------------------------------------------------
1 | import GithubSlugger from "github-slugger";
2 | import { fromMarkdown } from "mdast-util-from-markdown";
3 | import { toString } from "mdast-util-to-string";
4 | import { visit } from "unist-util-visit";
5 |
6 | type Headings = Array<{ depth: number; text: string; slug: string }>;
7 |
8 | export default function extractHeadings(rawPostBody: string) {
9 | const slugger = new GithubSlugger();
10 | const tree = fromMarkdown(rawPostBody);
11 | const headings: Headings = [];
12 | visit(tree, "heading", (node) => {
13 | const text = toString(node);
14 | headings.push({
15 | depth: node.depth,
16 | slug: slugger.slug(text),
17 | text,
18 | });
19 | });
20 | slugger.reset();
21 | return headings;
22 | }
23 |
--------------------------------------------------------------------------------
/lib/mdx/remark-normalize-headings.ts:
--------------------------------------------------------------------------------
1 | import { visit } from "unist-util-visit";
2 | import { Heading, Root } from "mdast";
3 |
4 | /* code taken from https://github.com/syntax-tree/mdast-normalize-headings with slight modifications */
5 | export default function remarkNormalizeHeadings() {
6 | const max = 6;
7 |
8 | return (tree: Root) => {
9 | const all: Array = [];
10 | let multiple = false;
11 | let heading: Heading | undefined;
12 |
13 | visit(tree, "heading", function (node) {
14 | all.push(node);
15 | if (node.depth === 1) {
16 | if (heading) multiple = true;
17 | else heading = node;
18 | }
19 | });
20 |
21 | // If there are multiple H1 headings increase their depth by one
22 | if (multiple) {
23 | let index = -1;
24 | while (++index < all.length) {
25 | const heading = all[index];
26 | if (heading.depth < max) {
27 | heading.depth++;
28 | }
29 | }
30 | } else if (heading) {
31 | heading.depth++;
32 | }
33 | };
34 | }
35 |
--------------------------------------------------------------------------------
/lib/siteConfig.ts:
--------------------------------------------------------------------------------
1 | const siteConfig = {
2 | title: "Blog",
3 | description:
4 | "A static minimalistic blog template powered by Next.js and Contentlayer.",
5 | author: "Saurabh Charde",
6 | url: "https://nextjs-contentlayer-tailwind.vercel.app",
7 | siteImage: "/images/announcement-banner.png",
8 | socials: {
9 | github: "https://github.com/schardev",
10 | linkedin: "https://linkedin.com/in/scharde",
11 | twitter: "https://twitter.com/saurabhcharde",
12 | telegram: "https://t.me/saurabhcharde",
13 | email: "saurabhchardereal@gmail.com",
14 | site: "https://schar.dev",
15 | },
16 | blog: {
17 | postPerPage: 10,
18 | openAllExternalLinksInNewTab: true,
19 | },
20 | };
21 |
22 | export default siteConfig;
23 |
--------------------------------------------------------------------------------
/lib/utils.ts:
--------------------------------------------------------------------------------
1 | import clsx, { ClassValue } from "clsx";
2 | import { twMerge } from "tailwind-merge";
3 | import { slug } from "github-slugger";
4 | import { Metadata } from "next";
5 | import config from "../lib/siteConfig";
6 |
7 | export const cn = (...classNames: ClassValue[]) => {
8 | return twMerge(clsx(classNames));
9 | };
10 |
11 | export const formatDate = (
12 | date: string | Date,
13 | dateStyle: Intl.DateTimeFormatOptions["dateStyle"] = "medium",
14 | ) => {
15 | if (typeof date === "string") date = new Date(date);
16 | return Intl.DateTimeFormat("en-US", { dateStyle: dateStyle }).format(date);
17 | };
18 |
19 | export const isArrayNotEmpty = (arr: T[] | undefined): arr is T[] => {
20 | if (Array.isArray(arr) && arr.length > 0) return true;
21 | return false;
22 | };
23 |
24 | export const copyToClipboard = async (text: string) => {
25 | try {
26 | await navigator.clipboard.writeText(text);
27 | } catch (error) {
28 | console.error(error);
29 | return false;
30 | }
31 | return true;
32 | };
33 |
34 | export const slugify = (str: string) => {
35 | return slug(str);
36 | };
37 |
38 | export const nonNullable = (value: T): value is NonNullable =>
39 | value !== null && value !== undefined;
40 |
41 | export const generateCommonMeta = (meta: {
42 | title: string;
43 | description: string;
44 | image?: string;
45 | }): Metadata => {
46 | return {
47 | title: meta.title,
48 | description: meta.description,
49 | openGraph: {
50 | title: meta.title,
51 | description: meta.description,
52 | images: [meta.image || config.siteImage],
53 | },
54 | };
55 | };
56 |
--------------------------------------------------------------------------------
/next.config.js:
--------------------------------------------------------------------------------
1 | import { withContentlayer } from "next-contentlayer";
2 | import withBundleAnalyzer from "@next/bundle-analyzer";
3 |
4 | /** @type {import('next').NextConfig} */
5 | const nextConfig = {
6 | reactStrictMode: true,
7 | images: {
8 | remotePatterns: [
9 | {
10 | protocol: "https",
11 | hostname: "images.unsplash.com",
12 | pathname: "**",
13 | },
14 | ],
15 | },
16 | experimental: {
17 | optimizePackageImports: ["iconoir-react", "@icons-pack/react-simple-icons"],
18 | },
19 | };
20 |
21 | const config = () => {
22 | const plugins = [
23 | withBundleAnalyzer({ enabled: process.env.ANALYZE === "true" }),
24 | withContentlayer,
25 | ];
26 | return plugins.reduce((acc, next) => next(acc), nextConfig);
27 | };
28 |
29 | export default config;
30 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "nextjs-contentlayer-blog",
3 | "version": "0.1.0",
4 | "private": true,
5 | "type": "module",
6 | "scripts": {
7 | "dev": "next dev",
8 | "build": "next build",
9 | "build:contentlayer": "contentlayer build",
10 | "start": "next start",
11 | "lint": "next lint && prettier --check .",
12 | "typecheck": "tsc --noEmit",
13 | "format": "prettier --write ."
14 | },
15 | "dependencies": {
16 | "@next/bundle-analyzer": "^14.0.1",
17 | "@radix-ui/react-dropdown-menu": "^2.0.6",
18 | "clsx": "^2.0.0",
19 | "github-slugger": "^2.0.0",
20 | "image-size": "^1.0.2",
21 | "next": "^14.0.1",
22 | "next-themes": "^0.3.0",
23 | "react": "^18.2.0",
24 | "react-dom": "^18.2.0",
25 | "server-only": "^0.0.1",
26 | "sharp": "^0.33.0"
27 | },
28 | "devDependencies": {
29 | "@icons-pack/react-simple-icons": "^9.1.0",
30 | "@tailwindcss/typography": "^0.5.10",
31 | "@types/mdast": "^4.0.2",
32 | "@types/mdx": "^2.0.9",
33 | "@types/node": "^20.8.10",
34 | "@types/react": "^18.2.33",
35 | "@types/react-dom": "^18.2.14",
36 | "autoprefixer": "^10.4.16",
37 | "contentlayer": "^0.3.4",
38 | "eslint": "^8.52.0",
39 | "eslint-config-next": "^14.0.1",
40 | "eslint-config-prettier": "^9.0.0",
41 | "iconoir-react": "^7.0.0",
42 | "mdast-flatten-image-paragraphs": "^1.0.0",
43 | "mdast-util-from-markdown": "^2.0.0",
44 | "mdast-util-to-string": "^4.0.0",
45 | "next-contentlayer": "^0.3.4",
46 | "postcss": "^8.4.31",
47 | "prettier": "^3.0.3",
48 | "rehype-autolink-headings": "^7.0.0",
49 | "rehype-pretty-code": "^0.10.2",
50 | "rehype-slug": "^6.0.0",
51 | "remark-gfm": "^3.0.1",
52 | "shiki": "^0.14.5",
53 | "tailwind-merge": "^2.0.0",
54 | "tailwindcss": "^3.3.5",
55 | "typescript": "^5.2.2",
56 | "unist-util-visit": "^5.0.0"
57 | },
58 | "prettier": {
59 | "bracketSameLine": true
60 | }
61 | }
62 |
--------------------------------------------------------------------------------
/postcss.config.cjs:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | plugins: {
3 | "tailwindcss/nesting": {},
4 | tailwindcss: {},
5 | autoprefixer: {},
6 | },
7 | };
8 |
--------------------------------------------------------------------------------
/public/favicon.ico:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/schardev/nextjs-contentlayer-blog/37219ce77a85b8352da0f73045833a17b6917a97/public/favicon.ico
--------------------------------------------------------------------------------
/public/fonts/Biotif-Bold.woff:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/schardev/nextjs-contentlayer-blog/37219ce77a85b8352da0f73045833a17b6917a97/public/fonts/Biotif-Bold.woff
--------------------------------------------------------------------------------
/public/fonts/Biotif-Bold.woff2:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/schardev/nextjs-contentlayer-blog/37219ce77a85b8352da0f73045833a17b6917a97/public/fonts/Biotif-Bold.woff2
--------------------------------------------------------------------------------
/public/fonts/Biotif-BoldItalic.woff2:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/schardev/nextjs-contentlayer-blog/37219ce77a85b8352da0f73045833a17b6917a97/public/fonts/Biotif-BoldItalic.woff2
--------------------------------------------------------------------------------
/public/fonts/Biotif-Medium.woff2:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/schardev/nextjs-contentlayer-blog/37219ce77a85b8352da0f73045833a17b6917a97/public/fonts/Biotif-Medium.woff2
--------------------------------------------------------------------------------
/public/fonts/Biotif-MediumItalic.woff2:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/schardev/nextjs-contentlayer-blog/37219ce77a85b8352da0f73045833a17b6917a97/public/fonts/Biotif-MediumItalic.woff2
--------------------------------------------------------------------------------
/public/fonts/Biotif-Regular.woff2:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/schardev/nextjs-contentlayer-blog/37219ce77a85b8352da0f73045833a17b6917a97/public/fonts/Biotif-Regular.woff2
--------------------------------------------------------------------------------
/public/fonts/Biotif-RegularItalic.woff2:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/schardev/nextjs-contentlayer-blog/37219ce77a85b8352da0f73045833a17b6917a97/public/fonts/Biotif-RegularItalic.woff2
--------------------------------------------------------------------------------
/public/fonts/NeuzeitGrotesk-Bold.woff2:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/schardev/nextjs-contentlayer-blog/37219ce77a85b8352da0f73045833a17b6917a97/public/fonts/NeuzeitGrotesk-Bold.woff2
--------------------------------------------------------------------------------
/public/images/announcement-banner.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/schardev/nextjs-contentlayer-blog/37219ce77a85b8352da0f73045833a17b6917a97/public/images/announcement-banner.png
--------------------------------------------------------------------------------
/public/images/arrow-tr.svg:
--------------------------------------------------------------------------------
1 |
2 |
--------------------------------------------------------------------------------
/public/images/react.jpeg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/schardev/nextjs-contentlayer-blog/37219ce77a85b8352da0f73045833a17b6917a97/public/images/react.jpeg
--------------------------------------------------------------------------------
/schema/contentlayer/blog-post.ts:
--------------------------------------------------------------------------------
1 | // esbuild (which contentlayer uses behind the scenes) doesn't support ts path aliases
2 | // @see - https://github.com/evanw/esbuild/issues/394
3 | import { slugify } from "../../lib/utils";
4 | import extractHeadings from "../../lib/mdx/contentlayer-extract-headings";
5 | import { defineDocumentType } from "contentlayer/source-files";
6 |
7 | const BlogPost = defineDocumentType(() => ({
8 | name: "BlogPost",
9 | contentType: "mdx",
10 | filePathPattern: "blog/**/*.mdx",
11 | fields: {
12 | title: {
13 | type: "string",
14 | description: "The title of the post",
15 | required: true,
16 | },
17 | description: {
18 | type: "string",
19 | description: "Short description of the post",
20 | required: true,
21 | },
22 | date: {
23 | type: "date",
24 | description: "The date when the post was published",
25 | required: true,
26 | },
27 | lastmod: {
28 | type: "date",
29 | description: "The date when the post was last modified",
30 | },
31 | tags: {
32 | type: "list",
33 | of: { type: "string" },
34 | description: "Post tags",
35 | },
36 | image: {
37 | type: "string",
38 | description: "Heading image",
39 | },
40 | },
41 | computedFields: {
42 | slug: {
43 | type: "string",
44 | resolve: (post) => slugify(post._raw.sourceFileName.replace(".mdx", "")),
45 | },
46 | headings: {
47 | type: "json",
48 | description: "All headings from the post",
49 | resolve: (post) => extractHeadings(post.body.raw),
50 | },
51 | },
52 | }));
53 |
54 | export default BlogPost;
55 |
--------------------------------------------------------------------------------
/styles/config.css:
--------------------------------------------------------------------------------
1 | /* Other styles */
2 | :root {
3 | --border-radius: theme(borderRadius.md);
4 | --pre-code-line-highlight-border-width: 3px;
5 | }
6 |
7 | /* Colors */
8 | :root {
9 | /* light mode colors */
10 | --color-accent: 26 90% 37%; /* hsl */
11 | --color-background-primary: 60 9% 98%;
12 | --color-background-secondary: 60 5% 96%;
13 | --color-background-tertiary: 20 6% 90%;
14 | --color-text-primary: theme(colors.stone.950);
15 | --color-text-secondary: theme(colors.stone.600);
16 | --color-text-tertiary: theme(colors.stone.500);
17 |
18 | /* markdown colors */
19 | --color-body: theme(colors.stone.700);
20 | --color-borders: theme(colors.stone.300);
21 | --color-headings: theme(colors.stone.900);
22 | --color-lead: theme(colors.stone.600);
23 | --color-link-inactive: theme(colors.stone.500);
24 | --color-links: hsl(var(--color-accent));
25 | --color-bold: theme(colors.stone.900);
26 | --color-counters: theme(colors.stone.500);
27 | --color-bullets: theme(colors.stone.300);
28 | --color-hr: theme(colors.stone.200);
29 | --color-quotes: theme(colors.stone.600);
30 | --color-quote-borders: theme(colors.stone.300);
31 | --color-captions: var(--color-quotes);
32 | --color-td-borders: var(--color-borders);
33 | --color-th-bg: theme(colors.stone.200);
34 | --color-th-borders: var(--color-borders);
35 | --color-toc-button-bg: hsl(var(--color-background-secondary));
36 |
37 | /* code blocks */
38 | --color-code-bg: theme(colors.stone.200);
39 | --color-code-fg: theme(colors.stone.950);
40 | --color-pre-code-fg: theme(colors.stone.500);
41 | --color-pre-code-bg: hsl(var(--color-background-primary) / 1);
42 | --color-pre-code-borders: var(--color-borders);
43 | --color-pre-code-title-bg: var(--color-th-bg);
44 | --color-pre-code-title-fg: theme(colors.stone.950);
45 | --color-pre-code-highlighted-chars: theme(colors.stone.300/80%);
46 | --color-pre-code-line-highlight: theme(colors.amber.100/50%);
47 | --color-pre-code-line-highlight-border: theme(colors.amber.300);
48 | }
49 |
50 | :root[data-theme="dark"] {
51 | /* dark mode colors */
52 | --color-accent: 38 92% 50%; /* hsl */
53 | --color-background-primary: 20 14% 4%;
54 | --color-background-secondary: 24 10% 10%;
55 | --color-background-tertiary: 12 6% 15%;
56 | --color-text-primary: theme(colors.stone.50);
57 | --color-text-secondary: theme(colors.stone.400);
58 | --color-text-tertiary: theme(colors.stone.600);
59 |
60 | /* markdown colors */
61 | --color-body: theme(colors.stone.300);
62 | --color-borders: theme(colors.stone.800);
63 | --color-headings: theme(colors.stone.50);
64 | --color-lead: theme(colors.stone.400);
65 | --color-link-inactive: theme(colors.stone.500);
66 | --color-links: hsl(var(--color-accent));
67 | --color-bold: theme(colors.stone.100);
68 | --color-counters: theme(colors.stone.400);
69 | --color-bullets: theme(colors.stone.600);
70 | --color-hr: theme(colors.stone.700);
71 | --color-quotes: var(--color-text-secondary);
72 | --color-quote-borders: theme(colors.stone.700);
73 | --color-captions: var(--color-quotes);
74 | --color-th-bg: theme(colors.stone.900);
75 | --color-th-borders: var(--color-borders);
76 | --color-td-borders: var(--color-borders);
77 | --color-toc-button-bg: hsl(var(--color-background-secondary));
78 |
79 | /* code blocks */
80 | --color-code-bg: theme(colors.stone.800);
81 | --color-code-fg: theme(colors.stone.100);
82 | --color-pre-code-fg: theme(colors.stone.400);
83 | --color-pre-code-bg: hsl(var(--color-background-primary) / 1);
84 | --color-pre-code-borders: var(--color-borders);
85 | --color-pre-code-title-bg: var(--color-th-bg);
86 | --color-pre-code-title-fg: theme(colors.stone.200);
87 | --color-pre-code-highlighted-chars: theme(colors.stone.500/50%);
88 | --color-pre-code-line-highlight: theme(colors.amber.500/10%);
89 | --color-pre-code-line-highlight-border: theme(colors.amber.600/50%);
90 | }
91 |
--------------------------------------------------------------------------------
/styles/global.css:
--------------------------------------------------------------------------------
1 | @import "./config.css";
2 | @import "./tailwind.css";
3 | @import "./markdown.css";
4 |
--------------------------------------------------------------------------------
/styles/markdown.css:
--------------------------------------------------------------------------------
1 | /*
2 | * Tailwind generates prose classes that has specificity of (0,1,0), element-only
3 | * selectors aren't enough to overwrite them, so we add `.prose` class
4 | */
5 | .prose {
6 | h1,
7 | h2,
8 | h3,
9 | h4,
10 | h5,
11 | h6 {
12 | &:target {
13 | @apply scroll-mt-24;
14 | }
15 |
16 | &:hover .anchor-link {
17 | @apply opacity-100;
18 | }
19 | }
20 |
21 | a {
22 | @apply no-underline;
23 |
24 | &:hover {
25 | @apply underline underline-offset-[0.5ex];
26 | }
27 | }
28 |
29 | .anchor-link {
30 | @apply ml-2 text-[--color-link-inactive];
31 | @apply transition-[opacity,color] duration-100;
32 | @apply opacity-0 focus-visible:opacity-100;
33 |
34 | &:hover {
35 | @apply text-accent no-underline;
36 | }
37 |
38 | .icon-link::before {
39 | @apply content-['#'];
40 | }
41 | }
42 |
43 | img {
44 | @apply rounded-global shadow-lg;
45 | }
46 |
47 | figure > figcaption {
48 | @apply text-center italic font-medium text-[--color-quotes];
49 | }
50 |
51 | table {
52 | @apply my-0;
53 | }
54 |
55 | thead {
56 | @apply bg-[--color-th-bg];
57 | }
58 |
59 | thead th,
60 | tbody td {
61 | @apply p-4 md:px-6;
62 | }
63 |
64 | :not(pre) > code {
65 | @apply bg-[--color-code-bg] rounded-md;
66 | @apply py-[0.25em] px-[0.45em];
67 | @apply before:content-[''] after:content-[''];
68 | }
69 |
70 | :not(pre, a[href]) > code {
71 | @apply text-[--color-code-fg];
72 | }
73 |
74 | pre {
75 | @apply p-0;
76 | }
77 |
78 | pre > code:not([data-language]) {
79 | @apply pl-4;
80 | }
81 |
82 | pre > code {
83 | @apply grid overflow-x-auto py-4 bg-[--color-pre-code-bg];
84 | @apply border border-[--color-pre-code-borders] rounded-inherit;
85 |
86 | > [data-line] {
87 | @apply px-4 border-l-[length:--pre-code-line-highlight-border-width] border-l-transparent;
88 | }
89 |
90 | > [data-highlighted-line] {
91 | @apply border-l-[--color-pre-code-line-highlight-border];
92 | @apply bg-[--color-pre-code-line-highlight];
93 | }
94 | }
95 |
96 | pre > code[data-line-numbers] {
97 | --_counter-width: 1ch;
98 | counter-reset: line;
99 |
100 | > [data-line]::before {
101 | counter-increment: line;
102 | content: counter(line);
103 | @apply inline-block w-[--_counter-width] mr-4 text-right text-inherit;
104 | }
105 |
106 | &[data-line-numbers-max-digits="2"] {
107 | --_counter-width: 2ch;
108 | }
109 |
110 | &[data-line-numbers-max-digits="3"] {
111 | --_counter-width: 3ch;
112 | }
113 |
114 | &[data-line-numbers-max-digits="4"] {
115 | --_counter-width: 4ch;
116 | }
117 | }
118 |
119 | [data-rehype-pretty-code-title] {
120 | @apply text-[--color-pre-code-title-fg] bg-[--color-pre-code-title-bg];
121 | @apply border-t border-x border-[--color-pre-code-borders] rounded-t-global;
122 | @apply px-4 py-2;
123 |
124 | + pre {
125 | @apply mt-0 rounded-t-none;
126 | }
127 | }
128 |
129 | [data-rehype-pretty-code-caption] {
130 | @apply text-center italic font-medium text-sm text-[--color-captions] mt-3;
131 | }
132 |
133 | [data-highlighted-chars] {
134 | @apply bg-[--color-pre-code-highlighted-chars] p-[0.2em] rounded-md;
135 | }
136 | }
137 |
138 | /* remove dark theme code blocks in light mode and vice-versa */
139 | :root[data-theme="dark"]
140 | [data-rehype-pretty-code-fragment]
141 | [data-theme="light"] {
142 | @apply hidden;
143 | }
144 |
145 | :root[data-theme="light"]
146 | [data-rehype-pretty-code-fragment]
147 | [data-theme="dark"] {
148 | @apply hidden;
149 | }
150 |
--------------------------------------------------------------------------------
/styles/tailwind.css:
--------------------------------------------------------------------------------
1 | @tailwind base;
2 | @tailwind components;
3 | @tailwind utilities;
4 |
5 | @layer base {
6 | :root {
7 | --px-padding: 1rem;
8 | @apply md:[--px-padding:2rem] lg:[--px-padding:3rem];
9 | }
10 |
11 | body {
12 | @apply transition-colors duration-200;
13 | }
14 | }
15 |
16 | @layer components {
17 | .post-img {
18 | @apply aspect-5/3 rounded-md object-cover;
19 | }
20 |
21 | .tag {
22 | @apply text-sm font-medium px-3 py-1 rounded-full inline-block;
23 | }
24 |
25 | .copy-btn {
26 | @apply absolute top-2 right-2 p-1.5;
27 | @apply rounded-global opacity-0 bg-background-tertiary text-foreground-secondary;
28 | @apply transition-[opacity,transform];
29 | @apply active:scale-90 focus-visible:opacity-100;
30 | }
31 |
32 | pre:hover .copy-btn {
33 | @apply opacity-80;
34 | }
35 |
36 | pre:hover .copy-btn:hover {
37 | @apply opacity-100;
38 | }
39 | }
40 |
41 | @layer utilities {
42 | .max-w-container-center {
43 | @apply max-w-screen-2xl mx-auto px-[--px-padding];
44 | }
45 | }
46 |
--------------------------------------------------------------------------------
/tailwind.config.js:
--------------------------------------------------------------------------------
1 | import defaultTheme from "tailwindcss/defaultTheme";
2 | import tailwindTypography from "@tailwindcss/typography";
3 | import plugin from "tailwindcss/plugin";
4 |
5 | /** @type {import('tailwindcss').Config} */
6 | const tailwindConfig = {
7 | darkMode: ["class", "[data-theme='dark']"],
8 | content: [
9 | "./app/**/*.{js,ts,jsx,tsx,mdx}",
10 | "./components/**/*.{js,ts,jsx,tsx,mdx}",
11 | ],
12 | theme: {
13 | extend: {
14 | colors: {
15 | accent: "hsl(var(--color-accent) / )",
16 | background: {
17 | DEFAULT: "hsl(var(--color-background-primary) / )",
18 | secondary: "hsl(var(--color-background-secondary) / )",
19 | tertiary: "hsl(var(--color-background-tertiary) / )",
20 | },
21 | foreground: {
22 | primary: "var(--color-text-primary)",
23 | secondary: "var(--color-text-secondary)",
24 | tertiary: "var(--color-text-tertiary)",
25 | },
26 | borders: "var(--color-borders)",
27 | },
28 | borderRadius: {
29 | global: "var(--border-radius)",
30 | inherit: "inherit",
31 | },
32 | fontFamily: {
33 | sans: ["var(--font-sans)", ...defaultTheme.fontFamily.sans],
34 | },
35 | aspectRatio: {
36 | "5/3": "5/3",
37 | },
38 | keyframes: {
39 | "slide-up-fade": {
40 | from: { opacity: 0, transform: "translateY(10px)" },
41 | to: { opacity: 1, transform: "translateY(0)" },
42 | },
43 | "slide-down-fade": {
44 | from: { opacity: 1, transform: "translateY(0px)" },
45 | to: { opacity: 0, transform: "translateY(10px)" },
46 | },
47 | },
48 | animation: {
49 | "slide-up-fade": "slide-up-fade 300ms cubic-bezier(0.16, 1, 0.3, 1)",
50 | "slide-down-fade":
51 | "slide-down-fade 300ms cubic-bezier(0.16, 1, 0.3, 1)",
52 | },
53 | typography: () => ({
54 | custom: {
55 | css: {
56 | "--tw-prose-body": "var(--color-body)",
57 | "--tw-prose-headings": "var(--color-headings)",
58 | "--tw-prose-lead": "var(--color-lead)",
59 | "--tw-prose-links": "var(--color-links)",
60 | "--tw-prose-bold": "var(--color-bold)",
61 | "--tw-prose-counters": "var(--color-counters)",
62 | "--tw-prose-bullets": "var(--color-bullets)",
63 | "--tw-prose-hr": "var(--color-hr)",
64 | "--tw-prose-quotes": "var(--color-quotes)",
65 | "--tw-prose-quote-borders": "var(--color-quote-borders)",
66 | "--tw-prose-captions": "var(--color-captions)",
67 | "--tw-prose-code": "var(--color-code-fg)",
68 | "--tw-prose-pre-bg": "var(--color-pre-code-bg)",
69 | "--tw-prose-pre-code": "var(--color-pre-code-fg)",
70 | "--tw-prose-th-borders": "var(--color-th-borders)",
71 | "--tw-prose-td-borders": "var(--color-td-borders)",
72 | },
73 | },
74 | }),
75 | },
76 | },
77 | plugins: [
78 | tailwindTypography,
79 | plugin(({ addVariant }) => {
80 | addVariant("hover-none", "@media (hover: none) and (pointer: coarse)");
81 | }),
82 | ],
83 | };
84 |
85 | export default tailwindConfig;
86 |
--------------------------------------------------------------------------------
/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "target": "ES2021",
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 | "plugins": [{ "name": "next" }],
18 | // baseUrl is technically not required but added here for editor auto-completions
19 | "baseUrl": ".",
20 | "paths": {
21 | "@/*": ["./*"],
22 | "contentlayer/generated": ["./.contentlayer/generated"]
23 | }
24 | },
25 | "include": [
26 | "**/*.ts",
27 | "**/*.tsx",
28 | ".contentlayer/generated",
29 | ".next/types/**/*.ts"
30 | ],
31 | "exclude": ["node_modules"]
32 | }
33 |
--------------------------------------------------------------------------------