refactor: 改用 TypeScript。close #1.

This commit is contained in:
Ivan Li 2022-10-07 13:55:39 +08:00
parent 11b9017a07
commit 1dfd5e5271
84 changed files with 645 additions and 462 deletions

View File

@ -1,8 +1,13 @@
![tailwind-nextjs-banner](/public/static/images/twitter-card.png)
# Ivan Li's Blog
# Tailwind Nextjs Starter Blog
[![Build Status](https://ci.ivanli.cc/api/badges/Ivan/tailwind-nextjs-blog/status.svg)](https://ci.ivanli.cc/Ivan/tailwind-nextjs-blog)
[![GitHub Repo stars](https://img.shields.io/github/stars/timlrx/tailwind-nextjs-starter-blog?style=social)](https://GitHub.com/timlrx/tailwind-nextjs-starter-blog/stargazers/)
[![GitHub forks](https://img.shields.io/github/forks/timlrx/tailwind-nextjs-starter-blog?style=social)](https://GitHub.com/timlrx/tailwind-nextjs-starter-blog/network/)
[![Twitter URL](https://img.shields.io/twitter/url?style=social&url=https%3A%2F%2Ftwitter.com%2Ftimlrxx)](https://twitter.com/timlrxx)
[![Sponsor](https://img.shields.io/static/v1?label=Sponsor&message=%E2%9D%A4&logo=GitHub&link=https://github.com/sponsors/timlrx)](https://github.com/sponsors/timlrx)
[![Deploy with Vercel](https://vercel.com/button)](https://vercel.com/new/git/external?repository-url=https://github.com/timlrx/tailwind-nextjs-starter-blog)
This is a [Next.js](https://nextjs.org/), [Tailwind CSS](https://tailwindcss.com/) blogging starter template. Probably the most feature-rich Next.js markdown blogging template out there. Comes out of the box configured with the latest technologies to make technical writing a breeze. Easily configurable and customizable. Perfect as a replacement to existing Jekyll and Hugo individual blogs.
@ -26,32 +31,13 @@ Feature request? Check the past discussions to see if it has been brought up pre
- [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.
@ -81,7 +67,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, convertkit and klaviyo
- Supports [giscus](https://github.com/laymonage/giscus), [utterances](https://github.com/utterance/utterances) or disqus
- Projects page
- Preconfigured security headers
@ -98,27 +84,18 @@ 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)
1. JS (official support)
```bash
npx degit https://github.com/timlrx/tailwind-nextjs-starter-blog.git
```
or with TypeScript (community support)
```bash
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.
@ -188,7 +165,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)
@ -242,4 +219,4 @@ Using the template? Support this effort by giving a star on GitHub, sharing your
## Licence
[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.timrlx.com)

View File

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

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>

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 MobileNav from './MobileNav'
import ThemeSwitch from './ThemeSwitch'
import { ReactNode } from 'react'
const LayoutWrapper = ({ children }) => {
interface Props {
children: ReactNode
}
const LayoutWrapper = ({ children }: Props) => {
return (
<SectionContainer>
<div className="flex h-screen flex-col justify-between">

View File

@ -1,7 +1,11 @@
/* 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('#')

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"
className="text-gray-900 dark:text-gray-100"
>
<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"
/>
{navShow ? (
<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"
/>
) : (
<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>
</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 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'
}`}
>
<div className="flex justify-end">
<button
type="button"
className="mr-5 mt-11 h-8 w-8 rounded"
aria-label="Toggle Menu"
onClick={onToggleNav}
>
<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>
<button
type="button"
aria-label="toggle modal"
className="fixed h-full w-full cursor-auto focus:outline-none"
onClick={onToggleNav}
></button>
<nav className="fixed mt-8 h-full">
{headerNavLinks.map((link) => (
<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'
const NewsletterForm = ({ title = 'Subscribe to the newsletter' }) => {
const inputEl = useRef(null)
const inputEl = useRef<HTMLInputElement>(null)
const [error, setError] = useState(false)
const [message, setMessage] = useState('')
const [subscribed, setSubscribed] = useState(false)
const subscribe = async (e) => {
const subscribe = async (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault()
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 (
<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,33 +1,38 @@
import Link from '@/components/Link'
export default function Pagination({ totalPages, currentPage }) {
const prevPage = parseInt(currentPage) - 1 > 0
const nextPage = parseInt(currentPage) + 1 <= parseInt(totalPages)
interface Props {
totalPages: number
currentPage: number
}
export default function Pagination({ totalPages, currentPage }: Props) {
const prevPage = currentPage - 1 > 0
const nextPage = currentPage + 1 <= 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}>
<button 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>
<button>Previous</button>
</Link>
)}
<span>
{currentPage} of {totalPages}
</span>
{!nextPage && (
<button rel="next" className="cursor-auto disabled:opacity-50" disabled={!nextPage}>
<button className="cursor-auto disabled:opacity-50" disabled={!nextPage}>
Next
</button>
)}
{nextPage && (
<Link href={`/blog/page/${currentPage + 1}`}>
<button rel="next">Next</button>
<button>Next</button>
</Link>
)}
</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 [hovered, setHovered] = useState(false)
const [copied, setCopied] = useState(false)
@ -63,7 +67,7 @@ const Pre = (props) => {
</button>
)}
<pre>{props.children}</pre>
<pre>{children}</pre>
</div>
)
}

View File

@ -1,8 +1,31 @@
import Head from 'next/head'
import { useRouter } from 'next/router'
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()
return (
<Head>
@ -14,7 +37,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 +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 twImageUrl = siteMetadata.siteUrl + siteMetadata.socialBanner
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 twImageUrl = siteMetadata.siteUrl + siteMetadata.socialBanner
const router = useRouter()
@ -71,6 +99,11 @@ export const TagSEO = ({ title, description }) => {
)
}
interface BlogSeoProps extends PostFrontMatter {
authorDetails?: AuthorFrontMatter[]
url: string
}
export const BlogSEO = ({
authorDetails,
title,
@ -80,11 +113,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'
@ -94,7 +126,7 @@ export const BlogSEO = ({
const featuredImages = imagesArr.map((img) => {
return {
'@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'
const ScrollTopAndComment = () => {
@ -15,7 +14,7 @@ const ScrollTopAndComment = () => {
}, [])
const handleScrollTop = () => {
window.scrollTo({ top: 0 })
window.scrollTo({ top: 0, behavior: 'smooth' })
}
const handleScrollToComment = () => {
document.getElementById('comment').scrollIntoView()
@ -24,22 +23,20 @@ const ScrollTopAndComment = () => {
<div
className={`fixed right-8 bottom-8 hidden flex-col gap-3 ${show ? 'md:flex' : 'md:hidden'}`}
>
{siteMetadata.comment.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"
>
<svg className="h-5 w-5" viewBox="0 0 20 20" fill="currentColor">
<path
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"
clipRule="evenodd"
/>
</svg>
</button>
)}
<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"
>
<svg className="h-5 w-5" viewBox="0 0 20 20" fill="currentColor">
<path
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"
clipRule="evenodd"
/>
</svg>
</button>
<button
aria-label="Scroll To Top"
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 @@
/**
* @typedef TocHeading
* @prop {string} value
* @prop {number} depth
* @prop {string} url
*/
import { Toc } from 'types/Toc'
interface TOCInlineProps {
toc: Toc
indentDepth?: number
fromHeading?: number
toHeading?: number
asDisclosure?: boolean
exclude?: string | string[]
}
/**
* 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
* @param {TOCInlineProps} {
* toc,
* indentDepth = 3,
* fromHeading = 1,
* toHeading = 6,
* asDisclosure = false,
* exclude = '',
* }
*
*/
const TOCInline = ({
@ -27,7 +31,7 @@ const TOCInline = ({
toHeading = 6,
asDisclosure = false,
exclude = '',
}) => {
}: TOCInlineProps) => {
const re = Array.isArray(exclude)
? new RegExp('^(' + exclude.join('|') + ')$', 'i')
: new RegExp('^(' + exclude + ')$', 'i')

View File

@ -1,7 +1,11 @@
import Link from 'next/link'
import kebabCase from '@/lib/utils/kebabCase'
const Tag = ({ text }) => {
interface Props {
text: string
}
const Tag = ({ text }: Props) => {
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">

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 SimpleAnalytics from './SimpleAnalytics'
import Umami from './Umami'
import Posthog from './Posthog'
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 Analytics = () => {
@ -14,7 +21,6 @@ const Analytics = () => {
{isProduction && siteMetadata.analytics.simpleAnalytics && <SimpleAnalytics />}
{isProduction && siteMetadata.analytics.umamiWebsiteId && <Umami />}
{isProduction && siteMetadata.analytics.googleAnalyticsId && <GA />}
{isProduction && siteMetadata.analytics.posthogAnalyticsId && <Posthog />}
</>
)
}

View File

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

View File

@ -3,7 +3,11 @@ import { useTheme } from 'next-themes'
import siteMetadata from '@/data/siteMetadata'
const Giscus = () => {
interface Props {
mapping: string
}
const Giscus = ({ mapping }: Props) => {
const [enableLoadComments, setEnabledLoadComments] = useState(true)
const { theme, resolvedTheme } = useTheme()
const commentsTheme =
@ -17,30 +21,15 @@ const Giscus = () => {
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-repo', siteMetadata.comment.giscusConfig.repo)
script.setAttribute('data-repo-id', siteMetadata.comment.giscusConfig.repositoryId)
script.setAttribute('data-category', siteMetadata.comment.giscusConfig.category)
script.setAttribute('data-category-id', siteMetadata.comment.giscusConfig.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-reactions-enabled', siteMetadata.comment.giscusConfig.reactions)
script.setAttribute('data-emit-metadata', siteMetadata.comment.giscusConfig.metadata)
script.setAttribute('data-theme', commentsTheme)
script.setAttribute('crossorigin', 'anonymous')
script.async = true
@ -52,7 +41,7 @@ const Giscus = () => {
const comments = document.getElementById(COMMENTS_ID)
if (comments) comments.innerHTML = ''
}
}, [commentsTheme])
}, [commentsTheme, mapping])
// Reload on theme change
useEffect(() => {

View File

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

View File

@ -1,5 +1,10 @@
import siteMetadata from '@/data/siteMetadata'
import dynamic from 'next/dynamic'
import { PostFrontMatter } from 'types/PostFrontMatter'
interface Props {
frontMatter: PostFrontMatter
}
const UtterancesComponent = dynamic(
() => {
@ -20,14 +25,29 @@ const DisqusComponent = dynamic(
{ ssr: false }
)
const Comments = ({ frontMatter }) => {
const comment = siteMetadata?.comment
if (!comment || Object.keys(comment).length === 0) return <></>
const Comments = ({ frontMatter }: Props) => {
let term
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 (
<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' && (
<UtterancesComponent />
<UtterancesComponent issueTerm={term} />
)}
{siteMetadata.comment && siteMetadata.comment.provider === 'disqus' && (
<DisqusComponent frontMatter={frontMatter} />

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 {

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

10
data/projectsData.ts Normal file
View File

@ -0,0 +1,10 @@
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',
},
]
export default projectsData

View File

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

View File

@ -33,14 +33,14 @@ const siteMetadata = {
newsletter: {
// supports mailchimp, buttondown, convertkit, klaviyo, revue, emailoctopus
// Please add your .env file and modify it according to your selection
provider: 'buttondown',
provider: '',
},
comment: {
// 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.
// Select a provider and use the environment variables associated to it
// https://vercel.com/docs/environment-variables
provider: 'giscus', // supported providers: giscus, utterances, disqus
provider: '', // supported providers: giscus, utterances, disqus
giscusConfig: {
// Visit the link below, and follow the steps in the 'configuration' section
// https://giscus.app/
@ -55,10 +55,6 @@ 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`

View File

@ -1,21 +1,28 @@
import SocialIcon from '@/components/social-icons'
import Image from '@/components/Image'
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
return (
<>
<PageSEO title={`About - ${name}`} description={`About me - ${name}`} />
<div className="divide-y divide-gray-200 dark:divide-gray-700">
<div className="divide-y">
<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">
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">
<div className="flex flex-col items-center space-x-2 pt-8">
<Image
src={avatar}
alt="avatar"

View File

@ -1,11 +1,17 @@
import Link from '@/components/Link'
import Tag from '@/components/Tag'
import siteMetadata from '@/data/siteMetadata'
import { useState } from 'react'
import { ComponentProps, useState } from 'react'
import Pagination from '@/components/Pagination'
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 filteredBlogPosts = posts.filter((frontMatter) => {
const searchContent = frontMatter.title + frontMatter.summary + frontMatter.tags.join(' ')
@ -18,7 +24,7 @@ export default function ListLayout({ posts, title, initialDisplayPosts = [], pag
return (
<>
<div className="divide-y divide-gray-200 dark:divide-gray-700">
<div className="divide-y">
<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}

View File

@ -7,7 +7,9 @@ import Tag from '@/components/Tag'
import siteMetadata from '@/data/siteMetadata'
import Comments from '@/components/comments'
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 discussUrl = (slug) =>
@ -30,9 +32,22 @@ const Copyright = () => (
</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 headerStyles = useMemo(
@ -123,9 +138,8 @@ export default function PostLayout({ frontMatter, authorDetails, next, prev, chi
</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="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 />
{``}
<Link href={editUrl(fileName)}>{'View source'}</Link>
</div>
<Comments frontMatter={frontMatter} />

View File

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

View File

@ -1,8 +1,9 @@
import { escape } from '@/lib/utils/htmlEscaper'
import siteMetadata from '@/data/siteMetadata'
import { PostFrontMatter } from 'types/PostFrontMatter'
const generateRssItem = (post) => `
const generateRssItem = (post: PostFrontMatter) => `
<item>
<guid>${siteMetadata.siteUrl}/blog/${post.slug}</guid>
<title>${escape(post.title)}</title>
@ -14,7 +15,7 @@ const generateRssItem = (post) => `
</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">
<channel>
<title>${escape(siteMetadata.title)}</title>

View File

@ -3,8 +3,10 @@ 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'
import { PostFrontMatter } from 'types/PostFrontMatter'
import { AuthorFrontMatter } from 'types/AuthorFrontMatter'
import { Toc } from 'types/Toc'
// Remark packages
import remarkGfm from 'remark-gfm'
import remarkFootnotes from 'remark-footnotes'
@ -23,24 +25,24 @@ import rehypePresetMinify from 'rehype-preset-minify'
const root = process.cwd()
export function getFiles(type) {
export function getFiles(type: 'blog' | 'authors') {
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) {
export function formatSlug(slug: string) {
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
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 mdPath = path.join(root, 'data', type, `${slug}.md`)
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')
}
let toc = []
const toc: Toc = []
const { code, frontmatter } = await bundleMDX({
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 files = getAllFilesRecursively(prefixPaths)
const allFrontMatter = []
const allFrontMatter: PostFrontMatter[] = []
files.forEach((file) => {
files.forEach((file: string) => {
// Replace is needed to work on Windows
const fileName = file.slice(prefixPaths.length + 1).replace(/\\/g, '/')
// Remove Unexpected File
@ -122,8 +124,9 @@ export async function getAllFilesFrontMatter(folder) {
return
}
const source = fs.readFileSync(file, 'utf8')
const { data: frontmatter } = matter(source)
if (frontmatter.draft !== true) {
const matterFile = matter(source)
const frontmatter = matterFile.data as AuthorFrontMatter | PostFrontMatter
if ('draft' in frontmatter && frontmatter.draft !== true) {
allFrontMatter.push({
...frontmatter,
slug: formatSlug(fileName),

View File

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

View File

@ -1,9 +1,12 @@
import { Parent } from 'unist'
import { VFile } from 'vfile'
import { visit } from 'unist-util-visit'
import { load } from 'js-yaml'
export default function extractFrontmatter() {
return (tree, file) => {
visit(tree, 'yaml', (node, index, parent) => {
return (tree: Parent, file: VFile) => {
visit(tree, 'yaml', (node: Parent) => {
//@ts-ignore
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 sizeOf from 'image-size'
import fs from 'fs'
type ImageNode = Parent & {
url: string
alt: string
name: string
attributes: (Literal & { name: string })[]
}
export default function remarkImgToJsx() {
return (tree) => {
return (tree: Node) => {
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')
(node: Parent): node is Parent =>
node.type === 'paragraph' && node.children.some((n) => n.type === 'image'),
(node: Parent) => {
const imageNode = node.children.find((n) => n.type === 'image') as ImageNode
// only local files
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 { slug } from 'github-slugger'
import { toString } from 'mdast-util-to-string'
export default function remarkTocHeadings(options) {
return (tree) =>
visit(tree, 'heading', (node, index, parent) => {
return (tree: Parent) =>
visit(tree, 'heading', (node) => {
const textContent = toString(node)
options.exportRef.push({
value: textContent,

View File

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

View File

@ -11,13 +11,13 @@ const flattenArray = (input) =>
const map = (fn) => (input) => input.map(fn)
const walkDir = (fullPath) => {
const walkDir = (fullPath: string) => {
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)
export default getAllFilesRecursively

View File

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

View File

@ -1,7 +1,6 @@
const { replace } = ''
// escape
const es = /&(?:amp|#38|lt|#60|gt|#62|apos|#39|quot|#34);/g
const ca = /[&<>'"]/g
const esca = {
@ -11,7 +10,7 @@ const esca = {
"'": '&#39;',
'"': '&quot;',
}
const pe = (m) => esca[m]
const pe = (m: keyof typeof esca) => esca[m]
/**
* 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,
* 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'
const kebabCase = (str) => slug(str)
const kebabCase = (str: string) => slug(str)
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({
reactStrictMode: true,
pageExtensions: ['js', 'jsx', 'md', 'mdx'],
pageExtensions: ['ts', 'tsx', 'js', 'jsx', 'md', 'mdx'],
eslint: {
dirs: ['pages', 'components', 'lib', 'layouts', 'scripts'],
},
@ -70,6 +73,19 @@ module.exports = withBundleAnalyzer({
]
},
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({
test: /\.svg$/,
use: ['@svgr/webpack'],

BIN
package-lock.json generated

Binary file not shown.

View File

@ -12,7 +12,6 @@
"prepare": "husky install"
},
"dependencies": {
"@next/bundle-analyzer": "12.1.4",
"@fontsource/inter": "4.5.2",
"@mailchimp/mailchimp_marketing": "^3.0.58",
"@tailwindcss/forms": "^0.4.0",
@ -23,7 +22,7 @@
"gray-matter": "^4.0.2",
"image-size": "1.0.0",
"mdx-bundler": "^8.0.0",
"next": "12.1.4",
"next": "12.0.9",
"next-themes": "^0.0.14",
"postcss": "^8.4.5",
"preact": "^10.6.2",
@ -31,7 +30,7 @@
"react-dom": "17.0.2",
"reading-time": "1.3.0",
"rehype-autolink-headings": "^6.1.0",
"rehype-citation": "^0.4.0",
"rehype-citation": "^0.2.0",
"rehype-katex": "^6.0.2",
"rehype-preset-minify": "6.0.0",
"rehype-prism-plus": "^1.1.3",
@ -40,15 +39,21 @@
"remark-gfm": "^3.0.1",
"remark-math": "^5.1.1",
"sharp": "^0.28.3",
"tailwindcss": "^3.0.23",
"smoothscroll-polyfill": "^0.4.4",
"tailwindcss": "^3.0.18",
"unist-util-visit": "^4.0.0"
},
"devDependencies": {
"@next/bundle-analyzer": "12.0.9",
"@svgr/webpack": "^6.1.2",
"@types/react": "^17.0.14",
"@types/tailwindcss": "^2.2.0",
"@typescript-eslint/eslint-plugin": "^4.28.1",
"@typescript-eslint/parser": "^4.28.1",
"cross-env": "^7.0.3",
"dedent": "^0.7.0",
"eslint": "^7.29.0",
"eslint-config-next": "12.1.4",
"eslint-config-next": "12.0.9",
"eslint-config-prettier": "^8.3.0",
"eslint-plugin-prettier": "^3.3.1",
"file-loader": "^6.0.0",
@ -60,7 +65,8 @@
"prettier": "^2.5.1",
"prettier-plugin-tailwindcss": "^0.1.4",
"socket.io": "^4.4.0",
"socket.io-client": "^4.4.0"
"socket.io-client": "^4.4.0",
"typescript": "^4.6.1-rc"
},
"lint-staged": {
"*.+(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

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

View File

@ -1,4 +1,5 @@
import Document, { Html, Head, Main, NextScript } from 'next/document'
class MyDocument extends Document {
render() {
return (
@ -20,8 +21,7 @@ class MyDocument extends Document {
<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" />
<meta name="theme-color" content="#000000" />
<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">

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
export default async (req, res) => {
export default async (req: NextApiRequest, res: NextApiResponse) => {
const { email } = req.body
if (!email) {
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 */
export default async (req, res) => {
export default async (req: NextApiRequest, res: NextApiResponse) => {
const { email } = req.body
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 */
export default async (req, res) => {
export default async (req: NextApiRequest, res: NextApiResponse) => {
const { email } = req.body
if (!email) {
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'
mailchimp.setConfig({
@ -6,7 +7,7 @@ mailchimp.setConfig({
})
// 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
if (!email) {
@ -14,7 +15,7 @@ export default async (req, res) => {
}
try {
const test = await mailchimp.lists.addListMember(process.env.MAILCHIMP_AUDIENCE_ID, {
await mailchimp.lists.addListMember(process.env.MAILCHIMP_AUDIENCE_ID, {
email_address: email,
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 ListLayout from '@/layouts/ListLayout'
import { PageSEO } from '@/components/SEO'
import { GetStaticProps, InferGetStaticPropsType } from 'next'
import { ComponentProps } from 'react'
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 initialDisplayPosts = posts.slice(0, POSTS_PER_PAGE)
const pagination = {
@ -16,7 +22,11 @@ export async function getStaticProps() {
return { props: { initialDisplayPosts, posts, pagination } }
}
export default function Blog({ posts, initialDisplayPosts, pagination }) {
export default function Blog({
posts,
initialDisplayPosts,
pagination,
}: InferGetStaticPropsType<typeof getStaticProps>) {
return (
<>
<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 { MDXLayoutRenderer } from '@/components/MDXComponents'
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'
@ -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 postIndex = allPosts.findIndex((post) => formatSlug(post.slug) === params.slug.join('/'))
const prev = allPosts[postIndex + 1] || null
const next = allPosts[postIndex - 1] || null
const post = await getFileBySlug('blog', params.slug.join('/'))
const postIndex = allPosts.findIndex((post) => formatSlug(post.slug) === slug)
const prev: { slug: string; title: string } = allPosts[postIndex + 1] || null
const next: { slug: string; title: string } = allPosts[postIndex - 1] || null
const post = await getFileBySlug<PostFrontMatter>('blog', slug)
// @ts-ignore
const authorList = post.frontMatter.authors || ['default']
const authorPromise = authorList.map(async (author) => {
const authorResults = await getFileBySlug('authors', [author])
const authorResults = await getFileBySlug<AuthorFrontMatter>('authors', [author])
return authorResults.frontMatter
})
const authorDetails = await Promise.all(authorPromise)
@ -37,15 +49,27 @@ export async function getStaticProps({ params }) {
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
return (
<>
{frontMatter.draft !== true ? (
{'draft' in frontMatter && frontMatter.draft !== true ? (
<MDXLayoutRenderer
layout={frontMatter.layout || DEFAULT_LAYOUT}
toc={toc}

View File

@ -3,8 +3,10 @@ import siteMetadata from '@/data/siteMetadata'
import { getAllFilesFrontMatter } from '@/lib/mdx'
import ListLayout from '@/layouts/ListLayout'
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 totalPages = Math.ceil(totalPosts.length / POSTS_PER_PAGE)
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 {
params: { page },
} = context
const posts = await getAllFilesFrontMatter('blog')
const pageNumber = parseInt(page)
const pageNumber = parseInt(page as string)
const initialDisplayPosts = posts.slice(
POSTS_PER_PAGE * (pageNumber - 1),
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 (
<>
<PageSEO title={siteMetadata.title} description={siteMetadata.description} />

View File

@ -4,25 +4,26 @@ import Tag from '@/components/Tag'
import siteMetadata from '@/data/siteMetadata'
import { getAllFilesFrontMatter } from '@/lib/mdx'
import formatDate from '@/lib/utils/formatDate'
import { GetStaticProps, InferGetStaticPropsType } from 'next'
import { PostFrontMatter } from 'types/PostFrontMatter'
import NewsletterForm from '@/components/NewsletterForm'
const MAX_DISPLAY = 5
export async function getStaticProps() {
export const getStaticProps: GetStaticProps<{ posts: PostFrontMatter[] }> = async () => {
const posts = await getAllFilesFrontMatter('blog')
return { props: { posts } }
}
export default function Home({ posts }) {
export default function Home({ posts }: InferGetStaticPropsType<typeof getStaticProps>) {
return (
<>
<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">
<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.
Latest
</h1>
<p className="text-lg leading-7 text-gray-500 dark:text-gray-400">
{siteMetadata.description}

View File

@ -13,7 +13,7 @@ export default function Projects() {
Projects
</h1>
<p className="text-lg leading-7 text-gray-500 dark:text-gray-400">
Showcase your projects with a hero image (16 x 9)
</p>
</div>
<div className="container py-12">

View File

@ -4,14 +4,15 @@ import Tag from '@/components/Tag'
import siteMetadata from '@/data/siteMetadata'
import { getAllTags } from '@/lib/tags'
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')
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])
return (
<>

View File

@ -6,7 +6,9 @@ import { getAllFilesFrontMatter } from '@/lib/mdx'
import { getAllTags } from '@/lib/tags'
import kebabCase from '@/lib/utils/kebabCase'
import fs from 'fs'
import { GetStaticProps, InferGetStaticPropsType } from 'next'
import path from 'path'
import { PostFrontMatter } from 'types/PostFrontMatter'
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 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
if (filteredPosts.length > 0) {
const rss = generateRss(filteredPosts, `tags/${params.tag}/feed.xml`)
const rssPath = path.join(root, 'public', 'tags', params.tag)
const rss = generateRss(filteredPosts, `tags/${tag}/feed.xml`)
const rssPath = path.join(root, 'public', 'tags', tag)
fs.mkdirSync(rssPath, { recursive: true })
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
const title = tag[0].toUpperCase() + tag.split(' ').join('-').slice(1)
return (
<>
<TagSEO
title={`${tag} - ${siteMetadata.author}`}
title={`${tag} - ${siteMetadata.title}`}
description={`${tag} tags - ${siteMetadata.author}`}
/>
<ListLayout posts={posts} title={title} />

View File

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

View File

@ -1,6 +1,6 @@
#!/usr/bin/env node
// Adapted from https://github.com/hashicorp/next-remote-watch
// Adapated from https://github.com/hashicorp/next-remote-watch
// A copy of next-remote-watch with an additional ws reload emitter.
// The app listens to the event and triggers a client-side router refresh
// see components/ClientReload.js

View File

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