mirror of
https://github.com/theoludwig/theoludwig.git
synced 2024-12-08 00:44:30 +01:00
feat: add blog (#320)
This commit is contained in:
parent
1505b81233
commit
bcb184e49c
@ -4,13 +4,18 @@
|
||||
"startServerCommand": "npm run start",
|
||||
"startServerReadyPattern": "ready on",
|
||||
"startServerReadyTimeout": 20000,
|
||||
"url": ["http://localhost:3000/"],
|
||||
"url": [
|
||||
"http://localhost:3000/",
|
||||
"http://localhost:3000/blog",
|
||||
"http://localhost:3000/blog/hello-world"
|
||||
],
|
||||
"numberOfRuns": 3
|
||||
},
|
||||
"assert": {
|
||||
"preset": "lighthouse:recommended",
|
||||
"assertions": {
|
||||
"csp-xss": "warning"
|
||||
"csp-xss": "warning",
|
||||
"unused-javascript": "warning"
|
||||
}
|
||||
},
|
||||
"upload": {
|
||||
|
@ -6,6 +6,6 @@
|
||||
"jest --findRelatedTests"
|
||||
],
|
||||
"*.{css,yml,json}": ["prettier --write"],
|
||||
"*.{md}": ["prettier --write", "markdownlint --dot --fix"],
|
||||
"*.{md,mdx}": ["prettier --write", "markdownlint --dot --fix"],
|
||||
"./Dockerfile": ["dockerfilelint"]
|
||||
}
|
||||
|
@ -4,7 +4,13 @@ import Image from 'next/image'
|
||||
import { Language } from './Language'
|
||||
import { SwitchTheme } from './SwitchTheme'
|
||||
|
||||
export const Header: React.FC = () => {
|
||||
export interface HeaderProps {
|
||||
showLanguage?: boolean
|
||||
}
|
||||
|
||||
export const Header: React.FC<HeaderProps> = (props) => {
|
||||
const { showLanguage = false } = props
|
||||
|
||||
return (
|
||||
<header className='bg-white sticky top-0 z-50 flex w-full justify-between px-6 py-2 border-b-2 border-gray-600 dark:border-gray-400 dark:bg-black'>
|
||||
<Link href='/'>
|
||||
@ -23,7 +29,17 @@ export const Header: React.FC = () => {
|
||||
</a>
|
||||
</Link>
|
||||
<div className='flex justify-between'>
|
||||
<Language />
|
||||
<div className='flex flex-col justify-center items-center px-6'>
|
||||
<Link href='/blog'>
|
||||
<a
|
||||
data-cy='header-blog-link'
|
||||
className='text-yellow dark:text-yellow-dark hover:underline'
|
||||
>
|
||||
Blog
|
||||
</a>
|
||||
</Link>
|
||||
</div>
|
||||
{showLanguage && <Language />}
|
||||
<SwitchTheme />
|
||||
</div>
|
||||
</header>
|
||||
|
@ -22,6 +22,12 @@ export const Interests: React.FC = () => {
|
||||
})}
|
||||
<InterestsList />
|
||||
</div>
|
||||
|
||||
<style jsx global>{`
|
||||
#__next {
|
||||
display: block;
|
||||
}
|
||||
`}</style>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
@ -1,6 +1,13 @@
|
||||
describe('Common > Header', () => {
|
||||
beforeEach(() => cy.visit('/'))
|
||||
|
||||
it('should redirect to /blog on click of the blog link', () => {
|
||||
cy.get('[data-cy=header-blog-link]')
|
||||
.click()
|
||||
.location('pathname')
|
||||
.should('eq', '/blog')
|
||||
})
|
||||
|
||||
describe('Switch theme color (dark/light)', () => {
|
||||
it('should switch theme from `dark` (default) to `light`', () => {
|
||||
cy.get('[data-cy=switch-theme-dark]').should('be.visible')
|
||||
|
14
cypress/integration/pages/blog/[slug].spec.ts
Normal file
14
cypress/integration/pages/blog/[slug].spec.ts
Normal file
@ -0,0 +1,14 @@
|
||||
describe('Page /blog/[slug]', () => {
|
||||
it('should displays the first blog post (`hello-world`)', () => {
|
||||
cy.visit('/blog/hello-world')
|
||||
cy.get('[data-cy=language-flag-text]').should('not.exist')
|
||||
cy.get('h1').should('have.text', 'Hello, world! 👋')
|
||||
cy.get('[data-cy=blog-post-date]').should('have.text', '06/11/2021')
|
||||
cy.get('.prose a').should('have.attr', 'target', '_blank')
|
||||
})
|
||||
|
||||
it("should redirect to /404 if the blog post doesn't exist", () => {
|
||||
cy.visit('/blog/random-blog-post-not-found', { failOnStatusCode: false })
|
||||
cy.get('[data-cy=status-code]').contains('404')
|
||||
})
|
||||
})
|
27
cypress/integration/pages/blog/index.spec.ts
Normal file
27
cypress/integration/pages/blog/index.spec.ts
Normal file
@ -0,0 +1,27 @@
|
||||
describe('Page /blog', () => {
|
||||
it('should displays the blog posts sorted from newest to oldest', () => {
|
||||
cy.visit('/blog')
|
||||
cy.get('[data-cy=blog-posts]:last-child [data-cy=blog-post-title]').should(
|
||||
'have.text',
|
||||
'Hello, world! 👋'
|
||||
)
|
||||
cy.get(
|
||||
'[data-cy=blog-posts]:last-child [data-cy=blog-post-description]'
|
||||
).should(
|
||||
'have.text',
|
||||
'First post of the blog, introduction and explanation of how this blog is made.'
|
||||
)
|
||||
cy.get('[data-cy=blog-posts]:last-child [data-cy=blog-post-date]').should(
|
||||
'have.text',
|
||||
'06/11/2021'
|
||||
)
|
||||
})
|
||||
|
||||
it('should redirect the user to the right blog post', () => {
|
||||
cy.visit('/blog')
|
||||
cy.get('[data-cy=blog-posts]:last-child')
|
||||
.click()
|
||||
.location('pathname')
|
||||
.should('eq', '/blog/hello-world')
|
||||
})
|
||||
})
|
4814
package-lock.json
generated
4814
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
16
package.json
16
package.json
@ -18,7 +18,7 @@
|
||||
"lint:commit": "commitlint",
|
||||
"lint:docker": "dockerfilelint './Dockerfile'",
|
||||
"lint:editorconfig": "editorconfig-checker",
|
||||
"lint:markdown": "markdownlint '**/*.md' --dot --ignore node_modules",
|
||||
"lint:markdown": "markdownlint '**/*.{md,mdx}' --dot --ignore node_modules",
|
||||
"lint:typescript": "eslint '**/*.{js,ts,jsx,tsx}'",
|
||||
"lint:staged": "lint-staged",
|
||||
"test:unit": "jest",
|
||||
@ -36,14 +36,21 @@
|
||||
"@fortawesome/free-solid-svg-icons": "5.15.4",
|
||||
"@fortawesome/react-fontawesome": "0.1.16",
|
||||
"classnames": "2.3.1",
|
||||
"date-and-time": "2.0.1",
|
||||
"esbuild": "0.13.12",
|
||||
"gray-matter": "4.0.3",
|
||||
"html-react-parser": "1.4.0",
|
||||
"next": "11.1.2",
|
||||
"next-mdx-remote": "3.0.8",
|
||||
"next-pwa": "5.4.0",
|
||||
"next-themes": "0.0.15",
|
||||
"next-translate": "1.1.1",
|
||||
"next-translate": "1.2.0",
|
||||
"prism-themes": "1.9.0",
|
||||
"react": "17.0.2",
|
||||
"react-dom": "17.0.2",
|
||||
"read-pkg": "7.0.0",
|
||||
"remark-gfm": "3.0.1",
|
||||
"remark-prism": "1.3.6",
|
||||
"sharp": "0.29.2",
|
||||
"universal-cookie": "4.0.4"
|
||||
},
|
||||
@ -53,11 +60,14 @@
|
||||
"@lhci/cli": "0.8.2",
|
||||
"@saithodev/semantic-release-backmerge": "2.1.0",
|
||||
"@semantic-release/git": "10.0.1",
|
||||
"@tailwindcss/typography": "0.4.1",
|
||||
"@testing-library/jest-dom": "5.15.0",
|
||||
"@testing-library/react": "12.1.2",
|
||||
"@types/date-and-time": "0.13.0",
|
||||
"@types/jest": "27.0.2",
|
||||
"@types/node": "16.11.6",
|
||||
"@types/react": "17.0.34",
|
||||
"@types/remark-prism": "1.3.0",
|
||||
"@typescript-eslint/eslint-plugin": "4.33.0",
|
||||
"autoprefixer": "10.4.0",
|
||||
"babel-jest": "27.3.1",
|
||||
@ -72,7 +82,7 @@
|
||||
"eslint-plugin-node": "11.1.0",
|
||||
"eslint-plugin-prettier": "4.0.0",
|
||||
"eslint-plugin-promise": "5.1.1",
|
||||
"eslint-plugin-unicorn": "38.0.0",
|
||||
"eslint-plugin-unicorn": "38.0.1",
|
||||
"husky": "7.0.4",
|
||||
"jest": "27.3.1",
|
||||
"lint-staged": "11.2.6",
|
||||
|
@ -12,9 +12,9 @@ const Error404: React.FC<FooterProps> = (props) => {
|
||||
|
||||
return (
|
||||
<>
|
||||
<Head title='Divlo - 404' />
|
||||
<Head title='404 | Divlo' />
|
||||
|
||||
<Header />
|
||||
<Header showLanguage />
|
||||
<main className='flex flex-col md:mx-auto md:max-w-4xl lg:max-w-7xl'>
|
||||
<ErrorPage statusCode={404} message={t('errors:not-found')} />
|
||||
</main>
|
||||
|
@ -12,9 +12,9 @@ const Error500: React.FC<FooterProps> = (props) => {
|
||||
|
||||
return (
|
||||
<>
|
||||
<Head title='Divlo - 500' />
|
||||
<Head title='500 | Divlo' />
|
||||
|
||||
<Header />
|
||||
<Header showLanguage />
|
||||
<main className='flex flex-col md:mx-auto md:max-w-4xl lg:max-w-7xl'>
|
||||
<ErrorPage statusCode={500} message={t('errors:server-error')} />
|
||||
</main>
|
||||
|
@ -4,7 +4,7 @@ import { ThemeProvider } from 'next-themes'
|
||||
import useTranslation from 'next-translate/useTranslation'
|
||||
import UniversalCookie from 'universal-cookie'
|
||||
|
||||
import 'tailwindcss/tailwind.css'
|
||||
import 'styles/global.css'
|
||||
import '@fontsource/montserrat/400.css'
|
||||
import '@fontsource/montserrat/600.css'
|
||||
|
||||
|
79
pages/blog/[slug].tsx
Normal file
79
pages/blog/[slug].tsx
Normal file
@ -0,0 +1,79 @@
|
||||
import { GetStaticProps, GetStaticPaths } from 'next'
|
||||
import { MDXRemote } from 'next-mdx-remote'
|
||||
import date from 'date-and-time'
|
||||
import 'prism-themes/themes/prism-one-dark.css'
|
||||
|
||||
import { Head } from 'components/Head'
|
||||
import { Header } from 'components/Header'
|
||||
import { Footer, FooterProps } from 'components/Footer'
|
||||
import type { Post } from 'utils/blog'
|
||||
|
||||
interface BlogPostPageProps extends FooterProps {
|
||||
post: Post
|
||||
}
|
||||
|
||||
const BlogPostPage: React.FC<BlogPostPageProps> = (props) => {
|
||||
const { version, post } = props
|
||||
|
||||
return (
|
||||
<>
|
||||
<Head
|
||||
title={`${post.frontmatter.title} | Divlo`}
|
||||
description={post.frontmatter.description}
|
||||
/>
|
||||
|
||||
<Header />
|
||||
<main className='flex flex-col flex-wrap flex-1 items-center'>
|
||||
<div className='flex flex-col items-center my-10'>
|
||||
<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 px-8'>
|
||||
<MDXRemote
|
||||
{...post.source}
|
||||
components={{
|
||||
a: (props: React.ComponentPropsWithoutRef<'a'>) => (
|
||||
<a target='_blank' rel='noopener noreferrer' {...props} />
|
||||
)
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</main>
|
||||
<Footer version={version} />
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export const getStaticProps: GetStaticProps<BlogPostPageProps> = async (
|
||||
context
|
||||
) => {
|
||||
const slug = context?.params?.slug
|
||||
const { getPostBySlug } = await import('utils/blog')
|
||||
const post = await getPostBySlug(slug)
|
||||
if (post == null || (post != null && !post.frontmatter.isPublished)) {
|
||||
return {
|
||||
redirect: {
|
||||
destination: '/404',
|
||||
permanent: false
|
||||
}
|
||||
}
|
||||
}
|
||||
const { readPackage } = await import('read-pkg')
|
||||
const { version } = await readPackage()
|
||||
return { props: { version, post } }
|
||||
}
|
||||
|
||||
export const getStaticPaths: GetStaticPaths = async () => {
|
||||
const { getPosts } = await import('utils/blog')
|
||||
const posts = await getPosts()
|
||||
return {
|
||||
paths: posts.map((post) => {
|
||||
return { params: { slug: post.slug } }
|
||||
}),
|
||||
fallback: false
|
||||
}
|
||||
}
|
||||
|
||||
export default BlogPostPage
|
77
pages/blog/index.tsx
Normal file
77
pages/blog/index.tsx
Normal file
@ -0,0 +1,77 @@
|
||||
import { GetStaticProps } from 'next'
|
||||
import Link from 'next/link'
|
||||
import date from 'date-and-time'
|
||||
|
||||
import { Head } from 'components/Head'
|
||||
import { Header } from 'components/Header'
|
||||
import { Footer, FooterProps } from 'components/Footer'
|
||||
import { ShadowContainer } from 'components/design/ShadowContainer'
|
||||
import type { PostMetadata } from 'utils/blog'
|
||||
|
||||
const blogDescription =
|
||||
'The latest news about my journey of learning computer science.'
|
||||
|
||||
interface BlogPageProps extends FooterProps {
|
||||
posts: PostMetadata[]
|
||||
}
|
||||
|
||||
const BlogPage: React.FC<BlogPageProps> = (props) => {
|
||||
const { version, posts } = props
|
||||
|
||||
return (
|
||||
<>
|
||||
<Head title='Blog | Divlo' description={blogDescription} />
|
||||
|
||||
<Header />
|
||||
<main className='flex flex-col flex-wrap flex-1 items-center'>
|
||||
<div className='flex flex-col items-center mt-10'>
|
||||
<h1 className='text-4xl font-semibold'>Blog</h1>
|
||||
<p className='mt-6' data-cy='blog-post-date'>
|
||||
{blogDescription}
|
||||
</p>
|
||||
</div>
|
||||
<div className='w-full flex 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}>
|
||||
<a data-cy='blog-post'>
|
||||
<ShadowContainer className='p-6 cursor-pointer 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>
|
||||
</a>
|
||||
</Link>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
<Footer version={version} />
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export const getStaticProps: GetStaticProps<BlogPageProps> = async () => {
|
||||
const { readPackage } = await import('read-pkg')
|
||||
const { getPosts } = await import('utils/blog')
|
||||
const posts = await getPosts()
|
||||
const { version } = await readPackage()
|
||||
return { props: { version, posts } }
|
||||
}
|
||||
|
||||
export default BlogPage
|
@ -21,7 +21,7 @@ const Home: React.FC<FooterProps> = (props) => {
|
||||
<>
|
||||
<Head />
|
||||
|
||||
<Header />
|
||||
<Header showLanguage />
|
||||
<main className='flex flex-col md:mx-auto md:max-w-4xl lg:max-w-7xl'>
|
||||
<Section isMain id='about'>
|
||||
<Profile />
|
||||
|
55
posts/hello-world.mdx
Normal file
55
posts/hello-world.mdx
Normal file
@ -0,0 +1,55 @@
|
||||
---
|
||||
title: 'Hello, world! 👋'
|
||||
description: 'First post of the blog, introduction and explanation of how this blog is made.'
|
||||
isPublished: true
|
||||
publishedOn: '2021-11-06T22:06:33.818Z'
|
||||
---
|
||||
|
||||
Hello, world! 👋
|
||||
|
||||
## Introduction
|
||||
|
||||
This blog is here to document my journey of learning computer science, explaining technical difficulties and problems I encountered, and how I solved them.
|
||||
|
||||
The idea is that I will share my knowledge with you (readers), and hopefully help you to learn too.
|
||||
|
||||
Keep in mind that I will not translate the posts in French, all the posts will be written in English, as I'm not a native English speaker, I will probably make mistakes, feel free to open pull requests on [GitHub](https://github.com/Divlo/Divlo) to correct them. 😊
|
||||
|
||||
I don't plan to publish new posts regularly, but I will do so when I have something new to share.
|
||||
|
||||
To stay informed of new blog post and to ask questions, feel free to follow me on Twitter: [@Divlo_FR](https://twitter.com/Divlo_FR).
|
||||
|
||||
## How this blog is made
|
||||
|
||||
In this section, I will explain what technologies I used to make this blog, and what are the technical choices I had to do.
|
||||
|
||||
The code of this website is open source on [GitHub](https://github.com/Divlo/Divlo), so you can see the code and contribute to it.
|
||||
|
||||
I decided to keep things simple, here are the 2 main features missing on my blog:
|
||||
|
||||
- Comments (you can interact with me on my Twitter account)
|
||||
- Views counter
|
||||
|
||||
That not mean that theses features will never be implemented, but to avoid the need of a database now, I dropped out theses features.
|
||||
|
||||
### Technologies
|
||||
|
||||
- [Next.js](https://nextjs.org/)
|
||||
|
||||
It allows to have a server-side rendered website, that means that it is faster and easier to have a good SEO (Search Engine Optimization) than a SPA (Single Page Application).
|
||||
|
||||
- [MDX](https://mdxjs.com/)
|
||||
|
||||
MDX is an extension of Markdown that allows you to use custom React components.
|
||||
|
||||
Here's what Markdown looks like:
|
||||
|
||||
```md
|
||||
A simple paragraph, with some **bold** text and some `inline code`.
|
||||
```
|
||||
|
||||
When using Markdown in a web application, there's a "compile" step; the Markdown needs to be transformed into HTML, so that it can be understood by the browser. Those asterisks get turned into a `<strong>` tag, and each paragraph gets a `<p>` tag etc.
|
||||
|
||||
- [Tailwind CSS](https://tailwindcss.com/)
|
||||
|
||||
Tailwind is a CSS framework to rapidly build modern websites without ever leaving HTML.
|
34
styles/global.css
Normal file
34
styles/global.css
Normal file
@ -0,0 +1,34 @@
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
#__next {
|
||||
@apply flex flex-col h-screen;
|
||||
}
|
||||
|
||||
.prose {
|
||||
@apply text-gray dark:text-gray-dark !max-w-4xl;
|
||||
}
|
||||
|
||||
.prose a {
|
||||
@apply text-yellow dark:text-yellow-dark;
|
||||
}
|
||||
|
||||
.prose h2,
|
||||
.prose h3,
|
||||
.prose h4,
|
||||
.prose h5,
|
||||
.prose h6 {
|
||||
@apply text-gray dark:text-gray-dark mt-1;
|
||||
}
|
||||
|
||||
.prose code {
|
||||
color: hsl(286, 60%, 67%);
|
||||
}
|
||||
|
||||
code[class*='language-'],
|
||||
pre[class*='language-'] {
|
||||
white-space: pre-wrap !important;
|
||||
word-break: break-word !important;
|
||||
word-wrap: normal;
|
||||
}
|
@ -24,11 +24,25 @@ module.exports = {
|
||||
},
|
||||
fontFamily: {
|
||||
headline: ['Montserrat', 'Arial', 'sans-serif']
|
||||
},
|
||||
typography: {
|
||||
DEFAULT: {
|
||||
css: {
|
||||
fontFamily: ['Montserrat', 'Arial', 'sans-serif'],
|
||||
a: {
|
||||
textDecoration: 'none',
|
||||
'&:hover': {
|
||||
textDecoration: 'underline',
|
||||
fontWeight: 400
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
variants: {
|
||||
extend: {}
|
||||
},
|
||||
plugins: []
|
||||
plugins: [require('@tailwindcss/typography')]
|
||||
}
|
||||
|
70
utils/blog.ts
Normal file
70
utils/blog.ts
Normal file
@ -0,0 +1,70 @@
|
||||
import fs from 'node:fs'
|
||||
import path from 'node:path'
|
||||
|
||||
import type { MDXRemoteSerializeResult } from 'next-mdx-remote'
|
||||
import { serialize } from 'next-mdx-remote/serialize'
|
||||
import remarkGfm from 'remark-gfm'
|
||||
import remarkPrism from 'remark-prism'
|
||||
import matter from 'gray-matter'
|
||||
|
||||
export const postsPath = path.join(process.cwd(), 'posts')
|
||||
|
||||
export type MDXSource = MDXRemoteSerializeResult<Record<string, unknown>>
|
||||
|
||||
export interface FrontMatter {
|
||||
title: string
|
||||
description: string
|
||||
isPublished: boolean
|
||||
publishedOn: string
|
||||
}
|
||||
|
||||
export interface PostMetadata {
|
||||
frontmatter: FrontMatter
|
||||
slug: string
|
||||
content: string
|
||||
}
|
||||
|
||||
export interface Post extends PostMetadata {
|
||||
source: MDXSource
|
||||
}
|
||||
|
||||
export const getPosts = async (): Promise<PostMetadata[]> => {
|
||||
const posts = await fs.promises.readdir(postsPath)
|
||||
const postsWithTime = await Promise.all(
|
||||
posts.map(async (postFilename) => {
|
||||
const [slug] = postFilename.split('.')
|
||||
const blogPostPath = path.join(postsPath, `${slug}.mdx`)
|
||||
const blogPostContent = await fs.promises.readFile(blogPostPath, {
|
||||
encoding: 'utf8'
|
||||
})
|
||||
const { data, content } = matter(blogPostContent) as any
|
||||
const date = new Date(data.publishedOn)
|
||||
return {
|
||||
slug,
|
||||
content,
|
||||
frontmatter: data,
|
||||
time: date.getTime()
|
||||
}
|
||||
})
|
||||
)
|
||||
const postsWithTimeSorted = postsWithTime
|
||||
.filter((post) => post.frontmatter.isPublished)
|
||||
.sort((a, b) => 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) => post.slug === slug)
|
||||
if (post == null) {
|
||||
return undefined
|
||||
}
|
||||
const source = await serialize(post.content, {
|
||||
mdxOptions: {
|
||||
remarkPlugins: [remarkGfm as any, remarkPrism]
|
||||
}
|
||||
})
|
||||
return { ...post, source }
|
||||
}
|
Loading…
Reference in New Issue
Block a user