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:
parent
caa6a90418
commit
4b2e7bae90
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
|
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>
|
||||||
|
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'
|
'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
|
||||||
|
@ -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>
|
||||||
|
@ -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)'
|
||||||
},
|
},
|
||||||
|
@ -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 }
|
|
||||||
}
|
|
||||||
|
Loading…
Reference in New Issue
Block a user