typescript. Close #1. #2

Merged
Ivan merged 4 commits from typescript into master 2022-10-07 14:43:11 +08:00
83 changed files with 667 additions and 502 deletions

View File

@ -12,55 +12,6 @@ Facing issues? Check the [FAQ page](https://github.com/timlrx/tailwind-nextjs-st
Feature request? Check the past discussions to see if it has been brought up previously. Otherwise, feel free to start a new discussion thread. All ideas are welcomed! Feature request? Check the past discussions to see if it has been brought up previously. Otherwise, feel free to start a new discussion thread. All ideas are welcomed!
## Examples
- [Demo Blog](https://tailwind-nextjs-starter-blog.vercel.app/) - this repo
- [My personal blog](https://www.timlrx.com) - modified to auto-generate blog posts with dates
- [Aloisdg's cookbook](https://tambouille.vercel.app/) - with pictures and recipes!
- [GautierArcin's demo with next translate](https://tailwind-nextjs-starter-blog-seven.vercel.app/) - includes translation of mdx posts, [source code](https://github.com/GautierArcin/tailwind-nextjs-starter-blog/tree/demo/next-translate)
- [David Levai's digital garden](https://davidlevai.com/) - customized design and added email subscriptions
- [Thinh's Corner](https://thinhcorner.com/) - [customized layout](https://github.com/Th1nhNg0/th1nhng0.vercel.app/blob/5e73a420828d82f01e7147512a2c3273c4ec19f8/layouts/PostLayout.js) with sticky side table of contents
- [Leo's Blog](https://leohuynh.dev) - Tuan Anh Huynh's personal site. Add Snippets Page, Author Profile Card, Image Lightbox, ...
- [thvu.dev](https://thvu.dev) - Added `mdx-embed`, view count, reading minutes and more.
- [fiqrychoerudin.dev](https://www.fiqrychoerudin.dev/) - simple portfolio.
- [irvin.dev](https://www.irvin.dev/) - Irvin Lin's personal site. Added YouTube embedding.
- [the all JavaScript Blog](https://the-all-javascript-blog.vercel.app/) - a JavaScript enlightenment blog uses this
- [KirillSo.com](https://www.kirillso.com/) - Personal blog & website.
- [ghali.dev](https://ghali.dev) - Cyril's Blog
- [DevBoy Blog](https://devboy.vercel.app/) - M.Reza's personal blog
- [slightlysharpe.com](https://slightlysharpe.com) - [Tincre's](https://tincre.com) main company blog
- [blog.b00st.com](https://blog.b00st.com) - [b00st.com's](https://b00st.com) main music promotion blog
- [astrosaurus.me](https://astrosaurus.me/) - Ephraim Atta-Duncan's Personal Blog
- [dhanrajsp.me](https://dhanrajsp.me/) - Dhanraj's personal site and blog.
- [blog.r00ks.io](https://blog.r00ks.io/) - Austin Rooks's personal blog ([source code](https://github.com/Austionian/blog.r00ks)).
- [honghong.me](https://honghong.me) - Tszhong's personal website ([source code](https://github.com/tszhong0411/home))
- [alfoncode.com](https://alfoncode.com) - Alfonso García's personar website. Customized design ([source code](https://github.com/alfoncode/personal-web))
- [marceloformentao.dev](https://marceloformentao.dev) - Marcelo Formentão personal website ([source code](https://github.com/marceloavf/marceloformentao.dev)).
- [abiraja.com](https://www.abiraja.com/) - with a [runnable JS code snippet component!](https://www.abiraja.com/blog/querying-solana-blockchain)
- [einargudni.com](https://www.einargudni.com) - with a customized theme, command pallette and more ([source code](https://github.com/einargudnig/einargudni.com))
- [bpiggin.com](https://www.bpiggin.com) - Ben Piggin's personal blog
- [maqib.cn](https://maqib.cn) - A blog of Chinese front-end developers 狂奔小马的博客 ([源码](https://github.com/maqi1520/nextjs-tailwind-blog))
- [ambilena.com](https://ambilena.com/) - Electronic Music Blog & imprint for upcoming musicians.
- [kittan.ru](https://www.kittan.ru/) - Kittanb's personal blog about linux ([source code](https://github.com/kittanb/blog))
- [nchristopher.me](https://nchristopher.me) - Nicholas Christopher's personal website and blog ([source code](https://github.com/nchristopher/blog))
- [dalelarroder.com](https://dalelarroder.com) - Dale Larroder's personal website and blog ([source code](https://github.com/dlarroder/dalelarroder))
- [devahoy.com](https://devahoy.com) - Chai's personal blog (Thai language)
- [0xchai.io](https://0xchai.io) - Chai's personal blog
- [techipedia](https://techipedia.vercel.app) - Simple blogging progressive web app with custom installation button and top progress bar
- [reubence.com](https://reubence.com) - Reuben Rapose's Digital Garden
- [axolo.co/blog](https://axolo.co/blog) - Engineering management news & axolo.co updates (with image preview for article in the home page)
- [musing.vercel.app](https://musing.vercel.app/) - Parth Desai's personal blog ([source code](https://github.com/pycoder2000/blog))
- [onyourmental.com](https://www.onyourmental.com/) - [Curtis Warcup's](https://github.com/Cwarcup) website for the On Your Mental Podcast ([source code](https://github.com/Cwarcup/on-your-mental))
- [cwarcup.com](https://www.cwarcup.com/) - Curtis Warcup's personal website and blog ([source code](https://github.com/Cwarcup/personal-blog).
Using the template? Feel free to create a PR and add your blog to this list.
## Motivation
I wanted to port my existing blog to Nextjs and Tailwind CSS but there was no easy out of the box template to use so I decided to create one. Design is adapted from [Tailwindlabs blog](https://github.com/tailwindlabs/blog.tailwindcss.com).
I wanted it to be nearly as feature-rich as popular blogging templates like [beautiful-jekyll](https://github.com/daattali/beautiful-jekyll) and [Hugo Academic](https://github.com/wowchemy/wowchemy-hugo-modules) but with the best of React's ecosystem and current web development's best practices.
## Features ## Features
- Easy styling customization with [Tailwind 3.0](https://tailwindcss.com/blog/tailwindcss-v3) and primary color attribute - Easy styling customization with [Tailwind 3.0](https://tailwindcss.com/blog/tailwindcss-v3) and primary color attribute
@ -228,10 +179,10 @@ Follow the interactive prompt to generate a post with pre-filled front matter.
## Deploy ## Deploy
**Vercel** **Vercel**
The easiest way to deploy the template is to use the [Vercel Platform](https://vercel.com) from the creators of Next.js. Check out the [Next.js deployment documentation](https://nextjs.org/docs/deployment) for more details. The easiest way to deploy the template is to use the [Vercel Platform](https://vercel.com) from the creators of Next.js. Check out the [Next.js deployment documentation](https://nextjs.org/docs/deployment) for more details.
**Netlify / GitHub Pages / Firebase etc.** **Netlify / GitHub Pages / Firebase etc.**
As the template uses `next/image` for image optimization, additional configurations have to be made to deploy on other popular static hosting websites like [Netlify](https://www.netlify.com/) or [GitHub Pages](https://pages.github.com/). An alternative image optimization provider such as Imgix, Cloudinary or Akamai has to be used. Alternatively, replace the `next/image` component with a standard `<img>` tag. See [`next/image` documentation](https://nextjs.org/docs/basic-features/image-optimization) for more details. As the template uses `next/image` for image optimization, additional configurations have to be made to deploy on other popular static hosting websites like [Netlify](https://www.netlify.com/) or [GitHub Pages](https://pages.github.com/). An alternative image optimization provider such as Imgix, Cloudinary or Akamai has to be used. Alternatively, replace the `next/image` component with a standard `<img>` tag. See [`next/image` documentation](https://nextjs.org/docs/basic-features/image-optimization) for more details.
The API routes used in the newsletter component cannot be used in a static site export. You will need to use a form API endpoint provider and substitute the route in the newsletter component accordingly. Other hosting platforms such as Netlify also offer alternative solutions - please refer to their docs for more information. The API routes used in the newsletter component cannot be used in a static site export. You will need to use a form API endpoint provider and substitute the route in the newsletter component accordingly. Other hosting platforms such as Netlify also offer alternative solutions - please refer to their docs for more information.
@ -240,6 +191,6 @@ The API routes used in the newsletter component cannot be used in a static site
Using the template? Support this effort by giving a star on GitHub, sharing your own blog and giving a shoutout on Twitter or becoming a project [sponsor](https://github.com/sponsors/timlrx). Using the template? Support this effort by giving a star on GitHub, sharing your own blog and giving a shoutout on Twitter or becoming a project [sponsor](https://github.com/sponsors/timlrx).
## Licence ## License
[MIT](https://github.com/timlrx/tailwind-nextjs-starter-blog/blob/master/LICENSE) © [Timothy Lin](https://www.timlrx.com) [MIT](https://github.com/timlrx/tailwind-nextjs-starter-blog/blob/master/LICENSE) © [Timothy Lin](https://www.timlrx.com)

View File

@ -11,7 +11,7 @@ export const ClientReload = () => {
useEffect(() => { useEffect(() => {
import('socket.io-client').then((module) => { import('socket.io-client').then((module) => {
const socket = module.io() const socket = module.io()
socket.on('reload', (data) => { socket.on('reload', () => {
Router.replace(Router.asPath, undefined, { Router.replace(Router.asPath, undefined, {
scroll: false, scroll: false,
}) })

View File

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

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

@ -6,8 +6,13 @@ import SectionContainer from './SectionContainer'
import Footer from './Footer' import Footer from './Footer'
import MobileNav from './MobileNav' import MobileNav from './MobileNav'
import ThemeSwitch from './ThemeSwitch' import ThemeSwitch from './ThemeSwitch'
import { ReactNode } from 'react'
const LayoutWrapper = ({ children }) => { interface Props {
children: ReactNode
}
const LayoutWrapper = ({ children }: Props) => {
return ( return (
<SectionContainer> <SectionContainer>
<div className="flex h-screen flex-col justify-between"> <div className="flex h-screen flex-col justify-between">

View File

@ -1,7 +1,11 @@
/* eslint-disable jsx-a11y/anchor-has-content */ /* eslint-disable jsx-a11y/anchor-has-content */
import Link from 'next/link' 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 isInternalLink = href && href.startsWith('/')
const isAnchorLink = href && href.startsWith('#') const isAnchorLink = href && href.startsWith('#')

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,36 @@
/* eslint-disable react/display-name */
import React, { useMemo } from 'react'
import { ComponentMap, 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'
const Wrapper: React.ComponentType<{ layout: string }> = ({ layout, ...rest }) => {
const Layout = require(`../layouts/${layout}`).default
return <Layout {...rest} />
}
export const MDXComponents: ComponentMap = {
Image,
//@ts-ignore
TOCInline,
a: CustomLink,
pre: Pre,
wrapper: Wrapper,
//@ts-ignore
BlogNewsletterForm,
}
interface Props {
layout: string
mdxSource: string
[key: string]: unknown
}
export const MDXLayoutRenderer = ({ layout, mdxSource, ...rest }: Props) => {
const MDXLayout = useMemo(() => getMDXComponent(mdxSource), [mdxSource])
return <MDXLayout layout={layout} components={MDXComponents} {...rest} />
}

View File

@ -31,39 +31,32 @@ const MobileNav = () => {
fill="currentColor" fill="currentColor"
className="text-gray-900 dark:text-gray-100" className="text-gray-900 dark:text-gray-100"
> >
<path {navShow ? (
fillRule="evenodd" <path
d="M3 5a1 1 0 011-1h12a1 1 0 110 2H4a1 1 0 01-1-1zM3 10a1 1 0 011-1h12a1 1 0 110 2H4a1 1 0 01-1-1zM3 15a1 1 0 011-1h12a1 1 0 110 2H4a1 1 0 01-1-1z" fillRule="evenodd"
clipRule="evenodd" d="M4.293 4.293a1 1 0 011.414 0L10 8.586l4.293-4.293a1 1 0 111.414 1.414L11.414 10l4.293 4.293a1 1 0 01-1.414 1.414L10 11.414l-4.293 4.293a1 1 0 01-1.414-1.414L8.586 10 4.293 5.707a1 1 0 010-1.414z"
/> clipRule="evenodd"
/>
) : (
<path
fillRule="evenodd"
d="M3 5a1 1 0 011-1h12a1 1 0 110 2H4a1 1 0 01-1-1zM3 10a1 1 0 011-1h12a1 1 0 110 2H4a1 1 0 01-1-1zM3 15a1 1 0 011-1h12a1 1 0 110 2H4a1 1 0 01-1-1z"
clipRule="evenodd"
/>
)}
</svg> </svg>
</button> </button>
<div <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 top-24 right-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' navShow ? 'translate-x-0' : 'translate-x-full'
}`} }`}
> >
<div className="flex justify-end"> <button
<button type="button"
type="button" aria-label="toggle modal"
className="mr-5 mt-11 h-8 w-8 rounded" className="fixed h-full w-full cursor-auto focus:outline-none"
aria-label="Toggle Menu" onClick={onToggleNav}
onClick={onToggleNav} ></button>
>
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 20 20"
fill="currentColor"
className="text-gray-900 dark:text-gray-100"
>
<path
fillRule="evenodd"
d="M4.293 4.293a1 1 0 011.414 0L10 8.586l4.293-4.293a1 1 0 111.414 1.414L11.414 10l4.293 4.293a1 1 0 01-1.414 1.414L10 11.414l-4.293 4.293a1 1 0 01-1.414-1.414L8.586 10 4.293 5.707a1 1 0 010-1.414z"
clipRule="evenodd"
/>
</svg>
</button>
</div>
<nav className="fixed mt-8 h-full"> <nav className="fixed mt-8 h-full">
{headerNavLinks.map((link) => ( {headerNavLinks.map((link) => (
<div key={link.title} className="px-12 py-4"> <div key={link.title} className="px-12 py-4">

View File

@ -1,14 +1,14 @@
import { useRef, useState } from 'react' import React, { useRef, useState } from 'react'
import siteMetadata from '@/data/siteMetadata' import siteMetadata from '@/data/siteMetadata'
const NewsletterForm = ({ title = 'Subscribe to the newsletter' }) => { const NewsletterForm = ({ title = 'Subscribe to the newsletter' }) => {
const inputEl = useRef(null) const inputEl = useRef<HTMLInputElement>(null)
const [error, setError] = useState(false) const [error, setError] = useState(false)
const [message, setMessage] = useState('') const [message, setMessage] = useState('')
const [subscribed, setSubscribed] = useState(false) const [subscribed, setSubscribed] = useState(false)
const subscribe = async (e) => { const subscribe = async (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault() e.preventDefault()
const res = await fetch(`/api/${siteMetadata.newsletter.provider}`, { const res = await fetch(`/api/${siteMetadata.newsletter.provider}`, {

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 ( 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"> <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} {children}

View File

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

View File

@ -1,6 +1,10 @@
import { useState, useRef } from 'react' import { useState, useRef, ReactNode } from 'react'
const Pre = (props) => { interface Props {
children: ReactNode
}
const Pre = ({ children }: Props) => {
const textInput = useRef(null) const textInput = useRef(null)
const [hovered, setHovered] = useState(false) const [hovered, setHovered] = useState(false)
const [copied, setCopied] = useState(false) const [copied, setCopied] = useState(false)
@ -63,7 +67,7 @@ const Pre = (props) => {
</button> </button>
)} )}
<pre>{props.children}</pre> <pre>{children}</pre>
</div> </div>
) )
} }

View File

@ -1,8 +1,31 @@
import Head from 'next/head' import Head from 'next/head'
import { useRouter } from 'next/router' import { useRouter } from 'next/router'
import siteMetadata from '@/data/siteMetadata' import siteMetadata from '@/data/siteMetadata'
import { AuthorFrontMatter } from 'types/AuthorFrontMatter'
import { PostFrontMatter } from 'types/PostFrontMatter'
const CommonSEO = ({ title, description, ogType, ogImage, twImage, canonicalUrl }) => { 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,
}: CommonSEOProps) => {
const router = useRouter() const router = useRouter()
return ( return (
<Head> <Head>
@ -14,7 +37,7 @@ const CommonSEO = ({ title, description, ogType, ogImage, twImage, canonicalUrl
<meta property="og:site_name" content={siteMetadata.title} /> <meta property="og:site_name" content={siteMetadata.title} />
<meta property="og:description" content={description} /> <meta property="og:description" content={description} />
<meta property="og:title" content={title} /> <meta property="og:title" content={title} />
{ogImage.constructor.name === 'Array' ? ( {Array.isArray(ogImage) ? (
ogImage.map(({ url }) => <meta property="og:image" content={url} key={url} />) ogImage.map(({ url }) => <meta property="og:image" content={url} key={url} />)
) : ( ) : (
<meta property="og:image" content={ogImage} key={ogImage} /> <meta property="og:image" content={ogImage} key={ogImage} />
@ -32,7 +55,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 ogImageUrl = siteMetadata.siteUrl + siteMetadata.socialBanner
const twImageUrl = siteMetadata.siteUrl + siteMetadata.socialBanner const twImageUrl = siteMetadata.siteUrl + siteMetadata.socialBanner
return ( return (
@ -46,7 +74,7 @@ export const PageSEO = ({ title, description }) => {
) )
} }
export const TagSEO = ({ title, description }) => { export const TagSEO = ({ title, description }: PageSEOProps) => {
const ogImageUrl = siteMetadata.siteUrl + siteMetadata.socialBanner const ogImageUrl = siteMetadata.siteUrl + siteMetadata.socialBanner
const twImageUrl = siteMetadata.siteUrl + siteMetadata.socialBanner const twImageUrl = siteMetadata.siteUrl + siteMetadata.socialBanner
const router = useRouter() const router = useRouter()
@ -71,6 +99,11 @@ export const TagSEO = ({ title, description }) => {
) )
} }
interface BlogSeoProps extends PostFrontMatter {
authorDetails?: AuthorFrontMatter[]
url: string
}
export const BlogSEO = ({ export const BlogSEO = ({
authorDetails, authorDetails,
title, title,
@ -80,11 +113,10 @@ export const BlogSEO = ({
url, url,
images = [], images = [],
canonicalUrl, canonicalUrl,
}) => { }: BlogSeoProps) => {
const router = useRouter()
const publishedAt = new Date(date).toISOString() const publishedAt = new Date(date).toISOString()
const modifiedAt = new Date(lastmod || date).toISOString() const modifiedAt = new Date(lastmod || date).toISOString()
let imagesArr = const imagesArr =
images.length === 0 images.length === 0
? [siteMetadata.socialBanner] ? [siteMetadata.socialBanner]
: typeof images === 'string' : typeof images === 'string'
@ -94,7 +126,7 @@ export const BlogSEO = ({
const featuredImages = imagesArr.map((img) => { const featuredImages = imagesArr.map((img) => {
return { return {
'@type': 'ImageObject', '@type': 'ImageObject',
url: img.includes('http') ? img : siteMetadata.siteUrl + img, url: `${siteMetadata.siteUrl}${img}`,
} }
}) })

View File

@ -1,4 +1,3 @@
import siteMetadata from '@/data/siteMetadata'
import { useEffect, useState } from 'react' import { useEffect, useState } from 'react'
const ScrollTopAndComment = () => { const ScrollTopAndComment = () => {
@ -15,7 +14,7 @@ const ScrollTopAndComment = () => {
}, []) }, [])
const handleScrollTop = () => { const handleScrollTop = () => {
window.scrollTo({ top: 0 }) window.scrollTo({ top: 0, behavior: 'smooth' })
} }
const handleScrollToComment = () => { const handleScrollToComment = () => {
document.getElementById('comment').scrollIntoView() document.getElementById('comment').scrollIntoView()
@ -24,22 +23,20 @@ const ScrollTopAndComment = () => {
<div <div
className={`fixed right-8 bottom-8 hidden flex-col gap-3 ${show ? 'md:flex' : 'md:hidden'}`} className={`fixed right-8 bottom-8 hidden flex-col gap-3 ${show ? 'md:flex' : 'md:hidden'}`}
> >
{siteMetadata.comment.provider && ( <button
<button aria-label="Scroll To Comment"
aria-label="Scroll To Comment" type="button"
type="button" onClick={handleScrollToComment}
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"
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" >
> <svg className="h-5 w-5" viewBox="0 0 20 20" fill="currentColor">
<svg className="h-5 w-5" viewBox="0 0 20 20" fill="currentColor"> <path
<path fillRule="evenodd"
fillRule="evenodd" d="M18 10c0 3.866-3.582 7-8 7a8.841 8.841 0 01-4.083-.98L2 17l1.338-3.123C2.493 12.767 2 11.434 2 10c0-3.866 3.582-7 8-7s8 3.134 8 7zM7 9H5v2h2V9zm8 0h-2v2h2V9zM9 9h2v2H9V9z"
d="M18 10c0 3.866-3.582 7-8 7a8.841 8.841 0 01-4.083-.98L2 17l1.338-3.123C2.493 12.767 2 11.434 2 10c0-3.866 3.582-7 8-7s8 3.134 8 7zM7 9H5v2h2V9zm8 0h-2v2h2V9zM9 9h2v2H9V9z" clipRule="evenodd"
clipRule="evenodd" />
/> </svg>
</svg> </button>
</button>
)}
<button <button
aria-label="Scroll To Top" aria-label="Scroll To Top"
type="button" type="button"

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,9 @@
import { ReactNode } from 'react'
interface Props {
children: ReactNode
}
export default function SectionContainer({ children }: Props) {
return <div className="mx-auto max-w-3xl px-4 sm:px-6 xl:max-w-5xl xl:px-0">{children}</div>
}

View File

@ -1,23 +1,27 @@
/** import { Toc } from 'types/Toc'
* @typedef TocHeading
* @prop {string} value interface TOCInlineProps {
* @prop {number} depth toc: Toc
* @prop {string} url indentDepth?: number
*/ fromHeading?: number
toHeading?: number
asDisclosure?: boolean
exclude?: string | string[]
}
/** /**
* Generates an inline table of contents * Generates an inline table of contents
* Exclude titles matching this string (new RegExp('^(' + string + ')$', 'i')). * 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')). * If an array is passed the array gets joined with a pipe (new RegExp('^(' + array.join('|') + ')$', 'i')).
* *
* @param {{ * @param {TOCInlineProps} {
* toc: TocHeading[], * toc,
* indentDepth?: number, * indentDepth = 3,
* fromHeading?: number, * fromHeading = 1,
* toHeading?: number, * toHeading = 6,
* asDisclosure?: boolean, * asDisclosure = false,
* exclude?: string|string[] * exclude = '',
* }} props * }
* *
*/ */
const TOCInline = ({ const TOCInline = ({
@ -27,7 +31,7 @@ const TOCInline = ({
toHeading = 6, toHeading = 6,
asDisclosure = false, asDisclosure = false,
exclude = '', exclude = '',
}) => { }: TOCInlineProps) => {
const re = Array.isArray(exclude) const re = Array.isArray(exclude)
? new RegExp('^(' + exclude.join('|') + ')$', 'i') ? new RegExp('^(' + exclude.join('|') + ')$', 'i')
: new RegExp('^(' + exclude + ')$', 'i') : new RegExp('^(' + exclude + ')$', 'i')

View File

@ -1,7 +1,11 @@
import Link from 'next/link' import Link from 'next/link'
import kebabCase from '@/lib/utils/kebabCase' import kebabCase from '@/lib/utils/kebabCase'
const Tag = ({ text }) => { interface Props {
text: string
}
const Tag = ({ text }: Props) => {
return ( return (
<Link href={`/tags/${kebabCase(text)}`}> <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"> <a className="mr-3 text-sm font-medium uppercase text-primary-500 hover:text-primary-600 dark:hover:text-primary-400">

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

@ -2,9 +2,16 @@ import GA from './GoogleAnalytics'
import Plausible from './Plausible' import Plausible from './Plausible'
import SimpleAnalytics from './SimpleAnalytics' import SimpleAnalytics from './SimpleAnalytics'
import Umami from './Umami' import Umami from './Umami'
import Posthog from './Posthog'
import siteMetadata from '@/data/siteMetadata' import siteMetadata from '@/data/siteMetadata'
declare global {
interface Window {
gtag?: (...args: any[]) => void
plausible?: (...args: any[]) => void
sa_event?: (...args: any[]) => void
}
}
const isProduction = process.env.NODE_ENV === 'production' const isProduction = process.env.NODE_ENV === 'production'
const Analytics = () => { const Analytics = () => {
@ -14,7 +21,6 @@ const Analytics = () => {
{isProduction && siteMetadata.analytics.simpleAnalytics && <SimpleAnalytics />} {isProduction && siteMetadata.analytics.simpleAnalytics && <SimpleAnalytics />}
{isProduction && siteMetadata.analytics.umamiWebsiteId && <Umami />} {isProduction && siteMetadata.analytics.umamiWebsiteId && <Umami />}
{isProduction && siteMetadata.analytics.googleAnalyticsId && <GA />} {isProduction && siteMetadata.analytics.googleAnalyticsId && <GA />}
{isProduction && siteMetadata.analytics.posthogAnalyticsId && <Posthog />}
</> </>
) )
} }

View File

@ -1,8 +1,13 @@
import React, { useState } from 'react' import React, { useState } from 'react'
import siteMetadata from '@/data/siteMetadata' import siteMetadata from '@/data/siteMetadata'
import { PostFrontMatter } from 'types/PostFrontMatter'
const Disqus = ({ frontMatter }) => { interface Props {
frontMatter: PostFrontMatter
}
const Disqus = ({ frontMatter }: Props) => {
const [enableLoadComments, setEnabledLoadComments] = useState(true) const [enableLoadComments, setEnabledLoadComments] = useState(true)
const COMMENTS_ID = 'disqus_thread' const COMMENTS_ID = 'disqus_thread'
@ -10,18 +15,22 @@ const Disqus = ({ frontMatter }) => {
function LoadComments() { function LoadComments() {
setEnabledLoadComments(false) setEnabledLoadComments(false)
// @ts-ignore
window.disqus_config = function () { window.disqus_config = function () {
this.page.url = window.location.href this.page.url = window.location.href
this.page.identifier = frontMatter.slug this.page.identifier = frontMatter.slug
} }
// @ts-ignore
if (window.DISQUS === undefined) { if (window.DISQUS === undefined) {
const script = document.createElement('script') const script = document.createElement('script')
script.src = 'https://' + siteMetadata.comment.disqusConfig.shortname + '.disqus.com/embed.js' script.src = 'https://' + siteMetadata.comment.disqusConfig.shortname + '.disqus.com/embed.js'
// @ts-ignore
script.setAttribute('data-timestamp', +new Date()) script.setAttribute('data-timestamp', +new Date())
script.setAttribute('crossorigin', 'anonymous') script.setAttribute('crossorigin', 'anonymous')
script.async = true script.async = true
document.body.appendChild(script) document.body.appendChild(script)
} else { } else {
// @ts-ignore
window.DISQUS.reset({ reload: true }) window.DISQUS.reset({ reload: true })
} }
} }

View File

@ -3,7 +3,11 @@ import { useTheme } from 'next-themes'
import siteMetadata from '@/data/siteMetadata' import siteMetadata from '@/data/siteMetadata'
const Giscus = () => { interface Props {
mapping: string
}
const Giscus = ({ mapping }: Props) => {
const [enableLoadComments, setEnabledLoadComments] = useState(true) const [enableLoadComments, setEnabledLoadComments] = useState(true)
const { theme, resolvedTheme } = useTheme() const { theme, resolvedTheme } = useTheme()
const commentsTheme = const commentsTheme =
@ -17,30 +21,15 @@ const Giscus = () => {
const LoadComments = useCallback(() => { const LoadComments = useCallback(() => {
setEnabledLoadComments(false) setEnabledLoadComments(false)
const {
repo,
repositoryId,
category,
categoryId,
mapping,
reactions,
metadata,
inputPosition,
lang,
} = siteMetadata?.comment?.giscusConfig
const script = document.createElement('script') const script = document.createElement('script')
script.src = 'https://giscus.app/client.js' script.src = 'https://giscus.app/client.js'
script.setAttribute('data-repo', repo) script.setAttribute('data-repo', siteMetadata.comment.giscusConfig.repo)
script.setAttribute('data-repo-id', repositoryId) script.setAttribute('data-repo-id', siteMetadata.comment.giscusConfig.repositoryId)
script.setAttribute('data-category', category) script.setAttribute('data-category', siteMetadata.comment.giscusConfig.category)
script.setAttribute('data-category-id', categoryId) script.setAttribute('data-category-id', siteMetadata.comment.giscusConfig.categoryId)
script.setAttribute('data-mapping', mapping) script.setAttribute('data-mapping', mapping)
script.setAttribute('data-reactions-enabled', reactions) script.setAttribute('data-reactions-enabled', siteMetadata.comment.giscusConfig.reactions)
script.setAttribute('data-emit-metadata', metadata) script.setAttribute('data-emit-metadata', siteMetadata.comment.giscusConfig.metadata)
script.setAttribute('data-input-position', inputPosition)
script.setAttribute('data-lang', lang)
script.setAttribute('data-theme', commentsTheme) script.setAttribute('data-theme', commentsTheme)
script.setAttribute('crossorigin', 'anonymous') script.setAttribute('crossorigin', 'anonymous')
script.async = true script.async = true
@ -52,7 +41,7 @@ const Giscus = () => {
const comments = document.getElementById(COMMENTS_ID) const comments = document.getElementById(COMMENTS_ID)
if (comments) comments.innerHTML = '' if (comments) comments.innerHTML = ''
} }
}, [commentsTheme]) }, [commentsTheme, mapping])
// Reload on theme change // Reload on theme change
useEffect(() => { useEffect(() => {

View File

@ -3,7 +3,11 @@ import { useTheme } from 'next-themes'
import siteMetadata from '@/data/siteMetadata' import siteMetadata from '@/data/siteMetadata'
const Utterances = () => { interface Props {
issueTerm: string
}
const Utterances = ({ issueTerm }: Props) => {
const [enableLoadComments, setEnabledLoadComments] = useState(true) const [enableLoadComments, setEnabledLoadComments] = useState(true)
const { theme, resolvedTheme } = useTheme() const { theme, resolvedTheme } = useTheme()
const commentsTheme = const commentsTheme =
@ -18,7 +22,7 @@ const Utterances = () => {
const script = document.createElement('script') const script = document.createElement('script')
script.src = 'https://utteranc.es/client.js' script.src = 'https://utteranc.es/client.js'
script.setAttribute('repo', siteMetadata.comment.utterancesConfig.repo) script.setAttribute('repo', siteMetadata.comment.utterancesConfig.repo)
script.setAttribute('issue-term', siteMetadata.comment.utterancesConfig.issueTerm) script.setAttribute('issue-term', issueTerm)
script.setAttribute('label', siteMetadata.comment.utterancesConfig.label) script.setAttribute('label', siteMetadata.comment.utterancesConfig.label)
script.setAttribute('theme', commentsTheme) script.setAttribute('theme', commentsTheme)
script.setAttribute('crossorigin', 'anonymous') script.setAttribute('crossorigin', 'anonymous')
@ -31,7 +35,7 @@ const Utterances = () => {
const comments = document.getElementById(COMMENTS_ID) const comments = document.getElementById(COMMENTS_ID)
if (comments) comments.innerHTML = '' if (comments) comments.innerHTML = ''
} }
}, [commentsTheme]) }, [commentsTheme, issueTerm])
// Reload on theme change // Reload on theme change
useEffect(() => { useEffect(() => {

View File

@ -1,5 +1,10 @@
import siteMetadata from '@/data/siteMetadata' import siteMetadata from '@/data/siteMetadata'
import dynamic from 'next/dynamic' import dynamic from 'next/dynamic'
import { PostFrontMatter } from 'types/PostFrontMatter'
interface Props {
frontMatter: PostFrontMatter
}
const UtterancesComponent = dynamic( const UtterancesComponent = dynamic(
() => { () => {
@ -20,14 +25,29 @@ const DisqusComponent = dynamic(
{ ssr: false } { ssr: false }
) )
const Comments = ({ frontMatter }) => { const Comments = ({ frontMatter }: Props) => {
const comment = siteMetadata?.comment let term
if (!comment || Object.keys(comment).length === 0) return <></> switch (
siteMetadata.comment.giscusConfig.mapping ||
siteMetadata.comment.utterancesConfig.issueTerm
) {
case 'pathname':
term = frontMatter.slug
break
case 'url':
term = window.location.href
break
case 'title':
term = frontMatter.title
break
}
return ( return (
<div id="comment"> <div id="comment">
{siteMetadata.comment && siteMetadata.comment.provider === 'giscus' && <GiscusComponent />} {siteMetadata.comment && siteMetadata.comment.provider === 'giscus' && (
<GiscusComponent mapping={term} />
)}
{siteMetadata.comment && siteMetadata.comment.provider === 'utterances' && ( {siteMetadata.comment && siteMetadata.comment.provider === 'utterances' && (
<UtterancesComponent /> <UtterancesComponent issueTerm={term} />
)} )}
{siteMetadata.comment && siteMetadata.comment.provider === 'disqus' && ( {siteMetadata.comment && siteMetadata.comment.provider === 'disqus' && (
<DisqusComponent frontMatter={frontMatter} /> <DisqusComponent frontMatter={frontMatter} />

View File

@ -32,7 +32,7 @@
} }
.highlight-line { .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 { .line-number::before {

View File

@ -1,18 +0,0 @@
const projectsData = [
{
title: 'UPS',
description: `一个不间断电源UPS的全栈项目。核心硬件使用乐鑫×安信可的 ESP32-C3-32S 模块作为主控,软件部分使用了 Rust + ESP-IDF 开发。`,
// imgSrc: '/static/images/google.png',
href: 'https://git.ivanli.cc/Ivan/ups-esp32c3-rust',
},
// {
// title: 'The Time Machine',
// description: `Imagine being able to travel back in time or to the future. Simple turn the knob
// to the desired date and press "Go". No more worrying about lost keys or
// forgotten headphones with this simple yet affordable solution.`,
// imgSrc: '/static/images/time-machine.jpg',
// href: '/blog/the-time-machine',
// },
]
export default projectsData

17
data/projectsData.ts Normal file
View File

@ -0,0 +1,17 @@
interface ProjectData {
title?: string
description?: string
imgSrc?: string
href?: string
}
const projectsData: ProjectData[] = [
{
title: 'UPS',
description: `一个不间断电源UPS的全栈项目。核心硬件使用乐鑫×安信可的 ESP32-C3-32S 模块作为主控,软件部分使用了 Rust + ESP-IDF 开发。`,
// imgSrc: '/static/images/google.png',
href: 'https://git.ivanli.cc/Ivan/ups-esp32c3-rust',
},
]
export default projectsData

View File

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

View File

@ -33,14 +33,14 @@ const siteMetadata = {
newsletter: { newsletter: {
// supports mailchimp, buttondown, convertkit, klaviyo, revue, emailoctopus // supports mailchimp, buttondown, convertkit, klaviyo, revue, emailoctopus
// Please add your .env file and modify it according to your selection // Please add your .env file and modify it according to your selection
provider: 'buttondown', provider: '',
}, },
comment: { comment: {
// If you want to use a commenting system other than giscus you have to add it to the // If you want to use a commenting system other than giscus you have to add it to the
// content security policy in the `next.config.js` file. // content security policy in the `next.config.js` file.
// Select a provider and use the environment variables associated to it // Select a provider and use the environment variables associated to it
// https://vercel.com/docs/environment-variables // https://vercel.com/docs/environment-variables
provider: 'giscus', // supported providers: giscus, utterances, disqus provider: '', // supported providers: giscus, utterances, disqus
giscusConfig: { giscusConfig: {
// Visit the link below, and follow the steps in the 'configuration' section // Visit the link below, and follow the steps in the 'configuration' section
// https://giscus.app/ // https://giscus.app/
@ -55,10 +55,6 @@ const siteMetadata = {
// theme example: light, dark, dark_dimmed, dark_high_contrast // theme example: light, dark, dark_dimmed, dark_high_contrast
// transparent_dark, preferred_color_scheme, custom // transparent_dark, preferred_color_scheme, custom
theme: 'light', 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 // theme when dark mode
darkTheme: 'transparent_dark', darkTheme: 'transparent_dark',
// If the theme option above is set to 'custom` // If the theme option above is set to 'custom`

View File

@ -1,8 +1,15 @@
import SocialIcon from '@/components/social-icons' import SocialIcon from '@/components/social-icons'
import Image from '@/components/Image' import Image from '@/components/Image'
import { PageSEO } from '@/components/SEO' import { PageSEO } from '@/components/SEO'
import { ReactNode } from 'react'
import { AuthorFrontMatter } from 'types/AuthorFrontMatter'
export default function AuthorLayout({ children, frontMatter }) { interface Props {
children: ReactNode
frontMatter: AuthorFrontMatter
}
export default function AuthorLayout({ children, frontMatter }: Props) {
const { name, avatar, occupation, company, email, twitter, linkedin, github } = frontMatter const { name, avatar, occupation, company, email, twitter, linkedin, github } = frontMatter
return ( return (

View File

@ -1,11 +1,17 @@
import Link from '@/components/Link' import Link from '@/components/Link'
import Tag from '@/components/Tag' import Tag from '@/components/Tag'
import siteMetadata from '@/data/siteMetadata' import { ComponentProps, useState } from 'react'
import { useState } from 'react'
import Pagination from '@/components/Pagination' import Pagination from '@/components/Pagination'
import formatDate from '@/lib/utils/formatDate' import formatDate from '@/lib/utils/formatDate'
import { PostFrontMatter } from 'types/PostFrontMatter'
interface Props {
posts: PostFrontMatter[]
title: string
initialDisplayPosts?: PostFrontMatter[]
pagination?: ComponentProps<typeof Pagination>
}
export default function ListLayout({ posts, title, initialDisplayPosts = [], pagination }) { export default function ListLayout({ posts, title, initialDisplayPosts = [], pagination }: Props) {
const [searchValue, setSearchValue] = useState('') const [searchValue, setSearchValue] = useState('')
const filteredBlogPosts = posts.filter((frontMatter) => { const filteredBlogPosts = posts.filter((frontMatter) => {
const searchContent = frontMatter.title + frontMatter.summary + frontMatter.tags.join(' ') const searchContent = frontMatter.title + frontMatter.summary + frontMatter.tags.join(' ')

View File

@ -7,7 +7,9 @@ import Tag from '@/components/Tag'
import siteMetadata from '@/data/siteMetadata' import siteMetadata from '@/data/siteMetadata'
import Comments from '@/components/comments' import Comments from '@/components/comments'
import ScrollTopAndComment from '@/components/ScrollTopAndComment' import ScrollTopAndComment from '@/components/ScrollTopAndComment'
import { useMemo } from 'react' import { ReactNode, useMemo } from 'react'
import { PostFrontMatter } from 'types/PostFrontMatter'
import { AuthorFrontMatter } from 'types/AuthorFrontMatter'
const editUrl = (fileName) => `${siteMetadata.siteRepo}/raw/master/data/blog/${fileName}` const editUrl = (fileName) => `${siteMetadata.siteRepo}/raw/master/data/blog/${fileName}`
const discussUrl = (slug) => const discussUrl = (slug) =>
@ -30,9 +32,22 @@ const Copyright = () => (
</a> </a>
) )
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 }) { interface Props {
frontMatter: PostFrontMatter
authorDetails: AuthorFrontMatter[]
next?: { slug: string; title: string }
prev?: { slug: string; title: string }
children: ReactNode
}
export default function PostLayout({ frontMatter, authorDetails, next, prev, children }: Props) {
const { slug, fileName, date, title, images, tags } = frontMatter const { slug, fileName, date, title, images, tags } = frontMatter
const headerStyles = useMemo( const headerStyles = useMemo(
@ -123,9 +138,8 @@ export default function PostLayout({ frontMatter, authorDetails, next, prev, chi
</dl> </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="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 pt-10 pb-8 dark:prose-dark">{children}</div>
<div className="flex items-center pt-6 pb-6 text-sm text-gray-700 dark:text-gray-300"> <div className="flex items-center gap-4 pt-6 pb-6 text-sm text-gray-700 dark:text-gray-300">
<Copyright /> <Copyright />
{``}
<Link href={editUrl(fileName)}>{'View source'}</Link> <Link href={editUrl(fileName)}>{'View source'}</Link>
</div> </div>
<Comments frontMatter={frontMatter} /> <Comments frontMatter={frontMatter} />

View File

@ -6,13 +6,22 @@ import siteMetadata from '@/data/siteMetadata'
import formatDate from '@/lib/utils/formatDate' import formatDate from '@/lib/utils/formatDate'
import Comments from '@/components/comments' import Comments from '@/components/comments'
import ScrollTopAndComment from '@/components/ScrollTopAndComment' import ScrollTopAndComment from '@/components/ScrollTopAndComment'
import { ReactNode } from 'react'
import { PostFrontMatter } from 'types/PostFrontMatter'
export default function PostLayout({ frontMatter, authorDetails, next, prev, children }) { interface Props {
const { date, title } = frontMatter frontMatter: PostFrontMatter
children: ReactNode
next?: { slug: string; title: string }
prev?: { slug: string; title: string }
}
export default function PostLayout({ frontMatter, next, prev, children }: Props) {
const { slug, date, title } = frontMatter
return ( return (
<SectionContainer> <SectionContainer>
<BlogSEO url={`${siteMetadata.siteUrl}/blog/${frontMatter.slug}`} {...frontMatter} /> <BlogSEO url={`${siteMetadata.siteUrl}/blog/${slug}`} {...frontMatter} />
<ScrollTopAndComment /> <ScrollTopAndComment />
<article> <article>
<div> <div>

View File

@ -1,8 +1,9 @@
import { escape } from '@/lib/utils/htmlEscaper' import { escape } from '@/lib/utils/htmlEscaper'
import siteMetadata from '@/data/siteMetadata' import siteMetadata from '@/data/siteMetadata'
import { PostFrontMatter } from 'types/PostFrontMatter'
const generateRssItem = (post) => ` const generateRssItem = (post: PostFrontMatter) => `
<item> <item>
<guid>${siteMetadata.siteUrl}/blog/${post.slug}</guid> <guid>${siteMetadata.siteUrl}/blog/${post.slug}</guid>
<title>${escape(post.title)}</title> <title>${escape(post.title)}</title>
@ -14,7 +15,7 @@ const generateRssItem = (post) => `
</item> </item>
` `
const generateRss = (posts, page = 'feed.xml') => ` const generateRss = (posts: PostFrontMatter[], page = 'feed.xml') => `
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom"> <rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom">
<channel> <channel>
<title>${escape(siteMetadata.title)}</title> <title>${escape(siteMetadata.title)}</title>

View File

@ -3,8 +3,10 @@ import fs from 'fs'
import matter from 'gray-matter' import matter from 'gray-matter'
import path from 'path' import path from 'path'
import readingTime from 'reading-time' import readingTime from 'reading-time'
import { visit } from 'unist-util-visit'
import getAllFilesRecursively from './utils/files' import getAllFilesRecursively from './utils/files'
import { PostFrontMatter } from 'types/PostFrontMatter'
import { AuthorFrontMatter } from 'types/AuthorFrontMatter'
import { Toc } from 'types/Toc'
// Remark packages // Remark packages
import remarkGfm from 'remark-gfm' import remarkGfm from 'remark-gfm'
import remarkFootnotes from 'remark-footnotes' import remarkFootnotes from 'remark-footnotes'
@ -23,24 +25,24 @@ import rehypePresetMinify from 'rehype-preset-minify'
const root = process.cwd() const root = process.cwd()
export function getFiles(type) { export function getFiles(type: 'blog' | 'authors') {
const prefixPaths = path.join(root, 'data', type) const prefixPaths = path.join(root, 'data', type)
const files = getAllFilesRecursively(prefixPaths) const files = getAllFilesRecursively(prefixPaths)
// Only want to return blog/path and ignore root, replace is needed to work on Windows // 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, '/')) return files.map((file) => file.slice(prefixPaths.length + 1).replace(/\\/g, '/'))
} }
export function formatSlug(slug) { export function formatSlug(slug: string) {
return slug.replace(/\.(mdx|md)/, '') return slug.replace(/\.(mdx|md)/, '')
} }
export function dateSortDesc(a, b) { export function dateSortDesc(a: string, b: string) {
if (a > b) return -1 if (a > b) return -1
if (a < b) return 1 if (a < b) return 1
return 0 return 0
} }
export async function getFileBySlug(type, slug) { export async function getFileBySlug<T>(type: 'authors' | 'blog', slug: string | string[]) {
const mdxPath = path.join(root, 'data', type, `${slug}.mdx`) const mdxPath = path.join(root, 'data', type, `${slug}.mdx`)
const mdPath = path.join(root, 'data', type, `${slug}.md`) const mdPath = path.join(root, 'data', type, `${slug}.md`)
const source = fs.existsSync(mdxPath) const source = fs.existsSync(mdxPath)
@ -54,7 +56,7 @@ export async function getFileBySlug(type, slug) {
process.env.ESBUILD_BINARY_PATH = path.join(root, 'node_modules', 'esbuild', 'bin', 'esbuild') process.env.ESBUILD_BINARY_PATH = path.join(root, 'node_modules', 'esbuild', 'bin', 'esbuild')
} }
let toc = [] const toc: Toc = []
const { code, frontmatter } = await bundleMDX({ const { code, frontmatter } = await bundleMDX({
source, source,
@ -107,14 +109,14 @@ export async function getFileBySlug(type, slug) {
} }
} }
export async function getAllFilesFrontMatter(folder) { export async function getAllFilesFrontMatter(folder: 'blog') {
const prefixPaths = path.join(root, 'data', folder) const prefixPaths = path.join(root, 'data', folder)
const files = getAllFilesRecursively(prefixPaths) const files = getAllFilesRecursively(prefixPaths)
const allFrontMatter = [] const allFrontMatter: PostFrontMatter[] = []
files.forEach((file) => { files.forEach((file: string) => {
// Replace is needed to work on Windows // Replace is needed to work on Windows
const fileName = file.slice(prefixPaths.length + 1).replace(/\\/g, '/') const fileName = file.slice(prefixPaths.length + 1).replace(/\\/g, '/')
// Remove Unexpected File // Remove Unexpected File
@ -122,8 +124,9 @@ export async function getAllFilesFrontMatter(folder) {
return return
} }
const source = fs.readFileSync(file, 'utf8') const source = fs.readFileSync(file, 'utf8')
const { data: frontmatter } = matter(source) const matterFile = matter(source)
if (frontmatter.draft !== true) { const frontmatter = matterFile.data as AuthorFrontMatter | PostFrontMatter
if ('draft' in frontmatter && frontmatter.draft !== true) {
allFrontMatter.push({ allFrontMatter.push({
...frontmatter, ...frontmatter,
slug: formatSlug(fileName), slug: formatSlug(fileName),

View File

@ -1,8 +1,9 @@
import { Parent } from 'unist'
import { visit } from 'unist-util-visit' import { visit } from 'unist-util-visit'
export default function remarkCodeTitles() { export default function remarkCodeTitles() {
return (tree) => return (tree: Parent & { lang?: string }) =>
visit(tree, 'code', (node, index, parent) => { visit(tree, 'code', (node: Parent & { lang?: string }, index, parent: Parent) => {
const nodeLang = node.lang || '' const nodeLang = node.lang || ''
let language = '' let language = ''
let title = '' let title = ''

View File

@ -1,9 +1,12 @@
import { Parent } from 'unist'
import { VFile } from 'vfile'
import { visit } from 'unist-util-visit' import { visit } from 'unist-util-visit'
import { load } from 'js-yaml' import { load } from 'js-yaml'
export default function extractFrontmatter() { export default function extractFrontmatter() {
return (tree, file) => { return (tree: Parent, file: VFile) => {
visit(tree, 'yaml', (node, index, parent) => { visit(tree, 'yaml', (node: Parent) => {
//@ts-ignore
file.data.frontmatter = load(node.value) file.data.frontmatter = load(node.value)
}) })
} }

View File

@ -1,15 +1,24 @@
import { Parent, Node, Literal } from 'unist'
import { visit } from 'unist-util-visit' import { visit } from 'unist-util-visit'
import sizeOf from 'image-size' import sizeOf from 'image-size'
import fs from 'fs' import fs from 'fs'
type ImageNode = Parent & {
url: string
alt: string
name: string
attributes: (Literal & { name: string })[]
}
export default function remarkImgToJsx() { export default function remarkImgToJsx() {
return (tree) => { return (tree: Node) => {
visit( visit(
tree, tree,
// only visit p tags that contain an img element // only visit p tags that contain an img element
(node) => node.type === 'paragraph' && node.children.some((n) => n.type === 'image'), (node: Parent): node is Parent =>
(node) => { node.type === 'paragraph' && node.children.some((n) => n.type === 'image'),
const imageNode = node.children.find((n) => n.type === 'image') (node: Parent) => {
const imageNode = node.children.find((n) => n.type === 'image') as ImageNode
// only local files // only local files
if (fs.existsSync(`${process.cwd()}/public${imageNode.url}`)) { if (fs.existsSync(`${process.cwd()}/public${imageNode.url}`)) {

View File

@ -1,10 +1,12 @@
//@ts-nocheck
import { Parent } from 'unist'
import { visit } from 'unist-util-visit' import { visit } from 'unist-util-visit'
import { slug } from 'github-slugger' import { slug } from 'github-slugger'
import { toString } from 'mdast-util-to-string' import { toString } from 'mdast-util-to-string'
export default function remarkTocHeadings(options) { export default function remarkTocHeadings(options) {
return (tree) => return (tree: Parent) =>
visit(tree, 'heading', (node, index, parent) => { visit(tree, 'heading', (node) => {
const textContent = toString(node) const textContent = toString(node)
options.exportRef.push({ options.exportRef.push({
value: textContent, value: textContent,

View File

@ -1,3 +1,4 @@
import { PostFrontMatter } from 'types/PostFrontMatter'
import fs from 'fs' import fs from 'fs'
import matter from 'gray-matter' import matter from 'gray-matter'
import path from 'path' import path from 'path'
@ -6,14 +7,15 @@ import kebabCase from './utils/kebabCase'
const root = process.cwd() const root = process.cwd()
export async function getAllTags(type) { export async function getAllTags(type: 'blog' | 'authors') {
const files = await getFiles(type) const files = getFiles(type)
let tagCount = {} const tagCount: Record<string, number> = {}
// Iterate through each post, putting all found tags into `tags` // Iterate through each post, putting all found tags into `tags`
files.forEach((file) => { files.forEach((file) => {
const source = fs.readFileSync(path.join(root, 'data', type, file), 'utf8') const source = fs.readFileSync(path.join(root, 'data', type, file), 'utf8')
const { data } = matter(source) const matterFile = matter(source)
const data = matterFile.data as PostFrontMatter
if (data.tags && data.draft !== true) { if (data.tags && data.draft !== true) {
data.tags.forEach((tag) => { data.tags.forEach((tag) => {
const formattedTag = kebabCase(tag) const formattedTag = kebabCase(tag)

View File

@ -11,13 +11,13 @@ const flattenArray = (input) =>
const map = (fn) => (input) => input.map(fn) const map = (fn) => (input) => input.map(fn)
const walkDir = (fullPath) => { const walkDir = (fullPath: string) => {
return fs.statSync(fullPath).isFile() ? fullPath : getAllFilesRecursively(fullPath) return fs.statSync(fullPath).isFile() ? fullPath : getAllFilesRecursively(fullPath)
} }
const pathJoinPrefix = (prefix) => (extraPath) => path.join(prefix, extraPath) const pathJoinPrefix = (prefix: string) => (extraPath: string) => path.join(prefix, extraPath)
const getAllFilesRecursively = (folder) => const getAllFilesRecursively = (folder: string): string[] =>
pipe(fs.readdirSync, map(pipe(pathJoinPrefix(folder), walkDir)), flattenArray)(folder) pipe(fs.readdirSync, map(pipe(pathJoinPrefix(folder), walkDir)), flattenArray)(folder)
export default getAllFilesRecursively export default getAllFilesRecursively

View File

@ -1,7 +1,7 @@
import siteMetadata from '@/data/siteMetadata' import siteMetadata from '@/data/siteMetadata'
const formatDate = (date) => { const formatDate = (date: string) => {
const options = { const options: Intl.DateTimeFormatOptions = {
year: 'numeric', year: 'numeric',
month: 'long', month: 'long',
day: 'numeric', day: 'numeric',

View File

@ -1,7 +1,6 @@
const { replace } = '' const { replace } = ''
// escape // escape
const es = /&(?:amp|#38|lt|#60|gt|#62|apos|#39|quot|#34);/g
const ca = /[&<>'"]/g const ca = /[&<>'"]/g
const esca = { const esca = {
@ -11,7 +10,7 @@ const esca = {
"'": '&#39;', "'": '&#39;',
'"': '&quot;', '"': '&quot;',
} }
const pe = (m) => esca[m] const pe = (m: keyof typeof esca) => esca[m]
/** /**
* Safely escape HTML entities such as `&`, `<`, `>`, `"`, and `'`. * Safely escape HTML entities such as `&`, `<`, `>`, `"`, and `'`.
@ -20,4 +19,4 @@ const pe = (m) => esca[m]
* the input type is unexpected, except for boolean and numbers, * the input type is unexpected, except for boolean and numbers,
* converted as string. * converted as string.
*/ */
export const escape = (es) => replace.call(es, ca, pe) export const escape = (es: string): string => replace.call(es, ca, pe)

View File

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

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

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

View File

@ -52,9 +52,12 @@ const securityHeaders = [
}, },
] ]
/**
* @type {import('next/dist/next-server/server/config').NextConfig}
**/
module.exports = withBundleAnalyzer({ module.exports = withBundleAnalyzer({
reactStrictMode: true, reactStrictMode: true,
pageExtensions: ['js', 'jsx', 'md', 'mdx'], pageExtensions: ['ts', 'tsx', 'js', 'jsx', 'md', 'mdx'],
eslint: { eslint: {
dirs: ['pages', 'components', 'lib', 'layouts', 'scripts'], dirs: ['pages', 'components', 'lib', 'layouts', 'scripts'],
}, },
@ -70,6 +73,19 @@ module.exports = withBundleAnalyzer({
] ]
}, },
webpack: (config, { dev, isServer }) => { webpack: (config, { dev, isServer }) => {
config.module.rules.push({
test: /\.(png|jpe?g|gif|mp4)$/i,
use: [
{
loader: 'file-loader',
options: {
publicPath: '/_next',
name: 'static/media/[name].[hash].[ext]',
},
},
],
})
config.module.rules.push({ config.module.rules.push({
test: /\.svg$/, test: /\.svg$/,
use: ['@svgr/webpack'], use: ['@svgr/webpack'],

BIN
package-lock.json generated

Binary file not shown.

View File

@ -12,55 +12,61 @@
"prepare": "husky install" "prepare": "husky install"
}, },
"dependencies": { "dependencies": {
"@next/bundle-analyzer": "12.1.4", "@fontsource/inter": "4.5.13",
"@fontsource/inter": "4.5.2", "@mailchimp/mailchimp_marketing": "^3.0.78",
"@mailchimp/mailchimp_marketing": "^3.0.58", "@tailwindcss/forms": "^0.5.3",
"@tailwindcss/forms": "^0.4.0", "@tailwindcss/typography": "^0.5.7",
"@tailwindcss/typography": "^0.5.0", "autoprefixer": "^10.4.12",
"autoprefixer": "^10.4.0", "esbuild": "^0.14.0",
"esbuild": "^0.13.13", "github-slugger": "^1.4.0",
"github-slugger": "^1.3.0", "gray-matter": "^4.0.3",
"gray-matter": "^4.0.2", "image-size": "1.0.2",
"image-size": "1.0.0", "mdx-bundler": "^9.0.1",
"mdx-bundler": "^8.0.0", "next": "12.3.1",
"next": "12.1.4", "next-themes": "^0.2.1",
"next-themes": "^0.0.14", "postcss": "^8.4.17",
"postcss": "^8.4.5", "preact": "^10.11.1",
"preact": "^10.6.2", "react": "18.2.0",
"react": "17.0.2", "react-dom": "18.2.0",
"react-dom": "17.0.2", "reading-time": "1.5.0",
"reading-time": "1.3.0", "rehype-autolink-headings": "^6.1.1",
"rehype-autolink-headings": "^6.1.0",
"rehype-citation": "^0.4.0", "rehype-citation": "^0.4.0",
"rehype-katex": "^6.0.2", "rehype-katex": "^6.0.2",
"rehype-preset-minify": "6.0.0", "rehype-preset-minify": "6.0.0",
"rehype-prism-plus": "^1.1.3", "rehype-prism-plus": "^1.5.0",
"rehype-slug": "^5.0.0", "rehype-slug": "^5.0.1",
"remark-footnotes": "^4.0.1", "remark-footnotes": "^4.0.1",
"remark-gfm": "^3.0.1", "remark-gfm": "^3.0.1",
"remark-math": "^5.1.1", "remark-math": "^5.1.1",
"sharp": "^0.28.3", "sharp": "^0.31.1",
"tailwindcss": "^3.0.23", "smoothscroll-polyfill": "^0.4.4",
"unist-util-visit": "^4.0.0" "tailwindcss": "^3.1.8",
"unist-util-visit": "^4.1.1"
}, },
"devDependencies": { "devDependencies": {
"@svgr/webpack": "^6.1.2", "@next/bundle-analyzer": "12.3.1",
"@svgr/webpack": "^6.4.0",
"@types/react": "^18.0.21",
"@types/tailwindcss": "^3.0.11",
"@typescript-eslint/eslint-plugin": "^5.39.0",
"@typescript-eslint/parser": "^5.39.0",
"cross-env": "^7.0.3", "cross-env": "^7.0.3",
"dedent": "^0.7.0", "dedent": "^0.7.0",
"eslint": "^7.29.0", "eslint": "^8.24.0",
"eslint-config-next": "12.1.4", "eslint-config-next": "12.3.1",
"eslint-config-prettier": "^8.3.0", "eslint-config-prettier": "^8.5.0",
"eslint-plugin-prettier": "^3.3.1", "eslint-plugin-prettier": "^4.2.1",
"file-loader": "^6.0.0", "file-loader": "^6.2.0",
"globby": "11.0.3", "globby": "13.1.2",
"husky": "^6.0.0", "husky": "^8.0.1",
"inquirer": "^8.1.1", "inquirer": "^9.1.3",
"lint-staged": "^11.0.0", "lint-staged": "^13.0.3",
"next-remote-watch": "^1.0.0", "next-remote-watch": "^2.0.0",
"prettier": "^2.5.1", "prettier": "^2.7.1",
"prettier-plugin-tailwindcss": "^0.1.4", "prettier-plugin-tailwindcss": "^0.1.13",
"socket.io": "^4.4.0", "socket.io": "^4.5.2",
"socket.io-client": "^4.4.0" "socket.io-client": "^4.5.2",
"typescript": "^4.8.4"
}, },
"lint-staged": { "lint-staged": {
"*.+(js|jsx|ts|tsx)": [ "*.+(js|jsx|ts|tsx)": [

View File

@ -1,31 +0,0 @@
import Link from '@/components/Link'
import { PageSEO } from '@/components/SEO'
import siteMetadata from '@/data/siteMetadata'
export default function FourZeroFour() {
return (
<>
<PageSEO title={`Page Not Found - ${siteMetadata.title}`} />
<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">
<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>
</div>
<div className="max-w-md">
<p className="mb-4 text-xl font-bold leading-normal md:text-2xl">
Sorry we couldn't find this page.
</p>
<p className="mb-8">
But don't 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>
</div>
</div>
</>
)
}

24
pages/404.tsx Normal file
View File

@ -0,0 +1,24 @@
import Link from '@/components/Link'
export default function FourZeroFour() {
return (
<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">
<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>
</div>
<div className="max-w-md">
<p className="mb-4 text-xl font-bold leading-normal md:text-2xl">
Sorry we couldn't find this page.
</p>
<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>
</div>
</div>
)
}

View File

@ -2,9 +2,10 @@ import '@/css/tailwind.css'
import '@/css/prism.css' import '@/css/prism.css'
import 'katex/dist/katex.css' import 'katex/dist/katex.css'
import '@fontsource/inter/variable-full.css' import '@fontsource/inter/variable.css'
import { ThemeProvider } from 'next-themes' import { ThemeProvider } from 'next-themes'
import type { AppProps } from 'next/app'
import Head from 'next/head' import Head from 'next/head'
import siteMetadata from '@/data/siteMetadata' import siteMetadata from '@/data/siteMetadata'
@ -15,7 +16,7 @@ import { ClientReload } from '@/components/ClientReload'
const isDevelopment = process.env.NODE_ENV === 'development' const isDevelopment = process.env.NODE_ENV === 'development'
const isSocket = process.env.SOCKET const isSocket = process.env.SOCKET
export default function App({ Component, pageProps }) { export default function App({ Component, pageProps }: AppProps) {
return ( return (
<ThemeProvider attribute="class" defaultTheme={siteMetadata.theme}> <ThemeProvider attribute="class" defaultTheme={siteMetadata.theme}>
<Head> <Head>

View File

@ -1,4 +1,5 @@
import Document, { Html, Head, Main, NextScript } from 'next/document' import Document, { Html, Head, Main, NextScript } from 'next/document'
class MyDocument extends Document { class MyDocument extends Document {
render() { render() {
return ( return (

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}
/>
)
}

27
pages/about.tsx Normal file
View File

@ -0,0 +1,27 @@
import { MDXLayoutRenderer } from '@/components/MDXComponents'
import { getFileBySlug } from '@/lib/mdx'
import { GetStaticProps, InferGetStaticPropsType } from 'next'
import { AuthorFrontMatter } from 'types/AuthorFrontMatter'
const DEFAULT_LAYOUT = 'AuthorLayout'
// @ts-ignore
export const getStaticProps: GetStaticProps<{
authorDetails: { mdxSource: string; frontMatter: AuthorFrontMatter }
}> = async () => {
const authorDetails = await getFileBySlug<AuthorFrontMatter>('authors', ['default'])
const { mdxSource, frontMatter } = authorDetails
return { props: { authorDetails: { mdxSource, frontMatter } } }
}
export default function About({ authorDetails }: InferGetStaticPropsType<typeof getStaticProps>) {
const { mdxSource, frontMatter } = authorDetails
return (
<MDXLayoutRenderer
layout={frontMatter.layout || DEFAULT_LAYOUT}
mdxSource={mdxSource}
frontMatter={frontMatter}
/>
)
}

View File

@ -1,5 +1,7 @@
import { NextApiRequest, NextApiResponse } from 'next'
// eslint-disable-next-line import/no-anonymous-default-export // eslint-disable-next-line import/no-anonymous-default-export
export default async (req, res) => { export default async (req: NextApiRequest, res: NextApiResponse) => {
const { email } = req.body const { email } = req.body
if (!email) { if (!email) {
return res.status(400).json({ error: 'Email is required' }) return res.status(400).json({ error: 'Email is required' })

View File

@ -1,5 +1,7 @@
import { NextApiRequest, NextApiResponse } from 'next'
/* eslint-disable import/no-anonymous-default-export */ /* eslint-disable import/no-anonymous-default-export */
export default async (req, res) => { export default async (req: NextApiRequest, res: NextApiResponse) => {
const { email } = req.body const { email } = req.body
if (!email) { if (!email) {

View File

@ -1,33 +0,0 @@
// eslint-disable-next-line import/no-anonymous-default-export
export default async (req, res) => {
const { email } = req.body
if (!email) {
return res.status(400).json({ error: 'Email is required' })
}
try {
const API_URL = process.env.EMAILOCTOPUS_API_URL
const API_KEY = process.env.EMAILOCTOPUS_API_KEY
const LIST_ID = process.env.EMAILOCTOPUS_LIST_ID
const data = { email_address: email, api_key: API_KEY }
const API_ROUTE = `${API_URL}lists/${LIST_ID}/contacts`
const response = await fetch(API_ROUTE, {
body: JSON.stringify(data),
headers: {
'Content-Type': 'application/json',
},
method: 'POST',
})
if (response.status >= 400) {
return res.status(500).json({ error: `There was an error subscribing to the list.` })
}
return res.status(201).json({ error: '' })
} catch (error) {
return res.status(500).json({ error: error.message || error.toString() })
}
}

View File

@ -1,5 +1,7 @@
import { NextApiRequest, NextApiResponse } from 'next'
/* eslint-disable import/no-anonymous-default-export */ /* eslint-disable import/no-anonymous-default-export */
export default async (req, res) => { export default async (req: NextApiRequest, res: NextApiResponse) => {
const { email } = req.body const { email } = req.body
if (!email) { if (!email) {
return res.status(400).json({ error: 'Email is required' }) return res.status(400).json({ error: 'Email is required' })

View File

@ -1,3 +1,4 @@
import { NextApiRequest, NextApiResponse } from 'next'
import mailchimp from '@mailchimp/mailchimp_marketing' import mailchimp from '@mailchimp/mailchimp_marketing'
mailchimp.setConfig({ mailchimp.setConfig({
@ -6,7 +7,7 @@ mailchimp.setConfig({
}) })
// eslint-disable-next-line import/no-anonymous-default-export // eslint-disable-next-line import/no-anonymous-default-export
export default async (req, res) => { export default async (req: NextApiRequest, res: NextApiResponse) => {
const { email } = req.body const { email } = req.body
if (!email) { if (!email) {
@ -14,7 +15,7 @@ export default async (req, res) => {
} }
try { try {
const test = await mailchimp.lists.addListMember(process.env.MAILCHIMP_AUDIENCE_ID, { await mailchimp.lists.addListMember(process.env.MAILCHIMP_AUDIENCE_ID, {
email_address: email, email_address: email,
status: 'subscribed', status: 'subscribed',
}) })

View File

@ -1,30 +0,0 @@
// eslint-disable-next-line import/no-anonymous-default-export
export default async (req, res) => {
const { email } = req.body
if (!email) {
return res.status(400).json({ error: 'Email is required' })
}
try {
const API_KEY = process.env.REVUE_API_KEY
const revueRoute = `${process.env.REVUE_API_URL}subscribers`
const response = await fetch(revueRoute, {
method: 'POST',
headers: {
Authorization: `Token ${API_KEY}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({ email, double_opt_in: false }),
})
if (response.status >= 400) {
return res.status(500).json({ error: `There was an error subscribing to the list.` })
}
return res.status(201).json({ error: '' })
} catch (error) {
return res.status(500).json({ error: error.message || error.toString() })
}
}

View File

@ -2,10 +2,16 @@ import { getAllFilesFrontMatter } from '@/lib/mdx'
import siteMetadata from '@/data/siteMetadata' import siteMetadata from '@/data/siteMetadata'
import ListLayout from '@/layouts/ListLayout' import ListLayout from '@/layouts/ListLayout'
import { PageSEO } from '@/components/SEO' import { PageSEO } from '@/components/SEO'
import { GetStaticProps, InferGetStaticPropsType } from 'next'
import { ComponentProps } from 'react'
export const POSTS_PER_PAGE = 5 export const POSTS_PER_PAGE = 5
export async function getStaticProps() { export const getStaticProps: GetStaticProps<{
posts: ComponentProps<typeof ListLayout>['posts']
initialDisplayPosts: ComponentProps<typeof ListLayout>['initialDisplayPosts']
pagination: ComponentProps<typeof ListLayout>['pagination']
}> = async () => {
const posts = await getAllFilesFrontMatter('blog') const posts = await getAllFilesFrontMatter('blog')
const initialDisplayPosts = posts.slice(0, POSTS_PER_PAGE) const initialDisplayPosts = posts.slice(0, POSTS_PER_PAGE)
const pagination = { const pagination = {
@ -16,7 +22,11 @@ export async function getStaticProps() {
return { props: { initialDisplayPosts, posts, pagination } } return { props: { initialDisplayPosts, posts, pagination } }
} }
export default function Blog({ posts, initialDisplayPosts, pagination }) { export default function Blog({
posts,
initialDisplayPosts,
pagination,
}: InferGetStaticPropsType<typeof getStaticProps>) {
return ( return (
<> <>
<PageSEO title={`Blog - ${siteMetadata.author}`} description={siteMetadata.description} /> <PageSEO title={`Blog - ${siteMetadata.author}`} description={siteMetadata.description} />

View File

@ -3,6 +3,10 @@ import PageTitle from '@/components/PageTitle'
import generateRss from '@/lib/generate-rss' import generateRss from '@/lib/generate-rss'
import { MDXLayoutRenderer } from '@/components/MDXComponents' import { MDXLayoutRenderer } from '@/components/MDXComponents'
import { formatSlug, getAllFilesFrontMatter, getFileBySlug, getFiles } from '@/lib/mdx' import { formatSlug, getAllFilesFrontMatter, getFileBySlug, getFiles } from '@/lib/mdx'
import { GetStaticProps, InferGetStaticPropsType } from 'next'
import { AuthorFrontMatter } from 'types/AuthorFrontMatter'
import { PostFrontMatter } from 'types/PostFrontMatter'
import { Toc } from 'types/Toc'
const DEFAULT_LAYOUT = 'PostLayout' const DEFAULT_LAYOUT = 'PostLayout'
@ -18,15 +22,23 @@ export async function getStaticPaths() {
} }
} }
export async function getStaticProps({ params }) { // @ts-ignore
export const getStaticProps: GetStaticProps<{
post: { mdxSource: string; toc: Toc; frontMatter: PostFrontMatter }
authorDetails: AuthorFrontMatter[]
prev?: { slug: string; title: string }
next?: { slug: string; title: string }
}> = async ({ params }) => {
const slug = (params.slug as string[]).join('/')
const allPosts = await getAllFilesFrontMatter('blog') const allPosts = await getAllFilesFrontMatter('blog')
const postIndex = allPosts.findIndex((post) => formatSlug(post.slug) === params.slug.join('/')) const postIndex = allPosts.findIndex((post) => formatSlug(post.slug) === slug)
const prev = allPosts[postIndex + 1] || null const prev: { slug: string; title: string } = allPosts[postIndex + 1] || null
const next = allPosts[postIndex - 1] || null const next: { slug: string; title: string } = allPosts[postIndex - 1] || null
const post = await getFileBySlug('blog', params.slug.join('/')) const post = await getFileBySlug<PostFrontMatter>('blog', slug)
// @ts-ignore
const authorList = post.frontMatter.authors || ['default'] const authorList = post.frontMatter.authors || ['default']
const authorPromise = authorList.map(async (author) => { const authorPromise = authorList.map(async (author) => {
const authorResults = await getFileBySlug('authors', [author]) const authorResults = await getFileBySlug<AuthorFrontMatter>('authors', [author])
return authorResults.frontMatter return authorResults.frontMatter
}) })
const authorDetails = await Promise.all(authorPromise) const authorDetails = await Promise.all(authorPromise)
@ -37,15 +49,27 @@ export async function getStaticProps({ params }) {
fs.writeFileSync('./public/feed.xml', rss) fs.writeFileSync('./public/feed.xml', rss)
} }
return { props: { post, authorDetails, prev, next } } return {
props: {
post,
authorDetails,
prev,
next,
},
}
} }
export default function Blog({ post, authorDetails, prev, next }) { export default function Blog({
post,
authorDetails,
prev,
next,
}: InferGetStaticPropsType<typeof getStaticProps>) {
const { mdxSource, toc, frontMatter } = post const { mdxSource, toc, frontMatter } = post
return ( return (
<> <>
{frontMatter.draft !== true ? ( {'draft' in frontMatter && frontMatter.draft !== true ? (
<MDXLayoutRenderer <MDXLayoutRenderer
layout={frontMatter.layout || DEFAULT_LAYOUT} layout={frontMatter.layout || DEFAULT_LAYOUT}
toc={toc} toc={toc}

View File

@ -3,8 +3,10 @@ import siteMetadata from '@/data/siteMetadata'
import { getAllFilesFrontMatter } from '@/lib/mdx' import { getAllFilesFrontMatter } from '@/lib/mdx'
import ListLayout from '@/layouts/ListLayout' import ListLayout from '@/layouts/ListLayout'
import { POSTS_PER_PAGE } from '../../blog' import { POSTS_PER_PAGE } from '../../blog'
import { GetStaticPaths, GetStaticProps, InferGetStaticPropsType } from 'next'
import { PostFrontMatter } from 'types/PostFrontMatter'
export async function getStaticPaths() { export const getStaticPaths: GetStaticPaths<{ page: string }> = async () => {
const totalPosts = await getAllFilesFrontMatter('blog') const totalPosts = await getAllFilesFrontMatter('blog')
const totalPages = Math.ceil(totalPosts.length / POSTS_PER_PAGE) const totalPages = Math.ceil(totalPosts.length / POSTS_PER_PAGE)
const paths = Array.from({ length: totalPages }, (_, i) => ({ const paths = Array.from({ length: totalPages }, (_, i) => ({
@ -17,12 +19,16 @@ export async function getStaticPaths() {
} }
} }
export async function getStaticProps(context) { export const getStaticProps: GetStaticProps<{
posts: PostFrontMatter[]
initialDisplayPosts: PostFrontMatter[]
pagination: { currentPage: number; totalPages: number }
}> = async (context) => {
const { const {
params: { page }, params: { page },
} = context } = context
const posts = await getAllFilesFrontMatter('blog') const posts = await getAllFilesFrontMatter('blog')
const pageNumber = parseInt(page) const pageNumber = parseInt(page as string)
const initialDisplayPosts = posts.slice( const initialDisplayPosts = posts.slice(
POSTS_PER_PAGE * (pageNumber - 1), POSTS_PER_PAGE * (pageNumber - 1),
POSTS_PER_PAGE * pageNumber POSTS_PER_PAGE * pageNumber
@ -41,7 +47,11 @@ export async function getStaticProps(context) {
} }
} }
export default function PostPage({ posts, initialDisplayPosts, pagination }) { export default function PostPage({
posts,
initialDisplayPosts,
pagination,
}: InferGetStaticPropsType<typeof getStaticProps>) {
return ( return (
<> <>
<PageSEO title={siteMetadata.title} description={siteMetadata.description} /> <PageSEO title={siteMetadata.title} description={siteMetadata.description} />

View File

@ -4,32 +4,33 @@ import Tag from '@/components/Tag'
import siteMetadata from '@/data/siteMetadata' import siteMetadata from '@/data/siteMetadata'
import { getAllFilesFrontMatter } from '@/lib/mdx' import { getAllFilesFrontMatter } from '@/lib/mdx'
import formatDate from '@/lib/utils/formatDate' import formatDate from '@/lib/utils/formatDate'
import { GetStaticProps, InferGetStaticPropsType } from 'next'
import { PostFrontMatter } from 'types/PostFrontMatter'
import NewsletterForm from '@/components/NewsletterForm' import NewsletterForm from '@/components/NewsletterForm'
const MAX_DISPLAY = 5 const MAX_DISPLAY = 5
export async function getStaticProps() { export const getStaticProps: GetStaticProps<{ posts: PostFrontMatter[] }> = async () => {
const posts = await getAllFilesFrontMatter('blog') const posts = await getAllFilesFrontMatter('blog')
return { props: { posts } } return { props: { posts } }
} }
export default function Home({ posts }) { export default function Home({ posts }: InferGetStaticPropsType<typeof getStaticProps>) {
return ( 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="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 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"> <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">
Hi.
</h1> </h1>
<p className="text-lg leading-7 text-gray-500 dark:text-gray-400"> <p className="text-lg leading-7 text-gray-500 dark:text-gray-400">
{siteMetadata.description} {siteMetadata.description}
</p> </p>
</div> </div>
<ul className="divide-y divide-gray-200 dark:divide-gray-700"> <ul className="divide-y divide-gray-200 dark:divide-gray-700">
{!posts.length && 'No posts found.'} {!posts.length && '没有找到文章。 😭'}
{posts.slice(0, MAX_DISPLAY).map((frontMatter) => { {posts.slice(0, MAX_DISPLAY).map((frontMatter) => {
const { slug, date, title, summary, tags } = frontMatter const { slug, date, title, summary, tags } = frontMatter
return ( return (

View File

@ -4,14 +4,15 @@ import Tag from '@/components/Tag'
import siteMetadata from '@/data/siteMetadata' import siteMetadata from '@/data/siteMetadata'
import { getAllTags } from '@/lib/tags' import { getAllTags } from '@/lib/tags'
import kebabCase from '@/lib/utils/kebabCase' import kebabCase from '@/lib/utils/kebabCase'
import { GetStaticProps, InferGetStaticPropsType } from 'next'
export async function getStaticProps() { export const getStaticProps: GetStaticProps<{ tags: Record<string, number> }> = async () => {
const tags = await getAllTags('blog') const tags = await getAllTags('blog')
return { props: { tags } } return { props: { tags } }
} }
export default function Tags({ tags }) { export default function Tags({ tags }: InferGetStaticPropsType<typeof getStaticProps>) {
const sortedTags = Object.keys(tags).sort((a, b) => tags[b] - tags[a]) const sortedTags = Object.keys(tags).sort((a, b) => tags[b] - tags[a])
return ( return (
<> <>

View File

@ -6,7 +6,9 @@ import { getAllFilesFrontMatter } from '@/lib/mdx'
import { getAllTags } from '@/lib/tags' import { getAllTags } from '@/lib/tags'
import kebabCase from '@/lib/utils/kebabCase' import kebabCase from '@/lib/utils/kebabCase'
import fs from 'fs' import fs from 'fs'
import { GetStaticProps, InferGetStaticPropsType } from 'next'
import path from 'path' import path from 'path'
import { PostFrontMatter } from 'types/PostFrontMatter'
const root = process.cwd() const root = process.cwd()
@ -23,30 +25,33 @@ export async function getStaticPaths() {
} }
} }
export async function getStaticProps({ params }) { export const getStaticProps: GetStaticProps<{ posts: PostFrontMatter[]; tag: string }> = async (
context
) => {
const tag = context.params.tag as string
const allPosts = await getAllFilesFrontMatter('blog') const allPosts = await getAllFilesFrontMatter('blog')
const filteredPosts = allPosts.filter( const filteredPosts = allPosts.filter(
(post) => post.draft !== true && post.tags.map((t) => kebabCase(t)).includes(params.tag) (post) => post.draft !== true && post.tags.map((t) => kebabCase(t)).includes(tag)
) )
// rss // rss
if (filteredPosts.length > 0) { if (filteredPosts.length > 0) {
const rss = generateRss(filteredPosts, `tags/${params.tag}/feed.xml`) const rss = generateRss(filteredPosts, `tags/${tag}/feed.xml`)
const rssPath = path.join(root, 'public', 'tags', params.tag) const rssPath = path.join(root, 'public', 'tags', tag)
fs.mkdirSync(rssPath, { recursive: true }) fs.mkdirSync(rssPath, { recursive: true })
fs.writeFileSync(path.join(rssPath, 'feed.xml'), rss) fs.writeFileSync(path.join(rssPath, 'feed.xml'), rss)
} }
return { props: { posts: filteredPosts, tag: params.tag } } return { props: { posts: filteredPosts, tag } }
} }
export default function Tag({ posts, tag }) { export default function Tag({ posts, tag }: InferGetStaticPropsType<typeof getStaticProps>) {
// Capitalize first letter and convert space to dash // Capitalize first letter and convert space to dash
const title = tag[0].toUpperCase() + tag.split(' ').join('-').slice(1) const title = tag[0].toUpperCase() + tag.split(' ').join('-').slice(1)
return ( return (
<> <>
<TagSEO <TagSEO
title={`${tag} - ${siteMetadata.author}`} title={`${tag} - ${siteMetadata.title}`}
description={`${tag} tags - ${siteMetadata.author}`} description={`${tag} tags - ${siteMetadata.author}`}
/> />
<ListLayout posts={posts} title={title} /> <ListLayout posts={posts} title={title} />

View File

@ -43,7 +43,6 @@ const siteMetadata = require('../data/siteMetadata')
.replace('.md', '') .replace('.md', '')
.replace('/feed.xml', '') .replace('/feed.xml', '')
const route = path === '/index' ? '' : path const route = path === '/index' ? '' : path
if (page.search('pages/404.') > -1 || page.search(`pages/blog/[...slug].`) > -1) { if (page.search('pages/404.') > -1 || page.search(`pages/blog/[...slug].`) > -1) {
return return
} }

View File

@ -1,15 +1,18 @@
// @ts-check
const defaultTheme = require('tailwindcss/defaultTheme') const defaultTheme = require('tailwindcss/defaultTheme')
const colors = require('tailwindcss/colors') const colors = require('tailwindcss/colors')
/** @type {import("tailwindcss/tailwind-config").TailwindConfig } */
module.exports = { module.exports = {
experimental: { experimental: {
optimizeUniversalDefaults: true, optimizeUniversalDefaults: true,
}, },
content: [ content: [
'./pages/**/*.js', './pages/**/*.tsx',
'./components/**/*.js', './components/**/*.tsx',
'./layouts/**/*.js', './layouts/**/*.tsx',
'./lib/**/*.js', './lib/**/*.ts',
'./data/**/*.mdx', './data/**/*.mdx',
], ],
darkMode: 'class', darkMode: 'class',
@ -29,7 +32,8 @@ module.exports = {
}, },
colors: { colors: {
primary: colors.teal, primary: colors.teal,
gray: colors.neutral, //@ts-ignore
gray: colors.neutral, // TODO: Remove ts-ignore after tw types gets updated to v3
}, },
typography: (theme) => ({ typography: (theme) => ({
DEFAULT: { DEFAULT: {

28
tsconfig.json Normal file
View File

@ -0,0 +1,28 @@
{
"compilerOptions": {
"incremental": true,
"target": "ES6",
"lib": ["dom", "dom.iterable", "esnext"],
"allowJs": true,
"skipLibCheck": true,
"strict": false,
"forceConsistentCasingInFileNames": true,
"noEmit": true,
"esModuleInterop": true,
"module": "esnext",
"moduleResolution": "node",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "preserve",
"baseUrl": ".",
"paths": {
"@/components/*": ["components/*"],
"@/data/*": ["data/*"],
"@/layouts/*": ["layouts/*"],
"@/lib/*": ["lib/*"],
"@/css/*": ["css/*"]
}
},
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx"],
"exclude": ["node_modules"]
}

View File

@ -0,0 +1,11 @@
export type AuthorFrontMatter = {
layout?: string
name: string
avatar: string
occupation: string
company: string
email: string
twitter: string
linkedin: string
github: string
}

14
types/PostFrontMatter.ts Normal file
View File

@ -0,0 +1,14 @@
export type PostFrontMatter = {
title: string
date: string
tags: string[]
lastmod?: string
draft?: boolean
summary?: string
images?: string[]
authors?: string[]
layout?: string
canonicalUrl?: string
slug: string
fileName: string
}

5
types/Toc.ts Normal file
View File

@ -0,0 +1,5 @@
export type Toc = {
value: string
depth: number
url: string
}[]