1
1
mirror of https://github.com/theoludwig/theoludwig.git synced 2024-11-09 14:51:30 +01:00

feat: rewrite blog to Next.js v13 app directory

Improvement: Support light theme in code block
This commit is contained in:
Théo LUDWIG 2023-08-01 17:07:19 +02:00
parent caa6a90418
commit 4b2e7bae90
Signed by: theoludwig
GPG Key ID: ADFE5A563D718F3B
9 changed files with 297 additions and 65 deletions

155
app/blog/[slug]/page.tsx Normal file
View 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
View 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

View File

@ -68,8 +68,8 @@ const RootLayout = (props: RootLayoutProps): JSX.Element => {
colorScheme: theme colorScheme: theme
}} }}
> >
<body className='bg-white font-headline text-black dark:bg-black dark:text-white'> <body className='bg-white font-headline text-black dark:bg-black dark:text-white flex flex-col min-h-screen'>
<Header showLocale /> <Header />
{children} {children}
<Footer /> <Footer />
</body> </body>

View 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
View 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>
)
}

View File

@ -1,5 +1,6 @@
'use client' 'use client'
import { usePathname } from 'next/navigation'
import { useCallback, useEffect, useState, useRef } from 'react' import { useCallback, useEffect, useState, useRef } from 'react'
import classNames from 'clsx' import classNames from 'clsx'
@ -16,6 +17,7 @@ export interface LocalesProps {
export const Locales = (props: LocalesProps): JSX.Element => { export const Locales = (props: LocalesProps): JSX.Element => {
const { currentLocale, cookiesStore } = props const { currentLocale, cookiesStore } = props
const pathname = usePathname()
const [hiddenMenu, setHiddenMenu] = useState(true) const [hiddenMenu, setHiddenMenu] = useState(true)
const languageClickRef = useRef<HTMLDivElement | null>(null) const languageClickRef = useRef<HTMLDivElement | null>(null)
@ -48,6 +50,10 @@ export const Locales = (props: LocalesProps): JSX.Element => {
setLocale(locale) setLocale(locale)
} }
if (pathname.startsWith('/blog')) {
return <></>
}
return ( return (
<div className='flex cursor-pointer flex-col items-center justify-center'> <div className='flex cursor-pointer flex-col items-center justify-center'>
<div <div

View File

@ -7,13 +7,7 @@ import { getI18n } from '@/i18n/i18n.server'
import { Locales } from './Locales' import { Locales } from './Locales'
import { SwitchTheme } from './SwitchTheme' import { SwitchTheme } from './SwitchTheme'
export interface HeaderProps { export const Header = (): JSX.Element => {
showLocale?: boolean
}
export const Header: React.FC<HeaderProps> = (props) => {
const { showLocale = false } = props
const cookiesStore = cookies() const cookiesStore = cookies()
const i18n = getI18n() const i18n = getI18n()
@ -44,12 +38,10 @@ export const Header: React.FC<HeaderProps> = (props) => {
Blog Blog
</Link> </Link>
</div> </div>
{showLocale ? (
<Locales <Locales
currentLocale={i18n.locale} currentLocale={i18n.locale}
cookiesStore={cookiesStore.toString()} cookiesStore={cookiesStore.toString()}
/> />
) : null}
<SwitchTheme cookiesStore={cookiesStore.toString()} /> <SwitchTheme cookiesStore={cookiesStore.toString()} />
</div> </div>
</header> </header>

View File

@ -19,8 +19,8 @@ const tailwindConfig = {
} }
}, },
boxShadow: { boxShadow: {
dark: '0px 0px 6px 6px rgba(0, 0, 0, 0.25)', dark: '0px 0px 4px 4px rgba(0, 0, 0, 0.25)',
light: '0px 0px 6px 6px rgba(0, 0, 0, 0.10)', light: '0px 0px 4px 4px rgba(0, 0, 0, 0.10)',
darkFlag: '0px 1px 10px hsla(0, 0%, 100%, 0.2)', darkFlag: '0px 1px 10px hsla(0, 0%, 100%, 0.2)',
lightFlag: '0px 1px 10px rgba(0, 0, 0, 0.25)' lightFlag: '0px 1px 10px rgba(0, 0, 0, 0.25)'
}, },

View File

@ -1,23 +1,11 @@
import fs from 'node:fs' import fs from 'node:fs'
import path from 'node:path' import path from 'node:path'
import type { MDXRemoteSerializeResult } from 'next-mdx-remote' import { cache } from 'react'
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 matter from 'gray-matter' import matter from 'gray-matter'
import { getHighlighter } from 'shiki'
import { remarkSyntaxHighlightingPlugin } from './remarkSyntaxHighlightingPlugin'
export const POSTS_PATH = path.join(process.cwd(), 'posts') export const POSTS_PATH = path.join(process.cwd(), 'posts')
export type MDXSource = MDXRemoteSerializeResult<Record<string, unknown>>
export interface FrontMatter { export interface FrontMatter {
title: string title: string
description: string description: string
@ -25,17 +13,13 @@ export interface FrontMatter {
publishedOn: string publishedOn: string
} }
export interface PostMetadata { export interface Post {
frontmatter: FrontMatter frontmatter: FrontMatter
slug: string slug: string
content: string content: string
} }
export interface Post extends PostMetadata { export const getPosts = cache(async (): Promise<Post[]> => {
source: MDXSource
}
export const getPosts = async (): Promise<PostMetadata[]> => {
const posts = await fs.promises.readdir(POSTS_PATH) const posts = await fs.promises.readdir(POSTS_PATH)
const postsWithTime = await Promise.all( const postsWithTime = await Promise.all(
posts.map(async (postFilename) => { posts.map(async (postFilename) => {
@ -68,34 +52,14 @@ export const getPosts = async (): Promise<PostMetadata[]> => {
return b.time - a.time return b.time - a.time
}) })
return postsWithTimeSorted return postsWithTimeSorted
} })
export const getPostBySlug = async ( export const getPostBySlug = cache(
slug?: string | string[] async (slug: string): Promise<Post | undefined> => {
): Promise<Post | undefined> => {
const posts = await getPosts() const posts = await getPosts()
const post = posts.find((post) => { const post = posts.find((post) => {
return post.slug === slug return post.slug === slug
}) })
if (post == null) { return post
return undefined
} }
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 }
}