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,38 @@
---
title: 'Contribute to the blog'
sidebarTitle: 'Blog'
icon: 'newspaper'
---
The [official Typebot blog](https://typebot.io/blog) is a place where we share any ideas, original content related to the chatbot industry, and Typebot itself.
You are free to contribute to the blog or fix any typos you may find.
1. Head over to the [content folder](https://github.com/baptisteArno/typebot.io/tree/main/apps/landing-page/content) on the Github repo.
2. Click on the blog post file you want to edit. Or create a new file by clicking on the `Add file` button.
3. If you did not already have a fork of the repository, you will be prompted to create one.
4. Once you're happy with your changes, hit `Commit changes...`.
5. Click on `Create pull request`.
6. Add a title and a description to describe your changes.
7. Click on `Create pull request`.
It will be reviewed and merged if approved!
## New article guidelines
- The article should be related to chatbots, or Typebot.
- The article should be written in English.
- The article should be original content. No plagiarism.
- The article should not be 100% AI-generated.
The mdx file should always start with the following frontmatter:
```md
---
title: 'My awesome blog post'
publishedAt: '2023-11-19'
summary: 'A short summary of the blog post.'
---
```
By default the og image is generated from the title of the blog post. If you want to use a custom og image, you can specify a `image` field in the frontmatter.

View File

@ -40,8 +40,19 @@ Any contributions you make are **greatly appreciated**. There are many ways to c
href="./guides/documentation"
color="#97A0B1"
>
Help us improve the documentation by fixing typos, adding missing information
or proposing new sections.
Improve the documentation by fixing typos, adding missing information or
proposing new sections.
</Card>
<Card
title="Write official blog posts"
icon="newspaper"
iconType="duotone"
href="./guides/blog"
color="#97A0B1"
>
Write original content for Typebot's blog. Share your knowledge and ideas to a
wider audience. The author will be credited.
</Card>
<Card

View File

@ -239,6 +239,7 @@
"contribute/guides/local-installation",
"contribute/guides/create-block",
"contribute/guides/documentation",
"contribute/guides/blog",
"contribute/guides/translation"
]
},

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"]
}

15
packages/env/env.ts vendored
View File

@ -42,6 +42,16 @@ const guessViewerUrlForVercelPreview = (val: unknown) => {
)
}
const guessLandingUrlForVercelPreview = (val: unknown) => {
if (
(val && typeof val === 'string' && val.length > 0) ||
process.env.VERCEL_ENV !== 'preview' ||
!process.env.VERCEL_LANDING_PROJECT_NAME
)
return val
return `https://${process.env.VERCEL_BRANCH_URL}`
}
const boolean = z.enum(['true', 'false']).transform((value) => value === 'true')
const baseEnv = {
@ -89,6 +99,10 @@ const baseEnv = {
val.split('/').map((s) => s.split(',').map((s) => s.split('|')))
)
.optional(),
LANDING_PAGE_URL: z.preprocess(
guessLandingUrlForVercelPreview,
z.string().url().optional()
),
},
client: {
NEXT_PUBLIC_E2E_TEST: boolean.optional(),
@ -269,6 +283,7 @@ const vercelEnv = {
VERCEL_TEAM_ID: z.string().min(1).optional(),
VERCEL_GIT_COMMIT_SHA: z.string().min(1).optional(),
VERCEL_BUILDER_PROJECT_NAME: z.string().min(1).optional(),
VERCEL_LANDING_PROJECT_NAME: z.string().min(1).optional(),
},
client: {
NEXT_PUBLIC_VERCEL_VIEWER_PROJECT_NAME: z.string().min(1).optional(),

522
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff