refactor: migrate to rsc and app dir

This commit is contained in:
Timothy Lin 2023-07-07 11:17:22 +08:00
parent a03d358ef9
commit 09ba0550ca
121 changed files with 13431 additions and 12945 deletions

View File

@ -1,3 +1,4 @@
# visit https://giscus.app to get your Giscus ids
NEXT_PUBLIC_GISCUS_REPO=
NEXT_PUBLIC_GISCUS_REPOSITORY_ID=
NEXT_PUBLIC_GISCUS_CATEGORY=
@ -10,20 +11,16 @@ MAILCHIMP_API_KEY=
MAILCHIMP_API_SERVER=
MAILCHIMP_AUDIENCE_ID=
BUTTONDOWN_API_URL=https://api.buttondown.email/v1/
BUTTONDOWN_API_KEY=
CONVERTKIT_API_URL=https://api.convertkit.com/v3/
CONVERTKIT_API_KEY=
// curl https://api.convertkit.com/v3/forms?api_key=<your_public_api_key> to get your form ID
CONVERTKIT_FORM_ID=
# curl https://api.convertkit.com/v3/forms?api_key=<your_public_api_key> to get your form ID
CONVERTKIT_FORM_ID=
KLAVIYO_API_KEY=
KLAVIYO_LIST_ID=
REVUE_API_URL=https://www.getrevue.co/api/v2/
REVUE_API_KEY=
EMAILOCTOPUS_API_URL=https://emailoctopus.com/api/1.6/
EMAILOCTOPUS_API_KEY=
EMAILOCTOPUS_LIST_ID=
EMAILOCTOPUS_LIST_ID=

View File

@ -1,17 +1,38 @@
module.exports = {
root: true,
parser: '@typescript-eslint/parser',
env: {
browser: true,
amd: true,
node: true,
es6: true,
},
extends: ['eslint:recommended', 'plugin:prettier/recommended', 'next', 'next/core-web-vitals'],
plugins: ['@typescript-eslint'],
extends: [
'eslint:recommended',
'plugin:@typescript-eslint/eslint-recommended',
'plugin:@typescript-eslint/recommended',
'plugin:jsx-a11y/recommended',
'plugin:prettier/recommended',
'next',
'next/core-web-vitals',
],
rules: {
'prettier/prettier': 'error',
'react/react-in-jsx-scope': 'off',
'jsx-a11y/anchor-is-valid': [
'error',
{
components: ['Link'],
specialLink: ['hrefLeft', 'hrefRight'],
aspects: ['invalidHref', 'preferButton'],
},
],
'react/prop-types': 0,
'no-unused-vars': 0,
'react/no-unescaped-entities': 0,
'@typescript-eslint/explicit-module-boundary-types': 'off',
'@typescript-eslint/no-var-requires': 'off',
'@typescript-eslint/ban-ts-comment': 'off',
},
}

2
.gitattributes vendored
View File

@ -1,5 +1,5 @@
## Source: https://github.com/alexkaratarakis/gitattributes
## Modified * text=auto to * text=auto eol=lf to force LF endings.
## Modified * text=auto to * text=auto eol=lf eol=lf to force LF endings.
## GITATTRIBUTES FOR WEB PROJECTS
#

15
.gitignore vendored
View File

@ -4,6 +4,10 @@
/node_modules
/.pnp
.pnp.js
/.yarn/*
!/.yarn/releases
!/.yarn/plugins
!/.yarn/sdks
# testing
/coverage
@ -17,8 +21,12 @@ public/sitemap.xml
# production
/build
*.xml
# rss feed
/public/feed.xml
/public/feed.xml
# search
/public/search.json
# misc
.DS_Store
@ -33,4 +41,7 @@ yarn-error.log*
.env.local
.env.development.local
.env.test.local
.env.production.local
.env.production.local
# Contentlayer
.contentlayer

4
.vscode/settings.json vendored Normal file
View File

@ -0,0 +1,4 @@
{
"typescript.tsdk": "node_modules/typescript/lib",
"typescript.enablePromptUseWorkspaceTsdk": true
}

1
.yarnrc.yml Normal file
View File

@ -0,0 +1 @@
nodeLinker: node-modules

View File

@ -1,26 +1,20 @@
'use client'
import Link from '@/components/Link'
import { PageSEO } from '@/components/SEO'
// import { PageSEO } from '@/components/SEO'
import Tag from '@/components/Tag'
import siteMetadata from '@/data/siteMetadata'
import { getAllFilesFrontMatter } from '@/lib/mdx'
import formatDate from '@/lib/utils/formatDate'
import NewsletterForm from '@/components/NewsletterForm'
import { formatDate } from 'pliny/utils/formatDate'
import { NewsletterForm } from 'pliny/ui/NewsletterForm'
const MAX_DISPLAY = 5
export async function getStaticProps() {
const posts = await getAllFilesFrontMatter('blog')
return { props: { posts } }
}
export default function Home({ posts }) {
return (
<>
<PageSEO title={siteMetadata.title} description={siteMetadata.description} />
{/* <PageSEO title={siteMetadata.title} description={siteMetadata.description} /> */}
<div className="divide-y divide-gray-200 dark:divide-gray-700">
<div className="space-y-2 pt-6 pb-8 md:space-y-5">
<div className="space-y-2 pb-8 pt-6 md:space-y-5">
<h1 className="text-3xl font-extrabold leading-9 tracking-tight text-gray-900 dark:text-gray-100 sm:text-4xl sm:leading-10 md:text-6xl md:leading-14">
Latest
</h1>
@ -30,8 +24,8 @@ export default function Home({ posts }) {
</div>
<ul className="divide-y divide-gray-200 dark:divide-gray-700">
{!posts.length && 'No posts found.'}
{posts.slice(0, MAX_DISPLAY).map((frontMatter) => {
const { slug, date, title, summary, tags } = frontMatter
{posts.slice(0, MAX_DISPLAY).map((post) => {
const { slug, date, title, summary, tags } = post
return (
<li key={slug} className="py-12">
<article>
@ -39,7 +33,7 @@ export default function Home({ posts }) {
<dl>
<dt className="sr-only">Published on</dt>
<dd className="text-base font-medium leading-6 text-gray-500 dark:text-gray-400">
<time dateTime={date}>{formatDate(date)}</time>
<time dateTime={date}>{formatDate(date, siteMetadata.locale)}</time>
</dd>
</dl>
<div className="space-y-5 xl:col-span-3">
@ -85,13 +79,13 @@ export default function Home({ posts }) {
<Link
href="/blog"
className="text-primary-500 hover:text-primary-600 dark:hover:text-primary-400"
aria-label="all posts"
aria-label="All posts"
>
All Posts &rarr;
</Link>
</div>
)}
{siteMetadata.newsletter.provider !== '' && (
{siteMetadata.newsletter?.provider && (
<div className="flex items-center justify-center pt-4">
<NewsletterForm />
</div>

16
app/about/About.tsx Normal file
View File

@ -0,0 +1,16 @@
'use client'
import { MDXLayoutRenderer } from 'pliny/mdx-components'
import { MDXComponents } from '@/components/MDXComponents'
const DEFAULT_LAYOUT = 'AuthorLayout'
export default function About({ author }) {
return (
<MDXLayoutRenderer
layout={author.layout || DEFAULT_LAYOUT}
content={author}
MDXComponents={MDXComponents}
/>
)
}

7
app/about/page.tsx Normal file
View File

@ -0,0 +1,7 @@
import { allAuthors } from 'contentlayer/generated'
import About from './About'
export default function Page() {
const author = allAuthors.find((p) => p.slug === 'default')
return <About author={author} />
}

View File

@ -0,0 +1,14 @@
import { NextResponse } from 'next/server'
import { NewsletterAPI } from 'pliny/newsletter'
import siteMetadata from '@/data/siteMetadata'
export async function POST(request: Request) {
const res = await request.json()
console.log(res) // { email: 'test@example.com' }
return NextResponse.json({ res })
// return NewsletterAPI({
// // @ts-ignore
// provider: siteMetadata.newsletter.provider,
// })
}

View File

@ -0,0 +1,21 @@
'use client'
import { MDXLayoutRenderer } from 'pliny/mdx-components'
import { MDXComponents } from '@/components/MDXComponents'
import type { Blog } from 'contentlayer/generated'
const DEFAULT_LAYOUT = 'PostLayout'
export default function Blog({ post, authorDetails, prev, next }) {
return (
<MDXLayoutRenderer
layout={post.layout || DEFAULT_LAYOUT}
content={post}
MDXComponents={MDXComponents}
toc={post.toc}
authorDetails={authorDetails}
prev={prev}
next={next}
/>
)
}

View File

@ -0,0 +1,44 @@
import PageTitle from '@/components/PageTitle'
import { sortedBlogPost, coreContent } from 'pliny/utils/contentlayer'
import { allBlogs, allAuthors } from 'contentlayer/generated'
import type { Authors, Blog } from 'contentlayer/generated'
import BlogPage from './Blog'
export const generateStaticParams = async () => {
const paths = allBlogs.map((p) => ({ slug: p.slug.split('/') }))
return paths
}
export default function Page({ params }: { params: { slug: string[] } }) {
const slug = params.slug.join('/')
const sortedPosts = sortedBlogPost(allBlogs) as Blog[]
const postIndex = sortedPosts.findIndex((p) => p.slug === slug)
const prevContent = sortedPosts[postIndex + 1] || null
const prev = prevContent ? coreContent(prevContent) : null
const nextContent = sortedPosts[postIndex - 1] || null
const next = nextContent ? coreContent(nextContent) : null
const post = sortedPosts.find((p) => p.slug === slug)
const authorList = post?.authors || ['default']
const authorDetails = authorList.map((author) => {
const authorResults = allAuthors.find((p) => p.slug === author)
return coreContent(authorResults as Authors)
})
return (
<>
{post && 'draft' in post && post.draft === true ? (
<div className="mt-24 text-center">
<PageTitle>
Under Construction{' '}
<span role="img" aria-label="roadwork sign">
🚧
</span>
</PageTitle>
</div>
) : (
<BlogPage post={post} authorDetails={authorDetails} prev={prev} next={next} />
)}
</>
)
}

31
app/blog/page.tsx Normal file
View File

@ -0,0 +1,31 @@
import ListLayout from '@/layouts/ListLayout'
import { sortedBlogPost } from 'pliny/utils/contentlayer'
import { allBlogs } from 'contentlayer/generated'
import type { Blog } from 'contentlayer/generated'
const POSTS_PER_PAGE = 5
export default function BlogPage() {
const posts = sortedBlogPost(allBlogs) as Blog[]
const pageNumber = 1
const initialDisplayPosts = posts.slice(
POSTS_PER_PAGE * (pageNumber - 1),
POSTS_PER_PAGE * pageNumber
)
const pagination = {
currentPage: pageNumber,
totalPages: Math.ceil(posts.length / POSTS_PER_PAGE),
}
return (
<>
{/* <PageSEO title={`Blog - ${siteMetadata.author}`} description={siteMetadata.description} /> */}
<ListLayout
posts={posts}
initialDisplayPosts={initialDisplayPosts}
pagination={pagination}
title="All Posts"
/>
</>
)
}

View File

@ -0,0 +1,41 @@
// import { PageSEO } from '@/components/SEO'
import siteMetadata from '@/data/siteMetadata'
import ListLayout from '@/layouts/ListLayout'
import { allCoreContent, sortedBlogPost } from 'pliny/utils/contentlayer'
import { allBlogs } from 'contentlayer/generated'
import type { Blog } from 'contentlayer/generated'
const POSTS_PER_PAGE = 5
export const generateStaticParams = async () => {
const totalPosts = allBlogs
const totalPages = Math.ceil(totalPosts.length / POSTS_PER_PAGE)
const paths = Array.from({ length: totalPages }, (_, i) => ({ page: (i + 1).toString() }))
return paths
}
export default function Page({ params }: { params: { page: string } }) {
const posts = sortedBlogPost(allBlogs) as Blog[]
const pageNumber = parseInt(params.page as string)
const initialDisplayPosts = posts.slice(
POSTS_PER_PAGE * (pageNumber - 1),
POSTS_PER_PAGE * pageNumber
)
const pagination = {
currentPage: pageNumber,
totalPages: Math.ceil(posts.length / POSTS_PER_PAGE),
}
return (
<>
{/* <PageSEO title={siteMetadata.title} description={siteMetadata.description} /> */}
<ListLayout
posts={allCoreContent(posts)}
initialDisplayPosts={allCoreContent(initialDisplayPosts)}
pagination={pagination}
title="All Posts"
/>
</>
)
}

29
app/globals.css Normal file
View File

@ -0,0 +1,29 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
.task-list-item::before {
@apply hidden;
}
.task-list-item {
@apply list-none;
}
.footnotes {
@apply mt-12 border-t border-gray-200 pt-8 dark:border-gray-700;
}
.data-footnote-backref {
@apply no-underline;
}
.csl-entry {
@apply my-5;
}
/* https://stackoverflow.com/questions/61083813/how-to-avoid-internal-autofill-selected-style-to-be-applied */
input:-webkit-autofill,
input:-webkit-autofill:focus {
transition: background-color 600000s 0s, color 600000s 0s;
}

16
app/head.tsx Normal file
View File

@ -0,0 +1,16 @@
export default function Head() {
return (
<>
<link rel="apple-touch-icon" sizes="76x76" href="/static/favicons/apple-touch-icon.png" />
<link rel="icon" type="image/png" sizes="32x32" href="/static/favicons/favicon-32x32.png" />
<link rel="icon" type="image/png" sizes="16x16" href="/static/favicons/favicon-16x16.png" />
<link rel="manifest" href="/static/favicons/site.webmanifest" />
<link rel="mask-icon" href="/static/favicons/safari-pinned-tab.svg" color="#5bbad5" />
<meta name="msapplication-TileColor" content="#000000" />
<meta name="theme-color" media="(prefers-color-scheme: light)" content="#fff" />
<meta name="theme-color" media="(prefers-color-scheme: dark)" content="#000" />
<link rel="alternate" type="application/rss+xml" href="/feed.xml" />
<meta content="width=device-width, initial-scale=1" name="viewport" />
</>
)
}

35
app/layout.tsx Normal file
View File

@ -0,0 +1,35 @@
import './globals.css'
import { Inter } from 'next/font/google'
import { Analytics } from 'pliny/analytics'
import { SearchProvider } from 'pliny/search'
import Header from '@/components/Header'
import SectionContainer from '@/components/SectionContainer'
import Footer from '@/components/Footer'
import siteMetadata from '@/data/siteMetadata'
import { ThemeProviders } from './theme-providers'
const inter = Inter({
subsets: ['latin'],
})
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang={siteMetadata.language} className={`${inter.className} scroll-smooth`}>
<body className="bg-white text-black antialiased dark:bg-gray-900 dark:text-white">
<ThemeProviders>
{/* <Analytics analyticsConfig={siteMetadata.analytics} /> */}
<SectionContainer>
<div className="flex h-screen flex-col justify-between font-sans">
{/* <SearchProvider searchConfig={siteMetadata.search}> */}
<Header />
<main className="mb-auto">{children}</main>
{/* </SearchProvider> */}
<Footer />
</div>
</SectionContainer>
</ThemeProviders>
</body>
</html>
)
}

View File

@ -1,13 +1,12 @@
import Link from '@/components/Link'
import { PageSEO } from '@/components/SEO'
import siteMetadata from '@/data/siteMetadata'
// import { PageSEO } from '@/components/SEO'
export default function FourZeroFour() {
export default function NotFound() {
return (
<>
<PageSEO title={`Page Not Found - ${siteMetadata.title}`} />
{/* <PageSEO title="Page Not Found" description="Sorry we couldn't find this page :(" /> */}
<div className="flex flex-col items-start justify-start md:mt-24 md:flex-row md:items-center md:justify-center md:space-x-6">
<div className="space-x-2 pt-6 pb-8 md:space-y-5">
<div className="space-x-2 pb-8 pt-6 md:space-y-5">
<h1 className="text-6xl font-extrabold leading-9 tracking-tight text-gray-900 dark:text-gray-100 md:border-r-2 md:px-6 md:text-8xl md:leading-14">
404
</h1>
@ -19,10 +18,11 @@ export default function FourZeroFour() {
<p className="mb-8">
But dont worry, you can find plenty of other things on our homepage.
</p>
<Link href="/">
<button className="focus:shadow-outline-blue inline rounded-lg border border-transparent bg-blue-600 px-4 py-2 text-sm font-medium leading-5 text-white shadow transition-colors duration-150 hover:bg-blue-700 focus:outline-none dark:hover:bg-blue-500">
Back to homepage
</button>
<Link
href="/"
className="focus:shadow-outline-blue inline rounded-lg border border-transparent bg-blue-600 px-4 py-2 text-sm font-medium leading-5 text-white shadow transition-colors duration-150 hover:bg-blue-700 focus:outline-none dark:hover:bg-blue-500"
>
Back to homepage
</Link>
</div>
</div>

10
app/page.tsx Normal file
View File

@ -0,0 +1,10 @@
import { sortedBlogPost, allCoreContent } from 'pliny/utils/contentlayer'
import { allBlogs } from 'contentlayer/generated'
import type { Blog } from 'contentlayer/generated'
import Main from './Main'
export default async function Page() {
const sortedPosts = sortedBlogPost(allBlogs) as Blog[]
const posts = allCoreContent(sortedPosts)
return <Main posts={posts} />
}

View File

@ -1,14 +1,12 @@
import siteMetadata from '@/data/siteMetadata'
import projectsData from '@/data/projectsData'
import Card from '@/components/Card'
import { PageSEO } from '@/components/SEO'
export default function Projects() {
return (
<>
<PageSEO title={`Projects - ${siteMetadata.author}`} description={siteMetadata.description} />
<div className="divide-y divide-gray-200 dark:divide-gray-700">
<div className="space-y-2 pt-6 pb-8 md:space-y-5">
<div className="space-y-2 pb-8 pt-6 md:space-y-5">
<h1 className="text-3xl font-extrabold leading-9 tracking-tight text-gray-900 dark:text-gray-100 sm:text-4xl sm:leading-10 md:text-6xl md:leading-14">
Projects
</h1>

View File

@ -1,23 +1,19 @@
import { getAllTags } from 'pliny/utils/contentlayer'
import Link from '@/components/Link'
import { PageSEO } from '@/components/SEO'
// import { PageSEO } from '@/components/SEO'
import Tag from '@/components/Tag'
import siteMetadata from '@/data/siteMetadata'
import { getAllTags } from '@/lib/tags'
import kebabCase from '@/lib/utils/kebabCase'
import { kebabCase } from 'pliny/utils/kebabCase'
import { allBlogs } from 'contentlayer/generated'
export async function getStaticProps() {
const tags = await getAllTags('blog')
return { props: { tags } }
}
export default function Tags({ tags }) {
export default async function Page() {
const tags = await getAllTags(allBlogs)
const sortedTags = Object.keys(tags).sort((a, b) => tags[b] - tags[a])
return (
<>
<PageSEO title={`Tags - ${siteMetadata.author}`} description="Things I blog about" />
{/* <PageSEO title={`Tags - ${siteMetadata.author}`} description="Things I blog about" /> */}
<div className="flex flex-col items-start justify-start divide-y divide-gray-200 dark:divide-gray-700 md:mt-24 md:flex-row md:items-center md:justify-center md:space-x-6 md:divide-y-0">
<div className="space-x-2 pt-6 pb-8 md:space-y-5">
<div className="space-x-2 pb-8 pt-6 md:space-y-5">
<h1 className="text-3xl font-extrabold leading-9 tracking-tight text-gray-900 dark:text-gray-100 sm:text-4xl sm:leading-10 md:border-r-2 md:px-6 md:text-6xl md:leading-14">
Tags
</h1>
@ -26,11 +22,12 @@ export default function Tags({ tags }) {
{Object.keys(tags).length === 0 && 'No tags found.'}
{sortedTags.map((t) => {
return (
<div key={t} className="mt-2 mb-2 mr-5">
<div key={t} className="mb-2 mr-5 mt-2">
<Tag text={t} />
<Link
href={`/tags/${kebabCase(t)}`}
className="-ml-2 text-sm font-semibold uppercase text-gray-600 dark:text-gray-300"
aria-label={`View posts tagged ${t}`}
>
{` (${tags[t]})`}
</Link>

12
app/theme-providers.tsx Normal file
View File

@ -0,0 +1,12 @@
'use client'
import { ThemeProvider } from 'next-themes'
import siteMetadata from '@/data/siteMetadata'
export function ThemeProviders({ children }: { children: React.ReactNode }) {
return (
<ThemeProvider attribute="class" defaultTheme={siteMetadata.theme} enableSystem>
{children}
</ThemeProvider>
)
}

View File

@ -2,7 +2,7 @@ import Image from './Image'
import Link from './Link'
const Card = ({ title, description, imgSrc, href }) => (
<div className="md p-4 md:w-1/2" style={{ maxWidth: '544px' }}>
<div className="md max-w-[544px] p-4 md:w-1/2">
<div
className={`${
imgSrc && 'h-full'

View File

@ -1,23 +0,0 @@
import { useEffect } from 'react'
import Router from 'next/router'
/**
* Client-side complement to next-remote-watch
* Re-triggers getStaticProps when watched mdx files change
*
*/
export const ClientReload = () => {
// Exclude socket.io from prod bundle
useEffect(() => {
import('socket.io-client').then((module) => {
const socket = module.io()
socket.on('reload', (data) => {
Router.replace(Router.asPath, undefined, {
scroll: false,
})
})
})
}, [])
return null
}

View File

@ -7,12 +7,12 @@ export default function Footer() {
<footer>
<div className="mt-16 flex flex-col items-center">
<div className="mb-3 flex space-x-4">
<SocialIcon kind="mail" href={`mailto:${siteMetadata.email}`} size="6" />
<SocialIcon kind="github" href={siteMetadata.github} size="6" />
<SocialIcon kind="facebook" href={siteMetadata.facebook} size="6" />
<SocialIcon kind="youtube" href={siteMetadata.youtube} size="6" />
<SocialIcon kind="linkedin" href={siteMetadata.linkedin} size="6" />
<SocialIcon kind="twitter" href={siteMetadata.twitter} size="6" />
<SocialIcon kind="mail" href={`mailto:${siteMetadata.email}`} size={6} />
<SocialIcon kind="github" href={siteMetadata.github} size={6} />
<SocialIcon kind="facebook" href={siteMetadata.facebook} size={6} />
<SocialIcon kind="youtube" href={siteMetadata.youtube} size={6} />
<SocialIcon kind="linkedin" href={siteMetadata.linkedin} size={6} />
<SocialIcon kind="twitter" href={siteMetadata.twitter} size={6} />
</div>
<div className="mb-2 flex space-x-2 text-sm text-gray-500 dark:text-gray-400">
<div>{siteMetadata.author}</div>

46
components/Header.tsx Normal file
View File

@ -0,0 +1,46 @@
import siteMetadata from '@/data/siteMetadata'
import headerNavLinks from '@/data/headerNavLinks'
import Logo from '@/data/logo.svg'
import Link from './Link'
import MobileNav from './MobileNav'
import ThemeSwitch from './ThemeSwitch'
const Header = () => {
return (
<header className="flex items-center justify-between py-10">
<div>
<Link href="/" aria-label={siteMetadata.headerTitle}>
<div className="flex items-center justify-between">
<div className="mr-3">
<Logo />
</div>
{typeof siteMetadata.headerTitle === 'string' ? (
<div className="hidden h-6 text-2xl font-semibold sm:block">
{siteMetadata.headerTitle}
</div>
) : (
siteMetadata.headerTitle
)}
</div>
</Link>
</div>
<div className="flex items-center text-base leading-5">
<div className="hidden sm:block">
{headerNavLinks.map((link) => (
<Link
key={link.title}
href={link.href}
className="p-1 font-medium text-gray-900 dark:text-gray-100 sm:p-4"
>
{link.title}
</Link>
))}
</div>
<ThemeSwitch />
<MobileNav />
</div>
</header>
)
}
export default Header

View File

@ -1,6 +0,0 @@
import NextImage from 'next/image'
// eslint-disable-next-line jsx-a11y/alt-text
const Image = ({ ...rest }) => <NextImage {...rest} />
export default Image

5
components/Image.tsx Normal file
View File

@ -0,0 +1,5 @@
import NextImage, { ImageProps } from 'next/image'
const Image = ({ ...rest }: ImageProps) => <NextImage {...rest} />
export default Image

View File

@ -1,54 +0,0 @@
import siteMetadata from '@/data/siteMetadata'
import headerNavLinks from '@/data/headerNavLinks'
import Logo from '@/data/logo.svg'
import Link from './Link'
import SectionContainer from './SectionContainer'
import Footer from './Footer'
import MobileNav from './MobileNav'
import ThemeSwitch from './ThemeSwitch'
const LayoutWrapper = ({ children }) => {
return (
<SectionContainer>
<div className="flex h-screen flex-col justify-between">
<header className="flex items-center justify-between py-10">
<div>
<Link href="/" aria-label={siteMetadata.headerTitle}>
<div className="flex items-center justify-between">
<div className="mr-3">
<Logo />
</div>
{typeof siteMetadata.headerTitle === 'string' ? (
<div className="hidden h-6 text-2xl font-semibold sm:block">
{siteMetadata.headerTitle}
</div>
) : (
siteMetadata.headerTitle
)}
</div>
</Link>
</div>
<div className="flex items-center text-base leading-5">
<div className="hidden sm:block">
{headerNavLinks.map((link) => (
<Link
key={link.title}
href={link.href}
className="p-1 font-medium text-gray-900 dark:text-gray-100 sm:p-4"
>
{link.title}
</Link>
))}
</div>
<ThemeSwitch />
<MobileNav />
</div>
</header>
<main className="mb-auto">{children}</main>
<Footer />
</div>
</SectionContainer>
)
}
export default LayoutWrapper

View File

@ -0,0 +1,27 @@
import { Inter } from 'next/font/google'
import SectionContainer from './SectionContainer'
import Footer from './Footer'
import { ReactNode } from 'react'
import Header from './Header'
interface Props {
children: ReactNode
}
const inter = Inter({
subsets: ['latin'],
})
const LayoutWrapper = ({ children }: Props) => {
return (
<SectionContainer>
<div className={`${inter.className} flex h-screen flex-col justify-between font-sans`}>
<Header />
<main className="mb-auto">{children}</main>
<Footer />
</div>
</SectionContainer>
)
}
export default LayoutWrapper

View File

@ -1,16 +1,17 @@
/* eslint-disable jsx-a11y/anchor-has-content */
import Link from 'next/link'
import { AnchorHTMLAttributes, DetailedHTMLProps } from 'react'
const CustomLink = ({ href, ...rest }) => {
const CustomLink = ({
href,
...rest
}: DetailedHTMLProps<AnchorHTMLAttributes<HTMLAnchorElement>, HTMLAnchorElement>) => {
const isInternalLink = href && href.startsWith('/')
const isAnchorLink = href && href.startsWith('#')
if (isInternalLink) {
return (
<Link href={href}>
<a {...rest} />
</Link>
)
// @ts-ignore
return <Link href={href} {...rest} />
}
if (isAnchorLink) {

View File

@ -1,26 +0,0 @@
/* eslint-disable react/display-name */
import { useMemo } from 'react'
import { getMDXComponent } from 'mdx-bundler/client'
import Image from './Image'
import CustomLink from './Link'
import TOCInline from './TOCInline'
import Pre from './Pre'
import { BlogNewsletterForm } from './NewsletterForm'
export const MDXComponents = {
Image,
TOCInline,
a: CustomLink,
pre: Pre,
BlogNewsletterForm: BlogNewsletterForm,
wrapper: ({ components, layout, ...rest }) => {
const Layout = require(`../layouts/${layout}`).default
return <Layout {...rest} />
},
}
export const MDXLayoutRenderer = ({ layout, mdxSource, ...rest }) => {
const MDXLayout = useMemo(() => getMDXComponent(mdxSource), [mdxSource])
return <MDXLayout layout={layout} components={MDXComponents} {...rest} />
}

View File

@ -0,0 +1,23 @@
/* eslint-disable react/display-name */
import React from 'react'
import { MDXLayout, ComponentMap } from 'pliny/mdx-components'
import { TOCInline } from 'pliny/ui/TOCInline'
import { Pre } from 'pliny/ui/Pre'
import { BlogNewsletterForm } from 'pliny/ui/NewsletterForm'
import Image from './Image'
import CustomLink from './Link'
export const Wrapper = ({ layout, content, ...rest }: MDXLayout) => {
const Layout = require(`../layouts/${layout}`).default
return <Layout content={content} {...rest} />
}
export const MDXComponents: ComponentMap = {
Image,
TOCInline,
a: CustomLink,
pre: Pre,
wrapper: Wrapper,
BlogNewsletterForm,
}

View File

@ -1,3 +1,5 @@
'use client'
import { useState } from 'react'
import Link from './Link'
import headerNavLinks from '@/data/headerNavLinks'
@ -20,7 +22,6 @@ const MobileNav = () => {
return (
<div className="sm:hidden">
<button
type="button"
className="ml-1 mr-1 h-8 w-8 rounded py-1"
aria-label="Toggle Menu"
onClick={onToggleNav}
@ -39,13 +40,12 @@ const MobileNav = () => {
</svg>
</button>
<div
className={`fixed top-0 left-0 z-10 h-full w-full transform bg-gray-200 opacity-95 duration-300 ease-in-out dark:bg-gray-800 ${
className={`fixed left-0 top-0 z-10 h-full w-full transform bg-gray-200 opacity-95 duration-300 ease-in-out dark:bg-gray-800 ${
navShow ? 'translate-x-0' : 'translate-x-full'
}`}
>
<div className="flex justify-end">
<button
type="button"
className="mr-5 mt-11 h-8 w-8 rounded"
aria-label="Toggle Menu"
onClick={onToggleNav}

View File

@ -1,84 +0,0 @@
import { useRef, useState } from 'react'
import siteMetadata from '@/data/siteMetadata'
const NewsletterForm = ({ title = 'Subscribe to the newsletter' }) => {
const inputEl = useRef(null)
const [error, setError] = useState(false)
const [message, setMessage] = useState('')
const [subscribed, setSubscribed] = useState(false)
const subscribe = async (e) => {
e.preventDefault()
const res = await fetch(`/api/${siteMetadata.newsletter.provider}`, {
body: JSON.stringify({
email: inputEl.current.value,
}),
headers: {
'Content-Type': 'application/json',
},
method: 'POST',
})
const { error } = await res.json()
if (error) {
setError(true)
setMessage('Your e-mail address is invalid or you are already subscribed!')
return
}
inputEl.current.value = ''
setError(false)
setSubscribed(true)
setMessage('Successfully! 🎉 You are now subscribed.')
}
return (
<div>
<div className="pb-1 text-lg font-semibold text-gray-800 dark:text-gray-100">{title}</div>
<form className="flex flex-col sm:flex-row" onSubmit={subscribe}>
<div>
<label className="sr-only" htmlFor="email-input">
Email address
</label>
<input
autoComplete="email"
className="w-72 rounded-md px-4 focus:border-transparent focus:outline-none focus:ring-2 focus:ring-primary-600 dark:bg-black"
id="email-input"
name="email"
placeholder={subscribed ? "You're subscribed ! 🎉" : 'Enter your email'}
ref={inputEl}
required
type="email"
disabled={subscribed}
/>
</div>
<div className="mt-2 flex w-full rounded-md shadow-sm sm:mt-0 sm:ml-3">
<button
className={`w-full rounded-md bg-primary-500 py-2 px-4 font-medium text-white sm:py-0 ${
subscribed ? 'cursor-default' : 'hover:bg-primary-700 dark:hover:bg-primary-400'
} focus:outline-none focus:ring-2 focus:ring-primary-600 focus:ring-offset-2 dark:ring-offset-black`}
type="submit"
disabled={subscribed}
>
{subscribed ? 'Thank you!' : 'Sign up'}
</button>
</div>
</form>
{error && (
<div className="w-72 pt-2 text-sm text-red-500 dark:text-red-400 sm:w-96">{message}</div>
)}
</div>
)
}
export default NewsletterForm
export const BlogNewsletterForm = ({ title }) => (
<div className="flex items-center justify-center">
<div className="bg-gray-100 p-6 dark:bg-gray-800 sm:px-14 sm:py-8">
<NewsletterForm title={title} />
</div>
</div>
)

View File

@ -1,4 +1,10 @@
export default function PageTitle({ children }) {
import { ReactNode } from 'react'
interface Props {
children: ReactNode
}
export default function PageTitle({ children }: Props) {
return (
<h1 className="text-3xl font-extrabold leading-9 tracking-tight text-gray-900 dark:text-gray-100 sm:text-4xl sm:leading-10 md:text-5xl md:leading-14">
{children}

View File

@ -1,36 +0,0 @@
import Link from '@/components/Link'
export default function Pagination({ totalPages, currentPage }) {
const prevPage = parseInt(currentPage) - 1 > 0
const nextPage = parseInt(currentPage) + 1 <= parseInt(totalPages)
return (
<div className="space-y-2 pt-6 pb-8 md:space-y-5">
<nav className="flex justify-between">
{!prevPage && (
<button rel="previous" className="cursor-auto disabled:opacity-50" disabled={!prevPage}>
Previous
</button>
)}
{prevPage && (
<Link href={currentPage - 1 === 1 ? `/blog/` : `/blog/page/${currentPage - 1}`}>
<button rel="previous">Previous</button>
</Link>
)}
<span>
{currentPage} of {totalPages}
</span>
{!nextPage && (
<button rel="next" className="cursor-auto disabled:opacity-50" disabled={!nextPage}>
Next
</button>
)}
{nextPage && (
<Link href={`/blog/page/${currentPage + 1}`}>
<button rel="next">Next</button>
</Link>
)}
</nav>
</div>
)
}

View File

@ -1,71 +0,0 @@
import { useState, useRef } from 'react'
const Pre = (props) => {
const textInput = useRef(null)
const [hovered, setHovered] = useState(false)
const [copied, setCopied] = useState(false)
const onEnter = () => {
setHovered(true)
}
const onExit = () => {
setHovered(false)
setCopied(false)
}
const onCopy = () => {
setCopied(true)
navigator.clipboard.writeText(textInput.current.textContent)
setTimeout(() => {
setCopied(false)
}, 2000)
}
return (
<div ref={textInput} onMouseEnter={onEnter} onMouseLeave={onExit} className="relative">
{hovered && (
<button
aria-label="Copy code"
type="button"
className={`absolute right-2 top-2 h-8 w-8 rounded border-2 bg-gray-700 p-1 dark:bg-gray-800 ${
copied
? 'border-green-400 focus:border-green-400 focus:outline-none'
: 'border-gray-300'
}`}
onClick={onCopy}
>
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
stroke="currentColor"
fill="none"
className={copied ? 'text-green-400' : 'text-gray-300'}
>
{copied ? (
<>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2m-6 9l2 2 4-4"
/>
</>
) : (
<>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2"
/>
</>
)}
</svg>
</button>
)}
<pre>{props.children}</pre>
</div>
)
}
export default Pre

View File

@ -1,8 +1,30 @@
import Head from 'next/head'
import { useRouter } from 'next/router'
import siteMetadata from '@/data/siteMetadata'
import { CoreContent } from 'pliny/utils/contentlayer'
import type { Blog, Authors } from 'contentlayer/generated'
interface CommonSEOProps {
title: string
description: string
ogType: string
ogImage:
| string
| {
'@type': string
url: string
}[]
twImage: string
canonicalUrl?: string
}
const CommonSEO = ({ title, description, ogType, ogImage, twImage, canonicalUrl }) => {
const CommonSEO = ({
title,
description,
ogType,
ogImage,
twImage,
canonicalUrl,
}: CommonSEOProps) => {
const router = useRouter()
return (
<Head>
@ -14,7 +36,7 @@ const CommonSEO = ({ title, description, ogType, ogImage, twImage, canonicalUrl
<meta property="og:site_name" content={siteMetadata.title} />
<meta property="og:description" content={description} />
<meta property="og:title" content={title} />
{ogImage.constructor.name === 'Array' ? (
{Array.isArray(ogImage) ? (
ogImage.map(({ url }) => <meta property="og:image" content={url} key={url} />)
) : (
<meta property="og:image" content={ogImage} key={ogImage} />
@ -32,7 +54,12 @@ const CommonSEO = ({ title, description, ogType, ogImage, twImage, canonicalUrl
)
}
export const PageSEO = ({ title, description }) => {
interface PageSEOProps {
title: string
description: string
}
export const PageSEO = ({ title, description }: PageSEOProps) => {
const ogImageUrl = siteMetadata.siteUrl + siteMetadata.socialBanner
const twImageUrl = siteMetadata.siteUrl + siteMetadata.socialBanner
return (
@ -46,7 +73,7 @@ export const PageSEO = ({ title, description }) => {
)
}
export const TagSEO = ({ title, description }) => {
export const TagSEO = ({ title, description }: PageSEOProps) => {
const ogImageUrl = siteMetadata.siteUrl + siteMetadata.socialBanner
const twImageUrl = siteMetadata.siteUrl + siteMetadata.socialBanner
const router = useRouter()
@ -71,6 +98,11 @@ export const TagSEO = ({ title, description }) => {
)
}
interface BlogSeoProps extends CoreContent<Blog> {
authorDetails?: CoreContent<Authors>[]
url: string
}
export const BlogSEO = ({
authorDetails,
title,
@ -80,11 +112,10 @@ export const BlogSEO = ({
url,
images = [],
canonicalUrl,
}) => {
const router = useRouter()
}: BlogSeoProps) => {
const publishedAt = new Date(date).toISOString()
const modifiedAt = new Date(lastmod || date).toISOString()
let imagesArr =
const imagesArr =
images.length === 0
? [siteMetadata.socialBanner]
: typeof images === 'string'
@ -142,7 +173,7 @@ export const BlogSEO = ({
<>
<CommonSEO
title={title}
description={summary}
description={summary || ''}
ogType="article"
ogImage={featuredImages}
twImage={twImageUrl}

View File

@ -18,16 +18,15 @@ const ScrollTopAndComment = () => {
window.scrollTo({ top: 0 })
}
const handleScrollToComment = () => {
document.getElementById('comment').scrollIntoView()
document.getElementById('comment')?.scrollIntoView()
}
return (
<div
className={`fixed right-8 bottom-8 hidden flex-col gap-3 ${show ? 'md:flex' : 'md:hidden'}`}
className={`fixed bottom-8 right-8 hidden flex-col gap-3 ${show ? 'md:flex' : 'md:hidden'}`}
>
{siteMetadata.comment.provider && (
{siteMetadata.comments?.provider && (
<button
aria-label="Scroll To Comment"
type="button"
onClick={handleScrollToComment}
className="rounded-full bg-gray-200 p-2 text-gray-500 transition-all hover:bg-gray-300 dark:bg-gray-700 dark:text-gray-400 dark:hover:bg-gray-600"
>
@ -42,7 +41,6 @@ const ScrollTopAndComment = () => {
)}
<button
aria-label="Scroll To Top"
type="button"
onClick={handleScrollTop}
className="rounded-full bg-gray-200 p-2 text-gray-500 transition-all hover:bg-gray-300 dark:bg-gray-700 dark:text-gray-400 dark:hover:bg-gray-600"
>

View File

@ -1,3 +0,0 @@
export default function SectionContainer({ children }) {
return <div className="mx-auto max-w-3xl px-4 sm:px-6 xl:max-w-5xl xl:px-0">{children}</div>
}

View File

@ -0,0 +1,11 @@
import { ReactNode } from 'react'
interface Props {
children: ReactNode
}
export default function SectionContainer({ children }: Props) {
return (
<section className="mx-auto max-w-3xl px-4 sm:px-6 xl:max-w-5xl xl:px-0">{children}</section>
)
}

View File

@ -1,64 +0,0 @@
/**
* @typedef TocHeading
* @prop {string} value
* @prop {number} depth
* @prop {string} url
*/
/**
* Generates an inline table of contents
* Exclude titles matching this string (new RegExp('^(' + string + ')$', 'i')).
* If an array is passed the array gets joined with a pipe (new RegExp('^(' + array.join('|') + ')$', 'i')).
*
* @param {{
* toc: TocHeading[],
* indentDepth?: number,
* fromHeading?: number,
* toHeading?: number,
* asDisclosure?: boolean,
* exclude?: string|string[]
* }} props
*
*/
const TOCInline = ({
toc,
indentDepth = 3,
fromHeading = 1,
toHeading = 6,
asDisclosure = false,
exclude = '',
}) => {
const re = Array.isArray(exclude)
? new RegExp('^(' + exclude.join('|') + ')$', 'i')
: new RegExp('^(' + exclude + ')$', 'i')
const filteredToc = toc.filter(
(heading) =>
heading.depth >= fromHeading && heading.depth <= toHeading && !re.test(heading.value)
)
const tocList = (
<ul>
{filteredToc.map((heading) => (
<li key={heading.value} className={`${heading.depth >= indentDepth && 'ml-6'}`}>
<a href={heading.url}>{heading.value}</a>
</li>
))}
</ul>
)
return (
<>
{asDisclosure ? (
<details open>
<summary className="ml-6 pt-2 pb-2 text-xl font-bold">Table of Contents</summary>
<div className="ml-6">{tocList}</div>
</details>
) : (
tocList
)}
</>
)
}
export default TOCInline

View File

@ -1,14 +0,0 @@
import Link from 'next/link'
import kebabCase from '@/lib/utils/kebabCase'
const Tag = ({ text }) => {
return (
<Link href={`/tags/${kebabCase(text)}`}>
<a className="mr-3 text-sm font-medium uppercase text-primary-500 hover:text-primary-600 dark:hover:text-primary-400">
{text.split(' ').join('-')}
</a>
</Link>
)
}
export default Tag

19
components/Tag.tsx Normal file
View File

@ -0,0 +1,19 @@
import Link from 'next/link'
import { kebabCase } from 'pliny/utils/kebabCase'
interface Props {
text: string
}
const Tag = ({ text }: Props) => {
return (
<Link
href={`/tags/${kebabCase(text)}`}
className="mr-3 text-sm font-medium uppercase text-primary-500 hover:text-primary-600 dark:hover:text-primary-400"
>
{text.split(' ').join('-')}
</Link>
)
}
export default Tag

View File

@ -1,19 +1,24 @@
'use client'
import { useEffect, useState } from 'react'
import { useTheme } from 'next-themes'
const ThemeSwitch = () => {
const [mounted, setMounted] = useState(false)
const { theme, setTheme, resolvedTheme } = useTheme()
const { theme, setTheme } = useTheme()
// When mounted on client, now we can show the UI
useEffect(() => setMounted(true), [])
if (!mounted) {
return null
}
return (
<button
aria-label="Toggle Dark Mode"
type="button"
className="ml-1 mr-1 h-8 w-8 rounded p-1 sm:ml-4"
onClick={() => setTheme(theme === 'dark' || resolvedTheme === 'dark' ? 'light' : 'dark')}
onClick={() => setTheme(theme === 'dark' ? 'light' : 'dark')}
>
<svg
xmlns="http://www.w3.org/2000/svg"
@ -21,7 +26,7 @@ const ThemeSwitch = () => {
fill="currentColor"
className="text-gray-900 dark:text-gray-100"
>
{mounted && (theme === 'dark' || resolvedTheme === 'dark') ? (
{mounted && theme === 'dark' ? (
<path
fillRule="evenodd"
d="M10 2a1 1 0 011 1v1a1 1 0 11-2 0V3a1 1 0 011-1zm4 8a4 4 0 11-8 0 4 4 0 018 0zm-.464 4.95l.707.707a1 1 0 001.414-1.414l-.707-.707a1 1 0 00-1.414 1.414zm2.12-10.607a1 1 0 010 1.414l-.706.707a1 1 0 11-1.414-1.414l.707-.707a1 1 0 011.414 0zM17 11a1 1 0 100-2h-1a1 1 0 100 2h1zm-7 4a1 1 0 011 1v1a1 1 0 11-2 0v-1a1 1 0 011-1zM5.05 6.464A1 1 0 106.465 5.05l-.708-.707a1 1 0 00-1.414 1.414l.707.707zm1.414 8.486l-.707.707a1 1 0 01-1.414-1.414l.707-.707a1 1 0 011.414 1.414zM4 11a1 1 0 100-2H3a1 1 0 000 2h1z"

View File

@ -1,36 +0,0 @@
import Script from 'next/script'
import siteMetadata from '@/data/siteMetadata'
const GAScript = () => {
return (
<>
<Script
strategy="lazyOnload"
src={`https://www.googletagmanager.com/gtag/js?id=${siteMetadata.analytics.googleAnalyticsId}`}
/>
<Script strategy="lazyOnload" id="ga-script">
{`
window.dataLayer = window.dataLayer || [];
function gtag(){dataLayer.push(arguments);}
gtag('js', new Date());
gtag('config', '${siteMetadata.analytics.googleAnalyticsId}', {
page_path: window.location.pathname,
});
`}
</Script>
</>
)
}
export default GAScript
// https://developers.google.com/analytics/devguides/collection/gtagjs/events
export const logEvent = (action, category, label, value) => {
window.gtag?.('event', action, {
event_category: category,
event_label: label,
value: value,
})
}

View File

@ -1,27 +0,0 @@
import Script from 'next/script'
import siteMetadata from '@/data/siteMetadata'
const PlausibleScript = () => {
return (
<>
<Script
strategy="lazyOnload"
data-domain={siteMetadata.analytics.plausibleDataDomain}
src="https://plausible.io/js/plausible.js"
/>
<Script strategy="lazyOnload" id="plausible-script">
{`
window.plausible = window.plausible || function() { (window.plausible.q = window.plausible.q || []).push(arguments) }
`}
</Script>
</>
)
}
export default PlausibleScript
// https://plausible.io/docs/custom-event-goals
export const logEvent = (eventName, ...rest) => {
return window.plausible?.(eventName, ...rest)
}

View File

@ -1,18 +0,0 @@
import Script from 'next/script'
import siteMetadata from '@/data/siteMetadata'
const PosthogScript = () => {
return (
<>
<Script strategy="lazyOnload" id="posthog-script">
{`
!function(t,e){var o,n,p,r;e.__SV||(window.posthog=e,e._i=[],e.init=function(i,s,a){function g(t,e){var o=e.split(".");2==o.length&&(t=t[o[0]],e=o[1]),t[e]=function(){t.push([e].concat(Array.prototype.slice.call(arguments,0)))}}(p=t.createElement("script")).type="text/javascript",p.async=!0,p.src=s.api_host+"/static/array.js",(r=t.getElementsByTagName("script")[0]).parentNode.insertBefore(p,r);var u=e;for(void 0!==a?u=e[a]=[]:a="posthog",u.people=u.people||[],u.toString=function(t){var e="posthog";return"posthog"!==a&&(e+="."+a),t||(e+=" (stub)"),e},u.people.toString=function(){return u.toString(1)+".people (stub)"},o="capture identify alias people.set people.set_once set_config register register_once unregister opt_out_capturing has_opted_out_capturing opt_in_capturing reset isFeatureEnabled onFeatureFlags".split(" "),n=0;n<o.length;n++)g(u,o[n]);e._i.push([i,s,a])},e.__SV=1)}(document,window.posthog||[]);
posthog.init('${siteMetadata.analytics.posthogAnalyticsId}',{api_host:'https://app.posthog.com'})
`}
</Script>
</>
)
}
export default PosthogScript

View File

@ -1,25 +0,0 @@
import Script from 'next/script'
const SimpleAnalyticsScript = () => {
return (
<>
<Script strategy="lazyOnload" id="sa-script">
{`
window.sa_event=window.sa_event||function(){var a=[].slice.call(arguments);window.sa_event.q?window.sa_event.q.push(a):window.sa_event.q=[a]};
`}
</Script>
<Script strategy="lazyOnload" src="https://scripts.simpleanalyticscdn.com/latest.js" />
</>
)
}
// https://docs.simpleanalytics.com/events
export const logEvent = (eventName, callback) => {
if (callback) {
return window.sa_event?.(eventName, callback)
} else {
return window.sa_event?.(eventName)
}
}
export default SimpleAnalyticsScript

View File

@ -1,18 +0,0 @@
import Script from 'next/script'
import siteMetadata from '@/data/siteMetadata'
const UmamiScript = () => {
return (
<>
<Script
async
defer
data-website-id={siteMetadata.analytics.umamiWebsiteId}
src="https://umami.example.com/umami.js" // Replace with your umami instance
/>
</>
)
}
export default UmamiScript

View File

@ -1,22 +0,0 @@
import GA from './GoogleAnalytics'
import Plausible from './Plausible'
import SimpleAnalytics from './SimpleAnalytics'
import Umami from './Umami'
import Posthog from './Posthog'
import siteMetadata from '@/data/siteMetadata'
const isProduction = process.env.NODE_ENV === 'production'
const Analytics = () => {
return (
<>
{isProduction && siteMetadata.analytics.plausibleDataDomain && <Plausible />}
{isProduction && siteMetadata.analytics.simpleAnalytics && <SimpleAnalytics />}
{isProduction && siteMetadata.analytics.umamiWebsiteId && <Umami />}
{isProduction && siteMetadata.analytics.googleAnalyticsId && <GA />}
{isProduction && siteMetadata.analytics.posthogAnalyticsId && <Posthog />}
</>
)
}
export default Analytics

View File

@ -1,37 +0,0 @@
import React, { useState } from 'react'
import siteMetadata from '@/data/siteMetadata'
const Disqus = ({ frontMatter }) => {
const [enableLoadComments, setEnabledLoadComments] = useState(true)
const COMMENTS_ID = 'disqus_thread'
function LoadComments() {
setEnabledLoadComments(false)
window.disqus_config = function () {
this.page.url = window.location.href
this.page.identifier = frontMatter.slug
}
if (window.DISQUS === undefined) {
const script = document.createElement('script')
script.src = 'https://' + siteMetadata.comment.disqusConfig.shortname + '.disqus.com/embed.js'
script.setAttribute('data-timestamp', +new Date())
script.setAttribute('crossorigin', 'anonymous')
script.async = true
document.body.appendChild(script)
} else {
window.DISQUS.reset({ reload: true })
}
}
return (
<div className="pt-6 pb-6 text-center text-gray-700 dark:text-gray-300">
{enableLoadComments && <button onClick={LoadComments}>Load Comments</button>}
<div className="disqus-frame" id={COMMENTS_ID} />
</div>
)
}
export default Disqus

View File

@ -1,72 +0,0 @@
import React, { useState, useEffect, useCallback } from 'react'
import { useTheme } from 'next-themes'
import siteMetadata from '@/data/siteMetadata'
const Giscus = () => {
const [enableLoadComments, setEnabledLoadComments] = useState(true)
const { theme, resolvedTheme } = useTheme()
const commentsTheme =
siteMetadata.comment.giscusConfig.themeURL === ''
? theme === 'dark' || resolvedTheme === 'dark'
? siteMetadata.comment.giscusConfig.darkTheme
: siteMetadata.comment.giscusConfig.theme
: siteMetadata.comment.giscusConfig.themeURL
const COMMENTS_ID = 'comments-container'
const LoadComments = useCallback(() => {
setEnabledLoadComments(false)
const {
repo,
repositoryId,
category,
categoryId,
mapping,
reactions,
metadata,
inputPosition,
lang,
} = siteMetadata?.comment?.giscusConfig
const script = document.createElement('script')
script.src = 'https://giscus.app/client.js'
script.setAttribute('data-repo', repo)
script.setAttribute('data-repo-id', repositoryId)
script.setAttribute('data-category', category)
script.setAttribute('data-category-id', categoryId)
script.setAttribute('data-mapping', mapping)
script.setAttribute('data-reactions-enabled', reactions)
script.setAttribute('data-emit-metadata', metadata)
script.setAttribute('data-input-position', inputPosition)
script.setAttribute('data-lang', lang)
script.setAttribute('data-theme', commentsTheme)
script.setAttribute('crossorigin', 'anonymous')
script.async = true
const comments = document.getElementById(COMMENTS_ID)
if (comments) comments.appendChild(script)
return () => {
const comments = document.getElementById(COMMENTS_ID)
if (comments) comments.innerHTML = ''
}
}, [commentsTheme])
// Reload on theme change
useEffect(() => {
const iframe = document.querySelector('iframe.giscus-frame')
if (!iframe) return
LoadComments()
}, [LoadComments])
return (
<div className="pt-6 pb-6 text-center text-gray-700 dark:text-gray-300">
{enableLoadComments && <button onClick={LoadComments}>Load Comments</button>}
<div className="giscus" id={COMMENTS_ID} />
</div>
)
}
export default Giscus

View File

@ -1,52 +0,0 @@
import React, { useState, useEffect, useCallback } from 'react'
import { useTheme } from 'next-themes'
import siteMetadata from '@/data/siteMetadata'
const Utterances = () => {
const [enableLoadComments, setEnabledLoadComments] = useState(true)
const { theme, resolvedTheme } = useTheme()
const commentsTheme =
theme === 'dark' || resolvedTheme === 'dark'
? siteMetadata.comment.utterancesConfig.darkTheme
: siteMetadata.comment.utterancesConfig.theme
const COMMENTS_ID = 'comments-container'
const LoadComments = useCallback(() => {
setEnabledLoadComments(false)
const script = document.createElement('script')
script.src = 'https://utteranc.es/client.js'
script.setAttribute('repo', siteMetadata.comment.utterancesConfig.repo)
script.setAttribute('issue-term', siteMetadata.comment.utterancesConfig.issueTerm)
script.setAttribute('label', siteMetadata.comment.utterancesConfig.label)
script.setAttribute('theme', commentsTheme)
script.setAttribute('crossorigin', 'anonymous')
script.async = true
const comments = document.getElementById(COMMENTS_ID)
if (comments) comments.appendChild(script)
return () => {
const comments = document.getElementById(COMMENTS_ID)
if (comments) comments.innerHTML = ''
}
}, [commentsTheme])
// Reload on theme change
useEffect(() => {
const iframe = document.querySelector('iframe.utterances-frame')
if (!iframe) return
LoadComments()
}, [LoadComments])
// Added `relative` to fix a weird bug with `utterances-frame` position
return (
<div className="pt-6 pb-6 text-center text-gray-700 dark:text-gray-300">
{enableLoadComments && <button onClick={LoadComments}>Load Comments</button>}
<div className="utterances-frame relative" id={COMMENTS_ID} />
</div>
)
}
export default Utterances

View File

@ -1,39 +0,0 @@
import siteMetadata from '@/data/siteMetadata'
import dynamic from 'next/dynamic'
const UtterancesComponent = dynamic(
() => {
return import('@/components/comments/Utterances')
},
{ ssr: false }
)
const GiscusComponent = dynamic(
() => {
return import('@/components/comments/Giscus')
},
{ ssr: false }
)
const DisqusComponent = dynamic(
() => {
return import('@/components/comments/Disqus')
},
{ ssr: false }
)
const Comments = ({ frontMatter }) => {
const comment = siteMetadata?.comment
if (!comment || Object.keys(comment).length === 0) return <></>
return (
<div id="comment">
{siteMetadata.comment && siteMetadata.comment.provider === 'giscus' && <GiscusComponent />}
{siteMetadata.comment && siteMetadata.comment.provider === 'utterances' && (
<UtterancesComponent />
)}
{siteMetadata.comment && siteMetadata.comment.provider === 'disqus' && (
<DisqusComponent frontMatter={frontMatter} />
)}
</div>
)
}
export default Comments

99
contentlayer.config.ts Normal file
View File

@ -0,0 +1,99 @@
import { defineDocumentType, ComputedFields, makeSource } from 'contentlayer/source-files'
import readingTime from 'reading-time'
import path from 'path'
// Remark packages
import remarkGfm from 'remark-gfm'
import remarkMath from 'remark-math'
import {
remarkExtractFrontmatter,
remarkCodeTitles,
remarkImgToJsx,
extractTocHeadings,
} from 'pliny/mdx-plugins.js'
// Rehype packages
import rehypeSlug from 'rehype-slug'
import rehypeAutolinkHeadings from 'rehype-autolink-headings'
import rehypeKatex from 'rehype-katex'
import rehypeCitation from 'rehype-citation'
import rehypePrismPlus from 'rehype-prism-plus'
import rehypePresetMinify from 'rehype-preset-minify'
const root = process.cwd()
const computedFields: ComputedFields = {
readingTime: { type: 'json', resolve: (doc) => readingTime(doc.body.raw) },
slug: {
type: 'string',
resolve: (doc) => doc._raw.flattenedPath.replace(/^.+?(\/)/, ''),
},
path: {
type: 'string',
resolve: (doc) => doc._raw.flattenedPath,
},
filePath: {
type: 'string',
resolve: (doc) => doc._raw.sourceFilePath,
},
toc: { type: 'string', resolve: (doc) => extractTocHeadings(doc.body.raw) },
}
export const Blog = defineDocumentType(() => ({
name: 'Blog',
filePathPattern: 'blog/**/*.mdx',
contentType: 'mdx',
fields: {
title: { type: 'string', required: true },
date: { type: 'date', required: true },
tags: { type: 'list', of: { type: 'string' } },
lastmod: { type: 'date' },
draft: { type: 'boolean' },
summary: { type: 'string' },
images: { type: 'list', of: { type: 'string' } },
authors: { type: 'list', of: { type: 'string' } },
layout: { type: 'string' },
bibliography: { type: 'string' },
canonicalUrl: { type: 'string' },
},
computedFields,
}))
export const Authors = defineDocumentType(() => ({
name: 'Authors',
filePathPattern: 'authors/**/*.mdx',
contentType: 'mdx',
fields: {
name: { type: 'string', required: true },
avatar: { type: 'string' },
occupation: { type: 'string' },
company: { type: 'string' },
email: { type: 'string' },
twitter: { type: 'string' },
linkedin: { type: 'string' },
github: { type: 'string' },
layout: { type: 'string' },
},
computedFields,
}))
export default makeSource({
contentDirPath: 'data',
documentTypes: [Blog, Authors],
mdx: {
cwd: process.cwd(),
remarkPlugins: [
remarkExtractFrontmatter,
remarkGfm,
remarkCodeTitles,
remarkMath,
remarkImgToJsx,
],
rehypePlugins: [
rehypeSlug,
rehypeAutolinkHeadings,
rehypeKatex,
[rehypeCitation, { path: path.join(root, 'data') }],
[rehypePrismPlus, { defaultLanguage: 'js' }],
rehypePresetMinify,
],
},
})

63
css/docsearch.css Normal file
View File

@ -0,0 +1,63 @@
.light .DocSearch {
--docsearch-primary-color: theme(colors.primary.600);
--docsearch-highlight-color: theme(colors.primary.600);
--docsearch-searchbox-shadow: inset 0 0 0 2px theme(colors.primary.600);
--docsearch-muted-color: theme(colors.gray.500);
--docsearch-container-background: theme(colors.gray.400 / 80%);
/* Modal */
--docsearch-modal-background: theme(colors.gray.200);
/* Search box */
--docsearch-searchbox-background: theme(colors.gray.100);
--docsearch-searchbox-focus-background: theme(colors.gray.100);
/* Hit */
--docsearch-hit-color: theme(colors.gray.700);
--docsearch-hit-shadow: none;
--docsearch-hit-active-color: theme(colors.gray.800);
--docsearch-hit-background: theme(colors.gray.100);
/* Footer */
--docsearch-footer-background: theme(colors.gray.100);
}
.dark .DocSearch {
--docsearch-primary-color: theme(colors.primary.600);
--docsearch-highlight-color: theme(colors.primary.600);
--docsearch-searchbox-shadow: inset 0 0 0 2px theme(colors.primary.600);
--docsearch-text-color: theme(colors.gray.200);
--docsearch-muted-color: theme(colors.gray.400);
--docsearch-container-background: theme(colors.gray.900 / 80%);
/* Modal */
--docsearch-modal-background: theme(colors.gray.900);
--docsearch-modal-shadow: inset 1px 1px 0 0 rgb(44, 46, 64),
0 3px 8px 0 rgb(0, 3, 9);
/* Search box */
--docsearch-searchbox-background: theme(colors.gray.800);
--docsearch-searchbox-focus-background: theme(colors.gray.800);
/* Hit */
--docsearch-hit-color: theme(colors.gray.200);
--docsearch-hit-shadow: none;
--docsearch-hit-active-color: theme(colors.gray.100);
--docsearch-hit-background: theme(colors.gray.800);
/* Footer */
--docsearch-footer-background: theme(colors.gray.900);
--docsearch-footer-shadow: inset 0 1px 0 0 rgba(73, 76, 106, 0.5),
0 -4px 8px 0 rgba(0, 0, 0, 0.2);
--docsearch-key-gradient: linear-gradient(-26.5deg,
theme(colors.gray.800) 0%,
theme(colors.gray.900) 100%);
--docsearch-key-shadow: inset 0 -2px 0 0 rgb(40, 45, 85),
inset 0 0 1px 1px rgb(81, 87, 125), 0 2px 2px 0 rgba(3, 4, 9, 0.3);
--docsearch-logo-color: theme(colors.gray.300);
}
.light .DocSearch-Input {
@apply hover:ring-0 ring-0;
}
.dark .DocSearch-Input {
@apply hover:ring-0 ring-0;
}
.DocSearch-Container {
@apply backdrop-blur;
}

View File

@ -32,7 +32,7 @@
}
.highlight-line {
@apply -mx-4 border-l-4 border-primary-500 bg-gray-700 bg-opacity-50;
@apply -mx-4 border-l-4 border-primary-500 bg-gray-700 bg-opacity-50;
}
.line-number::before {
@ -138,3 +138,7 @@
.token.table {
display: inline;
}
.token.table {
display: inline;
}

View File

@ -11,7 +11,11 @@
}
.footnotes {
@apply pt-8 mt-12 border-t border-gray-200 dark:border-gray-700;
@apply mt-12 border-t border-gray-200 pt-8 dark:border-gray-700;
}
.data-footnote-backref {
@apply no-underline;
}
.csl-entry {

View File

@ -10,7 +10,9 @@ summary: 'How to derive the OLS Estimator with matrix notation and a tour of mat
Parsing and display of math equations is included in this blog template. Parsing of math is enabled by `remark-math` and `rehype-katex`.
KaTeX and its associated font is included in `_document.js` so feel free to use it on any page.
^[For the full list of supported TeX functions, check out the [KaTeX documentation](https://katex.org/docs/supported.html)]
[^footnote]
[^footnote]: For the full list of supported TeX functions, check out the [KaTeX documentation](https://katex.org/docs/supported.html)
Inline math symbols can be included by enclosing the term between the `$` symbol.

View File

@ -49,12 +49,8 @@ _Note_: If you try to save the image, it is in webp format, if your browser supp
![ocean](/static/images/ocean.jpeg)
<p>
Photo by [YUCAR
FotoGrafik](https://unsplash.com/@yucar?utm_source=unsplash&amp;utm_medium=referral&amp;utm_content=creditCopyText)
on
[Unsplash](https://unsplash.com/s/photos/sea?utm_source=unsplash&amp;utm_medium=referral&amp;utm_content=creditCopyText)
</p>
Photo by [YUCAR FotoGrafik](https://unsplash.com/@yucar?utm_source=unsplash&utm_medium=referral&utm_content=creditCopyText)
on [Unsplash](https://unsplash.com/s/photos/sea?utm_source=unsplash&utm_medium=referral&utm_content=creditCopyText)
# Benefits

View File

@ -60,7 +60,7 @@ I wanted it to be nearly as feature-rich as popular blogging templates like [bea
- Blog templates
- TOC component
- Support for nested routing of blog posts
- Newsletter component with support for mailchimp, buttondown, convertkit, klaviyo, revue, and emailoctopus
- Newsletter component with support for mailchimp, buttondown and convertkit
- Supports [giscus](https://github.com/laymonage/giscus), [utterances](https://github.com/utterance/utterances) or disqus
- Projects page
- Preconfigured security headers
@ -77,27 +77,7 @@ I wanted it to be nearly as feature-rich as popular blogging templates like [bea
## Quick Start Guide
1. Try installing the starter using the new [Pliny project CLI](https://github.com/timlrx/pliny):
```bash
npm i -g @pliny/cli
pliny new --template=starter-blog my-blog
```
It supports the updated version of the blog with Contentlayer, optional choice of TS/JS and different package managers as well as more modularized components which will be the basis of the template going forward.
Alternatively to stick with the current version, TypeScript and Contentlayer:
```bash
npx degit 'timlrx/tailwind-nextjs-starter-blog#contentlayer'
```
or JS (official support)
```bash
npx degit https://github.com/timlrx/tailwind-nextjs-starter-blog.git
```
1. JS (official support) - `npx degit https://github.com/timlrx/tailwind-nextjs-starter-blog.git` or TS (community support) - `npx degit timlrx/tailwind-nextjs-starter-blog#typescript`
2. Personalize `siteMetadata.js` (site related information)
3. Modify the content security policy in `next.config.js` if you want to use
any analytics provider or a commenting solution other than giscus.
@ -157,7 +137,7 @@ You can start editing the page by modifying `pages/index.js`. The page auto-upda
Frontmatter follows [Hugo's standards](https://gohugo.io/content-management/front-matter/).
Currently 7 fields are supported.
Currently 10 fields are supported.
```
title (required)

View File

@ -3,7 +3,7 @@ title: My fancy title
date: '2021-01-31'
tags: ['hello']
draft: true
summary:
summary: draft post
images: []
---

View File

@ -7,7 +7,6 @@ draft: false
summary: 'An overview of the new features released in v1 - code block copy, multiple authors, frontmatter layout and more'
layout: PostSimple
bibliography: references-data.bib
canonicalUrl: https://tailwind-nextjs-starter-blog.vercel.app/blog/new-features-in-v1/
---
## Overview
@ -16,7 +15,9 @@ A post on the new features introduced in v1.0. New features:
<TOCInline toc={props.toc} exclude="Overview" toHeading={2} />
First load JS decreased from 43kB to 39kB despite all the new features added!^[With the new changes in Nextjs 12, first load JS increase to 45kB.]
First load JS decreased from 43kB to 39kB despite all the new features added! [^1]
[^1]: With the new changes in Nextjs 12, first load JS increase to 45kB.
See [upgrade guide](#upgrade-guide) below if you are migrating from v0 version of the template.
@ -59,14 +60,11 @@ Some new possibilities include loading components directly in the mdx file using
For example, the following jsx snippet can be used directly in an MDX file to render the page title component:
```jsx
import PageTitle from './PageTitle.js'
// Or import PageTitle from './components/PageTitle.js' if you are using js
import PageTitle from './components/PageTitle.tsx'
;<PageTitle> Using JSX components in MDX </PageTitle>
```
import PageTitle from './PageTitle.js'
<PageTitle> Using JSX components in MDX </PageTitle>
The default configuration resolves all components relative to the `components` directory.
**Note**:
@ -338,7 +336,7 @@ To modify the styles, change the following class selectors in the `prism.css` fi
}
.line-number::before {
@apply mr-4 -ml-2 inline-block w-4 text-right text-gray-400;
@apply -ml-2 mr-4 inline-block w-4 text-right text-gray-400;
content: attr(line);
}
```

View File

@ -18,17 +18,17 @@ Since we are using mdx, we can create a simple responsive flexbox grid to displa
# Gallery
<div className="flex flex-wrap -mx-2 overflow-hidden xl:-mx-2">
<div className="my-1 px-2 w-full overflow-hidden xl:my-1 xl:px-2 xl:w-1/2">
<div className="-mx-2 flex flex-wrap overflow-hidden xl:-mx-2">
<div className="my-1 w-full overflow-hidden px-2 xl:my-1 xl:w-1/2 xl:px-2">
![Maple](/static/images/canada/maple.jpg)
</div>
<div className="my-1 px-2 w-full overflow-hidden xl:my-1 xl:px-2 xl:w-1/2">
<div className="my-1 w-full overflow-hidden px-2 xl:my-1 xl:w-1/2 xl:px-2">
![Lake](/static/images/canada/lake.jpg)
</div>
<div className="my-1 px-2 w-full overflow-hidden xl:my-1 xl:px-2 xl:w-1/2">
<div className="my-1 w-full overflow-hidden px-2 xl:my-1 xl:w-1/2 xl:px-2">
![Mountains](/static/images/canada/mountains.jpg)
</div>
<div className="my-1 px-2 w-full overflow-hidden xl:my-1 xl:px-2 xl:w-1/2">
<div className="my-1 w-full overflow-hidden px-2 xl:my-1 xl:w-1/2 xl:px-2">
![Toronto](/static/images/canada/toronto.jpg)
</div>
</div>
@ -36,17 +36,17 @@ Since we are using mdx, we can create a simple responsive flexbox grid to displa
# Implementation
```js
<div className="flex flex-wrap -mx-2 overflow-hidden xl:-mx-2">
<div className="my-1 px-2 w-full overflow-hidden xl:my-1 xl:px-2 xl:w-1/2">
<div className="-mx-2 flex flex-wrap overflow-hidden xl:-mx-2">
<div className="my-1 w-full overflow-hidden px-2 xl:my-1 xl:w-1/2 xl:px-2">
![Maple](/static/images/canada/maple.jpg)
</div>
<div className="my-1 px-2 w-full overflow-hidden xl:my-1 xl:px-2 xl:w-1/2">
<div className="my-1 w-full overflow-hidden px-2 xl:my-1 xl:w-1/2 xl:px-2">
![Lake](/static/images/canada/lake.jpg)
</div>
<div className="my-1 px-2 w-full overflow-hidden xl:my-1 xl:px-2 xl:w-1/2">
<div className="my-1 w-full overflow-hidden px-2 xl:my-1 xl:w-1/2 xl:px-2">
![Mountains](/static/images/canada/mountains.jpg)
</div>
<div className="my-1 px-2 w-full overflow-hidden xl:my-1 xl:px-2 xl:w-1/2">
<div className="my-1 w-full overflow-hidden px-2 xl:my-1 xl:w-1/2 xl:px-2">
![Toronto](/static/images/canada/toronto.jpg)
</div>
</div>

View File

@ -3,9 +3,9 @@ title: 'The Time Machine'
date: '2018-08-15'
tags: ['writings', 'book', 'reflection']
draft: false
summary: 'The Time Traveller (for so it will be convenient to speak of him) was
expounding a recondite matter to us. His pale grey eyes shone and
twinkled, and his usually pale face was flushed and animated...'
summary: The Time Traveller (for so it will be convenient to speak of him) was
expounding a recondite matter to us. His pale grey eyes shone and
twinkled, and his usually pale face was flushed and animated...
---
# The Time Machine by H. G. Wells

View File

@ -30,4 +30,4 @@
author={Xie, Yihui},
year={2016},
publisher={CRC Press}
}
}

View File

@ -1,3 +1,6 @@
// @ts-check
/** @type {import("pliny/config").PlinyConfig } */
const siteMetadata = {
title: 'Next.js Starter Blog',
author: 'Tails Azimuth',
@ -24,16 +27,16 @@ const siteMetadata = {
plausibleDataDomain: '', // e.g. tailwind-nextjs-starter-blog.vercel.app
simpleAnalytics: false, // true or false
umamiWebsiteId: '', // e.g. 123e4567-e89b-12d3-a456-426614174000
posthogProjectApiKey: '', // e.g. AhnJK8392ndPOav87as450xd
googleAnalyticsId: '', // e.g. UA-000000-2 or G-XXXXXXX
posthogAnalyticsId: '', // posthog.init e.g. phc_5yXvArzvRdqtZIsHkEm3Fkkhm3d0bEYUXCaFISzqPSQ
},
newsletter: {
// supports mailchimp, buttondown, convertkit, klaviyo, revue, emailoctopus
// Please add your .env file and modify it according to your selection
provider: 'buttondown',
},
comment: {
// If you want to use a commenting system other than giscus you have to add it to the
comments: {
// If you want to use an analytics provider you have to add it to the
// content security policy in the `next.config.js` file.
// Select a provider and use the environment variables associated to it
// https://vercel.com/docs/environment-variables
@ -52,34 +55,30 @@ const siteMetadata = {
// theme example: light, dark, dark_dimmed, dark_high_contrast
// transparent_dark, preferred_color_scheme, custom
theme: 'light',
// Place the comment box above the comments. options: bottom, top
inputPosition: 'bottom',
// Choose the language giscus will be displayed in. options: en, es, zh-CN, zh-TW, ko, ja etc
lang: 'en',
// theme when dark mode
darkTheme: 'transparent_dark',
// If the theme option above is set to 'custom`
// please provide a link below to your custom theme css file.
// example: https://giscus.app/themes/custom_example.css
themeURL: '',
},
utterancesConfig: {
// Visit the link below, and follow the steps in the 'configuration' section
// https://utteranc.es/
repo: process.env.NEXT_PUBLIC_UTTERANCES_REPO,
issueTerm: '', // supported options: pathname, url, title
label: '', // label (optional): Comment 💬
// theme example: github-light, github-dark, preferred-color-scheme
// github-dark-orange, icy-dark, dark-blue, photon-dark, boxy-light
theme: '',
// theme when dark mode
darkTheme: '',
},
disqusConfig: {
// https://help.disqus.com/en/articles/1717111-what-s-a-shortname
shortname: process.env.NEXT_PUBLIC_DISQUS_SHORTNAME,
// This corresponds to the `data-lang="en"` in giscus's configurations
lang: 'en',
},
},
// search: {
// provider: 'kbar', // kbar or algolia
// kbarConfig: {
// searchDocumentsPath: 'search.json', // path to load documents to search
// },
// provider: 'algolia',
// algoliaConfig: {
// // The application ID provided by Algolia
// appId: 'R2IYF7ETH7',
// // Public API key: it is safe to commit it
// apiKey: '599cec31baffa4868cae4e79f180729b',
// indexName: 'docsearch',
// },
// },
}
module.exports = siteMetadata

View File

@ -6,7 +6,8 @@
"@/data/*": ["data/*"],
"@/layouts/*": ["layouts/*"],
"@/lib/*": ["lib/*"],
"@/css/*": ["css/*"]
"@/css/*": ["css/*"],
"contentlayer/generated": ["./.contentlayer/generated"]
}
}
}

View File

@ -1,29 +1,38 @@
import { ReactNode } from 'react'
import type { Authors } from 'contentlayer/generated'
import SocialIcon from '@/components/social-icons'
import Image from '@/components/Image'
import { PageSEO } from '@/components/SEO'
// import { PageSEO } from '@/components/SEO'
export default function AuthorLayout({ children, frontMatter }) {
const { name, avatar, occupation, company, email, twitter, linkedin, github } = frontMatter
interface Props {
children: ReactNode
content: Omit<Authors, '_id' | '_raw' | 'body'>
}
export default function AuthorLayout({ children, content }: Props) {
const { name, avatar, occupation, company, email, twitter, linkedin, github } = content
return (
<>
<PageSEO title={`About - ${name}`} description={`About me - ${name}`} />
{/* <PageSEO title={`About - ${name}`} description={`About me - ${name}`} /> */}
<div className="divide-y divide-gray-200 dark:divide-gray-700">
<div className="space-y-2 pt-6 pb-8 md:space-y-5">
<div className="space-y-2 pb-8 pt-6 md:space-y-5">
<h1 className="text-3xl font-extrabold leading-9 tracking-tight text-gray-900 dark:text-gray-100 sm:text-4xl sm:leading-10 md:text-6xl md:leading-14">
About
</h1>
</div>
<div className="items-start space-y-2 xl:grid xl:grid-cols-3 xl:gap-x-8 xl:space-y-0">
<div className="flex flex-col items-center pt-8">
<Image
src={avatar}
alt="avatar"
width="192px"
height="192px"
className="h-48 w-48 rounded-full"
/>
<h3 className="pt-4 pb-2 text-2xl font-bold leading-8 tracking-tight">{name}</h3>
<div className="flex flex-col items-center space-x-2 pt-8">
{avatar && (
<Image
src={avatar}
alt="avatar"
width={192}
height={192}
className="h-48 w-48 rounded-full"
/>
)}
<h3 className="pb-2 pt-4 text-2xl font-bold leading-8 tracking-tight">{name}</h3>
<div className="text-gray-500 dark:text-gray-400">{occupation}</div>
<div className="text-gray-500 dark:text-gray-400">{company}</div>
<div className="flex space-x-3 pt-6">
@ -33,7 +42,7 @@ export default function AuthorLayout({ children, frontMatter }) {
<SocialIcon kind="twitter" href={twitter} />
</div>
</div>
<div className="prose max-w-none pt-8 pb-8 dark:prose-dark xl:col-span-2">{children}</div>
<div className="prose max-w-none pb-8 pt-8 dark:prose-dark xl:col-span-2">{children}</div>
</div>
</div>
</>

View File

@ -1,91 +0,0 @@
import Link from '@/components/Link'
import Tag from '@/components/Tag'
import siteMetadata from '@/data/siteMetadata'
import { useState } from 'react'
import Pagination from '@/components/Pagination'
import formatDate from '@/lib/utils/formatDate'
export default function ListLayout({ posts, title, initialDisplayPosts = [], pagination }) {
const [searchValue, setSearchValue] = useState('')
const filteredBlogPosts = posts.filter((frontMatter) => {
const searchContent = frontMatter.title + frontMatter.summary + frontMatter.tags.join(' ')
return searchContent.toLowerCase().includes(searchValue.toLowerCase())
})
// If initialDisplayPosts exist, display it if no searchValue is specified
const displayPosts =
initialDisplayPosts.length > 0 && !searchValue ? initialDisplayPosts : filteredBlogPosts
return (
<>
<div className="divide-y divide-gray-200 dark:divide-gray-700">
<div className="space-y-2 pt-6 pb-8 md:space-y-5">
<h1 className="text-3xl font-extrabold leading-9 tracking-tight text-gray-900 dark:text-gray-100 sm:text-4xl sm:leading-10 md:text-6xl md:leading-14">
{title}
</h1>
<div className="relative max-w-lg">
<input
aria-label="Search articles"
type="text"
onChange={(e) => setSearchValue(e.target.value)}
placeholder="Search articles"
className="block w-full rounded-md border border-gray-300 bg-white px-4 py-2 text-gray-900 focus:border-primary-500 focus:ring-primary-500 dark:border-gray-900 dark:bg-gray-800 dark:text-gray-100"
/>
<svg
className="absolute right-3 top-3 h-5 w-5 text-gray-400 dark:text-gray-300"
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"
/>
</svg>
</div>
</div>
<ul>
{!filteredBlogPosts.length && 'No posts found.'}
{displayPosts.map((frontMatter) => {
const { slug, date, title, summary, tags } = frontMatter
return (
<li key={slug} className="py-4">
<article className="space-y-2 xl:grid xl:grid-cols-4 xl:items-baseline xl:space-y-0">
<dl>
<dt className="sr-only">Published on</dt>
<dd className="text-base font-medium leading-6 text-gray-500 dark:text-gray-400">
<time dateTime={date}>{formatDate(date)}</time>
</dd>
</dl>
<div className="space-y-3 xl:col-span-3">
<div>
<h3 className="text-2xl font-bold leading-8 tracking-tight">
<Link href={`/blog/${slug}`} className="text-gray-900 dark:text-gray-100">
{title}
</Link>
</h3>
<div className="flex flex-wrap">
{tags.map((tag) => (
<Tag key={tag} text={tag} />
))}
</div>
</div>
<div className="prose max-w-none text-gray-500 dark:text-gray-400">
{summary}
</div>
</div>
</article>
</li>
)
})}
</ul>
</div>
{pagination && pagination.totalPages > 1 && !searchValue && (
<Pagination currentPage={pagination.currentPage} totalPages={pagination.totalPages} />
)}
</>
)
}

154
layouts/ListLayout.tsx Normal file
View File

@ -0,0 +1,154 @@
'use client'
import { useState } from 'react'
import { usePathname } from 'next/navigation'
import { formatDate } from 'pliny/utils/formatDate'
import { CoreContent } from 'pliny/utils/contentlayer'
import type { Blog } from 'contentlayer/generated'
import Link from '@/components/Link'
import Tag from '@/components/Tag'
import siteMetadata from '@/data/siteMetadata'
interface PaginationProps {
totalPages: number
currentPage: number
}
interface ListLayoutProps {
posts: CoreContent<Blog>[]
title: string
initialDisplayPosts?: CoreContent<Blog>[]
pagination?: PaginationProps
}
function Pagination({ totalPages, currentPage }: PaginationProps) {
const pathname = usePathname() as string
const basePath = pathname.split('/')[1]
const prevPage = currentPage - 1 > 0
const nextPage = currentPage + 1 <= totalPages
return (
<div className="space-y-2 pb-8 pt-6 md:space-y-5">
<nav className="flex justify-between">
{!prevPage && (
<button className="cursor-auto disabled:opacity-50" disabled={!prevPage}>
Previous
</button>
)}
{prevPage && (
<Link
href={currentPage - 1 === 1 ? `/${basePath}/` : `/${basePath}/page/${currentPage - 1}`}
rel="prev"
>
Previous
</Link>
)}
<span>
{currentPage} of {totalPages}
</span>
{!nextPage && (
<button className="cursor-auto disabled:opacity-50" disabled={!nextPage}>
Next
</button>
)}
{nextPage && (
<Link href={`/${basePath}/page/${currentPage + 1}`} rel="next">
Next
</Link>
)}
</nav>
</div>
)
}
export default function ListLayout({
posts,
title,
initialDisplayPosts = [],
pagination,
}: ListLayoutProps) {
const [searchValue, setSearchValue] = useState('')
const filteredBlogPosts = posts.filter((post) => {
const searchContent = post.title + post.summary + post.tags?.join(' ')
return searchContent.toLowerCase().includes(searchValue.toLowerCase())
})
// If initialDisplayPosts exist, display it if no searchValue is specified
const displayPosts =
initialDisplayPosts.length > 0 && !searchValue ? initialDisplayPosts : filteredBlogPosts
return (
<>
<div className="divide-y divide-gray-200 dark:divide-gray-700">
<div className="space-y-2 pb-8 pt-6 md:space-y-5">
<h1 className="text-3xl font-extrabold leading-9 tracking-tight text-gray-900 dark:text-gray-100 sm:text-4xl sm:leading-10 md:text-6xl md:leading-14">
{title}
</h1>
<div className="relative max-w-lg">
<label>
<span className="sr-only">Search articles</span>
<input
aria-label="Search articles"
type="text"
onChange={(e) => setSearchValue(e.target.value)}
placeholder="Search articles"
className="block w-full rounded-md border border-gray-300 bg-white px-4 py-2 text-gray-900 focus:border-primary-500 focus:ring-primary-500 dark:border-gray-900 dark:bg-gray-800 dark:text-gray-100"
/>
</label>
<svg
className="absolute right-3 top-3 h-5 w-5 text-gray-400 dark:text-gray-300"
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"
/>
</svg>
</div>
</div>
<ul>
{!filteredBlogPosts.length && 'No posts found.'}
{displayPosts.map((post) => {
const { path, date, title, summary, tags } = post
return (
<li key={path} className="py-4">
<article className="space-y-2 xl:grid xl:grid-cols-4 xl:items-baseline xl:space-y-0">
<dl>
<dt className="sr-only">Published on</dt>
<dd className="text-base font-medium leading-6 text-gray-500 dark:text-gray-400">
<time dateTime={date}>{formatDate(date, siteMetadata.locale)}</time>
</dd>
</dl>
<div className="space-y-3 xl:col-span-3">
<div>
<h3 className="text-2xl font-bold leading-8 tracking-tight">
<Link href={`/${path}`} className="text-gray-900 dark:text-gray-100">
{title}
</Link>
</h3>
<div className="flex flex-wrap">
{tags?.map((tag) => (
<Tag key={tag} text={tag} />
))}
</div>
</div>
<div className="prose max-w-none text-gray-500 dark:text-gray-400">
{summary}
</div>
</div>
</article>
</li>
)
})}
</ul>
</div>
{pagination && pagination.totalPages > 1 && !searchValue && (
<Pagination currentPage={pagination.currentPage} totalPages={pagination.totalPages} />
)}
</>
)
}

View File

@ -1,31 +1,43 @@
import { useState, ReactNode } from 'react'
import { Comments } from 'pliny/comments'
import { CoreContent } from 'pliny/utils/contentlayer'
import type { Blog, Authors } from 'contentlayer/generated'
import Link from '@/components/Link'
import PageTitle from '@/components/PageTitle'
import SectionContainer from '@/components/SectionContainer'
import { BlogSEO } from '@/components/SEO'
// import { BlogSEO } from '@/components/SEO'
import Image from '@/components/Image'
import Tag from '@/components/Tag'
import siteMetadata from '@/data/siteMetadata'
import Comments from '@/components/comments'
import ScrollTopAndComment from '@/components/ScrollTopAndComment'
const editUrl = (fileName) => `${siteMetadata.siteRepo}/blob/master/data/blog/${fileName}`
const discussUrl = (slug) =>
`https://mobile.twitter.com/search?q=${encodeURIComponent(
`${siteMetadata.siteUrl}/blog/${slug}`
)}`
const editUrl = (path) => `${siteMetadata.siteRepo}/blob/master/data/${path}`
const discussUrl = (path) =>
`https://mobile.twitter.com/search?q=${encodeURIComponent(`${siteMetadata.siteUrl}/${path}`)}`
const postDateTemplate = { weekday: 'long', year: 'numeric', month: 'long', day: 'numeric' }
const postDateTemplate: Intl.DateTimeFormatOptions = {
weekday: 'long',
year: 'numeric',
month: 'long',
day: 'numeric',
}
export default function PostLayout({ frontMatter, authorDetails, next, prev, children }) {
const { slug, fileName, date, title, images, tags } = frontMatter
interface LayoutProps {
content: CoreContent<Blog>
authorDetails: CoreContent<Authors>[]
next?: { path: string; title: string }
prev?: { path: string; title: string }
children: ReactNode
}
export default function PostLayout({ content, authorDetails, next, prev, children }: LayoutProps) {
const { filePath, path, slug, date, title, tags } = content
const basePath = path.split('/')[0]
const [loadComments, setLoadComments] = useState(false)
return (
<SectionContainer>
<BlogSEO
url={`${siteMetadata.siteUrl}/blog/${slug}`}
authorDetails={authorDetails}
{...frontMatter}
/>
{/* <BlogSEO url={`${siteMetadata.siteUrl}/${path}`} authorDetails={authorDetails} {...content} /> */}
<ScrollTopAndComment />
<article>
<div className="xl:divide-y xl:divide-gray-200 xl:dark:divide-gray-700">
@ -46,21 +58,18 @@ export default function PostLayout({ frontMatter, authorDetails, next, prev, chi
</div>
</div>
</header>
<div
className="divide-y divide-gray-200 pb-8 dark:divide-gray-700 xl:grid xl:grid-cols-4 xl:gap-x-6 xl:divide-y-0"
style={{ gridTemplateRows: 'auto 1fr' }}
>
<dl className="pt-6 pb-10 xl:border-b xl:border-gray-200 xl:pt-11 xl:dark:border-gray-700">
<div className="grid-rows-[auto_1fr] divide-y divide-gray-200 pb-8 dark:divide-gray-700 xl:grid xl:grid-cols-4 xl:gap-x-6 xl:divide-y-0">
<dl className="pb-10 pt-6 xl:border-b xl:border-gray-200 xl:pt-11 xl:dark:border-gray-700">
<dt className="sr-only">Authors</dt>
<dd>
<ul className="flex justify-center space-x-8 sm:space-x-12 xl:block xl:space-x-0 xl:space-y-8">
<ul className="flex flex-wrap justify-center gap-4 sm:space-x-12 xl:block xl:space-x-0 xl:space-y-8">
{authorDetails.map((author) => (
<li className="flex items-center space-x-2" key={author.name}>
{author.avatar && (
<Image
src={author.avatar}
width="38px"
height="38px"
width={38}
height={38}
alt="avatar"
className="h-10 w-10 rounded-full"
/>
@ -86,15 +95,25 @@ export default function PostLayout({ frontMatter, authorDetails, next, prev, chi
</dd>
</dl>
<div className="divide-y divide-gray-200 dark:divide-gray-700 xl:col-span-3 xl:row-span-2 xl:pb-0">
<div className="prose max-w-none pt-10 pb-8 dark:prose-dark">{children}</div>
<div className="pt-6 pb-6 text-sm text-gray-700 dark:text-gray-300">
<Link href={discussUrl(slug)} rel="nofollow">
{'Discuss on Twitter'}
<div className="prose max-w-none pb-8 pt-10 dark:prose-dark">{children}</div>
<div className="pb-6 pt-6 text-sm text-gray-700 dark:text-gray-300">
<Link href={discussUrl(path)} rel="nofollow">
Discuss on Twitter
</Link>
{``}
<Link href={editUrl(fileName)}>{'View on GitHub'}</Link>
<Link href={editUrl(filePath)}>View on GitHub</Link>
</div>
<Comments frontMatter={frontMatter} />
{siteMetadata.comments && (
<div
className="pb-6 pt-6 text-center text-gray-700 dark:text-gray-300"
id="comment"
>
{!loadComments && (
<button onClick={() => setLoadComments(true)}>Load Comments</button>
)}
{loadComments && <Comments commentsConfig={siteMetadata.comments} slug={slug} />}
</div>
)}
</div>
<footer>
<div className="divide-gray-200 text-sm font-medium leading-5 dark:divide-gray-700 xl:col-start-1 xl:row-start-2 xl:divide-y">
@ -118,7 +137,7 @@ export default function PostLayout({ frontMatter, authorDetails, next, prev, chi
Previous Article
</h2>
<div className="text-primary-500 hover:text-primary-600 dark:hover:text-primary-400">
<Link href={`/blog/${prev.slug}`}>{prev.title}</Link>
<Link href={`/${prev.path}`}>{prev.title}</Link>
</div>
</div>
)}
@ -128,7 +147,7 @@ export default function PostLayout({ frontMatter, authorDetails, next, prev, chi
Next Article
</h2>
<div className="text-primary-500 hover:text-primary-600 dark:hover:text-primary-400">
<Link href={`/blog/${next.slug}`}>{next.title}</Link>
<Link href={`/${next.path}`}>{next.title}</Link>
</div>
</div>
)}
@ -137,8 +156,9 @@ export default function PostLayout({ frontMatter, authorDetails, next, prev, chi
</div>
<div className="pt-4 xl:pt-8">
<Link
href="/blog"
href={`/${basePath}`}
className="text-primary-500 hover:text-primary-600 dark:hover:text-primary-400"
aria-label="Back to the blog"
>
&larr; Back to the blog
</Link>

View File

@ -1,18 +1,30 @@
import { useState, ReactNode } from 'react'
import { Comments } from 'pliny/comments'
import { formatDate } from 'pliny/utils/formatDate'
import { CoreContent } from 'pliny/utils/contentlayer'
import type { Blog } from 'contentlayer/generated'
import Link from '@/components/Link'
import PageTitle from '@/components/PageTitle'
import SectionContainer from '@/components/SectionContainer'
import { BlogSEO } from '@/components/SEO'
// import { BlogSEO } from '@/components/SEO'
import siteMetadata from '@/data/siteMetadata'
import formatDate from '@/lib/utils/formatDate'
import Comments from '@/components/comments'
import ScrollTopAndComment from '@/components/ScrollTopAndComment'
export default function PostLayout({ frontMatter, authorDetails, next, prev, children }) {
const { date, title } = frontMatter
interface LayoutProps {
content: CoreContent<Blog>
children: ReactNode
next?: { path: string; title: string }
prev?: { path: string; title: string }
}
export default function PostLayout({ content, next, prev, children }: LayoutProps) {
const [loadComments, setLoadComments] = useState(false)
const { path, slug, date, title } = content
return (
<SectionContainer>
<BlogSEO url={`${siteMetadata.siteUrl}/blog/${frontMatter.slug}`} {...frontMatter} />
{/* <BlogSEO url={`${siteMetadata.siteUrl}/${path}`} {...content} /> */}
<ScrollTopAndComment />
<article>
<div>
@ -22,7 +34,7 @@ export default function PostLayout({ frontMatter, authorDetails, next, prev, chi
<div>
<dt className="sr-only">Published on</dt>
<dd className="text-base font-medium leading-6 text-gray-500 dark:text-gray-400">
<time dateTime={date}>{formatDate(date)}</time>
<time dateTime={date}>{formatDate(date, siteMetadata.locale)}</time>
</dd>
</div>
</dl>
@ -31,21 +43,26 @@ export default function PostLayout({ frontMatter, authorDetails, next, prev, chi
</div>
</div>
</header>
<div
className="divide-y divide-gray-200 pb-8 dark:divide-gray-700 xl:divide-y-0 "
style={{ gridTemplateRows: 'auto 1fr' }}
>
<div className="grid-rows-[auto_1fr] divide-y divide-gray-200 pb-8 dark:divide-gray-700 xl:divide-y-0">
<div className="divide-y divide-gray-200 dark:divide-gray-700 xl:col-span-3 xl:row-span-2 xl:pb-0">
<div className="prose max-w-none pt-10 pb-8 dark:prose-dark">{children}</div>
<div className="prose max-w-none pb-8 pt-10 dark:prose-dark">{children}</div>
</div>
<Comments frontMatter={frontMatter} />
{siteMetadata.comments && (
<div className="pb-6 pt-6 text-center text-gray-700 dark:text-gray-300" id="comment">
{!loadComments && (
<button onClick={() => setLoadComments(true)}>Load Comments</button>
)}
{loadComments && <Comments commentsConfig={siteMetadata.comments} slug={slug} />}
</div>
)}
<footer>
<div className="flex flex-col text-sm font-medium sm:flex-row sm:justify-between sm:text-base">
{prev && (
<div className="pt-4 xl:pt-8">
<Link
href={`/blog/${prev.slug}`}
href={`/${prev.path}`}
className="text-primary-500 hover:text-primary-600 dark:hover:text-primary-400"
aria-label={`Previous post: ${prev.title}`}
>
&larr; {prev.title}
</Link>
@ -54,8 +71,9 @@ export default function PostLayout({ frontMatter, authorDetails, next, prev, chi
{next && (
<div className="pt-4 xl:pt-8">
<Link
href={`/blog/${next.slug}`}
href={`/${next.path}`}
className="text-primary-500 hover:text-primary-600 dark:hover:text-primary-400"
aria-label={`Next post: ${next.title}`}
>
{next.title} &rarr;
</Link>

View File

@ -1,32 +0,0 @@
import { escape } from '@/lib/utils/htmlEscaper'
import siteMetadata from '@/data/siteMetadata'
const generateRssItem = (post) => `
<item>
<guid>${siteMetadata.siteUrl}/blog/${post.slug}</guid>
<title>${escape(post.title)}</title>
<link>${siteMetadata.siteUrl}/blog/${post.slug}</link>
${post.summary && `<description>${escape(post.summary)}</description>`}
<pubDate>${new Date(post.date).toUTCString()}</pubDate>
<author>${siteMetadata.email} (${siteMetadata.author})</author>
${post.tags && post.tags.map((t) => `<category>${t}</category>`).join('')}
</item>
`
const generateRss = (posts, page = 'feed.xml') => `
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom">
<channel>
<title>${escape(siteMetadata.title)}</title>
<link>${siteMetadata.siteUrl}/blog</link>
<description>${escape(siteMetadata.description)}</description>
<language>${siteMetadata.language}</language>
<managingEditor>${siteMetadata.email} (${siteMetadata.author})</managingEditor>
<webMaster>${siteMetadata.email} (${siteMetadata.author})</webMaster>
<lastBuildDate>${new Date(posts[0].date).toUTCString()}</lastBuildDate>
<atom:link href="${siteMetadata.siteUrl}/${page}" rel="self" type="application/rss+xml"/>
${posts.map(generateRssItem).join('')}
</channel>
</rss>
`
export default generateRss

View File

@ -1,136 +0,0 @@
import { bundleMDX } from 'mdx-bundler'
import fs from 'fs'
import matter from 'gray-matter'
import path from 'path'
import readingTime from 'reading-time'
import { visit } from 'unist-util-visit'
import getAllFilesRecursively from './utils/files'
// Remark packages
import remarkGfm from 'remark-gfm'
import remarkFootnotes from 'remark-footnotes'
import remarkMath from 'remark-math'
import remarkExtractFrontmatter from './remark-extract-frontmatter'
import remarkCodeTitles from './remark-code-title'
import remarkTocHeadings from './remark-toc-headings'
import remarkImgToJsx from './remark-img-to-jsx'
// Rehype packages
import rehypeSlug from 'rehype-slug'
import rehypeAutolinkHeadings from 'rehype-autolink-headings'
import rehypeKatex from 'rehype-katex'
import rehypeCitation from 'rehype-citation'
import rehypePrismPlus from 'rehype-prism-plus'
import rehypePresetMinify from 'rehype-preset-minify'
const root = process.cwd()
export function getFiles(type) {
const prefixPaths = path.join(root, 'data', type)
const files = getAllFilesRecursively(prefixPaths)
// Only want to return blog/path and ignore root, replace is needed to work on Windows
return files.map((file) => file.slice(prefixPaths.length + 1).replace(/\\/g, '/'))
}
export function formatSlug(slug) {
return slug.replace(/\.(mdx|md)/, '')
}
export function dateSortDesc(a, b) {
if (a > b) return -1
if (a < b) return 1
return 0
}
export async function getFileBySlug(type, slug) {
const mdxPath = path.join(root, 'data', type, `${slug}.mdx`)
const mdPath = path.join(root, 'data', type, `${slug}.md`)
const source = fs.existsSync(mdxPath)
? fs.readFileSync(mdxPath, 'utf8')
: fs.readFileSync(mdPath, 'utf8')
// https://github.com/kentcdodds/mdx-bundler#nextjs-esbuild-enoent
if (process.platform === 'win32') {
process.env.ESBUILD_BINARY_PATH = path.join(root, 'node_modules', 'esbuild', 'esbuild.exe')
} else {
process.env.ESBUILD_BINARY_PATH = path.join(root, 'node_modules', 'esbuild', 'bin', 'esbuild')
}
let toc = []
const { code, frontmatter } = await bundleMDX({
source,
// mdx imports can be automatically source from the components directory
cwd: path.join(root, 'components'),
xdmOptions(options, frontmatter) {
// this is the recommended way to add custom remark/rehype plugins:
// The syntax might look weird, but it protects you in case we add/remove
// plugins in the future.
options.remarkPlugins = [
...(options.remarkPlugins ?? []),
remarkExtractFrontmatter,
[remarkTocHeadings, { exportRef: toc }],
remarkGfm,
remarkCodeTitles,
[remarkFootnotes, { inlineNotes: true }],
remarkMath,
remarkImgToJsx,
]
options.rehypePlugins = [
...(options.rehypePlugins ?? []),
rehypeSlug,
rehypeAutolinkHeadings,
rehypeKatex,
[rehypeCitation, { path: path.join(root, 'data') }],
[rehypePrismPlus, { ignoreMissing: true }],
rehypePresetMinify,
]
return options
},
esbuildOptions: (options) => {
options.loader = {
...options.loader,
'.js': 'jsx',
}
return options
},
})
return {
mdxSource: code,
toc,
frontMatter: {
readingTime: readingTime(code),
slug: slug || null,
fileName: fs.existsSync(mdxPath) ? `${slug}.mdx` : `${slug}.md`,
...frontmatter,
date: frontmatter.date ? new Date(frontmatter.date).toISOString() : null,
},
}
}
export async function getAllFilesFrontMatter(folder) {
const prefixPaths = path.join(root, 'data', folder)
const files = getAllFilesRecursively(prefixPaths)
const allFrontMatter = []
files.forEach((file) => {
// Replace is needed to work on Windows
const fileName = file.slice(prefixPaths.length + 1).replace(/\\/g, '/')
// Remove Unexpected File
if (path.extname(fileName) !== '.md' && path.extname(fileName) !== '.mdx') {
return
}
const source = fs.readFileSync(file, 'utf8')
const { data: frontmatter } = matter(source)
if (frontmatter.draft !== true) {
allFrontMatter.push({
...frontmatter,
slug: formatSlug(fileName),
date: frontmatter.date ? new Date(frontmatter.date).toISOString() : null,
})
}
})
return allFrontMatter.sort((a, b) => dateSortDesc(a.date, b.date))
}

View File

@ -1,32 +0,0 @@
import { visit } from 'unist-util-visit'
export default function remarkCodeTitles() {
return (tree) =>
visit(tree, 'code', (node, index, parent) => {
const nodeLang = node.lang || ''
let language = ''
let title = ''
if (nodeLang.includes(':')) {
language = nodeLang.slice(0, nodeLang.search(':'))
title = nodeLang.slice(nodeLang.search(':') + 1, nodeLang.length)
}
if (!title) {
return
}
const className = 'remark-code-title'
const titleNode = {
type: 'mdxJsxFlowElement',
name: 'div',
attributes: [{ type: 'mdxJsxAttribute', name: 'className', value: className }],
children: [{ type: 'text', value: title }],
data: { _xdmExplicitJsx: true },
}
parent.children.splice(index, 0, titleNode)
node.lang = language
})
}

View File

@ -1,10 +0,0 @@
import { visit } from 'unist-util-visit'
import { load } from 'js-yaml'
export default function extractFrontmatter() {
return (tree, file) => {
visit(tree, 'yaml', (node, index, parent) => {
file.data.frontmatter = load(node.value)
})
}
}

View File

@ -1,35 +0,0 @@
import { visit } from 'unist-util-visit'
import sizeOf from 'image-size'
import fs from 'fs'
export default function remarkImgToJsx() {
return (tree) => {
visit(
tree,
// only visit p tags that contain an img element
(node) => node.type === 'paragraph' && node.children.some((n) => n.type === 'image'),
(node) => {
const imageNode = node.children.find((n) => n.type === 'image')
// only local files
if (fs.existsSync(`${process.cwd()}/public${imageNode.url}`)) {
const dimensions = sizeOf(`${process.cwd()}/public${imageNode.url}`)
// Convert original node to next/image
;(imageNode.type = 'mdxJsxFlowElement'),
(imageNode.name = 'Image'),
(imageNode.attributes = [
{ type: 'mdxJsxAttribute', name: 'alt', value: imageNode.alt },
{ type: 'mdxJsxAttribute', name: 'src', value: imageNode.url },
{ type: 'mdxJsxAttribute', name: 'width', value: dimensions.width },
{ type: 'mdxJsxAttribute', name: 'height', value: dimensions.height },
])
// Change node type from p to div to avoid nesting error
node.type = 'div'
node.children = [imageNode]
}
}
)
}
}

View File

@ -1,15 +0,0 @@
import { visit } from 'unist-util-visit'
import { slug } from 'github-slugger'
import { toString } from 'mdast-util-to-string'
export default function remarkTocHeadings(options) {
return (tree) =>
visit(tree, 'heading', (node, index, parent) => {
const textContent = toString(node)
options.exportRef.push({
value: textContent,
url: '#' + slug(textContent),
depth: node.depth,
})
})
}

View File

@ -1,30 +0,0 @@
import fs from 'fs'
import matter from 'gray-matter'
import path from 'path'
import { getFiles } from './mdx'
import kebabCase from './utils/kebabCase'
const root = process.cwd()
export async function getAllTags(type) {
const files = await getFiles(type)
let tagCount = {}
// Iterate through each post, putting all found tags into `tags`
files.forEach((file) => {
const source = fs.readFileSync(path.join(root, 'data', type, file), 'utf8')
const { data } = matter(source)
if (data.tags && data.draft !== true) {
data.tags.forEach((tag) => {
const formattedTag = kebabCase(tag)
if (formattedTag in tagCount) {
tagCount[formattedTag] += 1
} else {
tagCount[formattedTag] = 1
}
})
}
})
return tagCount
}

View File

@ -1,23 +0,0 @@
import fs from 'fs'
import path from 'path'
const pipe =
(...fns) =>
(x) =>
fns.reduce((v, f) => f(v), x)
const flattenArray = (input) =>
input.reduce((acc, item) => [...acc, ...(Array.isArray(item) ? item : [item])], [])
const map = (fn) => (input) => input.map(fn)
const walkDir = (fullPath) => {
return fs.statSync(fullPath).isFile() ? fullPath : getAllFilesRecursively(fullPath)
}
const pathJoinPrefix = (prefix) => (extraPath) => path.join(prefix, extraPath)
const getAllFilesRecursively = (folder) =>
pipe(fs.readdirSync, map(pipe(pathJoinPrefix(folder), walkDir)), flattenArray)(folder)
export default getAllFilesRecursively

View File

@ -1,14 +0,0 @@
import siteMetadata from '@/data/siteMetadata'
const formatDate = (date) => {
const options = {
year: 'numeric',
month: 'long',
day: 'numeric',
}
const now = new Date(date).toLocaleDateString(siteMetadata.locale, options)
return now
}
export default formatDate

View File

@ -1,23 +0,0 @@
const { replace } = ''
// escape
const es = /&(?:amp|#38|lt|#60|gt|#62|apos|#39|quot|#34);/g
const ca = /[&<>'"]/g
const esca = {
'&': '&amp;',
'<': '&lt;',
'>': '&gt;',
"'": '&#39;',
'"': '&quot;',
}
const pe = (m) => esca[m]
/**
* Safely escape HTML entities such as `&`, `<`, `>`, `"`, and `'`.
* @param {string} es the input to safely escape
* @returns {string} the escaped input, and it **throws** an error if
* the input type is unexpected, except for boolean and numbers,
* converted as string.
*/
export const escape = (es) => replace.call(es, ca, pe)

View File

@ -1,5 +0,0 @@
import { slug } from 'github-slugger'
const kebabCase = (str) => slug(str)
export default kebabCase

6
next-env.d.ts vendored Normal file
View File

@ -0,0 +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

@ -1,3 +1,5 @@
const { withContentlayer } = require('next-contentlayer')
const withBundleAnalyzer = require('@next/bundle-analyzer')({
enabled: process.env.ANALYZE === 'true',
})
@ -52,36 +54,35 @@ const securityHeaders = [
},
]
module.exports = withBundleAnalyzer({
reactStrictMode: true,
pageExtensions: ['js', 'jsx', 'md', 'mdx'],
eslint: {
dirs: ['pages', 'components', 'lib', 'layouts', 'scripts'],
},
async headers() {
return [
{
source: '/(.*)',
headers: securityHeaders,
},
]
},
webpack: (config, { dev, isServer }) => {
config.module.rules.push({
test: /\.svg$/,
use: ['@svgr/webpack'],
})
if (!dev && !isServer) {
// Replace React with Preact only in client production build
Object.assign(config.resolve.alias, {
'react/jsx-runtime.js': 'preact/compat/jsx-runtime',
react: 'preact/compat',
'react-dom/test-utils': 'preact/test-utils',
'react-dom': 'preact/compat',
/**
* @type {import('next/dist/next-server/server/config').NextConfig}
**/
module.exports = () => {
const plugins = [withContentlayer, withBundleAnalyzer]
return plugins.reduce((acc, next) => next(acc), {
reactStrictMode: true,
pageExtensions: ['ts', 'tsx', 'js', 'jsx', 'md', 'mdx'],
eslint: {
dirs: ['pages', 'components', 'lib', 'layouts', 'scripts'],
},
experimental: {
appDir: true,
},
async headers() {
return [
{
source: '/(.*)',
headers: securityHeaders,
},
]
},
webpack: (config, options) => {
config.module.rules.push({
test: /\.svg$/,
use: ['@svgr/webpack'],
})
}
return config
},
})
return config
},
})
}

10657
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -3,64 +3,64 @@
"version": "1.5.6",
"private": true,
"scripts": {
"start": "cross-env SOCKET=true node ./scripts/next-remote-watch.js ./data",
"dev": "next dev",
"build": "next build && node ./scripts/generate-sitemap",
"start": "next dev",
"dev": "cross-env INIT_CWD=$PWD next dev",
"build": "cross-env INIT_CWD=$PWD next build && cross-env NODE_OPTIONS='--experimental-json-modules' node -r esbuild-register ./scripts/postbuild.mjs",
"serve": "next start",
"analyze": "cross-env ANALYZE=true next build",
"lint": "next lint --fix --dir pages --dir components --dir lib --dir layouts --dir scripts",
"prepare": "husky install"
"lint": "next lint --fix --dir pages --dir components --dir lib --dir layouts --dir scripts"
},
"dependencies": {
"@fontsource/inter": "4.5.2",
"@mailchimp/mailchimp_marketing": "^3.0.58",
"@next/bundle-analyzer": "^12.1.4",
"@tailwindcss/forms": "^0.4.0",
"@tailwindcss/typography": "^0.5.0",
"autoprefixer": "^10.4.0",
"esbuild": "^0.13.13",
"github-slugger": "^1.3.0",
"@next/bundle-analyzer": "13.4.8",
"@tailwindcss/forms": "^0.5.3",
"@tailwindcss/typography": "^0.5.9",
"autoprefixer": "^10.4.13",
"contentlayer": "0.3.4",
"esbuild": "0.18.11",
"github-slugger": "^1.4.0",
"gray-matter": "^4.0.2",
"image-size": "1.0.0",
"mdx-bundler": "^8.0.0",
"next": "12.1.4",
"next-themes": "^0.0.14",
"postcss": "^8.4.5",
"preact": "^10.6.2",
"react": "17.0.2",
"react-dom": "17.0.2",
"reading-time": "1.3.0",
"mdx-bundler": "^9.2.1",
"next": "13.4.8",
"next-contentlayer": "0.3.4",
"next-themes": "^0.2.1",
"pliny": "0.0.10",
"postcss": "^8.4.24",
"react": "18.2.0",
"react-dom": "18.2.0",
"reading-time": "1.5.0",
"rehype-autolink-headings": "^6.1.0",
"rehype-citation": "^0.4.0",
"rehype-katex": "^6.0.2",
"rehype-citation": "^1.0.2",
"rehype-katex": "^6.0.3",
"rehype-preset-minify": "6.0.0",
"rehype-prism-plus": "^1.1.3",
"rehype-slug": "^5.0.0",
"remark-footnotes": "^4.0.1",
"rehype-prism-plus": "^1.6.0",
"rehype-slug": "^5.1.0",
"remark": "^14.0.2",
"remark-gfm": "^3.0.1",
"remark-math": "^5.1.1",
"sharp": "^0.28.3",
"tailwindcss": "^3.0.23",
"unist-util-visit": "^4.0.0"
"tailwindcss": "^3.3.2",
"unist-util-visit": "^4.1.0"
},
"devDependencies": {
"@svgr/webpack": "^6.1.2",
"@svgr/webpack": "^8.0.1",
"@types/react": "^18.2.14",
"@typescript-eslint/eslint-plugin": "^5.61.0",
"@typescript-eslint/parser": "^5.61.0",
"cross-env": "^7.0.3",
"dedent": "^0.7.0",
"eslint": "^7.29.0",
"eslint-config-next": "12.1.4",
"eslint-config-prettier": "^8.3.0",
"eslint-plugin-prettier": "^3.3.1",
"file-loader": "^6.0.0",
"esbuild-register": "3.4.2",
"eslint": "^8.43.0",
"eslint-config-next": "13.4.7",
"eslint-config-prettier": "^8.8.0",
"eslint-plugin-prettier": "^4.2.1",
"file-loader": "^6.2.0",
"globby": "11.0.3",
"husky": "^6.0.0",
"inquirer": "^8.1.1",
"lint-staged": "^11.0.0",
"next-remote-watch": "^1.0.0",
"prettier": "^2.5.1",
"prettier-plugin-tailwindcss": "^0.1.4",
"socket.io": "^4.4.0",
"socket.io-client": "^4.4.0"
"husky": "^8.0.0",
"js-yaml": "4.1.0",
"lint-staged": "^13.0.0",
"prettier": "^2.8.8",
"prettier-plugin-tailwindcss": "^0.3.0",
"typescript": "^5.1.3"
},
"lint-staged": {
"*.+(js|jsx|ts|tsx)": [

View File

@ -1,31 +0,0 @@
import '@/css/tailwind.css'
import '@/css/prism.css'
import 'katex/dist/katex.css'
import '@fontsource/inter/variable-full.css'
import { ThemeProvider } from 'next-themes'
import Head from 'next/head'
import siteMetadata from '@/data/siteMetadata'
import Analytics from '@/components/analytics'
import LayoutWrapper from '@/components/LayoutWrapper'
import { ClientReload } from '@/components/ClientReload'
const isDevelopment = process.env.NODE_ENV === 'development'
const isSocket = process.env.SOCKET
export default function App({ Component, pageProps }) {
return (
<ThemeProvider attribute="class" defaultTheme={siteMetadata.theme}>
<Head>
<meta content="width=device-width, initial-scale=1" name="viewport" />
</Head>
{isDevelopment && isSocket && <ClientReload />}
<Analytics />
<LayoutWrapper>
<Component {...pageProps} />
</LayoutWrapper>
</ThemeProvider>
)
}

View File

@ -1,36 +0,0 @@
import Document, { Html, Head, Main, NextScript } from 'next/document'
class MyDocument extends Document {
render() {
return (
<Html lang="en" className="scroll-smooth">
<Head>
<link rel="apple-touch-icon" sizes="76x76" href="/static/favicons/apple-touch-icon.png" />
<link
rel="icon"
type="image/png"
sizes="32x32"
href="/static/favicons/favicon-32x32.png"
/>
<link
rel="icon"
type="image/png"
sizes="16x16"
href="/static/favicons/favicon-16x16.png"
/>
<link rel="manifest" href="/static/favicons/site.webmanifest" />
<link rel="mask-icon" href="/static/favicons/safari-pinned-tab.svg" color="#5bbad5" />
<meta name="msapplication-TileColor" content="#000000" />
<meta name="theme-color" media="(prefers-color-scheme: light)" content="#fff" />
<meta name="theme-color" media="(prefers-color-scheme: dark)" content="#000" />
<link rel="alternate" type="application/rss+xml" href="/feed.xml" />
</Head>
<body className="bg-white text-black antialiased dark:bg-gray-900 dark:text-white">
<Main />
<NextScript />
</body>
</Html>
)
}
}
export default MyDocument

View File

@ -1,21 +0,0 @@
import { MDXLayoutRenderer } from '@/components/MDXComponents'
import { getFileBySlug } from '@/lib/mdx'
const DEFAULT_LAYOUT = 'AuthorLayout'
export async function getStaticProps() {
const authorDetails = await getFileBySlug('authors', ['default'])
return { props: { authorDetails } }
}
export default function About({ authorDetails }) {
const { mdxSource, frontMatter } = authorDetails
return (
<MDXLayoutRenderer
layout={frontMatter.layout || DEFAULT_LAYOUT}
mdxSource={mdxSource}
frontMatter={frontMatter}
/>
)
}

Some files were not shown because too many files have changed in this diff Show More