2
0

📝 Add new blog structure

This commit is contained in:
Baptiste Arnaud
2024-04-19 13:20:58 +02:00
parent af014394da
commit 6fe4e28bc3
25 changed files with 862 additions and 184 deletions

View File

@@ -0,0 +1,56 @@
'use client'
import { Heading, Stack, Text } from '@chakra-ui/react'
import { Link } from '@chakra-ui/next-js'
type Props = {
allBlogs: {
metadata: {
title: string
publishedAt: string
}
slug: string
}[]
}
export const Posts = ({ allBlogs }: Props) => (
<Stack
spacing={10}
mx="auto"
maxW="3xl"
my="20"
fontSize="17px"
textAlign="justify"
>
<Heading>Latest blog posts:</Heading>
<Stack>
{allBlogs
.filter((post) => post.metadata.publishedAt)
.sort((a, b) => {
if (
new Date(a.metadata.publishedAt) > new Date(b.metadata.publishedAt)
) {
return -1
}
return 1
})
.map((post) => (
<Link key={post.slug} href={`/blog/${post.slug}`}>
<Stack
w="full"
rounded="md"
borderColor="gray.600"
borderWidth={1}
p="4"
>
<Heading as="h2" fontSize="2xl">
{post.metadata.title}
</Heading>
<Text color="gray.500">
{new Date(post.metadata.publishedAt).toDateString()}
</Text>
</Stack>
</Link>
))}
</Stack>
</Stack>
)

View File

@@ -0,0 +1,80 @@
'use client'
import { Link } from '@chakra-ui/next-js'
import { Heading, Stack, Text } from '@chakra-ui/react'
import { MDXRemote, MDXRemoteSerializeResult } from 'next-mdx-remote'
import { highlight } from 'sugar-high'
type Props = {
metadata: {
title: string
publishedAt: string
}
mdxSource: MDXRemoteSerializeResult
}
export const Post = ({ metadata, mdxSource }: Props) => (
<Stack spacing={10} my="20">
<Stack mx="auto" w="full" maxW="65ch">
<Heading>{metadata.title}</Heading>
<Text>{formatDate(metadata.publishedAt)}</Text>
</Stack>
<Stack
mx="auto"
spacing={0}
as="article"
className="prose prose-quoteless prose-neutral prose-invert"
>
<MDXRemote
{...mdxSource}
components={{
h1: (props) => <Heading as="h1" {...props} />,
h2: (props) => <Heading as="h2" {...props} />,
h3: (props) => <Heading as="h3" {...props} />,
h4: (props) => <Heading as="h4" {...props} />,
h5: (props) => <Heading as="h5" {...props} />,
h6: (props) => <Heading as="h6" {...props} />,
code: ({ children, ...props }) => {
const codeHTML = highlight(children?.toString() ?? '')
return (
<code dangerouslySetInnerHTML={{ __html: codeHTML }} {...props} />
)
},
// eslint-disable-next-line @typescript-eslint/no-explicit-any
link: (props: any) => <Link {...props} />,
}}
/>
</Stack>
</Stack>
)
function formatDate(date: string) {
const currentDate = new Date().getTime()
if (!date.includes('T')) {
date = `${date}T00:00:00`
}
const targetDate = new Date(date).getTime()
const timeDifference = Math.abs(currentDate - targetDate)
const daysAgo = Math.floor(timeDifference / (1000 * 60 * 60 * 24))
const fullDate = new Date(date).toLocaleString('en-us', {
month: 'long',
day: 'numeric',
year: 'numeric',
})
if (daysAgo < 1) {
return 'Today'
} else if (daysAgo < 7) {
return `${fullDate} (${daysAgo}d ago)`
} else if (daysAgo < 30) {
const weeksAgo = Math.floor(daysAgo / 7)
return `${fullDate} (${weeksAgo}w ago)`
} else if (daysAgo < 365) {
const monthsAgo = Math.floor(daysAgo / 30)
return `${fullDate} (${monthsAgo}mo ago)`
} else {
const yearsAgo = Math.floor(daysAgo / 365)
return `${fullDate} (${yearsAgo}y ago)`
}
}

View File

@@ -0,0 +1,67 @@
import type { Metadata } from 'next'
import { notFound } from 'next/navigation'
import { getBlogPosts } from '@/app/db/blog'
import { Post } from './Post'
import { serialize } from 'next-mdx-remote/serialize'
import '@/assets/prose.css'
import { env } from '@typebot.io/env'
export async function generateMetadata({
params,
}: {
params: { slug: string }
}): Promise<Metadata | undefined> {
const post = getBlogPosts().find(
(post) => post.slug === params.slug && post.metadata.publishedAt
)
if (!post) {
return
}
const {
title,
publishedAt: publishedTime,
summary: description,
image,
} = post.metadata
const ogImage = image
? `${env.LANDING_PAGE_URL}${image}`
: `${env.LANDING_PAGE_URL}/og?title=${title}`
return {
title,
description,
openGraph: {
title,
description,
type: 'article',
publishedTime,
url: `${env.LANDING_PAGE_URL}/blog/${post.slug}`,
images: [
{
url: ogImage,
},
],
},
twitter: {
card: 'summary_large_image',
title,
description,
images: [ogImage],
},
}
}
export default async function Blog({ params }: { params: { slug: string } }) {
const post = getBlogPosts().find(
(post) => post.slug === params.slug && post.metadata.publishedAt
)
if (!post) {
notFound()
}
const mdxSource = await serialize(post.content)
return <Post metadata={post.metadata} mdxSource={mdxSource} />
}

View File

@@ -0,0 +1,13 @@
import { getBlogPosts } from '@/app/db/blog'
import { Posts } from './Posts'
export const metadata = {
title: 'Blog',
description: 'Read my thoughts on software development, design, and more.',
}
export default function Home() {
const allBlogs = getBlogPosts()
return <Posts allBlogs={allBlogs} />
}

View File

@@ -0,0 +1,60 @@
import fs from 'fs'
import path from 'path'
type Metadata = {
title: string
publishedAt: string
summary: string
image?: string
}
function parseFrontmatter(fileContent: string) {
const frontmatterRegex = /---\s*([\s\S]*?)\s*---/
const match = frontmatterRegex.exec(fileContent)
const frontMatterBlock = match![1]
const content = fileContent.replace(frontmatterRegex, '').trim()
const frontMatterLines = frontMatterBlock.trim().split('\n')
const metadata: Partial<Metadata> = {}
frontMatterLines.forEach((line) => {
const [key, ...valueArr] = line.split(': ')
let value = valueArr.join(': ').trim()
value = value.replace(/^['"](.*)['"]$/, '$1') // Remove quotes
metadata[key.trim() as keyof Metadata] = value
})
return { metadata: metadata as Metadata, content }
}
function getMDXFiles(dir: fs.PathLike) {
return fs.readdirSync(dir).filter((file) => path.extname(file) === '.mdx')
}
function readMDXFile(filePath: fs.PathOrFileDescriptor) {
const rawContent = fs.readFileSync(filePath, 'utf-8')
return parseFrontmatter(rawContent)
}
function extractTweetIds(content: string) {
const tweetMatches = content.match(/<StaticTweet\sid="[0-9]+"\s\/>/g)
return tweetMatches?.map((tweet) => tweet.match(/[0-9]+/g)?.[0]) || []
}
function getMDXData(dir: string) {
const mdxFiles = getMDXFiles(dir)
return mdxFiles.map((file) => {
const { metadata, content } = readMDXFile(path.join(dir, file))
const slug = path.basename(file, path.extname(file))
const tweetIds = extractTweetIds(content)
return {
metadata,
slug,
tweetIds,
content,
}
})
}
export function getBlogPosts() {
return getMDXData(path.join(process.cwd(), 'content'))
}

View File

@@ -0,0 +1,42 @@
/* eslint-disable @next/next/no-sync-scripts */
/* eslint-disable @next/next/no-page-custom-font */
import type { Metadata } from 'next'
import { Header } from 'components/common/Header/Header'
import { Footer } from 'components/common/Footer'
import { Providers } from './providers'
import { EndCta } from '@/components/Homepage/EndCta'
import 'assets/style.css'
export const metadata: Metadata = {
title: 'Typebot - Open-source conversational apps builder',
description:
'Powerful blocks to create unique chat experiences. Embed them anywhere on your apps and start collecting results like magic.',
}
export default function RootLayout({
children,
}: Readonly<{
children: React.ReactNode
}>) {
return (
<html lang="en">
<head>
<link rel="icon" type="image/png" href="/favicon.png" />
<link
href="https://fonts.googleapis.com/css2?family=Outfit:wght@400;500;600;700&family=Open+Sans:wght@400;500;600;700&family=Indie+Flower:wght@400&display=swap"
rel="stylesheet"
/>
<script src="/__ENV.js" />
</head>
<body style={{ backgroundColor: '#171923' }}>
<Providers>
<Header />
{children}
<EndCta />
<Footer />
</Providers>
</body>
</html>
)
}

View File

@@ -0,0 +1,59 @@
import { ImageResponse } from 'next/og'
import { NextRequest } from 'next/server'
import { env } from '@typebot.io/env'
export const runtime = 'edge'
export async function GET(req: NextRequest) {
const { searchParams } = req.nextUrl
const postTitle = searchParams.get('title')
const font = fetch(
new URL('../../assets/Outfit-Medium.ttf', import.meta.url)
).then((res) => res.arrayBuffer())
const fontData = await font
return new ImageResponse(
(
<div
style={{
height: '100%',
width: '100%',
display: 'flex',
flexDirection: 'column',
alignItems: 'flex-start',
justifyContent: 'center',
backgroundImage: `url(${env.LANDING_PAGE_URL}/images/og-bg.png)`,
}}
>
<div
style={{
marginLeft: 190,
marginRight: 190,
display: 'flex',
fontSize: 130,
fontFamily: 'Outfit',
letterSpacing: '-0.05em',
fontStyle: 'normal',
color: 'white',
lineHeight: '120px',
whiteSpace: 'pre-wrap',
}}
>
{postTitle}
</div>
</div>
),
{
width: 1280,
height: 720,
fonts: [
{
name: 'Outfit',
data: fontData,
style: 'normal',
},
],
}
)
}

View File

@@ -0,0 +1,13 @@
'use client'
import { theme } from '@/lib/chakraTheme'
import { ChakraProvider, ColorModeScript } from '@chakra-ui/react'
export function Providers({ children }: { children: React.ReactNode }) {
return (
<ChakraProvider theme={theme}>
<ColorModeScript initialColorMode={theme.config.initialColorMode} />
{children}
</ChakraProvider>
)
}

Binary file not shown.

View File

@@ -0,0 +1,15 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
:root {
--sh-class: #5395e5;
--sh-identifier: white;
--sh-sign: #8996a3;
--sh-property: #5395e5;
--sh-entity: #249a97;
--sh-jsxliterals: #6266d1;
--sh-string: #00a99a;
--sh-keyword: #f47067;
--sh-comment: #a19595;
}

View File

@@ -1,3 +1,5 @@
'use client'
import { Heading, Button, Text, Flex, VStack } from '@chakra-ui/react'
import Link from 'next/link'
import React from 'react'

View File

@@ -1,3 +1,5 @@
'use client'
import React, { ReactNode } from 'react'
import {

View File

@@ -1,3 +1,5 @@
'use client'
import {
Button,
Flex,

View File

@@ -0,0 +1,8 @@
---
title: 'Blog post example'
summary: 'A short summary of the blog post.'
---
This is a blog post example.
This can be deleted once we published the first blog post.

View File

@@ -1,5 +1,6 @@
/// <reference types="next" />
/// <reference types="next/image-types/global" />
/// <reference types="next/navigation-types/compat/navigation" />
// NOTE: This file should not be edited
// see https://nextjs.org/docs/basic-features/typescript for more information.

View File

@@ -10,9 +10,11 @@
},
"dependencies": {
"@chakra-ui/icon": "3.0.15",
"@chakra-ui/next-js": "2.2.0",
"@chakra-ui/react": "2.7.1",
"@emotion/react": "11.11.1",
"@emotion/styled": "11.11.0",
"@typebot.io/billing": "workspace:*",
"@typebot.io/lib": "workspace:*",
"@typebot.io/nextjs": "workspace:*",
"@typebot.io/prisma": "workspace:*",
@@ -21,13 +23,16 @@
"focus-visible": "5.2.0",
"framer-motion": "10.12.20",
"next": "14.1.0",
"next-mdx-remote": "4.4.1",
"react": "18.2.0",
"react-dom": "18.2.0",
"@typebot.io/billing": "workspace:*"
"sugar-high": "0.6.0"
},
"devDependencies": {
"@babel/core": "7.22.9",
"@chakra-ui/styled-system": "2.9.1",
"@tailwindcss/typography": "0.5.12",
"@typebot.io/env": "workspace:*",
"@typebot.io/tsconfig": "workspace:*",
"@types/aos": "3.0.4",
"@types/node": "20.4.2",
@@ -38,10 +43,10 @@
"eslint": "8.44.0",
"eslint-config-custom": "workspace:*",
"next-runtime-env": "1.6.2",
"@typebot.io/env": "workspace:*",
"next-transpile-modules": "10.0.0",
"postcss": "8.4.26",
"prettier": "3.0.0",
"tailwindcss": "3.3.3",
"typescript": "5.3.2"
}
}

View File

@@ -0,0 +1,6 @@
module.exports = {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 501 KiB

View File

@@ -0,0 +1,8 @@
/** @type {import('tailwindcss').Config} */
module.exports = {
content: ['./app/blog/**/*.{js,ts,jsx,tsx,mdx}'],
theme: {
extend: {},
},
plugins: [require('@tailwindcss/typography')],
}

View File

@@ -3,8 +3,14 @@
"compilerOptions": {
"baseUrl": ".",
"paths": {
"@/*": ["src/*"]
}
"@/*": ["./*"]
},
"plugins": [
{
"name": "next"
}
],
"strictNullChecks": true
},
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx"]
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"]
}