mirror of
				https://github.com/theoludwig/theoludwig.git
				synced 2025-10-14 20:23:25 +02:00 
			
		
		
		
	feat: rewrite blog to Next.js v13 app directory
Improvement: Support light theme in code block
This commit is contained in:
		
							
								
								
									
										155
									
								
								app/blog/[slug]/page.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										155
									
								
								app/blog/[slug]/page.tsx
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,155 @@ | ||||
| import type { Metadata } from 'next' | ||||
| import { notFound } from 'next/navigation' | ||||
| import { cookies } from 'next/headers' | ||||
| import Link from 'next/link' | ||||
| import Image from 'next/image' | ||||
| import { MDXRemote } from 'next-mdx-remote/rsc' | ||||
| import { FontAwesomeIcon } from '@fortawesome/react-fontawesome' | ||||
| import { faLink } from '@fortawesome/free-solid-svg-icons' | ||||
| import date from 'date-and-time' | ||||
| import { nodeTypes } from '@mdx-js/mdx' | ||||
| import rehypeRaw from 'rehype-raw' | ||||
| import remarkGfm from 'remark-gfm' | ||||
| import rehypeSlug from 'rehype-slug' | ||||
| import remarkMath from 'remark-math' | ||||
| import rehypeKatex from 'rehype-katex' | ||||
| import { getHighlighter } from 'shiki' | ||||
|  | ||||
| import 'katex/dist/katex.min.css' | ||||
|  | ||||
| import { remarkSyntaxHighlightingPlugin } from '@/utils/remarkSyntaxHighlightingPlugin' | ||||
| import { getPostBySlug } from '@/utils/blog' | ||||
| import { BlogPostComments } from '@/components/BlogPostComments' | ||||
| import { getTheme } from '@/theme/theme.server' | ||||
|  | ||||
| const Heading = ( | ||||
|   props: React.DetailedHTMLProps< | ||||
|     React.HTMLAttributes<HTMLHeadingElement>, | ||||
|     HTMLHeadingElement | ||||
|   > | ||||
| ): JSX.Element => { | ||||
|   const { children, id = '' } = props | ||||
|   return ( | ||||
|     <h2 {...props} className='group'> | ||||
|       <Link | ||||
|         href={`#${id}`} | ||||
|         className='invisible !text-black group-hover:visible dark:!text-white' | ||||
|       > | ||||
|         <FontAwesomeIcon className='mr-2 inline h-4 w-4' icon={faLink} /> | ||||
|       </Link> | ||||
|       {children} | ||||
|     </h2> | ||||
|   ) | ||||
| } | ||||
|  | ||||
| interface BlogPostPageProps { | ||||
|   params: { | ||||
|     slug: string | ||||
|   } | ||||
| } | ||||
|  | ||||
| export const generateMetadata = async ( | ||||
|   props: BlogPostPageProps | ||||
| ): Promise<Metadata> => { | ||||
|   const post = await getPostBySlug(props.params.slug) | ||||
|   if (post == null || !post.frontmatter.isPublished) { | ||||
|     return notFound() | ||||
|   } | ||||
|   const title = `${post.frontmatter.title} | Théo LUDWIG` | ||||
|   const description = post.frontmatter.description | ||||
|   return { | ||||
|     title, | ||||
|     description, | ||||
|     openGraph: { | ||||
|       title, | ||||
|       description | ||||
|     }, | ||||
|     twitter: { | ||||
|       title, | ||||
|       description | ||||
|     } | ||||
|   } | ||||
| } | ||||
|  | ||||
| const BlogPostPage = async (props: BlogPostPageProps): Promise<JSX.Element> => { | ||||
|   const { params } = props | ||||
|  | ||||
|   const post = await getPostBySlug(params.slug) | ||||
|   if (post == null || !post.frontmatter.isPublished) { | ||||
|     return notFound() | ||||
|   } | ||||
|  | ||||
|   const cookiesStore = cookies() | ||||
|   const theme = getTheme() | ||||
|  | ||||
|   const highlighter = await getHighlighter({ | ||||
|     theme: `${theme}-plus` | ||||
|   }) | ||||
|  | ||||
|   return ( | ||||
|     <main className='break-wrap-words flex flex-1 flex-col flex-wrap items-center'> | ||||
|       <div className='my-10 flex flex-col items-center text-center'> | ||||
|         <h1 className='text-3xl font-semibold'>{post.frontmatter.title}</h1> | ||||
|         <p className='mt-2' data-cy='blog-post-date'> | ||||
|           {date.format(new Date(post.frontmatter.publishedOn), 'DD/MM/YYYY')} | ||||
|         </p> | ||||
|       </div> | ||||
|       <div className='prose mb-10'> | ||||
|         <div className='px-8'> | ||||
|           <MDXRemote | ||||
|             source={post.content} | ||||
|             options={{ | ||||
|               mdxOptions: { | ||||
|                 remarkPlugins: [ | ||||
|                   remarkGfm, | ||||
|                   [remarkSyntaxHighlightingPlugin, { highlighter }], | ||||
|                   remarkMath | ||||
|                 ], | ||||
|                 rehypePlugins: [ | ||||
|                   rehypeSlug, | ||||
|                   [rehypeRaw, { passThrough: nodeTypes }], | ||||
|                   rehypeKatex | ||||
|                 ] | ||||
|               } | ||||
|             }} | ||||
|             components={{ | ||||
|               h1: Heading, | ||||
|               h2: Heading, | ||||
|               h3: Heading, | ||||
|               h4: Heading, | ||||
|               h5: Heading, | ||||
|               h6: Heading, | ||||
|               img: (properties) => { | ||||
|                 const { src = '', alt = 'Blog Image' } = properties | ||||
|                 const source = src.replace('../public/', '/') | ||||
|                 return ( | ||||
|                   <span className='flex flex-col items-center justify-center'> | ||||
|                     <Image | ||||
|                       src={source} | ||||
|                       alt={alt} | ||||
|                       width={1000} | ||||
|                       height={1000} | ||||
|                       className='h-auto w-auto' | ||||
|                     /> | ||||
|                   </span> | ||||
|                 ) | ||||
|               }, | ||||
|               a: (props) => { | ||||
|                 const { href = '' } = props | ||||
|                 if (href.startsWith('#')) { | ||||
|                   return <a {...props} /> | ||||
|                 } | ||||
|                 return ( | ||||
|                   <a target='_blank' rel='noopener noreferrer' {...props} /> | ||||
|                 ) | ||||
|               } | ||||
|             }} | ||||
|           /> | ||||
|           <BlogPostComments cookiesStore={cookiesStore.toString()} /> | ||||
|         </div> | ||||
|       </div> | ||||
|     </main> | ||||
|   ) | ||||
| } | ||||
|  | ||||
| export default BlogPostPage | ||||
							
								
								
									
										40
									
								
								app/blog/page.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										40
									
								
								app/blog/page.tsx
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,40 @@ | ||||
| import { Suspense } from 'react' | ||||
| import type { Metadata } from 'next' | ||||
|  | ||||
| import { BlogPosts } from '@/components/BlogPosts' | ||||
| import { Loader } from '@/components/Loader/Loader' | ||||
|  | ||||
| const title = 'Blog | Théo LUDWIG' | ||||
| const description = | ||||
|   'The latest news about my journey of learning computer science.' | ||||
|  | ||||
| export const metadata: Metadata = { | ||||
|   title, | ||||
|   description, | ||||
|   openGraph: { | ||||
|     title, | ||||
|     description | ||||
|   }, | ||||
|   twitter: { | ||||
|     title, | ||||
|     description | ||||
|   } | ||||
| } | ||||
|  | ||||
| const BlogPage = async (): Promise<JSX.Element> => { | ||||
|   return ( | ||||
|     <main className='flex flex-1 flex-col flex-wrap items-center'> | ||||
|       <div className='mt-10 flex flex-col items-center'> | ||||
|         <h1 className='text-4xl font-semibold'>Blog</h1> | ||||
|         <p className='mt-6 text-center' data-cy='blog-post-date'> | ||||
|           {description} | ||||
|         </p> | ||||
|       </div> | ||||
|       <Suspense fallback={<Loader className='mt-8' />}> | ||||
|         <BlogPosts /> | ||||
|       </Suspense> | ||||
|     </main> | ||||
|   ) | ||||
| } | ||||
|  | ||||
| export default BlogPage | ||||
| @@ -68,8 +68,8 @@ const RootLayout = (props: RootLayoutProps): JSX.Element => { | ||||
|         colorScheme: theme | ||||
|       }} | ||||
|     > | ||||
|       <body className='bg-white font-headline text-black dark:bg-black dark:text-white'> | ||||
|         <Header showLocale /> | ||||
|       <body className='bg-white font-headline text-black dark:bg-black dark:text-white flex flex-col min-h-screen'> | ||||
|         <Header /> | ||||
|         {children} | ||||
|         <Footer /> | ||||
|       </body> | ||||
|   | ||||
							
								
								
									
										33
									
								
								components/BlogPostComments.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										33
									
								
								components/BlogPostComments.tsx
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,33 @@ | ||||
| 'use client' | ||||
|  | ||||
| import Giscus from '@giscus/react' | ||||
|  | ||||
| import { useTheme } from '@/theme/theme.client' | ||||
| import type { CookiesStore } from '@/utils/constants' | ||||
|  | ||||
| interface BlogPostCommentsProps { | ||||
|   cookiesStore: CookiesStore | ||||
| } | ||||
|  | ||||
| export const BlogPostComments = (props: BlogPostCommentsProps): JSX.Element => { | ||||
|   const { cookiesStore } = props | ||||
|  | ||||
|   const theme = useTheme(cookiesStore) | ||||
|  | ||||
|   return ( | ||||
|     <Giscus | ||||
|       id='comments' | ||||
|       repo='theoludwig/theoludwig' | ||||
|       repoId='MDEwOlJlcG9zaXRvcnkzNTg5NDg1NDQ=' | ||||
|       category='General' | ||||
|       categoryId='DIC_kwDOFWUewM4CQ_WK' | ||||
|       mapping='pathname' | ||||
|       reactionsEnabled='1' | ||||
|       emitMetadata='0' | ||||
|       inputPosition='top' | ||||
|       theme={theme} | ||||
|       lang='en' | ||||
|       loading='lazy' | ||||
|     /> | ||||
|   ) | ||||
| } | ||||
							
								
								
									
										42
									
								
								components/BlogPosts.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										42
									
								
								components/BlogPosts.tsx
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,42 @@ | ||||
| import Link from 'next/link' | ||||
| import date from 'date-and-time' | ||||
|  | ||||
| import { ShadowContainer } from '@/components/design/ShadowContainer' | ||||
| import { getPosts } from '@/utils/blog' | ||||
|  | ||||
| export const BlogPosts = async (): Promise<JSX.Element> => { | ||||
|   const posts = await getPosts() | ||||
|  | ||||
|   return ( | ||||
|     <div className='flex w-full items-center justify-center p-8'> | ||||
|       <div className='w-[1600px]' data-cy='blog-posts'> | ||||
|         {posts.map((post, index) => { | ||||
|           const postPublishedOn = date.format( | ||||
|             new Date(post.frontmatter.publishedOn), | ||||
|             'DD/MM/YYYY' | ||||
|           ) | ||||
|           return ( | ||||
|             <Link | ||||
|               href={`/blog/${post.slug}`} | ||||
|               key={index} | ||||
|               locale='en' | ||||
|               data-cy={post.slug} | ||||
|             > | ||||
|               <ShadowContainer className='cursor-pointer p-6 transition duration-200 ease-in-out hover:-translate-y-2'> | ||||
|                 <h2 data-cy='blog-post-title' className='text-xl font-semibold'> | ||||
|                   {post.frontmatter.title} | ||||
|                 </h2> | ||||
|                 <p data-cy='blog-post-date' className='mt-2'> | ||||
|                   {postPublishedOn} | ||||
|                 </p> | ||||
|                 <p data-cy='blog-post-description' className='mt-3'> | ||||
|                   {post.frontmatter.description} | ||||
|                 </p> | ||||
|               </ShadowContainer> | ||||
|             </Link> | ||||
|           ) | ||||
|         })} | ||||
|       </div> | ||||
|     </div> | ||||
|   ) | ||||
| } | ||||
| @@ -1,5 +1,6 @@ | ||||
| 'use client' | ||||
|  | ||||
| import { usePathname } from 'next/navigation' | ||||
| import { useCallback, useEffect, useState, useRef } from 'react' | ||||
| import classNames from 'clsx' | ||||
|  | ||||
| @@ -16,6 +17,7 @@ export interface LocalesProps { | ||||
|  | ||||
| export const Locales = (props: LocalesProps): JSX.Element => { | ||||
|   const { currentLocale, cookiesStore } = props | ||||
|   const pathname = usePathname() | ||||
|  | ||||
|   const [hiddenMenu, setHiddenMenu] = useState(true) | ||||
|   const languageClickRef = useRef<HTMLDivElement | null>(null) | ||||
| @@ -48,6 +50,10 @@ export const Locales = (props: LocalesProps): JSX.Element => { | ||||
|     setLocale(locale) | ||||
|   } | ||||
|  | ||||
|   if (pathname.startsWith('/blog')) { | ||||
|     return <></> | ||||
|   } | ||||
|  | ||||
|   return ( | ||||
|     <div className='flex cursor-pointer flex-col items-center justify-center'> | ||||
|       <div | ||||
|   | ||||
| @@ -7,13 +7,7 @@ import { getI18n } from '@/i18n/i18n.server' | ||||
| import { Locales } from './Locales' | ||||
| import { SwitchTheme } from './SwitchTheme' | ||||
|  | ||||
| export interface HeaderProps { | ||||
|   showLocale?: boolean | ||||
| } | ||||
|  | ||||
| export const Header: React.FC<HeaderProps> = (props) => { | ||||
|   const { showLocale = false } = props | ||||
|  | ||||
| export const Header = (): JSX.Element => { | ||||
|   const cookiesStore = cookies() | ||||
|   const i18n = getI18n() | ||||
|  | ||||
| @@ -44,12 +38,10 @@ export const Header: React.FC<HeaderProps> = (props) => { | ||||
|             Blog | ||||
|           </Link> | ||||
|         </div> | ||||
|         {showLocale ? ( | ||||
|           <Locales | ||||
|             currentLocale={i18n.locale} | ||||
|             cookiesStore={cookiesStore.toString()} | ||||
|           /> | ||||
|         ) : null} | ||||
|         <Locales | ||||
|           currentLocale={i18n.locale} | ||||
|           cookiesStore={cookiesStore.toString()} | ||||
|         /> | ||||
|         <SwitchTheme cookiesStore={cookiesStore.toString()} /> | ||||
|       </div> | ||||
|     </header> | ||||
|   | ||||
| @@ -19,8 +19,8 @@ const tailwindConfig = { | ||||
|         } | ||||
|       }, | ||||
|       boxShadow: { | ||||
|         dark: '0px 0px 6px 6px rgba(0, 0, 0, 0.25)', | ||||
|         light: '0px 0px 6px 6px rgba(0, 0, 0, 0.10)', | ||||
|         dark: '0px 0px 4px 4px rgba(0, 0, 0, 0.25)', | ||||
|         light: '0px 0px 4px 4px rgba(0, 0, 0, 0.10)', | ||||
|         darkFlag: '0px 1px 10px hsla(0, 0%, 100%, 0.2)', | ||||
|         lightFlag: '0px 1px 10px rgba(0, 0, 0, 0.25)' | ||||
|       }, | ||||
|   | ||||
| @@ -1,23 +1,11 @@ | ||||
| import fs from 'node:fs' | ||||
| import path from 'node:path' | ||||
|  | ||||
| import type { MDXRemoteSerializeResult } from 'next-mdx-remote' | ||||
| import { nodeTypes } from '@mdx-js/mdx' | ||||
| import rehypeRaw from 'rehype-raw' | ||||
| import { serialize } from 'next-mdx-remote/serialize' | ||||
| import remarkGfm from 'remark-gfm' | ||||
| import rehypeSlug from 'rehype-slug' | ||||
| import remarkMath from 'remark-math' | ||||
| import rehypeKatex from 'rehype-katex' | ||||
| import { cache } from 'react' | ||||
| import matter from 'gray-matter' | ||||
| import { getHighlighter } from 'shiki' | ||||
|  | ||||
| import { remarkSyntaxHighlightingPlugin } from './remarkSyntaxHighlightingPlugin' | ||||
|  | ||||
| export const POSTS_PATH = path.join(process.cwd(), 'posts') | ||||
|  | ||||
| export type MDXSource = MDXRemoteSerializeResult<Record<string, unknown>> | ||||
|  | ||||
| export interface FrontMatter { | ||||
|   title: string | ||||
|   description: string | ||||
| @@ -25,17 +13,13 @@ export interface FrontMatter { | ||||
|   publishedOn: string | ||||
| } | ||||
|  | ||||
| export interface PostMetadata { | ||||
| export interface Post { | ||||
|   frontmatter: FrontMatter | ||||
|   slug: string | ||||
|   content: string | ||||
| } | ||||
|  | ||||
| export interface Post extends PostMetadata { | ||||
|   source: MDXSource | ||||
| } | ||||
|  | ||||
| export const getPosts = async (): Promise<PostMetadata[]> => { | ||||
| export const getPosts = cache(async (): Promise<Post[]> => { | ||||
|   const posts = await fs.promises.readdir(POSTS_PATH) | ||||
|   const postsWithTime = await Promise.all( | ||||
|     posts.map(async (postFilename) => { | ||||
| @@ -68,34 +52,14 @@ export const getPosts = async (): Promise<PostMetadata[]> => { | ||||
|       return b.time - a.time | ||||
|     }) | ||||
|   return postsWithTimeSorted | ||||
| } | ||||
| }) | ||||
|  | ||||
| export const getPostBySlug = async ( | ||||
|   slug?: string | string[] | ||||
| ): Promise<Post | undefined> => { | ||||
|   const posts = await getPosts() | ||||
|   const post = posts.find((post) => { | ||||
|     return post.slug === slug | ||||
|   }) | ||||
|   if (post == null) { | ||||
|     return undefined | ||||
| export const getPostBySlug = cache( | ||||
|   async (slug: string): Promise<Post | undefined> => { | ||||
|     const posts = await getPosts() | ||||
|     const post = posts.find((post) => { | ||||
|       return post.slug === slug | ||||
|     }) | ||||
|     return post | ||||
|   } | ||||
|   const highlighter = await getHighlighter({ | ||||
|     theme: 'dark-plus' | ||||
|   }) | ||||
|   const source = await serialize(post.content, { | ||||
|     mdxOptions: { | ||||
|       remarkPlugins: [ | ||||
|         remarkGfm, | ||||
|         [remarkSyntaxHighlightingPlugin, { highlighter }], | ||||
|         remarkMath | ||||
|       ], | ||||
|       rehypePlugins: [ | ||||
|         rehypeSlug, | ||||
|         [rehypeRaw, { passThrough: nodeTypes }], | ||||
|         rehypeKatex | ||||
|       ] | ||||
|     } | ||||
|   }) | ||||
|   return { ...post, source } | ||||
| } | ||||
| ) | ||||
|   | ||||
		Reference in New Issue
	
	Block a user