mirror of
				https://github.com/theoludwig/theoludwig.git
				synced 2025-10-14 20:23:25 +02:00 
			
		
		
		
	feat: add blog (#320)
This commit is contained in:
		| @@ -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 } | ||||
| } | ||||
		Reference in New Issue
	
	Block a user