1
1
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:
Divlo 2021-11-08 15:10:26 +01:00
parent 1505b81233
commit bcb184e49c
No known key found for this signature in database
GPG Key ID: 8F9478F220CE65E9
19 changed files with 5002 additions and 256 deletions

View File

@ -4,13 +4,18 @@
"startServerCommand": "npm run start", "startServerCommand": "npm run start",
"startServerReadyPattern": "ready on", "startServerReadyPattern": "ready on",
"startServerReadyTimeout": 20000, "startServerReadyTimeout": 20000,
"url": ["http://localhost:3000/"], "url": [
"http://localhost:3000/",
"http://localhost:3000/blog",
"http://localhost:3000/blog/hello-world"
],
"numberOfRuns": 3 "numberOfRuns": 3
}, },
"assert": { "assert": {
"preset": "lighthouse:recommended", "preset": "lighthouse:recommended",
"assertions": { "assertions": {
"csp-xss": "warning" "csp-xss": "warning",
"unused-javascript": "warning"
} }
}, },
"upload": { "upload": {

View File

@ -6,6 +6,6 @@
"jest --findRelatedTests" "jest --findRelatedTests"
], ],
"*.{css,yml,json}": ["prettier --write"], "*.{css,yml,json}": ["prettier --write"],
"*.{md}": ["prettier --write", "markdownlint --dot --fix"], "*.{md,mdx}": ["prettier --write", "markdownlint --dot --fix"],
"./Dockerfile": ["dockerfilelint"] "./Dockerfile": ["dockerfilelint"]
} }

View File

@ -4,7 +4,13 @@ import Image from 'next/image'
import { Language } from './Language' import { Language } from './Language'
import { SwitchTheme } from './SwitchTheme' 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 ( 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'> <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='/'> <Link href='/'>
@ -23,7 +29,17 @@ export const Header: React.FC = () => {
</a> </a>
</Link> </Link>
<div className='flex justify-between'> <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 /> <SwitchTheme />
</div> </div>
</header> </header>

View File

@ -22,6 +22,12 @@ export const Interests: React.FC = () => {
})} })}
<InterestsList /> <InterestsList />
</div> </div>
<style jsx global>{`
#__next {
display: block;
}
`}</style>
</> </>
) )
} }

View File

@ -1,6 +1,13 @@
describe('Common > Header', () => { describe('Common > Header', () => {
beforeEach(() => cy.visit('/')) 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)', () => { describe('Switch theme color (dark/light)', () => {
it('should switch theme from `dark` (default) to `light`', () => { it('should switch theme from `dark` (default) to `light`', () => {
cy.get('[data-cy=switch-theme-dark]').should('be.visible') cy.get('[data-cy=switch-theme-dark]').should('be.visible')

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

View 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

File diff suppressed because it is too large Load Diff

View File

@ -18,7 +18,7 @@
"lint:commit": "commitlint", "lint:commit": "commitlint",
"lint:docker": "dockerfilelint './Dockerfile'", "lint:docker": "dockerfilelint './Dockerfile'",
"lint:editorconfig": "editorconfig-checker", "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:typescript": "eslint '**/*.{js,ts,jsx,tsx}'",
"lint:staged": "lint-staged", "lint:staged": "lint-staged",
"test:unit": "jest", "test:unit": "jest",
@ -36,14 +36,21 @@
"@fortawesome/free-solid-svg-icons": "5.15.4", "@fortawesome/free-solid-svg-icons": "5.15.4",
"@fortawesome/react-fontawesome": "0.1.16", "@fortawesome/react-fontawesome": "0.1.16",
"classnames": "2.3.1", "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", "html-react-parser": "1.4.0",
"next": "11.1.2", "next": "11.1.2",
"next-mdx-remote": "3.0.8",
"next-pwa": "5.4.0", "next-pwa": "5.4.0",
"next-themes": "0.0.15", "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": "17.0.2",
"react-dom": "17.0.2", "react-dom": "17.0.2",
"read-pkg": "7.0.0", "read-pkg": "7.0.0",
"remark-gfm": "3.0.1",
"remark-prism": "1.3.6",
"sharp": "0.29.2", "sharp": "0.29.2",
"universal-cookie": "4.0.4" "universal-cookie": "4.0.4"
}, },
@ -53,11 +60,14 @@
"@lhci/cli": "0.8.2", "@lhci/cli": "0.8.2",
"@saithodev/semantic-release-backmerge": "2.1.0", "@saithodev/semantic-release-backmerge": "2.1.0",
"@semantic-release/git": "10.0.1", "@semantic-release/git": "10.0.1",
"@tailwindcss/typography": "0.4.1",
"@testing-library/jest-dom": "5.15.0", "@testing-library/jest-dom": "5.15.0",
"@testing-library/react": "12.1.2", "@testing-library/react": "12.1.2",
"@types/date-and-time": "0.13.0",
"@types/jest": "27.0.2", "@types/jest": "27.0.2",
"@types/node": "16.11.6", "@types/node": "16.11.6",
"@types/react": "17.0.34", "@types/react": "17.0.34",
"@types/remark-prism": "1.3.0",
"@typescript-eslint/eslint-plugin": "4.33.0", "@typescript-eslint/eslint-plugin": "4.33.0",
"autoprefixer": "10.4.0", "autoprefixer": "10.4.0",
"babel-jest": "27.3.1", "babel-jest": "27.3.1",
@ -72,7 +82,7 @@
"eslint-plugin-node": "11.1.0", "eslint-plugin-node": "11.1.0",
"eslint-plugin-prettier": "4.0.0", "eslint-plugin-prettier": "4.0.0",
"eslint-plugin-promise": "5.1.1", "eslint-plugin-promise": "5.1.1",
"eslint-plugin-unicorn": "38.0.0", "eslint-plugin-unicorn": "38.0.1",
"husky": "7.0.4", "husky": "7.0.4",
"jest": "27.3.1", "jest": "27.3.1",
"lint-staged": "11.2.6", "lint-staged": "11.2.6",

View File

@ -12,9 +12,9 @@ const Error404: React.FC<FooterProps> = (props) => {
return ( 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'> <main className='flex flex-col md:mx-auto md:max-w-4xl lg:max-w-7xl'>
<ErrorPage statusCode={404} message={t('errors:not-found')} /> <ErrorPage statusCode={404} message={t('errors:not-found')} />
</main> </main>

View File

@ -12,9 +12,9 @@ const Error500: React.FC<FooterProps> = (props) => {
return ( 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'> <main className='flex flex-col md:mx-auto md:max-w-4xl lg:max-w-7xl'>
<ErrorPage statusCode={500} message={t('errors:server-error')} /> <ErrorPage statusCode={500} message={t('errors:server-error')} />
</main> </main>

View File

@ -4,7 +4,7 @@ import { ThemeProvider } from 'next-themes'
import useTranslation from 'next-translate/useTranslation' import useTranslation from 'next-translate/useTranslation'
import UniversalCookie from 'universal-cookie' import UniversalCookie from 'universal-cookie'
import 'tailwindcss/tailwind.css' import 'styles/global.css'
import '@fontsource/montserrat/400.css' import '@fontsource/montserrat/400.css'
import '@fontsource/montserrat/600.css' import '@fontsource/montserrat/600.css'

79
pages/blog/[slug].tsx Normal file
View 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
View 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

View File

@ -21,7 +21,7 @@ const Home: React.FC<FooterProps> = (props) => {
<> <>
<Head /> <Head />
<Header /> <Header showLanguage />
<main className='flex flex-col md:mx-auto md:max-w-4xl lg:max-w-7xl'> <main className='flex flex-col md:mx-auto md:max-w-4xl lg:max-w-7xl'>
<Section isMain id='about'> <Section isMain id='about'>
<Profile /> <Profile />

55
posts/hello-world.mdx Normal file
View 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
View 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;
}

View File

@ -24,11 +24,25 @@ module.exports = {
}, },
fontFamily: { fontFamily: {
headline: ['Montserrat', 'Arial', 'sans-serif'] headline: ['Montserrat', 'Arial', 'sans-serif']
},
typography: {
DEFAULT: {
css: {
fontFamily: ['Montserrat', 'Arial', 'sans-serif'],
a: {
textDecoration: 'none',
'&:hover': {
textDecoration: 'underline',
fontWeight: 400
}
}
}
}
} }
} }
}, },
variants: { variants: {
extend: {} extend: {}
}, },
plugins: [] plugins: [require('@tailwindcss/typography')]
} }

70
utils/blog.ts Normal file
View 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 }
}